178a2c47da
Centralize timeout policies, stall detection, and error classification for image/video/text generation tasks. Improve ecommerce OSS upload flow and add script evaluation enhancements. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
499 lines
18 KiB
TypeScript
499 lines
18 KiB
TypeScript
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<T extends { id: string; position: CanvasPoint }>(
|
|
currentNodes: T[],
|
|
originPositions: Record<string, CanvasPoint>,
|
|
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<string, unknown> }).params;
|
|
const paramValue = params?.[key];
|
|
if (typeof paramValue === "string" && paramValue.trim()) return paramValue.trim();
|
|
const metadata = (node as unknown as { metadata?: Record<string, unknown> }).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<string, unknown> }).params;
|
|
const paramValue = params?.[key];
|
|
if (typeof paramValue === "string") return paramValue;
|
|
const metadata = (node as unknown as { metadata?: Record<string, unknown> }).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<void>((resolve) => {
|
|
window.setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
export function blobToDataUrl(blob: Blob) {
|
|
return new Promise<string>((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<CanvasStyleCase, "keywords">, 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<CanvasStyleCase, "keywords"> = {
|
|
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<string, unknown> }).metadata;
|
|
const rawStyle = metadata?.styleReference;
|
|
if (!rawStyle || typeof rawStyle !== "object" || Array.isArray(rawStyle)) return undefined;
|
|
const style = rawStyle as Record<string, unknown>;
|
|
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<string, unknown> }).metadata;
|
|
const rawSelection = metadata?.focusSelection;
|
|
if (!rawSelection || typeof rawSelection !== "object" || Array.isArray(rawSelection)) return undefined;
|
|
const selection = rawSelection as Record<string, unknown>;
|
|
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"),
|
|
};
|
|
}
|