diff --git a/docs/NEXTGEN_REVIEW.md b/docs/NEXTGEN_REVIEW.md index a194440..a38fcc0 100644 --- a/docs/NEXTGEN_REVIEW.md +++ b/docs/NEXTGEN_REVIEW.md @@ -124,8 +124,10 @@ Culling: Index의 품질 점수로 필터 → 후보군/제외 뷰 ## 8. Phase 0 + Phase 1 상세 실행 계획 (확정 범위) ### 8.1 기술 추가 (이 단계 한정) -- **better-sqlite3** (네이티브) — 인덱스 DB. electron-builder의 `@electron/rebuild`로 ABI 재빌드(현재 빌드 파이프라인이 이미 수행). -- **썸네일은 네이티브(sharp) 없이** AI Worker(렌더러)의 canvas로 생성 → 바이트를 Main이 캐시. 네이티브 의존 최소화. +- ~~better-sqlite3(네이티브)~~ → **`sql.js`(WASM SQLite)로 확정**. 이유: 이 환경(Node 24 + Python 3.12)에서 네이티브 컴파일이 실패(distutils 제거)했고, 네이티브 모듈은 사용자 PC마다 ABI/빌드툴 문제가 재발한다. WASM은 빌드/재빌드가 전혀 없어 Windows+macOS 배포가 단순. 인메모리 DB를 `userData/index.db`로 export 영속화. (수천~수만 장 메타데이터 규모에 충분. 추후 필요 시 indexDb 추상화 뒤에서 네이티브로 교체 가능.) +- **썸네일은 네이티브(sharp) 없이** AI Worker(렌더러)의 canvas로 생성 → 바이트를 Main이 캐시. 네이티브 의존 0. + +> ✅ **Phase 0-a 완료(2026-06-01)**: `indexDb`(sql.js) + asset/quality 스키마 + 영속화. Electron 부팅 시 `userData/index.db` 생성 확인. typecheck/build/스모크 통과. - Phase 1 품질 점수는 **모델 거의 불필요**: 초점=라플라시안 분산, 노출=휘도 히스토그램, 감은 눈=face-api 랜드마크(EAR). face-api는 이미 추론창에 로드됨. ### 8.2 데이터 모델 (SQLite) @@ -163,13 +165,27 @@ UI (신규 화면) - 기존 정리기와 통합: 정리 잡도 인덱스(EXIF/얼굴)를 재사용하도록 점진 연결. ### 8.4 작업 순서 (체크리스트) -- [ ] Phase 0-a: better-sqlite3 도입 + `indexDb` 스키마/마이그레이션 + 빌드(ABI 재빌드) 검증 -- [ ] Phase 0-b: contentHash 해셔 + 라이브러리 폴더 지정 UI + `indexer` 워크/재개 + 진행률 IPC -- [ ] Phase 0-c: AI Worker에 썸네일 생성 + 캐시 + 그리드 표시(빈 품질로 우선) -- [ ] Phase 1-a: `qualityEngine` 초점/노출 점수 → DB 저장 -- [ ] Phase 1-b: 감은 눈(EAR, face-api 랜드마크) 점수 → flag 산출 -- [ ] Phase 1-c: CullingView(후보/제외 그리드, 점수, 임계값 설정, 오버라이드) -- [ ] Phase 1-d: (옵션) 제외 사진 내보내기/이동 액션 +- [x] **Phase 0-a 완료**: `indexDb`(sql.js로 확정) 스키마/마이그레이션 + Electron 부팅 시 DB 생성 검증 +- [x] **Phase 0-b 완료**: contentHash(샘플 sha1) + 라이브러리 폴더 지정 UI(라이브러리 탭) + `indexer` 워크/재개(경로·mtime 스킵)/진행률 IPC/배치 영속화/취소. 헤드리스 파이프라인 검증(중복 0) + 부팅 스모크 통과 +- [x] **Phase 0-c 완료**: AI Worker(추론창) canvas로 썸네일(webp) 생성 → `userData/thumbs/.webp` 캐시(비파괴), 색인 시 자동 생성 + 원본 치수 저장, `photoai-media://thumb` 해시 기반 보안 제공, 라이브러리 탭 썸네일 그리드(페이지네이션). typecheck/build/부팅 스모크 통과 +- [x] **Phase 1-a 완료**: `qualityEngine` 초점(라플라시안 분산)/노출(히스토그램 클리핑) 점수 → DB +- [x] **Phase 1-b 완료**: 감은 눈(EAR, face-api 랜드마크, Tiny 검출기) 점수 → `classifyFlag`로 종합 분류(candidate/blurry/eyesClosed/badExposure). 색인 시 `infer:analyze`로 썸네일+품질 1회 로드 통합 +- [x] **Phase 1-c (기본) 완료**: 라이브러리 그리드에 컬링 필터(전체/고품질 후보/제외) + 품질 배지. *임계값 튜닝 UI · 사진별 수동 오버라이드는 후속으로 보류* +- [ ] Phase 1-d(옵션): 제외 사진 내보내기/이동 액션 (보류) + +### 8.6 Phase 1 마무리 (임계값 튜닝 + 수동 오버라이드) — 완료(2026-06-01) +- [x] 품질 임계값(초점/노출/눈)을 설정에 저장 + 슬라이더 UI. 임계값 변경 시 **저장된 원본 점수로 SQL CASE 실시간 재분류**(재분석 없음) +- [x] 별점(0~5) + 색라벨(5색) 수동 메타(`usermeta` 테이블), 썸네일 호버 편집, 별점 최소 필터 + +### 8.7 Phase 2 (CLIP 자연어/유사 검색) — 기본 완료(2026-06-01) +- [x] `@huggingface/transformers`(WASM/WebGPU, 네이티브 빌드 0) 도입 +- [x] CLIP(`Xenova/clip-vit-base-patch32`) 이미지/텍스트 임베딩 — 추론창 lazy-load +- [x] 한국어 쿼리 자동 번역(`Xenova/opus-mt-ko-en`) → 영어 CLIP +- [x] 임베딩 SQLite BLOB 저장 + "검색 색인 생성"(임베딩 배치, 진행률/취소) — 기본 색인과 분리 +- [x] 브루트포스 코사인 검색 + **검색 탭**(임베딩 상태/생성 + 검색바 + 결과 그리드) +- [x] typecheck/build/부팅 스모크 통과 + +> ⚠️ **Phase 2 런타임 미검증 항목**: CLIP/번역 모델은 **최초 사용 시 HF Hub에서 다운로드**(온라인 1회 필요, 이후 캐시). ONNX Runtime WASM의 **완전 오프라인 패키징**(CDN 대신 동봉)과 **한국어 검색 정확도 실측**은 후속 과제. 임베딩 생성은 이미지당 수백 ms(WASM) → 대량은 시간 소요(백그라운드). - [ ] i18n(ko/en) · 다크모드 · 검증(typecheck/test/build/스모크) ### 8.5 리스크 (이 단계) diff --git a/electron-builder.yml b/electron-builder.yml index d8e2a02..f52607d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -14,6 +14,8 @@ extraResources: - "**/*" asarUnpack: - "**/*.node" + # sql.js WASM 바이너리 (인덱스 DB) — asar 밖에서 읽도록 + - "node_modules/sql.js/dist/*.wasm" win: target: - nsis diff --git a/package-lock.json b/package-lock.json index 938ff02..0aadcb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,17 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@tensorflow/tfjs": "^4.22.0", + "@huggingface/transformers": "^3.8.1", "@vladmandic/face-api": "^1.7.13", "exifr": "^7.1.3", + "sql.js": "^1.12.0", "zustand": "^4.5.5" }, "devDependencies": { "@types/node": "^20.16.0", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", + "@types/sql.js": "^1.4.9", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "electron": "^33.0.0", @@ -774,6 +776,16 @@ "node": ">= 10.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1172,6 +1184,492 @@ "dev": true, "license": "MIT" }, + "node_modules/@huggingface/jinja": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", + "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/transformers": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", + "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.3", + "onnxruntime-node": "1.21.0", + "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", + "sharp": "^0.34.1" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1275,6 +1773,27 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/fs-minipass/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1494,6 +2013,69 @@ "node": ">=14" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1877,120 +2459,6 @@ "node": ">=10" } }, - "node_modules/@tensorflow/tfjs": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs/-/tfjs-4.22.0.tgz", - "integrity": "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg==", - "license": "Apache-2.0", - "dependencies": { - "@tensorflow/tfjs-backend-cpu": "4.22.0", - "@tensorflow/tfjs-backend-webgl": "4.22.0", - "@tensorflow/tfjs-converter": "4.22.0", - "@tensorflow/tfjs-core": "4.22.0", - "@tensorflow/tfjs-data": "4.22.0", - "@tensorflow/tfjs-layers": "4.22.0", - "argparse": "^1.0.10", - "chalk": "^4.1.0", - "core-js": "3.29.1", - "regenerator-runtime": "^0.13.5", - "yargs": "^16.0.3" - }, - "bin": { - "tfjs-custom-module": "dist/tools/custom_module/cli.js" - } - }, - "node_modules/@tensorflow/tfjs-backend-cpu": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.22.0.tgz", - "integrity": "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==", - "license": "Apache-2.0", - "dependencies": { - "@types/seedrandom": "^2.4.28", - "seedrandom": "^3.0.5" - }, - "engines": { - "yarn": ">= 1.3.2" - }, - "peerDependencies": { - "@tensorflow/tfjs-core": "4.22.0" - } - }, - "node_modules/@tensorflow/tfjs-backend-webgl": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-4.22.0.tgz", - "integrity": "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==", - "license": "Apache-2.0", - "dependencies": { - "@tensorflow/tfjs-backend-cpu": "4.22.0", - "@types/offscreencanvas": "~2019.3.0", - "@types/seedrandom": "^2.4.28", - "seedrandom": "^3.0.5" - }, - "engines": { - "yarn": ">= 1.3.2" - }, - "peerDependencies": { - "@tensorflow/tfjs-core": "4.22.0" - } - }, - "node_modules/@tensorflow/tfjs-converter": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-4.22.0.tgz", - "integrity": "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==", - "license": "Apache-2.0", - "peerDependencies": { - "@tensorflow/tfjs-core": "4.22.0" - } - }, - "node_modules/@tensorflow/tfjs-core": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-4.22.0.tgz", - "integrity": "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@types/long": "^4.0.1", - "@types/offscreencanvas": "~2019.7.0", - "@types/seedrandom": "^2.4.28", - "@webgpu/types": "0.1.38", - "long": "4.0.0", - "node-fetch": "~2.6.1", - "seedrandom": "^3.0.5" - }, - "engines": { - "yarn": ">= 1.3.2" - } - }, - "node_modules/@tensorflow/tfjs-core/node_modules/@types/offscreencanvas": { - "version": "2019.7.3", - "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", - "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", - "license": "MIT" - }, - "node_modules/@tensorflow/tfjs-data": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-data/-/tfjs-data-4.22.0.tgz", - "integrity": "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w==", - "license": "Apache-2.0", - "dependencies": { - "@types/node-fetch": "^2.1.2", - "node-fetch": "~2.6.1", - "string_decoder": "^1.3.0" - }, - "peerDependencies": { - "@tensorflow/tfjs-core": "4.22.0", - "seedrandom": "^3.0.5" - } - }, - "node_modules/@tensorflow/tfjs-layers": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-layers/-/tfjs-layers-4.22.0.tgz", - "integrity": "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA==", - "license": "Apache-2.0 AND MIT", - "peerDependencies": { - "@tensorflow/tfjs-core": "4.22.0" - } - }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", @@ -2069,6 +2537,13 @@ "@types/ms": "*" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2103,12 +2578,6 @@ "@types/node": "*" } }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT" - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -2125,22 +2594,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/@types/offscreencanvas": { - "version": "2019.3.0", - "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz", - "integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==", - "license": "MIT" - }, "node_modules/@types/plist": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", @@ -2192,11 +2645,16 @@ "@types/node": "*" } }, - "node_modules/@types/seedrandom": { - "version": "2.4.34", - "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.34.tgz", - "integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==", - "license": "MIT" + "node_modules/@types/sql.js": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz", + "integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/emscripten": "*", + "@types/node": "*" + } }, "node_modules/@types/verror": { "version": "1.10.11", @@ -2360,12 +2818,6 @@ "node": ">=14.0.0" } }, - "node_modules/@webgpu/types": { - "version": "0.1.38", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.38.tgz", - "integrity": "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==", - "license": "BSD-3-Clause" - }, "node_modules/@xmldom/xmldom": { "version": "0.9.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", @@ -2459,6 +2911,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2468,6 +2921,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2709,15 +3163,6 @@ "dev": true, "license": "MIT" }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -2771,6 +3216,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -2911,9 +3357,7 @@ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/brace-expansion": { "version": "5.0.6", @@ -3229,6 +3673,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3290,6 +3735,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -3437,17 +3883,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, "node_modules/clone": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", @@ -3475,6 +3910,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3487,6 +3923,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/color-support": { @@ -3503,6 +3940,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -3644,17 +4082,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-js": { - "version": "3.29.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.29.1.tgz", - "integrity": "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -3819,9 +4246,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -3838,9 +4263,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -3857,6 +4280,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3873,7 +4297,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -3883,9 +4306,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/didyoumean": { "version": "1.2.2", @@ -3949,6 +4370,7 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -4059,6 +4481,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4365,15 +4788,16 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -4434,6 +4858,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4446,6 +4871,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4461,9 +4887,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/esbuild": { "version": "0.21.5", @@ -4508,6 +4932,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4517,9 +4942,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10" }, @@ -4709,6 +5132,12 @@ "node": ">=8" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4743,6 +5172,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4830,6 +5260,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4870,6 +5301,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4879,6 +5311,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4903,6 +5336,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4998,9 +5432,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, "license": "BSD-3-Clause", - "optional": true, "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", @@ -5017,9 +5449,7 @@ "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "dev": true, "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -5031,9 +5461,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" @@ -5090,10 +5518,17 @@ "dev": true, "license": "ISC" }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5103,9 +5538,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -5117,6 +5550,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5129,6 +5563,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -5151,6 +5586,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5273,7 +5709,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5415,6 +5851,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5612,9 +6049,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/json5": { "version": "2.2.3", @@ -5782,9 +6217,9 @@ } }, "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, "node_modules/loose-envify": { @@ -5920,9 +6355,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "escape-string-regexp": "^4.0.0" }, @@ -5934,6 +6367,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5980,6 +6414,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -5989,6 +6424,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -6272,26 +6708,6 @@ "node": ">=10" } }, - "node_modules/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-gyp": { "version": "9.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", @@ -6421,9 +6837,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 0.4" } @@ -6454,6 +6868,104 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/onnxruntime-common": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", + "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz", + "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "global-agent": "^3.0.0", + "onnxruntime-common": "1.21.0", + "tar": "^7.0.1" + } + }, + "node_modules/onnxruntime-node/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/onnxruntime-node/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/onnxruntime-node/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/onnxruntime-node/node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/onnxruntime-node/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", + "license": "MIT" + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -6667,6 +7179,12 @@ "node": ">= 6" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", @@ -6884,6 +7402,30 @@ "node": ">=10" } }, + "node_modules/protobufjs": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.2.tgz", + "integrity": "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -7067,16 +7609,11 @@ "node": ">=8.10.0" } }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7198,9 +7735,7 @@ "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, "license": "BSD-3-Clause", - "optional": true, "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", @@ -7217,9 +7752,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/rollup": { "version": "4.60.4", @@ -7294,6 +7827,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -7314,7 +7848,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -7347,13 +7881,6 @@ "loose-envify": "^1.1.0" } }, - "node_modules/seedrandom": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", - "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", - "license": "MIT", - "peer": true - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -7368,17 +7895,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "type-fest": "^0.13.1" }, @@ -7396,6 +7919,62 @@ "dev": true, "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7560,11 +8139,11 @@ "source-map": "^0.6.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" }, "node_modules/ssri": { "version": "9.0.1", @@ -7607,6 +8186,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -7616,6 +8196,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7646,6 +8227,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7718,6 +8300,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -8028,12 +8611,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -8051,13 +8628,18 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, "license": "(MIT OR CC0-1.0)", - "optional": true, "engines": { "node": ">=10" }, @@ -8361,22 +8943,6 @@ "defaults": "^1.0.3" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8424,6 +8990,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -8477,6 +9044,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -8489,33 +9057,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/package.json b/package.json index 4b04806..fe650fa 100644 --- a/package.json +++ b/package.json @@ -22,14 +22,17 @@ "dist:all": "electron-vite build && electron-builder --win --mac" }, "dependencies": { + "@huggingface/transformers": "^3.8.1", "@vladmandic/face-api": "^1.7.13", "exifr": "^7.1.3", + "sql.js": "^1.12.0", "zustand": "^4.5.5" }, "devDependencies": { "@types/node": "^20.16.0", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", + "@types/sql.js": "^1.4.9", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "electron": "^33.0.0", diff --git a/scripts/verify-index.mjs b/scripts/verify-index.mjs new file mode 100644 index 0000000..a5a48fc --- /dev/null +++ b/scripts/verify-index.mjs @@ -0,0 +1,100 @@ +// Phase 0-b 색인 파이프라인 헤드리스 검증. +// sql.js 스키마 + 폴더 워크 + 샘플 해시 + upsert + count 가 실제로 동작하는지 확인. +// node scripts/verify-index.mjs [folder] +import initSqlJs from 'sql.js' +import { readdir, stat, open, mkdtemp, writeFile, rm } from 'node:fs/promises' +import { readFileSync } from 'node:fs' +import { join, extname, dirname } from 'node:path' +import { tmpdir } from 'node:os' +import { createHash } from 'node:crypto' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const IMG = new Set(['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov']) + +async function* walk(root) { + for (const e of await readdir(root, { withFileTypes: true })) { + const full = join(root, e.name) + if (e.isDirectory()) yield* walk(full) + else if (e.isFile() && IMG.has(extname(e.name).toLowerCase())) yield full + } +} + +async function contentHash(path) { + const s = await stat(path) + const h = createHash('sha1') + h.update(String(s.size)) + const len = Math.min(512 * 1024, s.size) + if (len > 0) { + const fh = await open(path, 'r') + try { + const buf = Buffer.alloc(len) + await fh.read(buf, 0, len, 0) + h.update(buf) + } finally { + await fh.close() + } + } + return h.digest('hex') +} + +async function main() { + // 인자 폴더 없으면 더미 이미지 2개로 임시 폴더 생성 + let folder = process.argv[2] + let temp = null + if (!folder) { + temp = await mkdtemp(join(tmpdir(), 'photoai-idx-')) + await writeFile(join(temp, 'a.jpg'), Buffer.from('dummy-image-a')) + await writeFile(join(temp, 'b.png'), Buffer.from('dummy-image-b-different')) + folder = temp + console.log('테스트 폴더 생성:', folder) + } + + const wasm = readFileSync(join(__dirname, '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm')) + const SQL = await initSqlJs({ wasmBinary: new Uint8Array(wasm).buffer }) + const db = new SQL.Database() + db.run(`CREATE TABLE asset ( + id INTEGER PRIMARY KEY AUTOINCREMENT, contentHash TEXT UNIQUE NOT NULL, + path TEXT, ext TEXT, sizeBytes INTEGER, mtime INTEGER, indexedAt INTEGER); + CREATE INDEX idx_path ON asset(path);`) + + let indexed = 0 + for await (const file of walk(folder)) { + const s = await stat(file) + const hash = await contentHash(file) + db.run( + `INSERT INTO asset (contentHash,path,ext,sizeBytes,mtime,indexedAt) + VALUES (?,?,?,?,?,?) + ON CONFLICT(contentHash) DO UPDATE SET path=excluded.path, mtime=excluded.mtime`, + [hash, file, extname(file).toLowerCase(), s.size, Math.floor(s.mtimeMs), Date.now()] + ) + indexed++ + } + + const n = db.exec('SELECT COUNT(*) FROM asset')[0].values[0][0] + const sample = db.exec('SELECT contentHash, path FROM asset LIMIT 2') + console.log(`색인 처리: ${indexed}건, DB asset 수: ${n}`) + if (sample[0]) for (const row of sample[0].values) console.log(' row:', row[0].slice(0, 12), '…', row[1]) + + // 재실행 시 중복 안 늘어나는지(upsert) 확인 + for await (const file of walk(folder)) { + const s = await stat(file) + const hash = await contentHash(file) + db.run( + `INSERT INTO asset (contentHash,path,ext,sizeBytes,mtime,indexedAt) + VALUES (?,?,?,?,?,?) ON CONFLICT(contentHash) DO UPDATE SET mtime=excluded.mtime`, + [hash, file, extname(file).toLowerCase(), s.size, Math.floor(s.mtimeMs), Date.now()] + ) + } + const n2 = db.exec('SELECT COUNT(*) FROM asset')[0].values[0][0] + console.log(`재실행 후 asset 수(중복 없어야 함): ${n2}`) + console.log(n === n2 ? 'PASS: upsert 중복 없음' : 'FAIL: 중복 발생') + + db.close() + if (temp) await rm(temp, { recursive: true, force: true }) +} + +main().catch((e) => { + console.error('오류:', e) + process.exit(1) +}) diff --git a/src/inference/clipEngine.ts b/src/inference/clipEngine.ts new file mode 100644 index 0000000..7298896 --- /dev/null +++ b/src/inference/clipEngine.ts @@ -0,0 +1,93 @@ +import { + env, + AutoProcessor, + AutoTokenizer, + CLIPVisionModelWithProjection, + CLIPTextModelWithProjection, + RawImage, + pipeline, + type Processor, + type PreTrainedTokenizer, + type PreTrainedModel +} from '@huggingface/transformers' + +/** 사용 메서드만 추린 최소 텐서 형태 */ +type TfTensor = { normalize(): { tolist(): number[][] } } + +const CLIP_MODEL = 'Xenova/clip-vit-base-patch32' +const TRANSLATE_MODEL = 'Xenova/opus-mt-ko-en' +const HANGUL = /[가-힣]/ + +// 원격(HF Hub) 모델만 사용 — 최초 1회 다운로드 후 브라우저 캐시에 보관 +env.allowLocalModels = false + +/** + * CLIP 임베딩 엔진 (검색용). 추론창에서 lazy-load. + * - 이미지/텍스트를 512-d 임베딩으로 변환 (코사인 유사도 검색) + * - 한국어 쿼리는 opus-mt-ko-en으로 영어로 번역 후 임베딩 (영어 CLIP) + */ +class ClipEngine { + private clipLoading: Promise | null = null + private processor: Processor | null = null + private tokenizer: PreTrainedTokenizer | null = null + private vision: PreTrainedModel | null = null + private text: PreTrainedModel | null = null + private translator: ((input: string) => Promise) | null = null + + private async loadClip(): Promise { + if (this.clipLoading) return this.clipLoading + this.clipLoading = (async () => { + this.processor = await AutoProcessor.from_pretrained(CLIP_MODEL) + this.tokenizer = await AutoTokenizer.from_pretrained(CLIP_MODEL) + this.vision = await CLIPVisionModelWithProjection.from_pretrained(CLIP_MODEL) + this.text = await CLIPTextModelWithProjection.from_pretrained(CLIP_MODEL) + })() + return this.clipLoading + } + + /** 캔버스 이미지 → 정규화된 512-d 임베딩 */ + async embedImage(canvas: HTMLCanvasElement): Promise { + await this.loadClip() + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) throw new Error('2D 컨텍스트 생성 실패') + const { data, width, height } = ctx.getImageData(0, 0, canvas.width, canvas.height) + const image = new RawImage(new Uint8ClampedArray(data), width, height, 4).rgb() + + // transformers.js의 무거운 제네릭 유니온을 피하려고 호출부는 느슨하게 캐스팅 + const processor = this.processor as unknown as (i: unknown) => Promise + const vision = this.vision as unknown as (i: unknown) => Promise<{ image_embeds: TfTensor }> + const inputs = await processor(image) + const out = await vision(inputs) + return Array.from(out.image_embeds.normalize().tolist()[0] as number[]) + } + + /** 텍스트 쿼리 → (필요시 KO→EN 번역) → 정규화된 512-d 임베딩 */ + async embedText(query: string): Promise { + await this.loadClip() + let text = query + if (HANGUL.test(query)) { + if (!this.translator) { + // pipeline()의 거대한 오버로드 유니온을 피하려고 느슨하게 캐스팅 + const makePipeline = pipeline as unknown as ( + task: string, + model: string + ) => Promise<(input: string) => Promise> + this.translator = await makePipeline('translation', TRANSLATE_MODEL) + } + const translate = this.translator as unknown as (input: string) => Promise + const res = await translate(query) + const first = Array.isArray(res) ? res[0] : res + text = (first as { translation_text: string }).translation_text || query + } + const tokenizer = this.tokenizer as unknown as ( + t: string[], + o: Record + ) => unknown + const textModel = this.text as unknown as (i: unknown) => Promise<{ text_embeds: TfTensor }> + const inputs = tokenizer([text], { padding: true, truncation: true }) + const out = await textModel(inputs) + return Array.from(out.text_embeds.normalize().tolist()[0] as number[]) + } +} + +export const clipEngine = new ClipEngine() diff --git a/src/inference/faceEngine.ts b/src/inference/faceEngine.ts index 9d2619e..a7cdd41 100644 --- a/src/inference/faceEngine.ts +++ b/src/inference/faceEngine.ts @@ -66,6 +66,42 @@ class FaceEngine { } } + /** + * 눈 뜸 정도(EAR, Eye Aspect Ratio). 가장 큰 얼굴 기준 좌우 평균. + * 얼굴이 없으면 null (해당 없음). 낮을수록 감은 눈. + * 색인 속도를 위해 Tiny 검출기 사용. + */ + async eyesOpenScore(canvas: HTMLCanvasElement): Promise { + const results = await faceapi + .detectAllFaces(canvas, new faceapi.TinyFaceDetectorOptions({ inputSize: 416 })) + .withFaceLandmarks() + if (results.length === 0) return null + + // 가장 큰 얼굴 선택 + let best = results[0] + let bestArea = 0 + for (const r of results) { + const box = r.detection.box + const area = box.width * box.height + if (area > bestArea) { + bestArea = area + best = r + } + } + + const ear = (eye: faceapi.Point[]): number => { + const d = (a: faceapi.Point, b: faceapi.Point) => Math.hypot(a.x - b.x, a.y - b.y) + const A = d(eye[1], eye[5]) + const B = d(eye[2], eye[4]) + const C = d(eye[0], eye[3]) + return C === 0 ? 0 : (A + B) / (2 * C) + } + + const left = ear(best.landmarks.getLeftEye()) + const right = ear(best.landmarks.getRightEye()) + return (left + right) / 2 + } + /** 사진 1장 → 얼굴 검출 + 프로필 매칭 결과 */ async detectImage(imagePath: string): Promise { const canvas = await loadImageToCanvas(imagePath) diff --git a/src/inference/imageLoader.ts b/src/inference/imageLoader.ts index bea1167..21da20c 100644 --- a/src/inference/imageLoader.ts +++ b/src/inference/imageLoader.ts @@ -42,3 +42,59 @@ export function releaseCanvas(canvas: HTMLCanvasElement): void { canvas.width = 0 canvas.height = 0 } + +/** + * 썸네일용 로더: 장변을 maxDim으로 축소한 캔버스 + 원본(자연) 치수 반환. + */ +export async function loadThumbnailCanvas( + imagePath: string, + maxDim: number +): Promise<{ canvas: HTMLCanvasElement; naturalWidth: number; naturalHeight: number }> { + const img = await loadImageElement(pathToFileUrl(imagePath)) + const naturalWidth = img.naturalWidth || img.width + const naturalHeight = img.naturalHeight || img.height + + const longSide = Math.max(naturalWidth, naturalHeight) + const scale = longSide > maxDim ? maxDim / longSide : 1 + const w = Math.max(1, Math.round(naturalWidth * scale)) + const h = Math.max(1, Math.round(naturalHeight * scale)) + + const canvas = document.createElement('canvas') + canvas.width = w + canvas.height = h + // 이 캔버스는 이후 품질/CLIP 분석에서 getImageData로 읽히므로 willReadFrequently 지정 + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) throw new Error('2D 컨텍스트 생성 실패') + ctx.drawImage(img, 0, 0, w, h) + img.src = '' + return { canvas, naturalWidth, naturalHeight } +} + +/** 기존 캔버스를 장변 maxDim으로 축소한 새 캔버스 반환 (썸네일 파생용) */ +export function downscaleCanvas(src: HTMLCanvasElement, maxDim: number): HTMLCanvasElement { + const longSide = Math.max(src.width, src.height) + const scale = longSide > maxDim ? maxDim / longSide : 1 + const w = Math.max(1, Math.round(src.width * scale)) + const h = Math.max(1, Math.round(src.height * scale)) + const out = document.createElement('canvas') + out.width = w + out.height = h + const ctx = out.getContext('2d') + if (!ctx) throw new Error('2D 컨텍스트 생성 실패') + ctx.drawImage(src, 0, 0, w, h) + return out +} + +/** 캔버스를 webp 바이트(ArrayBuffer)로 인코딩 */ +export function canvasToWebp(canvas: HTMLCanvasElement, quality = 0.8): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (!blob) return reject(new Error('썸네일 인코딩 실패')) + blob.arrayBuffer().then(resolve, reject) + }, + 'image/webp', + quality + ) + }) +} diff --git a/src/inference/main.ts b/src/inference/main.ts index a9d5672..837c2f5 100644 --- a/src/inference/main.ts +++ b/src/inference/main.ts @@ -1,4 +1,13 @@ import { faceEngine } from './faceEngine' +import { clipEngine } from './clipEngine' +import { + loadThumbnailCanvas, + downscaleCanvas, + canvasToWebp, + releaseCanvas +} from './imageLoader' +import { computeFocus, computeExposure } from './qualityEngine' +import { THUMBNAIL_SIZE, ANALYZE_SIZE } from '@shared/constants' import type { Profile, JobOptions, DescriptorResult } from '@shared/types' /** @@ -38,6 +47,50 @@ async function bootstrap(): Promise { const { imagePath } = payload as unknown as { imagePath: string } const result = await faceEngine.detectImage(imagePath) window.inferBridge.reply(requestId, true, result) + } else if (channel === 'infer:analyze') { + // 한 번 로드해서 썸네일 + 품질 점수(초점/노출/눈)를 모두 산출 + const { imagePath } = payload as unknown as { imagePath: string } + const { canvas, naturalWidth, naturalHeight } = await loadThumbnailCanvas( + imagePath, + ANALYZE_SIZE + ) + try { + const focus = computeFocus(canvas) + const exposure = computeExposure(canvas) + const eyesOpen = await faceEngine.eyesOpenScore(canvas) + + const thumb = downscaleCanvas(canvas, THUMBNAIL_SIZE) + let bytes: ArrayBuffer + try { + bytes = await canvasToWebp(thumb) + } finally { + releaseCanvas(thumb) + } + + window.inferBridge.reply(requestId, true, { + bytes, + width: naturalWidth, + height: naturalHeight, + focus, + exposure, + eyesOpen + }) + } finally { + releaseCanvas(canvas) + } + } else if (channel === 'infer:embedImage') { + const { imagePath } = payload as unknown as { imagePath: string } + const { canvas } = await loadThumbnailCanvas(imagePath, ANALYZE_SIZE) + try { + const vec = await clipEngine.embedImage(canvas) + window.inferBridge.reply(requestId, true, { vec }) + } finally { + releaseCanvas(canvas) + } + } else if (channel === 'infer:embedText') { + const { text } = payload as unknown as { text: string } + const vec = await clipEngine.embedText(text) + window.inferBridge.reply(requestId, true, { vec }) } } catch (err) { window.inferBridge.reply(requestId, false, undefined, (err as Error).message) diff --git a/src/inference/qualityEngine.ts b/src/inference/qualityEngine.ts new file mode 100644 index 0000000..2fa12b4 --- /dev/null +++ b/src/inference/qualityEngine.ts @@ -0,0 +1,64 @@ +/** + * 모델이 거의 필요 없는 고전 CV 기반 품질 지표 (초점/노출). 추론창 canvas에서 동작. + */ + +function getGray(canvas: HTMLCanvasElement): { gray: Float64Array; w: number; h: number } { + const w = canvas.width + const h = canvas.height + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) throw new Error('2D 컨텍스트 생성 실패') + const data = ctx.getImageData(0, 0, w, h).data + const gray = new Float64Array(w * h) + for (let i = 0; i < w * h; i++) { + gray[i] = 0.299 * data[i * 4] + 0.587 * data[i * 4 + 1] + 0.114 * data[i * 4 + 2] + } + return { gray, w, h } +} + +/** + * 초점/선명도 = 라플라시안 분산. 높을수록 선명, 낮을수록 흐림. + * (값의 절대 스케일은 해상도/내용 의존 → 임계값으로 판정) + */ +export function computeFocus(canvas: HTMLCanvasElement): number { + const { gray, w, h } = getGray(canvas) + let sum = 0 + let sumSq = 0 + let count = 0 + for (let y = 1; y < h - 1; y++) { + for (let x = 1; x < w - 1; x++) { + const i = y * w + x + const lap = gray[i - 1] + gray[i + 1] + gray[i - w] + gray[i + w] - 4 * gray[i] + sum += lap + sumSq += lap * lap + count++ + } + } + if (count === 0) return 0 + const mean = sum / count + return sumSq / count - mean * mean +} + +/** + * 노출 점수 = 1 - (그림자/하이라이트 클리핑 페널티). 0~1, 높을수록 양호. + */ +export function computeExposure(canvas: HTMLCanvasElement): number { + const w = canvas.width + const h = canvas.height + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) throw new Error('2D 컨텍스트 생성 실패') + const data = ctx.getImageData(0, 0, w, h).data + const n = w * h + if (n === 0) return 1 + + let shadow = 0 + let highlight = 0 + for (let i = 0; i < n; i++) { + const lum = 0.299 * data[i * 4] + 0.587 * data[i * 4 + 1] + 0.114 * data[i * 4 + 2] + if (lum <= 4) shadow++ + else if (lum >= 251) highlight++ + } + const shadowFrac = shadow / n + const highFrac = highlight / n + // 클리핑이 많을수록 점수 하락 (계수 3로 민감도 조절) + return Math.max(0, 1 - Math.min(1, shadowFrac * 3 + highFrac * 3)) +} diff --git a/src/main/embedder.ts b/src/main/embedder.ts new file mode 100644 index 0000000..aafbe5b --- /dev/null +++ b/src/main/embedder.ts @@ -0,0 +1,66 @@ +import { BrowserWindow } from 'electron' +import type { SearchProgress, SearchSummary } from '@shared/types' +import { IPC, SUPPORTED_EXTENSIONS } from '@shared/constants' +import { indexDb } from './indexDb' +import { inferenceBridge } from './inferenceBridge' +import { logger } from './logger' + +/** + * 검색 색인 생성기 (Phase 2): 임베딩 없는 이미지들을 CLIP으로 임베딩해 DB에 저장. + * 기본 색인(썸네일/품질)과 분리 — 무거운 CLIP 작업을 사용자가 명시적으로 시작. + */ +class Embedder { + private cancelled = false + private running = false + + cancel(): void { + if (this.running) { + this.cancelled = true + logger.warn('검색 색인 취소 요청됨') + } + } + + async run(sender: BrowserWindow): Promise { + if (this.running) throw new Error('이미 검색 색인이 실행 중입니다.') + this.running = true + this.cancelled = false + + const send = (channel: string, payload: T) => { + if (!sender.isDestroyed()) sender.webContents.send(channel, payload) + } + + try { + await inferenceBridge.whenReady() + const todo = indexDb.listAssetsNeedingEmbedding([...SUPPORTED_EXTENSIONS]) + const total = todo.length + logger.info('검색 색인(임베딩) 시작', { total }) + + let embedded = 0 + let done = 0 + for (const item of todo) { + if (this.cancelled) break + send(IPC.SEARCH_PROGRESS, { done, total, current: item.path, embedded }) + try { + const vec = await inferenceBridge.embedImage(item.path) + indexDb.setEmbedding(item.id, vec) + embedded++ + } catch (err) { + await logger.warn('임베딩 실패', { file: item.path, message: (err as Error).message }) + } + done++ + if (done % 20 === 0) await indexDb.save() + send(IPC.SEARCH_PROGRESS, { done, total, current: item.path, embedded }) + } + + await indexDb.save() + const summary: SearchSummary = { embedded, total, count: indexDb.embeddingCount() } + logger.info('검색 색인 완료', summary) + send(IPC.SEARCH_DONE, summary) + return summary + } finally { + this.running = false + } + } +} + +export const embedder = new Embedder() diff --git a/src/main/hash.ts b/src/main/hash.ts new file mode 100644 index 0000000..dca9bfd --- /dev/null +++ b/src/main/hash.ts @@ -0,0 +1,29 @@ +import { createHash } from 'node:crypto' +import { open, stat } from 'node:fs/promises' + +const SAMPLE_BYTES = 512 * 1024 // 앞부분 512KB만 샘플링 + +/** + * 콘텐츠 식별용 해시. (파일 크기 + 앞부분 512KB)의 SHA-1. + * 전체 바이트 해시는 대량 라이브러리에서 너무 느리므로 샘플링. + * 서로 다른 사진은 크기/헤더가 달라 충돌 확률이 사실상 0 → + * "재색인 스킵 / 동일 파일 식별" 용도로 충분. (정확한 바이트 단위 중복은 추후 보강 가능) + */ +export async function contentHash(path: string): Promise { + const s = await stat(path) + const h = createHash('sha1') + h.update(String(s.size)) + + const len = Math.min(SAMPLE_BYTES, s.size) + if (len > 0) { + const fh = await open(path, 'r') + try { + const buf = Buffer.alloc(len) + await fh.read(buf, 0, len, 0) + h.update(buf) + } finally { + await fh.close() + } + } + return h.digest('hex') +} diff --git a/src/main/index.ts b/src/main/index.ts index b36f563..05d59b5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,7 @@ import { registerMediaScheme, handleMediaProtocol } from './mediaProtocol' import { settingsStore } from './settingsStore' import { buildAppMenu } from './menu' import { applySettings } from './applySettings' +import { indexDb } from './indexDb' import { logger } from './logger' // 커스텀 미디어 스킴은 app ready 이전에 등록해야 한다. @@ -51,6 +52,8 @@ app.whenReady().then(async () => { // 설정 로드 후 로컬라이즈된 메뉴 빌드 const settings = await settingsStore.load() buildAppMenu({ settings, onChange: applySettings }) + // 라이브러리 인덱스 DB 초기화 (Phase 0) + await indexDb.init() // 숨김 추론 창을 먼저 띄워 모델 로드를 선행 inferenceBridge.init() createMainWindow() @@ -62,6 +65,11 @@ app.whenReady().then(async () => { logger.info('앱 시작 완료') }) +app.on('before-quit', () => { + // 인덱스 변경분 영속화 후 종료 + void indexDb.saveIfDirty() +}) + app.on('window-all-closed', () => { inferenceBridge.dispose() if (process.platform !== 'darwin') app.quit() diff --git a/src/main/indexDb.ts b/src/main/indexDb.ts new file mode 100644 index 0000000..1debf39 --- /dev/null +++ b/src/main/indexDb.ts @@ -0,0 +1,327 @@ +import { app } from 'electron' +import initSqlJs, { type Database, type SqlJsStatic } from 'sql.js' +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { existsSync, readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import type { + AssetRecord, + QualityScores, + IndexedAsset, + AssetQuery, + QualityThresholds, + ColorLabel +} from '@shared/types' +import { logger } from './logger' + +/** + * 라이브러리 인덱스 DB. WASM SQLite(sql.js) 사용 — 네이티브 빌드/ABI 재빌드 불필요. + * sql.js는 인메모리 → 변경분을 주기적으로 파일(userData/index.db)로 export 하여 영속화. + * (수천~수만 장 메타데이터 규모에 충분. 대규모/임베딩은 Phase 2+에서 별도 전략.) + */ +class IndexDb { + private SQL: SqlJsStatic | null = null + private db: Database | null = null + private dbPath = '' + private dirty = false + + async init(): Promise { + if (this.db) return + + // sql.js wasm 바이트를 직접 읽어 전달 (asar/패키징 환경에서도 안전). + // wasmBinary는 ArrayBuffer를 기대하므로 Buffer → ArrayBuffer 변환. + const wasmPath = join(app.getAppPath(), 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm') + const wasmBuf = readFileSync(wasmPath) + this.SQL = await initSqlJs({ wasmBinary: new Uint8Array(wasmBuf).buffer }) + + this.dbPath = join(app.getPath('userData'), 'index.db') + const existing = existsSync(this.dbPath) ? await readFile(this.dbPath) : undefined + this.db = new this.SQL.Database(existing) + this.migrate() + await this.save() + + logger.info('인덱스 DB 준비', { path: this.dbPath, assets: this.count() }) + } + + private migrate(): void { + this.db!.run(` + CREATE TABLE IF NOT EXISTS asset ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contentHash TEXT UNIQUE NOT NULL, + path TEXT NOT NULL, + ext TEXT, + sizeBytes INTEGER, + mtime INTEGER, + width INTEGER, + height INTEGER, + exifYear TEXT, + exifMonth TEXT, + indexedAt INTEGER + ); + CREATE TABLE IF NOT EXISTS quality ( + assetId INTEGER PRIMARY KEY REFERENCES asset(id) ON DELETE CASCADE, + focus REAL, + exposure REAL, + eyesOpen REAL, + flag TEXT + ); + CREATE TABLE IF NOT EXISTS usermeta ( + assetId INTEGER PRIMARY KEY REFERENCES asset(id) ON DELETE CASCADE, + rating INTEGER DEFAULT 0, + label TEXT + ); + CREATE TABLE IF NOT EXISTS embedding ( + assetId INTEGER PRIMARY KEY REFERENCES asset(id) ON DELETE CASCADE, + vec BLOB + ); + CREATE INDEX IF NOT EXISTS idx_asset_hash ON asset(contentHash); + CREATE INDEX IF NOT EXISTS idx_asset_path ON asset(path); + `) + } + + /** 인메모리 DB를 디스크로 영속화 */ + async save(): Promise { + if (!this.db) return + const data = this.db.export() + await mkdir(dirname(this.dbPath), { recursive: true }) + await writeFile(this.dbPath, Buffer.from(data)) + this.dirty = false + } + + /** 변경이 있을 때만 저장 (배치 색인 후 호출) */ + async saveIfDirty(): Promise { + if (this.dirty) await this.save() + } + + count(): number { + const res = this.db!.exec('SELECT COUNT(*) AS n FROM asset') + return res.length ? Number(res[0].values[0][0]) : 0 + } + + /** 같은 경로가 같은 mtime으로 이미 색인되어 있으면 true (해시 계산 없이 빠른 스킵) */ + isIndexedPath(path: string, mtime: number): boolean { + const stmt = this.db!.prepare('SELECT 1 FROM asset WHERE path = ? AND mtime = ? LIMIT 1') + try { + stmt.bind([path, mtime]) + return stmt.step() + } finally { + stmt.free() + } + } + + /** 이미 색인되었고 mtime이 동일하면 재색인 불필요 */ + needsIndex(contentHash: string, mtime: number): boolean { + const stmt = this.db!.prepare('SELECT mtime FROM asset WHERE contentHash = ?') + try { + stmt.bind([contentHash]) + if (!stmt.step()) return true // 미존재 → 색인 필요 + const row = stmt.getAsObject() as { mtime: number } + return row.mtime !== mtime + } finally { + stmt.free() + } + } + + /** + * 최근 색인순 자산 목록 + 품질 + 사용자메타. 품질 플래그는 임계값으로 **실시간 계산** + * (임계값 변경 시 재분석 없이 즉시 반영). 별점/색라벨 필터 지원. + */ + /** 자산+품질(실시간 플래그)+사용자메타를 결합하는 공통 SELECT */ + private innerSelect(th: QualityThresholds): string { + const f = Number(th.focus) + const x = Number(th.exposure) + const e = Number(th.eyes) + return ` + SELECT a.*, + q.focus AS focus, q.exposure AS exposure, q.eyesOpen AS eyesOpen, + COALESCE(um.rating, 0) AS rating, um.label AS label, + CASE + WHEN q.assetId IS NULL THEN NULL + WHEN q.focus < ${f} THEN 'blurry' + WHEN q.eyesOpen IS NOT NULL AND q.eyesOpen < ${e} THEN 'eyesClosed' + WHEN q.exposure < ${x} THEN 'badExposure' + ELSE 'candidate' + END AS flag + FROM asset a + LEFT JOIN quality q ON q.assetId = a.id + LEFT JOIN usermeta um ON um.assetId = a.id` + } + + listAssets( + offset: number, + limit: number, + query: AssetQuery, + th: QualityThresholds + ): IndexedAsset[] { + const inner = this.innerSelect(th) + const conds: string[] = [] + if (query.filter === 'rejected') { + conds.push("flag IN ('blurry', 'eyesClosed', 'badExposure')") + } else if (query.filter !== 'all') { + conds.push(`flag = '${query.filter}'`) // 고정 열거값 + } + if (query.ratingMin > 0) conds.push(`rating >= ${Number(query.ratingMin)}`) + const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '' + + const stmt = this.db!.prepare( + `SELECT * FROM (${inner}) ${where} ORDER BY indexedAt DESC, id DESC LIMIT ? OFFSET ?` + ) + const out: IndexedAsset[] = [] + try { + stmt.bind([limit, offset]) + while (stmt.step()) out.push(stmt.getAsObject() as unknown as IndexedAsset) + } finally { + stmt.free() + } + return out + } + + setRating(assetId: number, rating: number): void { + const r = Math.max(0, Math.min(5, Math.round(rating))) + this.db!.run( + `INSERT INTO usermeta (assetId, rating) VALUES (?, ?) + ON CONFLICT(assetId) DO UPDATE SET rating = excluded.rating`, + [assetId, r] + ) + this.dirty = true + } + + setLabel(assetId: number, label: ColorLabel): void { + this.db!.run( + `INSERT INTO usermeta (assetId, label) VALUES (?, ?) + ON CONFLICT(assetId) DO UPDATE SET label = excluded.label`, + [assetId, label] + ) + this.dirty = true + } + + // ---- 임베딩 / 검색 (Phase 2) ---- + + /** 임베딩 미보유 이미지(영상 제외) 목록 — 검색 색인 생성용 */ + listAssetsNeedingEmbedding(imageExts: string[]): { id: number; path: string }[] { + const placeholders = imageExts.map(() => '?').join(',') + const stmt = this.db!.prepare( + `SELECT a.id AS id, a.path AS path + FROM asset a LEFT JOIN embedding e ON e.assetId = a.id + WHERE e.assetId IS NULL AND a.ext IN (${placeholders}) + ORDER BY a.id` + ) + const out: { id: number; path: string }[] = [] + try { + stmt.bind(imageExts) + while (stmt.step()) out.push(stmt.getAsObject() as unknown as { id: number; path: string }) + } finally { + stmt.free() + } + return out + } + + setEmbedding(assetId: number, vec: number[]): void { + const bytes = new Uint8Array(new Float32Array(vec).buffer) + this.db!.run( + `INSERT INTO embedding (assetId, vec) VALUES (?, ?) + ON CONFLICT(assetId) DO UPDATE SET vec = excluded.vec`, + [assetId, bytes] + ) + this.dirty = true + } + + embeddingCount(): number { + const res = this.db!.exec('SELECT COUNT(*) AS n FROM embedding') + return res.length ? Number(res[0].values[0][0]) : 0 + } + + /** 전체 임베딩 로드 (브루트포스 코사인 검색용) */ + getAllEmbeddings(): { assetId: number; vec: Float32Array }[] { + const stmt = this.db!.prepare('SELECT assetId, vec FROM embedding') + const out: { assetId: number; vec: Float32Array }[] = [] + try { + while (stmt.step()) { + const row = stmt.getAsObject() as { assetId: number; vec: Uint8Array } + const u8 = row.vec + const vec = new Float32Array(u8.buffer, u8.byteOffset, Math.floor(u8.byteLength / 4)) + out.push({ assetId: Number(row.assetId), vec }) + } + } finally { + stmt.free() + } + return out + } + + /** id 목록을 입력 순서대로 IndexedAsset으로 조회 (검색 결과 정렬 유지) */ + assetsByIds(ids: number[], th: QualityThresholds): IndexedAsset[] { + if (ids.length === 0) return [] + const placeholders = ids.map(() => '?').join(',') + const stmt = this.db!.prepare( + `SELECT * FROM (${this.innerSelect(th)}) WHERE id IN (${placeholders})` + ) + const byId = new Map() + try { + stmt.bind(ids) + while (stmt.step()) { + const a = stmt.getAsObject() as unknown as IndexedAsset + if (a.id != null) byId.set(a.id, a) + } + } finally { + stmt.free() + } + return ids.map((id) => byId.get(id)).filter((a): a is IndexedAsset => !!a) + } + + getByHash(contentHash: string): AssetRecord | null { + const stmt = this.db!.prepare('SELECT * FROM asset WHERE contentHash = ?') + try { + stmt.bind([contentHash]) + if (!stmt.step()) return null + return stmt.getAsObject() as unknown as AssetRecord + } finally { + stmt.free() + } + } + + /** 자산 upsert (contentHash 기준). 반환: asset id */ + upsertAsset(r: AssetRecord): number { + this.db!.run( + `INSERT INTO asset + (contentHash, path, ext, sizeBytes, mtime, width, height, exifYear, exifMonth, indexedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(contentHash) DO UPDATE SET + path=excluded.path, ext=excluded.ext, sizeBytes=excluded.sizeBytes, + mtime=excluded.mtime, width=excluded.width, height=excluded.height, + exifYear=excluded.exifYear, exifMonth=excluded.exifMonth, indexedAt=excluded.indexedAt`, + [ + r.contentHash, + r.path, + r.ext, + r.sizeBytes, + r.mtime, + r.width, + r.height, + r.exifYear, + r.exifMonth, + r.indexedAt + ] + ) + this.dirty = true + const res = this.db!.exec('SELECT id FROM asset WHERE contentHash = ?', [r.contentHash]) + return res.length ? Number(res[0].values[0][0]) : -1 + } + + setQuality(assetId: number, q: QualityScores): void { + this.db!.run( + `INSERT INTO quality (assetId, focus, exposure, eyesOpen, flag) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(assetId) DO UPDATE SET + focus=excluded.focus, exposure=excluded.exposure, + eyesOpen=excluded.eyesOpen, flag=excluded.flag`, + [assetId, q.focus, q.exposure, q.eyesOpen, q.flag] + ) + this.dirty = true + } + + close(): void { + this.db?.close() + this.db = null + } +} + +export const indexDb = new IndexDb() diff --git a/src/main/indexer.ts b/src/main/indexer.ts new file mode 100644 index 0000000..ecc0da4 --- /dev/null +++ b/src/main/indexer.ts @@ -0,0 +1,155 @@ +import { BrowserWindow } from 'electron' +import { extname } from 'node:path' +import { stat } from 'node:fs/promises' +import type { IndexProgress, IndexSummary, QualityScores } from '@shared/types' +import { IPC, LOG_FOLDER } from '@shared/constants' +import { scan, countMedia, mediaKind } from './scanner' +import { getCaptureDate } from './exif' +import { contentHash } from './hash' +import { indexDb } from './indexDb' +import { libraryStore } from './libraryStore' +import { inferenceBridge } from './inferenceBridge' +import { hasThumb, writeThumb } from './thumbnails' +import { classifyFlag } from './quality' +import { logger } from './logger' + +// 색인에서 제외할(우리가 만든) 디렉터리 +const SKIP_DIRS = new Set([LOG_FOLDER, '_PhotoAI_thumbs']) + +/** + * 라이브러리 색인 오케스트레이터 (Phase 0-b). + * 라이브러리 루트들을 비파괴로 워크 → 파일별 해시/메타데이터를 인덱스 DB에 적재. + * 재개 가능(변경 없는 파일은 스킵), 진행률 이벤트, 취소, 배치 영속화. + * (썸네일/품질 점수는 Phase 0-c / Phase 1에서 추가) + */ +class Indexer { + private cancelled = false + private running = false + + cancel(): void { + if (this.running) { + this.cancelled = true + logger.warn('색인 취소 요청됨') + } + } + + async run(sender: BrowserWindow): Promise { + if (this.running) throw new Error('이미 색인이 실행 중입니다.') + this.running = true + this.cancelled = false + + const send = (channel: string, payload: T) => { + if (!sender.isDestroyed()) sender.webContents.send(channel, payload) + } + + const startedAt = Date.now() + const roots = await libraryStore.list() + let indexed = 0 + let skipped = 0 + let failed = 0 + let done = 0 + + try { + // 썸네일 생성을 위해 추론 워커 준비 대기 + await inferenceBridge.whenReady() + + // 진행률 total 산출 + let total = 0 + for (const root of roots) total += await countMedia(root, SKIP_DIRS) + logger.info('색인 시작', { roots: roots.length, total }) + + const emit = (current: string) => { + const p: IndexProgress = { done, total, current, indexed, skipped } + send(IPC.INDEX_PROGRESS, p) + } + + for (const root of roots) { + if (this.cancelled) break + for await (const file of scan(root, SKIP_DIRS)) { + if (this.cancelled) break + emit(file) + try { + const st = await stat(file) + const mtime = Math.floor(st.mtimeMs) + // 빠른 스킵: 같은 경로·mtime이면 해시 없이 건너뜀 + if (indexDb.isIndexedPath(file, mtime)) { + skipped++ + } else { + const hash = await contentHash(file) + const date = await getCaptureDate(file) + let width: number | null = null + let height: number | null = null + let quality: QualityScores | null = null + + // 이미지: 썸네일 + 품질(초점/노출/눈) 분석. 영상은 메타데이터만. + if (mediaKind(file) === 'image') { + const existing = indexDb.getByHash(hash) + if (existing && (await hasThumb(hash))) { + // 이미 분석됨 → 치수 재사용, 품질은 유지(재계산 생략) + width = existing.width + height = existing.height + } else { + try { + const a = await inferenceBridge.analyze(file) + await writeThumb(hash, a.bytes) + width = a.width + height = a.height + quality = { + focus: a.focus, + exposure: a.exposure, + eyesOpen: a.eyesOpen, + flag: classifyFlag(a.focus, a.exposure, a.eyesOpen) + } + } catch (err) { + await logger.warn('이미지 분석 실패(메타만 색인)', { + file, + message: (err as Error).message + }) + } + } + } + + const assetId = indexDb.upsertAsset({ + contentHash: hash, + path: file, + ext: extname(file).toLowerCase(), + sizeBytes: st.size, + mtime, + width, + height, + exifYear: date.year, + exifMonth: date.month, + indexedAt: Date.now() + }) + if (quality && assetId >= 0) indexDb.setQuality(assetId, quality) + indexed++ + } + } catch (err) { + failed++ + await logger.error('색인 실패', { file, message: (err as Error).message }) + } + done++ + if (done % 100 === 0) await indexDb.save() // 배치 영속화 + emit(file) + } + } + + await indexDb.save() + const summary: IndexSummary = { + total, + indexed, + skipped, + failed, + assets: indexDb.count(), + elapsedMs: Date.now() - startedAt + } + logger.info('색인 완료', summary) + send(IPC.INDEX_DONE, summary) + return summary + } finally { + this.running = false + } + } +} + +export const indexer = new Indexer() diff --git a/src/main/inferenceBridge.ts b/src/main/inferenceBridge.ts index f16104e..33531a7 100644 --- a/src/main/inferenceBridge.ts +++ b/src/main/inferenceBridge.ts @@ -118,6 +118,30 @@ class InferenceBridge { return this.call('infer:detect', { imagePath }) } + /** 이미지 CLIP 임베딩 (512-d) */ + async embedImage(imagePath: string): Promise { + const r = await this.call<{ vec: number[] }>('infer:embedImage', { imagePath }) + return r.vec + } + + /** 텍스트 CLIP 임베딩 (512-d, 한국어는 자동 번역) */ + async embedText(text: string): Promise { + const r = await this.call<{ vec: number[] }>('infer:embedText', { text }) + return r.vec + } + + /** 썸네일(webp 바이트) + 원본 치수 + 품질 점수(초점/노출/눈) 산출 */ + async analyze(imagePath: string): Promise<{ + bytes: ArrayBuffer + width: number + height: number + focus: number + exposure: number + eyesOpen: number | null + }> { + return this.call('infer:analyze', { imagePath }) + } + dispose(): void { this.pending.forEach((p) => p.reject(new Error('브릿지 종료'))) this.pending.clear() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 9cbe40f..3107730 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,12 +1,23 @@ import { ipcMain, dialog, BrowserWindow, app } from 'electron' import { writeFile, mkdir } from 'node:fs/promises' import { join, extname } from 'node:path' -import type { ProfileInput, JobRequest, ReferenceData } from '@shared/types' -import { IPC } from '@shared/constants' +import type { + ProfileInput, + JobRequest, + ReferenceData, + AssetQuery, + ColorLabel +} from '@shared/types' +import { IPC, SUPPORTED_EXTENSIONS } from '@shared/constants' import { profileStore } from './profileStore' import { presetStore } from './presetStore' import { inferenceBridge } from './inferenceBridge' import { orchestrator } from './orchestrator' +import { libraryStore } from './libraryStore' +import { indexer } from './indexer' +import { indexDb } from './indexDb' +import { embedder } from './embedder' +import { search } from './searchService' import { settingsStore } from './settingsStore' import { applySettings } from './applySettings' import { logger } from './logger' @@ -127,4 +138,57 @@ export function registerIpc(): void { // ---- 설정 ---- ipcMain.handle(IPC.SETTINGS_GET, () => settingsStore.load()) ipcMain.handle(IPC.SETTINGS_SET, (_e, patch: Partial) => applySettings(patch)) + + // ---- 라이브러리 / 색인 (Phase 0) ---- + ipcMain.handle(IPC.LIBRARY_LIST, () => libraryStore.list()) + + ipcMain.handle(IPC.LIBRARY_ADD, async () => { + const r = await dialog.showOpenDialog({ properties: ['openDirectory'] }) + if (r.canceled || !r.filePaths[0]) return libraryStore.list() + return libraryStore.add(r.filePaths[0]) + }) + + ipcMain.handle(IPC.LIBRARY_REMOVE, (_e, path: string) => libraryStore.remove(path)) + + ipcMain.handle(IPC.INDEX_RUN, async (e) => { + const win = BrowserWindow.fromWebContents(e.sender) + if (!win) throw new Error('요청 창을 찾을 수 없음') + indexer.run(win).catch((err) => { + logger.error('색인 실행 실패', { message: (err as Error).message }) + }) + }) + + ipcMain.handle(IPC.INDEX_CANCEL, () => indexer.cancel()) + + ipcMain.handle( + IPC.INDEX_ASSETS, + (_e, offset: number, limit: number, query: AssetQuery) => + indexDb.listAssets(offset, limit, query, settingsStore.current().qualityThresholds) + ) + + ipcMain.handle(IPC.INDEX_SET_RATING, (_e, assetId: number, rating: number) => + indexDb.setRating(assetId, rating) + ) + + ipcMain.handle(IPC.INDEX_SET_LABEL, (_e, assetId: number, label: ColorLabel) => + indexDb.setLabel(assetId, label) + ) + + // ---- 검색 (Phase 2) ---- + ipcMain.handle(IPC.SEARCH_BUILD, async (e) => { + const win = BrowserWindow.fromWebContents(e.sender) + if (!win) throw new Error('요청 창을 찾을 수 없음') + embedder.run(win).catch((err) => { + logger.error('검색 색인 실패', { message: (err as Error).message }) + }) + }) + + ipcMain.handle(IPC.SEARCH_CANCEL, () => embedder.cancel()) + + ipcMain.handle(IPC.SEARCH_STATUS, () => ({ + embedded: indexDb.embeddingCount(), + totalImages: indexDb.listAssetsNeedingEmbedding([...SUPPORTED_EXTENSIONS]).length + indexDb.embeddingCount() + })) + + ipcMain.handle(IPC.SEARCH_QUERY, (_e, text: string) => search(text)) } diff --git a/src/main/libraryStore.ts b/src/main/libraryStore.ts new file mode 100644 index 0000000..a8bbcfc --- /dev/null +++ b/src/main/libraryStore.ts @@ -0,0 +1,61 @@ +import { app } from 'electron' +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { logger } from './logger' + +const FILE = 'libraries.json' + +/** + * 색인 대상 라이브러리 루트 폴더 목록. userData/libraries.json (로컬 전용). + * 비파괴: 폴더를 옮기지 않고 "제자리"에서 색인한다. + */ +class LibraryStore { + private roots: string[] = [] + private loaded = false + + private filePath(): string { + return join(app.getPath('userData'), FILE) + } + + async load(): Promise { + if (this.loaded) return this.roots + try { + const raw = await readFile(this.filePath(), 'utf-8') + const parsed = JSON.parse(raw) as { roots?: string[] } + this.roots = Array.isArray(parsed.roots) ? parsed.roots : [] + } catch { + this.roots = [] + } + this.loaded = true + return this.roots + } + + async list(): Promise { + await this.load() + return [...this.roots] + } + + private async persist(): Promise { + await mkdir(app.getPath('userData'), { recursive: true }) + await writeFile(this.filePath(), JSON.stringify({ roots: this.roots }, null, 2), 'utf-8') + } + + async add(path: string): Promise { + await this.load() + if (!this.roots.includes(path)) { + this.roots.push(path) + await this.persist() + logger.info('라이브러리 폴더 추가', { path }) + } + return [...this.roots] + } + + async remove(path: string): Promise { + await this.load() + this.roots = this.roots.filter((r) => r !== path) + await this.persist() + return [...this.roots] + } +} + +export const libraryStore = new LibraryStore() diff --git a/src/main/mediaProtocol.ts b/src/main/mediaProtocol.ts index 8ad2bfa..c5be86a 100644 --- a/src/main/mediaProtocol.ts +++ b/src/main/mediaProtocol.ts @@ -4,6 +4,7 @@ import { extname } from 'node:path' import { MEDIA_SCHEME } from '@shared/constants' import { profileStore } from './profileStore' import { presetStore } from './presetStore' +import { thumbPath } from './thumbnails' import { logger } from './logger' const MIME: Record = { @@ -41,12 +42,28 @@ export function registerMediaScheme(): void { export function handleMediaProtocol(): void { protocol.handle(MEDIA_SCHEME, async (request) => { try { - // request.url = photoai-media://img/?p= + const url = request.url + + // 썸네일 요청: photoai-media://thumb/?t= + const tMarker = 't=' + const ti = url.indexOf(tMarker) + if (ti >= 0) { + const hash = decodeURIComponent(url.slice(ti + tMarker.length)) + // 해시는 16진수만 허용 → 경로 조작 차단 + if (!/^[a-f0-9]+$/.test(hash)) return new Response('bad hash', { status: 400 }) + const data = await readFile(thumbPath(hash)) + return new Response(new Uint8Array(data), { + status: 200, + headers: { 'content-type': 'image/webp', 'cache-control': 'no-cache' } + }) + } + + // 참조 이미지 요청: photoai-media://img/?p= // searchParams의 자동 디코딩과의 이중 디코딩을 피하려고 raw 문자열을 직접 파싱한다. const marker = 'p=' - const i = request.url.indexOf(marker) + const i = url.indexOf(marker) if (i < 0) return new Response('missing path', { status: 400 }) - const filePath = decodeURIComponent(request.url.slice(i + marker.length)) + const filePath = decodeURIComponent(url.slice(i + marker.length)) // 등록된 참조 이미지(활성 프로필 또는 프리셋)에 한해 제공 — 임의 파일 읽기 차단 const allowed = diff --git a/src/main/quality.ts b/src/main/quality.ts new file mode 100644 index 0000000..8b2d382 --- /dev/null +++ b/src/main/quality.ts @@ -0,0 +1,18 @@ +import { QUALITY_THRESHOLDS } from '@shared/constants' +import type { QualityScores } from '@shared/types' + +/** + * 원본 점수 → 종합 플래그 분류 (Main 측, 임계값 기반). + * 우선순위: 흐림 → 눈감음 → 노출불량 → 후보(candidate) + */ +export function classifyFlag( + focus: number, + exposure: number, + eyesOpen: number | null, + th = QUALITY_THRESHOLDS +): QualityScores['flag'] { + if (focus < th.focus) return 'blurry' + if (eyesOpen !== null && eyesOpen < th.eyes) return 'eyesClosed' + if (exposure < th.exposure) return 'badExposure' + return 'candidate' +} diff --git a/src/main/searchService.ts b/src/main/searchService.ts new file mode 100644 index 0000000..00e522e --- /dev/null +++ b/src/main/searchService.ts @@ -0,0 +1,32 @@ +import type { IndexedAsset } from '@shared/types' +import { indexDb } from './indexDb' +import { inferenceBridge } from './inferenceBridge' +import { settingsStore } from './settingsStore' + +/** 정규화된 두 벡터의 코사인 = 내적 */ +function dot(a: number[], b: Float32Array): number { + const n = Math.min(a.length, b.length) + let s = 0 + for (let i = 0; i < n; i++) s += a[i] * b[i] + return s +} + +/** + * 자연어 쿼리 → CLIP 텍스트 임베딩 → 전체 이미지 임베딩과 코사인 유사도 → 상위 K. + * (수만 장 규모까지 브루트포스로 충분; 대규모는 추후 ANN.) + */ +export async function search(query: string, topK = 80): Promise { + const trimmed = query.trim() + if (!trimmed) return [] + + await inferenceBridge.whenReady() + const qvec = await inferenceBridge.embedText(trimmed) + const all = indexDb.getAllEmbeddings() + if (all.length === 0) return [] + + const scored = all.map((e) => ({ assetId: e.assetId, score: dot(qvec, e.vec) })) + scored.sort((a, b) => b.score - a.score) + const topIds = scored.slice(0, topK).map((s) => s.assetId) + + return indexDb.assetsByIds(topIds, settingsStore.current().qualityThresholds) +} diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index f722ae4..9d623b6 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -2,13 +2,14 @@ import { app } from 'electron' import { readFile, writeFile, mkdir } from 'node:fs/promises' import { join } from 'node:path' import type { Settings } from '@shared/types' -import { SETTINGS_FILE } from '@shared/constants' +import { SETTINGS_FILE, QUALITY_THRESHOLDS } from '@shared/constants' import { DEFAULT_LANG } from '@shared/i18n' const DEFAULTS: Settings = { language: DEFAULT_LANG, // 기본 한국어 theme: 'dark', // 기본 다크모드 - onboarded: false + onboarded: false, + qualityThresholds: { ...QUALITY_THRESHOLDS } } /** 앱 설정(언어/테마/온보딩) 영속화. userData/settings.json */ diff --git a/src/main/thumbnails.ts b/src/main/thumbnails.ts new file mode 100644 index 0000000..56d3c24 --- /dev/null +++ b/src/main/thumbnails.ts @@ -0,0 +1,29 @@ +import { app } from 'electron' +import { writeFile, mkdir, access } from 'node:fs/promises' +import { constants as FS } from 'node:fs' +import { join } from 'node:path' + +/** + * 썸네일 캐시. userData/thumbs/.webp (라이브러리 폴더는 손대지 않음 = 비파괴). + */ +export function thumbsDir(): string { + return join(app.getPath('userData'), 'thumbs') +} + +export function thumbPath(contentHash: string): string { + return join(thumbsDir(), `${contentHash}.webp`) +} + +export async function hasThumb(contentHash: string): Promise { + try { + await access(thumbPath(contentHash), FS.F_OK) + return true + } catch { + return false + } +} + +export async function writeThumb(contentHash: string, bytes: ArrayBuffer): Promise { + await mkdir(thumbsDir(), { recursive: true }) + await writeFile(thumbPath(contentHash), Buffer.from(bytes)) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index d76665b..ff6817c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,8 @@ import type { JobRequest, Settings, ReferenceData, + AssetQuery, + ColorLabel, RendererEventName, RendererEvents } from '../shared/types' @@ -16,7 +18,11 @@ const EVENT_CHANNELS: Record = { 'job:fileProcessed': IPC.JOB_FILE_PROCESSED, 'job:done': IPC.JOB_DONE, 'job:error': IPC.JOB_ERROR, - 'settings:changed': IPC.SETTINGS_CHANGED + 'settings:changed': IPC.SETTINGS_CHANGED, + 'index:progress': IPC.INDEX_PROGRESS, + 'index:done': IPC.INDEX_DONE, + 'search:progress': IPC.SEARCH_PROGRESS, + 'search:done': IPC.SEARCH_DONE } const api: ExposedApi = { @@ -51,6 +57,27 @@ const api: ExposedApi = { get: () => ipcRenderer.invoke(IPC.SETTINGS_GET), set: (patch: Partial) => ipcRenderer.invoke(IPC.SETTINGS_SET, patch) }, + library: { + list: () => ipcRenderer.invoke(IPC.LIBRARY_LIST), + add: () => ipcRenderer.invoke(IPC.LIBRARY_ADD), + remove: (path: string) => ipcRenderer.invoke(IPC.LIBRARY_REMOVE, path) + }, + index: { + run: () => ipcRenderer.invoke(IPC.INDEX_RUN), + cancel: () => ipcRenderer.invoke(IPC.INDEX_CANCEL), + assets: (offset: number, limit: number, query: AssetQuery) => + ipcRenderer.invoke(IPC.INDEX_ASSETS, offset, limit, query), + setRating: (assetId: number, rating: number) => + ipcRenderer.invoke(IPC.INDEX_SET_RATING, assetId, rating), + setLabel: (assetId: number, label: ColorLabel) => + ipcRenderer.invoke(IPC.INDEX_SET_LABEL, assetId, label) + }, + search: { + build: () => ipcRenderer.invoke(IPC.SEARCH_BUILD), + cancel: () => ipcRenderer.invoke(IPC.SEARCH_CANCEL), + status: () => ipcRenderer.invoke(IPC.SEARCH_STATUS), + query: (text: string) => ipcRenderer.invoke(IPC.SEARCH_QUERY, text) + }, // Electron 33: File.path 제거됨 → webUtils로 드롭된 파일의 실제 경로 획득 getPathForFile: (file: unknown) => webUtils.getPathForFile(file as File), on(event: E, cb: (payload: RendererEvents[E]) => void) { diff --git a/src/preload/inference.ts b/src/preload/inference.ts index ebb0147..78a27d4 100644 --- a/src/preload/inference.ts +++ b/src/preload/inference.ts @@ -2,7 +2,14 @@ import { contextBridge, ipcRenderer } from 'electron' // 숨김 추론 창 전용 브릿지. // Main이 보내는 요청 채널만 수신하고, 응답은 'infer:reply'로만 전송한다. -const REQUEST_CHANNELS = ['infer:init', 'infer:describe', 'infer:detect'] as const +const REQUEST_CHANNELS = [ + 'infer:init', + 'infer:describe', + 'infer:detect', + 'infer:analyze', + 'infer:embedImage', + 'infer:embedText' +] as const type RequestChannel = (typeof REQUEST_CHANNELS)[number] export interface InferBridge { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8196791..e00b9f3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -8,10 +8,15 @@ import { RunControl } from './components/RunControl' import { ProgressView } from './components/ProgressView' import { FileList } from './components/FileList' import { ReportView } from './components/ReportView' +import { LibraryView } from './components/LibraryView' +import { SearchView } from './components/SearchView' +import type { AppView } from './store' export default function App(): JSX.Element { const t = useT() const phase = useStore((s) => s.phase) + const view = useStore((s) => s.view) + const setView = useStore((s) => s.setView) const onboarded = useStore((s) => s.onboarded) const refreshProfiles = useStore((s) => s.refreshProfiles) const initSettings = useStore((s) => s.initSettings) @@ -28,29 +33,65 @@ export default function App(): JSX.Element { if (!ready) return
if (!onboarded) return + const tabs: { id: AppView; label: string }[] = [ + { id: 'organize', label: t('nav.organize') }, + { id: 'library', label: t('nav.library') }, + { id: 'search', label: t('nav.search') } + ] + return (
-
-

{t('app.title')}

-

{t('app.subtitle')}

+
+
+
+

{t('app.title')}

+

{t('app.subtitle')}

+
+
+ {/* 탭 네비 */} +
-
- {/* 좌측: 설정 패널 (자체 스크롤) */} -
- - - -
+ {view === 'organize' ? ( +
+ {/* 좌측: 설정 패널 (자체 스크롤) */} +
+ + + +
- {/* 우측: 진행/결과 — FileList만 내부 스크롤 */} -
-
- {phase === 'done' ? : } -
- -
-
+ {/* 우측: 진행/결과 — FileList만 내부 스크롤 */} +
+
+ {phase === 'done' ? : } +
+ +
+
+ ) : view === 'library' ? ( +
+ +
+ ) : ( +
+ +
+ )}
) } diff --git a/src/renderer/components/LibraryView.tsx b/src/renderer/components/LibraryView.tsx new file mode 100644 index 0000000..39e1a0a --- /dev/null +++ b/src/renderer/components/LibraryView.tsx @@ -0,0 +1,417 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useStore } from '../store' +import { useT } from '../i18n' +import { thumbUrl, baseName } from '../media' +import type { + IndexedAsset, + QualityFilter, + QualityFlag, + ColorLabel, + AssetQuery, + QualityThresholds +} from '@shared/types' + +const PAGE = 120 + +const FILTERS: { id: QualityFilter; key: string }[] = [ + { id: 'all', key: 'cull.all' }, + { id: 'candidate', key: 'cull.candidate' }, + { id: 'rejected', key: 'cull.rejected' } +] + +const FLAG_STYLE: Record, string> = { + candidate: 'bg-emerald-500/80', + blurry: 'bg-amber-500/80', + eyesClosed: 'bg-violet-500/80', + badExposure: 'bg-red-500/80' +} +const VIDEO_EXTS = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v'] + +const LABEL_COLORS: { id: Exclude; cls: string }[] = [ + { id: 'red', cls: 'bg-red-500' }, + { id: 'yellow', cls: 'bg-yellow-400' }, + { id: 'green', cls: 'bg-emerald-500' }, + { id: 'blue', cls: 'bg-sky-500' }, + { id: 'purple', cls: 'bg-violet-500' } +] + +/** 라이브러리: 폴더 색인 + 썸네일 그리드 + 컬링(품질 임계값 / 별점·색라벨) */ +export function LibraryView(): JSX.Element { + const t = useT() + const libraries = useStore((s) => s.libraries) + const refreshLibraries = useStore((s) => s.refreshLibraries) + const addLibrary = useStore((s) => s.addLibrary) + const removeLibrary = useStore((s) => s.removeLibrary) + const startIndex = useStore((s) => s.startIndex) + const cancelIndex = useStore((s) => s.cancelIndex) + const indexPhase = useStore((s) => s.indexPhase) + const progress = useStore((s) => s.indexProgress) + const summary = useStore((s) => s.indexSummary) + const qt = useStore((s) => s.qualityThresholds) + const updateSettings = useStore((s) => s.updateSettings) + + const [assets, setAssets] = useState([]) + const [hasMore, setHasMore] = useState(false) + const [filter, setFilter] = useState('all') + const [ratingMin, setRatingMin] = useState(0) + const [showThresholds, setShowThresholds] = useState(false) + const [localTh, setLocalTh] = useState(qt) + const localThRef = useRef(localTh) + localThRef.current = localTh + useEffect(() => setLocalTh(qt), [qt]) + + const loadAssets = useCallback(async (offset: number, q: AssetQuery) => { + const page = await window.api.index.assets(offset, PAGE, q) + setHasMore(page.length === PAGE) + setAssets((prev) => (offset === 0 ? page : [...prev, ...page])) + }, []) + + useEffect(() => { + void refreshLibraries() + }, [refreshLibraries]) + + // 필터/별점 변경 시 그리드 갱신 + useEffect(() => { + void loadAssets(0, { filter, ratingMin }) + }, [filter, ratingMin, loadAssets]) + // 색인 완료 시 갱신 + useEffect(() => { + if (indexPhase === 'done') void loadAssets(0, { filter, ratingMin }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [indexPhase, summary]) + + const commitThresholds = async (th: QualityThresholds) => { + await updateSettings({ qualityThresholds: th }) + void loadAssets(0, { filter, ratingMin }) + } + + const setRating = async (a: IndexedAsset, rating: number) => { + if (a.id == null) return + const next = a.rating === rating ? 0 : rating + await window.api.index.setRating(a.id, next) + setAssets((prev) => prev.map((x) => (x.id === a.id ? { ...x, rating: next } : x))) + } + const setLabel = async (a: IndexedAsset, label: Exclude) => { + if (a.id == null) return + const next: ColorLabel = a.label === label ? null : label + await window.api.index.setLabel(a.id, next) + setAssets((prev) => prev.map((x) => (x.id === a.id ? { ...x, label: next } : x))) + } + + const running = indexPhase === 'running' + const pct = + progress && progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0 + + return ( +
+ {/* 라이브러리 폴더 */} +
+
+

