mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ chore: improve image display quality (#8571)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
11
.gitignore
vendored
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
18
src/const/imageGeneration.ts
Normal file
18
src/const/imageGeneration.ts
Normal 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;
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
848
src/server/services/generation/index.test.ts
Normal file
848
src/server/services/generation/index.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user