feat(*): 画布创作体验 - 完善 Agent 引导、生成防重、搜索快捷键、素材多选与分辨率兼容

This commit is contained in:
ddCat committed 2026-08-28 16:24:58 +08:00
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";
}
+15
View File
@@ -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;
}
+82
View File
@@ -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,
+220
View File
@@ -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));
});
}
+42
View File
@@ -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, "'\\''")}'`;
}
+5 -1
View File
@@ -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()
+2 -2
View File
@@ -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);
}
+2 -6
View File
@@ -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) {
+23 -2
View File
@@ -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");
}