chore: improve image display quality (#8571)

This commit is contained in:
YuTengjing
2025-07-25 17:42:23 +08:00
committed by GitHub
parent d1e4a54b01
commit 9d7c6014fd
18 changed files with 1901 additions and 125 deletions

View File

@@ -26,6 +26,8 @@ Gather the modified code and context. Please strictly follow the process below:
### Code Style
read [typescript.mdc](mdc:.cursor/rules/typescript.mdc) to learn the project's code style.
- Ensure JSDoc comments accurately reflect the implementation; update them when needed.
- Look for opportunities to simplify or modernize code with the latest JavaScript/TypeScript features.
- Prefer `async`/`await` over callbacks or chained `.then` promises.

View File

@@ -16,4 +16,6 @@ TypeScript Code Style Guide:
- Always refactor repeated logic into a reusable function
- Don't remove meaningful code comments, be sure to keep original comments when providing applied code
- Update the code comments when needed after you modify the related code
- Please respect my prettier preferences when you provide code
- Please respect my prettier preferences when you provide code
- Prefer object destructuring when accessing and using properties
- Prefer async version api than sync version, eg: use readFile from 'fs/promises' instead of 'fs'

11
.gitignore vendored
View File

@@ -41,10 +41,6 @@ test-output
# husky
.husky/prepare-commit-msg
# misc
# add other ignore file below
CLAUDE.md
# local env files
.env*.local
@@ -74,5 +70,8 @@ vertex-ai-key.json
./packages/lobe-ui
# for local prd docs
docs/prd
# local use ai coding files
docs/.prd
.claude
.mcp.json
CLAUDE.md

View File

@@ -20,6 +20,7 @@ import { AsyncTaskErrorType } from '@/types/asyncTask';
import { GenerationBatch } from '@/types/generation';
import { GenerationItem } from './GenerationItem';
import { DEFAULT_MAX_ITEM_WIDTH } from './GenerationItem/utils';
import { ReferenceImages } from './ReferenceImages';
const useStyles = createStyles(({ cx, css, token }) => ({
@@ -182,7 +183,11 @@ export const GenerationBatchItem = memo<GenerationBatchItemProps>(({ batch }) =>
{promptAndMetadata}
</>
)}
<Grid maxItemWidth={200} ref={imageGridRef} rows={batch.generations.length || 4}>
<Grid
maxItemWidth={DEFAULT_MAX_ITEM_WIDTH}
ref={imageGridRef}
rows={batch.generations.length}
>
{batch.generations.map((generation) => (
<GenerationItem
generation={generation}

View File

@@ -9,10 +9,11 @@ import { Center } from 'react-layout-kit';
import { ActionButtons } from './ActionButtons';
import { useStyles } from './styles';
import { ErrorStateProps } from './types';
import { getThumbnailMaxWidth } from './utils';
// 错误状态组件
export const ErrorState = memo<ErrorStateProps>(
({ generation, aspectRatio, onDelete, onCopyError }) => {
({ generation, generationBatch, aspectRatio, onDelete, onCopyError }) => {
const { styles, theme } = useStyles();
const { t } = useTranslation('image');
@@ -32,7 +33,7 @@ export const ErrorState = memo<ErrorStateProps>(
style={{
aspectRatio,
cursor: 'pointer',
maxWidth: generation.asset?.width ? generation.asset.width / 2 : 'unset',
maxWidth: getThumbnailMaxWidth(generation, generationBatch),
}}
variant={'filled'}
>

View File

@@ -12,33 +12,36 @@ import { ActionButtons } from './ActionButtons';
import { ElapsedTime } from './ElapsedTime';
import { useStyles } from './styles';
import { LoadingStateProps } from './types';
import { getThumbnailMaxWidth } from './utils';
// 加载状态组件
export const LoadingState = memo<LoadingStateProps>(({ generation, aspectRatio, onDelete }) => {
const { styles } = useStyles();
export const LoadingState = memo<LoadingStateProps>(
({ generation, generationBatch, aspectRatio, onDelete }) => {
const { styles } = useStyles();
const isGenerating =
generation.task.status === AsyncTaskStatus.Processing ||
generation.task.status === AsyncTaskStatus.Pending;
const isGenerating =
generation.task.status === AsyncTaskStatus.Processing ||
generation.task.status === AsyncTaskStatus.Pending;
return (
<Block
align={'center'}
className={styles.placeholderContainer}
justify={'center'}
style={{
aspectRatio,
maxWidth: generation.asset?.width ? generation.asset.width / 2 : 'unset',
}}
variant={'filled'}
>
<Center gap={8}>
<Spin indicator={<LoadingOutlined spin />} />
<ElapsedTime generationId={generation.id} isActive={isGenerating} />
</Center>
<ActionButtons onDelete={onDelete} />
</Block>
);
});
return (
<Block
align={'center'}
className={styles.placeholderContainer}
justify={'center'}
style={{
aspectRatio,
maxWidth: getThumbnailMaxWidth(generation, generationBatch),
}}
variant={'filled'}
>
<Center gap={8}>
<Spin indicator={<LoadingOutlined spin />} />
<ElapsedTime generationId={generation.id} isActive={isGenerating} />
</Center>
<ActionButtons onDelete={onDelete} />
</Block>
);
},
);
LoadingState.displayName = 'LoadingState';

View File

@@ -8,10 +8,20 @@ import ImageItem from '@/components/ImageItem';
import { ActionButtons } from './ActionButtons';
import { useStyles } from './styles';
import { SuccessStateProps } from './types';
import { getThumbnailMaxWidth } from './utils';
// 成功状态组件
export const SuccessState = memo<SuccessStateProps>(
({ generation, prompt, aspectRatio, onDelete, onDownload, onCopySeed, seedTooltip }) => {
({
generation,
generationBatch,
prompt,
aspectRatio,
onDelete,
onDownload,
onCopySeed,
seedTooltip,
}) => {
const { styles } = useStyles();
return (
@@ -21,7 +31,7 @@ export const SuccessState = memo<SuccessStateProps>(
justify={'center'}
style={{
aspectRatio,
maxWidth: generation.asset?.width ? generation.asset.width / 2 : 'unset',
maxWidth: getThumbnailMaxWidth(generation, generationBatch),
}}
variant={'filled'}
>
@@ -31,7 +41,8 @@ export const SuccessState = memo<SuccessStateProps>(
src: generation.asset!.url,
}}
style={{ height: '100%', width: '100%' }}
url={generation.asset!.thumbnailUrl}
// Thumbnail quality is too bad
url={generation.asset!.url}
/>
<ActionButtons
onCopySeed={onCopySeed}

View File

@@ -37,13 +37,7 @@ export const GenerationItem = memo<GenerationItemProps>(
const shouldPoll = !isFinalized;
useCheckGenerationStatus(generation.id, generation.task.id, activeTopicId!, shouldPoll);
const aspectRatio = getAspectRatio(
generation.asset ?? {
height: generationBatch.config?.height,
type: 'image',
width: generationBatch.config?.width,
},
);
const aspectRatio = getAspectRatio(generation, generationBatch);
// 事件处理函数
const handleDeleteGeneration = async () => {
@@ -120,6 +114,7 @@ export const GenerationItem = memo<GenerationItemProps>(
<SuccessState
aspectRatio={aspectRatio}
generation={generation}
generationBatch={generationBatch}
onCopySeed={handleCopySeed}
onDelete={handleDeleteGeneration}
onDownload={handleDownloadImage}
@@ -134,6 +129,7 @@ export const GenerationItem = memo<GenerationItemProps>(
<ErrorState
aspectRatio={aspectRatio}
generation={generation}
generationBatch={generationBatch}
onCopyError={handleCopyError}
onDelete={handleDeleteGeneration}
/>
@@ -145,6 +141,7 @@ export const GenerationItem = memo<GenerationItemProps>(
<LoadingState
aspectRatio={aspectRatio}
generation={generation}
generationBatch={generationBatch}
onDelete={handleDeleteGeneration}
/>
);

View File

@@ -18,6 +18,7 @@ export interface ActionButtonsProps {
export interface SuccessStateProps {
aspectRatio: string;
generation: Generation;
generationBatch: GenerationBatch;
onCopySeed?: () => void;
onDelete: () => void;
onDownload: () => void;
@@ -28,6 +29,7 @@ export interface SuccessStateProps {
export interface ErrorStateProps {
aspectRatio: string;
generation: Generation;
generationBatch: GenerationBatch;
onCopyError: () => void;
onDelete: () => void;
}
@@ -35,5 +37,6 @@ export interface ErrorStateProps {
export interface LoadingStateProps {
aspectRatio: string;
generation: Generation;
generationBatch: GenerationBatch;
onDelete: () => void;
}

View File

@@ -0,0 +1,600 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Generation, GenerationBatch } from '@/types/generation';
// Import functions for testing
import {
DEFAULT_MAX_ITEM_WIDTH,
getAspectRatio,
getImageDimensions,
getThumbnailMaxWidth,
} from './utils';
describe('getImageDimensions', () => {
// Mock base generation object
const baseGeneration: Generation = {
id: 'test-gen-id',
seed: 12345,
createdAt: new Date(),
asyncTaskId: null,
task: {
id: 'task-id',
status: 'success' as any,
},
};
describe('with asset dimensions', () => {
it('should return width, height and aspect ratio from asset', () => {
const generation: Generation = {
...baseGeneration,
asset: {
type: 'image',
width: 1920,
height: 1080,
},
};
const result = getImageDimensions(generation);
expect(result).toEqual({
width: 1920,
height: 1080,
aspectRatio: '1920 / 1080',
});
});
it('should prioritize asset even when other sources exist', () => {
const generation: Generation = {
...baseGeneration,
asset: {
type: 'image',
width: 800,
height: 600,
},
};
const generationBatch: GenerationBatch = {
id: 'batch-id',
provider: 'test',
model: 'test-model',
prompt: 'test prompt',
width: 1024,
height: 1024,
config: {
prompt: 'test',
width: 512,
height: 512,
},
createdAt: new Date(),
generations: [],
};
const result = getImageDimensions(generation, generationBatch);
expect(result).toEqual({
width: 800,
height: 600,
aspectRatio: '800 / 600',
});
});
});
describe('with config dimensions', () => {
it('should return dimensions from config when asset is not available', () => {
const generation: Generation = {
...baseGeneration,
asset: null,
};
const generationBatch: GenerationBatch = {
id: 'batch-id',
provider: 'test',
model: 'test-model',
prompt: 'test prompt',
config: {
prompt: 'test',
width: 1024,
height: 768,
},
createdAt: new Date(),
generations: [],
};
const result = getImageDimensions(generation, generationBatch);
expect(result).toEqual({
width: 1024,
height: 768,
aspectRatio: '1024 / 768',
});
});
});
describe('with batch top-level dimensions', () => {
it('should return dimensions from batch when config is not available', () => {
const generation: Generation = {
...baseGeneration,
asset: null,
};
const generationBatch: GenerationBatch = {
id: 'batch-id',
provider: 'test',
model: 'test-model',
prompt: 'test prompt',
width: 1280,
height: 720,
createdAt: new Date(),
generations: [],
};
const result = getImageDimensions(generation, generationBatch);
expect(result).toEqual({
width: 1280,
height: 720,
aspectRatio: '1280 / 720',
});
});
});
describe('with size parameter', () => {
it('should parse dimensions from size parameter', () => {
const generation: Generation = {
...baseGeneration,
asset: null,
};
const generationBatch: GenerationBatch = {
id: 'batch-id',
provider: 'test',
model: 'test-model',
prompt: 'test prompt',
config: {
prompt: 'test',
size: '1920x1080',
},
createdAt: new Date(),
generations: [],
};
const result = getImageDimensions(generation, generationBatch);
expect(result).toEqual({
width: 1920,
height: 1080,
aspectRatio: '1920 / 1080',
});
});
it('should ignore size when it is "auto"', () => {
const generation: Generation = {
...baseGeneration,
asset: null,
};
const generationBatch: GenerationBatch = {
id: 'batch-id',
provider: 'test',
model: 'test-model',
prompt: 'test prompt',
config: {
prompt: 'test',
size: 'auto',
aspectRatio: '16:9',
},
createdAt: new Date(),
generations: [],
};
const result = getImageDimensions(generation, generationBatch);
expect(result).toEqual({
width: null,
height: null,
aspectRatio: '16 / 9',
});
});
});
describe('with aspectRatio parameter only', () => {
it('should return aspect ratio without dimensions', () => {
const generation: Generation = {
...baseGeneration,
asset: null,
};
const generationBatch: GenerationBatch = {
id: 'batch-id',
provider: 'test',
model: 'test-model',
prompt: 'test prompt',
config: {
prompt: 'test',
aspectRatio: '16:9',
},
createdAt: new Date(),
generations: [],
};
const result = getImageDimensions(generation, generationBatch);
expect(result).toEqual({
width: null,
height: null,
aspectRatio: '16 / 9',
});
});
it('should handle various aspect ratio formats', () => {
const testCases = [
{ aspectRatio: '1:1', expected: '1 / 1' },
{ aspectRatio: '4:3', expected: '4 / 3' },
{ aspectRatio: '16:9', expected: '16 / 9' },
{ aspectRatio: '21:9', expected: '21 / 9' },
];
testCases.forEach(({ aspectRatio, expected }) => {
const generation: Generation = {
...baseGeneration,
asset: null,
};
const generationBatch: GenerationBatch = {
id: 'batch-id',
provider: 'test',
model: 'test-model',
prompt: 'test prompt',
config: {
prompt: 'test',
aspectRatio,
},
createdAt: new Date(),
generations: [],
};
const result = getImageDimensions(generation, generationBatch);
expect(result.aspectRatio).toBe(expected);
});
});
});
describe('edge cases', () => {
it('should return all null when no dimensions are available', () => {
const generation: Generation = {
...baseGeneration,
asset: null,
};
const result = getImageDimensions(generation);
expect(result).toEqual({
width: null,
height: null,
aspectRatio: null,
});
});
it('should handle partial asset dimensions', () => {
const generation: Generation = {
...baseGeneration,
asset: {
type: 'image',
width: 1920,
// height is missing
},
};
const generationBatch: GenerationBatch = {
id: 'batch-id',
provider: 'test',
model: 'test-model',
prompt: 'test prompt',
config: {
prompt: 'test',
width: 1024,
height: 768,
},
createdAt: new Date(),
generations: [],
};
const result = getImageDimensions(generation, generationBatch);
expect(result).toEqual({
width: 1024,
height: 768,
aspectRatio: '1024 / 768',
});
});
it('should handle invalid size format', () => {
const generation: Generation = {
...baseGeneration,
asset: null,
};
const generationBatch: GenerationBatch = {
id: 'batch-id',
provider: 'test',
model: 'test-model',
prompt: 'test prompt',
config: {
prompt: 'test',
size: 'invalid-format',
},
createdAt: new Date(),
generations: [],
};
const result = getImageDimensions(generation, generationBatch);
expect(result).toEqual({
width: null,
height: null,
aspectRatio: null,
});
});
it('should handle invalid aspectRatio format', () => {
const generation: Generation = {
...baseGeneration,
asset: null,
};
const generationBatch: GenerationBatch = {
id: 'batch-id',
provider: 'test',
model: 'test-model',
prompt: 'test prompt',
config: {
prompt: 'test',
aspectRatio: 'invalid-format',
},
createdAt: new Date(),
generations: [],
};
const result = getImageDimensions(generation, generationBatch);
expect(result).toEqual({
width: null,
height: null,
aspectRatio: null,
});
});
it('should handle zero dimensions', () => {
const generation: Generation = {
...baseGeneration,
asset: {
type: 'image',
width: 0,
height: 0,
},
};
const result = getImageDimensions(generation);
expect(result).toEqual({
width: null,
height: null,
aspectRatio: null,
});
});
});
});
describe('getAspectRatio (isolated unit testing)', () => {
const mockGeneration: Generation = {
id: 'test-gen-id',
seed: 12345,
createdAt: new Date(),
asyncTaskId: null,
task: {
id: 'task-id',
status: 'success' as any,
},
};
const mockGenerationBatch: GenerationBatch = {
id: 'test-batch-id',
provider: 'test-provider',
model: 'test-model',
prompt: 'test prompt',
createdAt: new Date(),
generations: [],
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should return aspectRatio from getImageDimensions when dimensions have aspectRatio', () => {
// Test the actual implementation directly with mock data
const mockGen: Generation = {
...mockGeneration,
asset: {
type: 'image',
width: 1920,
height: 1080,
},
};
const result = getAspectRatio(mockGen);
expect(result).toBe('1920 / 1080');
});
it('should return default "1 / 1" when no dimensions are available', () => {
const result = getAspectRatio(mockGeneration, mockGenerationBatch);
expect(result).toBe('1 / 1');
});
it('should work with different aspectRatio sources', () => {
const mockBatch: GenerationBatch = {
id: 'test-batch',
provider: 'test-provider',
model: 'test-model',
prompt: 'test prompt',
createdAt: new Date(),
generations: [],
config: {
prompt: 'test prompt',
aspectRatio: '16:9',
},
};
const result = getAspectRatio(mockGeneration, mockBatch);
expect(result).toBe('16 / 9');
});
});
describe('getThumbnailMaxWidth (isolated unit testing)', () => {
const mockGeneration: Generation = {
id: 'test-gen-id',
seed: 12345,
createdAt: new Date(),
asyncTaskId: null,
task: {
id: 'task-id',
status: 'success' as any,
},
};
const mockGenerationBatch: GenerationBatch = {
id: 'test-batch-id',
provider: 'test-provider',
model: 'test-model',
prompt: 'test prompt',
createdAt: new Date(),
generations: [],
};
// Mock window.innerHeight for tests
const originalWindow = global.window;
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(global, 'window', {
writable: true,
value: {
innerHeight: 800,
},
});
});
afterEach(() => {
global.window = originalWindow;
});
it('should return DEFAULT_MAX_ITEM_WIDTH when no dimensions available', () => {
const result = getThumbnailMaxWidth(mockGeneration, mockGenerationBatch);
expect(result).toBe(DEFAULT_MAX_ITEM_WIDTH);
});
it('should return DEFAULT_MAX_ITEM_WIDTH when width is missing', () => {
const mockGen: Generation = {
...mockGeneration,
// No asset with width/height, should fall back to default
};
const result = getThumbnailMaxWidth(mockGen);
expect(result).toBe(DEFAULT_MAX_ITEM_WIDTH);
});
it('should return DEFAULT_MAX_ITEM_WIDTH when height is missing', () => {
const mockGen: Generation = {
...mockGeneration,
// No asset with valid dimensions
};
const result = getThumbnailMaxWidth(mockGen);
expect(result).toBe(DEFAULT_MAX_ITEM_WIDTH);
});
it('should calculate width based on screen height constraint', () => {
const mockGen: Generation = {
...mockGeneration,
asset: {
type: 'image',
width: 300,
height: 200,
},
};
// aspectRatio = 300/200 = 1.5
// maxScreenHeight = 800/2 = 400
// maxWidthFromHeight = 400 * 1.5 = 600
// maxReasonableWidth = 200 * 2 = 400
// min(600, 400) = 400
const result = getThumbnailMaxWidth(mockGen);
expect(result).toBe(400);
});
it('should apply maxReasonableWidth limit', () => {
const mockGen: Generation = {
...mockGeneration,
asset: {
type: 'image',
width: 600,
height: 200,
},
};
// aspectRatio = 600/200 = 3
// maxScreenHeight = 800/2 = 400
// maxWidthFromHeight = 400 * 3 = 1200
// maxReasonableWidth = 200 * 2 = 400
// min(1200, 400) = 400
const result = getThumbnailMaxWidth(mockGen);
expect(result).toBe(400);
});
it('should use screen height constraint when smaller', () => {
const mockGen: Generation = {
...mockGeneration,
asset: {
type: 'image',
width: 200,
height: 400,
},
};
// aspectRatio = 200/400 = 0.5
// maxScreenHeight = 800/2 = 400
// maxWidthFromHeight = 400 * 0.5 = 200
// maxReasonableWidth = 200 * 2 = 400
// min(200, 400) = 200
const result = getThumbnailMaxWidth(mockGen);
expect(result).toBe(200);
});
it('should handle different window.innerHeight values', () => {
Object.defineProperty(global, 'window', {
writable: true,
value: {
innerHeight: 600,
},
});
const mockGen: Generation = {
...mockGeneration,
asset: {
type: 'image',
width: 400,
height: 200,
},
};
// aspectRatio = 400/200 = 2
// maxScreenHeight = 600/2 = 300
// maxWidthFromHeight = 300 * 2 = 600
// maxReasonableWidth = 200 * 2 = 400
// min(600, 400) = 400
const result = getThumbnailMaxWidth(mockGen);
expect(result).toBe(400);
});
it('should round calculated width correctly', () => {
const mockGen: Generation = {
...mockGeneration,
asset: {
type: 'image',
width: 512,
height: 1000,
},
};
// aspectRatio = 512/1000 = 0.512
// maxScreenHeight = 800/2 = 400
// maxWidthFromHeight = Math.round(400 * 0.512) = Math.round(204.8) = 205
// maxReasonableWidth = 200 * 2 = 400
// min(205, 400) = 205
const result = getThumbnailMaxWidth(mockGen);
expect(result).toBe(205);
});
});

View File

@@ -1,11 +1,130 @@
import { Generation } from '@/types/generation';
import { Generation, GenerationBatch } from '@/types/generation';
// 计算图片的宽高比,用于设置容器的 aspect-ratio
export const getAspectRatio = (asset: Generation['asset']) => {
if (!asset?.width || !asset?.height) {
// 如果没有尺寸信息,使用 1:1 比例
return '1 / 1';
// Default maximum width for image items
export const DEFAULT_MAX_ITEM_WIDTH = 200;
/**
* Get image dimensions from various sources
* Returns width, height and aspect ratio when available
*/
export const getImageDimensions = (
generation: Generation,
generationBatch?: GenerationBatch,
): { aspectRatio: string | null; height: number | null; width: number | null } => {
// 1. Priority: actual dimensions from asset
if (
generation.asset?.width &&
generation.asset?.height &&
generation.asset.width > 0 &&
generation.asset.height > 0
) {
const { width, height } = generation.asset;
return {
aspectRatio: `${width} / ${height}`,
height,
width,
};
}
return `${asset.width} / ${asset.height}`;
// 2. Try to get dimensions from generationBatch config
const config = generationBatch?.config;
if (config?.width && config?.height && config.width > 0 && config.height > 0) {
const { width, height } = config;
return {
aspectRatio: `${width} / ${height}`,
height,
width,
};
}
// 3. Try to get dimensions from generationBatch top-level
if (
generationBatch?.width &&
generationBatch?.height &&
generationBatch.width > 0 &&
generationBatch.height > 0
) {
const { width, height } = generationBatch;
return {
aspectRatio: `${width} / ${height}`,
height,
width,
};
}
// 4. Try to parse from size parameter (format: "1024x768")
if (config?.size && config.size !== 'auto') {
const sizeMatch = config.size.match(/^(\d+)x(\d+)$/);
if (sizeMatch) {
const [, widthStr, heightStr] = sizeMatch;
const width = parseInt(widthStr, 10);
const height = parseInt(heightStr, 10);
if (width > 0 && height > 0) {
return {
aspectRatio: `${width} / ${height}`,
height,
width,
};
}
}
}
// 5. Try to get aspect ratio only (format: "16:9")
if (config?.aspectRatio) {
const ratioMatch = config.aspectRatio.match(/^(\d+):(\d+)$/);
if (ratioMatch) {
const [, x, y] = ratioMatch;
return {
aspectRatio: `${x} / ${y}`,
height: null,
width: null,
};
}
}
// 6. No dimensions available
return {
aspectRatio: null,
height: null,
width: null,
};
};
export const getAspectRatio = (
generation: Generation,
generationBatch?: GenerationBatch,
): string => {
const dimensions = getImageDimensions(generation, generationBatch);
return dimensions.aspectRatio || '1 / 1';
};
/**
* Calculate display max width for generation items
* Ensures height doesn't exceed half screen height based on original aspect ratio
*
* @note This function is only used in client-side rendering environments.
* It directly accesses window.innerHeight and is not designed for SSR compatibility.
*/
export const getThumbnailMaxWidth = (
generation: Generation,
generationBatch?: GenerationBatch,
): number => {
const dimensions = getImageDimensions(generation, generationBatch);
// Return default width if dimensions are not available
if (!dimensions.width || !dimensions.height) {
return DEFAULT_MAX_ITEM_WIDTH;
}
const { width: originalWidth, height: originalHeight } = dimensions;
const aspectRatio = originalWidth / originalHeight;
// Apply screen height constraint (half of screen height)
// Note: window.innerHeight is safe to use here as this function is client-side only
const maxScreenHeight = window.innerHeight / 2;
const maxWidthFromHeight = Math.round(maxScreenHeight * aspectRatio);
// Use the smaller of: calculated width from height constraint or a reasonable maximum
const maxReasonableWidth = DEFAULT_MAX_ITEM_WIDTH * 2;
return Math.min(maxWidthFromHeight, maxReasonableWidth);
};

View File

@@ -0,0 +1,18 @@
/**
* Image generation and processing configuration constants
*/
export const IMAGE_GENERATION_CONFIG = {
/**
* Maximum cover image size in pixels (longest edge)
* Used for generating cover images from source images
*/
COVER_MAX_SIZE: 256,
/**
* Maximum thumbnail size in pixels (longest edge)
* Used for generating thumbnail images from original images
*/
THUMBNAIL_MAX_SIZE: 512,
} as const;

View File

@@ -1112,6 +1112,7 @@ describe('LobeOpenAICompatibleFactory', () => {
image: expect.any(File),
mask: 'https://example.com/mask.jpg',
response_format: 'b64_json',
input_fidelity: 'high',
});
expect(result).toEqual({
@@ -1157,6 +1158,7 @@ describe('LobeOpenAICompatibleFactory', () => {
prompt: 'Merge these images',
image: [mockFile1, mockFile2],
response_format: 'b64_json',
input_fidelity: 'high',
});
expect(result).toEqual({
@@ -1286,6 +1288,7 @@ describe('LobeOpenAICompatibleFactory', () => {
image: expect.any(File),
customParam: 'should remain unchanged',
response_format: 'b64_json',
input_fidelity: 'high',
});
});

View File

@@ -340,11 +340,6 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
log('Creating image with model: %s and params: %O', model, params);
const defaultInput = {
n: 1,
...(model.includes('dall-e') ? { response_format: 'b64_json' } : {}),
};
// 映射参数名称,将 imageUrls 映射为 image
const paramsMap = new Map<RuntimeImageGenParamsValue, string>([
['imageUrls', 'image'],
@@ -357,6 +352,7 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
]),
);
// https://platform.openai.com/docs/api-reference/images/createEdit
const isImageEdit = Array.isArray(userInput.image) && userInput.image.length > 0;
// 如果有 imageUrls 参数,将其转换为 File 对象
if (isImageEdit) {
@@ -383,6 +379,12 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
delete userInput.size;
}
const defaultInput = {
n: 1,
...(model.includes('dall-e') ? { response_format: 'b64_json' } : {}),
...(isImageEdit ? { input_fidelity: 'high' } : {}),
};
const options = {
model,
...defaultInput,

View File

@@ -0,0 +1,848 @@
import debug from 'debug';
import { sha256 } from 'js-sha256';
import mime from 'mime';
import { nanoid } from 'nanoid';
import sharp from 'sharp';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FileService } from '@/server/services/file';
import { calculateThumbnailDimensions } from '@/utils/number';
import { getYYYYmmddHHMMss } from '@/utils/time';
import { inferFileExtensionFromImageUrl } from '@/utils/url';
import { GenerationService, fetchImageFromUrl } from './index';
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
vi.mock('debug', () => ({
default: () => vi.fn(),
}));
vi.mock('js-sha256');
vi.mock('mime');
vi.mock('nanoid');
vi.mock('sharp');
vi.mock('@/server/services/file');
vi.mock('@/utils/number');
vi.mock('@/utils/time');
vi.mock('@/utils/url');
describe('GenerationService', () => {
let service: GenerationService;
const mockDb = {} as any;
const mockUserId = 'test-user';
let mockFileService: any;
beforeEach(() => {
vi.clearAllMocks();
// Setup common mocks used across all tests
mockFileService = {
uploadMedia: vi.fn(),
};
vi.mocked(FileService).mockImplementation(() => mockFileService);
vi.mocked(nanoid).mockReturnValue('test-uuid');
vi.mocked(getYYYYmmddHHMMss).mockReturnValue('20240101123000');
// Setup mime.getExtension with consistent behavior
vi.mocked(mime.getExtension).mockImplementation((mimeType) => {
const extensions = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/unknown': null,
};
return extensions[mimeType as keyof typeof extensions] || 'png';
});
// Setup inferFileExtensionFromImageUrl with consistent behavior
vi.mocked(inferFileExtensionFromImageUrl).mockImplementation((url) => {
if (url.includes('.jpg')) return 'jpg';
if (url.includes('.gif')) return 'gif';
if (url.includes('image') && !url.includes('.')) return ''; // For error testing
return 'png';
});
service = new GenerationService(mockDb, mockUserId);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('fetchImageFromUrl', () => {
// Note: Using global beforeEach/afterEach from parent describe for consistency
describe('base64 data URI', () => {
it('should extract buffer and MIME type from base64 data URI', async () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
const dataUri = `data:image/png;base64,${base64Data}`;
const result = await fetchImageFromUrl(dataUri);
expect(result.mimeType).toBe('image/png');
expect(result.buffer).toBeInstanceOf(Buffer);
expect(result.buffer.length).toBeGreaterThan(0);
expect(Buffer.from(base64Data, 'base64').equals(result.buffer)).toBe(true);
});
it('should handle different MIME types in base64 data URI', async () => {
const base64Data = 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const dataUri = `data:image/gif;base64,${base64Data}`;
const result = await fetchImageFromUrl(dataUri);
expect(result.mimeType).toBe('image/gif');
expect(result.buffer).toBeInstanceOf(Buffer);
});
it('should handle base64 data URI with additional parameters', async () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
const dataUri = `data:image/png;charset=utf-8;base64,${base64Data}`;
// This should fail because parseDataUri only supports the strict format: data:mime/type;base64,data
await expect(fetchImageFromUrl(dataUri)).rejects.toThrow(
'Invalid data URI format: data:image/png;charset=utf-8;base64,',
);
});
});
describe('HTTP URL', () => {
it('should fetch image from HTTP URL successfully', async () => {
const mockBuffer = Buffer.from('mock image data');
const mockArrayBuffer = mockBuffer.buffer.slice(
mockBuffer.byteOffset,
mockBuffer.byteOffset + mockBuffer.byteLength,
);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: {
get: vi.fn().mockReturnValue('image/jpeg'),
},
arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer),
});
const result = await fetchImageFromUrl('https://example.com/image.jpg');
expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg');
expect(result.mimeType).toBe('image/jpeg');
expect(result.buffer).toBeInstanceOf(Buffer);
expect(result.buffer.equals(mockBuffer)).toBe(true);
});
it('should handle missing content-type header', async () => {
const mockBuffer = Buffer.from('mock image data');
const mockArrayBuffer = mockBuffer.buffer.slice(
mockBuffer.byteOffset,
mockBuffer.byteOffset + mockBuffer.byteLength,
);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: {
get: vi.fn().mockReturnValue(null), // No content-type header
},
arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer),
});
const result = await fetchImageFromUrl('https://example.com/image.jpg');
expect(result.mimeType).toBe('application/octet-stream');
expect(result.buffer).toBeInstanceOf(Buffer);
});
it('should throw error when fetch fails', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
});
await expect(fetchImageFromUrl('https://example.com/nonexistent.jpg')).rejects.toThrow(
'Failed to fetch image from https://example.com/nonexistent.jpg: 404 Not Found',
);
expect(mockFetch).toHaveBeenCalledWith('https://example.com/nonexistent.jpg');
});
it('should throw error when network request fails', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
await expect(fetchImageFromUrl('https://example.com/image.jpg')).rejects.toThrow(
'Network error',
);
});
});
describe('edge cases', () => {
it('should handle base64 data URI correctly', async () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
const dataUri = `data:image/png;base64,${base64Data}`;
const result = await fetchImageFromUrl(dataUri);
expect(result.mimeType).toBe('image/png');
expect(result.buffer).toBeInstanceOf(Buffer);
});
it('should throw error for invalid data URI format', async () => {
const invalidDataUri = 'data:image/png:invalid-format';
await expect(fetchImageFromUrl(invalidDataUri)).rejects.toThrow(
'Invalid data URI format: data:image/png:invalid-format',
);
});
it('should throw error for malformed data URI without base64', async () => {
const malformedDataUri = 'data:image/png;charset=utf-8,not-base64-data';
await expect(fetchImageFromUrl(malformedDataUri)).rejects.toThrow(
'Invalid data URI format: data:image/png;charset=utf-8,not-base64-data',
);
});
it('should handle different URL schemes', async () => {
const mockBuffer = Buffer.from('mock image data');
const mockArrayBuffer = mockBuffer.buffer.slice(
mockBuffer.byteOffset,
mockBuffer.byteOffset + mockBuffer.byteLength,
);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: {
get: vi.fn().mockReturnValue('image/png'),
},
arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer),
});
const result = await fetchImageFromUrl('http://example.com/image.png');
expect(result.mimeType).toBe('image/png');
expect(result.buffer).toBeInstanceOf(Buffer);
});
});
describe('return type validation', () => {
it('should return object with correct structure', async () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
const dataUri = `data:image/png;base64,${base64Data}`;
const result = await fetchImageFromUrl(dataUri);
expect(result).toHaveProperty('buffer');
expect(result).toHaveProperty('mimeType');
expect(typeof result.mimeType).toBe('string');
expect(result.buffer).toBeInstanceOf(Buffer);
});
});
});
describe('transformImageForGeneration', () => {
const mockOriginalBuffer = Buffer.from('original image data');
const mockThumbnailBuffer = Buffer.from('thumbnail image data');
beforeEach(() => {
// Reset and configure sha256 with stable implementation
vi.mocked(sha256)
.mockReset()
.mockImplementation(
(buffer: any) => `hash-${buffer.length}-${buffer.slice(0, 4).toString('hex')}`,
);
});
it('should transform base64 image successfully', async () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
const dataUri = `data:image/png;base64,${base64Data}`;
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ format: 'png', width: 800, height: 600 }),
resize: vi.fn().mockReturnThis(),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
vi.mocked(calculateThumbnailDimensions).mockReturnValue({
shouldResize: true,
thumbnailWidth: 512,
thumbnailHeight: 384,
});
const result = await service.transformImageForGeneration(dataUri);
// Verify image properties
expect(result.image.width).toBe(800);
expect(result.image.height).toBe(600);
expect(result.image.extension).toBe('png');
expect(result.image.hash).toMatch(/^hash-\d+-/); // Matches our stable hash format
// Verify thumbnail properties
expect(result.thumbnailImage.width).toBe(512);
expect(result.thumbnailImage.height).toBe(384);
expect(result.thumbnailImage.hash).toMatch(/^hash-\d+-/);
// Verify resize was called with correct dimensions
expect(mockSharp.resize).toHaveBeenCalledWith(512, 384);
expect(mockSharp.resize).toHaveBeenCalledTimes(1);
// Verify sha256 was called twice (for original and thumbnail)
expect(vi.mocked(sha256)).toHaveBeenCalledTimes(2);
});
it('should handle HTTP URL successfully', async () => {
const url = 'https://example.com/image.jpg';
// Mock fetch for HTTP URL
const mockArrayBuffer = mockOriginalBuffer.buffer.slice(
mockOriginalBuffer.byteOffset,
mockOriginalBuffer.byteOffset + mockOriginalBuffer.byteLength,
);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: {
get: vi.fn().mockReturnValue('image/jpeg'),
},
arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer),
});
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ format: 'jpeg', width: 1024, height: 768 }),
resize: vi.fn().mockReturnThis(),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
vi.mocked(calculateThumbnailDimensions).mockReturnValue({
shouldResize: true,
thumbnailWidth: 512,
thumbnailHeight: 384,
});
const result = await service.transformImageForGeneration(url);
expect(result.image.width).toBe(1024);
expect(result.image.height).toBe(768);
expect(result.image.extension).toBe('jpg'); // URL is image.jpg, so extension should be jpg
expect(result.thumbnailImage.width).toBe(512);
expect(result.thumbnailImage.height).toBe(384);
});
it('should handle images that do not need resizing', async () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
const dataUri = `data:image/png;base64,${base64Data}`;
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ format: 'png', width: 256, height: 256 }),
resize: vi.fn().mockReturnThis(),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
vi.mocked(calculateThumbnailDimensions).mockReturnValue({
shouldResize: false,
thumbnailWidth: 256,
thumbnailHeight: 256,
});
const result = await service.transformImageForGeneration(dataUri);
// When no resizing is needed but format is not webp, thumbnail is still processed for format conversion
const expectedBuffer = Buffer.from(base64Data, 'base64');
expect(result.image.buffer).toEqual(expectedBuffer);
// Thumbnail buffer will be different because it's converted to WebP even without resizing
expect(result.thumbnailImage.buffer).toEqual(mockThumbnailBuffer);
// Resize is called with original dimensions for format conversion
expect(mockSharp.resize).toHaveBeenCalledWith(256, 256);
});
it('should throw error for invalid image format', async () => {
const dataUri = 'data:image/png;base64,invalid-data';
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ format: 'png', width: null, height: null }),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
await expect(service.transformImageForGeneration(dataUri)).rejects.toThrow(
'Invalid image format: png, url: data:image/png;base64,invalid-data',
);
});
it('should throw error when unable to determine extension from MIME type', async () => {
const dataUri = 'data:image/unknown;base64,some-data';
vi.mocked(mime.getExtension).mockReturnValue(null);
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ format: 'unknown', width: 100, height: 100 }),
resize: vi.fn().mockReturnThis(),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
vi.mocked(calculateThumbnailDimensions).mockReturnValue({
shouldResize: false,
thumbnailWidth: 100,
thumbnailHeight: 100,
});
await expect(service.transformImageForGeneration(dataUri)).rejects.toThrow(
'Unable to determine file extension for MIME type: image/unknown',
);
});
it('should throw error when unable to determine extension from URL', async () => {
const url = 'https://example.com/image';
vi.mocked(inferFileExtensionFromImageUrl).mockReturnValue('');
// Mock fetch for HTTP URL
const mockArrayBuffer = mockOriginalBuffer.buffer.slice(
mockOriginalBuffer.byteOffset,
mockOriginalBuffer.byteOffset + mockOriginalBuffer.byteLength,
);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: {
get: vi.fn().mockReturnValue('image/jpeg'),
},
arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer),
});
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ format: 'jpeg', width: 100, height: 100 }),
resize: vi.fn().mockReturnThis(),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
vi.mocked(calculateThumbnailDimensions).mockReturnValue({
shouldResize: false,
thumbnailWidth: 100,
thumbnailHeight: 100,
});
await expect(service.transformImageForGeneration(url)).rejects.toThrow(
'Unable to determine file extension from URL: https://example.com/image',
);
});
it('should handle sharp processing error', async () => {
const dataUri = 'data:image/png;base64,invalid-data';
const mockSharp = {
metadata: vi.fn().mockRejectedValue(new Error('Invalid image data')),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
await expect(service.transformImageForGeneration(dataUri)).rejects.toThrow(
'Invalid image data',
);
});
it('should handle sharp resize error', async () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
const dataUri = `data:image/png;base64,${base64Data}`;
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ format: 'png', width: 800, height: 600 }),
resize: vi.fn().mockReturnThis(),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockRejectedValue(new Error('Sharp processing failed')),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
vi.mocked(calculateThumbnailDimensions).mockReturnValue({
shouldResize: true,
thumbnailWidth: 512,
thumbnailHeight: 384,
});
await expect(service.transformImageForGeneration(dataUri)).rejects.toThrow(
'Sharp processing failed',
);
});
it('should validate resize dimensions are called correctly', async () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
const dataUri = `data:image/png;base64,${base64Data}`;
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ format: 'png', width: 1024, height: 768 }),
resize: vi.fn().mockReturnThis(),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
vi.mocked(calculateThumbnailDimensions).mockReturnValue({
shouldResize: true,
thumbnailWidth: 400,
thumbnailHeight: 300,
});
await service.transformImageForGeneration(dataUri);
// Verify resize was called with exact calculated dimensions
expect(mockSharp.resize).toHaveBeenCalledWith(400, 300);
expect(mockSharp.resize).toHaveBeenCalledTimes(1);
// Verify calculateThumbnailDimensions was called with original dimensions
expect(calculateThumbnailDimensions).toHaveBeenCalledWith(1024, 768);
});
it('should validate file naming pattern includes correct dimensions', async () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
const dataUri = `data:image/png;base64,${base64Data}`;
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ format: 'png', width: 1920, height: 1080 }),
resize: vi.fn().mockReturnThis(),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
vi.mocked(calculateThumbnailDimensions).mockReturnValue({
shouldResize: true,
thumbnailWidth: 512,
thumbnailHeight: 288,
});
const result = await service.transformImageForGeneration(dataUri);
// Verify original image dimensions are preserved
expect(result.image.width).toBe(1920);
expect(result.image.height).toBe(1080);
// Verify thumbnail dimensions match calculation
expect(result.thumbnailImage.width).toBe(512);
expect(result.thumbnailImage.height).toBe(288);
// Verify proper extensions - image keeps original, thumbnail becomes webp
expect(result.image.extension).toBe('png');
expect(result.thumbnailImage.extension).toBe('webp');
});
it('should verify sha256 is called exactly twice for transformations', async () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
const dataUri = `data:image/png;base64,${base64Data}`;
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ format: 'png', width: 800, height: 600 }),
resize: vi.fn().mockReturnThis(),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
vi.mocked(calculateThumbnailDimensions).mockReturnValue({
shouldResize: true,
thumbnailWidth: 512,
thumbnailHeight: 384,
});
await service.transformImageForGeneration(dataUri);
// Should call sha256 exactly twice: once for original, once for thumbnail
expect(vi.mocked(sha256)).toHaveBeenCalledTimes(2);
// Verify it's called with Buffer instances
const calls = vi.mocked(sha256).mock.calls;
expect(calls[0][0]).toBeInstanceOf(Buffer); // Original image buffer
expect(calls[1][0]).toBeInstanceOf(Buffer); // Thumbnail buffer
});
});
describe('uploadImageForGeneration', () => {
const mockImage = {
buffer: Buffer.from('image data'),
extension: 'png',
hash: 'image-hash',
height: 800,
mime: 'image/png',
size: 1000,
width: 600,
};
const mockThumbnail = {
buffer: Buffer.from('thumbnail data'),
extension: 'png',
hash: 'thumbnail-hash',
height: 400,
mime: 'image/png',
size: 500,
width: 300,
};
it('should upload both images when buffers are different', async () => {
mockFileService.uploadMedia
.mockResolvedValueOnce({
key: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
})
.mockResolvedValueOnce({
key: 'generations/images/test-uuid_300x400_20240101123000_thumb.png',
});
const result = await service.uploadImageForGeneration(mockImage, mockThumbnail);
expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(2);
// Verify correct file naming pattern with dimensions
expect(mockFileService.uploadMedia).toHaveBeenNthCalledWith(
1,
expect.stringMatching(/^generations\/images\/test-uuid_600x800_20240101123000_raw\.png$/),
mockImage.buffer,
);
expect(mockFileService.uploadMedia).toHaveBeenNthCalledWith(
2,
expect.stringMatching(/^generations\/images\/test-uuid_300x400_20240101123000_thumb\.png$/),
mockThumbnail.buffer,
);
expect(result).toEqual({
imageUrl: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
thumbnailImageUrl: 'generations/images/test-uuid_300x400_20240101123000_thumb.png',
});
});
it('should upload single image when buffers are identical', async () => {
const identicalBuffer = Buffer.from('same data');
const imageWithSameBuffer = { ...mockImage, buffer: identicalBuffer };
const thumbnailWithSameBuffer = { ...mockThumbnail, buffer: identicalBuffer };
mockFileService.uploadMedia.mockResolvedValueOnce({
key: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
});
const result = await service.uploadImageForGeneration(
imageWithSameBuffer,
thumbnailWithSameBuffer,
);
expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(1);
expect(mockFileService.uploadMedia).toHaveBeenCalledWith(
'generations/images/test-uuid_600x800_20240101123000_raw.png',
identicalBuffer,
);
expect(result).toEqual({
imageUrl: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
thumbnailImageUrl: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
});
});
it('should handle partial upload failure in concurrent uploads', async () => {
mockFileService.uploadMedia
.mockResolvedValueOnce({
key: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
})
.mockRejectedValueOnce(new Error('Thumbnail upload failed'));
await expect(service.uploadImageForGeneration(mockImage, mockThumbnail)).rejects.toThrow(
'Thumbnail upload failed',
);
// Verify both uploads were attempted
expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(2);
});
it('should handle complete upload failure', async () => {
mockFileService.uploadMedia
.mockRejectedValueOnce(new Error('Image upload failed'))
.mockRejectedValueOnce(new Error('Thumbnail upload failed'));
await expect(service.uploadImageForGeneration(mockImage, mockThumbnail)).rejects.toThrow(
'Image upload failed',
);
// Should fail fast on first rejection
expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(2);
});
it('should handle single image upload failure', async () => {
const identicalBuffer = Buffer.from('same data');
const imageWithSameBuffer = { ...mockImage, buffer: identicalBuffer };
const thumbnailWithSameBuffer = { ...mockThumbnail, buffer: identicalBuffer };
mockFileService.uploadMedia.mockRejectedValueOnce(new Error('Upload service unavailable'));
await expect(
service.uploadImageForGeneration(imageWithSameBuffer, thumbnailWithSameBuffer),
).rejects.toThrow('Upload service unavailable');
expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(1);
});
it('should validate file naming format with correct patterns', async () => {
mockFileService.uploadMedia
.mockResolvedValueOnce({
key: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
})
.mockResolvedValueOnce({
key: 'generations/images/test-uuid_300x400_20240101123000_thumb.png',
});
await service.uploadImageForGeneration(mockImage, mockThumbnail);
// Verify file name patterns match exact format: {uuid}_{width}x{height}_{timestamp}_{type}.{ext}
const imageCall = mockFileService.uploadMedia.mock.calls[0];
const thumbnailCall = mockFileService.uploadMedia.mock.calls[1];
expect(imageCall[0]).toMatch(
/^generations\/images\/test-uuid_600x800_20240101123000_raw\.png$/,
);
expect(thumbnailCall[0]).toMatch(
/^generations\/images\/test-uuid_300x400_20240101123000_thumb\.png$/,
);
// Verify dimensions are correctly embedded in filename
expect(imageCall[0]).toContain('600x800'); // Original dimensions
expect(thumbnailCall[0]).toContain('300x400'); // Thumbnail dimensions
// Verify file type suffixes
expect(imageCall[0]).toContain('_raw.');
expect(thumbnailCall[0]).toContain('_thumb.');
});
});
describe('createCoverFromUrl', () => {
const mockCoverBuffer = Buffer.from('cover image data');
// Note: Using global mock configuration from parent describe
it('should create cover from base64 data URI', async () => {
const base64Data =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
const dataUri = `data:image/jpeg;base64,${base64Data}`;
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ width: 512, height: 384 }),
resize: vi.fn().mockReturnThis(),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(mockCoverBuffer),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
vi.mocked(calculateThumbnailDimensions).mockReturnValue({
shouldResize: true,
thumbnailWidth: 256,
thumbnailHeight: 192,
});
mockFileService.uploadMedia.mockResolvedValueOnce({
key: 'generations/covers/test-uuid_256x192_20240101123000_cover.webp',
});
const result = await service.createCoverFromUrl(dataUri);
expect(mockSharp.resize).toHaveBeenCalledWith(256, 192);
expect(mockFileService.uploadMedia).toHaveBeenCalledWith(
'generations/covers/test-uuid_256x192_20240101123000_cover.webp',
mockCoverBuffer,
);
expect(result).toBe('generations/covers/test-uuid_256x192_20240101123000_cover.webp');
});
it('should create cover from HTTP URL', async () => {
const url = 'https://example.com/image.jpg';
// Mock fetch for HTTP URL
const mockBuffer = Buffer.from('original image data');
const mockArrayBuffer = mockBuffer.buffer.slice(
mockBuffer.byteOffset,
mockBuffer.byteOffset + mockBuffer.byteLength,
);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: {
get: vi.fn().mockReturnValue('image/jpeg'),
},
arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer),
});
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
resize: vi.fn().mockReturnThis(),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(mockCoverBuffer),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
vi.mocked(calculateThumbnailDimensions).mockReturnValue({
shouldResize: true,
thumbnailWidth: 256,
thumbnailHeight: 192,
});
mockFileService.uploadMedia.mockResolvedValueOnce({
key: 'generations/covers/test-uuid_256x192_20240101123000_cover.webp',
});
const result = await service.createCoverFromUrl(url);
expect(result).toBe('generations/covers/test-uuid_256x192_20240101123000_cover.webp');
});
it('should throw error for invalid image format', async () => {
const dataUri = 'data:image/png;base64,invalid-data';
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ width: null, height: null }),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
await expect(service.createCoverFromUrl(dataUri)).rejects.toThrow(
'Invalid image format for cover creation',
);
});
it('should validate cover file naming format includes dimensions', async () => {
const dataUri = 'data:image/jpeg;base64,some-data';
const mockSharp = {
metadata: vi.fn().mockResolvedValue({ width: 1024, height: 768 }),
resize: vi.fn().mockReturnThis(),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(mockCoverBuffer),
};
vi.mocked(sharp).mockReturnValue(mockSharp as any);
vi.mocked(calculateThumbnailDimensions).mockReturnValue({
shouldResize: true,
thumbnailWidth: 256,
thumbnailHeight: 192,
});
mockFileService.uploadMedia.mockResolvedValueOnce({
key: 'generations/covers/test-uuid_256x192_20240101123000_cover.webp',
});
const result = await service.createCoverFromUrl(dataUri);
// Verify cover filename contains calculated dimensions
expect(mockFileService.uploadMedia).toHaveBeenCalledWith(
'generations/covers/test-uuid_256x192_20240101123000_cover.webp',
mockCoverBuffer,
);
// Verify filename pattern: {uuid}_{width}x{height}_{timestamp}_cover.{ext}
const filename = mockFileService.uploadMedia.mock.calls[0][0];
expect(filename).toMatch(
/^generations\/covers\/test-uuid_256x192_20240101123000_cover\.webp$/,
);
expect(filename).toContain('256x192'); // Cover dimensions
expect(filename).toContain('_cover.'); // Cover suffix
expect(result).toBe('generations/covers/test-uuid_256x192_20240101123000_cover.webp');
});
});
});

