import type { AgentPsdTextColorConfig } from "@/types/agent"; import type { AgentPsdTemplateSecondaryCategoryConfig } from "@/lib/agents/psd-template-secondary-category-config"; import type { AgentPsdTemplateCategoryTagConfig } from "@/lib/agents/psd-template-category-tag-config"; import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config"; export const AGENT_PSD_TEMPLATES_KEY = "agent_psd_templates"; export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 6; export type AgentPsdTemplateStatus = "active" | "disabled"; export type AgentPsdTemplateParseStatus = | "pending" | "processing" | "completed" | "failed"; export type AgentPsdTemplateMode = "main" | "sku"; export type AgentPsdTemplateReplaceMode = "cover" | "contain" | "stretch"; export type AgentPsdTemplateSlotType = "image" | "text"; export type AgentPsdTextAlign = "left" | "center" | "right"; export type AgentPsdTextStyleSource = "auto" | "custom"; export interface AgentPsdTemplateImageContentBounds { left: number; top: number; width: number; height: number; } export interface AgentPsdTemplateBaseLayerSlot { id: string; layerId: string; layerName: string; layerPath: string; depth: number; x: number; y: number; width: number; height: number; isEditable: boolean; isSuperLocked?: boolean; isRequired: boolean; previewUrl?: string; previewPath?: string; sourceUrl?: string; sourcePath?: string; } export interface AgentPsdTemplateImageLayerSlot extends AgentPsdTemplateBaseLayerSlot { slotType: "image"; isDefaultResultSource: boolean; allowUserUploadOverride: boolean; replaceMode: AgentPsdTemplateReplaceMode; contentBounds?: AgentPsdTemplateImageContentBounds; maskUrl?: string; maskPath?: string; } export interface AgentPsdTemplateTextLayerSlot extends AgentPsdTemplateBaseLayerSlot { slotType: "text"; styleSource?: AgentPsdTextStyleSource; defaultText?: string; placeholder?: string; detectedFontName?: string; detectedFontNames?: string[]; fontId?: string; fontFamily?: string; fontLabel?: string; fontFileUrl?: string; fontContentType?: string; fontSize: number; lineHeight: number; letterSpacing: number; fontWeight: number; textColor: string; textAlign: AgentPsdTextAlign; paddingX: number; paddingY: number; maxLines: number; } export type AgentPsdTemplateLayerSlot = | AgentPsdTemplateImageLayerSlot | AgentPsdTemplateTextLayerSlot; export interface AgentPsdTemplate { id: string; name: string; brandName: string; mode: AgentPsdTemplateMode; secondaryCategory?: string; categoryTags?: string[]; description?: string; status: AgentPsdTemplateStatus; parseStatus: AgentPsdTemplateParseStatus; parseProgress?: number; parseStage?: string; parseError?: string; fileName: string; fileUrl: string; filePath?: string; previewUrl: string; previewPath?: string; renderBaseUrl?: string; renderBasePath?: string; width: number; height: number; layerCount: number; layers: AgentPsdTemplateLayerSlot[]; assetVersion?: number; createdAt: string; updatedAt: string; uploadedById?: string; } export interface AgentPsdTemplateListResponse { success: boolean; templates: AgentPsdTemplate[]; textColorConfig?: AgentPsdTextColorConfig; secondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig; categoryTagConfig?: AgentPsdTemplateCategoryTagConfig; error?: string; } export interface AgentPsdTemplateMutationResponse { success: boolean; template?: AgentPsdTemplate; error?: string; } export interface AgentPsdTemplateRenderResult { url: string; pathname: string; contentType?: string; } export type AgentPsdTemplateRenderStatus = | "pending" | "running" | "succeeded" | "failed"; export interface AgentPsdTemplateRenderResponse { success: boolean; processing?: boolean; status?: AgentPsdTemplateRenderStatus; taskId?: string; pollAfterMs?: number; result?: AgentPsdTemplateRenderResult; error?: string; } function normalizeText(value: unknown) { return String(value || "").trim(); } function normalizeUrl(value: unknown) { const text = normalizeText(value); return /^https?:\/\//i.test(text) ? text : ""; } function normalizeOptionalUrl(value: unknown) { const url = normalizeUrl(value); return url || undefined; } function toIsoDateString(value: unknown) { const text = normalizeText(value); if (!text) return null; const date = new Date(text); if (Number.isNaN(date.getTime())) return null; return date.toISOString(); } function normalizePositiveInteger(value: unknown) { const numberValue = Number(value); if (!Number.isFinite(numberValue)) return 0; return Math.max(0, Math.round(numberValue)); } function normalizeInteger(value: unknown) { const numberValue = Number(value); if (!Number.isFinite(numberValue)) return 0; return Math.round(numberValue); } function normalizePositiveNumber(value: unknown, fallback: number) { const numberValue = Number(value); if (!Number.isFinite(numberValue) || numberValue <= 0) { return fallback; } return numberValue; } function normalizeColor(value: unknown, fallback = "#111111") { const text = normalizeText(value); if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(text)) { return text; } return fallback; } export function normalizeAgentPsdTemplateStatus(value: unknown): AgentPsdTemplateStatus { return normalizeText(value) === "active" ? "active" : "disabled"; } export function normalizeAgentPsdTemplateMode(value: unknown): AgentPsdTemplateMode { return normalizeText(value) === "sku" ? "sku" : "main"; } export function normalizeAgentPsdTemplateParseStatus( value: unknown ): AgentPsdTemplateParseStatus { const status = normalizeText(value); if ( status === "pending" || status === "processing" || status === "failed" ) { return status; } return "completed"; } export function normalizeAgentPsdTemplateReplaceMode( value: unknown ): AgentPsdTemplateReplaceMode { const mode = normalizeText(value); if (mode === "contain" || mode === "stretch") { return mode; } return "cover"; } function normalizeAgentPsdTemplateImageContentBounds( value: unknown, containerWidth: number, containerHeight: number ): AgentPsdTemplateImageContentBounds | undefined { if (!value || typeof value !== "object") { return undefined; } const record = value as Record; const left = normalizePositiveInteger(record.left); const top = normalizePositiveInteger(record.top); const width = normalizePositiveInteger(record.width); const height = normalizePositiveInteger(record.height); if (width <= 0 || height <= 0) { return undefined; } const clampedLeft = Math.min(left, Math.max(0, containerWidth - 1)); const clampedTop = Math.min(top, Math.max(0, containerHeight - 1)); const maxWidth = Math.max(1, containerWidth - clampedLeft); const maxHeight = Math.max(1, containerHeight - clampedTop); const clampedWidth = Math.min(width, maxWidth); const clampedHeight = Math.min(height, maxHeight); if ( clampedLeft === 0 && clampedTop === 0 && clampedWidth === containerWidth && clampedHeight === containerHeight ) { return undefined; } return { left: clampedLeft, top: clampedTop, width: clampedWidth, height: clampedHeight, }; } export function normalizeAgentPsdTemplateSlotType( value: unknown ): AgentPsdTemplateSlotType { return normalizeText(value) === "text" ? "text" : "image"; } export function normalizeAgentPsdTextAlign(value: unknown): AgentPsdTextAlign { const align = normalizeText(value); if (align === "center" || align === "right") { return align; } return "left"; } export function resolveAgentPsdTextLayerEffectiveAlign( layer: Pick< AgentPsdTemplateTextLayerSlot, "paddingX" | "styleSource" | "textAlign" | "width" > ): AgentPsdTextAlign { const textAlign = normalizeAgentPsdTextAlign(layer.textAlign); const paddingX = normalizePositiveInteger(layer.paddingX); const width = normalizePositiveInteger(layer.width); if ( layer.styleSource !== "custom" && textAlign === "center" && paddingX <= Math.max(2, Math.round(width * 0.02)) ) { return "left"; } return textAlign; } export function normalizeAgentPsdTextStyleSource(value: unknown): AgentPsdTextStyleSource { return normalizeText(value) === "custom" ? "custom" : "auto"; } export function parseAgentPsdTemplateLayers(value: unknown) { if (!Array.isArray(value)) return [] as AgentPsdTemplateLayerSlot[]; const layers: AgentPsdTemplateLayerSlot[] = []; for (const entry of value) { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { continue; } const record = entry as Record; const id = normalizeText(record.id); const layerId = normalizeText(record.layerId); const layerName = normalizeText(record.layerName); const layerPath = normalizeText(record.layerPath); const width = normalizePositiveInteger(record.width); const height = normalizePositiveInteger(record.height); const slotType = normalizeAgentPsdTemplateSlotType(record.slotType); if (!id || !layerId || !layerName || !layerPath || width <= 0 || height <= 0) { continue; } const isEditable = record.isEditable === true; const isSuperLocked = record.isSuperLocked === true; const baseRecord = { id, layerId, layerName, layerPath, depth: Math.max(0, normalizePositiveInteger(record.depth)), x: normalizeInteger(record.x), y: normalizeInteger(record.y), width, height, isEditable, isSuperLocked, isRequired: isEditable && !isSuperLocked && record.isRequired === true, previewUrl: normalizeOptionalUrl(record.previewUrl), previewPath: normalizeText(record.previewPath) || undefined, sourceUrl: normalizeOptionalUrl(record.sourceUrl), sourcePath: normalizeText(record.sourcePath) || undefined, } satisfies AgentPsdTemplateBaseLayerSlot; if (slotType === "text") { const estimatedFontSize = Math.max(16, Math.min(height, Math.round(height * 0.78))); layers.push({ ...baseRecord, slotType: "text", styleSource: normalizeAgentPsdTextStyleSource(record.styleSource), defaultText: normalizeText(record.defaultText) || undefined, placeholder: normalizeText(record.placeholder) || undefined, detectedFontName: normalizeText(record.detectedFontName) || undefined, detectedFontNames: Array.isArray(record.detectedFontNames) ? Array.from( new Set( record.detectedFontNames .map((item) => normalizeText(item)) .filter(Boolean) ) ).slice(0, 8) : undefined, fontId: normalizeText(record.fontId) || undefined, fontFamily: normalizeText(record.fontFamily) || undefined, fontLabel: normalizeText(record.fontLabel) || undefined, fontFileUrl: normalizeOptionalUrl(record.fontFileUrl), fontContentType: normalizeText(record.fontContentType) || undefined, fontSize: normalizePositiveNumber(record.fontSize, estimatedFontSize), lineHeight: normalizePositiveNumber(record.lineHeight, 1.2), letterSpacing: Number.isFinite(Number(record.letterSpacing)) ? Number(record.letterSpacing) : 0, fontWeight: Math.max( 100, Math.min(900, normalizePositiveInteger(record.fontWeight) || 500) ), textColor: normalizeColor(record.textColor), textAlign: normalizeAgentPsdTextAlign(record.textAlign), paddingX: normalizePositiveInteger(record.paddingX), paddingY: normalizePositiveInteger(record.paddingY), maxLines: Math.max(1, normalizePositiveInteger(record.maxLines) || 4), }); continue; } layers.push({ ...baseRecord, slotType: "image", isDefaultResultSource: isEditable && !isSuperLocked && record.isDefaultResultSource === true, allowUserUploadOverride: isEditable && !isSuperLocked ? record.allowUserUploadOverride !== false : false, replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode), contentBounds: normalizeAgentPsdTemplateImageContentBounds( record.contentBounds, width, height ), maskUrl: normalizeOptionalUrl(record.maskUrl), maskPath: normalizeText(record.maskPath) || undefined, }); } let defaultAssigned = false; return layers.map((layer) => { if (layer.slotType === "image") { if (!layer.isEditable || !layer.isDefaultResultSource) { return { ...layer, isDefaultResultSource: false, }; } if (defaultAssigned) { return { ...layer, isDefaultResultSource: false, }; } defaultAssigned = true; return layer; } return layer; }); } export function parseAgentPsdTemplates(value: unknown) { if (!Array.isArray(value)) return [] as AgentPsdTemplate[]; const templates: AgentPsdTemplate[] = []; for (const entry of value) { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { continue; } const record = entry as Record; const id = normalizeText(record.id); const name = normalizeText(record.name); const brandName = normalizeText(record.brandName) || "未分配品牌"; const fileName = normalizeText(record.fileName); const fileUrl = normalizeUrl(record.fileUrl); const previewUrl = normalizeUrl(record.previewUrl); const createdAt = toIsoDateString(record.createdAt); const updatedAt = toIsoDateString(record.updatedAt) || createdAt; const width = normalizePositiveInteger(record.width); const height = normalizePositiveInteger(record.height); const layers = parseAgentPsdTemplateLayers(record.layers); const layerCount = Math.max(layers.length, normalizePositiveInteger(record.layerCount)); if ( !id || !name || !fileName || !fileUrl || !previewUrl || !createdAt || !updatedAt || width <= 0 || height <= 0 ) { continue; } const parseStatus = normalizeAgentPsdTemplateParseStatus(record.parseStatus); const rawParseProgress = normalizePositiveInteger(record.parseProgress); const categoryTags = normalizeAgentPsdTemplateCategoryTags( record.categoryTags ?? record.productCategoryTags ); templates.push({ id, name, brandName, mode: normalizeAgentPsdTemplateMode(record.mode), secondaryCategory: normalizeText(record.secondaryCategory) || undefined, categoryTags: categoryTags.length > 0 ? categoryTags : undefined, description: normalizeText(record.description) || undefined, status: normalizeAgentPsdTemplateStatus(record.status), parseStatus, parseProgress: parseStatus === "completed" && rawParseProgress <= 0 ? 100 : Math.min(100, Math.max(0, rawParseProgress)), parseStage: normalizeText(record.parseStage) || undefined, parseError: normalizeText(record.parseError) || undefined, fileName, fileUrl, filePath: normalizeText(record.filePath) || undefined, previewUrl, previewPath: normalizeText(record.previewPath) || undefined, renderBaseUrl: normalizeOptionalUrl(record.renderBaseUrl) || normalizeOptionalUrl(record.previewUrl), renderBasePath: normalizeText(record.renderBasePath) || undefined, width, height, layerCount, layers, assetVersion: normalizePositiveInteger(record.assetVersion), createdAt, updatedAt, uploadedById: normalizeText(record.uploadedById) || undefined, }); } return templates.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } export function upsertAgentPsdTemplate( templates: AgentPsdTemplate[], template: AgentPsdTemplate ) { return [template, ...templates.filter((entry) => entry.id !== template.id)].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) ); } export function isAgentPsdTemplateReady(template: AgentPsdTemplate) { return template.parseStatus === "completed" && template.layers.length > 0; } export function hasAgentPsdTemplateStoredLayerSource( layer: Pick ) { return Boolean( String(layer.sourcePath || "").trim() || String(layer.sourceUrl || "").trim() ); } export function hasAgentPsdTemplateDedicatedRenderBase( template: Pick ) { const renderBaseUrl = String(template.renderBaseUrl || "").trim(); const previewUrl = String(template.previewUrl || "").trim(); return Boolean(String(template.renderBasePath || "").trim()) || ( Boolean(renderBaseUrl) && renderBaseUrl !== previewUrl ); } export function getAgentPsdTemplateRuntimeMissingLayers(template: AgentPsdTemplate) { const renderBaseUrl = String(template.renderBaseUrl || "").trim(); const hasUsableRenderBase = hasAgentPsdTemplateDedicatedRenderBase(template) && Boolean(renderBaseUrl) && renderBaseUrl !== String(template.previewUrl || "").trim(); return template.layers.filter( (layer) => { if (hasUsableRenderBase) { return ( isAgentPsdImageLayerSlot(layer) && isAgentPsdTemplateLayerUserEditable(layer) && !hasAgentPsdTemplateStoredLayerSource(layer) ); } return !hasAgentPsdTemplateStoredLayerSource(layer); } ); } export function getAgentPsdTemplateRuntimeIssue(template: AgentPsdTemplate) { if (!isAgentPsdTemplateReady(template)) { return "PSD 模板尚未完成解析,请先在后台重新解析后再使用"; } const missingLayers = getAgentPsdTemplateRuntimeMissingLayers(template); if (missingLayers.length === 0) { return null; } const layerNames = Array.from( new Set(missingLayers.map((layer) => String(layer.layerName || "").trim()).filter(Boolean)) ); const summarizedLayerNames = layerNames.length <= 3 ? layerNames.join("、") : `${layerNames.slice(0, 3).join("、")} 等 ${layerNames.length} 个图层`; return summarizedLayerNames ? `PSD 模板图层资源不完整(${summarizedLayerNames}),请在后台重新解析该模板后再导出` : "PSD 模板图层资源不完整,请在后台重新解析该模板后再导出"; } export function isAgentPsdTemplateRuntimeReady(template: AgentPsdTemplate) { return getAgentPsdTemplateRuntimeIssue(template) === null; } export function getEditableAgentPsdTemplateLayers(template: AgentPsdTemplate) { return template.layers.filter(isAgentPsdTemplateLayerUserEditable); } export function isAgentPsdLayerSuperLocked( layer: Pick ) { return layer.isSuperLocked === true; } export function isAgentPsdTemplateLayerUserEditable( layer: AgentPsdTemplateLayerSlot ) { return layer.isEditable && !isAgentPsdLayerSuperLocked(layer); } export function isAgentPsdTextLayerSlot( layer: AgentPsdTemplateLayerSlot ): layer is AgentPsdTemplateTextLayerSlot { return layer.slotType === "text"; } export function isAgentPsdImageLayerSlot( layer: AgentPsdTemplateLayerSlot ): layer is AgentPsdTemplateImageLayerSlot { return layer.slotType === "image"; }