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"