refactor: extract image node utility helpers

This commit is contained in:
Your Name 2026-05-27 10:02:11 +08:00
parent 0950e6827b
commit 2bcb975606
2 changed files with 160 additions and 141 deletions

View File

@ -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;

View File

@ -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;
}