feat(imajin-processing): Introduce ProcessingService logic, ProcessingController routes, and client interface for image/video processing tasks

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-10 15:15:06 -07:00
parent ab1317ff81
commit 8fbd15dd0f
3 changed files with 147 additions and 0 deletions

View file

@ -18,6 +18,8 @@ import type {
SingleDerivativeRequest,
SingleDerivativeResponse,
ConvertRequest,
OverlayRequest,
OverlayResponse,
ConvertResponse,
MetadataRequest,
MetadataResponse,
@ -257,6 +259,17 @@ export class ImageProcessingClient {
});
}
/**
* 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.
*/

View file

@ -27,6 +27,8 @@ import type {
MetadataResponse,
ProcessRequest,
ProcessResponse,
OverlayRequest,
OverlayResponse,
ProcessOperation,
HealthResponse,
} from '@lilith/imajin-processing-types';
@ -41,6 +43,7 @@ import {
ConvertRequestSchema,
MetadataRequestSchema,
ProcessRequestSchema,
OverlayRequestSchema,
} from '@lilith/imajin-processing-types';
@Controller()
@ -419,6 +422,39 @@ export class ProcessingController {
}
}
// ==========================================================================
// Overlay (sticker/emoji censoring)
// ==========================================================================
@Post('overlay')
async overlay(@Body() body: OverlayRequest): Promise<OverlayResponse> {
const result = OverlayRequestSchema.safeParse(body);
if (!result.success) {
throw new HttpException(
{ message: 'Invalid request', errors: result.error.errors },
HttpStatus.BAD_REQUEST
);
}
try {
const buffer = Buffer.from(result.data.image, 'base64');
const { image, regionsApplied } = await this.processingService.overlay(buffer, {
emoji: result.data.emoji,
sticker: result.data.sticker ? Buffer.from(result.data.sticker, 'base64') : undefined,
regions: result.data.regions,
size: result.data.size,
format: result.data.format,
quality: result.data.quality,
});
return { image, regionsApplied };
} catch (error) {
throw new HttpException(
{ message: error instanceof Error ? error.message : 'Processing failed' },
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
// ==========================================================================
// Metadata
// ==========================================================================

View file

@ -570,6 +570,104 @@ export class ProcessingService {
};
}
// ==========================================================================
// Overlay (sticker/emoji censoring)
// ==========================================================================
/**
* Composite a sticker (raw RGBA PNG or a server-rasterized emoji) onto the
* image at normalized region centers. Built for censoring overlays e.g.
* pasting a heart over a nipple region reported by imajin-moderator's
* /detect/nsfw/regions.
*/
async overlay(
buffer: Buffer,
opts: {
emoji?: string;
sticker?: Buffer;
regions: Array<{ centerX: number; centerY: number; scale?: number }>;
size?: number;
format?: ImageFormat;
quality?: number;
}
): Promise<{ image: ProcessedImage; regionsApplied: number }> {
const meta = await sharp(buffer).metadata();
const width = meta.width ?? 0;
const height = meta.height ?? 0;
if (!width || !height) {
throw new Error('Could not read image dimensions');
}
const baseSize = Math.round(Math.min(width, height) * (opts.size ?? 0.15));
const baseSticker = opts.emoji ? await this.rasterizeEmoji(opts.emoji, baseSize) : opts.sticker;
if (!baseSticker) {
throw new Error('Provide exactly one of emoji or sticker');
}
const composites: sharp.OverlayOptions[] = [];
for (const region of opts.regions) {
const target = Math.max(1, Math.round(baseSize * (region.scale ?? 1)));
const stickerBuffer = await sharp(baseSticker)
.resize(target, target, { fit: 'inside' })
.png()
.toBuffer();
const stickerMeta = await sharp(stickerBuffer).metadata();
composites.push({
input: stickerBuffer,
left: Math.round(region.centerX * width - (stickerMeta.width ?? target) / 2),
top: Math.round(region.centerY * height - (stickerMeta.height ?? target) / 2),
});
}
const format = opts.format ?? 'jpeg';
const quality = opts.quality ?? 95;
let pipeline = sharp(buffer).composite(composites);
switch (format) {
case 'jpeg':
pipeline = pipeline.jpeg({ quality });
break;
case 'png':
pipeline = pipeline.png({ compressionLevel: 9 });
break;
case 'webp':
pipeline = pipeline.webp({ quality });
break;
}
const outputBuffer = await pipeline.toBuffer();
return {
image: {
buffer: outputBuffer.toString('base64'),
width,
height,
format,
fileSize: outputBuffer.length,
},
regionsApplied: composites.length,
};
}
/**
* Rasterize an emoji to an RGBA PNG via libvips' Pango text renderer.
* Requires a color-emoji font on the host (Noto Color Emoji).
*/
private async rasterizeEmoji(emoji: string, sizePx: number): Promise<Buffer> {
// Render 4x oversized, then Lanczos-downscale: color-emoji fonts are fixed-size
// bitmap strikes and Pango upscales them with visible aliasing otherwise.
const rendered = await sharp({
text: {
text: emoji,
font: 'Noto Color Emoji',
height: sizePx * 4,
rgba: true,
},
})
.png()
.toBuffer();
// Trim transparent padding so placement math uses the visible glyph extents
return sharp(rendered).trim().resize(sizePx, sizePx, { fit: 'inside' }).png().toBuffer();
}
// ==========================================================================
// Metadata
// ==========================================================================