{t('lib.section')}

+ +
+

{t('lib.hint')}

+ + {libraries.length === 0 ? ( +

{t('lib.empty')}

+ ) : ( +
    + {libraries.map((dir) => ( +
  • + + {dir} + + +
  • + ))} +
+ )} +
+ + {/* 색인 제어 + 진행률 */} +
+
+ {!running ? ( + + ) : ( + + )} + {summary && !running && ( + + {t('lib.assets', { n: summary.assets })} + + )} +
+ + {running && ( + <> +
+
+
+
+ + {progress?.current ?? t('lib.indexing')} + + {pct}% +
+ + )} + + {summary && !running && ( +

+ {t('lib.doneSummary', { + indexed: summary.indexed, + skipped: summary.skipped, + failed: summary.failed, + assets: summary.assets + })} +

+ )} +
+ + {/* 그리드 + 컬링 */} +
+
+
+

{t('lib.grid')}

+
+ {FILTERS.map((f) => ( + + ))} +
+ {/* 별점 최소 필터 */} +
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
+
+
+ + {assets.length} +
+
+ + {/* 임계값 패널 */} + {showThresholds && ( +
+
+ setLocalTh({ ...localThRef.current, focus: v })} + onCommit={() => commitThresholds(localThRef.current)} + /> + setLocalTh({ ...localThRef.current, exposure: v })} + onCommit={() => commitThresholds(localThRef.current)} + /> + setLocalTh({ ...localThRef.current, eyes: v })} + onCommit={() => commitThresholds(localThRef.current)} + /> +
+
+

