diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index af553ef2f..87eda22a8 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -77,6 +77,19 @@ import { getNormalizedMinCropSize, updateCropRectWithPointer, } from "@/components/canvas/nodes/image-node/image-node-crop"; +import { + getImageNodeTitle, + guessNearestAspectRatio, + inferModelFormat, + inferTripoImageType, + normalizeImageIdentityUrl, + normalizeImageTitle, + normalizeMediaUrl, + resolveRegeneratePromptSeed, + resolveOriginalImageUrl, + sanitizeFileName, + triggerBrowserDownload, +} from "@/components/canvas/nodes/image-node/image-node-utils"; import { PromptRegenerateDialog, ThreeDGenerationDialog, @@ -118,7 +131,6 @@ import { } from "@/lib/detail-ui-subject-analysis"; import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter"; import { buildImageProxyUrl } from "@/lib/image-delivery"; -import { inferOriginalImageUrl } from "@/lib/image-url"; import { DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, getDefaultThreeDModelAliasId, @@ -853,144 +865,6 @@ async function renderAndUploadCroppedImage( } } -function inferModelFormat(modelUrl?: string | null) { - const value = String(modelUrl || "").toLowerCase(); - if (!value) return "glb"; - if (value.includes(".gltf")) return "gltf"; - if (value.includes(".glb")) return "glb"; - if (value.includes(".usdz")) return "usdz"; - if (value.includes(".fbx")) return "fbx"; - if (value.includes(".obj")) return "obj"; - if (value.includes(".stl")) return "stl"; - if (value.includes(".3mf")) return "3mf"; - return "glb"; -} - -function inferTripoImageType(imageUrl?: string | null) { - const value = String(imageUrl || "").toLowerCase(); - if (value.includes(".png")) return "png"; - if (value.includes(".webp")) return "webp"; - return "jpg"; -} - -const normalizeMediaUrl = (url: string): string => { - const raw = String(url || "").trim(); - if (!raw) return ""; - - try { - const parsed = new URL(raw); - return `${parsed.origin}${parsed.pathname}` - .replace(/\/+$/, "") - .toLowerCase(); - } catch { - return raw.split("#")[0].split("?")[0].replace(/\/+$/, "").toLowerCase(); - } -}; - -const normalizeImageIdentityUrl = (url: string): string => { - const raw = String(url || "").trim(); - if (!raw) return ""; - const canonical = inferOriginalImageUrl(raw); - return normalizeMediaUrl(canonical || raw); -}; - -const normalizeImageTitle = (raw?: string) => { - const title = (raw || "").trim(); - return title || ""; -}; - -const getImageNodeTitle = (data: ImageNodeData, nodeId: string) => { - return ( - normalizeImageTitle(data.title) || - normalizeImageTitle(data.prompt) || - `图片-${nodeId.slice(0, 6)}` - ); -}; - -const sanitizeFileName = (name: string) => { - const safe = name - .replace(/[<>:"/\\|?*\x00-\x1F]/g, "-") - .replace(/\s+/g, " ") - .trim() - .slice(0, 120); - return safe || "image"; -}; - -const triggerBrowserDownload = ( - url: string, - filename?: string, - options?: { openInNewTab?: boolean } -) => { - const link = document.createElement("a"); - link.href = url; - if (filename) { - link.download = filename; - } - if (options?.openInNewTab) { - link.target = "_blank"; - } - link.rel = "noopener noreferrer"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -}; - -const GENERIC_CANVAS_PROMPTS = new Set([ - "对话生成", - "图片生成", - "AI生成", - "智能生成", - "自动生成", -]); - -const normalizePromptText = (value: unknown): string => { - return typeof value === "string" ? value.trim() : ""; -}; - -const resolveRegeneratePromptSeed = (data: ImageNodeData): string => { - const metadataPrompt = normalizePromptText(data.metadata?.generationPrompt); - if (metadataPrompt) return metadataPrompt; - - const nodePrompt = normalizePromptText(data.prompt); - if (nodePrompt && !GENERIC_CANVAS_PROMPTS.has(nodePrompt)) { - return nodePrompt; - } - - return nodePrompt; -}; - -const COMMON_ASPECT_RATIOS = [ - "1:1", - "2:3", - "3:2", - "3:4", - "4:3", - "4:5", - "5:4", - "9:16", - "16:9", - "21:9", -] as const; - -function guessNearestAspectRatio(width: number, height: number): string { - if (!width || !height) return "auto"; - const target = width / height; - let best = "auto"; - let minDiff = Number.POSITIVE_INFINITY; - - for (const ratio of COMMON_ASPECT_RATIOS) { - const [w, h] = ratio.split(":").map(Number); - const value = w / h; - const diff = Math.abs(target - value); - if (diff < minDiff) { - minDiff = diff; - best = ratio; - } - } - - return best; -} - /** * 在图片上绘制标记点,返回带标记的图片 base64 */ @@ -1802,7 +1676,7 @@ export function ImageNode({ [data.src, displayImageWidth] ); const fullscreenPreviewSrc = useMemo(() => { - const originalUrl = inferOriginalImageUrl(data.src || ""); + const originalUrl = resolveOriginalImageUrl(data.src || ""); return buildImageProxyUrl(originalUrl || data.src || "", { delivery: "proxy", width: 2400, @@ -7040,7 +6914,7 @@ export function ImageNode({ // 下载图片 - 通过 fetch 获取图片数据实现真正的下载(避免跨域问题导致跳转) const handleDownload = async () => { if (!data.src) return; - const originalUrl = String(inferOriginalImageUrl(data.src) || data.src || "").trim(); + const originalUrl = String(resolveOriginalImageUrl(data.src) || data.src || "").trim(); if (!originalUrl) { toast.error("下载失败,请重试"); return; diff --git a/components/canvas/nodes/image-node/image-node-utils.ts b/components/canvas/nodes/image-node/image-node-utils.ts new file mode 100644 index 000000000..4cdbe54e1 --- /dev/null +++ b/components/canvas/nodes/image-node/image-node-utils.ts @@ -0,0 +1,145 @@ +import { inferOriginalImageUrl } from "@/lib/image-url"; +import type { ImageNodeData } from "@/types/canvas"; + +export function inferModelFormat(modelUrl?: string | null) { + const value = String(modelUrl || "").toLowerCase(); + if (!value) return "glb"; + if (value.includes(".gltf")) return "gltf"; + if (value.includes(".glb")) return "glb"; + if (value.includes(".usdz")) return "usdz"; + if (value.includes(".fbx")) return "fbx"; + if (value.includes(".obj")) return "obj"; + if (value.includes(".stl")) return "stl"; + if (value.includes(".3mf")) return "3mf"; + return "glb"; +} + +export function inferTripoImageType(imageUrl?: string | null) { + const value = String(imageUrl || "").toLowerCase(); + if (value.includes(".png")) return "png"; + if (value.includes(".webp")) return "webp"; + return "jpg"; +} + +export const normalizeMediaUrl = (url: string): string => { + const raw = String(url || "").trim(); + if (!raw) return ""; + + try { + const parsed = new URL(raw); + return `${parsed.origin}${parsed.pathname}` + .replace(/\/+$/, "") + .toLowerCase(); + } catch { + return raw.split("#")[0].split("?")[0].replace(/\/+$/, "").toLowerCase(); + } +}; + +export const normalizeImageIdentityUrl = (url: string): string => { + const raw = String(url || "").trim(); + if (!raw) return ""; + const canonical = inferOriginalImageUrl(raw); + return normalizeMediaUrl(canonical || raw); +}; + +export const resolveOriginalImageUrl = (url?: string | null): string => { + const raw = String(url || "").trim(); + return String(inferOriginalImageUrl(raw) || raw).trim(); +}; + +export const normalizeImageTitle = (raw?: string) => { + const title = (raw || "").trim(); + return title || ""; +}; + +export const getImageNodeTitle = (data: ImageNodeData, nodeId: string) => { + return ( + normalizeImageTitle(data.title) || + normalizeImageTitle(data.prompt) || + `图片-${nodeId.slice(0, 6)}` + ); +}; + +export const sanitizeFileName = (name: string) => { + const safe = name + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "-") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120); + return safe || "image"; +}; + +export const triggerBrowserDownload = ( + url: string, + filename?: string, + options?: { openInNewTab?: boolean } +) => { + const link = document.createElement("a"); + link.href = url; + if (filename) { + link.download = filename; + } + if (options?.openInNewTab) { + link.target = "_blank"; + } + link.rel = "noopener noreferrer"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; + +const GENERIC_CANVAS_PROMPTS = new Set([ + "对话生成", + "图片生成", + "AI生成", + "智能生成", + "自动生成", +]); + +export const normalizePromptText = (value: unknown): string => { + return typeof value === "string" ? value.trim() : ""; +}; + +export const resolveRegeneratePromptSeed = (data: ImageNodeData): string => { + const metadataPrompt = normalizePromptText(data.metadata?.generationPrompt); + if (metadataPrompt) return metadataPrompt; + + const nodePrompt = normalizePromptText(data.prompt); + if (nodePrompt && !GENERIC_CANVAS_PROMPTS.has(nodePrompt)) { + return nodePrompt; + } + + return nodePrompt; +}; + +const COMMON_ASPECT_RATIOS = [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +] as const; + +export function guessNearestAspectRatio(width: number, height: number): string { + if (!width || !height) return "auto"; + const target = width / height; + let best = "auto"; + let minDiff = Number.POSITIVE_INFINITY; + + for (const ratio of COMMON_ASPECT_RATIOS) { + const [w, h] = ratio.split(":").map(Number); + const value = w / h; + const diff = Math.abs(target - value); + if (diff < minDiff) { + minDiff = diff; + best = ratio; + } + } + + return best; +}