perf(canvas): 画布媒体 - 优化媒体加载、播放器与节点交互 (#357)

Merge origin/main at fb089b2 and retain canvas media performance, player layout, and interaction updates.

Co-authored-by: ddCat <2695092045@qq.com>
This commit is contained in:
wurendao007
2026-08-31 09:00:37 +08:00
committed by GitHub
parent 21facff923
commit 8ebceecbb6
58 changed files with 2662 additions and 207 deletions

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, RefreshCw, Settings2, Star, Trash2, Type, Video } from "lucide-react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, RefreshCw, Settings2, Star, Trash2, Type, Video } from "lucide-react";
import { useCanvasNodeActions } from "./canvas-node-action-context";
@@ -23,6 +23,8 @@ type CanvasNodeProps = {
dragOffset?: Position;
scale: number;
isSelected: boolean;
mediaActive?: boolean;
hydrateMediaPreview?: boolean;
isRelated: boolean;
isFocusRelated: boolean;
isConnectionTarget: boolean;
@@ -67,6 +69,8 @@ export const CanvasNode = React.memo(function CanvasNode({
dragOffset,
scale,
isSelected,
mediaActive = false,
hydrateMediaPreview = false,
isRelated,
isFocusRelated,
isConnectionTarget,
@@ -112,7 +116,7 @@ export const CanvasNode = React.memo(function CanvasNode({
const { download: downloadNode, duplicate: duplicateNode, deleteNode } = useCanvasNodeActions();
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content || data.metadata?.storageKey);
const mediaDimensionLabel = formatMediaDimensionLabel(data, hasImageContent || hasVideoContent);
const isComposerNode = data.type === CanvasNodeType.Config;
const hasMediaContent = hasImageContent || hasVideoContent || hasAudioContent;
@@ -261,7 +265,7 @@ export const CanvasNode = React.memo(function CanvasNode({
return (
<div
data-node-id={data.id}
className={`node-element absolute flex select-none flex-col ${dragOffset ? "cursor-grabbing" : data.type === CanvasNodeType.Drawing ? "cursor-pointer" : "cursor-default"} ${isSelected ? "z-[var(--z-node-active)]" : "z-[var(--z-node)]"}`}
className={`node-element absolute flex select-none flex-col ${dragOffset ? "cursor-grabbing" : data.type === CanvasNodeType.Drawing ? "cursor-pointer" : "cursor-default"} ${isSelected && data.type === CanvasNodeType.Video ? "z-[var(--z-node-toolbar)]" : isSelected || isFocusRelated || isConnectionTarget ? "z-[var(--z-node-active)]" : "z-[var(--z-node)]"}`}
style={{
transform: `translate(${data.position.x + (dragOffset?.x || 0)}px, ${data.position.y + (dragOffset?.y || 0)}px)`,
width: data.width,
@@ -282,7 +286,7 @@ export const CanvasNode = React.memo(function CanvasNode({
node={data}
scale={scale}
dimensionLabel={mediaDimensionLabel}
active={hovered || isSelected || isFocusRelated}
active={isSelected || isFocusRelated}
editable={!readOnly && !data.metadata?.locked && Boolean(onTitleChange)}
editing={isEditingTitle}
draft={titleDraft}
@@ -300,7 +304,7 @@ export const CanvasNode = React.memo(function CanvasNode({
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
// 固定占位但不绘制描边,避免聚焦切换时边框宽度变化造成白边跳动。
border: isComposerNode ? "0" : "1px solid transparent",
boxShadow: isComposerNode ? "none" : isSelected || hovered ? theme.node.hoverShadow : theme.node.shadow,
boxShadow: isComposerNode ? "none" : isSelected || isFocusRelated ? theme.node.hoverShadow : theme.node.shadow,
}}
onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => {
@@ -338,7 +342,7 @@ export const CanvasNode = React.memo(function CanvasNode({
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script ? "overflow-visible" : "overflow-hidden"}`}
style={
{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
background: hasImageContent || hasVideoContent || hasAudioContent ? "transparent" : theme.node.fill,
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
@@ -371,6 +375,8 @@ export const CanvasNode = React.memo(function CanvasNode({
onOpenTaskDetails={onOpenTaskDetails}
onToggleBatch={() => onToggleBatch?.(data.id)}
reduceMediaEffects={reduceMediaEffects}
mediaActive={mediaActive}
hydrateMediaPreview={hydrateMediaPreview}
/>
</div>
@@ -460,8 +466,8 @@ export const CanvasNode = React.memo(function CanvasNode({
</> : null}
</div>
{!readOnly && data.type !== CanvasNodeType.Script && (hovered || forceInputVisible) ? <ConnectionSideRail side="left" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && showOutputConnection && hovered ? <ConnectionSideRail side="right" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionSideRail side="left" scale={scale} theme={theme} visible={hovered || forceInputVisible} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && showOutputConnection ? <ConnectionSideRail side="right" scale={scale} theme={theme} visible={hovered} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
</div>
);
@@ -474,6 +480,7 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
previous.dragOffset?.y === next.dragOffset?.y &&
previous.scale === next.scale &&
previous.isSelected === next.isSelected &&
previous.mediaActive === next.mediaActive &&
previous.isRelated === next.isRelated &&
previous.isFocusRelated === next.isFocusRelated &&
previous.isConnectionTarget === next.isConnectionTarget &&
@@ -629,21 +636,21 @@ function NodeExternalHeader({ node, scale, dimensionLabel, active, editable, edi
const inverseScale = 1 / Math.max(scale, 0.05);
const Icon = nodeTypeIcon(node.type);
const maxHeaderWidth = Math.min(240, node.width * scale);
const externalHeaderWidth = node.width * scale;
return (
<div
className="canvas-node-external-header absolute bottom-full left-0 z-[var(--node-z-overlay)] flex h-6 items-center gap-1 overflow-hidden"
style={{
width: dimensionLabel ? externalHeaderWidth : undefined,
width: dimensionLabel ? "calc(var(--canvas-node-width) * var(--canvas-live-scale, 1))" : undefined,
maxWidth: dimensionLabel ? undefined : maxHeaderWidth,
"--canvas-node-width": `${node.width}px`,
borderRadius: "var(--r-sm)",
background: "transparent",
paddingInline: "var(--space-1-half)",
color: active ? theme.node.text : theme.node.label,
transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`,
transformOrigin: "left bottom",
}}
} as React.CSSProperties}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
@@ -729,53 +736,93 @@ function NodeStatusBadge({ status }: { status: "loading" | "success" | "error" }
);
}
function ConnectionSideRail({ side, scale, theme, onPointerDown }: { side: "left" | "right"; scale: number; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) {
function ConnectionSideRail({ side, scale, theme, visible = false, onPointerDown }: { side: "left" | "right"; scale: number; theme: CanvasTheme; visible?: boolean; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) {
const handleRef = useRef<HTMLSpanElement>(null);
const anchorRatioRef = useRef(0.5);
const [railHovered, setRailHovered] = useState(false);
const inverseScale = 1 / Math.max(scale, 0.05);
const railSize = 80;
// Keep the control responsive to zoom, but avoid sub-pixel circles and
// strokes at far zoom levels where the plus sign appears visually off-center.
const handleSize = Math.max(20, 8 * inverseScale);
// LibTV centers the visual quick-add icon in an approximately 80px
// circular hit zone, then offsets it toward the node edge. Keep that
// visual layer separate from the real centered connection anchor.
const sideOffset = side === "left" ? 25 : -25;
const resetAnchor = useCallback(() => {
anchorRatioRef.current = 0.5;
if (handleRef.current) handleRef.current.style.top = "50%";
}, []);
const resetHandle = useCallback(() => {
if (!handleRef.current) return;
handleRef.current.style.transform = `translate(${sideOffset}px, 0) scale(1)`;
}, [sideOffset]);
const updateAnchor = (event: React.PointerEvent<HTMLButtonElement>) => {
const railBounds = event.currentTarget.getBoundingClientRect();
const nodeBounds = event.currentTarget.parentElement?.getBoundingClientRect() || railBounds;
const screenPadding = Math.min(railBounds.height * 0.35, 12);
const railRatio = Math.min(1 - screenPadding / Math.max(railBounds.height, 1), Math.max(screenPadding / Math.max(railBounds.height, 1), (event.clientY - railBounds.top) / Math.max(railBounds.height, 1)));
const anchorY = railBounds.top + railBounds.height * railRatio;
anchorRatioRef.current = Math.min(1, Math.max(0, (anchorY - nodeBounds.top) / Math.max(nodeBounds.height, 1)));
if (handleRef.current) handleRef.current.style.top = `${railRatio * 100}%`;
const updateHandle = (event: React.PointerEvent<HTMLButtonElement>) => {
const bounds = event.currentTarget.getBoundingClientRect();
const width = Math.max(bounds.width, 1);
const height = Math.max(bounds.height, 1);
const followLimit = 30;
const deltaX = event.clientX - (bounds.left + width / 2);
const deltaY = event.clientY - (bounds.top + height / 2);
const offsetScreenX = Math.max(-followLimit, Math.min(followLimit, deltaX));
const offsetScreenY = Math.max(-followLimit, Math.min(followLimit, deltaY));
const focus = 1 + Math.max(0, 1 - Math.hypot(offsetScreenX, offsetScreenY) / followLimit) * 0.1;
// While the pointer is inside the rail LibTV uses the rail center as
// the origin. The side-specific +/-25px offset is only the resting
// position used after leaving the rail.
const offsetX = offsetScreenX * inverseScale;
const offsetY = offsetScreenY * inverseScale;
if (handleRef.current) handleRef.current.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${focus})`;
};
return (
<button
type="button"
className="group pointer-events-auto absolute top-1/2 z-[var(--node-z-overlay)] touch-none -translate-y-1/2 opacity-100 outline-none transition-opacity duration-150"
style={{ width: 56 * inverseScale, height: `min(100%, ${72 * inverseScale}px)`, ...(side === "left" ? { right: "100%" } : { left: "100%" }) }}
onPointerEnter={updateAnchor}
onPointerMove={updateAnchor}
onPointerLeave={resetAnchor}
onPointerDown={(event) => onPointerDown(event, anchorRatioRef.current)}
data-canvas-connection-rail={side}
className={`group pointer-events-auto absolute top-1/2 z-[var(--node-z-overlay)] flex -translate-y-1/2 items-center justify-center touch-none cursor-crosshair rounded-full outline-none transition-opacity duration-150 ${visible || railHovered ? "opacity-100" : "opacity-0"}`}
style={{ width: railSize, height: `min(100%, ${railSize}px)`, cursor: "crosshair", ...(side === "left" ? { right: "100%" } : { left: "100%" }) }}
onPointerEnter={(event) => {
setRailHovered(true);
updateHandle(event);
}}
onPointerMove={updateHandle}
onPointerLeave={() => {
setRailHovered(false);
resetHandle();
}}
onPointerDown={(event) => onPointerDown(event, 0.5)}
aria-label={`${side === "left" ? "输入" : "输出"}连接点,单击创建节点或拖动连线`}
>
<span
ref={handleRef}
className="absolute grid -translate-y-1/2 place-items-center rounded-full border transition-[background-color,box-shadow] duration-150 group-hover:brightness-125 group-focus-visible:brightness-125"
className="absolute left-1/2 top-1/2 block transition-transform duration-[80ms] ease-out group-hover:brightness-125 group-focus-visible:brightness-125"
style={{
top: "50%",
width: 18 * inverseScale,
height: 18 * inverseScale,
...(side === "left" ? { right: 6 * inverseScale } : { left: 6 * inverseScale }),
borderWidth: inverseScale,
background: theme.spatial.elevated,
borderColor: theme.node.activeStroke,
color: theme.node.activeStroke,
boxShadow: "none",
width: handleSize,
height: handleSize,
marginLeft: -handleSize / 2,
marginTop: -handleSize / 2,
transform: `translate(${sideOffset}px, 0) scale(1)`,
transition: "transform 80ms ease-out",
transformOrigin: "center",
willChange: "transform",
}}
>
<Plus style={{ width: 10 * inverseScale, height: 10 * inverseScale }} strokeWidth={2} />
<svg
aria-hidden="true"
className="block"
width={handleSize}
height={handleSize}
viewBox="0 0 20 20"
fill="none"
style={{
position: "absolute",
left: "50%",
top: "50%",
marginLeft: -handleSize / 2,
marginTop: -handleSize / 2,
}}
>
<circle cx="10" cy="10" r="9.35" fill={theme.spatial.elevated} />
<circle cx="10" cy="10" r="9.35" stroke={theme.node.activeStroke} strokeWidth="1.2" />
<path d="M10 6.5v7M6.5 10h7" stroke={theme.node.activeStroke} strokeWidth="1.5" strokeLinecap="round" />
</svg>
</span>
</button>
);