refactor(canvas-project): extract pure helpers into @/lib/canvas modules
Move the ~30 module-scope pure functions out of the 3.5k-line project.tsx into three focused lib modules, with identical names and signatures. No behavior change; project.tsx imports them back. - canvas-node-factory.ts: createCanvasNode, *Metadata builders, referenceUrl, applyNodeConfigPatch. - canvas-node-geometry.ts: nodeBounds, group drop/snap, connection anchors, normalizeConnection, hidden-batch checks. - canvas-generation-helpers.ts: generation config/inputs, reference hydration, retry-source lookup, angle prompt builders, misc pure helpers. project.tsx drops from 3536 to ~3230 lines. Also removes imports that were only used by the moved helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
57db0fe752
commit
cf1ea2646f
@@ -0,0 +1,159 @@
|
||||
import { defaultConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { imageMetadata, referenceUrl } from "@/lib/canvas/canvas-node-factory";
|
||||
import type { NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
|
||||
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
|
||||
|
||||
export function imageExtension(dataUrl: string) {
|
||||
return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png";
|
||||
}
|
||||
|
||||
export function audioExtension(mimeType?: string) {
|
||||
if (mimeType?.includes("wav")) return "wav";
|
||||
if (mimeType?.includes("opus")) return "opus";
|
||||
if (mimeType?.includes("aac")) return "aac";
|
||||
if (mimeType?.includes("flac")) return "flac";
|
||||
if (mimeType?.includes("pcm")) return "pcm";
|
||||
return "mp3";
|
||||
}
|
||||
|
||||
export function generationReferenceUrls(context: { referenceImages: ReferenceImage[]; referenceVideos: Array<{ storageKey?: string; url?: string }>; referenceAudios?: Array<{ storageKey?: string; url?: string }> }) {
|
||||
return [
|
||||
...context.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)),
|
||||
...context.referenceVideos.map((video) => video.storageKey || video.url).filter((url): url is string => Boolean(url)),
|
||||
...(context.referenceAudios || []).map((audio) => audio.storageKey || audio.url).filter((url): url is string => Boolean(url)),
|
||||
];
|
||||
}
|
||||
|
||||
export async function resolveMetadataReferences(metadata: CanvasNodeMetadata) {
|
||||
if (metadata.generationType !== "edit") return [];
|
||||
if (!metadata.references?.length) return null;
|
||||
const references = await Promise.all(
|
||||
metadata.references.map(async (url, index) => {
|
||||
const dataUrl = url.startsWith("image:") ? await resolveImageUrl(url, "") : url;
|
||||
return dataUrl ? { id: `${index}`, name: `reference-${index}.png`, type: "image/png", dataUrl, storageKey: url.startsWith("image:") ? url : undefined } : null;
|
||||
}),
|
||||
);
|
||||
return references.every(Boolean) ? (references as ReferenceImage[]) : null;
|
||||
}
|
||||
|
||||
export async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
|
||||
return Promise.all(
|
||||
nodes.map(async (node) => {
|
||||
const content = node.metadata?.content;
|
||||
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } };
|
||||
if (node.type !== CanvasNodeType.Image || !content) return node;
|
||||
if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } };
|
||||
if (!content.startsWith("data:image/")) return node;
|
||||
return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) {
|
||||
const hydrateItem = async <T extends { dataUrl?: string; storageKey?: string }>(item: T) => {
|
||||
if (item.storageKey) return { ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl) };
|
||||
if (item.dataUrl?.startsWith("data:image/")) {
|
||||
const image = await uploadImage(item.dataUrl);
|
||||
return { ...item, dataUrl: image.url, storageKey: image.storageKey };
|
||||
}
|
||||
return item;
|
||||
};
|
||||
return Promise.all(
|
||||
sessions.map(async (session) => ({
|
||||
...session,
|
||||
messages: await Promise.all(
|
||||
session.messages.map(async (message) => ({
|
||||
...message,
|
||||
references: await Promise.all((message.references || []).map(hydrateItem)),
|
||||
})),
|
||||
),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function getGenerationCount(count: string) {
|
||||
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1)));
|
||||
}
|
||||
|
||||
export function getInputSummary(inputs: NodeGenerationInput[]) {
|
||||
return {
|
||||
textCount: inputs.filter((input) => input.type === "text").length,
|
||||
imageCount: inputs.filter((input) => input.type === "image").length,
|
||||
videoCount: inputs.filter((input) => input.type === "video").length,
|
||||
audioCount: inputs.filter((input) => input.type === "audio").length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
|
||||
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
|
||||
return {
|
||||
...config,
|
||||
model: node?.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : config.model || defaultConfig.model),
|
||||
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
|
||||
size: node?.metadata?.size || config.size || defaultConfig.size,
|
||||
background: node?.metadata?.background ?? config.background ?? defaultConfig.background,
|
||||
videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark,
|
||||
audioVoice: node?.metadata?.audioVoice || config.audioVoice || defaultConfig.audioVoice,
|
||||
audioFormat: node?.metadata?.audioFormat || config.audioFormat || defaultConfig.audioFormat,
|
||||
audioSpeed: node?.metadata?.audioSpeed || config.audioSpeed || defaultConfig.audioSpeed,
|
||||
audioInstructions: node?.metadata?.audioInstructions || config.audioInstructions || defaultConfig.audioInstructions,
|
||||
count: String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
|
||||
return nodes.map((node) => (node.metadata?.status === "loading" ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: "页面刷新后生成已中断,请重新生成。" } } : node));
|
||||
}
|
||||
|
||||
export function isGenerationCanceled(error: unknown) {
|
||||
return error instanceof Error && (error.message === "请求已取消" || error.name === "AbortError");
|
||||
}
|
||||
|
||||
export function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId);
|
||||
const visited = new Set<string>();
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!;
|
||||
if (visited.has(id)) continue;
|
||||
visited.add(id);
|
||||
const node = nodes.find((item) => item.id === id);
|
||||
if (node?.type === CanvasNodeType.Config) return node;
|
||||
connections.filter((connection) => connection.toNodeId === id).forEach((connection) => queue.push(connection.fromNodeId));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sourceNodeReferenceImages(node: CanvasNodeData | null) {
|
||||
if (!node || node.type !== CanvasNodeType.Image || !node.metadata?.content) return [];
|
||||
return [
|
||||
{
|
||||
id: node.id,
|
||||
name: `${node.title || node.id}.png`,
|
||||
type: node.metadata.mimeType || "image/png",
|
||||
dataUrl: node.metadata.content,
|
||||
storageKey: node.metadata.storageKey,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function isAudioFile(file: File) {
|
||||
return file.type.startsWith("audio/") || /\.(mp3|wav)$/i.test(file.name);
|
||||
}
|
||||
|
||||
export function buildAngleLabel(params: CanvasImageAngleParams) {
|
||||
const horizontal = params.horizontalAngle === 0 ? "正面视角" : params.horizontalAngle > 0 ? `向右旋转 ${params.horizontalAngle} 度` : `向左旋转 ${Math.abs(params.horizontalAngle)} 度`;
|
||||
const pitch = params.pitchAngle === 0 ? "水平视角" : params.pitchAngle > 0 ? `俯视 ${params.pitchAngle} 度` : `仰视 ${Math.abs(params.pitchAngle)} 度`;
|
||||
return `AI 多角度:${horizontal},${pitch},镜头距离 ${params.cameraDistance.toFixed(1)},${params.wideAngle ? "广角" : "标准"}镜头`;
|
||||
}
|
||||
|
||||
export function buildAnglePrompt(params: CanvasImageAngleParams) {
|
||||
return `基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。${buildAngleLabel(params)}。`;
|
||||
}
|
||||
Reference in New Issue
Block a user