Spaces:
Running
Running
File size: 10,736 Bytes
c7d34c1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 | 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;
};
|