diff --git a/@packages/imajin-moderator-client/src/client.ts b/@packages/imajin-moderator-client/src/client.ts index ffebbb3a..ccb1a49a 100644 --- a/@packages/imajin-moderator-client/src/client.ts +++ b/@packages/imajin-moderator-client/src/client.ts @@ -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 { + 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; + const rawRegions = (raw['regions'] as Array>) ?? []; + 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 { try { const response = await fetch(`${this.baseUrl}/health`, { diff --git a/@packages/imajin-moderator-client/src/index.ts b/@packages/imajin-moderator-client/src/index.ts index 6de6b199..3d20a707 100644 --- a/@packages/imajin-moderator-client/src/index.ts +++ b/@packages/imajin-moderator-client/src/index.ts @@ -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'; diff --git a/@packages/imajin-moderator-client/src/types.ts b/@packages/imajin-moderator-client/src/types.ts index e9208d98..a395c655 100644 --- a/@packages/imajin-moderator-client/src/types.ts +++ b/@packages/imajin-moderator-client/src/types.ts @@ -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; +} diff --git a/bun.lock b/bun.lock index c47f973a..04d15898 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="],