perf(canvas): 画布渲染 - 增加节点渲染预算与性能模式控制
This commit is contained in:
@@ -1,17 +1,14 @@
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import type { ReactNode } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { History, PanelBottom, X, ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
import { PanelBottom, X, ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
|
||||
type CanvasFocusModeBarProps = {
|
||||
versionsOpen: boolean;
|
||||
onToggleVersions: () => void;
|
||||
syncStatus?: ReactNode;
|
||||
syncIcon?: ReactNode;
|
||||
dockRevealed: boolean;
|
||||
zoomPercent: number;
|
||||
onToggleDock: () => void;
|
||||
@@ -21,7 +18,7 @@ type CanvasFocusModeBarProps = {
|
||||
onFit: () => void;
|
||||
};
|
||||
|
||||
export function CanvasFocusModeBar({ versionsOpen, onToggleVersions, syncStatus, dockRevealed, zoomPercent, onToggleDock, onExit, onZoomIn, onZoomOut, onFit }: CanvasFocusModeBarProps) {
|
||||
export function CanvasFocusModeBar({ syncIcon, dockRevealed, zoomPercent, onToggleDock, onExit, onZoomIn, onZoomOut, onFit }: CanvasFocusModeBarProps) {
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
@@ -36,22 +33,11 @@ export function CanvasFocusModeBar({ versionsOpen, onToggleVersions, syncStatus,
|
||||
>
|
||||
<div className="flex items-center gap-0.5 rounded-full p-1 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, color: theme.node.text, boxShadow: "var(--workspace-overlay-shadow)" }}>
|
||||
<Tooltip title="退出专注模式(Esc)">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExit}
|
||||
className="grid size-8 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10"
|
||||
style={{ color: theme.node.text }}
|
||||
aria-label="退出专注模式"
|
||||
>
|
||||
<button type="button" onClick={onExit} className="grid size-8 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="退出专注模式">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{syncStatus}
|
||||
<Tooltip title="版本记录与本地草稿">
|
||||
<button type="button" onClick={onToggleVersions} className="grid size-8 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text, background: versionsOpen ? theme.toolbar.itemHover : undefined }} aria-label="版本记录" aria-pressed={versionsOpen}>
|
||||
<History className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{syncIcon}
|
||||
<span className="mx-0.5 h-4 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<Tooltip title={dockRevealed ? "收起工具" : "工具"}>
|
||||
<button
|
||||
|
||||
@@ -53,13 +53,13 @@ export type CanvasNodeContentProps = {
|
||||
onReloadResource?: (node: CanvasNodeData) => void;
|
||||
onOpenTaskDetails?: (node: CanvasNodeData) => void;
|
||||
onToggleBatch?: () => void;
|
||||
reduceMediaEffects?: boolean;
|
||||
disableMediaEffects?: boolean;
|
||||
mediaActive?: boolean;
|
||||
onMediaPlayRequest?: (nodeId: string) => void;
|
||||
};
|
||||
|
||||
export function CanvasNodeContent(props: CanvasNodeContentProps) {
|
||||
if (props.node.metadata?.fileUpload) return <CanvasFileUploadContent node={props.node} theme={props.theme} reduceMotion={props.reduceMediaEffects} />;
|
||||
if (props.node.metadata?.fileUpload) return <CanvasFileUploadContent node={props.node} theme={props.theme} reduceMotion={props.disableMediaEffects} />;
|
||||
// Preserve the decoded image across LOD changes; only its surrounding
|
||||
// controls and batch previews become lighter in overview mode.
|
||||
if (props.node.type === CanvasNodeType.Image && props.renderLOD !== "full" && (props.node.metadata?.content || props.node.metadata?.storageKey)) {
|
||||
|
||||
@@ -148,7 +148,7 @@ export function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlay
|
||||
node={node}
|
||||
alt={`${node.title || "视频"} 静态预览`}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
decoding="sync"
|
||||
draggable={false}
|
||||
className={`absolute inset-0 size-full select-none object-contain transition-opacity duration-150 ${videoReady ? "opacity-0" : "opacity-100"}`}
|
||||
loadingFallback={<LoaderCircle className="size-5 animate-spin text-white/55" />}
|
||||
@@ -276,14 +276,14 @@ export function InactiveVideoPreview({ node, theme, onPlay }: Pick<CanvasNodeCon
|
||||
node={node}
|
||||
alt={`${node.title || "视频"} 静态预览`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
decoding="sync"
|
||||
draggable={false}
|
||||
className="pointer-events-none size-full select-none object-contain"
|
||||
loadingFallback={<LoaderCircle className="size-5 animate-spin text-white/55" />}
|
||||
fallback={<Video className="size-7 text-white/40" />}
|
||||
/>
|
||||
) : (
|
||||
<img src={localPreviewUrl} alt={`${node.title || "视频"} 静态预览`} loading="lazy" decoding="async" draggable={false} className="pointer-events-none size-full select-none object-contain" />
|
||||
<img src={localPreviewUrl} alt={`${node.title || "视频"} 静态预览`} loading="lazy" decoding="sync" draggable={false} className="pointer-events-none size-full select-none object-contain" />
|
||||
)}
|
||||
<VideoPreviewPlayButton title={node.title || "视频"} onPlay={onPlay} />
|
||||
</div>
|
||||
@@ -637,7 +637,9 @@ export function RetainedCanvasImage({
|
||||
<img
|
||||
src={currentDisplayedSrc || undefined}
|
||||
alt={alt || ""}
|
||||
decoding="async"
|
||||
// 世界层每次缩放提交都会按新倍率重新栅格化;async 解码会让 Chromium 先画出
|
||||
// 空白再补图(缩放时白闪)。图片已由 prepareCanvasImage 预解码,同步解码只阻塞栅格不阻塞主线程。
|
||||
decoding="sync"
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
className={`absolute inset-0 ${className}`}
|
||||
|
||||
@@ -34,7 +34,7 @@ type CanvasNodeProps = {
|
||||
connectionApproach?: Position;
|
||||
forceInputVisible?: boolean;
|
||||
showImageInfo: boolean;
|
||||
reduceMediaEffects?: boolean;
|
||||
disableMediaEffects?: boolean;
|
||||
readOnly?: boolean;
|
||||
resourceLabel?: CanvasResourceReference;
|
||||
mentionReferences?: CanvasResourceReference[];
|
||||
@@ -60,7 +60,6 @@ type CanvasNodeProps = {
|
||||
onRetry?: (node: CanvasNodeData) => void;
|
||||
onReloadResource?: (node: CanvasNodeData) => void;
|
||||
onOpenTaskDetails?: (node: CanvasNodeData) => void;
|
||||
onOpenVersions?: (node: CanvasNodeData) => void;
|
||||
onViewImage?: (node: CanvasNodeData) => void;
|
||||
onReplaceMedia?: (node: CanvasNodeData) => void;
|
||||
onOpenTextEditor?: (node: CanvasNodeData) => void;
|
||||
@@ -83,7 +82,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
connectionApproach,
|
||||
forceInputVisible = false,
|
||||
showImageInfo,
|
||||
reduceMediaEffects = false,
|
||||
disableMediaEffects = false,
|
||||
readOnly = false,
|
||||
resourceLabel,
|
||||
mentionReferences = [],
|
||||
@@ -109,7 +108,6 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onRetry,
|
||||
onReloadResource,
|
||||
onOpenTaskDetails,
|
||||
onOpenVersions,
|
||||
onViewImage,
|
||||
onOpenTextEditor,
|
||||
onOpenPrevis,
|
||||
@@ -189,7 +187,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
if (next !== data.title) onTitleChange?.(data.id, next);
|
||||
};
|
||||
|
||||
const connectionTilt = isConnectionTarget && !reduceMediaEffects && !dragOffset
|
||||
const connectionTilt = isConnectionTarget && !disableMediaEffects && !dragOffset
|
||||
? canvasConnectionTilt(data, connectionApproach) : undefined;
|
||||
|
||||
return (
|
||||
@@ -317,7 +315,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onReloadResource={onReloadResource}
|
||||
onOpenTaskDetails={onOpenTaskDetails}
|
||||
onToggleBatch={() => onToggleBatch?.(data.id)}
|
||||
reduceMediaEffects={reduceMediaEffects}
|
||||
disableMediaEffects={disableMediaEffects}
|
||||
mediaActive={mediaActive}
|
||||
onMediaPlayRequest={onMediaPlayRequest}
|
||||
/>
|
||||
@@ -342,26 +340,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showChrome && data.metadata?.versionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-3 top-3 z-[var(--node-z-overlay)] grid size-7 place-items-center rounded-[var(--r-full)] border p-0.5 text-[var(--node-badge-fs)] font-semibold leading-none backdrop-blur-md transition-[transform,background,border-color,box-shadow] hover:-translate-y-px focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-reduce:hover:translate-y-0"
|
||||
style={{
|
||||
background: data.metadata.versionPrimary ? theme.accent.primarySoft : theme.toolbar.panel,
|
||||
borderColor: data.metadata.versionPrimary ? theme.accent.primary : theme.toolbar.border,
|
||||
color: data.metadata.versionPrimary ? theme.accent.primary : theme.node.text,
|
||||
outlineColor: theme.accent.primary,
|
||||
}}
|
||||
title={data.metadata.versionLabel + (data.metadata.versionPrimary ? " · 主版本" : "") + ",点击查看版本对比"}
|
||||
aria-label={data.metadata.versionLabel + (data.metadata.versionPrimary ? ",主版本" : "") + ",查看版本对比"}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => { event.stopPropagation(); onOpenVersions?.(data); }}
|
||||
>
|
||||
{data.metadata.versionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
{showChrome && showStatusTrack ? (
|
||||
<div className={`absolute right-3 top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
|
||||
<div className="absolute right-3 top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 max-w-[calc(100%-24px)]">
|
||||
{resourceLabel && data.type !== CanvasNodeType.Image ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
|
||||
{hasMediaContent && !readOnly ? <ResourceStorageBadge storageKey={data.metadata?.storageKey} active={isActive} theme={theme} /> : null}
|
||||
{isBatchRoot ? <BatchToggleBadge count={batchCount} expanded={batchExpanded} theme={theme} onToggle={() => onToggleBatch?.(data.id)} /> : null}
|
||||
@@ -436,7 +416,7 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
|
||||
previous.connectionApproach?.y === next.connectionApproach?.y &&
|
||||
previous.forceInputVisible === next.forceInputVisible &&
|
||||
previous.showImageInfo === next.showImageInfo &&
|
||||
previous.reduceMediaEffects === next.reduceMediaEffects &&
|
||||
previous.disableMediaEffects === next.disableMediaEffects &&
|
||||
previous.readOnly === next.readOnly &&
|
||||
previous.resourceLabel === next.resourceLabel &&
|
||||
previous.mentionReferences === next.mentionReferences &&
|
||||
@@ -464,7 +444,6 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
|
||||
previous.onRetry === next.onRetry &&
|
||||
previous.onReloadResource === next.onReloadResource &&
|
||||
previous.onOpenTaskDetails === next.onOpenTaskDetails &&
|
||||
previous.onOpenVersions === next.onOpenVersions &&
|
||||
previous.onViewImage === next.onViewImage &&
|
||||
previous.onReplaceMedia === next.onReplaceMedia &&
|
||||
previous.onOpenTextEditor === next.onOpenTextEditor &&
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { CanvasNodeData } from "@/types/canvas";
|
||||
type PanoramaNodeContentProps = {
|
||||
node: CanvasNodeData;
|
||||
theme: CanvasTheme;
|
||||
reduceMediaEffects?: boolean;
|
||||
disableMediaEffects?: boolean;
|
||||
};
|
||||
|
||||
/** 节点外壳调用查看器能力的最小控制面;实现由 PanoramaViewport 注入。 */
|
||||
@@ -44,15 +44,13 @@ function clampFov(value: number) {
|
||||
* 与既有的 DeferredMediaLoad「点击加载」是同一套交互习惯。
|
||||
* 2. **用原生 three 而不是 @react-three/fiber**。这里需要精确控制 dispose——
|
||||
* 上下文/纹理/几何体漏一个就逼近上限,节点还会被频繁增删。
|
||||
* 3. **性能模式下不提供激活入口**。错题本里多条崩溃出在画布高频渲染,
|
||||
* reduceMediaEffects 时只给静态预览。
|
||||
* 4. **截图/四宫格导出走 preserveDrawingBuffer + 同步重渲染**。captureSquare
|
||||
* 3. **截图/四宫格导出走 preserveDrawingBuffer + 同步重渲染**。captureSquare
|
||||
* 临时把 renderer 切到方形画幅再恢复,不依赖 rAF 时序;全屏展开时同一时刻
|
||||
* 只挂一个 viewer 实例(节点体退回静态预览),上下文数量不翻倍。
|
||||
* 5. **节点内不消费滚轮**,滚轮留给画布缩放;全屏覆盖层内画布缩放不存在,
|
||||
* 4. **节点内不消费滚轮**,滚轮留给画布缩放;全屏覆盖层内画布缩放不存在,
|
||||
* 才用滚轮调视场角。
|
||||
*/
|
||||
export function PanoramaNodeContent({ node, theme, reduceMediaEffects }: PanoramaNodeContentProps) {
|
||||
export function PanoramaNodeContent({ node, theme, disableMediaEffects }: PanoramaNodeContentProps) {
|
||||
const upstream = useUpstreamNodes(node.id);
|
||||
const actions = useCanvasNodeActions();
|
||||
const inherited = upstream.find((item) => getNodeResourceKind(item) === "image");
|
||||
@@ -142,9 +140,9 @@ export function PanoramaNodeContent({ node, theme, reduceMediaEffects }: Panoram
|
||||
<img src={url} alt={node.title || "全景"} className="h-full w-full object-cover opacity-80" draggable={false} />
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 px-3 py-2" style={{ background: "linear-gradient(to top, rgba(0,0,0,.85), rgba(0,0,0,.35))", boxShadow: "0 -1px 0 rgba(0,0,0,.25)" }}>
|
||||
<span className="min-w-0 truncate text-white" style={{ fontSize: "var(--fs-label)", textShadow: "0 1px 3px rgba(0,0,0,.85)" }}>
|
||||
{failed ? "全景加载失败(图片可能不允许跨域读取)" : expanded ? "已展开全屏环视" : reduceMediaEffects ? "性能模式下仅显示预览" : `${projectionLabel} · 360° 全景`}
|
||||
{failed ? "全景加载失败(图片可能不允许跨域读取)" : expanded ? "已展开全屏环视" : disableMediaEffects ? "仅显示预览" : `${projectionLabel} · 360° 全景`}
|
||||
</span>
|
||||
{reduceMediaEffects || expanded ? null : (
|
||||
{disableMediaEffects || expanded ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-[var(--r-md)] px-2 py-1 font-medium text-white outline-none transition-colors"
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { CanvasNodeType, type CanvasMediaPerformanceMode, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
const STORAGE_KEY = "canvas-media-performance-mode";
|
||||
|
||||
// 屏外预加载的软预算;屏内节点不可因预算而消失,媒体仍由节点按需加载。
|
||||
export const CANVAS_MAX_RENDERED_NODES = 720;
|
||||
export const CANVAS_MAX_RENDERED_CONNECTIONS = 5000;
|
||||
|
||||
export function readCanvasMediaPerformanceMode(): CanvasMediaPerformanceMode {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored === "quality" || stored === "performance" ? stored : "auto";
|
||||
} catch {
|
||||
return "auto";
|
||||
}
|
||||
}
|
||||
|
||||
export function persistCanvasMediaPerformanceMode(mode: CanvasMediaPerformanceMode) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// 浏览器禁用本地存储时保留当前会话内的选择。
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldReduceCanvasMediaEffects(mode: CanvasMediaPerformanceMode, nodes: CanvasNodeData[]) {
|
||||
if (mode === "performance") return true;
|
||||
if (mode === "quality") return false;
|
||||
const mediaCount = nodes.filter((node) => node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio).length;
|
||||
return nodes.length >= 80 || mediaCount >= 32;
|
||||
}
|
||||
|
||||
export function canvasNodeRenderPadding(reduceMediaEffects: boolean, previouslyRendered: boolean) {
|
||||
if (previouslyRendered) return reduceMediaEffects ? 640 : 384;
|
||||
return reduceMediaEffects ? 128 : 192;
|
||||
}
|
||||
|
||||
export function canvasNodeRenderBudget(scale: number) {
|
||||
if (scale < 0.14) return 280;
|
||||
if (scale < 0.28) return 420;
|
||||
return CANVAS_MAX_RENDERED_NODES;
|
||||
}
|
||||
|
||||
export function resolveActiveCanvasMediaNodeId(selectedNodeIds: ReadonlySet<string>, nodeById: ReadonlyMap<string, CanvasNodeData>) {
|
||||
if (selectedNodeIds.size !== 1) return null;
|
||||
const nodeId = selectedNodeIds.values().next().value;
|
||||
if (!nodeId) return null;
|
||||
const type = nodeById.get(nodeId)?.type;
|
||||
return type === CanvasNodeType.Video || type === CanvasNodeType.Audio ? nodeId : null;
|
||||
}
|
||||
12
web/src/lib/canvas/canvas-render-budget.ts
Normal file
12
web/src/lib/canvas/canvas-render-budget.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const CANVAS_MAX_RENDERED_NODES = 720;
|
||||
export const CANVAS_MAX_RENDERED_CONNECTIONS = 5000;
|
||||
|
||||
export function canvasNodeRenderPadding(previouslyRendered: boolean) {
|
||||
return previouslyRendered ? 384 : 192;
|
||||
}
|
||||
|
||||
export function canvasNodeRenderBudget(scale: number) {
|
||||
if (scale < 0.14) return 280;
|
||||
if (scale < 0.28) return 420;
|
||||
return CANVAS_MAX_RENDERED_NODES;
|
||||
}
|
||||
@@ -44,7 +44,6 @@ type CanvasProjectWorldLayersProps = {
|
||||
openingBatchIds: Set<string>;
|
||||
batchMotionById: Map<string, { x: number; y: number; index: number }>;
|
||||
showImageInfo: boolean;
|
||||
reduceMediaEffects: boolean;
|
||||
resourceReferenceByNodeId: Map<string, CanvasResourceReference>;
|
||||
mentionReferencesByNodeId: Map<string, CanvasResourceReference[]>;
|
||||
mediaEffectsDisabledNodeId?: string | null;
|
||||
@@ -72,7 +71,6 @@ type CanvasProjectWorldLayersProps = {
|
||||
onRetry: (node: CanvasNodeData) => void;
|
||||
onReloadResource: (node: CanvasNodeData) => void;
|
||||
onOpenTaskDetails: (node: CanvasNodeData) => void;
|
||||
onOpenVersions: (node: CanvasNodeData) => void;
|
||||
onViewImage: (node: CanvasNodeData) => void;
|
||||
onReplaceMedia: (node: CanvasNodeData) => void;
|
||||
onOpenTextEditor: (node: CanvasNodeData) => void;
|
||||
@@ -176,7 +174,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
|
||||
batchPrimary={Boolean(node.metadata?.batchRootId && props.nodeById.get(node.metadata.batchRootId)?.metadata?.primaryImageId === node.id)}
|
||||
batchMotion={props.batchMotionById.get(node.id)}
|
||||
showImageInfo={props.showImageInfo}
|
||||
reduceMediaEffects={props.reduceMediaEffects || props.mediaEffectsDisabledNodeId === node.id}
|
||||
disableMediaEffects={props.mediaEffectsDisabledNodeId === node.id}
|
||||
resourceLabel={props.resourceReferenceByNodeId.get(node.id)}
|
||||
mentionReferences={props.mentionReferencesByNodeId.get(node.id) || EMPTY_RESOURCE_REFERENCES}
|
||||
renderNodeContent={props.renderCanvasNodeContent}
|
||||
@@ -193,7 +191,6 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
|
||||
onRetry={props.onRetry}
|
||||
onReloadResource={props.onReloadResource}
|
||||
onOpenTaskDetails={props.onOpenTaskDetails}
|
||||
onOpenVersions={props.onOpenVersions}
|
||||
onViewImage={props.onViewImage}
|
||||
onReplaceMedia={props.onReplaceMedia}
|
||||
onOpenTextEditor={props.onOpenTextEditor}
|
||||
|
||||
@@ -15,7 +15,7 @@ type UseCanvasKeyboardOptions = {
|
||||
setCropNodeId: Dispatch<SetStateAction<string | null>>;
|
||||
setMaskEditNodeId: Dispatch<SetStateAction<string | null>>;
|
||||
setAnnotationNodeId: Dispatch<SetStateAction<string | null>>;
|
||||
saveCanvasProject: () => unknown;
|
||||
saveCanvasProject: (options?: { requireRemote?: boolean }) => unknown;
|
||||
zoomToActualSize: () => void;
|
||||
fitCanvasContent: () => void;
|
||||
fitCanvasSelection: () => void;
|
||||
@@ -113,7 +113,7 @@ export function useCanvasKeyboard({
|
||||
if (isModifierShortcut && !event.altKey && key === "s") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!event.repeat) void saveCanvasProject();
|
||||
if (!event.repeat) void saveCanvasProject({ requireRemote: false });
|
||||
return;
|
||||
}
|
||||
if (isModifierShortcut && !event.altKey && key === "f") {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { buildNodeGenerationInputs, type NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
|
||||
import { isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
import { sameNodeSemanticData } from "@/lib/canvas/canvas-project-domain";
|
||||
import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CONNECTIONS, shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode";
|
||||
import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CONNECTIONS } from "@/lib/canvas/canvas-render-budget";
|
||||
import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences, buildToolMentionReference, parseToolMentionTokens } from "@/lib/canvas/canvas-resource-references";
|
||||
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { buildCanvasConnectionIndex, type CanvasIndexedConnection } from "@/lib/canvas/canvas-connection-index";
|
||||
@@ -15,7 +15,7 @@ import { intersectsCanvasBounds, selectCanvasVisibleNodes } from "@/lib/canvas/c
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import type { Asset, ImageAsset } from "@/stores/use-asset-store";
|
||||
import type { PrevisScene } from "@/types/previs";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasMediaPerformanceMode, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
type DragPreview = { x: number; y: number; nodeIds: Set<string> } | null;
|
||||
|
||||
@@ -25,7 +25,6 @@ type UseCanvasRenderModelOptions = {
|
||||
assets: Asset[];
|
||||
viewport: ViewportTransform;
|
||||
viewportSize: { width: number; height: number };
|
||||
mediaPerformanceMode: CanvasMediaPerformanceMode;
|
||||
selectedNodeIds: Set<string>;
|
||||
hoveredNodeId: string | null;
|
||||
dragPreview: DragPreview;
|
||||
@@ -45,7 +44,6 @@ type UseCanvasRenderModelOptions = {
|
||||
emotionNodeId: string | null;
|
||||
previewNodeId: string | null;
|
||||
contextMenu: ContextMenuState | null;
|
||||
versionCompareRootId: string | null;
|
||||
previsNodeId: string | null;
|
||||
scriptEditorNodeId: string | null;
|
||||
dialogNodeId: string | null;
|
||||
@@ -57,7 +55,6 @@ export function useCanvasRenderModel({
|
||||
assets,
|
||||
viewport,
|
||||
viewportSize,
|
||||
mediaPerformanceMode,
|
||||
selectedNodeIds,
|
||||
hoveredNodeId,
|
||||
dragPreview,
|
||||
@@ -77,12 +74,10 @@ export function useCanvasRenderModel({
|
||||
emotionNodeId,
|
||||
previewNodeId,
|
||||
contextMenu,
|
||||
versionCompareRootId,
|
||||
previsNodeId,
|
||||
scriptEditorNodeId,
|
||||
dialogNodeId,
|
||||
}: UseCanvasRenderModelOptions) {
|
||||
const reduceMediaEffects = useMemo(() => shouldReduceCanvasMediaEffects(mediaPerformanceMode, nodes), [mediaPerformanceMode, nodes]);
|
||||
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
|
||||
// These maps are consumed by both virtualization and node chrome. Building
|
||||
// them together keeps a metadata update from walking a 50k-node array six
|
||||
@@ -143,16 +138,16 @@ export function useCanvasRenderModel({
|
||||
}, [collapsingBatchIds, nodeById, nodes]);
|
||||
const { batchChildCountById, batchMotionById, canvasImageNodes, collapsedBatchChildIds, frameChildrenById, renderHiddenNodeIds } = nodeDerivedData;
|
||||
const connectionLayerBounds = useMemo(() => {
|
||||
const padding = (reduceMediaEffects ? 96 : 144) / Math.max(viewport.k, 0.05);
|
||||
const padding = 144 / Math.max(viewport.k, 0.05);
|
||||
const left = -viewport.x / viewport.k - padding;
|
||||
const top = -viewport.y / viewport.k - padding;
|
||||
const width = viewportSize.width / viewport.k + padding * 2;
|
||||
const height = viewportSize.height / viewport.k + padding * 2;
|
||||
return { left, top, width: Math.max(2, width), height: Math.max(2, height) };
|
||||
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
|
||||
}, [viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
|
||||
const renderBounds = useMemo(() => {
|
||||
const enterPadding = canvasNodeRenderPadding(reduceMediaEffects, false) / viewport.k;
|
||||
const retainPadding = canvasNodeRenderPadding(reduceMediaEffects, true) / viewport.k;
|
||||
const enterPadding = canvasNodeRenderPadding(false) / viewport.k;
|
||||
const retainPadding = canvasNodeRenderPadding(true) / viewport.k;
|
||||
const viewLeft = -viewport.x / viewport.k;
|
||||
const viewTop = -viewport.y / viewport.k;
|
||||
const viewWidth = viewportSize.width / viewport.k;
|
||||
@@ -162,7 +157,7 @@ export function useCanvasRenderModel({
|
||||
enter: { left: viewLeft - enterPadding, top: viewTop - enterPadding, right: viewLeft + viewWidth + enterPadding, bottom: viewTop + viewHeight + enterPadding },
|
||||
retain: { left: viewLeft - retainPadding, top: viewTop - retainPadding, right: viewLeft + viewWidth + retainPadding, bottom: viewTop + viewHeight + retainPadding },
|
||||
};
|
||||
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
|
||||
}, [viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
|
||||
const layoutNodesRef = useRef<CanvasNodeData[]>(nodes);
|
||||
const layoutNodes = useMemo(() => {
|
||||
const retained = retainCanvasLayoutNodes(layoutNodesRef.current, nodes);
|
||||
@@ -268,10 +263,6 @@ export function useCanvasRenderModel({
|
||||
if (!positionOnlyChange) semanticNodesRef.current = nodes;
|
||||
return semanticNodesRef.current;
|
||||
}, [nodes]);
|
||||
const versionCompareNodes = useMemo(() => {
|
||||
if (!versionCompareRootId) return [];
|
||||
return nodes.filter((node) => (node.metadata?.versionOfNodeId || node.id) === versionCompareRootId).sort((a, b) => (a.metadata?.versionLabel || "").localeCompare(b.metadata?.versionLabel || ""));
|
||||
}, [nodes, versionCompareRootId]);
|
||||
|
||||
const selectedNodeIdForToolbar = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
|
||||
const toolbarCandidate = selectedNodeIdForToolbar ? nodeById.get(selectedNodeIdForToolbar) || null : null;
|
||||
@@ -440,7 +431,6 @@ export function useCanvasRenderModel({
|
||||
nodeById,
|
||||
nodeRenderLODById,
|
||||
previewNode,
|
||||
reduceMediaEffects,
|
||||
relatedHighlight,
|
||||
resourceReferenceByNodeId,
|
||||
selectedNodeBounds,
|
||||
@@ -451,7 +441,6 @@ export function useCanvasRenderModel({
|
||||
superResolveNode,
|
||||
toolbarNode,
|
||||
upscaleNode,
|
||||
versionCompareNodes,
|
||||
visibleNodes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ test("world-space approach is independent of viewport scaling", () => {
|
||||
test("only connection targets receive approach data; memo tracks it and selection does not activate tilt", () => {
|
||||
const source = readFileSync(new URL("../src/components/canvas/canvas-node.tsx", import.meta.url), "utf8");
|
||||
const layers = readFileSync(new URL("../src/pages/canvas/canvas-project-world-layers.tsx", import.meta.url), "utf8");
|
||||
expect(source).toContain("isConnectionTarget && !reduceMediaEffects && !dragOffset");
|
||||
expect(source).toContain("isConnectionTarget && !disableMediaEffects && !dragOffset");
|
||||
expect(source).toContain("previous.connectionApproach?.x === next.connectionApproach?.x");
|
||||
expect(source).toContain('data-connection-tilt={connectionTilt ? "true" : undefined}');
|
||||
expect(layers).toContain("props.connectionApproach?.nodeId === node.id ? props.connectionApproach.point : undefined");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasNodeRenderPadding, resolveActiveCanvasMediaNodeId } from "../src/lib/canvas/canvas-performance-mode";
|
||||
import { canvasNodeRenderPadding } from "../src/lib/canvas/canvas-render-budget";
|
||||
import { videoMetadata } from "../src/lib/canvas/canvas-generation-task-sync";
|
||||
import { canvasNodeVideoPreviewReference, canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "../src/lib/canvas/canvas-media-preview";
|
||||
import { collectImageStorageKeys } from "../src/services/image-storage";
|
||||
@@ -34,23 +34,6 @@ function node(id: string, type: CanvasNodeType): CanvasNodeData {
|
||||
return { id, type, title: id, position: { x: 0, y: 0 }, width: 320, height: 180, metadata: {} };
|
||||
}
|
||||
|
||||
describe("canvas media activation", () => {
|
||||
const nodes = [node("video", CanvasNodeType.Video), node("audio", CanvasNodeType.Audio), node("image", CanvasNodeType.Image)];
|
||||
const nodeById = new Map(nodes.map((item) => [item.id, item]));
|
||||
|
||||
test("activates exactly one selected video or audio node", () => {
|
||||
expect(resolveActiveCanvasMediaNodeId(new Set(["video"]), nodeById)).toBe("video");
|
||||
expect(resolveActiveCanvasMediaNodeId(new Set(["audio"]), nodeById)).toBe("audio");
|
||||
});
|
||||
|
||||
test("keeps media inactive for empty, multi-selection, and non-media selection", () => {
|
||||
expect(resolveActiveCanvasMediaNodeId(new Set(), nodeById)).toBeNull();
|
||||
expect(resolveActiveCanvasMediaNodeId(new Set(["video", "audio"]), nodeById)).toBeNull();
|
||||
expect(resolveActiveCanvasMediaNodeId(new Set(["image"]), nodeById)).toBeNull();
|
||||
expect(resolveActiveCanvasMediaNodeId(new Set(["missing"]), nodeById)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas dimension header rendering", () => {
|
||||
test("tracks live viewport scale without React width commits", () => {
|
||||
expect(canvasNodeSource).toContain("calc(var(--canvas-node-width) * var(--canvas-live-scale, 1))");
|
||||
@@ -60,9 +43,8 @@ describe("canvas dimension header rendering", () => {
|
||||
|
||||
describe("large canvas media rendering", () => {
|
||||
test("keeps rendered nodes mounted longer than newly entering nodes", () => {
|
||||
expect(canvasNodeRenderPadding(true, false)).toBe(128);
|
||||
expect(canvasNodeRenderPadding(true, true)).toBe(640);
|
||||
expect(canvasNodeRenderPadding(false, true)).toBeGreaterThan(canvasNodeRenderPadding(false, false));
|
||||
expect(canvasNodeRenderPadding(false)).toBe(192);
|
||||
expect(canvasNodeRenderPadding(true)).toBe(384);
|
||||
});
|
||||
|
||||
test("does not eagerly load or resize LibTV thumbnails", () => {
|
||||
@@ -76,6 +58,12 @@ describe("large canvas media rendering", () => {
|
||||
expect(canvasProjectSource).toContain("<CanvasNodeActionContext.Provider value={canvasNodeActions}>");
|
||||
});
|
||||
|
||||
test("decodes canvas images synchronously so zoom re-rasterization never paints blank frames", () => {
|
||||
const retainedImageSource = canvasNodeContentSource.match(/export function RetainedCanvasImage[\s\S]*?\n}\n/)?.[0] || "";
|
||||
expect(retainedImageSource).toContain('decoding="sync"');
|
||||
expect(retainedImageSource).not.toContain('decoding="async"');
|
||||
});
|
||||
|
||||
test("keeps node shells visible while panning and zooming", () => {
|
||||
expect(globalStylesSource).not.toMatch(/\[data-canvas-viewport-interacting="true"\]\s+\.canvas-node-shell\s*\{[^}]*content-visibility:\s*hidden/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user