292 lines
7.4 KiB
TypeScript
292 lines
7.4 KiB
TypeScript
/**
|
|
* Image Processing Client
|
|
*
|
|
* HTTP client for the image processing service.
|
|
*/
|
|
|
|
import type {
|
|
SanitizeRequest,
|
|
SanitizeResponse,
|
|
ResizeRequest,
|
|
ResizeResponse,
|
|
ThumbnailRequest,
|
|
ThumbnailResponse,
|
|
DerivativesRequest,
|
|
DerivativesResponse,
|
|
MasterRequest,
|
|
MasterResponse,
|
|
SingleDerivativeRequest,
|
|
SingleDerivativeResponse,
|
|
ConvertRequest,
|
|
OverlayRequest,
|
|
OverlayResponse,
|
|
ConvertResponse,
|
|
MetadataRequest,
|
|
MetadataResponse,
|
|
ProcessRequest,
|
|
ProcessResponse,
|
|
ProcessOperation,
|
|
HealthResponse,
|
|
AllowedMimeType,
|
|
FamilyName,
|
|
OptimizationPreset,
|
|
ImageFormat,
|
|
ResizeMode,
|
|
SanitizeOptions,
|
|
} from '@lilith/imajin-processing-types';
|
|
|
|
export interface ImageProcessingClientConfig {
|
|
/** Base URL of the image processing service */
|
|
baseUrl: string;
|
|
/** Request timeout in milliseconds (default: 60000) */
|
|
timeout?: number;
|
|
}
|
|
|
|
export class ImageProcessingError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly statusCode?: number,
|
|
public readonly code?: string
|
|
) {
|
|
super(message);
|
|
this.name = 'ImageProcessingError';
|
|
}
|
|
}
|
|
|
|
export class ImageProcessingClient {
|
|
private readonly baseUrl: string;
|
|
private readonly timeout: number;
|
|
|
|
constructor(config: ImageProcessingClientConfig) {
|
|
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
|
this.timeout = config.timeout ?? 60000;
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Private Helpers
|
|
// ==========================================================================
|
|
|
|
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
|
|
try {
|
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
...init,
|
|
signal: controller.signal,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...init?.headers,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ message: response.statusText })) as { message?: string; code?: string };
|
|
throw new ImageProcessingError(
|
|
errorData.message ?? 'Request failed',
|
|
response.status,
|
|
errorData.code
|
|
);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
} catch (error) {
|
|
if (error instanceof ImageProcessingError) throw error;
|
|
if (error instanceof Error) {
|
|
if (error.name === 'AbortError') {
|
|
throw new ImageProcessingError('Request timeout', 408, 'TIMEOUT');
|
|
}
|
|
throw new ImageProcessingError(error.message);
|
|
}
|
|
throw new ImageProcessingError('Unknown error');
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Public API
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Check service health.
|
|
*/
|
|
async health(): Promise<HealthResponse> {
|
|
return this.fetch<HealthResponse>('/health');
|
|
}
|
|
|
|
/**
|
|
* Process an image through a pipeline of operations.
|
|
* Operations are executed sequentially in the order specified.
|
|
*
|
|
* @param image - Base64-encoded image data
|
|
* @param mimeType - MIME type of the image
|
|
* @param operations - Array of operations to apply
|
|
* @param family - Image family (required if 'derivatives' operation is included)
|
|
*/
|
|
async process(
|
|
image: string,
|
|
mimeType: AllowedMimeType,
|
|
operations: ProcessOperation[],
|
|
family?: FamilyName
|
|
): Promise<ProcessResponse> {
|
|
const request: ProcessRequest = { image, mimeType, operations, family };
|
|
return this.fetch<ProcessResponse>('/process', {
|
|
method: 'POST',
|
|
body: JSON.stringify(request),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sanitize an image by re-encoding it.
|
|
* Strips EXIF data and potential malicious content.
|
|
*/
|
|
async sanitize(
|
|
image: string,
|
|
mimeType: AllowedMimeType,
|
|
options?: SanitizeOptions
|
|
): Promise<SanitizeResponse> {
|
|
const request: SanitizeRequest = { image, mimeType, options };
|
|
return this.fetch<SanitizeResponse>('/sanitize', {
|
|
method: 'POST',
|
|
body: JSON.stringify(request),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resize an image with the specified mode.
|
|
*/
|
|
async resize(
|
|
image: string,
|
|
width: number,
|
|
height: number,
|
|
options?: {
|
|
mode?: ResizeMode;
|
|
format?: ImageFormat;
|
|
quality?: number;
|
|
background?: string;
|
|
}
|
|
): Promise<ResizeResponse> {
|
|
const request: ResizeRequest = {
|
|
image,
|
|
options: { width, height, ...options },
|
|
};
|
|
return this.fetch<ResizeResponse>('/resize', {
|
|
method: 'POST',
|
|
body: JSON.stringify(request),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate a thumbnail.
|
|
*/
|
|
async thumbnail(
|
|
image: string,
|
|
size?: number,
|
|
quality?: number
|
|
): Promise<ThumbnailResponse> {
|
|
const request: ThumbnailRequest = { image, size, quality };
|
|
return this.fetch<ThumbnailResponse>('/thumbnail', {
|
|
method: 'POST',
|
|
body: JSON.stringify(request),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate all derivatives for a family.
|
|
*/
|
|
async derivatives(
|
|
image: string,
|
|
family: FamilyName,
|
|
preset?: OptimizationPreset
|
|
): Promise<DerivativesResponse> {
|
|
const request: DerivativesRequest = {
|
|
image,
|
|
options: { family, preset },
|
|
};
|
|
return this.fetch<DerivativesResponse>('/derivatives', {
|
|
method: 'POST',
|
|
body: JSON.stringify(request),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clip a single derivative from an image.
|
|
*/
|
|
async singleDerivative(
|
|
image: string,
|
|
width: number,
|
|
height: number,
|
|
preset?: OptimizationPreset
|
|
): Promise<SingleDerivativeResponse> {
|
|
const request: SingleDerivativeRequest = { image, width, height, preset };
|
|
return this.fetch<SingleDerivativeResponse>('/derivatives/single', {
|
|
method: 'POST',
|
|
body: JSON.stringify(request),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Prepare a master image for a family.
|
|
*/
|
|
async master(
|
|
image: string,
|
|
family: FamilyName,
|
|
preset?: OptimizationPreset
|
|
): Promise<MasterResponse> {
|
|
const request: MasterRequest = { image, family, preset };
|
|
return this.fetch<MasterResponse>('/master', {
|
|
method: 'POST',
|
|
body: JSON.stringify(request),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Convert an image to a different format.
|
|
*/
|
|
async convert(
|
|
image: string,
|
|
format: ImageFormat,
|
|
quality?: number
|
|
): Promise<ConvertResponse> {
|
|
const request: ConvertRequest = {
|
|
image,
|
|
options: { format, quality },
|
|
};
|
|
return this.fetch<ConvertResponse>('/convert', {
|
|
method: 'POST',
|
|
body: JSON.stringify(request),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Composite a sticker/emoji onto the image at normalized region centers
|
|
* (censoring overlays — e.g. hearts over regions from imajin-moderator).
|
|
*/
|
|
async overlay(request: OverlayRequest): Promise<OverlayResponse> {
|
|
return this.fetch<OverlayResponse>('/overlay', {
|
|
method: 'POST',
|
|
body: JSON.stringify(request),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get image metadata.
|
|
*/
|
|
async metadata(image: string): Promise<MetadataResponse> {
|
|
const request: MetadataRequest = { image };
|
|
return this.fetch<MetadataResponse>('/metadata', {
|
|
method: 'POST',
|
|
body: JSON.stringify(request),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a client for local development.
|
|
*/
|
|
export function createLocalClient(port: number = 8004): ImageProcessingClient {
|
|
return new ImageProcessingClient({
|
|
baseUrl: `http://localhost:${port}`,
|
|
});
|
|
}
|