visual-journal / src /lib /image-request-utils.ts
misonL's picture
Deploy Next.js Docker Space
c7d34c1 verified
Raw
History Blame
10.7 kB
import type OpenAI from 'openai';
export const VALID_IMAGE_FILENAME_PATTERN = /^\d{13}(?:-[a-f0-9]{16})?-\d+\.(png|jpe?g|webp)$/i;
export const MAX_IMAGE_COUNT = 10;
export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
export const MAX_PROMPT_LENGTH = 32000;
const VALID_MODE_VALUES = ['generate', 'edit'] as const;
const VALID_MODEL_VALUES = ['gpt-image-1', 'gpt-image-1-mini', 'gpt-image-1.5', 'gpt-image-2'] as const;
const VALID_OUTPUT_FORMAT_VALUES = ['png', 'jpeg', 'webp'] as const;
const VALID_GENERATE_QUALITY_VALUES = ['low', 'medium', 'high', 'auto'] as const;
const VALID_EDIT_QUALITY_VALUES = ['low', 'medium', 'high', 'auto'] as const;
const VALID_BACKGROUND_VALUES = ['transparent', 'opaque', 'auto'] as const;
const VALID_MODERATION_VALUES = ['low', 'auto'] as const;
const VALID_LEGACY_SIZE_VALUES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const;
export type ImageMode = (typeof VALID_MODE_VALUES)[number];
export type GptImageModel = (typeof VALID_MODEL_VALUES)[number];
export type ValidOutputFormat = (typeof VALID_OUTPUT_FORMAT_VALUES)[number];
export type GenerateQuality = (typeof VALID_GENERATE_QUALITY_VALUES)[number];
export type EditQuality = (typeof VALID_EDIT_QUALITY_VALUES)[number];
export type Background = (typeof VALID_BACKGROUND_VALUES)[number];
export type Moderation = (typeof VALID_MODERATION_VALUES)[number];
export type StorageMode = 'fs' | 'indexeddb';
export class RequestValidationError extends Error {
readonly status: number;
constructor(message: string, status = 400) {
super(message);
this.name = 'RequestValidationError';
this.status = status;
}
}
function isOneOf<T extends readonly string[]>(value: string, allowed: T): value is T[number] {
return (allowed as readonly string[]).includes(value);
}
export function readRequiredText(formData: FormData, field: string, maxLength = MAX_PROMPT_LENGTH): string {
const value = formData.get(field);
if (typeof value !== 'string' || value.trim().length === 0) {
throw new RequestValidationError(`缺少必填参数:${field}`);
}
if (value.length > maxLength) {
throw new RequestValidationError(`${field} 超过 ${maxLength} 个字符的最大长度。`);
}
return value;
}
export function readMode(formData: FormData): ImageMode {
const value = formData.get('mode');
if (typeof value !== 'string' || !isOneOf(value, VALID_MODE_VALUES)) {
throw new RequestValidationError('mode 无效,必须是 generate 或 edit。');
}
return value;
}
export function readModel(formData: FormData): GptImageModel {
const value = formData.get('model');
if (value === null) return 'gpt-image-2';
if (typeof value !== 'string' || !isOneOf(value, VALID_MODEL_VALUES)) {
throw new RequestValidationError('model 无效。');
}
return value;
}
export function readCount(formData: FormData, field: string, fallback: number, min: number, max: number): number {
const rawValue = formData.get(field);
if (rawValue === null) return fallback;
if (typeof rawValue !== 'string' || !/^\d+$/.test(rawValue)) {
throw new RequestValidationError(`${field} 必须是整数。`);
}
const value = Number(rawValue);
if (!Number.isInteger(value) || value < min || value > max) {
throw new RequestValidationError(`${field} 必须在 ${min}${max} 之间。`);
}
return value;
}
export function readOutputFormat(formData: FormData): ValidOutputFormat {
const rawValue = formData.get('output_format');
if (rawValue === null) return 'png';
if (typeof rawValue !== 'string') {
throw new RequestValidationError('output_format 必须是字符串。');
}
const normalized = rawValue.toLowerCase();
const mapped = normalized === 'jpg' ? 'jpeg' : normalized;
if (!isOneOf(mapped, VALID_OUTPUT_FORMAT_VALUES)) {
throw new RequestValidationError('output_format 无效,必须是 png、jpeg 或 webp。');
}
return mapped;
}
export function readGenerateQuality(formData: FormData): GenerateQuality {
const value = formData.get('quality');
if (value === null) return 'high';
if (typeof value !== 'string' || !isOneOf(value, VALID_GENERATE_QUALITY_VALUES)) {
throw new RequestValidationError('quality 无效。');
}
return value;
}
export function readEditQuality(formData: FormData): EditQuality {
const value = formData.get('quality');
if (value === null) return 'auto';
if (typeof value !== 'string' || !isOneOf(value, VALID_EDIT_QUALITY_VALUES)) {
throw new RequestValidationError('quality 无效。');
}
return value;
}
export function readBackground(formData: FormData, model: GptImageModel): Background {
const value = formData.get('background');
if (value === null) return 'auto';
if (typeof value !== 'string' || !isOneOf(value, VALID_BACKGROUND_VALUES)) {
throw new RequestValidationError('background 无效。');
}
if (model === 'gpt-image-2' && value === 'transparent') {
throw new RequestValidationError('gpt-image-2 不支持 transparent 背景。');
}
return value;
}
export function readModeration(formData: FormData): Moderation {
const value = formData.get('moderation');
if (value === null) return 'auto';
if (typeof value !== 'string' || !isOneOf(value, VALID_MODERATION_VALUES)) {
throw new RequestValidationError('moderation 无效。');
}
return value;
}
export function readSize(formData: FormData, field: string, fallback: string, model: GptImageModel): string {
const value = formData.get(field);
if (value === null) return fallback;
if (typeof value !== 'string' || value.trim().length === 0) {
throw new RequestValidationError(`${field} 必须是字符串。`);
}
if (model !== 'gpt-image-2' && !isOneOf(value, VALID_LEGACY_SIZE_VALUES)) {
throw new RequestValidationError(`${field}${model} 无效。`);
}
if (model === 'gpt-image-2' && value !== 'auto' && !/^\d+x\d+$/.test(value)) {
throw new RequestValidationError(`${field} 必须是 auto 或 WxH 格式的尺寸值。`);
}
return value;
}
export function readOutputCompression(formData: FormData, outputFormat: ValidOutputFormat): number | undefined {
const value = formData.get('output_compression');
if (value === null) return undefined;
if (outputFormat === 'png') {
throw new RequestValidationError('output_compression 仅适用于 jpeg 或 webp 输出。');
}
if (typeof value !== 'string' || !/^\d+$/.test(value)) {
throw new RequestValidationError('output_compression 必须是整数。');
}
const compression = Number(value);
if (!Number.isInteger(compression) || compression < 0 || compression > 100) {
throw new RequestValidationError('output_compression 必须在 0 到 100 之间。');
}
return compression;
}
export function readStorageMode(env: NodeJS.ProcessEnv): StorageMode {
const explicitMode = env.NEXT_PUBLIC_IMAGE_STORAGE_MODE;
if (explicitMode === 'fs' || explicitMode === 'indexeddb') return explicitMode;
if (explicitMode) {
throw new RequestValidationError('NEXT_PUBLIC_IMAGE_STORAGE_MODE 必须是 fs 或 indexeddb。', 500);
}
return env.VERCEL === '1' ? 'indexeddb' : 'fs';
}
export function assertSafeApiOverride(requestApiKey: string, requestApiBaseUrl: string): void {
if (requestApiBaseUrl && !requestApiKey) {
throw new RequestValidationError('填写自定义 API URL 时必须同时填写 API Key,避免服务器密钥被发送到未知地址。');
}
}
export function validateApiBaseUrl(baseUrl: string): void {
if (!baseUrl) return;
let parsed: URL;
try {
parsed = new URL(baseUrl);
} catch {
throw new RequestValidationError('API URL 格式无效。');
}
if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
throw new RequestValidationError('API URL 必须使用 https,localhost 调试地址除外。');
}
}
export function isValidImageFilename(filename: string): boolean {
return VALID_IMAGE_FILENAME_PATTERN.test(filename);
}
export function safeImagePath(baseDir: string, filename: string): string {
if (!isValidImageFilename(filename)) {
throw new RequestValidationError('文件名无效');
}
return `${baseDir}/${filename}`;
}
export function readImageFiles(formData: FormData): File[] {
const imageFiles: File[] = [];
for (const [key, value] of formData.entries()) {
if (key.startsWith('image_') && value instanceof File) {
imageFiles.push(value);
}
}
if (imageFiles.length === 0) {
throw new RequestValidationError('编辑时必须提供图片文件。');
}
if (imageFiles.length > MAX_IMAGE_COUNT) {
throw new RequestValidationError(`一次最多只能编辑 ${MAX_IMAGE_COUNT} 张图片。`);
}
imageFiles.forEach((file) => validateUploadFile(file));
return imageFiles;
}
export function readMaskFile(formData: FormData): File | undefined {
const value = formData.get('mask');
if (value === null) return undefined;
if (!(value instanceof File)) {
throw new RequestValidationError('mask 必须是 PNG 文件。');
}
validateUploadFile(value, { requirePng: true, fieldName: 'mask' });
return value;
}
function validateUploadFile(file: File, options: { requirePng?: boolean; fieldName?: string } = {}): void {
const fieldName = options.fieldName || file.name || 'image';
if (file.size <= 0) {
throw new RequestValidationError(`${fieldName} 为空。`);
}
if (file.size > MAX_UPLOAD_BYTES) {
throw new RequestValidationError(`${fieldName} 超过 ${MAX_UPLOAD_BYTES / 1024 / 1024} MB 限制。`);
}
if (!file.type.startsWith('image/')) {
throw new RequestValidationError(`${fieldName} 必须是图片文件。`);
}
if (options.requirePng && file.type !== 'image/png') {
throw new RequestValidationError(`${fieldName} 必须是 PNG 文件。`);
}
}
export function createImageResult(
filename: string,
b64Json: string,
outputFormat: ValidOutputFormat,
storageMode: StorageMode
): { filename: string; b64_json: string; path?: string; output_format: string } {
return {
filename,
b64_json: b64Json,
output_format: outputFormat,
...(storageMode === 'fs' ? { path: `/api/image/${filename}` } : {})
};
}
export type GenerateParams = Omit<OpenAI.Images.ImageGenerateParams, 'output_compression'> & {
output_compression?: number;
};