Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
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, {
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
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, {
|
||||
timeoutMs: 30 * 60 * 1000,
|
||||
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"),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user