export const AGENT_PSD_TEMPLATES_KEY = "agent_psd_templates"; export type AgentPsdTemplateStatus = "active" | "disabled"; export type AgentPsdTemplateParseStatus = | "pending" | "processing" | "completed" | "failed"; export type AgentPsdTemplateReplaceMode = "cover" | "contain" | "stretch"; export type AgentPsdTemplateSlotType = "image" | "text"; export type AgentPsdTextAlign = "left" | "center" | "right"; export interface AgentPsdTemplateBaseLayerSlot { id: string; layerId: string; layerName: string; layerPath: string; depth: number; x: number; y: number; width: number; height: number; isEditable: boolean; isRequired: boolean; previewUrl?: string; previewPath?: string; } export interface AgentPsdTemplateImageLayerSlot extends AgentPsdTemplateBaseLayerSlot { slotType: "image"; isDefaultResultSource: boolean; allowUserUploadOverride: boolean; replaceMode: AgentPsdTemplateReplaceMode; maskUrl?: string; maskPath?: string; } export interface AgentPsdTemplateTextLayerSlot extends AgentPsdTemplateBaseLayerSlot { slotType: "text"; 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; 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[]; createdAt: string; updatedAt: string; uploadedById?: string; } export interface AgentPsdTemplateListResponse { success: boolean; templates: AgentPsdTemplate[]; error?: string; } export interface AgentPsdTemplateMutationResponse { success: boolean; template?: AgentPsdTemplate; error?: string; } export interface AgentPsdTemplateRenderResult { url: string; pathname: string; contentType?: string; } export interface AgentPsdTemplateRenderResponse { success: boolean; 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 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"; } 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 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 baseRecord = { id, layerId, layerName, layerPath, depth: Math.max(0, normalizePositiveInteger(record.depth)), x: normalizeInteger(record.x), y: normalizeInteger(record.y), width, height, isEditable, isRequired: isEditable && record.isRequired === true, previewUrl: normalizeOptionalUrl(record.previewUrl), previewPath: normalizeText(record.previewPath) || undefined, } satisfies AgentPsdTemplateBaseLayerSlot; if (slotType === "text") { const estimatedFontSize = Math.max(16, Math.min(height, Math.round(height * 0.78))); layers.push({ ...baseRecord, slotType: "text", 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 && record.isDefaultResultSource === true, allowUserUploadOverride: isEditable ? record.allowUserUploadOverride !== false : false, replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode), 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); templates.push({ id, name, brandName, 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, 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 getEditableAgentPsdTemplateLayers(template: AgentPsdTemplate) { return template.layers.filter((layer) => layer.isEditable); } export function isAgentPsdTextLayerSlot( layer: AgentPsdTemplateLayerSlot ): layer is AgentPsdTemplateTextLayerSlot { return layer.slotType === "text"; } export function isAgentPsdImageLayerSlot( layer: AgentPsdTemplateLayerSlot ): layer is AgentPsdTemplateImageLayerSlot { return layer.slotType === "image"; }