mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ 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:
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.24",
|
||||
"version": "0.0.26",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user