feat(file): persist image dimensions into file metadata (#15594)

*  feat(file): persist image dimensions into file metadata

Record intrinsic width/height for uploaded images so consumers can
reserve layout space (avoid CLS) without loading the file first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

*  test(file): assert persisted dimensions in upload createFile payload

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🔖 chore(cli): bump version to 0.0.26 and regenerate man page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

*  feat(file): record image aspect ratio alongside width/height

Compute intrinsic aspect ratio (width / height, rounded) at extraction
time and persist it into file metadata so consumers can group/reserve
layout by orientation without recomputing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-09 22:11:15 +08:00
committed by GitHub
parent 5b534f45d1
commit ce5833cb67
7 changed files with 40 additions and 17 deletions
+4 -1
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.24" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.26" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
@@ -113,6 +113,9 @@ Manage plugins
.B user
Manage user account and settings
.TP
.B verify
Manage the Agent Run delivery checker (criteria, rubrics, plans, results)
.TP
.B whoami
Display current user information
.TP
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.24",
"version": "0.0.26",
"type": "module",
"bin": {
"lh": "./dist/index.js",
+14
View File
@@ -57,7 +57,21 @@ export const FileMetadataSchema = z.object({
date: z.string(),
dirname: z.string(),
filename: z.string(),
/**
* intrinsic image height in pixels, recorded for images so consumers can
* reserve layout space (avoid CLS) without loading the file first
*/
height: z.number().optional(),
path: z.string(),
/**
* intrinsic image aspect ratio (width / height), recorded for images so
* consumers can group/reserve layout by orientation without recomputing
*/
ratio: z.number().optional(),
/**
* intrinsic image width in pixels, recorded for images
*/
width: z.number().optional(),
});
export type FileMetadata = z.infer<typeof FileMetadataSchema>;
@@ -49,7 +49,7 @@ describe('getImageDimensions', () => {
loadHandler?.();
const result = await resultPromise;
expect(result).toEqual({ height: 600, width: 800 });
expect(result).toEqual({ height: 600, ratio: 1.3333, width: 800 });
expect(mockImage).toHaveBeenCalledTimes(1);
expect(mockCreateObjectURL).toHaveBeenCalledWith(imageFile);
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
@@ -63,7 +63,7 @@ describe('getImageDimensions', () => {
loadHandler?.();
const result = await resultPromise;
expect(result).toEqual({ height: 600, width: 800 });
expect(result).toEqual({ height: 600, ratio: 1.3333, width: 800 });
expect(mockImage).toHaveBeenCalledTimes(1);
// Data URI should not use createObjectURL
expect(mockCreateObjectURL).not.toHaveBeenCalled();
+9 -4
View File
@@ -1,11 +1,11 @@
/**
* Helper function to extract image dimensions from File objects or base64 data URIs
* @param source The image source - either a File object or base64 data URI string
* @returns Promise resolving to dimensions or undefined if not an image or error occurs
* @returns Promise resolving to dimensions (incl. aspect ratio) or undefined if not an image or error occurs
*/
export const getImageDimensions = async (
source: File | string,
): Promise<{ height: number; width: number } | undefined> => {
): Promise<{ height: number; ratio: number; width: number } | undefined> => {
// Type guard and validation
if (typeof source === 'string') {
// Handle base64 data URI
@@ -20,9 +20,14 @@ export const getImageDimensions = async (
let objectUrl: string | null = null;
const handleLoad = () => {
const height = img.naturalHeight;
const width = img.naturalWidth;
resolve({
height: img.naturalHeight,
width: img.naturalWidth,
height,
// intrinsic aspect ratio (width / height), rounded to avoid float noise;
// 0 when height is missing to avoid Infinity/NaN
ratio: height > 0 ? Math.round((width / height) * 10_000) / 10_000 : 0,
width,
});
// Clean up object URL if created
if (objectUrl) {
+7 -7
View File
@@ -65,7 +65,7 @@ describe('FileUploadAction', () => {
const { result } = renderHook(() => useStore());
const base64Data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA';
const mockDimensions = { height: 100, width: 200 };
const mockDimensions = { height: 100, ratio: 2, width: 200 };
const mockMetadata = {
date: '12345',
dirname: '/test',
@@ -97,7 +97,7 @@ describe('FileUploadAction', () => {
expect(fileService.createFile).toHaveBeenCalledWith({
fileType: mockUploadResult.fileType,
hash: mockUploadResult.hash,
metadata: mockUploadResult.metadata,
metadata: { ...mockUploadResult.metadata, ...mockDimensions },
name: mockMetadata.filename,
size: mockUploadResult.size,
url: mockMetadata.path,
@@ -200,7 +200,7 @@ describe('FileUploadAction', () => {
const { result } = renderHook(() => useStore());
const mockFile = new File(['test content'], 'test.png', { type: 'image/png' });
const mockDimensions = { height: 100, width: 200 };
const mockDimensions = { height: 100, ratio: 2, width: 200 };
const mockExistingMetadata = {
date: '12345',
dirname: '/test',
@@ -241,7 +241,7 @@ describe('FileUploadAction', () => {
{
fileType: mockFile.type,
hash: 'mock-hash-value',
metadata: mockExistingMetadata,
metadata: { ...mockExistingMetadata, ...mockDimensions },
name: mockFile.name,
size: mockFile.size,
url: mockExistingMetadata.path, // Uses metadata.path when available
@@ -261,7 +261,7 @@ describe('FileUploadAction', () => {
const { result } = renderHook(() => useStore());
const mockFile = new File(['test content'], 'newfile.jpg', { type: 'image/jpeg' });
const mockDimensions = { height: 150, width: 250 };
const mockDimensions = { height: 150, ratio: 1.6667, width: 250 };
const mockMetadata = {
date: '12345',
dirname: '/uploads',
@@ -303,7 +303,7 @@ describe('FileUploadAction', () => {
{
fileType: mockFile.type,
hash: 'mock-hash-value',
metadata: mockMetadata,
metadata: { ...mockMetadata, ...mockDimensions },
name: mockFile.name,
size: mockFile.size,
url: mockMetadata.path,
@@ -676,7 +676,7 @@ describe('FileUploadAction', () => {
const { result } = renderHook(() => useStore());
const mockFile = new File(['image data'], 'image.jpg', { type: 'image/jpeg' });
const mockDimensions = { height: 300, width: 400 };
const mockDimensions = { height: 300, ratio: 1.3333, width: 400 };
const mockMetadata = {
date: '12345',
dirname: '/images',
+3 -2
View File
@@ -48,6 +48,7 @@ interface UploadWithProgressParams {
interface UploadWithProgressResult {
dimensions?: {
height: number;
ratio: number;
width: number;
};
filename?: string;
@@ -93,7 +94,7 @@ export class FileUploadActionImpl {
const res = await fileService.createFile({
fileType,
hash,
metadata,
metadata: { ...metadata, ...dimensions },
name: metadata.filename,
size,
url: metadata.path,
@@ -184,7 +185,7 @@ export class FileUploadActionImpl {
{
fileType,
hash,
metadata,
metadata: { ...metadata, ...dimensions },
name: normalizedFile.name,
parentId,
size: normalizedFile.size,