View File

@@ -4,12 +4,54 @@ import mime from 'mime';
import { nanoid } from 'nanoid';
import sharp from 'sharp';
import { IMAGE_GENERATION_CONFIG } from '@/const/imageGeneration';
import { LobeChatDatabase } from '@/database/type';
import { parseDataUri } from '@/libs/model-runtime/utils/uriParser';
import { FileService } from '@/server/services/file';
import { calculateThumbnailDimensions } from '@/utils/number';
import { getYYYYmmddHHMMss } from '@/utils/time';
import { inferFileExtensionFromImageUrl } from '@/utils/url';
const log = debug('lobe-image:generation-service');
/**
* Fetch image buffer and MIME type from URL or base64 data
* @param url - Image URL or base64 data URI
* @returns Object containing buffer and MIME type
*/
export async function fetchImageFromUrl(url: string): Promise<{
buffer: Buffer;
mimeType: string;
}> {
if (url.startsWith('data:')) {
const { base64, mimeType, type } = parseDataUri(url);
if (type !== 'base64' || !base64 || !mimeType) {
throw new Error(`Invalid data URI format: ${url}`);
}
try {
const buffer = Buffer.from(base64, 'base64');
return { buffer, mimeType };
} catch (error) {
throw new Error(
`Failed to decode base64 data: ${error instanceof Error ? error.message : String(error)}`,
);
}
} else {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch image from ${url}: ${response.status} ${response.statusText}`,
);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const mimeType = response.headers.get('content-type') || 'application/octet-stream';
return { buffer, mimeType };
}
}
interface ImageForGeneration {
buffer: Buffer;
extension: string;
@@ -40,33 +82,12 @@ export class GenerationService {
}> {
log('Starting image transformation for:', url.startsWith('data:') ? 'base64 data' : url);
// If the url is in base64 format, extract the Buffer directly; otherwise, use fetch to get the Buffer
let originalImageBuffer: Buffer;
let originalMimeType: string;
if (url.startsWith('data:')) {
log('Processing base64 image data');
// Extract the MIME type and base64 data part
const [mimeTypePart, base64Data] = url.split(',');
originalMimeType = mimeTypePart.split(':')[1].split(';')[0];
originalImageBuffer = Buffer.from(base64Data, 'base64');
} else {
log('Fetching image from URL:', url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch image from ${url}: ${response.status} ${response.statusText}`,
);
}
const arrayBuffer = await response.arrayBuffer();
originalImageBuffer = Buffer.from(arrayBuffer);
originalMimeType = response.headers.get('content-type') || 'application/octet-stream';
log('Successfully fetched image, buffer size:', originalImageBuffer.length);
}
// Fetch image buffer and MIME type using utility function
const { buffer: originalImageBuffer, mimeType: originalMimeType } =
await fetchImageFromUrl(url);
// Calculate hash for original image
const originalHash = sha256(originalImageBuffer);
log('Original image hash calculated:', originalHash);
const sharpInstance = sharp(originalImageBuffer);
const { format, width, height } = await sharpInstance.metadata();
@@ -76,19 +97,20 @@ export class GenerationService {
throw new Error(`Invalid image format: ${format}, url: ${url}`);
}
const shouldResize = format !== 'webp' || width > 512 || height > 512;
const thumbnailWidth = shouldResize
? width > height
? 512
: Math.round((width * 512) / height)
: width;
const thumbnailHeight = shouldResize
? height > width
? 512
: Math.round((height * 512) / width)
: height;
const {
shouldResize: shouldResizeBySize,
thumbnailWidth,
thumbnailHeight,
} = calculateThumbnailDimensions(width, height);
const shouldResize = shouldResizeBySize || format !== 'webp';
log('Thumbnail dimensions calculated:', { shouldResize, thumbnailHeight, thumbnailWidth });
log('Thumbnail processing decision:', {
format,
shouldResize,
shouldResizeBySize,
thumbnailHeight,
thumbnailWidth,
});
const thumbnailBuffer = shouldResize
? await sharpInstance.resize(thumbnailWidth, thumbnailHeight).webp().toBuffer()
@@ -96,11 +118,10 @@ export class GenerationService {
// Calculate hash for thumbnail
const thumbnailHash = sha256(thumbnailBuffer);
log('Thumbnail image hash calculated:', thumbnailHash);
log('Image transformation completed successfully');
// Determine extension without fallback
// Determine extension using url utility
let extension: string;
if (url.startsWith('data:')) {
const mimeExtension = mime.getExtension(originalMimeType);
@@ -109,11 +130,10 @@ export class GenerationService {
}
extension = mimeExtension;
} else {
const urlExtension = url.split('.').pop();
if (!urlExtension) {
extension = inferFileExtensionFromImageUrl(url);
if (!extension) {
throw new Error(`Unable to determine file extension from URL: ${url}`);
}
extension = urlExtension;
}
return {
@@ -144,9 +164,8 @@ export class GenerationService {
const generationImagesFolder = 'generations/images';
const uuid = nanoid();
const dateTime = getYYYYmmddHHMMss(new Date());
const pathPrefix = `${generationImagesFolder}/${uuid}_${image.width}x${image.height}_${dateTime}`;
const imageKey = `${pathPrefix}_raw.${image.extension}`;
const thumbnailKey = `${pathPrefix}_thumb.${thumbnail.extension}`;
const imageKey = `${generationImagesFolder}/${uuid}_${image.width}x${image.height}_${dateTime}_raw.${image.extension}`;
const thumbnailKey = `${generationImagesFolder}/${uuid}_${thumbnail.width}x${thumbnail.height}_${dateTime}_thumb.${thumbnail.extension}`;
log('Generated paths:', { imagePath: imageKey, thumbnailPath: thumbnailKey });
@@ -189,37 +208,39 @@ export class GenerationService {
}
/**
* Create a 256x256 cover image from a given URL and upload
* Create a cover image from a given URL and upload
* @param coverUrl - The source image URL (can be base64 or HTTP URL)
* @returns The key of the uploaded cover image
*/
async createCoverFromUrl(coverUrl: string): Promise<string> {
log('Creating cover image from URL:', coverUrl.startsWith('data:') ? 'base64 data' : coverUrl);
// Download the original image
let originalImageBuffer: Buffer;
if (coverUrl.startsWith('data:')) {
log('Processing base64 cover image data');
// Extract base64 data part
const [, base64Data] = coverUrl.split(',');
originalImageBuffer = Buffer.from(base64Data, 'base64');
} else {
log('Fetching cover image from URL:', coverUrl);
const response = await fetch(coverUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch cover image from ${coverUrl}: ${response.status} ${response.statusText}`,
);
}
const arrayBuffer = await response.arrayBuffer();
originalImageBuffer = Buffer.from(arrayBuffer);
log('Successfully fetched cover image, buffer size:', originalImageBuffer.length);
// Fetch image buffer using utility function
const { buffer: originalImageBuffer } = await fetchImageFromUrl(coverUrl);
// Get image metadata to calculate proper cover dimensions
const sharpInstance = sharp(originalImageBuffer);
const { width, height } = await sharpInstance.metadata();
if (!width || !height) {
throw new Error('Invalid image format for cover creation');
}
// Process image to 256x256 cover with webp format
log('Processing cover image to 256x256 webp format');
const coverBuffer = await sharp(originalImageBuffer)
.resize(256, 256, { fit: 'cover', position: 'center' })
// Calculate cover dimensions maintaining aspect ratio with configurable max size
const { thumbnailWidth, thumbnailHeight } = calculateThumbnailDimensions(
width,
height,
IMAGE_GENERATION_CONFIG.COVER_MAX_SIZE,
);
log('Processing cover image with dimensions:', {
cover: { height: thumbnailHeight, width: thumbnailWidth },
original: { height, width },
});
const coverBuffer = await sharpInstance
.resize(thumbnailWidth, thumbnailHeight)
.webp()
.toBuffer();
log('Cover image processed, final size:', coverBuffer.length);
@@ -228,7 +249,7 @@ export class GenerationService {
const coverFolder = 'generations/covers';
const uuid = nanoid();
const dateTime = getYYYYmmddHHMMss(new Date());
const coverKey = `${coverFolder}/${uuid}_cover_${dateTime}.webp`;
const coverKey = `${coverFolder}/${uuid}_${thumbnailWidth}x${thumbnailHeight}_${dateTime}_cover.webp`;
log('Uploading cover image:', coverKey);
const result = await this.fileService.uploadMedia(coverKey, coverBuffer);

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { MAX_SEED, generateUniqueSeeds } from './number';
import { MAX_SEED, calculateThumbnailDimensions, generateUniqueSeeds } from './number';
describe('number utilities', () => {
describe('MAX_SEED constant', () => {
@@ -102,4 +102,104 @@ describe('number utilities', () => {
vi.useRealTimers();
});
});
describe('calculateThumbnailDimensions', () => {
it('should not resize when both dimensions are within max size', () => {
const result = calculateThumbnailDimensions(400, 300);
expect(result).toEqual({
shouldResize: false,
thumbnailWidth: 400,
thumbnailHeight: 300,
});
});
it('should not resize when dimensions equal max size', () => {
const result = calculateThumbnailDimensions(512, 512);
expect(result).toEqual({
shouldResize: false,
thumbnailWidth: 512,
thumbnailHeight: 512,
});
});
it('should resize when width exceeds max size (landscape)', () => {
const result = calculateThumbnailDimensions(1024, 768);
expect(result).toEqual({
shouldResize: true,
thumbnailWidth: 512,
thumbnailHeight: 384, // Math.round((768 * 512) / 1024)
});
});
it('should resize when height exceeds max size (portrait)', () => {
const result = calculateThumbnailDimensions(768, 1024);
expect(result).toEqual({
shouldResize: true,
thumbnailWidth: 384, // Math.round((768 * 512) / 1024)
thumbnailHeight: 512,
});
});
it('should resize square images correctly', () => {
const result = calculateThumbnailDimensions(1024, 1024);
expect(result).toEqual({
shouldResize: true,
thumbnailWidth: 512,
thumbnailHeight: 512,
});
});
it('should handle very large images', () => {
const result = calculateThumbnailDimensions(2048, 1536);
expect(result).toEqual({
shouldResize: true,
thumbnailWidth: 512,
thumbnailHeight: 384, // Math.round((1536 * 512) / 2048)
});
});
it('should handle very tall images', () => {
const result = calculateThumbnailDimensions(800, 2400);
expect(result).toEqual({
shouldResize: true,
thumbnailWidth: 171, // Math.round((800 * 512) / 2400)
thumbnailHeight: 512,
});
});
it('should work with custom max size', () => {
const result = calculateThumbnailDimensions(1000, 800, 256);
expect(result).toEqual({
shouldResize: true,
thumbnailWidth: 256,
thumbnailHeight: 205, // Math.round((800 * 256) / 1000)
});
});
it('should handle edge case with very small dimensions', () => {
const result = calculateThumbnailDimensions(50, 100);
expect(result).toEqual({
shouldResize: false,
thumbnailWidth: 50,
thumbnailHeight: 100,
});
});
it('should maintain aspect ratio correctly', () => {
const result = calculateThumbnailDimensions(1600, 900);
const originalRatio = 1600 / 900;
const thumbnailRatio = result.thumbnailWidth / result.thumbnailHeight;
expect(Math.abs(originalRatio - thumbnailRatio)).toBeLessThan(0.01);
});
});
});

View File

@@ -1,5 +1,7 @@
import prand from 'pure-rand';
import { IMAGE_GENERATION_CONFIG } from '@/const/imageGeneration';
export const MAX_SEED = 2 ** 31 - 1;
export function generateUniqueSeeds(seedCount: number): number[] {
// Use current timestamp as the initial seed
@@ -23,3 +25,43 @@ export function generateUniqueSeeds(seedCount: number): number[] {
return Array.from(seeds);
}
/**
* Calculate thumbnail dimensions
* Generate thumbnail with configurable max edge size
*/
export function calculateThumbnailDimensions(
originalWidth: number,
originalHeight: number,
maxSize: number = IMAGE_GENERATION_CONFIG.THUMBNAIL_MAX_SIZE,
): {
shouldResize: boolean;
thumbnailHeight: number;
thumbnailWidth: number;
} {
const shouldResize = originalWidth > maxSize || originalHeight > maxSize;
if (!shouldResize) {
return {
shouldResize: false,
thumbnailHeight: originalHeight,
thumbnailWidth: originalWidth,
};
}
const thumbnailWidth =
originalWidth > originalHeight
? maxSize
: Math.round((originalWidth * maxSize) / originalHeight);
const thumbnailHeight =
originalHeight > originalWidth
? maxSize
: Math.round((originalHeight * maxSize) / originalWidth);
return {
shouldResize: true,
thumbnailHeight,
thumbnailWidth,
};
}