支持资源图片卡按真实比例布局

图片预览返回并透传像素宽高。

统一计算图片卡边界并让布局碰撞、自动位置和分区范围消费实际尺寸。

依赖连线按逐资源矩形计算端点并补充模型与预览测试。
This commit is contained in:
2026-08-20 22:40:48 +08:00
parent 733368cd54
commit aaac44a622
9 changed files with 596 additions and 70 deletions
@@ -24,6 +24,8 @@ pub(crate) struct LocalProjectImagePreview {
pub(crate) path: String,
pub(crate) media_type: String,
pub(crate) byte_len: u64,
pub(crate) pixel_width: u32,
pub(crate) pixel_height: u32,
pub(crate) data_url: String,
}
@@ -32,6 +34,8 @@ pub(crate) struct AgentRuntimeInspectionImage {
pub(crate) sha256: String,
pub(crate) byte_len: u64,
pub(crate) media_type: &'static str,
pixel_width: u32,
pixel_height: u32,
bytes: Vec<u8>,
}
@@ -86,6 +90,8 @@ pub(crate) fn load_local_project_image_preview_with_cancellation(
path: image.relative_path.clone(),
media_type: image.media_type.to_string(),
byte_len: image.byte_len,
pixel_width: image.pixel_width,
pixel_height: image.pixel_height,
data_url: image.data_url_with_cancellation(cancellation)?,
})
}
@@ -360,6 +366,8 @@ fn read_agent_runtime_inspection_image_with_cancellation(
sha256,
byte_len: bytes.len() as u64,
media_type,
pixel_width: width,
pixel_height: height,
bytes,
})
}
@@ -6142,6 +6142,12 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
.expect("auto preview");
assert_eq!(preview.media_type, "image/png");
assert_eq!(preview.pixel_width, 1);
assert_eq!(preview.pixel_height, 1);
let serialized_preview =
serde_json::to_value(&preview).expect("serialize image preview");
assert_eq!(serialized_preview["pixelWidth"], 1);
assert_eq!(serialized_preview["pixelHeight"], 1);
fs::write(root.join("assets/unregistered.png"), &preview_bytes).expect("unregistered image");
let unregistered_error = read_local_project_image_preview_at(
@@ -13,8 +13,8 @@ import type {
ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
resourceCanvasCardSize,
type ResourceCanvasCardSizeByResourceId,
} from './resourceCanvasLayoutModel';
import {
type ProjectResourceGraph,
@@ -40,6 +40,7 @@ export type ResourceDependencyOverlayProps = {
positions: readonly ProjectResourceCanvasPosition[];
section: ProjectResourceCanvasSection;
visibleResourceIds: ReadonlySet<string>;
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId;
geometryRevision?: string;
};
@@ -458,7 +459,14 @@ export const ResourceDependencyOverlay = forwardRef<
ResourceDependencyOverlayHandle,
ResourceDependencyOverlayProps
>(function ResourceDependencyOverlay(
{ geometryRevision = '', graph, positions, section, visibleResourceIds },
{
cardSizeByResourceId,
geometryRevision = '',
graph,
positions,
section,
visibleResourceIds,
},
ref,
) {
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
@@ -545,6 +553,10 @@ export const ResourceDependencyOverlay = forwardRef<
) {
continue;
}
const cardSize = resourceCanvasCardSize(
position.resourceId,
cardSizeByResourceId,
);
result.set(position.resourceId, {
x:
dragPreview?.resourceId === position.resourceId
@@ -554,12 +566,19 @@ export const ResourceDependencyOverlay = forwardRef<
dragPreview?.resourceId === position.resourceId
? dragPreview.y
: position.y,
width: RESOURCE_CANVAS_CARD_WIDTH,
height: RESOURCE_CANVAS_CARD_HEIGHT,
width: cardSize.width,
height: cardSize.height,
});
}
return result;
}, [dragPreview, graph.resourceIds, positions, section, visibleResourceIds]);
}, [
cardSizeByResourceId,
dragPreview,
graph.resourceIds,
positions,
section,
visibleResourceIds,
]);
const sectionByResourceId = useMemo(
() =>
new Map(
File diff suppressed because it is too large Load Diff
@@ -18,10 +18,17 @@ export type ProjectResourceCardPreviewKind =
| 'version'
| 'placeholder';
export type ProjectResourceImageDimensions = {
pixelWidth: number;
pixelHeight: number;
};
export type ProjectResourceCardPreviewPayload = {
path: string;
mediaType: string;
byteLen: number;
pixelWidth?: number;
pixelHeight?: number;
sourceUrl?: string;
content?: string;
};
@@ -33,6 +40,29 @@ export type ProjectResourceCardPreviewTransportPayload = Omit<
dataUrl?: string;
};
export function projectResourceCardPreviewImageDimensions(
preview: Pick<
ProjectResourceCardPreviewPayload,
'pixelWidth' | 'pixelHeight'
>,
): ProjectResourceImageDimensions | null {
const { pixelWidth, pixelHeight } = preview;
if (pixelWidth === undefined && pixelHeight === undefined) {
return null;
}
if (
typeof pixelWidth !== 'number' ||
typeof pixelHeight !== 'number' ||
!Number.isSafeInteger(pixelWidth) ||
!Number.isSafeInteger(pixelHeight) ||
pixelWidth <= 0 ||
pixelHeight <= 0
) {
throw new Error('图片预览像素尺寸无效');
}
return { pixelWidth, pixelHeight };
}
export type ProjectResourceCardPreviewCacheEntry = {
identity: string;
retainedBytes: number;
@@ -23,6 +23,7 @@ import {
PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT,
projectResourceCardPreviewEvictionIdentities,
projectResourceCardPreviewIdentity,
projectResourceCardPreviewImageDimensions,
projectResourceCardPreviewKind,
type ProjectResourceCardPreviewPayload,
type ProjectResourceCardPreviewState,
@@ -133,6 +134,8 @@ function materializeProjectResourceCardPreview(
path: transport.path,
mediaType: transport.mediaType,
byteLen: transport.byteLen,
pixelWidth: transport.pixelWidth,
pixelHeight: transport.pixelHeight,
content: transport.content,
},
retainedBytes:
@@ -142,6 +145,7 @@ function materializeProjectResourceCardPreview(
objectUrl: null,
};
}
const imageDimensions = projectResourceCardPreviewImageDimensions(transport);
const prefix = `data:${transport.mediaType};base64,`;
if (!transport.dataUrl.startsWith(prefix)) {
throw new Error('媒体预览内容无效:data URL 类型与媒体类型不一致');
@@ -166,6 +170,8 @@ function materializeProjectResourceCardPreview(
path: transport.path,
mediaType: transport.mediaType,
byteLen: transport.byteLen,
pixelWidth: imageDimensions?.pixelWidth,
pixelHeight: imageDimensions?.pixelHeight,
sourceUrl: objectUrl,
},
retainedBytes: blob.size,
@@ -640,6 +646,31 @@ export function useProjectResourceCardPreviews(input: {
requestPreview,
]);
const imageDimensionsByResourceId = useMemo(() => {
const result = new Map<
string,
NonNullable<ReturnType<typeof projectResourceCardPreviewImageDimensions>>
>();
for (const resource of input.resources) {
const identity = identityByResourceId.get(resource.id);
const state = identity ? previews.get(identity) : undefined;
const kind = projectResourceCardPreviewKind(resource);
if (
state?.status !== 'loaded' ||
(kind !== 'raster-image' && kind !== 'media-image')
) {
continue;
}
const dimensions = projectResourceCardPreviewImageDimensions(
state.preview,
);
if (dimensions) {
result.set(resource.id, dimensions);
}
}
return result;
}, [identityByResourceId, input.resources, previews]);
useEffect(
() => () => {
cancelLocalProjectResourcePreviewScope(scopeIdRef.current);
@@ -695,6 +726,7 @@ export function useProjectResourceCardPreviews(input: {
return {
identityByResourceId,
previews,
imageDimensionsByResourceId,
idlePreview: IDLE_PROJECT_RESOURCE_CARD_PREVIEW,
observePreview,
requestPreview,
@@ -17,6 +17,7 @@ import type { ProjectResourceCanvasSection } from '../../../packages/shared/src/
import {
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
type ResourceCanvasCardSize,
} from '../src/view/project-development/resourceCanvasLayoutModel';
import {
normalizeProjectResourceGraph,
@@ -538,6 +539,52 @@ describe('ResourceDependencyOverlay', () => {
);
});
it('routes references from each resource actual card rectangle', async () => {
const graph = graphFixture();
const cardSizeByResourceId = new Map<string, ResourceCanvasCardSize>([
['source:one', { width: 220, height: 180 }],
['target:one', { width: 96, height: 180 }],
]);
render(
React.createElement(
'div',
{ 'data-testid': 'resource-dependency-overlay' },
React.createElement(
'div',
{ 'data-resource-section-scroll': 'art' },
React.createElement(
'div',
{
'data-resource-section-plane': 'art',
'data-resource-section-scale': '1',
},
React.createElement(ResourceDependencyOverlay, {
graph,
positions: [
position('source:one', 0, 0),
position('target:one', 268, 0),
],
section: 'art',
visibleResourceIds: new Set(['source:one', 'target:one']),
cardSizeByResourceId,
}),
),
),
),
);
const overlay = await screen.findByTestId('resource-dependency-overlay');
const reference = await waitFor(() => {
const path = overlay.querySelector<SVGPathElement>(
'[data-edge-kind="asset-reference"]',
);
expect(path).not.toBeNull();
return path as SVGPathElement;
});
expect(reference.getAttribute('data-route-axis')).toBe('horizontal');
expect(reference.getAttribute('d')).toBe('M 220 90 L 258 90');
});
it('keeps a relationship mounted while scrolling partial cards and marks an offscreen continuation', async () => {
const graph = graphFixture();
const originalGetBoundingClientRect =
@@ -6,6 +6,8 @@ import {
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
createEmptyResourceCanvasLayout,
createResourceCanvasCardSizeByResourceId,
DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
moveResourceCanvasPosition,
reconcileResourceCanvasLayout,
RESOURCE_CANVAS_CARD_HEIGHT,
@@ -13,8 +15,11 @@ import {
RESOURCE_CANVAS_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
type ResourceCanvasCardSize,
resourceCanvasImageCardSize,
type ResourceCanvasItem,
type ResourceCanvasLayoutTopology,
resourceCanvasSectionExtent,
} from '../src/view/project-development/resourceCanvasLayoutModel';
function resource(
@@ -582,3 +587,113 @@ describe('resource canvas layout model', () => {
expect(performance.now() - startedAt).toBeLessThan(2000);
});
});
describe('resource canvas variable card geometry', () => {
it.each([
[1, 1, { width: 180, height: 180 }],
[16, 9, { width: 220, height: 124 }],
[9, 16, { width: 101, height: 180 }],
[2, 3, { width: 120, height: 180 }],
[3, 2, { width: 220, height: 147 }],
[100, 1, { width: 220, height: 110 }],
[1, 100, { width: 96, height: 180 }],
])(
'bounds a %d:%d image inside the shared card limits',
(pixelWidth, pixelHeight, expected) => {
expect(resourceCanvasImageCardSize({ pixelWidth, pixelHeight })).toEqual(
expected,
);
},
);
it('keeps non-images and images without metadata at the fixed fallback size', () => {
const sizes = createResourceCanvasCardSizeByResourceId(
[resource('image', 'art'), resource('document', 'document')],
new Map([
['image', { pixelWidth: 1920, pixelHeight: 1080 }],
['document', { pixelWidth: 1920, pixelHeight: 1080 }],
]),
);
expect(sizes.get('image')).toEqual({ width: 220, height: 124 });
expect(sizes.get('document')).toEqual(DEFAULT_RESOURCE_CANVAS_CARD_SIZE);
expect(
createResourceCanvasCardSizeByResourceId(
[resource('pending-image', 'art')],
new Map(),
).get('pending-image'),
).toEqual(DEFAULT_RESOURCE_CANVAS_CARD_SIZE);
});
it('uses actual widths and heights for type placement collision checks', () => {
const resources = [resource('wide-a', 'art'), resource('wide-b', 'art')];
const cardSizes = new Map<string, ResourceCanvasCardSize>([
['wide-a', { width: 220, height: 180 }],
['wide-b', { width: 220, height: 180 }],
]);
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('variable-type', 'type'),
resources,
undefined,
cardSizes,
).layout;
expect(positionById(layout, 'wide-a')).toMatchObject({ x: 0, y: 0 });
expect(positionById(layout, 'wide-b')).toMatchObject({
x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP),
y: 0,
});
});
it('uses actual dimensions for dependency columns, rows, and section extent', () => {
const resources = [
resource('source-a', 'art', 0),
resource('source-b', 'art', 0),
resource('target', 'art', 1),
];
const cardSizes = new Map<string, ResourceCanvasCardSize>([
['source-a', { width: 220, height: 180 }],
['source-b', { width: 96, height: 180 }],
['target', { width: 180, height: 128 }],
]);
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('variable-dependency', 'dependency'),
resources,
undefined,
cardSizes,
).layout;
expect(positionById(layout, 'source-a')).toMatchObject({ x: 0, y: 0 });
expect(positionById(layout, 'source-b')).toMatchObject({
x: 0,
y: 180 + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
});
expect(positionById(layout, 'target')).toMatchObject({
x: 220 + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
y: 0,
});
expect(resourceCanvasSectionExtent(layout.positions, cardSizes)).toEqual({
width: 620,
height: 416,
});
});
it('accepts card sizes directly on resource items for hook integration', () => {
const wide = {
...resource('wide', 'art', 0),
cardSize: { width: 220, height: 180 },
};
const target = {
...resource('target', 'art', 1),
cardSize: { width: 180, height: 128 },
};
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('item-size', 'dependency'),
[wide, target],
).layout;
expect(positionById(layout, 'target').x).toBe(
wide.cardSize.width + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
);
});
});
@@ -49,6 +49,9 @@ function preview(path: string, mediaType = 'image/png') {
path,
mediaType,
byteLen: 4,
...(mediaType.startsWith('image/')
? { pixelWidth: 640, pixelHeight: 360 }
: {}),
dataUrl: `data:${mediaType};base64,dGVzdA==`,
};
}
@@ -125,6 +128,10 @@ describe('useProjectResourceCardPreviews', () => {
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
);
expect(previewReadCalls(invoke)).toHaveLength(1);
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
expect(previewReadCalls(invoke)[0]?.[1]).toMatchObject({
relativePath: art.path,
});