Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/backend/controllers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ export const PuterController =
public onServerShutdown() {
return;
}
public getReportedCosts(): // eslint-disable-next-line @typescript-eslint/no-explicit-any
public getReportedCosts():
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Promise<Record<string, any>[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[] {
Expand Down
654 changes: 607 additions & 47 deletions src/backend/drivers/ai-image/ImageGenerationDriver.test.ts

Large diffs are not rendered by default.

274 changes: 186 additions & 88 deletions src/backend/drivers/ai-image/ImageGenerationDriver.ts

Large diffs are not rendered by default.

159 changes: 159 additions & 0 deletions src/backend/drivers/ai-image/imageDimensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import { describe, expect, it } from 'vitest';
import {
isAspectRatio,
expandAspectRatio,
resolveImageSize,
closestAspectRatio,
formatAspectRatio,
} from './imageDimensions.js';

describe('isAspectRatio', () => {
it.each([
[16, 9, true],
[32, 18, true],
[64, 36, false],
[1024, 1024, false],
[0, 1, false],
[1, -1, false],
])('classifies %s by %s', (w, h, expected) => {
expect(isAspectRatio({ w: Number(w), h: Number(h) })).toBe(expected);
});
});

describe('resolveImageSize', () => {
it.each([
[{ ratio: { w: '16', h: '9' } }, { w: 16, h: 9, kind: 'aspect' }],
[
{ width: '4096', height: '3072' },
{ w: 4096, h: 3072, kind: 'pixels' },
],
[
{ width: ' 512 ', height: ' 512 ' },
{ w: 512, h: 512, kind: 'pixels' },
],
[{ aspect_ratio: '170:100' }, { w: 170, h: 100, kind: 'aspect' }],
[
{ ratio: { w: 4, h: 3 }, width: 1024, height: 1024 },
{ w: 4, h: 3, kind: 'aspect' },
],
[
{ width: 1024, height: 768, aspect_ratio: '1:1' },
{ w: 1024, h: 768, kind: 'pixels' },
],
])('preserves dimension intent for %j', (params, expected) => {
expect(resolveImageSize({ prompt: 'hi', ...params } as never)).toEqual(
expected,
);
});
it('retains the BytePlus legacy pixel threshold', () => {
expect(
resolveImageSize(
{ prompt: 'hi', width: 512, height: 512 },
{ pixelSizeThreshold: 921_600 },
),
).toEqual({ w: 512, h: 512, kind: 'aspect' });
});
it('does not manufacture a size when none is requested', () => {
expect(resolveImageSize({ prompt: 'hi' })).toBeUndefined();
});
it.each([
{ ratio: null },
{ width: null, height: null },
{ aspect_ratio: null },
{ aspect_ratio: '' },
{ aspect_ratio: ' ' },
{ ratio: null, width: null, height: null, aspect_ratio: null },
])('treats null and blank dimension fields %j as absent', (params) => {
expect(
resolveImageSize({ prompt: 'hi', ...params } as never),
).toBeUndefined();
});
it('names the missing partner when only one pixel dimension is set', () => {
expect(() =>
resolveImageSize({ prompt: 'hi', width: 1024 } as never),
).toThrow(/width and height must be set together/);
});
it('still rejects a lone dimension when its partner is null', () => {
expect(() =>
resolveImageSize({ prompt: 'hi', width: 1024, height: null } as never),
).toThrow(expect.objectContaining({ statusCode: 400 }));
});
it.each([
{ ratio: '16:9' },
{ ratio: {} },
{ ratio: { w: 0, h: 10 } },
{ ratio: { w: Infinity, h: 1 } },
{ ratio: { w: true, h: 1 } },
{ width: 'invalid', height: 1 },
{ width: 1 },
{ aspect_ratio: '1:2:3' },
{ aspect_ratio: 42 },
{ aspect_ratio: '-1:2' },
{ width: '0x10', height: '0x10' },
{ ratio: { w: '1e3', h: '1e3' } },
])('rejects malformed dimensions %j', (params) => {
expect(() =>
resolveImageSize({ prompt: 'hi', ...params } as never),
).toThrow(expect.objectContaining({ statusCode: 400 }));
});
});

describe('aspect helpers', () => {
it('chooses the closest supported shape symmetrically', () => {
const shapes = [
{ w: 16, h: 9 },
{ w: 9, h: 16 },
{ w: 1, h: 1 },
];
expect(closestAspectRatio({ w: 17, h: 10 }, shapes)).toEqual(shapes[0]);
expect(closestAspectRatio({ w: 10, h: 17 }, shapes)).toEqual(shapes[1]);
});
it.each([
[1920, 1080, '16:9'],
[16.5, 9.5, '33:19'],
[1.1, 1, '11:10'],
[0.1, 0.3, '1:3'],
[1024, 768, '4:3'],
[0, 1, undefined],
])('formats %s by %s', (w, h, expected) => {
expect(formatAspectRatio({ w: Number(w), h: Number(h) })).toBe(
expected,
);
});
it('omits a missing ratio', () =>
expect(formatAspectRatio()).toBeUndefined());
});

it.each([
{ w: 4, h: 1 },
{ w: 1e-200, h: 1e-200 },
])('expands an aspect hint without overflowing: %j', (ratio) => {
const result = expandAspectRatio(ratio);
expect(result.w / result.h).toBeCloseTo(ratio.w / ratio.h);
expect(result.w * result.h).toBeCloseTo(1024 * 1024);
});

it('rejects an aspect ratio whose quotient overflows', () => {
expect(() =>
resolveImageSize({ prompt: 'hi', ratio: { w: 16, h: 1e-320 } }),
).toThrow(expect.objectContaining({ statusCode: 400 }));
});
165 changes: 165 additions & 0 deletions src/backend/drivers/ai-image/imageDimensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import { HttpError } from '../../core/http/HttpError.js';
import type {
IGenerateParams,
IImageModel,
ImageDimensions,
ImageSize,
} from './types.js';

// Decimal-only, so `'0x10'`, `'1e3'` and similar number-literal coercions
// don't sneak into what is meant to be a plain image dimension.
const DECIMAL_RE = /^\d+(\.\d+)?$/;

function dimension(value: unknown): number {
const number =
typeof value === 'number'
? value
: typeof value === 'string' && DECIMAL_RE.test(value.trim())
? Number(value)
: NaN;
if (
!Number.isFinite(number) ||
number <= 0 ||
number > Number.MAX_SAFE_INTEGER
) {
throw new HttpError(
400,
'Image dimensions must be positive, finite numbers',
{ legacyCode: 'bad_request' },
);
}
return number;
}

/**
* Null and blank strings mean "not set": form state and spread option objects
* carry them, and JSON preserves them.
*/
function present<T>(value: T): value is NonNullable<T> {
return value != null && (typeof value !== 'string' || value.trim() !== '');
}

export function isAspectRatio(ratio: ImageDimensions): boolean {
return ratio.w > 0 && ratio.h > 0 && Math.max(ratio.w, ratio.h) <= 32;
}

/** Preserve dimension-source information before adapting to a model's sizes. */
export function resolveImageSize(
params: IGenerateParams,
model?: Pick<IImageModel, 'pixelSizeThreshold'>,
): ImageSize | undefined {
let pair: { w?: unknown; h?: unknown };
let explicitAspect = false;
if (present(params.ratio)) {
if (typeof params.ratio !== 'object' || Array.isArray(params.ratio)) {
throw new HttpError(400, 'ratio must contain w and h', {
legacyCode: 'bad_request',
});
}
pair = params.ratio;
} else if (present(params.width) || present(params.height)) {
if (!present(params.width) || !present(params.height)) {
throw new HttpError(400, 'width and height must be set together', {
legacyCode: 'bad_request',
});
}
pair = { w: params.width, h: params.height };
} else if (present(params.aspect_ratio)) {
if (
typeof params.aspect_ratio !== 'string' ||
params.aspect_ratio.split(':').length !== 2
) {
throw new HttpError(400, 'aspect_ratio must use w:h format', {
legacyCode: 'bad_request',
});
}
const [w, h] = params.aspect_ratio.split(':');
pair = { w, h };
explicitAspect = true;
} else return undefined;
const ratio = { w: dimension(pair.w), h: dimension(pair.h) };
const quotient = ratio.w / ratio.h;
if (!Number.isFinite(quotient) || quotient <= 0) {
throw new HttpError(
400,
'Image aspect ratio is outside the supported numeric range',
{ legacyCode: 'bad_request' },
);
}
const aspect =
explicitAspect ||
(model?.pixelSizeThreshold
? ratio.w * ratio.h < model.pixelSizeThreshold
: isAspectRatio(ratio));
return { ...ratio, kind: aspect ? 'aspect' : 'pixels' };
}

export function closestAspectRatio<T extends ImageDimensions>(
ratio: ImageDimensions,
candidates: T[],
): T {
const target = ratio.w / ratio.h;
return candidates.reduce((best, candidate) =>
Math.abs(Math.log(candidate.w / candidate.h / target)) <
Math.abs(Math.log(best.w / best.h / target))
? candidate
: best,
);
}

const isWhole = (value: number) => Math.abs(value - Math.round(value)) < 1e-6;

export function formatAspectRatio(ratio?: ImageDimensions): string | undefined {
if (
!ratio ||
!Number.isFinite(ratio.w) ||
!Number.isFinite(ratio.h) ||
ratio.w <= 0 ||
ratio.h <= 0
)
return undefined;
// Euclid needs integers: `%` on doubles turns 1.1:1 into sixteen-digit
// terms, so scale decimal pairs up first (16.5:9.5 → 165:95).
let scale = 1;
while (
scale < 1e6 &&
!(isWhole(ratio.w * scale) && isWhole(ratio.h * scale))
)
scale *= 10;
const w = Math.round(ratio.w * scale);
const h = Math.round(ratio.h * scale);
if (!w || !h) return undefined;
let a = w;
let b = h;
while (b !== 0) [a, b] = [b, a % b];
return `${w / a}:${h / a}`;
}

/** Expand a shape without multiplying tiny dimension pairs first. */
export function expandAspectRatio(
ratio: ImageDimensions,
pixels = 1024 * 1024,
): ImageDimensions {
const side = Math.sqrt(pixels);
const aspect = Math.sqrt(ratio.w / ratio.h);
return { w: side * aspect, h: side / aspect };
}
53 changes: 53 additions & 0 deletions src/backend/drivers/ai-image/imageOutput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import { describe, expect, it } from 'vitest';
import { imageDataUri } from './imageOutput.js';

describe('imageDataUri', () => {
it.each([
['jpeg', Buffer.from([0xff, 0xd8, 0xff, 0xe0])],
['png', Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])],
['webp', Buffer.from('RIFF0000WEBP')],
])('labels %s bytes without changing the payload', (format, bytes) => {
const base64 = Buffer.from(bytes).toString('base64');
expect(imageDataUri(base64)).toBe(
`data:image/${format};base64,${base64}`,
);
});

it('uses the model fallback for an unknown signature', () => {
expect(imageDataUri('AAAA', 'image/jpeg')).toBe(
'data:image/jpeg;base64,AAAA',
);
});

it('labels svg when the root sits beyond the first bytes', () => {
// `<?xml ...?>` pushes `<svg` past fixed-offset magic-number range, so
// the sniff has to scan the full 8 KB window rather than the head only.
const svg =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<svg xmlns="http://www.w3.org/2000/svg">' +
'<rect width="10" height="10"/></svg>';
const base64 = Buffer.from(svg).toString('base64');
expect(imageDataUri(base64)).toBe(
`data:image/svg+xml;base64,${base64}`,
);
});
});
Loading
Loading