{t('cull.thresholdHint')}

+ +
+
+ )} + + {assets.length === 0 ? ( +

{t('lib.gridEmpty')}

+ ) : ( + <> +
+ {assets.map((a) => ( + setRating(a, r)} + onLabel={(l) => setLabel(a, l)} + /> + ))} +
+ {hasMore && ( +
+ +
+ )} + + )} +
+
+ ) +} + +function ThresholdSlider(props: { + label: string + min: number + max: number + step: number + value: number + onInput: (v: number) => void + onCommit: () => void +}): JSX.Element { + return ( + + ) +} + +function AssetTile(props: { + asset: IndexedAsset + flagLabel: string + onRate: (rating: number) => void + onLabel: (label: Exclude) => void +}): JSX.Element { + const { asset: a } = props + const isVideo = VIDEO_EXTS.includes(a.ext) + const labelColor = a.label ? LABEL_COLORS.find((c) => c.id === a.label)?.cls : null + + return ( +
+ {isVideo ? ( +
+ ▶ {baseName(a.path).slice(-12)} +
+ ) : ( + {baseName(a.path)} + )} + + {/* 품질 배지 */} + {a.flag && ( + + {props.flagLabel} + + )} + + {/* 색라벨 표시(우상단 점) */} + {labelColor && ( + + )} + + {/* 호버 오버레이: 별점 + 색라벨 편집 */} +
+
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
+
+ {LABEL_COLORS.map((c) => ( +
+
+
+ ) +} diff --git a/src/renderer/components/SearchView.tsx b/src/renderer/components/SearchView.tsx new file mode 100644 index 0000000..c8fe8bb --- /dev/null +++ b/src/renderer/components/SearchView.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react' +import { useStore } from '../store' +import { useT } from '../i18n' +import { thumbUrl, baseName } from '../media' +import type { IndexedAsset, SearchStatus } from '@shared/types' + +const VIDEO_EXTS = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v'] + +/** 자연어/유사 검색 (Phase 2) */ +export function SearchView(): JSX.Element { + const t = useT() + const searchPhase = useStore((s) => s.searchPhase) + const progress = useStore((s) => s.searchProgress) + const summary = useStore((s) => s.searchSummary) + const buildSearchIndex = useStore((s) => s.buildSearchIndex) + const cancelSearchIndex = useStore((s) => s.cancelSearchIndex) + + const [status, setStatus] = useState(null) + const [text, setText] = useState('') + const [results, setResults] = useState([]) + const [searching, setSearching] = useState(false) + const [searched, setSearched] = useState(false) + + const refreshStatus = async () => setStatus(await window.api.search.status()) + useEffect(() => { + void refreshStatus() + }, []) + useEffect(() => { + if (searchPhase === 'done') void refreshStatus() + }, [searchPhase, summary]) + + const running = searchPhase === 'running' + const pct = + progress && progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0 + + const runSearch = async () => { + if (!text.trim()) return + setSearching(true) + setSearched(true) + try { + setResults(await window.api.search.query(text)) + } finally { + setSearching(false) + } + } + + return ( +
+ {/* 검색 색인 생성 */} +
+
+

{t('search.section')}

+ {status && ( + + {t('search.status', { embedded: status.embedded, total: status.totalImages })} + + )} +
+

{t('search.hint')}

+ +
+ {!running ? ( + + ) : ( + + )} +
+ + {running && ( +
+
+
+
+
+ + {progress?.current ?? t('search.building')} + + {pct}% +
+
+ )} +
+ + {/* 검색 바 */} +
+
+ setText(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && runSearch()} + /> + +
+ +
+ {!searched ? ( +

{t('search.prompt')}

+ ) : results.length === 0 ? ( +

{t('search.noResults')}

+ ) : ( +
+ {results.map((a) => ( +
+ {VIDEO_EXTS.includes(a.ext) ? ( +
+ ▶ +
+ ) : ( + {baseName(a.path)} + )} +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/src/renderer/media.ts b/src/renderer/media.ts index 8f4fdaf..71f47c8 100644 --- a/src/renderer/media.ts +++ b/src/renderer/media.ts @@ -8,6 +8,11 @@ export function mediaUrl(absolutePath: string): string { return `${MEDIA_SCHEME}://img/?p=${encodeURIComponent(absolutePath)}` } +/** 색인 자산 썸네일 URL (contentHash 기반) */ +export function thumbUrl(contentHash: string): string { + return `${MEDIA_SCHEME}://thumb/?t=${encodeURIComponent(contentHash)}` +} + /** 경로에서 파일명만 추출 (표시용) */ export function baseName(p: string): string { const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) diff --git a/src/renderer/store.ts b/src/renderer/store.ts index 70ab99b..a5ee5cc 100644 --- a/src/renderer/store.ts +++ b/src/renderer/store.ts @@ -6,13 +6,19 @@ import type { ProgressEvent, Report, Settings, - Theme + Theme, + QualityThresholds, + IndexProgress, + IndexSummary, + SearchProgress, + SearchSummary } from '@shared/types' import type { Lang } from '@shared/i18n' import { DEFAULT_LANG } from '@shared/i18n' import { DEFAULT_JOB_OPTIONS } from '@shared/constants' export type JobPhase = 'idle' | 'running' | 'done' +export type AppView = 'organize' | 'library' | 'search' /** 테마를 클래스에 반영 (Tailwind darkMode:'class') */ function applyTheme(theme: Theme): void { @@ -44,10 +50,33 @@ interface AppState { cancelJob: () => Promise resetJob: () => void - // 설정(언어/테마/온보딩) + // 화면 전환 + view: AppView + setView: (v: AppView) => void + + // 라이브러리 / 색인 (Phase 0) + libraries: string[] + indexPhase: JobPhase + indexProgress: IndexProgress | null + indexSummary: IndexSummary | null + refreshLibraries: () => Promise + addLibrary: () => Promise + removeLibrary: (path: string) => Promise + startIndex: () => Promise + cancelIndex: () => Promise + + // 검색 색인 (Phase 2) + searchPhase: JobPhase + searchProgress: SearchProgress | null + searchSummary: SearchSummary | null + buildSearchIndex: () => Promise + cancelSearchIndex: () => Promise + + // 설정(언어/테마/온보딩/임계값) language: Lang theme: Theme onboarded: boolean + qualityThresholds: QualityThresholds initSettings: () => Promise updateSettings: (patch: Partial) => Promise @@ -57,6 +86,10 @@ interface AppState { _onDone: (r: Report) => void _onError: (e: { file: string; message: string }) => void _onSettings: (s: Settings) => void + _onIndexProgress: (p: IndexProgress) => void + _onIndexDone: (s: IndexSummary) => void + _onSearchProgress: (p: SearchProgress) => void + _onSearchDone: (s: SearchSummary) => void } export const useStore = create((set, get) => ({ @@ -91,19 +124,62 @@ export const useStore = create((set, get) => ({ }, resetJob: () => set({ phase: 'idle', progress: null, processed: [], report: null, errors: [] }), + // ---- 화면 전환 ---- + view: 'organize', + setView: (view) => set({ view }), + + // ---- 라이브러리 / 색인 ---- + libraries: [], + indexPhase: 'idle', + indexProgress: null, + indexSummary: null, + refreshLibraries: async () => set({ libraries: await window.api.library.list() }), + addLibrary: async () => set({ libraries: await window.api.library.add() }), + removeLibrary: async (path) => set({ libraries: await window.api.library.remove(path) }), + startIndex: async () => { + set({ indexPhase: 'running', indexProgress: null, indexSummary: null }) + await window.api.index.run() + }, + cancelIndex: async () => { + await window.api.index.cancel() + }, + + // ---- 검색 색인 ---- + searchPhase: 'idle', + searchProgress: null, + searchSummary: null, + buildSearchIndex: async () => { + set({ searchPhase: 'running', searchProgress: null, searchSummary: null }) + await window.api.search.build() + }, + cancelSearchIndex: async () => { + await window.api.search.cancel() + }, + // ---- 설정 ---- language: DEFAULT_LANG, theme: 'dark', onboarded: false, + qualityThresholds: { focus: 60, exposure: 0.35, eyes: 0.18 }, initSettings: async () => { const s = await window.api.settings.get() applyTheme(s.theme) - set({ language: s.language, theme: s.theme, onboarded: s.onboarded }) + set({ + language: s.language, + theme: s.theme, + onboarded: s.onboarded, + qualityThresholds: s.qualityThresholds + }) }, updateSettings: async (patch) => { const s = await window.api.settings.set(patch) applyTheme(s.theme) - set({ language: s.language, theme: s.theme, onboarded: s.onboarded }) + set({ + language: s.language, + theme: s.theme, + onboarded: s.onboarded, + qualityThresholds: s.qualityThresholds + }) }, _onProgress: (progress) => set({ progress }), @@ -116,8 +192,17 @@ export const useStore = create((set, get) => ({ _onError: (e) => set((s) => ({ errors: [e, ...s.errors].slice(0, 200) })), _onSettings: (s) => { applyTheme(s.theme) - set({ language: s.language, theme: s.theme, onboarded: s.onboarded }) - } + set({ + language: s.language, + theme: s.theme, + onboarded: s.onboarded, + qualityThresholds: s.qualityThresholds + }) + }, + _onIndexProgress: (p: IndexProgress) => set({ indexProgress: p }), + _onIndexDone: (s: IndexSummary) => set({ indexSummary: s, indexPhase: 'done' }), + _onSearchProgress: (p: SearchProgress) => set({ searchProgress: p }), + _onSearchDone: (s: SearchSummary) => set({ searchSummary: s, searchPhase: 'done' }) })) /** 앱 시작 시 1회: Main→UI 이벤트 구독 */ @@ -128,7 +213,11 @@ export function wireEvents(): () => void { window.api.on('job:fileProcessed', s._onFile), window.api.on('job:done', s._onDone), window.api.on('job:error', s._onError), - window.api.on('settings:changed', s._onSettings) + window.api.on('settings:changed', s._onSettings), + window.api.on('index:progress', s._onIndexProgress), + window.api.on('index:done', s._onIndexDone), + window.api.on('search:progress', s._onSearchProgress), + window.api.on('search:done', s._onSearchDone) ] return () => offs.forEach((off) => off()) } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a11f4d7..e3de06f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -50,6 +50,22 @@ export const DEFAULT_JOB_OPTIONS = { /** 추론 시 이미지 장변 최대 픽셀 (다운스케일 기준) */ export const MAX_IMAGE_DIMENSION = 1024 +/** 썸네일 장변 픽셀 */ +export const THUMBNAIL_SIZE = 256 + +/** 품질 분석 시 이미지 장변 픽셀 (초점/노출/얼굴 계산용) */ +export const ANALYZE_SIZE = 512 + +/** 품질 판정 기본 임계값 (Phase 1) */ +export const QUALITY_THRESHOLDS = { + /** 라플라시안 분산이 이 값 미만이면 흐림 (512px 기준) */ + focus: 60, + /** 노출 점수(0~1)가 이 값 미만이면 노출 불량 */ + exposure: 0.35, + /** EAR(눈 종횡비)이 이 값 미만이면 눈 감음 */ + eyes: 0.18 +} + /** IPC 채널명 */ export const IPC = { // UI → Main (invoke) @@ -73,6 +89,24 @@ export const IPC = { SETTINGS_GET: 'settings:get', SETTINGS_SET: 'settings:set', SETTINGS_CHANGED: 'settings:changed', + // 라이브러리 / 색인 (Phase 0) + LIBRARY_LIST: 'library:list', + LIBRARY_ADD: 'library:add', + LIBRARY_REMOVE: 'library:remove', + INDEX_RUN: 'index:run', + INDEX_CANCEL: 'index:cancel', + INDEX_PROGRESS: 'index:progress', + INDEX_DONE: 'index:done', + INDEX_ASSETS: 'index:assets', + INDEX_SET_RATING: 'index:setRating', + INDEX_SET_LABEL: 'index:setLabel', + // 검색 (Phase 2) + SEARCH_BUILD: 'search:build', + SEARCH_CANCEL: 'search:cancel', + SEARCH_STATUS: 'search:status', + SEARCH_QUERY: 'search:query', + SEARCH_PROGRESS: 'search:progress', + SEARCH_DONE: 'search:done', // Main → UI (send) JOB_PROGRESS: 'job:progress', JOB_FILE_PROCESSED: 'job:fileProcessed', diff --git a/src/shared/i18n.ts b/src/shared/i18n.ts index 486f634..f67abca 100644 --- a/src/shared/i18n.ts +++ b/src/shared/i18n.ts @@ -141,6 +141,75 @@ export const MESSAGES: Table = { 'dur.ms': { ko: '{m}분 {s}초', en: '{m}m {s}s' }, 'dur.s': { ko: '{s}초', en: '{s}s' }, + // 내비게이션 / 라이브러리 (Phase 0) + 'nav.organize': { ko: '정리', en: 'Organize' }, + 'nav.library': { ko: '라이브러리', en: 'Library' }, + 'nav.search': { ko: '검색', en: 'Search' }, + + // 검색 (Phase 2) + 'search.section': { ko: '검색 색인', en: 'Search index' }, + 'search.hint': { + ko: '자연어로 사진을 검색하려면 먼저 CLIP 임베딩 색인을 생성하세요(최초 1회 모델 다운로드 필요).', + en: 'Build the CLIP embedding index to search photos by natural language (first run downloads the model).' + }, + 'search.build': { ko: '검색 색인 생성', en: 'Build search index' }, + 'search.cancel': { ko: '취소', en: 'Cancel' }, + 'search.building': { ko: '임베딩 중…', en: 'Embedding…' }, + 'search.status': { ko: '임베딩 {embedded} / {total}', en: 'Embedded {embedded} / {total}' }, + 'search.placeholder': { + ko: '예: 푸른 바다, 노을, 강아지가 있는 사진…', + en: 'e.g. blue ocean, sunset, photos with a dog…' + }, + 'search.go': { ko: '검색', en: 'Search' }, + 'search.searching': { ko: '검색 중…', en: 'Searching…' }, + 'search.noResults': { ko: '결과가 없습니다.', en: 'No results.' }, + 'search.prompt': { + ko: '검색어를 입력하세요.', + en: 'Type a query to search.' + }, + 'lib.section': { ko: '라이브러리 폴더', en: 'Library Folders' }, + 'lib.hint': { + ko: '색인할 폴더를 추가하세요. 사진은 옮기지 않고 제자리에서 색인됩니다(비파괴).', + en: 'Add folders to index. Photos are indexed in place, never moved (non-destructive).' + }, + 'lib.add': { ko: '폴더 추가', en: 'Add folder' }, + 'lib.empty': { ko: '색인할 라이브러리 폴더가 없습니다.', en: 'No library folders yet.' }, + 'lib.remove': { ko: '제거', en: 'Remove' }, + 'lib.index': { ko: '색인 시작', en: 'Start indexing' }, + 'lib.cancel': { ko: '취소', en: 'Cancel' }, + 'lib.indexing': { ko: '색인 중…', en: 'Indexing…' }, + 'lib.assets': { ko: '색인된 자산 {n}개', en: '{n} assets indexed' }, + 'lib.progress': { ko: '{done} / {total} · 신규 {indexed} · 스킵 {skipped}', en: '{done} / {total} · {indexed} new · {skipped} skipped' }, + 'lib.doneSummary': { + ko: '완료 — 신규 {indexed} · 스킵 {skipped} · 실패 {failed} · 총 {assets}개', + en: 'Done — {indexed} new · {skipped} skipped · {failed} failed · {assets} total' + }, + 'lib.grid': { ko: '색인된 사진', en: 'Indexed photos' }, + 'lib.gridEmpty': { + ko: '색인된 사진이 없습니다. 폴더를 추가하고 색인을 실행하세요.', + en: 'No indexed photos. Add a folder and run indexing.' + }, + 'lib.loadMore': { ko: '더 보기', en: 'Load more' }, + + // 컬링 필터 / 품질 플래그 (Phase 1) + 'cull.all': { ko: '전체', en: 'All' }, + 'cull.candidate': { ko: '고품질 후보', en: 'Candidates' }, + 'cull.rejected': { ko: '제외 후보', en: 'Rejected' }, + 'flag.candidate': { ko: '후보', en: 'Keep' }, + 'flag.blurry': { ko: '흐림', en: 'Blurry' }, + 'flag.eyesClosed': { ko: '눈감음', en: 'Eyes closed' }, + 'flag.badExposure': { ko: '노출', en: 'Exposure' }, + 'cull.thresholds': { ko: '품질 임계값', en: 'Quality thresholds' }, + 'cull.focus': { ko: '초점', en: 'Focus' }, + 'cull.exposure': { ko: '노출', en: 'Exposure' }, + 'cull.eyes': { ko: '눈 뜸', en: 'Eyes open' }, + 'cull.thresholdHint': { + ko: '값을 올리면 더 엄격하게 제외됩니다. 변경 즉시 재분석 없이 반영됩니다.', + en: 'Higher = stricter rejection. Applied instantly without re-analysis.' + }, + 'cull.reset': { ko: '기본값', en: 'Reset' }, + 'cull.ratingMin': { ko: '별점', en: 'Rating' }, + // 메뉴 'menu.file': { ko: '파일', en: 'File' }, 'menu.edit': { ko: '편집', en: 'Edit' }, diff --git a/src/shared/types.ts b/src/shared/types.ts index 6a777cd..e3912f7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -5,12 +5,21 @@ import type { Lang } from './i18n' /** UI 테마 */ export type Theme = 'dark' | 'light' +/** 품질 판정 임계값 (사용자 조절 가능) */ +export interface QualityThresholds { + focus: number + exposure: number + eyes: number +} + /** 앱 설정 (userData/settings.json) */ export interface Settings { language: Lang theme: Theme /** 첫 실행 온보딩(언어/테마 선택) 완료 여부 */ onboarded: boolean + /** 컬링 품질 임계값 */ + qualityThresholds: QualityThresholds } /** 등록된 인물 프로필 */ @@ -76,6 +85,111 @@ export interface DescriptorResult { descriptor: number[] | null } +/** 라이브러리 인덱스의 자산(사진/영상) 레코드 — SQLite asset 테이블 */ +export interface AssetRecord { + id?: number + /** 파일 내용 해시 — 경로가 바뀌어도 추적, 정확 중복 식별 */ + contentHash: string + path: string + ext: string + sizeBytes: number + mtime: number + width: number | null + height: number | null + exifYear: string | null + exifMonth: string | null + indexedAt: number +} + +/** 색인 진행률 이벤트 */ +export interface IndexProgress { + done: number + total: number + current: string + indexed: number + skipped: number +} + +/** 색인 완료 요약 */ +export interface IndexSummary { + total: number + indexed: number + skipped: number + failed: number + assets: number + elapsedMs: number +} + +/** 품질 종합 분류 */ +export type QualityFlag = 'candidate' | 'blurry' | 'eyesClosed' | 'badExposure' | null + +/** 품질 평가 점수 (Phase 1) — SQLite quality 테이블 */ +export interface QualityScores { + /** 초점/선명도 (라플라시안 분산 기반, 높을수록 선명) */ + focus: number | null + /** 노출 (히스토그램 기반) */ + exposure: number | null + /** 눈 뜸 정도 (face-api 랜드마크 EAR, 1=뜸) */ + eyesOpen: number | null + /** 종합 분류 */ + flag: QualityFlag +} + +/** 사용자 수동 메타 (별점/색라벨) */ +export type ColorLabel = 'red' | 'yellow' | 'green' | 'blue' | 'purple' | null + +/** 그리드/컬링용 — 자산 + 품질 + 사용자 메타 결합 레코드 */ +export interface IndexedAsset extends AssetRecord { + focus: number | null + exposure: number | null + eyesOpen: number | null + flag: QualityFlag + /** 사용자 별점 0~5 */ + rating: number + /** 사용자 색라벨 */ + label: ColorLabel +} + +/** 컬링 필터 */ +export type QualityFilter = + | 'all' + | 'candidate' + | 'rejected' + | 'blurry' + | 'eyesClosed' + | 'badExposure' + +/** 자산 조회 옵션 */ +export interface AssetQuery { + filter: QualityFilter + /** 최소 별점 (0이면 무시) */ + ratingMin: number +} + +/** 검색 색인(임베딩) 생성 진행률 */ +export interface SearchProgress { + done: number + total: number + current: string + embedded: number +} + +/** 검색 색인 생성 요약 */ +export interface SearchSummary { + embedded: number + total: number + /** 누적 임베딩 보유 수 */ + count: number +} + +/** 검색 색인 상태 */ +export interface SearchStatus { + /** 임베딩 보유 수 */ + embedded: number + /** 색인된 전체 이미지 수 */ + totalImages: number +} + /** 촬영 날짜 (EXIF 또는 mtime 폴백) */ export interface CaptureDate { year: string // "2024" @@ -149,6 +263,10 @@ export interface RendererEvents { 'job:done': Report 'job:error': { file: string; message: string } 'settings:changed': Settings + 'index:progress': IndexProgress + 'index:done': IndexSummary + 'search:progress': SearchProgress + 'search:done': SearchSummary } export type RendererEventName = keyof RendererEvents @@ -185,6 +303,33 @@ export interface ExposedApi { get(): Promise set(patch: Partial): Promise } + /** 라이브러리 폴더(색인 대상 루트) 관리 */ + library: { + list(): Promise + /** 폴더 선택 다이얼로그 후 추가. 추가된 목록 반환(취소 시 변경 없음) */ + add(): Promise + remove(path: string): Promise + } + /** 색인(인덱싱) 제어 */ + index: { + run(): Promise + cancel(): Promise + /** 색인된 자산 목록 (최근순, 페이지네이션, 품질/별점 필터) */ + assets(offset: number, limit: number, query: AssetQuery): Promise + /** 별점(0~5) 설정 */ + setRating(assetId: number, rating: number): Promise + /** 색라벨 설정 */ + setLabel(assetId: number, label: ColorLabel): Promise + } + /** 자연어/유사 검색 (Phase 2) */ + search: { + /** 검색 색인(CLIP 임베딩) 생성 시작 */ + build(): Promise + cancel(): Promise + status(): Promise + /** 자연어 쿼리 → 유사도 상위 결과 */ + query(text: string): Promise + } /** 드롭된 File의 로컬 절대경로 반환(Electron webUtils). 경로가 없으면 빈 문자열. */ getPathForFile(file: unknown): string on(