feat(moderator-client): Add NSFW region detection API with client methods, TypeScript types, and dependency updates

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

View file

@ -7,7 +7,9 @@ import {
type FullScanResult,
type ScanResult,
type LoadHashesResult,
} from './types';
type NsfwRegion,
type NsfwRegionsResult,
} from './types.js';
/**
* Options for a full image scan
@ -159,6 +161,62 @@ export class ModeratorClientService {
return result.loaded;
}
/**
* NSFW region localization: labeled bounding boxes for exposed content.
* Unlike scans, this FAILS LOUDLY censoring overlays must never silently
* proceed with zero regions because the service was down.
*/
async detectNsfwRegions(
buffer: Buffer,
options?: { minScore?: number; labels?: string[] },
): Promise<NsfwRegionsResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
let response: Response;
try {
response = await fetch(`${this.baseUrl}/detect/nsfw/regions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_base64: buffer.toString('base64'),
min_score: options?.minScore ?? 0.3,
labels: options?.labels ?? null,
}),
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
if (!response.ok) {
throw new Error(
`Moderator service returned ${response.status}: ${response.statusText}`,
);
}
const raw = (await response.json()) as Record<string, unknown>;
const rawRegions = (raw['regions'] as Array<Record<string, unknown>>) ?? [];
return {
regions: rawRegions.map(
(r): NsfwRegion => ({
label: r['label'] as string,
score: r['score'] as number,
x: r['x'] as number,
y: r['y'] as number,
width: r['width'] as number,
height: r['height'] as number,
centerX: r['center_x'] as number,
centerY: r['center_y'] as number,
}),
),
imageWidth: raw['image_width'] as number,
imageHeight: raw['image_height'] as number,
hasGenitalsOrAnus: raw['has_genitals_or_anus'] as boolean,
processingMs: raw['processing_time_ms'] as number,
};
}
async isHealthy(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`, {

View file

@ -1,10 +1,12 @@
export { ModeratorClientService } from './client';
export type { ScanImageOptions } from './client';
export { ScanDecision, BlockReason, ContentFlag } from './types';
export { ModeratorClientService } from './client.js';
export type { ScanImageOptions } from './client.js';
export { ScanDecision, BlockReason, ContentFlag } from './types.js';
export type {
ProhibitedContentScore,
IdentityVerificationResult,
ScanResult,
FullScanResult,
LoadHashesResult,
} from './types';
NsfwRegion,
NsfwRegionsResult,
} from './types.js';

View file

@ -79,3 +79,41 @@ export interface LoadHashesResult {
/** Number of hashes successfully loaded */
loaded: number;
}
/**
* One labeled NSFW region from /detect/nsfw/regions (NudeNet detector)
*/
export interface NsfwRegion {
/** NudeNet class label (e.g. FEMALE_BREAST_EXPOSED, ANUS_EXPOSED) */
label: string;
/** Detection confidence 0.0-1.0 */
score: number;
/** Box left edge, pixels */
x: number;
/** Box top edge, pixels */
y: number;
/** Box width, pixels */
width: number;
/** Box height, pixels */
height: number;
/** Normalized [0,1] box-center x */
centerX: number;
/** Normalized [0,1] box-center y */
centerY: number;
}
/**
* Result of NSFW region localization
*/
export interface NsfwRegionsResult {
/** Detected regions, highest score first */
regions: NsfwRegion[];
/** Analyzed image width, pixels */
imageWidth: number;
/** Analyzed image height, pixels */
imageHeight: number;
/** True if any genital/anus region detected — platform photo-rule gate */
hasGenitalsOrAnus: boolean;
/** Service processing time in milliseconds */
processingMs: number;
}

View file

@ -104,15 +104,17 @@
},
"@packages/imajin-moderator-client": {
"name": "@lilith/imajin-moderator-client",
"version": "1.0.0",
"version": "1.1.0",
"dependencies": {
"@nestjs/common": "^11.0.0",
},
"devDependencies": {
"@lilith/configs": "^2.2.0",
"@lilith/lix-build": "^1.0.7",
"@lilith/lix-configs": "^1.0.1",
"@lilith/lix-test": "^1.0.0",
"@types/node": "^20.19.28",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
},
"peerDependencies": {
@ -332,7 +334,7 @@
},
"services/imajin-processing/client": {
"name": "@lilith/imajin-processing-client",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@lilith/imajin-processing-types": "workspace:*",
},
@ -344,7 +346,7 @@
},
"services/imajin-processing/types": {
"name": "@lilith/imajin-processing-types",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"zod": "^3.23.0",
},
@ -704,6 +706,8 @@
"@lilith/imajin-semantic-types": ["@lilith/imajin-semantic-types@workspace:services/imajin-semantic/types"],
"@lilith/lix-build": ["@lilith/lix-build@1.0.7", "http://registry.black.lan:4873/@lilith/lix-build/-/lix-build-1.0.7.tgz", { "dependencies": { "@lilith/lix-cli": "^1.0.0", "@lilith/lix-core": "^1.0.1", "commander": "^12.1.0", "execa": "^9.5.2" }, "bin": { "lixbuild": "bin/lixbuild.js" } }, "sha512-mbwFuPNbFJyot6w+dx/IcanBBq2wNOEEN0QtI3tPl8F/AD+A3vjweqPsXcfiIEjPYDACi2pkj6y8XjfI6H3oEA=="],
"@lilith/lix-cli": ["@lilith/lix-cli@1.0.0", "http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Flix-cli/-/1.0.0/lix-cli-1.0.0.tgz", { "dependencies": { "chalk": "^5.3.0", "ora": "^8.1.1" } }, "sha512-QGdkldx/olO/2yHgj0SppYNN+LDkRdPrvm9VdQhlzgmtwhn7s2GyasSP++T2fj8FvNSF4gjfyoa20r69X/NFcA=="],
"@lilith/lix-configs": ["@lilith/lix-configs@1.0.3", "http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Flix-configs/-/1.0.3/lix-configs-1.0.3.tgz", { "peerDependencies": { "tsup": "^8.0.0", "vite": "^5.0.0 || ^6.0.0" } }, "sha512-OdQE7d5WdACRPmTYABgbcyt3h+3UrknfFhTVifzllgwubboT0fUNxre6xt8jaYjz+ZRu54wXDNqZggilLhN14Q=="],