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:
parent
ab1317ff81
commit
8fbd15dd0f
3 changed files with 147 additions and 0 deletions
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ==========================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ==========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue