perf(canvas): 画布渲染 - 增加节点渲染预算与性能模式控制

This commit is contained in:
ddcat666
2026-10-11 22:20:33 +08:00
parent 5f359f2b1d
commit a8edb6f27f
12 changed files with 57 additions and 156 deletions

View File

@@ -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

View File

@@ -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)) {

View File

@@ -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}`}

View File

@@ -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 &&

View File

@@ -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"

View File

@@ -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;
}

View 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;
}

View File

@@ -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}

View File

@@ -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") {

View File

@@ -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,
};
}

View File

@@ -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");

View File

@@ -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/);
});