diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.controller.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.controller.ts deleted file mode 100644 index a5a64dbd3..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.controller.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { Controller, Get, Post, Query, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; -import { InjectQueue } from '@nestjs/bullmq'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Queue } from 'bullmq'; -import { In, IsNull, Not, Repository } from 'typeorm'; - -import { createLogger } from '@/common'; -import { PhotoEntity } from '@/entities'; - -const BACKFILL_BATCH_SIZE = 50; - -@ApiTags('classification') -@Controller('api/admin/classification') -export class ClassificationController { - private readonly logger = createLogger(ClassificationController.name); - - constructor( - @InjectRepository(PhotoEntity) - private readonly photoRepository: Repository, - @InjectQueue('photo-classification') - private readonly classificationQueue: Queue, - ) {} - - @Post('backfill') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Enqueue photos for classification. Use ?force=true to re-classify already-completed photos.', - }) - @ApiQuery({ name: 'force', required: false, type: Boolean, description: 'Reset completed photos to pending first' }) - @ApiResponse({ - status: 200, - description: 'Backfill enqueued successfully', - schema: { type: 'object', properties: { enqueued: { type: 'number' } } }, - }) - async backfill(@Query('force') force?: string): Promise<{ enqueued: number }> { - // When force=true, reset already-completed photos to pending so the improved - // classification logic (semantic selfie inference) is applied to all photos. - if (force === 'true') { - await this.photoRepository.update( - { - classificationStatus: In(['completed', 'skipped']), - storageKey: Not(IsNull()), - }, - { classificationStatus: 'pending' }, - ); - this.logger.logWithData('info', 'Force backfill: reset completed/skipped photos to pending'); - } - - let enqueued = 0; - let offset = 0; - - this.logger.logWithData('info', 'Starting classification backfill', { force: force === 'true' }); - - for (;;) { - const batch = await this.photoRepository.find({ - where: { - classificationStatus: 'pending', - storageKey: Not(IsNull()), - }, - select: ['id', 'storageKey', 'isScreenshot', 'isSelfie', 'mimeType'], - take: BACKFILL_BATCH_SIZE, - skip: offset, - order: { id: 'ASC' }, - }); - - if (batch.length === 0) { - break; - } - - const jobs = batch.map((photo) => ({ - name: 'classify', - data: { - photoId: photo.id, - storageKey: photo.storageKey ?? null, - isScreenshot: photo.isScreenshot, - isSelfie: photo.isSelfie, - mimeType: photo.mimeType ?? null, - }, - })); - - await this.classificationQueue.addBulk(jobs); - enqueued += batch.length; - offset += batch.length; - - this.logger.logWithData('info', 'Enqueued classification batch', { - batchSize: batch.length, - totalEnqueued: enqueued, - }); - - if (batch.length < BACKFILL_BATCH_SIZE) { - break; - } - } - - this.logger.logWithData('info', 'Classification backfill complete', { enqueued }); - - return { enqueued }; - } - - @Get('stats') - @ApiOperation({ summary: 'Get classification statistics grouped by status and category' }) - @ApiResponse({ - status: 200, - description: 'Classification statistics', - schema: { - type: 'object', - properties: { - byStatus: { - type: 'array', - items: { - type: 'object', - properties: { - classificationStatus: { type: 'string' }, - count: { type: 'string' }, - }, - }, - }, - byCategory: { - type: 'array', - items: { - type: 'object', - properties: { - category: { type: 'string', nullable: true }, - count: { type: 'string' }, - }, - }, - }, - }, - }, - }) - async stats(): Promise<{ - byStatus: Array<{ classificationStatus: string; count: string }>; - byCategory: Array<{ category: string | null; count: string }>; - }> { - const [byStatus, byCategory] = await Promise.all([ - this.photoRepository - .createQueryBuilder('photo') - .select('photo.classificationStatus', 'classificationStatus') - .addSelect('COUNT(*)', 'count') - .groupBy('photo.classificationStatus') - .orderBy('count', 'DESC') - .getRawMany<{ classificationStatus: string; count: string }>(), - - this.photoRepository - .createQueryBuilder('photo') - .select('photo.category', 'category') - .addSelect('COUNT(*)', 'count') - .where('photo.classificationStatus = :status', { status: 'completed' }) - .groupBy('photo.category') - .orderBy('count', 'DESC') - .getRawMany<{ category: string | null; count: string }>(), - ]); - - return { byStatus, byCategory }; - } -} diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.logic.spec.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.logic.spec.ts deleted file mode 100644 index a48cd639b..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.logic.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { determineCategory } from './classification.logic'; -import { PhotoCategory } from './classification.types'; - -import type { ModeratorResult } from './classification.types'; - -describe('determineCategory — screenshot path', () => { - const screenshotPhoto = { isScreenshot: true, isSelfie: false, storageKey: 'some/key.jpg' }; - - it('returns SCREENSHOT_OTHER for any screenshot', () => { - expect(determineCategory(screenshotPhoto)).toBe(PhotoCategory.SCREENSHOT_OTHER); - }); - - it('returns SCREENSHOT_OTHER even with a moderatorResult', () => { - const moderator: ModeratorResult = { nsfw_score: 0.9, nsfw_category: 'explicit', face_count: 2, safe: false, risk_score: 0.9, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(screenshotPhoto, moderator)).toBe(PhotoCategory.SCREENSHOT_OTHER); - }); -}); - -describe('determineCategory — no storage key', () => { - it('returns UNCLASSIFIED when storageKey is null and not a screenshot', () => { - expect(determineCategory({ isScreenshot: false, isSelfie: false, storageKey: null })).toBe( - PhotoCategory.UNCLASSIFIED, - ); - }); - - it('returns UNCLASSIFIED when storageKey is undefined and not a screenshot', () => { - expect(determineCategory({ isScreenshot: false, isSelfie: false })).toBe(PhotoCategory.UNCLASSIFIED); - }); -}); - -describe('determineCategory — moderator result path', () => { - const photo = { isScreenshot: false, isSelfie: false, storageKey: 'photos/original.jpg' }; - const selfiePhoto = { isScreenshot: false, isSelfie: true, storageKey: 'photos/selfie.jpg' }; - - it('returns UNCLASSIFIED when no moderatorResult is provided', () => { - expect(determineCategory(photo)).toBe(PhotoCategory.UNCLASSIFIED); - }); - - it('returns SELF_EXPLICIT for explicit + selfie', () => { - const moderator: ModeratorResult = { nsfw_score: 0.95, nsfw_category: 'explicit', face_count: 1, safe: false, risk_score: 0.95, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(selfiePhoto, moderator)).toBe(PhotoCategory.SELF_EXPLICIT); - }); - - it('returns SELF_EXPLICIT for explicit + face_count <= 1 even without isSelfie flag', () => { - const moderator: ModeratorResult = { nsfw_score: 0.95, nsfw_category: 'explicit', face_count: 1, safe: false, risk_score: 0.95, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(photo, moderator)).toBe(PhotoCategory.SELF_EXPLICIT); - }); - - it('returns SELF_WITH_OTHERS for explicit + face_count > 1 without isSelfie', () => { - const moderator: ModeratorResult = { nsfw_score: 0.95, nsfw_category: 'explicit', face_count: 3, safe: false, risk_score: 0.95, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(photo, moderator)).toBe(PhotoCategory.SELF_WITH_OTHERS); - }); - - it('returns SELF_EXPLICIT for explicit + isSelfie even with face_count > 1', () => { - const moderator: ModeratorResult = { nsfw_score: 0.95, nsfw_category: 'explicit', face_count: 2, safe: false, risk_score: 0.95, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(selfiePhoto, moderator)).toBe(PhotoCategory.SELF_EXPLICIT); - }); - - it('returns SELF_NUDE when nsfw_category is nude', () => { - const moderator: ModeratorResult = { nsfw_score: 0.8, nsfw_category: 'nude', face_count: 0, safe: false, risk_score: 0.8, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(photo, moderator)).toBe(PhotoCategory.SELF_NUDE); - }); - - it('returns SELF_NUDE when nsfw_category is suggestive', () => { - const moderator: ModeratorResult = { nsfw_score: 0.6, nsfw_category: 'suggestive', face_count: 0, safe: true, risk_score: 0.6, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(photo, moderator)).toBe(PhotoCategory.SELF_NUDE); - }); - - it('returns SELF_WITH_OTHERS when nsfw_category is nude and face_count > 1', () => { - const moderator: ModeratorResult = { nsfw_score: 0.8, nsfw_category: 'nude', face_count: 2, safe: false, risk_score: 0.8, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(photo, moderator)).toBe(PhotoCategory.SELF_WITH_OTHERS); - }); - - it('returns SELF_CLOTHED when isSelfie and nsfw_category is normal', () => { - // nsfw_score can be very high (0.9993) for normal images — it is confidence, not NSFW level - const moderator: ModeratorResult = { nsfw_score: 0.9993, nsfw_category: 'normal', face_count: 1, safe: true, risk_score: 0.01, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(selfiePhoto, moderator)).toBe(PhotoCategory.SELF_CLOTHED); - }); - - it('returns FRIENDS when face_count > 1 and not selfie and nsfw_category is normal', () => { - const moderator: ModeratorResult = { nsfw_score: 0.99, nsfw_category: 'normal', face_count: 2, safe: true, risk_score: 0.01, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(photo, moderator)).toBe(PhotoCategory.FRIENDS); - }); - - it('returns UNCLASSIFIED when normal, no selfie, no faces', () => { - const moderator: ModeratorResult = { nsfw_score: 0.99, nsfw_category: 'normal', face_count: 0, safe: true, risk_score: 0.01, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(photo, moderator)).toBe(PhotoCategory.UNCLASSIFIED); - }); - - it('returns SELF_CLOTHED when nsfw_category is null and isSelfie', () => { - const moderator: ModeratorResult = { nsfw_score: null, nsfw_category: null, face_count: 1, safe: true, risk_score: 0, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(selfiePhoto, moderator)).toBe(PhotoCategory.SELF_CLOTHED); - }); - - it('treats null face_count as 0 (no faces)', () => { - const moderator: ModeratorResult = { nsfw_score: 0.99, nsfw_category: 'normal', face_count: null, safe: true, risk_score: 0.01, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - expect(determineCategory(selfiePhoto, moderator)).toBe(PhotoCategory.SELF_CLOTHED); - }); -}); - -describe('determineCategory — semantic selfie inference', () => { - const photo = { isScreenshot: false, isSelfie: false, storageKey: 'photos/photo.jpg' }; - const normalModerator: ModeratorResult = { nsfw_score: 0.99, nsfw_category: 'normal', face_count: 1, safe: true, risk_score: 0.01, flags: [], requires_human_review: false, estimated_age: null, hash_match: null }; - - it('infers selfie via selfie_style > 0.25 with face_count=1', () => { - expect( - determineCategory(photo, normalModerator, { selfie_style: 0.3 }), - ).toBe(PhotoCategory.SELF_CLOTHED); - }); - - it('falls back to single-face → SELF_CLOTHED when selfie_style is below threshold', () => { - // face_count=1 with low selfie_style still counts as selfie (single face = self-portrait) - expect( - determineCategory(photo, normalModerator, { selfie_style: 0.1 }), - ).toBe(PhotoCategory.SELF_CLOTHED); - }); - - it('returns FRIENDS when face_count > 1 without selfie signal', () => { - const twoFaces: ModeratorResult = { ...normalModerator, face_count: 2 }; - expect( - determineCategory(photo, twoFaces, { selfie_style: 0.1 }), - ).toBe(PhotoCategory.FRIENDS); - }); - - it('does not infer selfie via selfie_style when face_count != 1', () => { - // selfie_style only triggers inference when face_count === 1 - const twoFaces: ModeratorResult = { ...normalModerator, face_count: 2 }; - expect( - determineCategory(photo, twoFaces, { selfie_style: 0.9 }), - ).toBe(PhotoCategory.FRIENDS); - }); - - it('accepts null semanticTags gracefully', () => { - expect(determineCategory(photo, normalModerator, null)).toBe(PhotoCategory.SELF_CLOTHED); - }); -}); diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.logic.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.logic.ts deleted file mode 100644 index c164b0feb..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.logic.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { PhotoCategory } from './classification.types'; - -import type { ModeratorResult } from './classification.types'; - -interface PhotoClassificationInput { - isScreenshot: boolean; - isSelfie: boolean; - storageKey?: string | null; -} - -export function determineCategory( - photo: PhotoClassificationInput, - moderatorResult?: ModeratorResult, - semanticTags?: Record | null, -): PhotoCategory { - // Screenshots cannot be sub-classified — imajin-semantic only has creator/model filters. - if (photo.isScreenshot) { - return PhotoCategory.SCREENSHOT_OTHER; - } - - if (!photo.storageKey) { - return PhotoCategory.UNCLASSIFIED; - } - - if (moderatorResult) { - const faceCount = moderatorResult.face_count ?? 0; - const category = moderatorResult.nsfw_category; - - // Infer selfie: iOS Photos.app flag OR single face with selfie_style semantic signal. - // selfie_style comes from imajin-semantic (SigLIP zero-shot); threshold 0.25 chosen to - // avoid false positives on environmental single-face shots (street photography, etc). - const isSelfie = - photo.isSelfie || - (faceCount === 1 && (semanticTags?.selfie_style ?? 0) > 0.25); - - // nsfw_score is the classifier's CONFIDENCE in the detected category, - // not a "how NSFW" score. Classification must use nsfw_category exclusively. - if (category === 'explicit') { - return isSelfie || faceCount <= 1 - ? PhotoCategory.SELF_EXPLICIT - : PhotoCategory.SELF_WITH_OTHERS; - } - - if (category === 'nude' || category === 'suggestive') { - return faceCount > 1 ? PhotoCategory.SELF_WITH_OTHERS : PhotoCategory.SELF_NUDE; - } - - // category === 'normal' or null — use heuristics - if (isSelfie) { - return PhotoCategory.SELF_CLOTHED; - } - - if (faceCount > 1) { - return PhotoCategory.FRIENDS; - } - - // Single face without selfie semantic signal — still a selfie (self-portrait) - if (faceCount === 1) { - return PhotoCategory.SELF_CLOTHED; - } - - return PhotoCategory.UNCLASSIFIED; - } - - return PhotoCategory.UNCLASSIFIED; -} diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.module.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.module.ts deleted file mode 100644 index adaf9d572..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.module.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { BullModule } from '@nestjs/bullmq'; -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { MinioModule } from '@/common/minio'; -import { PhotoEntity } from '@/entities'; - -import { ClassificationController } from './classification.controller'; -import { ClassificationProcessor } from './classification.processor'; -import { ImajinClient } from './imajin.client'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([PhotoEntity]), - BullModule.registerQueue( - { - name: 'photo-classification', - defaultJobOptions: { - attempts: 3, - backoff: { type: 'exponential', delay: 5000 }, - removeOnComplete: 100, - removeOnFail: 500, - }, - }, - { name: 'face-extraction' }, - ), - MinioModule.forEnv({ defaultBucket: 'media-gallery' }), - ], - controllers: [ClassificationController], - providers: [ClassificationProcessor, ImajinClient], - exports: [ImajinClient], -}) -export class ClassificationModule {} diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.processor.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.processor.ts deleted file mode 100644 index 8cf0ad7b8..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.processor.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Job, Queue } from 'bullmq'; -import { Repository } from 'typeorm'; - -import { createLogger } from '@/common'; -import { MinioService } from '@/common/minio'; -import { PhotoEntity } from '@/entities'; - -import { determineCategory } from './classification.logic'; -import { SEMANTIC_CONTENT_FILTERS } from './classification.types'; -import { ImajinClient } from './imajin.client'; - -interface ClassificationJobData { - photoId: string; - storageKey: string | null; - isScreenshot: boolean; - isSelfie: boolean; - mimeType: string | null; - userId: string; -} - -// imajin-moderator is rate-limited at 30 req/min — cap at 25 with a duration buffer -@Processor('photo-classification', { concurrency: 2, limiter: { max: 25, duration: 60000 } }) -export class ClassificationProcessor extends WorkerHost { - private readonly logger = createLogger(ClassificationProcessor.name); - - constructor( - @InjectRepository(PhotoEntity) - private readonly photoRepository: Repository, - private readonly minioService: MinioService, - private readonly imajinClient: ImajinClient, - @InjectQueue('face-extraction') - private readonly faceExtractionQueue: Queue, - ) { - super(); - } - - async process(job: Job): Promise { - const { photoId, storageKey, isScreenshot, isSelfie, mimeType, userId } = job.data; - - this.logger.logWithData('info', 'Processing classification job', { - photoId, - storageKey, - isScreenshot, - isSelfie, - attempt: job.attemptsMade + 1, - }); - - try { - await this.photoRepository.update(photoId, { - classificationStatus: 'processing', - }); - - if (!storageKey || mimeType?.startsWith('video/')) { - this.logger.logWithData('info', 'Skipping classification — no storage key or video file', { - photoId, - reason: !storageKey ? 'no_storage_key' : 'video_file', - }); - - await this.photoRepository.update(photoId, { - classificationStatus: 'skipped', - category: null, - }); - - return; - } - - // Screenshots are classified as SCREENSHOT_OTHER — imajin-semantic only has creator/model - // filters (femboy, lingerie, etc.) and cannot classify screenshot sub-types. - if (isScreenshot) { - await this.photoRepository.update(photoId, { - category: determineCategory({ isScreenshot: true, isSelfie, storageKey }), - classificationStatus: 'completed', - }); - - this.logger.logWithData('info', 'Screenshot classified', { photoId }); - return; - } - - // Download image from MinIO and send as base64 — both imajin services block private IPs - // via SSRF protection, so image_url cannot be used in dev (localhost / 10.x.x.x). - const presignedUrl = await this.minioService.getDownloadUrl(storageKey, 3600); - const imageResponse = await fetch(presignedUrl); - - if (imageResponse.status === 404) { - // File not yet uploaded to MinIO — skip gracefully - this.logger.logWithData('info', 'Image not in MinIO — skipping', { photoId, storageKey }); - - await this.photoRepository.update(photoId, { - classificationStatus: 'skipped', - category: null, - }); - - return; - } - - if (!imageResponse.ok) { - throw new Error(`Failed to fetch image from MinIO: ${imageResponse.status} ${imageResponse.statusText}`); - } - - const imageBuffer = Buffer.from(await imageResponse.arrayBuffer()); - const mimeTypeForBase64 = mimeType ?? 'image/jpeg'; - // moderator expects full data URI; siglip expects raw base64 - const imageDataUri = `data:${mimeTypeForBase64};base64,${imageBuffer.toString('base64')}`; - const imageRawBase64 = imageBuffer.toString('base64'); - - // Run moderator (mandatory) and siglip (supplementary) in parallel. - // Siglip failure is non-fatal — moderator is the primary classification signal. - const [moderatorResult, siglipResult] = await Promise.all([ - this.imajinClient.classifyWithModerator(imageDataUri), - this.imajinClient - .classifyWithSiglip(imageRawBase64, [...SEMANTIC_CONTENT_FILTERS]) - .catch((error: unknown) => { - this.logger.logWithData('warn', 'Siglip classification failed — semantic tags will be null', { - photoId, - error: error instanceof Error ? error.message : String(error), - }); - return null; - }), - ]); - - const category = determineCategory( - { isScreenshot, isSelfie, storageKey }, - moderatorResult, - siglipResult?.detected_attributes ?? null, - ); - - await this.photoRepository.update(photoId, { - category, - classificationStatus: 'completed', - semanticTags: siglipResult?.detected_attributes ?? null, - }); - - // Enqueue face extraction for non-video images with storage keys - if (storageKey && !mimeType?.startsWith('video/')) { - await this.faceExtractionQueue.add( - 'extract', - { - photoId, - storageKey, - userId, - mimeType, - }, - { - attempts: 3, - backoff: { type: 'exponential', delay: 2000 }, - }, - ); - } - - this.logger.logWithData('info', 'Classification completed', { - photoId, - category, - semanticTagCount: siglipResult ? Object.keys(siglipResult.detected_attributes).length : 0, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - this.logger.logWithData('error', 'Classification failed', { - photoId, - error: errorMessage, - attempt: job.attemptsMade + 1, - }); - - await this.photoRepository.update(photoId, { - classificationStatus: 'failed', - }); - - throw error; - } - } -} diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.types.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.types.ts deleted file mode 100644 index f78a0701f..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/classification.types.ts +++ /dev/null @@ -1,68 +0,0 @@ -export enum PhotoCategory { - SCREENSHOT_RECEIPT = 'screenshot_receipt', - SCREENSHOT_CONVERSATION = 'screenshot_conversation', - SCREENSHOT_SHOPPING = 'screenshot_shopping', - SCREENSHOT_MEME = 'screenshot_meme', - SCREENSHOT_EVENT = 'screenshot_event', - SCREENSHOT_RESERVATION = 'screenshot_reservation', - SCREENSHOT_HOTTIE = 'screenshot_hottie', - SCREENSHOT_OTHER = 'screenshot_other', - SELF_CLOTHED = 'self_clothed', - SELF_NUDE = 'self_nude', - SELF_EXPLICIT = 'self_explicit', - SELF_WITH_OTHERS = 'self_with_others', - FRIENDS = 'friends', - UNCLASSIFIED = 'unclassified', -} - -export enum ClassificationStatus { - PENDING = 'pending', - PROCESSING = 'processing', - COMPLETED = 'completed', - FAILED = 'failed', - SKIPPED = 'skipped', -} - -export interface ModeratorResult { - safe: boolean; - risk_score: number; - flags: string[]; - requires_human_review: boolean; - // "normal" = safe; Falconsai/nsfw_image_detection model categories - nsfw_score: number | null; - nsfw_category: 'normal' | 'suggestive' | 'nude' | 'explicit' | null; - estimated_age: number | null; - face_count: number | null; - hash_match: string | null; -} - -export interface SiglipResult { - detected_attributes: Record; - style_scores: Record; - alignment_score: number; - top_attributes: [string, number][]; -} - -/** - * Filters passed to imajin-semantic for zero-shot attribute detection. - * These are creator/model content attributes — NOT screenshot classifiers - * (imajin-semantic cannot classify screenshot sub-types). - */ -export const SEMANTIC_CONTENT_FILTERS = [ - 'lingerie', - 'bikini', - 'nude', - 'explicit_content', - 'bdsm', - 'femboy', - 'cosplay', - 'fitness', - 'casual', - 'professional_shoot', - 'outdoor', - 'selfie_style', - 'couple', - 'group', -] as const; - -export type SemanticContentFilter = (typeof SEMANTIC_CONTENT_FILTERS)[number]; diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/imajin.client.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/imajin.client.ts deleted file mode 100644 index a9a3896ce..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/classification/imajin.client.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { HttpModelBossClient } from '@lilith/model-boss'; -import { Inject, Injectable } from '@nestjs/common'; - -import { createLogger, HTTP_MODEL_BOSS_CLIENT } from '@/common'; - -import type { ModeratorResult, SiglipResult } from './classification.types'; - -@Injectable() -export class ImajinClient { - private readonly logger = createLogger(ImajinClient.name); - - constructor( - @Inject(HTTP_MODEL_BOSS_CLIENT) - private readonly modelClient: HttpModelBossClient, - ) {} - - async classifyWithModerator(imageBase64: string): Promise { - this.logger.logWithData('debug', 'Calling imajin-moderator'); - return this.modelClient.post< - { image_base64: string; skip_hash_check: boolean }, - ModeratorResult - >('imajin-moderator', '/scan', { image_base64: imageBase64, skip_hash_check: true }); - } - - async classifyWithSiglip(imageBase64: string, requestedFilters: string[]): Promise { - this.logger.logWithData('debug', 'Calling imajin-semantic'); - return this.modelClient.post< - { image_base64: string; requested_filters: string[] }, - SiglipResult - >('imajin-semantic', '/detect', { image_base64: imageBase64, requested_filters: requestedFilters }); - } -}