fix(canvas): 图片节点 - 尺寸跟随图片真实比例,竖图不再两侧留黑
上传接口返回的 width/height 是可选字段(services/api/resources.ts),拿不到时节点落到 默认的横向比例,竖图被放进一个宽盒子、两侧留大片空白。改为在图片解码后于客户端量真实 尺寸并按比例校正节点 —— 这样不依赖服务端是否返回尺寸。 判据是「用户有没有手动定过尺寸」,**不是**「有没有量过尺寸」。第一版按后者写,结果所有 已经存过 naturalWidth 的既有节点永远得不到修正,改完等于没改。现在由 handleNodeResize 在手动拉伸时打 metadata.manualSize 标记,自动适配只避让它和 freeResize(自由比例是用户 显式要求脱离原始比例,自动校正会跟他打架)。 顺带收益:naturalWidth / naturalHeight 补齐后,canvas-node.tsx 的锁比例拉伸也会按真实 比例走 —— 它本来就在读这两个字段,之前多数节点拿不到。 已知影响:既有节点因为没有 manualSize 标记,首次重新加载时会自动校正一次尺寸。 对被 letterbox 的图是修复,但确实会让画布上现有图片节点的尺寸变动一次。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ export type CanvasNodeActionContextValue = {
|
||||
deleteNode?: (node: CanvasNodeData) => void;
|
||||
/** 合并式更新节点 metadata;扩展节点(如调色)在自己的面板里改参数时用。 */
|
||||
updateMetadata?: (nodeId: string, patch: CanvasNodeMetadata) => void;
|
||||
/** 改节点宽高;图片首次量到真实尺寸后按比例校正节点用。 */
|
||||
resizeNode?: (nodeId: string, size: { width: number; height: number }) => void;
|
||||
};
|
||||
|
||||
export const CanvasNodeActionContext = createContext<CanvasNodeActionContextValue>({});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { VideoPlayer } from "@/components/video-player";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
|
||||
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel, isGenerationTaskSubmissionUncertain } from "@/lib/generation-task-display";
|
||||
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
|
||||
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { buildLibTVImagePreviewUrl } from "@/lib/canvas/libtv-import";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
@@ -16,6 +17,7 @@ import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/r
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { createDefaultSubtitleStyle } from "@/types/timeline";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import { useCanvasNodeActions } from "./canvas-node-action-context";
|
||||
import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay";
|
||||
import { MarkdownNodeContent } from "./nodes/markdown-node";
|
||||
import { ChartNodeContent } from "./nodes/chart-node";
|
||||
@@ -432,11 +434,36 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat
|
||||
const nearViewport = useNearViewport(imageContainerRef);
|
||||
const { url, loading } = useNodeResourceUrl(node, nearViewport);
|
||||
const importedFromLibTV = node.metadata?.importSource?.provider === "libtv";
|
||||
const { resizeNode, updateMetadata } = useCanvasNodeActions();
|
||||
|
||||
/**
|
||||
* 让节点跟随图片真实比例。
|
||||
*
|
||||
* 上传接口的 width/height 是可选的(services/api/resources.ts),拿不到时节点会落到
|
||||
* 默认的横向比例,竖图就被放进一个宽盒子、两侧留黑。这里在图片解码后量真实尺寸并校正。
|
||||
*
|
||||
* 判据是「用户有没有手动定过尺寸」,**不是**「有没有量过尺寸」——后者会让所有已经
|
||||
* 存过 naturalWidth 的旧节点永远得不到修正(第一版就是这么写的,所以没生效)。
|
||||
* 手动拉过(manualSize)或自由比例(freeResize)的节点只补记尺寸、不动宽高。
|
||||
*/
|
||||
const fitToImage = (element: HTMLImageElement) => {
|
||||
const naturalWidth = element.naturalWidth;
|
||||
const naturalHeight = element.naturalHeight;
|
||||
if (!naturalWidth || !naturalHeight) return;
|
||||
if (node.metadata?.naturalWidth !== naturalWidth || node.metadata?.naturalHeight !== naturalHeight) {
|
||||
updateMetadata?.(node.id, { naturalWidth, naturalHeight });
|
||||
}
|
||||
if (node.metadata?.freeResize || node.metadata?.manualSize) return;
|
||||
const size = fitNodeSize(naturalWidth, naturalHeight);
|
||||
// 差不到 1px 就别动,避免无意义的状态写入。
|
||||
if (Math.abs(size.width - node.width) < 1 && Math.abs(size.height - node.height) < 1) return;
|
||||
resizeNode?.(node.id, size);
|
||||
};
|
||||
|
||||
return (
|
||||
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
|
||||
<div ref={imageContainerRef} className="h-full w-full overflow-hidden rounded-[var(--node-radius)]">
|
||||
{url ? <img src={url} alt={node.title} loading={importedFromLibTV ? "eager" : "lazy"} decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
|
||||
{url ? <img src={url} alt={node.title} loading={importedFromLibTV ? "eager" : "lazy"} decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} onLoad={(event) => fitToImage(event.currentTarget)} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
|
||||
</div>
|
||||
</BatchFrame>
|
||||
);
|
||||
|
||||
@@ -1872,7 +1872,7 @@ function InfiniteCanvasPage() {
|
||||
onFileDragLeave={handleFileDragLeave}
|
||||
onFileDragOver={handleFileDragOver}
|
||||
>
|
||||
<CanvasNodeActionContext.Provider value={{ download: downloadNodeImage, duplicate: (node) => duplicateNode(node.id), deleteNode: (node) => deleteNodes(new Set([node.id])), updateMetadata: (nodeId, patch) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))) }}>
|
||||
<CanvasNodeActionContext.Provider value={{ download: downloadNodeImage, duplicate: (node) => duplicateNode(node.id), deleteNode: (node) => deleteNodes(new Set([node.id])), updateMetadata: (nodeId, patch) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))), resizeNode: (nodeId, size) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, width: size.width, height: size.height } : node))) }}>
|
||||
<CanvasNodeGraphContext.Provider value={nodeGraphContext}>
|
||||
<CanvasProjectWorldLayers
|
||||
projectId={projectId}
|
||||
|
||||
@@ -47,9 +47,11 @@ export function useCanvasNodeEditor({
|
||||
const nextPosition = position || node.position;
|
||||
if (node.width === width && node.height === height && node.position.x === nextPosition.x && node.position.y === nextPosition.y) return node;
|
||||
changed = true;
|
||||
const resized = { ...node, width, height, position: nextPosition };
|
||||
// 打上「用户手动定过尺寸」标记:图片按真实比例自动适配时要避让它,
|
||||
// 否则每次图片重新加载都会把用户拉过的尺寸改回去。
|
||||
const resized = { ...node, width, height, position: nextPosition, metadata: { ...node.metadata, manualSize: true } };
|
||||
if (!isFrameNode(node) || node.metadata?.frame?.collapsed) return resized;
|
||||
return { ...resized, metadata: { ...node.metadata, frame: { collapsed: false, expandedWidth: width, expandedHeight: height } } };
|
||||
return { ...resized, metadata: { ...resized.metadata, frame: { collapsed: false, expandedWidth: width, expandedHeight: height } } };
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
|
||||
@@ -308,6 +308,8 @@ export type CanvasNodeMetadata = {
|
||||
chartKind?: "bar" | "line";
|
||||
/** 调色节点的参数;缺省视为未调色。 */
|
||||
colorGrade?: CanvasColorGrade;
|
||||
/** 用户手动拉伸过尺寸;图片按真实比例自动适配时避让它。 */
|
||||
manualSize?: boolean;
|
||||
storyboard?: StoryboardData;
|
||||
storyboardShotDuration?: StoryboardShotDuration;
|
||||
storyboardShotCount?: StoryboardShotCount;
|
||||
|
||||
Reference in New Issue
Block a user