From f946d9c1f051adec2b81cfb4bb950e425f2da174 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Apr 2026 17:28:55 +0800 Subject: [PATCH] feat: add PSD text color and position editing --- .../agents/AgentPsdTemplateEditorDialog.tsx | 1269 +++++++++++------ lib/agents/psd-render-service.ts | 42 +- lib/agents/psd-render-task-contract.ts | 8 + lib/agents/psd-template-renderer.ts | 185 ++- 4 files changed, 1051 insertions(+), 453 deletions(-) diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index c22b4aabf..949efdd67 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -88,7 +88,10 @@ import { import { requestLongTaskResult } from "@/lib/client/long-task"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { buildImageProxyUrl } from "@/lib/image-delivery"; -import { getFriendlyRateLimitMessage, getUserFacingRequestErrorMessage } from "@/lib/request-error"; +import { + getFriendlyRateLimitMessage, + getUserFacingRequestErrorMessage, +} from "@/lib/request-error"; function buildTemplateListRequestUrl(params?: { brandName?: string | null; @@ -105,7 +108,9 @@ function buildTemplateListRequestUrl(params?: { } const queryString = searchParams.toString(); - return queryString ? `/api/psd-templates?${queryString}` : "/api/psd-templates"; + return queryString + ? `/api/psd-templates?${queryString}` + : "/api/psd-templates"; } const fetcher = async (url: string): Promise => { @@ -168,7 +173,12 @@ const templateDetailFetcher = async ( .json() .catch(() => null)) as Partial | null; - if (!response.ok || !payload || payload.success !== true || !payload.template) { + if ( + !response.ok || + !payload || + payload.success !== true || + !payload.template + ) { return { success: false, error: @@ -245,10 +255,49 @@ const PREVIEW_STAGE_TARGET_WIDTH = 760; const PREVIEW_STAGE_TARGET_HEIGHT = 760; const MIN_IMAGE_SCALE = 0.4; const MAX_IMAGE_SCALE = 3; +const MIN_TEXT_POSITION_OFFSET = -5000; +const MAX_TEXT_POSITION_OFFSET = 5000; const UNASSIGNED_BRAND_LABEL = "未分类"; const IMAGE_EXTENSION_REGEX = /\.([a-z0-9]{2,5})$/i; const HIDDEN_LAYER_SCROLL_STEP = 180; +type AgentPsdTextPositionOffset = { + offsetX: number; + offsetY: number; +}; + +function clampTextPositionOffsetValue(value: number) { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max( + MIN_TEXT_POSITION_OFFSET, + Math.min(MAX_TEXT_POSITION_OFFSET, value) + ); +} + +function normalizeTextPositionOffset( + value?: Partial | null +): AgentPsdTextPositionOffset { + return { + offsetX: Number( + clampTextPositionOffsetValue(Number(value?.offsetX || 0)).toFixed(2) + ), + offsetY: Number( + clampTextPositionOffsetValue(Number(value?.offsetY || 0)).toFixed(2) + ), + }; +} + +function hasTextPositionOffset( + value?: Partial | null +) { + const normalized = normalizeTextPositionOffset(value); + return ( + Math.abs(normalized.offsetX) > 0.01 || Math.abs(normalized.offsetY) > 0.01 + ); +} + function buildTemplatePreviewThumbUrl( previewUrl: string, params?: { @@ -334,7 +383,13 @@ function TemplatePreviewImage({ quality, preferRenderBase, }), - [preferRenderBase, quality, template.previewUrl, template.renderBaseUrl, width] + [ + preferRenderBase, + quality, + template.previewUrl, + template.renderBaseUrl, + width, + ] ); const fallbackUrlKey = fallbackUrls.join("\n"); const [activeUrlIndex, setActiveUrlIndex] = useState(0); @@ -346,11 +401,7 @@ function TemplatePreviewImage({ const src = fallbackUrls[activeUrlIndex] || ""; if (!src) { return ( -
+
); } const intrinsicSize = Math.max(1, Math.round(width || 1)); @@ -725,9 +776,15 @@ function createInitialState( const imageAssignments: Record = {}; const imagePlacements: Record = {}; const textAssignments: Record = {}; + const textPositionOffsets: Record = {}; if (!template) { - return { imageAssignments, imagePlacements, textAssignments }; + return { + imageAssignments, + imagePlacements, + textAssignments, + textPositionOffsets, + }; } for (const layer of template.layers) { @@ -744,11 +801,18 @@ function createInitialState( } } - return { imageAssignments, imagePlacements, textAssignments }; + return { + imageAssignments, + imagePlacements, + textAssignments, + textPositionOffsets, + }; } function normalizeLayerTransferKey(value: string) { - return String(value || "").trim().toLowerCase(); + return String(value || "") + .trim() + .toLowerCase(); } function getLayerTransferKeys(layer: AgentPsdTemplateLayerSlot) { @@ -821,8 +885,9 @@ function resolveTransferredSelectedLayerId(params: { } const previousLayer = - previousTemplate.layers.find((layer) => layer.id === previousSelectedLayerId) || - null; + previousTemplate.layers.find( + (layer) => layer.id === previousSelectedLayerId + ) || null; if (!previousLayer) { return getPreferredSelectedLayerId(nextTemplate); } @@ -830,8 +895,10 @@ function resolveTransferredSelectedLayerId(params: { const nextCandidateLayers = nextTemplate.layers.filter( (layer) => layer.isEditable && - ((isAgentPsdImageLayerSlot(previousLayer) && isAgentPsdImageLayerSlot(layer)) || - (isAgentPsdTextLayerSlot(previousLayer) && isAgentPsdTextLayerSlot(layer))) + ((isAgentPsdImageLayerSlot(previousLayer) && + isAgentPsdImageLayerSlot(layer)) || + (isAgentPsdTextLayerSlot(previousLayer) && + isAgentPsdTextLayerSlot(layer))) ); const previousLayerKeySet = new Set(getLayerTransferKeys(previousLayer)); const matchedLayer = nextCandidateLayers.find((layer) => @@ -849,6 +916,7 @@ function createTemplateEditorState(params: { previousImagePlacements: Record; previousTextAssignments: Record; previousTextColorAssignments: Record; + previousTextPositionOffsets: Record; }) { const { nextTemplate, @@ -858,6 +926,7 @@ function createTemplateEditorState(params: { previousImagePlacements, previousTextAssignments, previousTextColorAssignments, + previousTextPositionOffsets, } = params; const initialState = createInitialState(nextTemplate, sourceImageUrl); @@ -866,6 +935,7 @@ function createTemplateEditorState(params: { imagePlacements: { ...initialState.imagePlacements }, textAssignments: { ...initialState.textAssignments }, textColorAssignments: {} as Record, + textPositionOffsets: { ...initialState.textPositionOffsets }, }; if (!nextTemplate || !previousTemplate) { @@ -966,6 +1036,14 @@ function createTemplateEditorState(params: { nextState.textColorAssignments[nextLayer.id] = previousTextColor; } + const previousTextPositionOffset = + previousTextPositionOffsets[matchedPreviousLayer.id]; + if (hasTextPositionOffset(previousTextPositionOffset)) { + nextState.textPositionOffsets[nextLayer.id] = normalizeTextPositionOffset( + previousTextPositionOffset + ); + } + usedPreviousTextLayerIds.add(matchedPreviousLayer.id); } @@ -1103,7 +1181,8 @@ function getResolvedTextLayerColor( layer: AgentPsdTemplateTextLayerSlot, value?: string ) { - return normalizeHexColor(value || layer.textColor, layer.textColor); + const fallbackColor = normalizeHexColor(layer.textColor, "#111111"); + return normalizeHexColor(value || fallbackColor, fallbackColor); } function getTextLayerRenderFrame( @@ -1151,11 +1230,7 @@ function renderTextLayerPreviewContent( const resolvedLayout = getTextLayerRenderFrame(layer, resolvedText); const textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer); const textAnchor = - textAlign === "center" - ? "middle" - : textAlign === "right" - ? "end" - : "start"; + textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start"; const textX = textAlign === "center" ? resolvedLayout.width / 2 @@ -1275,11 +1350,14 @@ function hasEditedTextValue( function hasCustomizedTextLayerAppearance( layer: AgentPsdTemplateTextLayerSlot, value: string, - color: string + color: string, + positionOffset?: Partial | null ) { return ( hasEditedTextValue(layer, value) || - getResolvedTextLayerColor(layer, color) !== getResolvedTextLayerColor(layer) + getResolvedTextLayerColor(layer, color) !== + getResolvedTextLayerColor(layer) || + hasTextPositionOffset(positionOffset) ); } @@ -1416,6 +1494,9 @@ export function AgentPsdTemplateEditorDialog({ const [textColorAssignments, setTextColorAssignments] = useState< Record >({}); + const [textPositionOffsets, setTextPositionOffsets] = useState< + Record + >({}); const [hiddenLayerIds, setHiddenLayerIds] = useState([]); const [uploadingSlotId, setUploadingSlotId] = useState(null); const [extendingSlotId, setExtendingSlotId] = useState(null); @@ -1426,7 +1507,8 @@ export function AgentPsdTemplateEditorDialog({ ); const [stageScale, setStageScale] = useState(1); const [canScrollHiddenLayersUp, setCanScrollHiddenLayersUp] = useState(false); - const [canScrollHiddenLayersDown, setCanScrollHiddenLayersDown] = useState(false); + const [canScrollHiddenLayersDown, setCanScrollHiddenLayersDown] = + useState(false); const previewViewportRef = useRef(null); const hiddenLayerScrollAreaRef = useRef(null); @@ -1439,11 +1521,19 @@ export function AgentPsdTemplateEditorDialog({ startClientY: number; startPlacement: AgentPsdImagePlacement; } | null>(null); + const textDragStateRef = useRef<{ + layerId: string; + pointerId: number; + startClientX: number; + startClientY: number; + startOffset: AgentPsdTextPositionOffset; + } | null>(null); const imageAssignmentsRef = useRef(imageAssignments); const imagePlacementsRef = useRef(imagePlacements); const textAssignmentsRef = useRef(textAssignments); const textColorAssignmentsRef = useRef(textColorAssignments); + const textPositionOffsetsRef = useRef(textPositionOffsets); const archivedRenderImageUrlCacheRef = useRef>({}); const selectedLayerIdRef = useRef(selectedLayerId); @@ -1451,6 +1541,7 @@ export function AgentPsdTemplateEditorDialog({ imagePlacementsRef.current = imagePlacements; textAssignmentsRef.current = textAssignments; textColorAssignmentsRef.current = textColorAssignments; + textPositionOffsetsRef.current = textPositionOffsets; selectedLayerIdRef.current = selectedLayerId; useEffect(() => { @@ -1506,7 +1597,10 @@ export function AgentPsdTemplateEditorDialog({ [selectedTemplateId, templates] ); const selectedTemplateDetail = useMemo(() => { - if (!selectedTemplateDetailData?.success || !selectedTemplateDetailData.template) { + if ( + !selectedTemplateDetailData?.success || + !selectedTemplateDetailData.template + ) { return null; } @@ -1641,11 +1735,17 @@ export function AgentPsdTemplateEditorDialog({ ); const hiddenLayerIdSet = useMemo( () => - new Set(hiddenLayerIds.map((layerId) => String(layerId || "").trim()).filter(Boolean)), + new Set( + hiddenLayerIds + .map((layerId) => String(layerId || "").trim()) + .filter(Boolean) + ), [hiddenLayerIds] ); useEffect(() => { - const viewport = getScrollAreaViewportElement(hiddenLayerScrollAreaRef.current); + const viewport = getScrollAreaViewportElement( + hiddenLayerScrollAreaRef.current + ); if (!viewport) { setCanScrollHiddenLayersUp(false); setCanScrollHiddenLayersDown(false); @@ -1668,9 +1768,15 @@ export function AgentPsdTemplateEditorDialog({ window.cancelAnimationFrame(animationFrameId); viewport.removeEventListener("scroll", updateScrollButtons); }; - }, [hiddenLayerIds.length, open, orderedPreviewLayers.length, selectedTemplate?.id]); + }, [ + hiddenLayerIds.length, + open, + orderedPreviewLayers.length, + selectedTemplate?.id, + ]); const orderedVisiblePreviewLayers = useMemo( - () => orderedPreviewLayers.filter((layer) => !hiddenLayerIdSet.has(layer.id)), + () => + orderedPreviewLayers.filter((layer) => !hiddenLayerIdSet.has(layer.id)), [hiddenLayerIdSet, orderedPreviewLayers] ); const orderedEditableLayers = useMemo( @@ -1754,6 +1860,20 @@ export function AgentPsdTemplateEditorDialog({ return nextAssignments; }, [editableTextLayers, selectedTemplate, textColorAssignments]); + const effectiveTextPositionOffsets = useMemo(() => { + const nextOffsets: Record = {}; + if (!selectedTemplate) { + return nextOffsets; + } + + for (const layer of editableTextLayers) { + nextOffsets[layer.id] = normalizeTextPositionOffset( + textPositionOffsets[layer.id] + ); + } + + return nextOffsets; + }, [editableTextLayers, selectedTemplate, textPositionOffsets]); const effectiveImagePlacements = useMemo(() => { const nextPlacements: Record = {}; @@ -1807,6 +1927,10 @@ export function AgentPsdTemplateEditorDialog({ ? effectiveTextColorAssignments[selectedTextLayer.id] || selectedTextLayer.textColor : "#111111"; + const selectedTextPositionOffset = selectedTextLayer + ? effectiveTextPositionOffsets[selectedTextLayer.id] || + normalizeTextPositionOffset() + : normalizeTextPositionOffset(); const isSelectedImageExtending = Boolean( selectedImageLayer && extendingSlotId === selectedImageLayer.id ); @@ -1817,7 +1941,9 @@ export function AgentPsdTemplateEditorDialog({ return; } setHiddenLayerIds((current) => - current.includes(normalizedLayerId) ? current : [...current, normalizedLayerId] + current.includes(normalizedLayerId) + ? current + : [...current, normalizedLayerId] ); } @@ -2062,6 +2188,7 @@ export function AgentPsdTemplateEditorDialog({ setImageSizes({}); setTextAssignments({}); setTextColorAssignments({}); + setTextPositionOffsets({}); setHiddenLayerIds([]); setRenderedResultUrl(""); setRightPanelView("editor"); @@ -2096,11 +2223,13 @@ export function AgentPsdTemplateEditorDialog({ previousImagePlacements: imagePlacementsRef.current, previousTextAssignments: textAssignmentsRef.current, previousTextColorAssignments: textColorAssignmentsRef.current, + previousTextPositionOffsets: textPositionOffsetsRef.current, }); setImageAssignments(initialState.imageAssignments); setImagePlacements(initialState.imagePlacements); setTextAssignments(initialState.textAssignments); setTextColorAssignments(initialState.textColorAssignments); + setTextPositionOffsets(initialState.textPositionOffsets); setHiddenLayerIds([]); setSelectedLayerId( resolveTransferredSelectedLayerId({ @@ -2141,8 +2270,12 @@ export function AgentPsdTemplateEditorDialog({ return; } - const validLayerIds = new Set(selectedTemplateLayers.map((layer) => layer.id)); - setHiddenLayerIds((current) => current.filter((layerId) => validLayerIds.has(layerId))); + const validLayerIds = new Set( + selectedTemplateLayers.map((layer) => layer.id) + ); + setHiddenLayerIds((current) => + current.filter((layerId) => validLayerIds.has(layerId)) + ); }, [hiddenLayerIds.length, selectedTemplateLayers]); useEffect(() => { @@ -2332,7 +2465,9 @@ export function AgentPsdTemplateEditorDialog({ } catch (error) { toast.error( getUserFacingRequestErrorMessage(error, "素材上传失败", { - rateLimitMessage: getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试"), + rateLimitMessage: getFriendlyRateLimitMessage( + "当前上传过于频繁,请稍后再试" + ), }) ); } finally { @@ -2398,7 +2533,9 @@ export function AgentPsdTemplateEditorDialog({ } catch (error) { toast.error( getUserFacingRequestErrorMessage(error, "图片拓展失败", { - rateLimitMessage: getFriendlyRateLimitMessage("当前图片拓展请求过于频繁,请稍后再试"), + rateLimitMessage: getFriendlyRateLimitMessage( + "当前图片拓展请求过于频繁,请稍后再试" + ), }) ); } finally { @@ -2440,14 +2577,60 @@ export function AgentPsdTemplateEditorDialog({ })); } + function updateTextColorAssignment(layerId: string, color: string) { + setTextColorAssignments((current) => ({ + ...current, + [layerId]: normalizeHexColor(color, current[layerId] || "#111111"), + })); + } + + function updateTextPositionOffset( + layerId: string, + updater: ( + current: AgentPsdTextPositionOffset + ) => Partial + ) { + setTextPositionOffsets((current) => ({ + ...current, + [layerId]: normalizeTextPositionOffset({ + ...normalizeTextPositionOffset(current[layerId]), + ...updater(normalizeTextPositionOffset(current[layerId])), + }), + })); + } + + function nudgeTextPositionOffset( + layerId: string, + deltaX: number, + deltaY: number + ) { + updateTextPositionOffset(layerId, (current) => ({ + offsetX: current.offsetX + deltaX, + offsetY: current.offsetY + deltaY, + })); + } + + function resetTextPositionOffset(layerId: string) { + setTextPositionOffsets((current) => { + const next = { ...current }; + delete next[layerId]; + return next; + }); + } + function handleHiddenLayerListScroll(direction: "up" | "down") { - const viewport = getScrollAreaViewportElement(hiddenLayerScrollAreaRef.current); + const viewport = getScrollAreaViewportElement( + hiddenLayerScrollAreaRef.current + ); if (!viewport) { return; } viewport.scrollBy({ - top: direction === "up" ? -HIDDEN_LAYER_SCROLL_STEP : HIDDEN_LAYER_SCROLL_STEP, + top: + direction === "up" + ? -HIDDEN_LAYER_SCROLL_STEP + : HIDDEN_LAYER_SCROLL_STEP, behavior: "smooth", }); } @@ -2467,7 +2650,9 @@ export function AgentPsdTemplateEditorDialog({ } const fetchCandidates = Array.from( - new Set([normalizedUrl, buildImageProxyUrl(normalizedUrl)].filter(Boolean)) + new Set( + [normalizedUrl, buildImageProxyUrl(normalizedUrl)].filter(Boolean) + ) ); let lastError: unknown = null; @@ -2493,9 +2678,13 @@ export function AgentPsdTemplateEditorDialog({ const formData = new FormData(); formData.set( "file", - new File([blob], `psd-render-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`, { - type: blob.type || "image/png", - }) + new File( + [blob], + `psd-render-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`, + { + type: blob.type || "image/png", + } + ) ); const uploadResponse = await fetchUploadWithRetry(formData); @@ -2606,6 +2795,64 @@ export function AgentPsdTemplateEditorDialog({ window.addEventListener("pointercancel", handlePointerUp); } + function handleTextDragStart( + layer: AgentPsdTemplateTextLayerSlot, + event: ReactPointerEvent + ) { + event.preventDefault(); + event.stopPropagation(); + setSelectedLayerId(layer.id); + + textDragStateRef.current = { + layerId: layer.id, + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startOffset: normalizeTextPositionOffset( + effectiveTextPositionOffsets[layer.id] + ), + }; + + const nextTarget = event.currentTarget; + try { + nextTarget.setPointerCapture?.(event.pointerId); + } catch (error) { + console.warn( + "[AgentPsdTemplateEditorDialog] setPointerCapture failed:", + error + ); + } + + const handlePointerMove = (moveEvent: PointerEvent) => { + const dragState = textDragStateRef.current; + if (!dragState || dragState.layerId !== layer.id) { + return; + } + + updateTextPositionOffset(layer.id, () => ({ + offsetX: + dragState.startOffset.offsetX + + (moveEvent.clientX - dragState.startClientX) / stageScale, + offsetY: + dragState.startOffset.offsetY + + (moveEvent.clientY - dragState.startClientY) / stageScale, + })); + }; + + const handlePointerUp = (upEvent: PointerEvent) => { + if (textDragStateRef.current?.pointerId === upEvent.pointerId) { + textDragStateRef.current = null; + } + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + } + async function handleRender() { if (!selectedTemplate) { toast.error("请先选择 PSD 模板"); @@ -2647,6 +2894,7 @@ export function AgentPsdTemplateEditorDialog({ imagePlacements: effectiveImagePlacements, textAssignments: effectiveTextAssignments, textColorAssignments: effectiveTextColorAssignments, + textPositionOffsets: effectiveTextPositionOffsets, }, })) as AgentPsdTemplateRenderResponse; if (!payload?.success || !payload.result?.url) { @@ -2664,7 +2912,9 @@ export function AgentPsdTemplateEditorDialog({ } catch (error) { toast.error( getUserFacingRequestErrorMessage(error, "PSD 模板渲染失败", { - rateLimitMessage: getFriendlyRateLimitMessage("当前 PSD 渲染请求过于频繁,请稍后再试"), + rateLimitMessage: getFriendlyRateLimitMessage( + "当前 PSD 渲染请求过于频繁,请稍后再试" + ), }) ); } finally { @@ -2991,7 +3241,6 @@ export function AgentPsdTemplateEditorDialog({ PSD 模板。
)} - ) : (
@@ -3080,354 +3329,394 @@ export function AgentPsdTemplateEditorDialog({ height: layer.height, } satisfies CSSProperties; - if (isAgentPsdImageLayerSlot(layer)) { - const assignedUrl = - effectiveImageAssignments[layer.id]; - const layerImageUrl = - assignedUrl || layer.sourceUrl; - if (!layerImageUrl) { - return null; - } - const assignedImageContainerStyle = assignedUrl - ? { - ...layerBoxStyle, - ...getAssignedImageMaskStyle(layer), - } - : layerBoxStyle; - - return ( -
- {layer.layerName} -
- ); - } - - const isSelected = selectedLayerId === layer.id; - if (isSelected) { - return null; - } - - const value = - effectiveTextAssignments[layer.id] ?? ""; - const resolvedTextColor = - effectiveTextColorAssignments[layer.id] || - layer.textColor; - const shouldUseEditedPreview = - hasCustomizedTextLayerAppearance( - layer, - value, - resolvedTextColor - ); - - if (!shouldUseEditedPreview && layer.sourceUrl) { - return ( -
- {layer.layerName} -
- ); - } - - if (!value && !layer.placeholder) { - return null; - } - - const textFrame = getTextLayerRenderFrame( - layer, - value - ); - - return ( -
- {renderTextLayerPreviewContent( - layer, - value, - resolvedTextColor - )} -
- ); - })} -
- -
- {orderedEditableImageLayers.map((layer) => { - if (hiddenLayerIdSet.has(layer.id)) { - return null; - } - const isSelected = selectedLayerId === layer.id; - const frameStyle = getLayerFrameStyle( - layer, - isSelected - ); - const hasAssignedImage = Boolean( - String( - effectiveImageAssignments[layer.id] || "" - ).trim() - ); - - return isSelected && hasAssignedImage ? ( -
setSelectedLayerId(layer.id)} - onPointerDown={(event) => - handleImageDragStart(layer, event) + if (isAgentPsdImageLayerSlot(layer)) { + const assignedUrl = + effectiveImageAssignments[layer.id]; + const layerImageUrl = + assignedUrl || layer.sourceUrl; + if (!layerImageUrl) { + return null; } - role="button" - style={{ - ...frameStyle, - zIndex: 20, - background: "rgba(249,115,22,0.08)", - cursor: - imageDragStateRef.current?.layerId === - layer.id - ? "grabbing" - : "grab", - touchAction: "none", - }} - tabIndex={0} - title={`${getLayerLabel(layer)}:${layer.layerName}`} - > -
- {layer.layerName} -
-
- - 拖动调整 -
-
- ) : ( - - ); - })} - - {orderedEditableImageLayers.map((layer) => { - if (hiddenLayerIdSet.has(layer.id)) { - return null; - } - const isSelected = selectedLayerId === layer.id; - const hasAssignedImage = Boolean( - String( - effectiveImageAssignments[layer.id] || "" - ).trim() - ); - if (!isSelected || !hasAssignedImage) { - return null; - } - - return ( -
- - -
- -
- ); - })} - - {orderedEditableTextLayers.map((layer) => { - if (hiddenLayerIdSet.has(layer.id)) { - return null; - } - const isSelected = selectedLayerId === layer.id; - const frameStyle = getLayerFrameStyle( - layer, - isSelected - ); - const currentValue = - effectiveTextAssignments[layer.id] ?? ""; - const resolvedTextColor = - effectiveTextColorAssignments[layer.id] || - layer.textColor; - const preserveSourceAppearance = - !!layer.sourceUrl && - !hasCustomizedTextLayerAppearance( - layer, - currentValue, - resolvedTextColor - ); - const editedTextFrame = getTextLayerRenderFrame( - layer, - currentValue - ); - - if (isSelected) { - return ( -
- {preserveSourceAppearance && layer.sourceUrl ? ( + return ( +
{layer.layerName} +
+ ); + } + + const isSelected = selectedLayerId === layer.id; + if (isSelected) { + return null; + } + + const value = + effectiveTextAssignments[layer.id] ?? ""; + const resolvedTextColor = + effectiveTextColorAssignments[layer.id] || + layer.textColor; + const textPositionOffset = + effectiveTextPositionOffsets[layer.id] || + normalizeTextPositionOffset(); + const shouldUseEditedPreview = + hasCustomizedTextLayerAppearance( + layer, + value, + resolvedTextColor, + textPositionOffset + ); + + if (!shouldUseEditedPreview && layer.sourceUrl) { + return ( +
+ {layer.layerName} - ) : null} - {preserveSourceAppearance ? null : ( -
- {renderTextLayerPreviewContent( - layer, - currentValue, - resolvedTextColor - )} +
+ ); + } + + if (!value && !layer.placeholder) { + return null; + } + + const textFrame = getTextLayerRenderFrame( + layer, + value + ); + + return ( +
+ {renderTextLayerPreviewContent( + layer, + value, + resolvedTextColor + )} +
+ ); + })} +
+ +
+ {orderedEditableImageLayers.map((layer) => { + if (hiddenLayerIdSet.has(layer.id)) { + return null; + } + const isSelected = selectedLayerId === layer.id; + const frameStyle = getLayerFrameStyle( + layer, + isSelected + ); + const hasAssignedImage = Boolean( + String( + effectiveImageAssignments[layer.id] || "" + ).trim() + ); + + return isSelected && hasAssignedImage ? ( +
setSelectedLayerId(layer.id)} + onPointerDown={(event) => + handleImageDragStart(layer, event) + } + role="button" + style={{ + ...frameStyle, + zIndex: 20, + background: "rgba(249,115,22,0.08)", + cursor: + imageDragStateRef.current?.layerId === + layer.id + ? "grabbing" + : "grab", + touchAction: "none", + }} + tabIndex={0} + title={`${getLayerLabel(layer)}:${layer.layerName}`} + > +
+ {layer.layerName} +
+
+ + 拖动调整 +
+
+ ) : ( + + ); + })} + + {orderedEditableImageLayers.map((layer) => { + if (hiddenLayerIdSet.has(layer.id)) { + return null; + } + const isSelected = selectedLayerId === layer.id; + const hasAssignedImage = Boolean( + String( + effectiveImageAssignments[layer.id] || "" + ).trim() + ); + if (!isSelected || !hasAssignedImage) { + return null; + } + + return ( +
+ + +
+ +
+ ); + })} + + {orderedEditableTextLayers.map((layer) => { + if (hiddenLayerIdSet.has(layer.id)) { + return null; + } + const isSelected = selectedLayerId === layer.id; + const textPositionOffset = + effectiveTextPositionOffsets[layer.id] || + normalizeTextPositionOffset(); + const frameStyle = { + ...getLayerFrameStyle(layer, isSelected), + left: layer.x + textPositionOffset.offsetX, + top: layer.y + textPositionOffset.offsetY, + } satisfies CSSProperties; + const currentValue = + effectiveTextAssignments[layer.id] ?? ""; + const resolvedTextColor = + effectiveTextColorAssignments[layer.id] || + layer.textColor; + const preserveSourceAppearance = + !!layer.sourceUrl && + !hasCustomizedTextLayerAppearance( + layer, + currentValue, + resolvedTextColor, + textPositionOffset + ); + const editedTextFrame = getTextLayerRenderFrame( + layer, + currentValue + ); + + if (isSelected) { + return ( +
setSelectedLayerId(layer.id)} + onPointerDown={(event) => + handleTextDragStart(layer, event) + } + style={{ + ...frameStyle, + zIndex: 40, + background: "transparent", + cursor: + textDragStateRef.current?.layerId === + layer.id + ? "grabbing" + : "grab", + touchAction: "none", + }} + > + {preserveSourceAppearance && + layer.sourceUrl ? ( + {layer.layerName} + ) : null} + {preserveSourceAppearance ? null : ( +
+ {renderTextLayerPreviewContent( + layer, + currentValue, + resolvedTextColor + )} +
+ )} +
+ {layer.layerName} +
+
+ 拖动文字 / 右侧微调 +
+
+ ); + } + + return ( + ); - } - - return ( - - ); - })} + })}
@@ -3559,9 +3848,12 @@ export function AgentPsdTemplateEditorDialog({
-
图层已临时删除
+
+ 图层已临时删除 +
- 当前会话里不会再预览或导出图层“{selectedLayer.layerName}”,但不会改动模板本身。 + 当前会话里不会再预览或导出图层“{selectedLayer.layerName} + ”,但不会改动模板本身。
- {textColorConfig.enabled ? ( -
-
-
-
- 文字颜色 -
-
- 选中文字后,可直接点击下面按钮切换品牌预设文字颜色。 -
+
+
+
+
+ 文字颜色 +
+
+ 支持自定义颜色;若后台配置了品牌色,也可以直接套用预设。
-
+
+
+
+ + updateTextColorAssignment( + selectedTextLayer.id, + event.target.value + ) + } + type="color" + value={selectedTextColor} + /> +
+ {selectedTextColor} +
+ +
+ {textColorConfig.enabled && + textColorConfig.colors.length > 0 ? (
{textColorConfig.colors.map((color, index) => { const isActive = @@ -3658,10 +3982,10 @@ export function AgentPsdTemplateEditorDialog({ }`} key={`text-color-option-${index + 1}`} onClick={() => - setTextColorAssignments((current) => ({ - ...current, - [selectedTextLayer.id]: color, - })) + updateTextColorAssignment( + selectedTextLayer.id, + color + ) } type="button" > @@ -3687,11 +4011,145 @@ export function AgentPsdTemplateEditorDialog({ ); })}
+ ) : ( +
+ 当前品牌未配置预设色,可直接使用上方颜色选择器。 +
+ )} +
+
+
+
+
+ 文字位置 +
+
+ 可在中间预览区直接拖动文字,也可以用数值微调。 +
+
+
- ) : null} +
+
+ + { + const nextValue = Number(event.target.value); + if (!Number.isFinite(nextValue)) return; + updateTextPositionOffset( + selectedTextLayer.id, + () => ({ offsetX: nextValue }) + ); + }} + type="number" + value={Math.round( + selectedTextPositionOffset.offsetX + )} + /> +
+
+ + { + const nextValue = Number(event.target.value); + if (!Number.isFinite(nextValue)) return; + updateTextPositionOffset( + selectedTextLayer.id, + () => ({ offsetY: nextValue }) + ); + }} + type="number" + value={Math.round( + selectedTextPositionOffset.offsetY + )} + /> +
+
+
+
+ +
+ + + +
+ +
+
+
- ) : selectedTemplate && selectedTemplateLayers.length === 0 ? ( + ) : selectedTemplate && + selectedTemplateLayers.length === 0 ? (
{selectedTemplateDetailError ? ( <> @@ -3731,7 +4189,8 @@ export function AgentPsdTemplateEditorDialog({ 图层显示
- 临时删除只影响这次套版预览和导出,不会改动 PSD 模板本身。 + 临时删除只影响这次套版预览和导出,不会改动 PSD + 模板本身。
@@ -3748,7 +4207,9 @@ export function AgentPsdTemplateEditorDialog({
- 已隐藏 {hiddenLayerIds.length} / {selectedTemplateLayers.length} + 已隐藏 {hiddenLayerIds.length} /{" "} + {selectedTemplateLayers.length}
@@ -3778,7 +4240,10 @@ export function AgentPsdTemplateEditorDialog({ key={`layer-visibility-${layer.id}`} onClick={() => setSelectedLayerId(layer.id)} onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { + if ( + event.key === "Enter" || + event.key === " " + ) { event.preventDefault(); setSelectedLayerId(layer.id); } diff --git a/lib/agents/psd-render-service.ts b/lib/agents/psd-render-service.ts index fb2d20fdd..207e7428a 100644 --- a/lib/agents/psd-render-service.ts +++ b/lib/agents/psd-render-service.ts @@ -2,9 +2,7 @@ import "server-only"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placement"; -import { - type AgentPsdTemplateRenderRequest, -} from "@/lib/agents/psd-render-task-contract"; +import { type AgentPsdTemplateRenderRequest } from "@/lib/agents/psd-render-task-contract"; import { type AgentPsdTemplateRenderResult, getAgentPsdTemplateRuntimeIssue, @@ -19,6 +17,31 @@ import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-re import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; import { normalizeHexColor } from "@/lib/agents/psd-text-layout"; +type AgentPsdTextPositionOffset = { + offsetX: number; + offsetY: number; +}; + +function clampTextPositionOffsetValue(value: number) { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(-5000, Math.min(5000, value)); +} + +function normalizeTextPositionOffset( + value?: Partial | null +): AgentPsdTextPositionOffset { + return { + offsetX: Number( + clampTextPositionOffsetValue(Number(value?.offsetX || 0)).toFixed(2) + ), + offsetY: Number( + clampTextPositionOffsetValue(Number(value?.offsetY || 0)).toFixed(2) + ), + }; +} + export async function executeAgentPsdTemplateRenderRequest( body: AgentPsdTemplateRenderRequest ): Promise { @@ -48,7 +71,9 @@ export async function executeAgentPsdTemplateRenderRequest( throw new Error("当前模板未配置可编辑图层"); } const hiddenLayerIdSet = new Set( - (body.hiddenLayerIds || []).map((layerId) => String(layerId || "").trim()).filter(Boolean) + (body.hiddenLayerIds || []) + .map((layerId) => String(layerId || "").trim()) + .filter(Boolean) ); const fontMap = new Map(fonts.map((font) => [font.id, font])); @@ -60,7 +85,9 @@ export async function executeAgentPsdTemplateRenderRequest( !fontMap.has(layer.fontId) ); if (missingFontLayer) { - throw new Error(`文字图层“${missingFontLayer.layerName}”引用的字体已不存在`); + throw new Error( + `文字图层“${missingFontLayer.layerName}”引用的字体已不存在` + ); } const imageAssignments: Record = {}; @@ -70,6 +97,7 @@ export async function executeAgentPsdTemplateRenderRequest( > = {}; const textAssignments: Record = {}; const textColorAssignments: Record = {}; + const textPositionOffsets: Record = {}; for (const layer of editableLayers) { if (hiddenLayerIdSet.has(layer.id)) { @@ -104,6 +132,9 @@ export async function executeAgentPsdTemplateRenderRequest( body.textColorAssignments[layer.id] || layer.textColor, layer.textColor ); + textPositionOffsets[layer.id] = normalizeTextPositionOffset( + body.textPositionOffsets[layer.id] + ); } } @@ -127,6 +158,7 @@ export async function executeAgentPsdTemplateRenderRequest( imagePlacements, textAssignments, textColorAssignments, + textPositionOffsets, fonts, }); diff --git a/lib/agents/psd-render-task-contract.ts b/lib/agents/psd-render-task-contract.ts index d6ffe2dbf..7841dbcdc 100644 --- a/lib/agents/psd-render-task-contract.ts +++ b/lib/agents/psd-render-task-contract.ts @@ -2,6 +2,11 @@ import { z } from "zod"; export const AGENT_PSD_RENDER_TASK_POLL_AFTER_MS = 1500; +const textPositionOffsetSchema = z.object({ + offsetX: z.number().min(-5000).max(5000).optional(), + offsetY: z.number().min(-5000).max(5000).optional(), +}); + export const agentPsdTemplateRenderRequestSchema = z.object({ templateId: z.string().trim().min(1), sourceImageUrl: z.string().trim().optional().nullable(), @@ -19,6 +24,9 @@ export const agentPsdTemplateRenderRequestSchema = z.object({ .default({}), textAssignments: z.record(z.string(), z.string()).default({}), textColorAssignments: z.record(z.string(), z.string()).default({}), + textPositionOffsets: z + .record(z.string(), textPositionOffsetSchema) + .default({}), }); export type AgentPsdTemplateRenderRequest = z.infer< diff --git a/lib/agents/psd-template-renderer.ts b/lib/agents/psd-template-renderer.ts index 35e3dcdc8..bdfd9173a 100644 --- a/lib/agents/psd-template-renderer.ts +++ b/lib/agents/psd-template-renderer.ts @@ -11,8 +11,48 @@ import { } from "@/lib/agents/psd-template-config"; import type { AgentPsdFont } from "@/lib/agents/psd-font-config"; import { ensurePsdFontAvailableForRender } from "@/lib/agents/psd-font-runtime"; -import { normalizeAgentPsdImagePlacement, resolveAgentPsdImagePlacementFrame } from "@/lib/agents/psd-image-placement"; -import { normalizeHexColor, resolveTextLayout } from "@/lib/agents/psd-text-layout"; +import { + normalizeAgentPsdImagePlacement, + resolveAgentPsdImagePlacementFrame, +} from "@/lib/agents/psd-image-placement"; +import { + normalizeHexColor, + resolveTextLayout, +} from "@/lib/agents/psd-text-layout"; + +type AgentPsdTextPositionOffset = { + offsetX: number; + offsetY: number; +}; + +function clampTextPositionOffsetValue(value: number) { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(-5000, Math.min(5000, value)); +} + +function normalizeTextPositionOffset( + value?: Partial | null +): AgentPsdTextPositionOffset { + return { + offsetX: Number( + clampTextPositionOffsetValue(Number(value?.offsetX || 0)).toFixed(2) + ), + offsetY: Number( + clampTextPositionOffsetValue(Number(value?.offsetY || 0)).toFixed(2) + ), + }; +} + +function hasTextPositionOffset( + value?: Partial | null +) { + const normalized = normalizeTextPositionOffset(value); + return ( + Math.abs(normalized.offsetX) > 0.01 || Math.abs(normalized.offsetY) > 0.01 + ); +} async function getSharp() { const sharp = await import("sharp"); @@ -116,7 +156,9 @@ function resolveTextOverlayFrame(params: { width, height, leftOffset, - topOffset: Number.isFinite(Number(params.topOffset)) ? Number(params.topOffset) : 0, + topOffset: Number.isFinite(Number(params.topOffset)) + ? Number(params.topOffset) + : 0, }; } @@ -139,7 +181,14 @@ function getFontMimeType(font: AgentPsdFont | undefined, fontFamily: string) { return "font/ttf"; } -const GENERIC_FONT_FAMILIES = new Set(["sans-serif", "serif", "monospace", "cursive", "fantasy", "system-ui"]); +const GENERIC_FONT_FAMILIES = new Set([ + "sans-serif", + "serif", + "monospace", + "cursive", + "fantasy", + "system-ui", +]); const CJK_FALLBACK_FONT_FAMILIES = [ "Noto Sans CJK SC", "Noto Sans SC", @@ -223,7 +272,9 @@ async function createTextOverlayBuffer(params: { maxLines, } = params; - const normalizedText = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const normalizedText = String(text || "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); const resolvedLayout = resolveTextLayout({ text: normalizedText, width, @@ -252,7 +303,10 @@ async function createTextOverlayBuffer(params: { .filter(Boolean) ) ); - const fontFamilyStack = buildSvgFontFamilyStack(primaryFamilies, normalizedText); + const fontFamilyStack = buildSvgFontFamilyStack( + primaryFamilies, + normalizedText + ); const fontFaceCss = fontBuffer ? primaryFamilies .map((family) => { @@ -262,7 +316,8 @@ async function createTextOverlayBuffer(params: { .join("\n") : ""; const clipId = `clip-${Math.random().toString(36).slice(2, 10)}`; - const anchor = textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start"; + const anchor = + textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start"; const x = textAlign === "center" ? resolvedLayout.contentWidth / 2 @@ -302,9 +357,13 @@ export async function renderAgentPsdTemplate(params: { template: AgentPsdTemplate; hiddenLayerIds?: string[]; imageAssignments: Record; - imagePlacements: Record>; + imagePlacements: Record< + string, + ReturnType + >; textAssignments: Record; textColorAssignments?: Record; + textPositionOffsets?: Record>; fonts: AgentPsdFont[]; }) { const { @@ -314,6 +373,7 @@ export async function renderAgentPsdTemplate(params: { imagePlacements, textAssignments, textColorAssignments = {}, + textPositionOffsets = {}, fonts, } = params; const sharp = await getSharp(); @@ -322,7 +382,9 @@ export async function renderAgentPsdTemplate(params: { const remoteBufferCache = new Map(); const fontMap = new Map(fonts.map((font) => [font.id, font])); const hiddenLayerIdSet = new Set( - hiddenLayerIds.map((layerId) => String(layerId || "").trim()).filter(Boolean) + hiddenLayerIds + .map((layerId) => String(layerId || "").trim()) + .filter(Boolean) ); async function readCachedBuffer(url: string, label = "PSD 模板资源") { @@ -345,7 +407,8 @@ export async function renderAgentPsdTemplate(params: { return remoteBufferCache.get(normalizedUrl)!; } - const compositeInputs: Array<{ input: Buffer; left: number; top: number }> = []; + const compositeInputs: Array<{ input: Buffer; left: number; top: number }> = + []; async function appendBufferOverlay(params: { input: Buffer; @@ -392,11 +455,14 @@ export async function renderAgentPsdTemplate(params: { return; } } catch (error) { - console.warn("[PSD Template] failed to reuse stored layer asset during render:", { - templateId: template.id, - layerId: layer.layerId, - error, - }); + console.warn( + "[PSD Template] failed to reuse stored layer asset during render:", + { + templateId: template.id, + layerId: layer.layerId, + error, + } + ); throw new Error( `PSD 模板图层“${layer.layerName}”资源加载失败,请稍后重试或在后台重新解析该模板` ); @@ -420,8 +486,14 @@ export async function renderAgentPsdTemplate(params: { continue; } - const sourceBuffer = await readCachedBuffer(assignmentUrl, "PSD 套版素材"); - const normalizedSourceBuffer = await sharp(sourceBuffer).rotate().png().toBuffer(); + const sourceBuffer = await readCachedBuffer( + assignmentUrl, + "PSD 套版素材" + ); + const normalizedSourceBuffer = await sharp(sourceBuffer) + .rotate() + .png() + .toBuffer(); const metadata = await sharp(normalizedSourceBuffer).metadata(); const placementFrame = resolveAgentPsdImagePlacementFrame({ containerWidth: layer.width, @@ -508,11 +580,14 @@ export async function renderAgentPsdTemplate(params: { .png() .toBuffer(); } catch (error) { - console.warn("[PSD Template] failed to apply layer mask during render:", { - templateId: template.id, - layerId: layer.layerId, - error, - }); + console.warn( + "[PSD Template] failed to apply layer mask during render:", + { + templateId: template.id, + layerId: layer.layerId, + error, + } + ); } } @@ -531,20 +606,26 @@ export async function renderAgentPsdTemplate(params: { const hasTextAssignment = Object.hasOwn(textAssignments, layer.id); const defaultText = String(layer.defaultText || "").trim(); const text = String( - hasTextAssignment ? textAssignments[layer.id] ?? "" : defaultText + 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 hasColorChange = + normalizeHexColor(textColor, layer.textColor) !== + normalizeHexColor(layer.textColor); + const textPositionOffset = normalizeTextPositionOffset( + textPositionOffsets[layer.id] + ); + const hasPositionChange = hasTextPositionOffset(textPositionOffset); if (!text) { if (!hasTextAssignment) { await appendStoredLayer(layer); } continue; } - if (text === defaultText && !hasColorChange) { + if (text === defaultText && !hasColorChange && !hasPositionChange) { await appendStoredLayer(layer); continue; } @@ -552,23 +633,29 @@ export async function renderAgentPsdTemplate(params: { const font = layer.fontId ? fontMap.get(layer.fontId) : undefined; const fontBuffer = font?.fileUrl - ? await readCachedBuffer(font.fileUrl, "PSD 字体资源").catch((error) => { - console.warn("[PSD Template] failed to load render font, falling back to family stack:", { - templateId: template.id, - layerId: layer.layerId, - fontId: font.id, - error, - }); - return undefined; - }) + ? await readCachedBuffer(font.fileUrl, "PSD 字体资源").catch( + (error) => { + console.warn( + "[PSD Template] failed to load render font, falling back to family stack:", + { + templateId: template.id, + layerId: layer.layerId, + fontId: font.id, + error, + } + ); + return undefined; + } + ) : undefined; - const runtimeFont = font && fontBuffer - ? await ensurePsdFontAvailableForRender({ - font, - fontBuffer, - preferredFamily: layer.fontFamily || font.family, - }).catch(() => null) - : null; + const runtimeFont = + font && fontBuffer + ? await ensurePsdFontAvailableForRender({ + font, + fontBuffer, + preferredFamily: layer.fontFamily || font.family, + }).catch(() => null) + : null; const overlayBuffer = await createTextOverlayBuffer({ width: layer.width, height: layer.height, @@ -586,13 +673,19 @@ export async function renderAgentPsdTemplate(params: { runtimeFont.fontPostscriptName, runtimeFont.fontFullName, runtimeFont.fontFamily, - ].filter((value): value is string => Boolean(String(value || "").trim())) + ].filter((value): value is string => + Boolean(String(value || "").trim()) + ) : [ ...(layer.detectedFontNames || []), ...(layer.detectedFontName ? [layer.detectedFontName] : []), - ].filter((value): value is string => Boolean(String(value || "").trim())), + ].filter((value): value is string => + Boolean(String(value || "").trim()) + ), renderFontFamily: runtimeFont?.fontFamily, - useExactFontFace: Boolean(runtimeFont?.fontPostscriptName || runtimeFont?.fontFullName), + useExactFontFace: Boolean( + runtimeFont?.fontPostscriptName || runtimeFont?.fontFullName + ), fontSize: layer.fontSize, fontWeight: layer.fontWeight, lineHeight: layer.lineHeight, @@ -629,8 +722,8 @@ export async function renderAgentPsdTemplate(params: { const didAppend = await appendBufferOverlay({ input: overlayBuffer, - left: layer.x + overlayFrame.leftOffset, - top: layer.y + overlayFrame.topOffset, + left: layer.x + textPositionOffset.offsetX + overlayFrame.leftOffset, + top: layer.y + textPositionOffset.offsetY + overlayFrame.topOffset, width: overlayFrame.width, height: overlayFrame.height, });