import type { CSSProperties } from "react"; import { aiGenerationClient, type AiTaskStatus } from "../../api/aiGenerationClient"; import type { ServerCommunityCase } from "../../api/communityClient"; import { waitForTask } from "../../api/taskSubscription"; import type { WebCanvasWorkflow } from "../../types"; import type { AssetLibraryCategory } from "../assets/localAssetStore"; import { communityCaseToPromptCase, getCommunityCaseCover } from "../community/communityCaseUtils"; import { toHappyHorseDisplayModel } from "../../utils/happyHorseRouting"; import { toViduDisplayModel } from "../../utils/viduRouting"; import { toPixverseDisplayModel } from "../../utils/pixverseRouting"; import type { CanvasImageFocusSelection, CanvasNodeKind, CanvasNodePort, CanvasNodeSide, CanvasNodeSize, CanvasOption, CanvasPoint, CanvasSelectedNode, CanvasStyleCase, CanvasStyleReference, CanvasVideoMode, } from "./canvasTypes"; import { assetLibraryCategories, assetTypePromptLabel, canvasNodeDefaultSizes, canvasNodeMaxSizes, canvasNodeMinSizes, canvasVideoModes, canvasViewportMaxZoom, canvasViewportMinZoom, defaultImageModel, defaultVideoModel, imageModelOptions, videoModelOptions, } from "./canvasConstants"; // ─── Utility Functions ───────────────────────────────────────────────────────── export function getCanvasNodeSideDirection(side: CanvasNodeSide) { return side === "right" ? 1 : -1; } export function normalizeCanvasLinkPorts(first: CanvasNodePort, second: CanvasNodePort) { if (first.side === second.side) return null; if (first.kind === second.kind && first.nodeId === second.nodeId) return null; return first.side === "right" ? { from: first, to: second } : { from: second, to: first }; } export function getCanvasPortIdentity(port: CanvasNodePort) { return `${port.kind}:${port.nodeId}:${port.side}:${port.slot}`; } export function getCanvasLinkIdentity(from: CanvasNodePort, to: CanvasNodePort) { return `${getCanvasPortIdentity(from)}->${getCanvasPortIdentity(to)}`; } export function moveCanvasNodesForPackageDrag( currentNodes: T[], originPositions: Record, delta: CanvasPoint ): T[] { return currentNodes.map((node) => { const origin = originPositions[node.id]; if (!origin) return node; return { ...node, position: { x: origin.x + delta.x, y: origin.y + delta.y, }, }; }); } export function createCanvasNodeSize(kind: CanvasNodeKind) { return { ...canvasNodeDefaultSizes[kind] }; } export function clampCanvasNodeSize(kind: CanvasNodeKind, width: number, height: number): CanvasNodeSize { const minSize = canvasNodeMinSizes[kind]; const maxSize = canvasNodeMaxSizes[kind]; return { width: Math.min(maxSize.width, Math.max(minSize.width, Math.round(width))), height: Math.min(maxSize.height, Math.max(minSize.height, Math.round(height))), }; } export function getCanvasSelectionKey(node: CanvasSelectedNode) { return `${node.kind}:${node.id}`; } export function normalizeCanvasSelectionRect(start: CanvasPoint, current: CanvasPoint) { const left = Math.min(start.x, current.x); const top = Math.min(start.y, current.y); const width = Math.abs(current.x - start.x); const height = Math.abs(current.y - start.y); return { left, top, width, height }; } export function doCanvasRectsIntersect( first: { left: number; top: number; width: number; height: number }, second: { left: number; top: number; width: number; height: number } ) { return ( first.left <= second.left + second.width && first.left + first.width >= second.left && first.top <= second.top + second.height && first.top + first.height >= second.top ); } export function clampCanvasViewportZoom(zoom: number) { return Math.min(canvasViewportMaxZoom, Math.max(canvasViewportMinZoom, zoom)); } export function getOptionLabel(options: CanvasOption[], value: string) { return options.find((option) => option.value === value)?.label || value; } import { getImageQualityOptions, getDefaultImageQuality, getVideoQualityOptions, getDefaultVideoQuality, getVideoQualityLabel, } from "../../utils/modelOptions"; export { getImageQualityOptions, getDefaultImageQuality, getVideoQualityOptions, getDefaultVideoQuality, getVideoQualityLabel, }; export function hasCanvasOptionValue(options: CanvasOption[], value: string | undefined) { return Boolean(value && options.some((option) => option.value === value)); } export function getWorkflowNodeMetadataString( node: WebCanvasWorkflow["nodes"][number], key: string, fallback = "", ) { const params = (node as unknown as { params?: Record }).params; const paramValue = params?.[key]; if (typeof paramValue === "string" && paramValue.trim()) return paramValue.trim(); const metadata = (node as unknown as { metadata?: Record }).metadata; const value = metadata?.[key]; return typeof value === "string" && value.trim() ? value.trim() : fallback; } export function getWorkflowNodeStringField( node: WebCanvasWorkflow["nodes"][number], key: string, ): string | undefined { const params = (node as unknown as { params?: Record }).params; const paramValue = params?.[key]; if (typeof paramValue === "string") return paramValue; const metadata = (node as unknown as { metadata?: Record }).metadata; const value = metadata?.[key]; return typeof value === "string" ? value : undefined; } export function isLikelyImageFileName(value: string) { const trimmed = value.trim(); if (!trimmed) return false; return /\.(png|jpe?g|webp)(\?.*)?$/i.test(trimmed); } export function getWorkflowImageNodePrompt(node: WebCanvasWorkflow["nodes"][number]) { const storedPrompt = getWorkflowNodeStringField(node, "prompt"); if (storedPrompt !== undefined) return storedPrompt; const detail = node.detail || ""; return isLikelyImageFileName(detail) ? "" : detail; } export function getWorkflowImageNodeFileName(node: WebCanvasWorkflow["nodes"][number], index: number) { const storedFileName = getWorkflowNodeStringField(node, "fileName"); if (storedFileName?.trim()) return storedFileName.trim(); if (isLikelyImageFileName(node.detail || "")) return node.detail.trim(); return node.label || `图片节点 ${index + 1}`; } export function resolveWorkflowImageModel(node: WebCanvasWorkflow["nodes"][number], workflowModel: string) { const storedModel = getWorkflowNodeMetadataString(node, "model"); if (hasCanvasOptionValue(imageModelOptions, storedModel)) return storedModel; if (hasCanvasOptionValue(imageModelOptions, workflowModel)) return workflowModel; return defaultImageModel; } export function resolveWorkflowVideoModel(node: WebCanvasWorkflow["nodes"][number], workflowModel: string) { const raw = getWorkflowNodeMetadataString(node, "model"); const storedModel = toPixverseDisplayModel(toViduDisplayModel(toHappyHorseDisplayModel(raw))); if (hasCanvasOptionValue(videoModelOptions, storedModel)) return storedModel; return defaultVideoModel; } export function resolveWorkflowRatio( node: WebCanvasWorkflow["nodes"][number], workflowRatio: string, options: CanvasOption[], fallback: string, ) { const storedRatio = getWorkflowNodeMetadataString(node, "aspectRatio"); if (hasCanvasOptionValue(options, storedRatio)) return storedRatio; if (hasCanvasOptionValue(options, workflowRatio)) return workflowRatio; return fallback; } export function resolveWorkflowVideoMode(node: WebCanvasWorkflow["nodes"][number]) { const storedMode = getWorkflowNodeMetadataString(node, "videoMode"); return canvasVideoModes.includes(storedMode as CanvasVideoMode) ? (storedMode as CanvasVideoMode) : "text2video"; } export function resolveImageQuality(model: string, imageSize: string) { const options = getImageQualityOptions(model || defaultImageModel); return options.some((option) => option.value === imageSize) ? imageSize : getDefaultImageQuality(model || defaultImageModel); } export function resolveVideoQuality(model: string, resolution: string) { const options = getVideoQualityOptions(model || defaultVideoModel); return options.some((option) => option.value === resolution) ? resolution : getDefaultVideoQuality(model || defaultVideoModel); } export function delay(ms: number) { return new Promise((resolve) => { window.setTimeout(resolve, ms); }); } export function blobToDataUrl(blob: Blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read canvas image")); }; reader.onerror = () => reject(reader.error || new Error("Unable to read canvas image")); reader.readAsDataURL(blob); }); } export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) { const resultUrl = await waitForTask(taskId, { kind: "image", onProgress: (e) => { onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); }, }); if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试"); return resultUrl; } export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) { const resultUrl = await waitForTask(taskId, { kind: "video", onProgress: (e) => { onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); }, }); if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试"); return resultUrl; } export function normalizeCanvasGenerationProgress(state?: { status: "submitting" | "running" | "success" | "error"; progress?: number }) { if (!state) return 0; if (state.status === "success") return 100; if (state.status === "error") return 0; const serverProgress = Number(state.progress || 0); if (state.status === "submitting") return Math.max(6, Math.min(18, serverProgress || 8)); return Math.max(18, Math.min(96, serverProgress || 36)); } export function canvasGenerationProgressStyle(progress: number) { return { "--canvas-generation-progress": `${Math.max(0, Math.min(100, progress)) * 3.6}deg`, } as CSSProperties; } export function buildCopyTitle(title: string) { return title.includes("副本") ? title : `${title} 副本`; } export function buildReversePromptFromAsset(asset: { type: AssetLibraryCategory; name: string; description: string }) { return `AI 反推提示词:${assetTypePromptLabel[asset.type]}「${asset.name}」,${asset.description},保留主体轮廓、材质细节、色彩氛围和镜头语言,电影感构图,高质量细节。`; } export function positionFloatingMenu(clientX: number, clientY: number, width: number, height: number, offset = 0) { const edgeGap = 8; const canOpenRight = clientX + offset + width + edgeGap <= window.innerWidth; const canOpenBelow = clientY + offset + height + edgeGap <= window.innerHeight; return { left: Math.max(edgeGap, canOpenRight ? clientX + offset : clientX - width - offset), top: Math.max(edgeGap, canOpenBelow ? clientY + offset : clientY - height - offset), }; } export function toCanvasStyleString(value: unknown, fallback = ""): string { if (typeof value === "string") return value.trim() || fallback; if (typeof value === "number" && Number.isFinite(value)) return String(value); return fallback; } export function buildCanvasStyleKeywords(item: Omit, tags: string[] = []) { return [ item.title, item.author, item.category, item.summary, item.prompt || "", ...tags, ] .join(" ") .toLowerCase(); } export function communityCaseToCanvasStyleCase(item: ServerCommunityCase): CanvasStyleCase | null { const promptCase = communityCaseToPromptCase(item); const imageUrl = promptCase?.imageUrl || getCommunityCaseCover(item); if (!imageUrl) return null; const metadata = item.metadata || {}; const title = promptCase?.title || item.title; const author = promptCase?.author || item.username || "OmniAI"; const category = promptCase?.category || toCanvasStyleString(metadata.category ?? metadata.styleCategory) || item.tags.find(Boolean) || "社区风格"; const prompt = promptCase?.prompt || toCanvasStyleString(metadata.prompt ?? metadata.inputPrompt ?? metadata.descriptionPrompt, item.description || ""); const summary = promptCase?.summary || item.description || prompt || "来自社区广场的风格参考"; const baseCase: Omit = { id: promptCase?.id || `server-style-case-${item.id}`, title, author, category, imageUrl, prompt, summary, favoriteCount: item.favoriteCount, likeCount: item.likeCount, isFavorited: item.isFavorited, }; return { ...baseCase, keywords: buildCanvasStyleKeywords(baseCase, item.tags), }; } export function createStyleReferenceFromCase(item: CanvasStyleCase): CanvasStyleReference { return { id: item.id, title: item.title, author: item.author, category: item.category, imageUrl: item.imageUrl, prompt: item.prompt, }; } export function clampCanvasPercent(value: number) { return Math.min(100, Math.max(0, value)); } export function parseCanvasRatio(value: string): number | null { const [width, height] = value.split(":").map(Number); return width > 0 && height > 0 ? width / height : null; } export function normalizeImageFocusSelectionFromAnchor( start: { x: number; y: number }, end: { x: number; y: number }, ratio: string, containerRatio: number, ): CanvasImageFocusSelection { const targetRatio = parseCanvasRatio(ratio); const directionX = end.x >= start.x ? 1 : -1; const directionY = end.y >= start.y ? 1 : -1; let width = Math.abs(end.x - start.x); let height = Math.abs(end.y - start.y); if (targetRatio && Number.isFinite(containerRatio) && containerRatio > 0 && width > 0 && height > 0) { const percentRatio = targetRatio / containerRatio; if (width / height > percentRatio) { width = height * percentRatio; } else { height = width / percentRatio; } } const maxWidth = directionX > 0 ? 100 - start.x : start.x; const maxHeight = directionY > 0 ? 100 - start.y : start.y; width = Math.min(width, maxWidth); height = Math.min(height, maxHeight); if (targetRatio && Number.isFinite(containerRatio) && containerRatio > 0 && width > 0 && height > 0) { const percentRatio = targetRatio / containerRatio; if (width / height > percentRatio) { width = height * percentRatio; } else { height = width / percentRatio; } } return { x: clampCanvasPercent(directionX > 0 ? start.x : start.x - width), y: clampCanvasPercent(directionY > 0 ? start.y : start.y - height), width: clampCanvasPercent(width), height: clampCanvasPercent(height), ratio, }; } export function applyImageFocusRatioFromTopLeft( selection: CanvasImageFocusSelection, ratio: string, containerRatio: number, ): CanvasImageFocusSelection { const targetRatio = parseCanvasRatio(ratio); if (!targetRatio || !Number.isFinite(containerRatio) || containerRatio <= 0) { return { ...selection, ratio }; } const percentRatio = targetRatio / containerRatio; let width = Math.max(4, selection.width); let height = Math.max(4, selection.height); if (width / height > percentRatio) { width = height * percentRatio; } else { height = width / percentRatio; } const maxWidth = Math.max(4, 100 - selection.x); const maxHeight = Math.max(4, 100 - selection.y); if (width > maxWidth) { width = maxWidth; height = width / percentRatio; } if (height > maxHeight) { height = maxHeight; width = height * percentRatio; } return { ...selection, width: clampCanvasPercent(width), height: clampCanvasPercent(height), ratio, }; } export function getWorkflowNodeStyleReference(node: WebCanvasWorkflow["nodes"][number]): CanvasStyleReference | undefined { const metadata = (node as unknown as { metadata?: Record }).metadata; const rawStyle = metadata?.styleReference; if (!rawStyle || typeof rawStyle !== "object" || Array.isArray(rawStyle)) return undefined; const style = rawStyle as Record; const imageUrl = toCanvasStyleString(style.imageUrl); const title = toCanvasStyleString(style.title); if (!imageUrl || !title) return undefined; return { id: toCanvasStyleString(style.id, `workflow-style-${node.id}`), title, author: toCanvasStyleString(style.author, "OmniAI"), category: toCanvasStyleString(style.category, "社区风格"), imageUrl, prompt: toCanvasStyleString(style.prompt), }; } export function getWorkflowNodeFocusSelection(node: WebCanvasWorkflow["nodes"][number]): CanvasImageFocusSelection | undefined { const metadata = (node as unknown as { metadata?: Record }).metadata; const rawSelection = metadata?.focusSelection; if (!rawSelection || typeof rawSelection !== "object" || Array.isArray(rawSelection)) return undefined; const selection = rawSelection as Record; const width = Number(selection.width); const height = Number(selection.height); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return undefined; return { x: clampCanvasPercent(Number(selection.x) || 0), y: clampCanvasPercent(Number(selection.y) || 0), width: clampCanvasPercent(width), height: clampCanvasPercent(height), ratio: toCanvasStyleString(selection.ratio, "16:9"), }; }