dokkebi-guard-upload 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ /** 단일 라벨 점수 */
2
+ export interface LabelScore {
3
+ label: string;
4
+ score: number;
5
+ }
6
+ /** NSFW 스캔 결과 */
7
+ export interface ScanResult {
8
+ /** 업로드 허용 여부 */
9
+ pass: boolean;
10
+ /** NSFW 관련 최고 점수 (0–1) */
11
+ nsfwScore: number;
12
+ /** 모델이 반환한 전체 라벨 점수 */
13
+ labels: LabelScore[];
14
+ /** 차단 시 사용자에게 보여줄 이유 */
15
+ reason?: string;
16
+ /** 처리에 걸린 시간(ms) */
17
+ durationMs: number;
18
+ }
19
+ export interface GuardUploadOptions {
20
+ /** NSFW 점수가 이 값 이상이면 차단 (0–1, 기본 0.75) */
21
+ nsfwThreshold?: number;
22
+ /** 차단 대상 라벨 (소문자 비교). 기본: nsfw, porn, explicit, hentai, sexy */
23
+ blockLabels?: string[];
24
+ /** Hugging Face 모델 ID (기본 AdamCodd/vit-base-nsfw-detector — Transformers.js ONNX) */
25
+ modelId?: string;
26
+ /** ONNX dtype: q4 | fp16 | fp32 (기본 q4, WASM 호환) */
27
+ modelDtype?: 'q4' | 'q4f16' | 'fp16' | 'fp32' | 'q8';
28
+ /** 추론 전 이미지 최대 변 (기본 512) */
29
+ maxImageSide?: number;
30
+ /** ONNX WASM 디렉터리. 미설정 시 Vite 등 번들러가 worker와 함께 wasm을 제공 (권장) */
31
+ onnxWasmPaths?: string;
32
+ /** Web Worker URL. 미지정 시 패키지 worker 또는 blob worker */
33
+ workerUrl?: string | URL;
34
+ /** 모델 로드 진행 콜백 */
35
+ onLoadProgress?: (progress: number, detail?: LoadProgressDetail) => void;
36
+ /** 스캔 시작/완료 콜백 */
37
+ onScanStart?: () => void;
38
+ onScanComplete?: (result: ScanResult) => void;
39
+ /** 배포용 무결성·proof (서버 검증) */
40
+ security?: GuardSecurityConfig;
41
+ }
42
+ /** 빌드 manifest (`guard-manifest.json`) */
43
+ export interface GuardManifest {
44
+ buildId: string;
45
+ workerSha256: string;
46
+ libVersion: string;
47
+ builtAt: string;
48
+ }
49
+ /** 서버 proof·무결성 검증 설정 */
50
+ export interface GuardSecurityConfig {
51
+ /** 빌드 ID (manifest.buildId) */
52
+ buildId: string;
53
+ /** Worker 스크립트 SHA-256 hex (manifest.workerSha256) */
54
+ workerSha256: string;
55
+ /** proof 발급 API (POST) */
56
+ proofEndpoint: string;
57
+ /** 변조 리포트 API (POST, 선택) */
58
+ reportEndpoint?: string;
59
+ /** proof 요청 추가 헤더 */
60
+ proofHeaders?: Record<string, string>;
61
+ proofCredentials?: RequestCredentials;
62
+ /** Worker 해시 검증 (기본 true) */
63
+ verifyWorker?: boolean;
64
+ }
65
+ export interface GuardSecurityOptions extends GuardUploadOptions {
66
+ security: GuardSecurityConfig;
67
+ }
68
+ export interface LoadProgressDetail {
69
+ status?: string;
70
+ loaded?: number;
71
+ total?: number;
72
+ file?: string;
73
+ }
74
+ export interface UploadOptions {
75
+ /** multipart 업로드 URL */
76
+ endpoint: string;
77
+ /** FormData 필드명 (기본 'file') */
78
+ fieldName?: string;
79
+ /** 추가 FormData 필드 */
80
+ extraFields?: Record<string, string | Blob>;
81
+ /** fetch 헤더 (Authorization 등) */
82
+ headers?: Record<string, string>;
83
+ /** proof 토큰 헤더명 (security 사용 시 기본 X-Guard-Token) */
84
+ guardTokenHeader?: string;
85
+ /** 응답 JSON에서 URL을 꺼낼 키 (기본 'url') */
86
+ urlKey?: string;
87
+ /** fetch credentials */
88
+ credentials?: RequestCredentials;
89
+ /** security.proofEndpoint 를 upload 에서도 쓸 경우 false 로 개별 지정 */
90
+ skipProof?: boolean;
91
+ }
92
+ export interface UploadResult {
93
+ url: string;
94
+ scan: ScanResult;
95
+ raw: unknown;
96
+ /** security 사용 시 발급된 proof 토큰 */
97
+ guardToken?: string;
98
+ }
99
+ export interface GuardUploadInstance {
100
+ /** 모델 로드 (최초 scan/upload 전 1회) */
101
+ load(): Promise<void>;
102
+ /** 이미지만 검열 */
103
+ scan(file: File | Blob): Promise<ScanResult>;
104
+ /** 검열 통과 시에만 업로드 */
105
+ upload(file: File | Blob, options: UploadOptions): Promise<UploadResult>;
106
+ /** Worker 종료 */
107
+ dispose(): void;
108
+ /** 모델 로드 완료 여부 */
109
+ readonly ready: boolean;
110
+ }
111
+ /** Worker ↔ Main 메시지 타입 */
112
+ export type WorkerOutMessage = {
113
+ type: 'loading';
114
+ device?: string;
115
+ } | {
116
+ type: 'progress';
117
+ progress: number;
118
+ loaded?: number;
119
+ total?: number;
120
+ file?: string;
121
+ } | {
122
+ type: 'ready';
123
+ device?: string;
124
+ } | {
125
+ type: 'result';
126
+ labels: LabelScore[];
127
+ nsfwScore: number;
128
+ pass: boolean;
129
+ reason?: string;
130
+ } | {
131
+ type: 'error';
132
+ message: string;
133
+ };
134
+ export type WorkerInMessage = {
135
+ type: 'load';
136
+ data: {
137
+ modelId: string;
138
+ modelDtype: string;
139
+ onnxWasmPaths?: string;
140
+ nsfwThreshold: number;
141
+ blockLabels: string[];
142
+ };
143
+ } | {
144
+ type: 'scan';
145
+ data: {
146
+ pixels: ArrayBuffer;
147
+ width: number;
148
+ height: number;
149
+ };
150
+ };
151
+ export declare const DEFAULT_MODEL_ID = "AdamCodd/vit-base-nsfw-detector";
152
+ export declare const DEFAULT_MODEL_DTYPE: "q4";
153
+ export declare const DEFAULT_BLOCK_LABELS: string[];
154
+ export declare const DEFAULT_NSFW_THRESHOLD = 0.75;
@@ -0,0 +1,9 @@
1
+ /** File/Blob → RGBA 픽셀 (최대 변 리사이즈) */
2
+ export interface ImagePixels {
3
+ pixels: Uint8ClampedArray;
4
+ width: number;
5
+ height: number;
6
+ }
7
+ export declare function fileToPixels(file: File | Blob, maxSide?: number): Promise<ImagePixels>;
8
+ export declare function isImageFile(file: File | Blob): boolean;
9
+ export declare function guessFilename(file: File | Blob, fallback?: string): string;
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "dokkebi-guard-upload",
3
+ "version": "0.1.0",
4
+ "description": "Client-side NSFW image screening before upload — browser Web Worker + ONNX",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.cjs",
13
+ "types": "./dist/index.d.ts"
14
+ },
15
+ "./worker": "./src/worker/nsfw-worker.ts",
16
+ "./server": "./server/guard-handler.mjs"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src/worker",
21
+ "server",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "build": "vite build && tsc --emitDeclarationOnly",
26
+ "copy-onnx": "node scripts/copy-onnx.mjs",
27
+ "dev": "npm run dev:vanilla",
28
+ "dev:vanilla": "vite --config examples/vanilla/vite.config.ts",
29
+ "dev:react": "vite --config examples/react/vite.config.ts",
30
+ "dev:vue": "vite --config examples/vue/vite.config.ts",
31
+ "guard:manifest": "vite build --config examples/react/vite.config.ts",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "keywords": [
35
+ "nsfw",
36
+ "content-moderation",
37
+ "image-upload",
38
+ "onnx",
39
+ "browser",
40
+ "dokkebi"
41
+ ],
42
+ "license": "MIT",
43
+ "peerDependencies": {
44
+ "@huggingface/transformers": "^3.0.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@huggingface/transformers": {
48
+ "optional": false
49
+ }
50
+ },
51
+ "devDependencies": {
52
+ "@huggingface/transformers": "^3.4.0",
53
+ "@types/react": "^18.3.0",
54
+ "@types/react-dom": "^18.3.0",
55
+ "@vitejs/plugin-react": "^4.3.0",
56
+ "@vitejs/plugin-vue": "^5.1.0",
57
+ "react": "^18.3.0",
58
+ "react-dom": "^18.3.0",
59
+ "typescript": "^5.5.3",
60
+ "vite": "^5.4.0",
61
+ "vite-plugin-dts": "^4.3.0",
62
+ "vue": "^3.4.0"
63
+ }
64
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Guard proof API — Node 18+ / Cloudflare Workers (Web Crypto)
3
+ * import from 'dokkebi-guard-upload/server' in host app
4
+ */
5
+
6
+ const DEFAULT_TTL_SEC = 30;
7
+
8
+ function b64urlEncode(bytes) {
9
+ return Buffer.from(bytes).toString('base64url');
10
+ }
11
+
12
+ function b64urlDecode(s) {
13
+ return new Uint8Array(Buffer.from(s, 'base64url'));
14
+ }
15
+
16
+ function canonicalProofMessage(p) {
17
+ return [p.v, p.bid, p.wh, p.fp, p.ns.toFixed(6), String(p.exp), p.nonce].join('|');
18
+ }
19
+
20
+ async function hmacSign(secret, message) {
21
+ const key = await crypto.subtle.importKey(
22
+ 'raw',
23
+ new TextEncoder().encode(secret),
24
+ { name: 'HMAC', hash: 'SHA-256' },
25
+ false,
26
+ ['sign'],
27
+ );
28
+ const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
29
+ return b64urlEncode(new Uint8Array(sig));
30
+ }
31
+
32
+ export async function signProofPayload(secret, payload) {
33
+ const body = b64urlEncode(new TextEncoder().encode(JSON.stringify(payload)));
34
+ const sig = await hmacSign(secret, canonicalProofMessage(payload));
35
+ return `${body}.${sig}`;
36
+ }
37
+
38
+ export async function verifyProofToken(secret, token, nowMs = Date.now()) {
39
+ const dot = token.lastIndexOf('.');
40
+ if (dot <= 0) return { ok: false, reason: 'MALFORMED_TOKEN' };
41
+
42
+ let payload;
43
+ try {
44
+ payload = JSON.parse(new TextDecoder().decode(b64urlDecode(token.slice(0, dot))));
45
+ } catch {
46
+ return { ok: false, reason: 'INVALID_PAYLOAD' };
47
+ }
48
+
49
+ if (payload.v !== 1) return { ok: false, reason: 'UNSUPPORTED_VERSION', payload };
50
+ if (payload.exp * 1000 < nowMs) return { ok: false, reason: 'EXPIRED', payload };
51
+
52
+ const expectedSig = await hmacSign(secret, canonicalProofMessage(payload));
53
+ const actualSig = token.slice(dot + 1);
54
+ if (expectedSig !== actualSig) return { ok: false, reason: 'SIGNATURE_MISMATCH', payload };
55
+
56
+ return { ok: true, payload };
57
+ }
58
+
59
+ /** @typedef {{ buildId: string; workerSha256: string; nsfwThreshold?: number }} AllowedBuild */
60
+
61
+ /**
62
+ * @param {object} opts
63
+ * @param {string} opts.secret GUARD_PROOF_SECRET
64
+ * @param {AllowedBuild[]} opts.allowedBuilds
65
+ * @param {number} [opts.nsfwThreshold=0.75]
66
+ * @param {(event: object) => void} [opts.auditLog]
67
+ */
68
+ export function createGuardHandlers(opts) {
69
+ const secret = opts.secret || 'dev-guard-secret-change-me';
70
+ const threshold = opts.nsfwThreshold ?? 0.75;
71
+ const allowed = new Map(
72
+ (opts.allowedBuilds || []).map((b) => [b.buildId, b]),
73
+ );
74
+ const audit = opts.auditLog || ((e) => console.info('[guard-audit]', JSON.stringify(e)));
75
+
76
+ async function handleProofRequest(body, meta = {}) {
77
+ const {
78
+ buildId,
79
+ workerSha256,
80
+ fileFingerprint,
81
+ nsfwScore,
82
+ pass,
83
+ labels,
84
+ durationMs,
85
+ } = body || {};
86
+
87
+ const base = {
88
+ ts: new Date().toISOString(),
89
+ buildId,
90
+ workerSha256,
91
+ fileFingerprint,
92
+ nsfwScore,
93
+ ip: meta.ip,
94
+ userAgent: meta.userAgent,
95
+ };
96
+
97
+ if (!buildId || !workerSha256 || !fileFingerprint) {
98
+ audit({ ...base, event: 'proof_rejected', code: 'MISSING_FIELDS' });
99
+ return { status: 400, json: { ok: false, code: 'MISSING_FIELDS', error: '필수 필드 누락' } };
100
+ }
101
+
102
+ const reg = allowed.get(buildId);
103
+ if (!reg) {
104
+ audit({ ...base, event: 'proof_rejected', code: 'UNKNOWN_BUILD' });
105
+ return { status: 403, json: { ok: false, code: 'UNKNOWN_BUILD', error: '등록되지 않은 빌드' } };
106
+ }
107
+
108
+ if (reg.workerSha256 && reg.workerSha256.toLowerCase() !== String(workerSha256).toLowerCase()) {
109
+ audit({
110
+ ...base,
111
+ event: 'integrity_mismatch',
112
+ expected: reg.workerSha256,
113
+ actual: workerSha256,
114
+ });
115
+ return {
116
+ status: 403,
117
+ json: { ok: false, code: 'INTEGRITY_MISMATCH', error: 'Worker 무결성 불일치' },
118
+ };
119
+ }
120
+
121
+ const limit = reg.nsfwThreshold ?? threshold;
122
+ if (!pass || Number(nsfwScore) >= limit) {
123
+ audit({ ...base, event: 'scan_blocked', threshold: limit, labels });
124
+ return { status: 403, json: { ok: false, code: 'SCAN_BLOCKED', error: '검열 미통과' } };
125
+ }
126
+
127
+ const exp = Math.floor(Date.now() / 1000) + DEFAULT_TTL_SEC;
128
+ const payload = {
129
+ v: 1,
130
+ bid: buildId,
131
+ wh: workerSha256.toLowerCase(),
132
+ fp: fileFingerprint,
133
+ ns: Number(nsfwScore),
134
+ exp,
135
+ nonce: crypto.randomUUID(),
136
+ };
137
+
138
+ const token = await signProofPayload(secret, payload);
139
+ audit({ ...base, event: 'proof_issued', exp, durationMs });
140
+
141
+ return { status: 200, json: { ok: true, token, exp } };
142
+ }
143
+
144
+ async function handleReport(body, meta = {}) {
145
+ audit({
146
+ ts: new Date().toISOString(),
147
+ event: body?.event || 'security_report',
148
+ ...body,
149
+ ip: meta.ip,
150
+ userAgent: meta.userAgent,
151
+ });
152
+ return { status: 204, json: null };
153
+ }
154
+
155
+ async function verifyUploadToken(token, fileFingerprint, buildId) {
156
+ const v = await verifyProofToken(secret, token);
157
+ if (!v.ok) return v;
158
+ if (v.payload.bid !== buildId) return { ok: false, reason: 'BUILD_MISMATCH' };
159
+ if (v.payload.fp !== fileFingerprint) return { ok: false, reason: 'FILE_MISMATCH' };
160
+ return v;
161
+ }
162
+
163
+ async function handleDemoUpload(req, meta = {}) {
164
+ const token = req.headers['x-guard-token'];
165
+ if (!token || typeof token !== 'string') {
166
+ audit({
167
+ ts: new Date().toISOString(),
168
+ event: 'upload_rejected',
169
+ code: 'MISSING_TOKEN',
170
+ ip: meta.ip,
171
+ userAgent: meta.userAgent,
172
+ });
173
+ return { status: 401, json: { ok: false, error: 'X-Guard-Token 필요' } };
174
+ }
175
+
176
+ const v = await verifyProofToken(secret, token);
177
+ if (!v.ok) {
178
+ audit({
179
+ ts: new Date().toISOString(),
180
+ event: 'upload_rejected',
181
+ code: v.reason,
182
+ ip: meta.ip,
183
+ userAgent: meta.userAgent,
184
+ });
185
+ return { status: 403, json: { ok: false, error: `토큰 검증 실패: ${v.reason}` } };
186
+ }
187
+
188
+ audit({
189
+ ts: new Date().toISOString(),
190
+ event: 'upload_accepted',
191
+ buildId: v.payload.bid,
192
+ fileFingerprint: v.payload.fp,
193
+ ip: meta.ip,
194
+ });
195
+
196
+ return {
197
+ status: 200,
198
+ json: { ok: true, url: `https://demo.local/upload/${v.payload.nonce}` },
199
+ };
200
+ }
201
+
202
+ return { handleProofRequest, handleReport, handleDemoUpload, verifyUploadToken, verifyProofToken };
203
+ }
204
+
205
+ /** Vite dev middleware (Connect) */
206
+ export function guardDevMiddleware(options = {}) {
207
+ const resolveBuilds = () =>
208
+ typeof options.allowedBuilds === 'function'
209
+ ? options.allowedBuilds()
210
+ : options.allowedBuilds || [];
211
+
212
+ return async function guardMiddleware(req, res, next) {
213
+ const handlers = createGuardHandlers({
214
+ ...options,
215
+ allowedBuilds: resolveBuilds(),
216
+ });
217
+ const url = req.url?.split('?')[0] || '';
218
+ if (!url.startsWith('/api/guard/')) return next();
219
+
220
+ if (req.method === 'OPTIONS') {
221
+ res.statusCode = 204;
222
+ res.end();
223
+ return;
224
+ }
225
+
226
+ const meta = {
227
+ ip: req.headers['x-forwarded-for'] || req.socket?.remoteAddress,
228
+ userAgent: req.headers['user-agent'],
229
+ };
230
+
231
+ let body = {};
232
+ if (req.method === 'POST' && url.startsWith('/api/guard/')) {
233
+ body = await readJsonBody(req);
234
+ }
235
+
236
+ let result;
237
+ if (url === '/api/guard/proof' && req.method === 'POST') {
238
+ result = await handlers.handleProofRequest(body, meta);
239
+ } else if (url === '/api/guard/report' && req.method === 'POST') {
240
+ result = await handlers.handleReport(body, meta);
241
+ } else if (url === '/api/upload' && req.method === 'POST') {
242
+ result = await handlers.handleDemoUpload(req, meta);
243
+ } else {
244
+ res.statusCode = 404;
245
+ res.setHeader('Content-Type', 'application/json');
246
+ res.end(JSON.stringify({ error: 'Not found' }));
247
+ return;
248
+ }
249
+
250
+ res.statusCode = result.status;
251
+ res.setHeader('Content-Type', 'application/json');
252
+ if (result.status === 204) {
253
+ res.end();
254
+ } else {
255
+ res.end(JSON.stringify(result.json));
256
+ }
257
+ };
258
+ }
259
+
260
+ function readJsonBody(req) {
261
+ return new Promise((resolve, reject) => {
262
+ let data = '';
263
+ req.on('data', (c) => {
264
+ data += c;
265
+ if (data.length > 65536) reject(new Error('body too large'));
266
+ });
267
+ req.on('end', () => {
268
+ try {
269
+ resolve(data ? JSON.parse(data) : {});
270
+ } catch {
271
+ resolve({});
272
+ }
273
+ });
274
+ req.on('error', reject);
275
+ });
276
+ }
@@ -0,0 +1,169 @@
1
+ /// <reference lib="webworker" />
2
+
3
+ import { env, pipeline, RawImage } from '@huggingface/transformers';
4
+
5
+ import {
6
+ DEFAULT_BLOCK_LABELS,
7
+ type LabelScore,
8
+ type WorkerInMessage,
9
+ type WorkerOutMessage,
10
+ } from '../types';
11
+
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ type Classifier = any;
14
+
15
+ let classifier: Classifier | null = null;
16
+ let nsfwThreshold = 0.75;
17
+ let blockLabels = DEFAULT_BLOCK_LABELS;
18
+
19
+ function configureEnv(onnxWasmPaths?: string) {
20
+ env.allowLocalModels = false;
21
+ env.useBrowserCache = true;
22
+
23
+ if (!onnxWasmPaths) return;
24
+
25
+ const e = env as Record<string, unknown> & {
26
+ backends?: { onnx?: { wasm?: Record<string, unknown> } };
27
+ };
28
+ if (!e.backends) e.backends = {};
29
+ if (!e.backends.onnx) e.backends.onnx = {};
30
+ if (!e.backends.onnx.wasm) e.backends.onnx.wasm = {};
31
+ e.backends.onnx.wasm.wasmPaths = onnxWasmPaths;
32
+ e.backends.onnx.wasm.numThreads = 1;
33
+ }
34
+
35
+ function normalizeLabel(label: string): string {
36
+ return label.toLowerCase().replace(/^label[_\s]*/i, '').trim();
37
+ }
38
+
39
+ function isBlockedLabel(label: string): boolean {
40
+ const norm = normalizeLabel(label);
41
+ if (norm === 'normal' || norm === 'safe' || norm === 'neutral' || norm === 'sfw') return false;
42
+ return blockLabels.some((b) => norm.includes(b) || b.includes(norm));
43
+ }
44
+
45
+ function computeNsfwScore(labels: LabelScore[]): number {
46
+ let max = 0;
47
+ for (const { label, score } of labels) {
48
+ if (isBlockedLabel(label)) max = Math.max(max, score);
49
+ }
50
+ return max;
51
+ }
52
+
53
+ function evaluate(labels: LabelScore[]): { pass: boolean; nsfwScore: number; reason?: string } {
54
+ const nsfwScore = computeNsfwScore(labels);
55
+ const pass = nsfwScore < nsfwThreshold;
56
+ if (pass) return { pass, nsfwScore };
57
+
58
+ const top = [...labels]
59
+ .filter(({ label }) => isBlockedLabel(label))
60
+ .sort((a, b) => b.score - a.score)[0];
61
+
62
+ const pct = Math.round(nsfwScore * 100);
63
+ const reason = top
64
+ ? `성인/노출 콘텐츠로 판단되어 업로드할 수 없습니다. (${normalizeLabel(top.label)} ${pct}%)`
65
+ : `성인/노출 콘텐츠로 판단되어 업로드할 수 없습니다. (${pct}%)`;
66
+
67
+ return { pass, nsfwScore, reason };
68
+ }
69
+
70
+ const FALLBACK_DTYPES = ['q4', 'fp16', 'fp32'] as const;
71
+
72
+ async function loadClassifier(
73
+ modelId: string,
74
+ preferredDtype: string,
75
+ onProgress: (p: Record<string, unknown>) => void,
76
+ ): Promise<{ classifier: Classifier; dtype: string }> {
77
+ const order = [
78
+ preferredDtype,
79
+ ...FALLBACK_DTYPES.filter((d) => d !== preferredDtype),
80
+ ];
81
+ let lastErr: unknown;
82
+ for (const dtype of order) {
83
+ try {
84
+ const clf = await pipeline('image-classification', modelId, {
85
+ device: 'wasm',
86
+ dtype,
87
+ progress_callback: onProgress,
88
+ });
89
+ return { classifier: clf, dtype };
90
+ } catch (err) {
91
+ lastErr = err;
92
+ const msg = err instanceof Error ? err.message : String(err);
93
+ // ConvInteger 등 WASM 미지원 양자화 → 다음 dtype 시도
94
+ if (/ConvInteger|Could not find an implementation|no available backend/i.test(msg)) {
95
+ continue;
96
+ }
97
+ throw err;
98
+ }
99
+ }
100
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
101
+ }
102
+
103
+ self.addEventListener('message', async (ev: MessageEvent<WorkerInMessage>) => {
104
+ const msg = ev.data;
105
+
106
+ if (msg.type === 'load') {
107
+ const { modelId, modelDtype, nsfwThreshold: threshold, blockLabels: labels } = msg.data;
108
+ nsfwThreshold = threshold;
109
+ blockLabels = labels;
110
+
111
+ try {
112
+ configureEnv(msg.data.onnxWasmPaths);
113
+
114
+ const onProgress = (p: Record<string, unknown>) => {
115
+ if (p.status === 'progress') {
116
+ self.postMessage({
117
+ type: 'progress',
118
+ progress: (p.progress as number) ?? 0,
119
+ loaded: p.loaded as number | undefined,
120
+ total: p.total as number | undefined,
121
+ file: p.file as string | undefined,
122
+ } satisfies WorkerOutMessage);
123
+ }
124
+ };
125
+
126
+ self.postMessage({ type: 'loading', device: 'wasm' } satisfies WorkerOutMessage);
127
+
128
+ const loaded = await loadClassifier(modelId, modelDtype, onProgress);
129
+ classifier = loaded.classifier;
130
+
131
+ self.postMessage({ type: 'ready', device: `wasm/${loaded.dtype}` } satisfies WorkerOutMessage);
132
+ } catch (err: unknown) {
133
+ const message = err instanceof Error ? err.message : String(err);
134
+ self.postMessage({ type: 'error', message } satisfies WorkerOutMessage);
135
+ }
136
+ return;
137
+ }
138
+
139
+ if (msg.type === 'scan') {
140
+ if (!classifier) {
141
+ self.postMessage({
142
+ type: 'error',
143
+ message: '모델이 로드되지 않았습니다. load()를 먼저 호출하세요.',
144
+ } satisfies WorkerOutMessage);
145
+ return;
146
+ }
147
+
148
+ try {
149
+ const { pixels, width, height } = msg.data;
150
+ const input = new RawImage(new Uint8ClampedArray(pixels), width, height, 4);
151
+ const raw = await classifier(input);
152
+ const labels: LabelScore[] = (raw as LabelScore[]).map(({ label, score }) => ({
153
+ label: String(label),
154
+ score: Number(score),
155
+ }));
156
+ const verdict = evaluate(labels);
157
+ self.postMessage({
158
+ type: 'result',
159
+ labels,
160
+ nsfwScore: verdict.nsfwScore,
161
+ pass: verdict.pass,
162
+ reason: verdict.reason,
163
+ } satisfies WorkerOutMessage);
164
+ } catch (err: unknown) {
165
+ const message = err instanceof Error ? err.message : String(err);
166
+ self.postMessage({ type: 'error', message } satisfies WorkerOutMessage);
167
+ }
168
+ }
169
+ });