feat(*): 画布创作体验 - 完善 Agent 引导、生成防重、搜索快捷键、素材多选与分辨率兼容
This commit is contained in:
1 parent
089b49f881
commit
e99ddf3a81
52 files changed
+2344
-500
No files matched your search
@@ -0,0 +1,78 @@
|
||||
import { canonicalize } from "json-canonicalize";
|
||||
|
||||
import type { NodeGenerationContext } from "@/components/canvas/canvas-node-generation";
|
||||
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
import { generationPromptFingerprint } from "@/lib/generation-error";
|
||||
|
||||
type GenerationReference = NodeGenerationContext["referenceImages"][number] | NodeGenerationContext["referenceVideos"][number] | NodeGenerationContext["referenceAudios"][number];
|
||||
|
||||
export type CanvasGenerationRequestFingerprintInput = {
|
||||
nodeId: string;
|
||||
mode: CanvasNodeGenerationMode;
|
||||
prompt: string;
|
||||
model: string;
|
||||
options: Record<string, unknown>;
|
||||
workflow?: {
|
||||
provider?: string;
|
||||
workflowId?: string;
|
||||
workflowKind?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
};
|
||||
operation?: string;
|
||||
audioInstructions?: string;
|
||||
promptTemplateOperation?: string;
|
||||
promptTemplateVariables?: Record<string, string>;
|
||||
context: Pick<NodeGenerationContext, "referenceImages" | "referenceVideos" | "referenceAudios" | "characterReferences" | "resolvedCharacterVersions" | "resolvedCharacterVoices">;
|
||||
};
|
||||
|
||||
export function canvasGenerationRequestFingerprint(input: CanvasGenerationRequestFingerprintInput) {
|
||||
const serialized = canonicalize({
|
||||
version: 1,
|
||||
nodeId: input.nodeId,
|
||||
mode: input.mode,
|
||||
prompt: input.prompt.trim(),
|
||||
model: input.model,
|
||||
options: input.options,
|
||||
workflow: input.workflow,
|
||||
operation: input.operation,
|
||||
audioInstructions: input.audioInstructions,
|
||||
promptTemplateOperation: input.promptTemplateOperation,
|
||||
promptTemplateVariables: input.promptTemplateVariables,
|
||||
references: {
|
||||
images: input.context.referenceImages.map(referenceIdentity),
|
||||
videos: input.context.referenceVideos.map(referenceIdentity),
|
||||
audios: input.context.referenceAudios.map(referenceIdentity),
|
||||
characters: input.context.characterReferences,
|
||||
resolvedCharacterVersions: input.context.resolvedCharacterVersions,
|
||||
resolvedCharacterVoices: input.context.resolvedCharacterVoices,
|
||||
},
|
||||
});
|
||||
return `canvas-generation:v1:${generationPromptFingerprint(serialized)}`;
|
||||
}
|
||||
|
||||
function referenceIdentity(reference: GenerationReference) {
|
||||
const source = "storageKey" in reference && reference.storageKey ? reference.storageKey : "url" in reference && reference.url ? reference.url : "dataUrl" in reference ? reference.dataUrl : "";
|
||||
return {
|
||||
id: reference.id,
|
||||
name: reference.name,
|
||||
type: reference.type,
|
||||
source: source.startsWith("data:") ? `data:${generationPromptFingerprint(source)}` : source,
|
||||
derivedSource: "source" in reference ? reference.source : undefined,
|
||||
durationMs: "durationMs" in reference ? reference.durationMs : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function runCanvasGenerationSubmissionOnce<T>(locks: Map<string, Promise<unknown>>, nodeId: string, operation: () => Promise<T>, onDuplicate?: () => void): Promise<T> {
|
||||
const existing = locks.get(nodeId) as Promise<T> | undefined;
|
||||
if (existing) {
|
||||
onDuplicate?.();
|
||||
return existing;
|
||||
}
|
||||
const running = Promise.resolve()
|
||||
.then(operation)
|
||||
.finally(() => {
|
||||
if (locks.get(nodeId) === running) locks.delete(nodeId);
|
||||
});
|
||||
locks.set(nodeId, running);
|
||||
return running;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas";
|
||||
|
||||
const FILE_NAME_PART_LIMIT = 80;
|
||||
|
||||
const MIME_EXTENSIONS: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"image/svg+xml": "svg",
|
||||
"video/mp4": "mp4",
|
||||
"video/webm": "webm",
|
||||
"video/quicktime": "mov",
|
||||
"video/ogg": "ogv",
|
||||
"video/mpeg": "mpeg",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/mp4": "m4a",
|
||||
"audio/wav": "wav",
|
||||
"audio/x-wav": "wav",
|
||||
"audio/aac": "aac",
|
||||
"audio/flac": "flac",
|
||||
"audio/ogg": "ogg",
|
||||
"audio/opus": "opus",
|
||||
};
|
||||
|
||||
const CONTENT_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "gif", "svg", "mp4", "webm", "mov", "ogv", "ogg", "mpeg", "mpg", "mp3", "m4a", "wav", "aac", "flac", "opus"]);
|
||||
|
||||
export function buildCanvasMediaDownloadFileName(canvasTitle: string, node: CanvasNodeData, now = new Date()) {
|
||||
const canvasName = safeFileNamePart(canvasTitle, "未命名画布");
|
||||
const nodeName = safeFileNamePart(node.title, defaultNodeName(node.type));
|
||||
return `${canvasName}_${nodeName}_${formatDownloadDate(now)}.${canvasMediaFileExtension(node)}`;
|
||||
}
|
||||
|
||||
export function canvasMediaFileExtension(node: CanvasNodeData) {
|
||||
return extensionFromMimeType(node.metadata?.mimeType)
|
||||
|| extensionFromContent(node.metadata?.content)
|
||||
|| defaultExtension(node.type);
|
||||
}
|
||||
|
||||
function extensionFromMimeType(mimeType?: string) {
|
||||
const normalized = mimeType?.split(";", 1)[0]?.trim().toLowerCase();
|
||||
return normalized ? MIME_EXTENSIONS[normalized] : undefined;
|
||||
}
|
||||
|
||||
function extensionFromContent(content?: string) {
|
||||
if (!content) return undefined;
|
||||
const dataMimeType = content.match(/^data:([^;,]+)/i)?.[1];
|
||||
const dataExtension = extensionFromMimeType(dataMimeType);
|
||||
if (dataExtension) return dataExtension;
|
||||
const path = content.split(/[?#]/, 1)[0];
|
||||
const extension = path.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase();
|
||||
if (!extension || !CONTENT_EXTENSIONS.has(extension)) return undefined;
|
||||
if (extension === "jpeg") return "jpg";
|
||||
if (extension === "mpg") return "mpeg";
|
||||
return extension;
|
||||
}
|
||||
|
||||
function safeFileNamePart(value: string, fallback: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, FILE_NAME_PART_LIMIT)
|
||||
.replace(/[. ]+$/g, "");
|
||||
if (!normalized) return fallback;
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(normalized) ? `_${normalized}` : normalized;
|
||||
}
|
||||
|
||||
function formatDownloadDate(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
function defaultNodeName(type: CanvasNodeTypeId) {
|
||||
if (type === CanvasNodeType.Video) return "视频节点";
|
||||
if (type === CanvasNodeType.Audio) return "音频节点";
|
||||
return "图片节点";
|
||||
}
|
||||
|
||||
function defaultExtension(type: CanvasNodeTypeId) {
|
||||
if (type === CanvasNodeType.Video) return "mp4";
|
||||
if (type === CanvasNodeType.Audio) return "mp3";
|
||||
return "png";
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
|
||||
import type { CanvasNodeData, CanvasNodeMetadata, StoryboardRow } from "@/types/canvas";
|
||||
|
||||
const COPY_TITLE_SUFFIX = /^(.*)_copy(\d+)$/i;
|
||||
|
||||
export function nextCopiedNodeTitle(sourceTitle: string, existingTitles: Iterable<string>) {
|
||||
const sourceMatch = sourceTitle.match(COPY_TITLE_SUFFIX);
|
||||
const baseTitle = (sourceMatch?.[1] || sourceTitle.replace(/ Copy$/i, "")).trim() || sourceTitle.trim() || "未命名节点";
|
||||
let maxCopyIndex = 0;
|
||||
for (const title of existingTitles) {
|
||||
const match = title.match(COPY_TITLE_SUFFIX);
|
||||
if (!match || match[1] !== baseTitle) continue;
|
||||
const copyIndex = Number(match[2]);
|
||||
if (Number.isSafeInteger(copyIndex)) maxCopyIndex = Math.max(maxCopyIndex, copyIndex);
|
||||
}
|
||||
return `${baseTitle}_copy${maxCopyIndex + 1}`;
|
||||
}
|
||||
|
||||
function remapReferenceId(nodeId: string | undefined, idMap: ReadonlyMap<string, string>) {
|
||||
return nodeId ? idMap.get(nodeId) || nodeId : undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { getNodeListLabel } from "@/lib/canvas/node-registry";
|
||||
import { canvasNodeCreatedAt, canvasNodeUpdatedAt } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
const searchTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
export function searchCanvasNodes(nodes: CanvasNodeData[], query: string, limit = 80) {
|
||||
const keyword = query.trim().toLocaleLowerCase();
|
||||
return nodes
|
||||
.filter((node) => !keyword || canvasNodeSearchTerms(node).some((value) => value.toLocaleLowerCase().includes(keyword)))
|
||||
.toSorted((left, right) => timestampValue(canvasNodeUpdatedAt(right)) - timestampValue(canvasNodeUpdatedAt(left)))
|
||||
.slice(0, keyword ? limit : Math.min(limit, 40));
|
||||
}
|
||||
|
||||
export function canvasNodeSearchContext(node: CanvasNodeData) {
|
||||
const location = [node.metadata?.chapterTitle, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : ""].filter(Boolean).join(" · ");
|
||||
const textContent = node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Markdown || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Skill
|
||||
? node.metadata?.content
|
||||
: undefined;
|
||||
return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || textContent || getNodeListLabel(node.type);
|
||||
}
|
||||
|
||||
export function canvasNodeMaterialSummary(node: CanvasNodeData) {
|
||||
const details = [getNodeListLabel(node.type)];
|
||||
const width = node.metadata?.naturalWidth;
|
||||
const height = node.metadata?.naturalHeight;
|
||||
if (width && height) details.push(`${width}×${height}`);
|
||||
if (node.metadata?.durationMs) details.push(formatDuration(node.metadata.durationMs));
|
||||
if (node.metadata?.bytes) details.push(formatBytes(node.metadata.bytes));
|
||||
if (node.metadata?.model) details.push(node.metadata.model);
|
||||
return details.slice(0, 3).join(" · ");
|
||||
}
|
||||
|
||||
export function canvasNodeSearchTimes(node: CanvasNodeData) {
|
||||
const createdAt = canvasNodeCreatedAt(node);
|
||||
const updatedAt = canvasNodeUpdatedAt(node);
|
||||
return {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
createdLabel: createdAt ? searchTimeFormatter.format(new Date(createdAt)) : "未记录",
|
||||
updatedLabel: updatedAt ? searchTimeFormatter.format(new Date(updatedAt)) : "未记录",
|
||||
};
|
||||
}
|
||||
|
||||
function canvasNodeSearchTerms(node: CanvasNodeData) {
|
||||
return [
|
||||
node.title,
|
||||
node.type,
|
||||
getNodeListLabel(node.type),
|
||||
node.metadata?.prompt,
|
||||
node.metadata?.composerContent,
|
||||
node.metadata?.model,
|
||||
node.metadata?.chapterTitle,
|
||||
node.metadata?.workflowTitle,
|
||||
node.metadata?.workflowDescription,
|
||||
typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : "",
|
||||
...(node.metadata?.assetTags || []),
|
||||
].filter((value): value is string => typeof value === "string" && Boolean(value));
|
||||
}
|
||||
|
||||
function timestampValue(value?: string) {
|
||||
const timestamp = value ? Date.parse(value) : 0;
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number) {
|
||||
const seconds = Math.max(0, Math.round(durationMs / 1000));
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
return `${Math.floor(seconds / 60)}分${seconds % 60}秒`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
|
||||
|
||||
type CanvasNodeTimestampFallback = {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
const HYDRATED_MEDIA_KEYS = new Set<keyof CanvasNodeMetadata>([
|
||||
"content",
|
||||
"storageKey",
|
||||
"naturalWidth",
|
||||
"naturalHeight",
|
||||
"bytes",
|
||||
"mimeType",
|
||||
"durationMs",
|
||||
"drawingPreviewUrl",
|
||||
]);
|
||||
|
||||
const MEDIA_NODE_TYPES = new Set<string>([CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Drawing]);
|
||||
|
||||
export function canvasNodeCreatedAt(node: CanvasNodeData, fallback?: string) {
|
||||
return firstValidDate(node.createdAt, node.metadata?.taskCreatedAt, node.metadata?.folder?.createdAt, fallback);
|
||||
}
|
||||
|
||||
export function canvasNodeUpdatedAt(node: CanvasNodeData, fallback?: string) {
|
||||
return firstValidDate(
|
||||
node.updatedAt,
|
||||
node.metadata?.taskUpdatedAt,
|
||||
node.metadata?.drawingUpdatedAt,
|
||||
node.metadata?.subtitleUpdatedAt,
|
||||
node.metadata?.taskCompletedAt,
|
||||
canvasNodeCreatedAt(node),
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeCanvasNodeTimestamps(nodes: CanvasNodeData[], fallback: CanvasNodeTimestampFallback = {}) {
|
||||
let changed = false;
|
||||
const normalized = nodes.map((node) => {
|
||||
const createdAt = canvasNodeCreatedAt(node, fallback.createdAt || fallback.updatedAt);
|
||||
const updatedAt = canvasNodeUpdatedAt(node, fallback.updatedAt || createdAt);
|
||||
if (createdAt === node.createdAt && updatedAt === node.updatedAt) return node;
|
||||
changed = true;
|
||||
return { ...node, ...(createdAt ? { createdAt } : {}), ...(updatedAt ? { updatedAt } : {}) };
|
||||
});
|
||||
return changed ? normalized : nodes;
|
||||
}
|
||||
|
||||
export function stampCanvasNodeChanges(previousNodes: CanvasNodeData[], nextNodes: CanvasNodeData[], now = new Date().toISOString()) {
|
||||
if (previousNodes === nextNodes) return previousNodes;
|
||||
const previousById = new Map(previousNodes.map((node) => [node.id, node]));
|
||||
let changed = false;
|
||||
const stamped = nextNodes.map((node) => {
|
||||
const previous = previousById.get(node.id);
|
||||
const createdAt = canvasNodeCreatedAt(node, canvasNodeCreatedAt(previous || node, now)) || now;
|
||||
const meaningfulChange = previous ? canvasNodeMeaningfullyChanged(previous, node) : false;
|
||||
const updatedAt = previous
|
||||
? meaningfulChange
|
||||
? now
|
||||
: canvasNodeUpdatedAt(node, canvasNodeUpdatedAt(previous, createdAt)) || createdAt
|
||||
: canvasNodeUpdatedAt(node, createdAt) || createdAt;
|
||||
if (node.createdAt === createdAt && node.updatedAt === updatedAt) return node;
|
||||
changed = true;
|
||||
return { ...node, createdAt, updatedAt };
|
||||
});
|
||||
return changed ? stamped : nextNodes;
|
||||
}
|
||||
|
||||
function canvasNodeMeaningfullyChanged(previous: CanvasNodeData, next: CanvasNodeData) {
|
||||
if (
|
||||
previous.type !== next.type
|
||||
|| previous.title !== next.title
|
||||
|| previous.parentId !== next.parentId
|
||||
|| previous.width !== next.width
|
||||
|| previous.height !== next.height
|
||||
|| previous.position.x !== next.position.x
|
||||
|| previous.position.y !== next.position.y
|
||||
) return true;
|
||||
if (previous.metadata === next.metadata) return false;
|
||||
|
||||
const previousMetadata = previous.metadata || {};
|
||||
const nextMetadata = next.metadata || {};
|
||||
const keys = new Set([...Object.keys(previousMetadata), ...Object.keys(nextMetadata)] as Array<keyof CanvasNodeMetadata>);
|
||||
for (const key of keys) {
|
||||
if (MEDIA_NODE_TYPES.has(next.type) && HYDRATED_MEDIA_KEYS.has(key)) continue;
|
||||
if (!serializableValuesEqual(previousMetadata[key], nextMetadata[key])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function serializableValuesEqual(left: unknown, right: unknown) {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
|
||||
try {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function firstValidDate(...values: Array<string | undefined>) {
|
||||
return values.find((value) => value && Number.isFinite(Date.parse(value)));
|
||||
}
|
||||
@@ -39,11 +39,14 @@ export function createCanvasNode(type: CanvasNodeTypeId, position: Position, met
|
||||
const spec = builtinSpec || (pluginDefinition ? { width: pluginDefinition.defaultSize.width, height: pluginDefinition.defaultSize.height, title: pluginDefinition.defaultTitle, metadata: pluginDefinition.defaultMetadata } : undefined);
|
||||
if (!spec) throw new Error(`未注册的画布节点类型:${type}`);
|
||||
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
title: spec.title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
position: {
|
||||
x: position.x - spec.width / 2,
|
||||
y: position.y - spec.height / 2,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
export type CanvasShortcutCategoryId = "common" | "navigation" | "selection" | "editing";
|
||||
|
||||
export type CanvasShortcutCategory = {
|
||||
id: CanvasShortcutCategoryId;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type CanvasShortcutItem = {
|
||||
id: string;
|
||||
category: CanvasShortcutCategoryId;
|
||||
title: string;
|
||||
description: string;
|
||||
keys: string[][];
|
||||
keywords?: string[];
|
||||
};
|
||||
|
||||
export const CANVAS_MODIFIER_KEY = "Ctrl / Cmd";
|
||||
|
||||
export const CANVAS_SHORTCUT_CATEGORIES: CanvasShortcutCategory[] = [
|
||||
{ id: "common", label: "常用", description: "搜索、保存与界面" },
|
||||
{ id: "navigation", label: "视图与导航", description: "平移、缩放与定位" },
|
||||
{ id: "selection", label: "选择与连接", description: "批量选择和节点连接" },
|
||||
{ id: "editing", label: "编辑与文件", description: "编辑、撤销与导入" },
|
||||
];
|
||||
|
||||
export const CANVAS_SHORTCUTS: CanvasShortcutItem[] = [
|
||||
{
|
||||
id: "search",
|
||||
category: "common",
|
||||
title: "搜索画布节点",
|
||||
description: "按名称或内容搜索并定位节点",
|
||||
keys: [[CANVAS_MODIFIER_KEY, "F"]],
|
||||
keywords: ["查找", "定位", "find", "search"],
|
||||
},
|
||||
{
|
||||
id: "shortcuts",
|
||||
category: "common",
|
||||
title: "打开快捷键中心",
|
||||
description: "查看画布中的键盘和鼠标操作",
|
||||
keys: [["?"]],
|
||||
keywords: ["帮助", "说明", "help"],
|
||||
},
|
||||
{
|
||||
id: "save",
|
||||
category: "common",
|
||||
title: "保存画布",
|
||||
description: "保存当前画布布局和节点位置",
|
||||
keys: [[CANVAS_MODIFIER_KEY, "S"]],
|
||||
keywords: ["存储", "save"],
|
||||
},
|
||||
{
|
||||
id: "focus",
|
||||
category: "common",
|
||||
title: "进入或退出专注模式",
|
||||
description: "隐藏界面干扰,聚焦当前画布",
|
||||
keys: [["Shift", CANVAS_MODIFIER_KEY, "F"]],
|
||||
keywords: ["沉浸", "全屏", "focus"],
|
||||
},
|
||||
{
|
||||
id: "pan",
|
||||
category: "navigation",
|
||||
title: "平移视图",
|
||||
description: "在画布空白处拖动,或使用空格键与中键拖动",
|
||||
keys: [["空白处拖动"], ["Space", "拖动"], ["中键拖动"]],
|
||||
keywords: ["移动", "画布", "pan"],
|
||||
},
|
||||
{
|
||||
id: "zoom-wheel",
|
||||
category: "navigation",
|
||||
title: "缩放画布",
|
||||
description: "以鼠标所在位置为中心缩放",
|
||||
keys: [["滚轮"]],
|
||||
keywords: ["放大", "缩小", "zoom"],
|
||||
},
|
||||
{
|
||||
id: "zoom-controls",
|
||||
category: "navigation",
|
||||
title: "精确调整缩放",
|
||||
description: "使用画布缩放滑杆调整比例",
|
||||
keys: [["缩放滑杆"]],
|
||||
keywords: ["比例", "zoom"],
|
||||
},
|
||||
{
|
||||
id: "zoom-steps",
|
||||
category: "navigation",
|
||||
title: "步进缩放画布",
|
||||
description: "按固定步长放大或缩小画布",
|
||||
keys: [[CANVAS_MODIFIER_KEY, "+"], [CANVAS_MODIFIER_KEY, "-"]],
|
||||
keywords: ["放大", "缩小", "zoom"],
|
||||
},
|
||||
{
|
||||
id: "zoom-presets",
|
||||
category: "navigation",
|
||||
title: "快速调整视图",
|
||||
description: "0/1 恢复 100%,2 适应画布,3 适应选择",
|
||||
keys: [[CANVAS_MODIFIER_KEY, "0"], [CANVAS_MODIFIER_KEY, "1"], [CANVAS_MODIFIER_KEY, "2"], [CANVAS_MODIFIER_KEY, "3"]],
|
||||
keywords: ["100%", "适应", "居中", "缩放", "fit"],
|
||||
},
|
||||
{
|
||||
id: "box-select",
|
||||
category: "selection",
|
||||
title: "框选多个节点",
|
||||
description: "按住修饰键后拖动选区",
|
||||
keys: [["Shift", "拖动"], [CANVAS_MODIFIER_KEY, "拖动"]],
|
||||
keywords: ["多选", "范围", "selection"],
|
||||
},
|
||||
{
|
||||
id: "box-select-tool",
|
||||
category: "selection",
|
||||
title: "使用框选工具",
|
||||
description: "完成框选后自动回到移动与选择工具",
|
||||
keys: [["框选工具", "拖动"]],
|
||||
keywords: ["工具栏", "多选", "selection"],
|
||||
},
|
||||
{
|
||||
id: "add-selection",
|
||||
category: "selection",
|
||||
title: "追加选择节点",
|
||||
description: "保留已有选择并加入更多节点",
|
||||
keys: [["Shift", "点击"], [CANVAS_MODIFIER_KEY, "点击"]],
|
||||
keywords: ["多选", "添加", "selection"],
|
||||
},
|
||||
{
|
||||
id: "remove-selection",
|
||||
category: "selection",
|
||||
title: "移除选择节点",
|
||||
description: "从当前选择中移除点击或框选的节点",
|
||||
keys: [["Alt", "点击 / 框选"]],
|
||||
keywords: ["取消", "排除", "selection"],
|
||||
},
|
||||
{
|
||||
id: "select-all",
|
||||
category: "selection",
|
||||
title: "全选节点",
|
||||
description: "选择画布中的全部节点",
|
||||
keys: [[CANVAS_MODIFIER_KEY, "A"]],
|
||||
keywords: ["全部", "select all"],
|
||||
},
|
||||
{
|
||||
id: "batch-connect",
|
||||
category: "selection",
|
||||
title: "批量连接节点",
|
||||
description: "为两个或更多已选节点进入批量连接模式",
|
||||
keys: [["Alt", "L"]],
|
||||
keywords: ["连线", "连接", "link"],
|
||||
},
|
||||
{
|
||||
id: "copy",
|
||||
category: "editing",
|
||||
title: "复制节点",
|
||||
description: "复制当前选中的节点",
|
||||
keys: [[CANVAS_MODIFIER_KEY, "C"]],
|
||||
keywords: ["copy"],
|
||||
},
|
||||
{
|
||||
id: "paste",
|
||||
category: "editing",
|
||||
title: "粘贴节点或剪贴板内容",
|
||||
description: "粘贴已复制节点、文本或图片",
|
||||
keys: [[CANVAS_MODIFIER_KEY, "V"]],
|
||||
keywords: ["剪贴板", "paste"],
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
category: "editing",
|
||||
title: "删除选中内容",
|
||||
description: "删除选中的节点或连线",
|
||||
keys: [["Delete"], ["Backspace"]],
|
||||
keywords: ["移除", "delete"],
|
||||
},
|
||||
{
|
||||
id: "undo",
|
||||
category: "editing",
|
||||
title: "撤销",
|
||||
description: "撤销上一步画布编辑",
|
||||
keys: [[CANVAS_MODIFIER_KEY, "Z"]],
|
||||
keywords: ["undo"],
|
||||
},
|
||||
{
|
||||
id: "redo",
|
||||
category: "editing",
|
||||
title: "重做",
|
||||
description: "恢复刚刚撤销的画布编辑",
|
||||
keys: [[CANVAS_MODIFIER_KEY, "Shift", "Z"], [CANVAS_MODIFIER_KEY, "Y"]],
|
||||
keywords: ["恢复", "redo"],
|
||||
},
|
||||
{
|
||||
id: "escape",
|
||||
category: "editing",
|
||||
title: "取消当前操作",
|
||||
description: "取消选择、关闭浮层或退出专注模式",
|
||||
keys: [["Esc"]],
|
||||
keywords: ["关闭", "退出", "cancel"],
|
||||
},
|
||||
{
|
||||
id: "import-media",
|
||||
category: "editing",
|
||||
title: "导入媒体",
|
||||
description: "将图片、视频或音频文件拖入画布",
|
||||
keys: [["拖入媒体"]],
|
||||
keywords: ["上传", "文件", "图片", "视频", "音频", "upload"],
|
||||
},
|
||||
];
|
||||
|
||||
export function filterCanvasShortcuts(query: string, category?: CanvasShortcutCategoryId | "all") {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||
|
||||
return CANVAS_SHORTCUTS.filter((shortcut) => {
|
||||
if (category && category !== "all" && shortcut.category !== category) return false;
|
||||
if (!terms.length) return true;
|
||||
|
||||
const categoryLabel = CANVAS_SHORTCUT_CATEGORIES.find((entry) => entry.id === shortcut.category)?.label || "";
|
||||
const searchableText = [shortcut.title, shortcut.description, categoryLabel, shortcut.keys.flat().join(" "), ...(shortcut.keywords || [])]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase();
|
||||
return terms.every((term) => searchableText.includes(term));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export type LocalAgentSetupPlatform = "unix" | "windows";
|
||||
|
||||
const CANVAS_REPOSITORY_URL = "https://github.com/ddcat-ai/open-ai-canvas.git";
|
||||
|
||||
export function detectLocalAgentSetupPlatform(userAgent = defaultUserAgent()): LocalAgentSetupPlatform {
|
||||
return /windows/i.test(userAgent) ? "windows" : "unix";
|
||||
}
|
||||
|
||||
export function buildLocalAgentSetupCommands(origin: string, platform: LocalAgentSetupPlatform) {
|
||||
const trustedOrigin = exactWebOrigin(origin);
|
||||
if (platform === "windows") {
|
||||
return {
|
||||
install: `git clone ${CANVAS_REPOSITORY_URL}\nSet-Location .\\open-ai-canvas\\canvas-agent\nnpm install\nnpm run build\nSet-Location ..\\..`,
|
||||
start: `Set-Location .\\open-ai-canvas\\canvas-agent\n$env:FRAMEFIELD_TRUSTED_WEB_ORIGINS=${powerShellArgument(trustedOrigin)}\nnode .\\dist\\index.js`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
install: `git clone ${CANVAS_REPOSITORY_URL}\ncd open-ai-canvas/canvas-agent\nnpm install\nnpm run build\ncd ../..`,
|
||||
start: `cd open-ai-canvas/canvas-agent\nFRAMEFIELD_TRUSTED_WEB_ORIGINS=${shellArgument(trustedOrigin)} node dist/index.js`,
|
||||
};
|
||||
}
|
||||
|
||||
function exactWebOrigin(value: string) {
|
||||
const url = new URL(value);
|
||||
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.pathname !== "/" || url.search || url.hash || url.origin !== value) {
|
||||
throw new Error("Web origin is invalid");
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
function defaultUserAgent() {
|
||||
if (typeof navigator === "undefined") return "";
|
||||
return `${navigator.platform} ${navigator.userAgent}`;
|
||||
}
|
||||
|
||||
function powerShellArgument(value: string) {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function shellArgument(value: string) {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
@@ -902,10 +902,14 @@ export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { sec
|
||||
const duration = profile.duration.selection === "enum" ? ((profile.duration.values || []).includes(Number(value.seconds)) ? Number(value.seconds) : profile.duration.default) : normalizeRangeDuration(profile, Number(value.seconds));
|
||||
const ratio = profile.ratios.includes(value.ratio || "") ? value.ratio! : profile.defaultRatio;
|
||||
// 前端状态历史上保存过 `720`,而能力配置和供应商通常使用 `720p`;统一按能力中的原始值返回,避免被误判为不支持。
|
||||
const resolution = videoResolutionRequest(profile, value.resolution) || profile.defaultResolution || profile.resolutions[0] || "";
|
||||
const resolution = resolveVideoResolutionValue(profile, value.resolution);
|
||||
return { seconds: String(duration), ratio, resolution };
|
||||
}
|
||||
|
||||
export function resolveVideoResolutionValue(profile: VideoCapabilityConfig, value: string | undefined) {
|
||||
return videoResolutionRequest(profile, value) || profile.defaultResolution || profile.resolutions[0] || "";
|
||||
}
|
||||
|
||||
export function videoResolutionRequest(profile: VideoCapabilityConfig, value: string | undefined) {
|
||||
const requested = String(value || "")
|
||||
.trim()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { modelRequestOptions, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
|
||||
import type { ModelRequestIntent } from "@/services/api/logical-models";
|
||||
import { modelOptionName, resolveModelChannel, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
@@ -115,5 +115,5 @@ function priceSelectorForTier(tier: ModelPriceTier) {
|
||||
export function normalizeTierResolution(value: string) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw || raw === "*") return "*";
|
||||
return `${normalizeVideoResolution(raw)}p`;
|
||||
return videoResolutionComparisonKey(raw);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defaultImageCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
|
||||
import { modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
export type ModelInputSummary = {
|
||||
@@ -160,12 +161,7 @@ function logicalOptionMatches(name: string, constraint: { values?: unknown[]; mi
|
||||
function normalizeLogicalOptionValue(name: string, value: unknown) {
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (name !== "vquality" && name !== "resolution") return normalized;
|
||||
if (normalized === "low") return "480p";
|
||||
if (["auto", "medium", "high"].includes(normalized)) return "720p";
|
||||
if (normalized === "2k") return "1440p";
|
||||
if (normalized === "4k") return "2160p";
|
||||
const resolution = normalized.replace(/p$/i, "");
|
||||
return resolution ? `${resolution}p` : "";
|
||||
return videoResolutionComparisonKey(normalized);
|
||||
}
|
||||
|
||||
function logicalOptionError(name: string) {
|
||||
|
||||
@@ -9,11 +9,32 @@ export function normalizeVideoDuration(value: string | number | undefined) {
|
||||
}
|
||||
|
||||
export function normalizeVideoResolution(value: string | number | undefined) {
|
||||
const token = String(value || "").trim().toLowerCase();
|
||||
const raw = String(value || "").trim();
|
||||
const token = raw.toLowerCase();
|
||||
if (token === "low") return "480";
|
||||
if (token === "auto" || token === "medium" || token === "high") return "720";
|
||||
if (token === "2k") return "1440";
|
||||
if (token === "4k") return "2160";
|
||||
const resolution = Number(token.replace(/p$/i, ""));
|
||||
return Number.isFinite(resolution) && resolution > 0 ? String(Math.floor(resolution)) : "720";
|
||||
if (Number.isFinite(resolution)) return resolution > 0 ? String(Math.floor(resolution)) : "720";
|
||||
// Channel-declared values are opaque enums, not necessarily numeric tiers.
|
||||
// Preserve them verbatim so values such as `768p竖` remain selectable and
|
||||
// can be sent back to the provider without being collapsed to 720p.
|
||||
return raw || "720";
|
||||
}
|
||||
|
||||
export function videoResolutionComparisonKey(value: string | number | undefined) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
const normalized = normalizeVideoResolution(raw);
|
||||
return /^\d+$/.test(normalized) ? `${normalized}p` : normalized.toLowerCase();
|
||||
}
|
||||
|
||||
export function formatVideoResolutionLabel(value: string | number | undefined) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
const normalized = normalizeVideoResolution(raw);
|
||||
if (normalized === "2160") return "4K";
|
||||
if (/^\d+$/.test(normalized)) return `${normalized}P`;
|
||||
return normalized.replace(/^(\d+)p/i, "$1P");
|
||||
}
|
||||
Reference in new issue
Block a user