Initial commit: OmniAI Web Frontend

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:38:01 +08:00
commit bedee3ba8d
183 changed files with 94805 additions and 0 deletions
+498
View File
@@ -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"),
};
}