Files
MiragenFlow/web/src/lib/canvas/canvas-generation-helpers.ts
T
HouYunFeiandClaude Opus 4.8 cf1ea2646f 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>
2026-07-17 13:46:07 +08:00

160 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)}。`;
}