From 8fbd15dd0fca66bce9fa656b179d77983e61dbdd Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 10 Jun 2026 15:15:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(imajin-processing):=20=E2=9C=A8=20Introduc?= =?UTF-8?q?e=20ProcessingService=20logic,=20ProcessingController=20routes,?= =?UTF-8?q?=20and=20client=20interface=20for=20image/video=20processing=20?= =?UTF-8?q?tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../imajin-processing/client/src/client.ts | 13 +++ .../src/processing/processing.controller.ts | 36 +++++++ .../src/processing/processing.service.ts | 98 +++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/services/imajin-processing/client/src/client.ts b/services/imajin-processing/client/src/client.ts index 294630c4..82c42d40 100644 --- a/services/imajin-processing/client/src/client.ts +++ b/services/imajin-processing/client/src/client.ts @@ -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 { + return this.fetch('/overlay', { + method: 'POST', + body: JSON.stringify(request), + }); + } + /** * Get image metadata. */ diff --git a/services/imajin-processing/service/src/processing/processing.controller.ts b/services/imajin-processing/service/src/processing/processing.controller.ts index 34cf563c..2f2fabee 100644 --- a/services/imajin-processing/service/src/processing/processing.controller.ts +++ b/services/imajin-processing/service/src/processing/processing.controller.ts @@ -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 { + 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 // ========================================================================== diff --git a/services/imajin-processing/service/src/processing/processing.service.ts b/services/imajin-processing/service/src/processing/processing.service.ts index c534d852..8b60e39b 100644 --- a/services/imajin-processing/service/src/processing/processing.service.ts +++ b/services/imajin-processing/service/src/processing/processing.service.ts @@ -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 { + // 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 // ==========================================================================