diff --git a/package-lock.json b/package-lock.json index bd7a126..9f1bf2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@tabler/icons-react": "^3.34.1", "@types/better-sqlite3": "^7.6.13", + "@xenova/transformers": "^2.17.2", "better-sqlite3": "^12.2.0", "exif-reader": "^2.0.2", "glob": "^11.0.3", @@ -54,6 +55,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@huggingface/jinja": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", + "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.34.3", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", @@ -707,6 +717,70 @@ "node": ">= 10" } }, + "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.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "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.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1027,6 +1101,12 @@ "@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/node": { "version": "24.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", @@ -1056,6 +1136,68 @@ "@types/react": "^19.0.0" } }, + "node_modules/@xenova/transformers": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", + "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.2.2", + "onnxruntime-web": "1.14.0", + "sharp": "^0.32.0" + }, + "optionalDependencies": { + "onnxruntime-node": "1.14.0" + } + }, + "node_modules/@xenova/transformers/node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@xenova/transformers/node_modules/tar-fs": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", + "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/@xenova/transformers/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/ansi-regex": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", @@ -1118,6 +1260,84 @@ "postcss": "^8.1.0" } }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "license": "Apache-2.0" + }, + "node_modules/bare-events": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.1.tgz", + "integrity": "sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/bare-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.2.1.tgz", + "integrity": "sha512-mELROzV0IhqilFgsl1gyp48pnZsaV9xhQapHLDsvn4d4ZTfbFhcghQezl7FTEDNBcGqLUnNI3lUlm6ecrLWdFA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1442,12 +1662,24 @@ "node": ">=6" } }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/flatbuffers": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", + "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", + "license": "SEE LICENSE IN LICENSE.txt" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -1520,6 +1752,12 @@ "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/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1845,6 +2083,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", @@ -2066,6 +2310,12 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -2092,6 +2342,50 @@ "wrappy": "1" } }, + "node_modules/onnx-proto": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz", + "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==", + "license": "MIT", + "dependencies": { + "protobufjs": "^6.8.8" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", + "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz", + "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", + "license": "MIT", + "optional": true, + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "onnxruntime-common": "~1.14.0" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz", + "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^1.12.0", + "guid-typescript": "^1.0.9", + "long": "^4.0.0", + "onnx-proto": "^4.0.4", + "onnxruntime-common": "~1.14.0", + "platform": "^1.3.6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2129,6 +2423,12 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "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/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2191,6 +2491,32 @@ "node": ">=10" } }, + "node_modules/protobufjs": { + "version": "6.11.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", + "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -2440,6 +2766,19 @@ "node": ">=0.10.0" } }, + "node_modules/streamx": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", + "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2650,6 +2989,15 @@ "node": ">=6" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index ac720b4..47e987d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@tabler/icons-react": "^3.34.1", "@types/better-sqlite3": "^7.6.13", + "@xenova/transformers": "^2.17.2", "better-sqlite3": "^12.2.0", "exif-reader": "^2.0.2", "glob": "^11.0.3", diff --git a/src/app/api/caption/batch/route.ts b/src/app/api/caption/batch/route.ts new file mode 100644 index 0000000..8485991 --- /dev/null +++ b/src/app/api/caption/batch/route.ts @@ -0,0 +1,210 @@ +import { NextRequest, NextResponse } from 'next/server' +import { photoService } from '@/lib/photo-service' +import { imageClassifier } from '@/lib/image-classifier' +import { existsSync } from 'fs' + +interface BatchCaptionRequest { + directory?: string + limit?: number + offset?: number + overwrite?: boolean // Overwrite existing captions + onlyUncaptioned?: boolean // Only process photos without captions +} + +export async function POST(request: NextRequest) { + try { + const body: BatchCaptionRequest = await request.json() + + const { + directory, + limit = 10, // Process 10 photos at a time to avoid memory issues + offset = 0, + overwrite = false, + onlyUncaptioned = true + } = body + + // Initialize captioner + console.log('[BATCH CAPTION] Initializing image captioner...') + await imageClassifier.initializeCaptioner() + + // Get photos to process + console.log('[BATCH CAPTION] Getting photos to caption...') + let photos = photoService.getPhotos({ + directory, + limit, + offset, + sortBy: 'created_at', + sortOrder: 'ASC' + }) + + // Filter to only uncaptioned photos if requested + if (onlyUncaptioned && !overwrite) { + photos = photos.filter(photo => !photo.description || photo.description.trim() === '') + } + + console.log(`[BATCH CAPTION] Processing ${photos.length} photos...`) + + const results = [] + let processed = 0 + let successful = 0 + let failed = 0 + let skipped = 0 + + for (const photo of photos) { + try { + processed++ + console.log(`[BATCH CAPTION] Processing ${processed}/${photos.length}: ${photo.filename}`) + + // Skip if already has caption and not overwriting + if (photo.description && photo.description.trim() !== '' && !overwrite) { + console.log(`[BATCH CAPTION] Photo already has caption, skipping: ${photo.filename}`) + skipped++ + results.push({ + photoId: photo.id, + filename: photo.filename, + existingCaption: photo.description, + skipped: true + }) + continue + } + + // Try to get cached thumbnail first, fall back to file path + let imageSource: string | Buffer = photo.filepath + let usingThumbnail = false + const thumbnailBlob = photoService.getCachedThumbnail(photo.id, 300) // Use larger thumbnail for better captioning + + if (thumbnailBlob) { + console.log(`[BATCH CAPTION] Using cached thumbnail: ${photo.filename}`) + imageSource = thumbnailBlob + usingThumbnail = true + } else { + // Check if file exists for fallback + if (!existsSync(photo.filepath)) { + console.warn(`[BATCH CAPTION] File not found and no thumbnail: ${photo.filepath}`) + failed++ + results.push({ + photoId: photo.id, + filename: photo.filename, + error: 'File not found and no cached thumbnail' + }) + continue + } + console.log(`[BATCH CAPTION] Using original file (no thumbnail): ${photo.filename}`) + } + + // Generate caption with fallback handling + let captionResult + try { + captionResult = await imageClassifier.captionImage(imageSource) + } catch (error) { + if (usingThumbnail && existsSync(photo.filepath)) { + console.warn(`[BATCH CAPTION] Thumbnail failed for ${photo.filename}, falling back to original file:`, error) + try { + // Fallback to original file + captionResult = await imageClassifier.captionImage(photo.filepath) + console.log(`[BATCH CAPTION] Fallback to original file successful: ${photo.filename}`) + } catch (fallbackError) { + console.error(`[BATCH CAPTION] Both thumbnail and original file failed for ${photo.filename}:`, fallbackError) + throw fallbackError + } + } else { + throw error + } + } + + // Update photo with generated caption + const updatedPhoto = photoService.updatePhoto(photo.id, { + description: captionResult.caption + }) + + if (updatedPhoto) { + successful++ + results.push({ + photoId: photo.id, + filename: photo.filename, + previousCaption: photo.description || null, + newCaption: captionResult.caption, + confidence: captionResult.confidence + }) + + console.log(`[BATCH CAPTION] Generated caption for ${photo.filename}: "${captionResult.caption}"`) + } else { + failed++ + results.push({ + photoId: photo.id, + filename: photo.filename, + error: 'Failed to update photo with caption' + }) + } + + // Log progress every 5 photos + if (processed % 5 === 0) { + console.log(`[BATCH CAPTION] Progress: ${processed}/${photos.length} (${successful} successful, ${skipped} skipped, ${failed} failed)`) + } + + } catch (error) { + console.error(`[BATCH CAPTION] Error processing ${photo.filename}:`, error) + failed++ + + results.push({ + photoId: photo.id, + filename: photo.filename, + error: error instanceof Error ? error.message : 'Unknown error' + }) + } + } + + console.log(`[BATCH CAPTION] Completed: ${successful} successful, ${skipped} skipped, ${failed} failed`) + + return NextResponse.json({ + summary: { + processed, + successful, + skipped, + failed, + overwrite, + onlyUncaptioned + }, + results: results.slice(0, 20), // Limit results in response for performance + hasMore: photos.length === limit // Indicate if there might be more photos to process + }) + + } catch (error) { + console.error('[BATCH CAPTION] Error:', error) + return NextResponse.json( + { error: 'Failed to batch caption images' }, + { status: 500 } + ) + } +} + +// Get captioning status +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const directory = searchParams.get('directory') || undefined + + // Get total photos + const allPhotos = photoService.getPhotos({ directory }) + + // Get photos with captions + const photosWithCaptions = allPhotos.filter(photo => + photo.description && photo.description.trim() !== '' + ) + + return NextResponse.json({ + total: allPhotos.length, + captioned: photosWithCaptions.length, + uncaptioned: allPhotos.length - photosWithCaptions.length, + captionedPercentage: Math.round((photosWithCaptions.length / allPhotos.length) * 100), + captionerReady: imageClassifier.isCaptionerReady() + }) + + } catch (error) { + console.error('[BATCH CAPTION STATUS] Error:', error) + return NextResponse.json( + { error: 'Failed to get captioning status' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/caption/route.ts b/src/app/api/caption/route.ts new file mode 100644 index 0000000..e8d9381 --- /dev/null +++ b/src/app/api/caption/route.ts @@ -0,0 +1,179 @@ +import { NextRequest, NextResponse } from 'next/server' +import { photoService } from '@/lib/photo-service' +import { imageClassifier } from '@/lib/image-classifier' +import { existsSync } from 'fs' + +interface CaptionRequest { + photoId?: string + photoIds?: string[] + overwrite?: boolean // Overwrite existing captions +} + +export async function POST(request: NextRequest) { + try { + const body: CaptionRequest = await request.json() + + if (!body.photoId && !body.photoIds) { + return NextResponse.json( + { error: 'Either photoId or photoIds must be provided' }, + { status: 400 } + ) + } + + // Initialize captioner if not already done + console.log('[CAPTION API] Initializing image captioner...') + await imageClassifier.initializeCaptioner() + + const photoIds = body.photoId ? [body.photoId] : body.photoIds! + const overwrite = body.overwrite || false + + const results = [] + + for (const photoId of photoIds) { + try { + console.log(`[CAPTION API] Processing photo: ${photoId}`) + + // Get photo from database + const photo = photoService.getPhoto(photoId) + if (!photo) { + console.warn(`[CAPTION API] Photo not found: ${photoId}`) + results.push({ + photoId, + error: 'Photo not found', + success: false + }) + continue + } + + // Skip if already has caption and not overwriting + if (photo.description && !overwrite) { + console.log(`[CAPTION API] Photo already has caption, skipping: ${photo.filename}`) + results.push({ + photoId, + filename: photo.filename, + existingCaption: photo.description, + skipped: true, + success: true + }) + continue + } + + // Try to get cached thumbnail first, fall back to file path + let imageSource: string | Buffer = photo.filepath + const thumbnailBlob = photoService.getCachedThumbnail(photoId, 300) // Use larger thumbnail for better captioning + + if (thumbnailBlob) { + console.log(`[CAPTION API] Using cached thumbnail for captioning: ${photo.filename}`) + imageSource = thumbnailBlob + } else { + // Check if file exists for fallback + if (!existsSync(photo.filepath)) { + console.warn(`[CAPTION API] Photo file not found and no thumbnail: ${photo.filepath}`) + results.push({ + photoId, + error: 'Photo file not found and no cached thumbnail', + success: false, + filepath: photo.filepath + }) + continue + } + console.log(`[CAPTION API] Using original file for captioning (no thumbnail): ${photo.filepath}`) + } + + // Generate caption with fallback handling + let captionResult + try { + captionResult = await imageClassifier.captionImage(imageSource) + } catch (error) { + if (thumbnailBlob && existsSync(photo.filepath)) { + console.warn(`[CAPTION API] Thumbnail failed for ${photo.filename}, falling back to original file:`, error) + try { + // Fallback to original file + captionResult = await imageClassifier.captionImage(photo.filepath) + console.log(`[CAPTION API] Fallback to original file successful: ${photo.filename}`) + } catch (fallbackError) { + console.error(`[CAPTION API] Both thumbnail and original file failed for ${photo.filename}:`, fallbackError) + throw fallbackError + } + } else { + throw error + } + } + + // Update photo with generated caption + const updatedPhoto = photoService.updatePhoto(photoId, { + description: captionResult.caption + }) + + if (updatedPhoto) { + results.push({ + photoId, + filename: photo.filename, + previousCaption: photo.description || null, + newCaption: captionResult.caption, + confidence: captionResult.confidence, + success: true + }) + + console.log(`[CAPTION API] Generated caption for ${photo.filename}: "${captionResult.caption}"`) + } else { + results.push({ + photoId, + error: 'Failed to update photo with caption', + success: false + }) + } + + } catch (error) { + console.error(`[CAPTION API] Error processing photo ${photoId}:`, error) + results.push({ + photoId, + error: error instanceof Error ? error.message : 'Unknown error', + success: false + }) + } + } + + const successful = results.filter(r => r.success).length + const failed = results.filter(r => !r.success).length + const skipped = results.filter(r => 'skipped' in r && r.skipped).length + + console.log(`[CAPTION API] Completed: ${successful} successful, ${skipped} skipped, ${failed} failed`) + + return NextResponse.json({ + results, + summary: { + total: photoIds.length, + successful, + skipped, + failed, + overwrite + } + }) + + } catch (error) { + console.error('[CAPTION API] Error:', error) + return NextResponse.json( + { error: 'Failed to generate captions' }, + { status: 500 } + ) + } +} + +// Health check endpoint +export async function GET() { + try { + const isReady = imageClassifier.isCaptionerReady() + + return NextResponse.json({ + status: 'ok', + captionerReady: isReady, + message: isReady ? 'Captioner ready' : 'Captioner not initialized' + }) + } catch (error) { + return NextResponse.json( + { error: 'Service unavailable' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/classify/batch/route.ts b/src/app/api/classify/batch/route.ts new file mode 100644 index 0000000..e8bf3aa --- /dev/null +++ b/src/app/api/classify/batch/route.ts @@ -0,0 +1,268 @@ +import { NextRequest, NextResponse } from 'next/server' +import { photoService } from '@/lib/photo-service' +import { imageClassifier } from '@/lib/image-classifier' +import { existsSync } from 'fs' + +interface BatchClassifyRequest { + directory?: string + limit?: number + offset?: number + minConfidence?: number + onlyUntagged?: boolean + dryRun?: boolean + customLabels?: string[] + maxResults?: number + comprehensive?: boolean // Use both ViT + CLIP for more tags + categories?: { + general?: string[] + time?: string[] + weather?: string[] + subjects?: string[] + locations?: string[] + style?: string[] + seasons?: string[] + } +} + +export async function POST(request: NextRequest) { + try { + const body: BatchClassifyRequest = await request.json() + + const { + directory, + limit = 50, // Process 50 photos at a time to avoid memory issues + offset = 0, + minConfidence = 0.3, + onlyUntagged = false, + dryRun = false, + customLabels, + maxResults, + comprehensive = false, + categories + } = body + + // Build classifier configuration for this batch + const classifierConfig = { + minConfidence: comprehensive ? 0.05 : minConfidence, // Lower threshold for comprehensive mode + maxResults: comprehensive ? 25 : (maxResults || 10), // More results for comprehensive mode + customLabels, + categories + } + + // Initialize classifier(s) + if (comprehensive) { + console.log('[BATCH CLASSIFY] Initializing comprehensive classifiers (ViT + CLIP)...') + await Promise.all([ + imageClassifier.initialize(), + imageClassifier.initializeZeroShot() + ]) + } else { + console.log('[BATCH CLASSIFY] Initializing image classifier...') + await imageClassifier.initialize() + } + + // Get photos to process + console.log('[BATCH CLASSIFY] Getting photos to classify...') + let photos = photoService.getPhotos({ + directory, + limit, + offset, + sortBy: 'created_at', + sortOrder: 'ASC' + }) + + // Filter to only untagged photos if requested + if (onlyUntagged) { + photos = photos.filter(photo => { + const tags = photoService.getPhotoTags(photo.id) + return tags.length === 0 + }) + } + + console.log(`[BATCH CLASSIFY] Processing ${photos.length} photos...`) + + const results = [] + let processed = 0 + let successful = 0 + let failed = 0 + let totalTagsAdded = 0 + + for (const photo of photos) { + try { + processed++ + console.log(`[BATCH CLASSIFY] Processing ${processed}/${photos.length}: ${photo.filename}`) + + // Try to get cached thumbnail first, fall back to file path + let imageSource: string | Buffer = photo.filepath + let usingThumbnail = false + const thumbnailBlob = photoService.getCachedThumbnail(photo.id, 200) // Use 200px thumbnail + + if (thumbnailBlob) { + console.log(`[BATCH CLASSIFY] Using cached thumbnail: ${photo.filename}`) + imageSource = thumbnailBlob + usingThumbnail = true + } else { + // Check if file exists for fallback + if (!existsSync(photo.filepath)) { + console.warn(`[BATCH CLASSIFY] File not found and no thumbnail: ${photo.filepath}`) + failed++ + continue + } + console.log(`[BATCH CLASSIFY] Using original file (no thumbnail): ${photo.filename}`) + } + + // Get existing tags if any + const existingTags = photoService.getPhotoTags(photo.id) + + // Classify the image with fallback handling + let classifications + try { + if (comprehensive) { + const comprehensiveResult = await imageClassifier.classifyImageComprehensive(imageSource, classifierConfig) + classifications = comprehensiveResult.combinedResults + console.log(`[BATCH CLASSIFY] Comprehensive: ${comprehensiveResult.objectClassification.length} object + ${comprehensiveResult.styleClassification.length} style tags`) + } else { + classifications = await imageClassifier.classifyImage(imageSource, customLabels, classifierConfig) + } + } catch (error) { + if (usingThumbnail && existsSync(photo.filepath)) { + console.warn(`[BATCH CLASSIFY] Thumbnail failed for ${photo.filename}, falling back to original file:`, error) + try { + // Fallback to original file + if (comprehensive) { + const comprehensiveResult = await imageClassifier.classifyImageComprehensive(photo.filepath, classifierConfig) + classifications = comprehensiveResult.combinedResults + } else { + classifications = await imageClassifier.classifyImage(photo.filepath, customLabels, classifierConfig) + } + console.log(`[BATCH CLASSIFY] Fallback to original file successful: ${photo.filename}`) + } catch (fallbackError) { + console.error(`[BATCH CLASSIFY] Both thumbnail and original file failed for ${photo.filename}:`, fallbackError) + throw fallbackError + } + } else { + throw error + } + } + + // Filter by confidence and exclude existing tags + // Note: classifications are already filtered by confidence in classifyImage + const existingTagNames = existingTags.map(tag => tag.name.toLowerCase()) + const newTags = classifications + .filter(result => !existingTagNames.includes(result.label.toLowerCase())) + .map(result => ({ name: result.label, confidence: result.score })) + + if (dryRun) { + // Just log what would be added + results.push({ + photoId: photo.id, + filename: photo.filename, + existingTags: existingTags.length, + newClassifications: newTags, + wouldAdd: newTags.length + }) + } else { + // Actually add the tags + const addedCount = photoService.addPhotoTags(photo.id, newTags) + totalTagsAdded += addedCount + + results.push({ + photoId: photo.id, + filename: photo.filename, + existingTags: existingTags.length, + classificationsFound: classifications.length, + newTagsAdded: addedCount, + topTags: newTags.slice(0, 5) // Show top 5 tags for logging + }) + } + + successful++ + + // Log progress every 10 photos + if (processed % 10 === 0) { + console.log(`[BATCH CLASSIFY] Progress: ${processed}/${photos.length} (${successful} successful, ${failed} failed)`) + } + + } catch (error) { + console.error(`[BATCH CLASSIFY] Error processing ${photo.filename}:`, error) + failed++ + + results.push({ + photoId: photo.id, + filename: photo.filename, + error: error instanceof Error ? error.message : 'Unknown error' + }) + } + } + + console.log(`[BATCH CLASSIFY] Completed: ${successful} successful, ${failed} failed, ${totalTagsAdded} total tags added`) + + return NextResponse.json({ + summary: { + processed, + successful, + failed, + totalTagsAdded, + config: classifierConfig, + dryRun, + onlyUntagged + }, + results: dryRun ? results : results.slice(0, 10), // Limit results in response for performance + hasMore: photos.length === limit // Indicate if there might be more photos to process + }) + + } catch (error) { + console.error('[BATCH CLASSIFY] Error:', error) + return NextResponse.json( + { error: 'Failed to batch classify images' }, + { status: 500 } + ) + } +} + +// Get classification status +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const directory = searchParams.get('directory') || undefined + + // Get total photos + const allPhotos = photoService.getPhotos({ directory }) + + // Get photos with tags + const photosWithTags = allPhotos.filter(photo => { + const tags = photoService.getPhotoTags(photo.id) + return tags.length > 0 + }) + + // Get most common tags + const tagCounts: { [tagName: string]: number } = {} + allPhotos.forEach(photo => { + const tags = photoService.getPhotoTags(photo.id) + tags.forEach(tag => { + tagCounts[tag.name] = (tagCounts[tag.name] || 0) + 1 + }) + }) + + const topTags = Object.entries(tagCounts) + .sort(([,a], [,b]) => b - a) + .slice(0, 20) + .map(([name, count]) => ({ name, count })) + + return NextResponse.json({ + total: allPhotos.length, + tagged: photosWithTags.length, + untagged: allPhotos.length - photosWithTags.length, + taggedPercentage: Math.round((photosWithTags.length / allPhotos.length) * 100), + topTags, + classifierReady: imageClassifier.isReady() + }) + + } catch (error) { + console.error('[BATCH CLASSIFY STATUS] Error:', error) + return NextResponse.json( + { error: 'Failed to get classification status' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/classify/comprehensive/route.ts b/src/app/api/classify/comprehensive/route.ts new file mode 100644 index 0000000..77bd1be --- /dev/null +++ b/src/app/api/classify/comprehensive/route.ts @@ -0,0 +1,171 @@ +import { NextRequest, NextResponse } from 'next/server' +import { photoService } from '@/lib/photo-service' +import { imageClassifier } from '@/lib/image-classifier' +import { existsSync } from 'fs' + +interface ComprehensiveClassifyRequest { + photoId?: string + photoIds?: string[] + minConfidence?: number + maxResults?: number + dryRun?: boolean +} + +export async function POST(request: NextRequest) { + try { + const body: ComprehensiveClassifyRequest = await request.json() + + if (!body.photoId && !body.photoIds) { + return NextResponse.json( + { error: 'Either photoId or photoIds must be provided' }, + { status: 400 } + ) + } + + // Initialize both classifiers + console.log('[COMPREHENSIVE API] Initializing classifiers...') + await Promise.all([ + imageClassifier.initialize(), + imageClassifier.initializeZeroShot() + ]) + + const photoIds = body.photoId ? [body.photoId] : body.photoIds! + const dryRun = body.dryRun || false + + const config = { + minConfidence: body.minConfidence || 0.05, // Lower threshold for more tags + maxResults: body.maxResults || 25 // More results + } + + const results = [] + + for (const photoId of photoIds) { + try { + console.log(`[COMPREHENSIVE API] Processing photo: ${photoId}`) + + // Get photo from database + const photo = photoService.getPhoto(photoId) + if (!photo) { + console.warn(`[COMPREHENSIVE API] Photo not found: ${photoId}`) + results.push({ + photoId, + error: 'Photo not found', + success: false + }) + continue + } + + // Try to get cached thumbnail first, fall back to file path + let imageSource: string | Buffer = photo.filepath + const thumbnailBlob = photoService.getCachedThumbnail(photoId, 300) // Larger thumbnail for better analysis + + if (thumbnailBlob) { + console.log(`[COMPREHENSIVE API] Using cached thumbnail: ${photo.filename}`) + imageSource = thumbnailBlob + } else { + if (!existsSync(photo.filepath)) { + console.warn(`[COMPREHENSIVE API] Photo file not found: ${photo.filepath}`) + results.push({ + photoId, + error: 'Photo file not found and no cached thumbnail', + success: false + }) + continue + } + console.log(`[COMPREHENSIVE API] Using original file: ${photo.filepath}`) + } + + // Run comprehensive classification + const comprehensive = await imageClassifier.classifyImageComprehensive(imageSource, config) + + if (dryRun) { + results.push({ + photoId, + filename: photo.filename, + objectTags: comprehensive.objectClassification, + styleTags: comprehensive.styleClassification, + combinedTags: comprehensive.combinedResults, + totalTags: comprehensive.combinedResults.length, + success: true, + dryRun: true + }) + } else { + // Save all combined tags to database + const tagsToAdd = comprehensive.combinedResults.map(result => ({ + name: result.label, + confidence: result.score + })) + + const addedCount = photoService.addPhotoTags(photoId, tagsToAdd) + + results.push({ + photoId, + filename: photo.filename, + objectTagsFound: comprehensive.objectClassification.length, + styleTagsFound: comprehensive.styleClassification.length, + totalTagsAdded: addedCount, + topTags: comprehensive.combinedResults.slice(0, 10), + success: true + }) + + console.log(`[COMPREHENSIVE API] Added ${addedCount} comprehensive tags to ${photo.filename}`) + } + + } catch (error) { + console.error(`[COMPREHENSIVE API] Error processing photo ${photoId}:`, error) + results.push({ + photoId, + error: error instanceof Error ? error.message : 'Unknown error', + success: false + }) + } + } + + const successful = results.filter(r => r.success).length + const failed = results.filter(r => !r.success).length + + console.log(`[COMPREHENSIVE API] Completed: ${successful} successful, ${failed} failed`) + + return NextResponse.json({ + results, + summary: { + total: photoIds.length, + successful, + failed, + config, + dryRun, + note: 'Uses both ViT (objects) and CLIP (style/artistic concepts) for comprehensive tagging' + } + }) + + } catch (error) { + console.error('[COMPREHENSIVE API] Error:', error) + return NextResponse.json( + { error: 'Failed to run comprehensive classification' }, + { status: 500 } + ) + } +} + +// Health check +export async function GET() { + try { + const vitReady = imageClassifier.isReady() + const clipReady = imageClassifier.isCaptionerReady() // We'll add isZeroShotReady later + + return NextResponse.json({ + status: 'ok', + models: { + vit: vitReady ? 'ready' : 'not initialized', + clip: 'initializing on first use' + }, + description: 'Comprehensive classification using ViT for objects + CLIP for style/artistic concepts', + expectedTags: '15-25 tags per image covering objects, photography styles, lighting, mood, composition' + }) + } catch (error) { + return NextResponse.json( + { error: 'Service unavailable' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/classify/config/route.ts b/src/app/api/classify/config/route.ts new file mode 100644 index 0000000..8e67c2f --- /dev/null +++ b/src/app/api/classify/config/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server' +import { imageClassifier, ClassifierConfig } from '@/lib/image-classifier' + +// Get current classifier configuration +export async function GET() { + try { + const config = imageClassifier.getConfig() + + return NextResponse.json({ + config, + isReady: imageClassifier.isReady(), + modelType: 'standard-image-classification', + modelName: 'Vision Transformer (ViT)', + note: 'Uses ImageNet classes - custom labels are not supported with standard image classification', + supportedSettings: { + minConfidence: 'Minimum confidence threshold (0-1)', + maxResults: 'Maximum number of results (1-50)' + }, + imageNetInfo: { + totalClasses: 1000, + description: 'ImageNet dataset with 1000 object classes including animals, objects, vehicles, nature, food, and more' + } + }) + } catch (error) { + console.error('[CLASSIFIER CONFIG GET] Error:', error) + return NextResponse.json( + { error: 'Failed to get classifier configuration' }, + { status: 500 } + ) + } +} + +// Update classifier configuration +export async function POST(request: NextRequest) { + try { + const body: Partial = await request.json() + + // Validate configuration + if (body.minConfidence !== undefined) { + if (typeof body.minConfidence !== 'number' || body.minConfidence < 0 || body.minConfidence > 1) { + return NextResponse.json( + { error: 'minConfidence must be a number between 0 and 1' }, + { status: 400 } + ) + } + } + + if (body.maxResults !== undefined) { + if (typeof body.maxResults !== 'number' || body.maxResults < 1 || body.maxResults > 50) { + return NextResponse.json( + { error: 'maxResults must be a number between 1 and 50' }, + { status: 400 } + ) + } + } + + if (body.customLabels !== undefined) { + return NextResponse.json( + { error: 'customLabels are not supported with standard image classification. The model uses ImageNet classes.' }, + { status: 400 } + ) + } + + if (body.categories !== undefined) { + return NextResponse.json( + { error: 'categories are not supported with standard image classification. The model uses ImageNet classes.' }, + { status: 400 } + ) + } + + // Update configuration + imageClassifier.updateConfig(body) + const updatedConfig = imageClassifier.getConfig() + + return NextResponse.json({ + success: true, + config: updatedConfig, + message: 'Classifier configuration updated successfully' + }) + + } catch (error) { + console.error('[CLASSIFIER CONFIG POST] Error:', error) + return NextResponse.json( + { error: 'Failed to update classifier configuration' }, + { status: 500 } + ) + } +} + +// Reset classifier configuration to defaults +export async function DELETE() { + try { + imageClassifier.resetConfig() + const config = imageClassifier.getConfig() + + return NextResponse.json({ + success: true, + config, + message: 'Classifier configuration reset to defaults' + }) + + } catch (error) { + console.error('[CLASSIFIER CONFIG DELETE] Error:', error) + return NextResponse.json( + { error: 'Failed to reset classifier configuration' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/classify/route.ts b/src/app/api/classify/route.ts new file mode 100644 index 0000000..5fe7452 --- /dev/null +++ b/src/app/api/classify/route.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from 'next/server' +import { photoService } from '@/lib/photo-service' +import { imageClassifier } from '@/lib/image-classifier' +import { existsSync } from 'fs' + +interface ClassifyRequest { + photoId?: string + photoIds?: string[] + minConfidence?: number + dryRun?: boolean // Just return classifications without saving + customLabels?: string[] + maxResults?: number + categories?: { + general?: string[] + time?: string[] + weather?: string[] + subjects?: string[] + locations?: string[] + style?: string[] + seasons?: string[] + } +} + +export async function POST(request: NextRequest) { + try { + const body: ClassifyRequest = await request.json() + + if (!body.photoId && !body.photoIds) { + return NextResponse.json( + { error: 'Either photoId or photoIds must be provided' }, + { status: 400 } + ) + } + + // Initialize classifier if not already done + console.log('[CLASSIFY API] Initializing image classifier...') + await imageClassifier.initialize() + + const photoIds = body.photoId ? [body.photoId] : body.photoIds! + const minConfidence = body.minConfidence || 0.3 + const dryRun = body.dryRun || false + + // Build classifier configuration + const classifierConfig = { + minConfidence, + maxResults: body.maxResults, + customLabels: body.customLabels, + categories: body.categories + } + + const results = [] + + for (const photoId of photoIds) { + try { + console.log(`[CLASSIFY API] Processing photo: ${photoId}`) + + // Get photo from database + const photo = photoService.getPhoto(photoId) + if (!photo) { + console.warn(`[CLASSIFY API] Photo not found: ${photoId}`) + results.push({ + photoId, + error: 'Photo not found', + success: false + }) + continue + } + + // Try to get cached thumbnail first, fall back to file path + let imageSource: string | Buffer = photo.filepath + const thumbnailBlob = photoService.getCachedThumbnail(photoId, 200) // Use 200px thumbnail for classification + + if (thumbnailBlob) { + console.log(`[CLASSIFY API] Using cached thumbnail for classification: ${photo.filename}`) + imageSource = thumbnailBlob + } else { + // Check if file exists for fallback + if (!existsSync(photo.filepath)) { + console.warn(`[CLASSIFY API] Photo file not found and no thumbnail: ${photo.filepath}`) + results.push({ + photoId, + error: 'Photo file not found and no cached thumbnail', + success: false, + filepath: photo.filepath + }) + continue + } + console.log(`[CLASSIFY API] Using original file for classification (no thumbnail): ${photo.filepath}`) + } + + // Classify the image with configuration + const sourceDesc = thumbnailBlob ? `thumbnail for ${photo.filename}` : photo.filepath + console.log(`[CLASSIFY API] Classifying image: ${sourceDesc}`) + const classifications = await imageClassifier.classifyImage(imageSource, body.customLabels, classifierConfig) + + // Filter by confidence (already done by classifyImage, but keep for backward compatibility) + const filteredTags = classifications + .map(result => ({ name: result.label, confidence: result.score })) + + if (dryRun) { + // Just return the classifications without saving + results.push({ + photoId, + filename: photo.filename, + classifications: filteredTags, + success: true, + dryRun: true + }) + } else { + // Save tags to database + const addedCount = photoService.addPhotoTags(photoId, filteredTags) + + results.push({ + photoId, + filename: photo.filename, + classificationsFound: classifications.length, + tagsAdded: addedCount, + tags: filteredTags, + success: true + }) + + console.log(`[CLASSIFY API] Added ${addedCount} tags to photo: ${photo.filename}`) + } + + } catch (error) { + console.error(`[CLASSIFY API] Error processing photo ${photoId}:`, error) + results.push({ + photoId, + error: error instanceof Error ? error.message : 'Unknown error', + success: false + }) + } + } + + const successful = results.filter(r => r.success).length + const failed = results.filter(r => !r.success).length + + console.log(`[CLASSIFY API] Completed: ${successful} successful, ${failed} failed`) + + return NextResponse.json({ + results, + summary: { + total: photoIds.length, + successful, + failed, + config: classifierConfig, + dryRun + } + }) + + } catch (error) { + console.error('[CLASSIFY API] Error:', error) + return NextResponse.json( + { error: 'Failed to classify images' }, + { status: 500 } + ) + } +} + +// Health check endpoint +export async function GET() { + try { + const isReady = imageClassifier.isReady() + + return NextResponse.json({ + status: 'ok', + classifierReady: isReady, + message: isReady ? 'Classifier ready' : 'Classifier not initialized' + }) + } catch (error) { + return NextResponse.json( + { error: 'Service unavailable' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/photos/[id]/tags/route.ts b/src/app/api/photos/[id]/tags/route.ts new file mode 100644 index 0000000..7a12d25 --- /dev/null +++ b/src/app/api/photos/[id]/tags/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server' +import { photoService } from '@/lib/photo-service' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + + const tags = photoService.getPhotoTags(id) + + return NextResponse.json(tags) + } catch (error) { + console.error('Error fetching photo tags:', error) + return NextResponse.json( + { error: 'Failed to fetch photo tags' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/tags/clear/route.ts b/src/app/api/tags/clear/route.ts new file mode 100644 index 0000000..2f086a1 --- /dev/null +++ b/src/app/api/tags/clear/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from 'next/server' +import { photoService } from '@/lib/photo-service' + +interface ClearTagsRequest { + directory?: string + confirmClear?: boolean // Safety flag to prevent accidental clears +} + +export async function POST(request: NextRequest) { + try { + const body: ClearTagsRequest = await request.json() + + const { directory, confirmClear = false } = body + + // Safety check - require explicit confirmation + if (!confirmClear) { + return NextResponse.json( + { error: 'confirmClear must be true to proceed with clearing tags' }, + { status: 400 } + ) + } + + console.log('[CLEAR TAGS] Starting tag clearing process...') + + // Get photos to clear tags from + let photos = photoService.getPhotos({ directory }) + + if (photos.length === 0) { + return NextResponse.json({ + message: 'No photos found to clear tags from', + cleared: 0, + directory: directory || 'all' + }) + } + + console.log(`[CLEAR TAGS] Found ${photos.length} photos to process`) + + let totalTagsCleared = 0 + let photosProcessed = 0 + const results = [] + + for (const photo of photos) { + try { + // Get current tags for this photo + const currentTags = photoService.getPhotoTags(photo.id) + const tagCount = currentTags.length + + if (tagCount > 0) { + // Clear all tags for this photo + const cleared = photoService.clearPhotoTags(photo.id) + totalTagsCleared += cleared + + results.push({ + photoId: photo.id, + filename: photo.filename, + tagsCleared: cleared, + success: true + }) + + console.log(`[CLEAR TAGS] Cleared ${cleared} tags from ${photo.filename}`) + } else { + results.push({ + photoId: photo.id, + filename: photo.filename, + tagsCleared: 0, + success: true, + note: 'No tags to clear' + }) + } + + photosProcessed++ + + // Log progress every 100 photos + if (photosProcessed % 100 === 0) { + console.log(`[CLEAR TAGS] Progress: ${photosProcessed}/${photos.length} photos processed, ${totalTagsCleared} tags cleared`) + } + + } catch (error) { + console.error(`[CLEAR TAGS] Error processing photo ${photo.filename}:`, error) + results.push({ + photoId: photo.id, + filename: photo.filename, + error: error instanceof Error ? error.message : 'Unknown error', + success: false + }) + } + } + + console.log(`[CLEAR TAGS] Completed: ${photosProcessed} photos processed, ${totalTagsCleared} total tags cleared`) + + return NextResponse.json({ + summary: { + photosProcessed, + totalTagsCleared, + directory: directory || 'all photos', + success: true + }, + results: results.slice(0, 20), // Limit results for performance + message: `Successfully cleared ${totalTagsCleared} tags from ${photosProcessed} photos` + }) + + } catch (error) { + console.error('[CLEAR TAGS] Error:', error) + return NextResponse.json( + { error: 'Failed to clear tags' }, + { status: 500 } + ) + } +} + +// Get count of tags that would be cleared (for confirmation) +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const directory = searchParams.get('directory') || undefined + + // Get photos + const photos = photoService.getPhotos({ directory }) + + let totalTags = 0 + let photosWithTags = 0 + + photos.forEach(photo => { + const tags = photoService.getPhotoTags(photo.id) + if (tags.length > 0) { + totalTags += tags.length + photosWithTags++ + } + }) + + return NextResponse.json({ + total: photos.length, + photosWithTags, + totalTags, + directory: directory || 'all photos', + warning: 'This operation will permanently remove all tags. Use POST with confirmClear: true to proceed.' + }) + + } catch (error) { + console.error('[CLEAR TAGS STATUS] Error:', error) + return NextResponse.json( + { error: 'Failed to get tag clear status' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/components/ImageModal.tsx b/src/components/ImageModal.tsx index e6cbec3..9d97288 100644 --- a/src/components/ImageModal.tsx +++ b/src/components/ImageModal.tsx @@ -22,6 +22,7 @@ export default function ImageModal({ photo, isOpen, onClose, photos = [], onNavi const [isDragging, setIsDragging] = useState(false) const [dragStart, setDragStart] = useState({ x: 0, y: 0 }) const [rotation, setRotation] = useState(0) + const [tags, setTags] = useState>([]) const imageRef = useRef(null) const containerRef = useRef(null) @@ -44,6 +45,23 @@ export default function ImageModal({ photo, isOpen, onClose, photos = [], onNavi setZoom(1) setPosition({ x: 0, y: 0 }) setRotation(0) + + // Fetch tags for this photo + const fetchTags = async () => { + try { + const response = await fetch(`/api/photos/${photo.id}/tags`) + if (response.ok) { + const photoTags = await response.json() + setTags(photoTags) + } + } catch (error) { + console.warn('Failed to fetch tags for photo:', photo.id) + setTags([]) + } + } + fetchTags() + } else { + setTags([]) } }, [photo]) @@ -347,6 +365,33 @@ export default function ImageModal({ photo, isOpen, onClose, photos = [], onNavi )} + + {/* Tag pills */} + {tags.length > 0 && ( +
+
+ {tags.map((tag) => ( + + {tag.name} + + ))} +
+
+ )} + + {/* AI-generated caption */} + {photo.description && ( +
+
+ AI: + {photo.description} +
+
+ )} {/* Controls */} diff --git a/src/components/PhotoGrid.tsx b/src/components/PhotoGrid.tsx index 83f30d5..ab63d5a 100644 --- a/src/components/PhotoGrid.tsx +++ b/src/components/PhotoGrid.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react' import PhotoThumbnail from './PhotoThumbnail' import ImageModal from './ImageModal' import { Photo } from '@/types/photo' -import { IconPhoto, IconFilter, IconSearch, IconSortAscending, IconSortDescending } from '@tabler/icons-react' +import { IconPhoto, IconFilter, IconSearch, IconSortAscending, IconSortDescending, IconBrain, IconMessage, IconTrash } from '@tabler/icons-react' interface PhotoGridProps { directoryPath?: string @@ -35,6 +35,26 @@ export default function PhotoGrid({ limit: 20, offset: 0 }) + const [isClassifying, setIsClassifying] = useState(false) + const [classificationStatus, setClassificationStatus] = useState(null) + const [classificationProgress, setClassificationProgress] = useState<{ + total: number + processed: number + successful: number + failed: number + totalTagsAdded: number + } | null>(null) + const [isCaptioning, setIsCaptioning] = useState(false) + const [captionProgress, setCaptionProgress] = useState<{ + total: number + processed: number + successful: number + failed: number + skipped: number + } | null>(null) + const [captionStatus, setCaptionStatus] = useState(null) + const [isClearing, setIsClearing] = useState(false) + const [clearStatus, setClearStatus] = useState(null) const fetchPhotos = async (reset = true) => { try { @@ -175,6 +195,342 @@ export default function PhotoGrid({ setPagination(prev => ({ ...prev, currentPage: 1, hasMore: false, offset: 0 })) } + // Classification functions + const handleBatchClassify = async () => { + if (isClassifying) return + + setIsClassifying(true) + setClassificationProgress(null) + + try { + console.log('[PHOTO GRID] Starting full database batch classification...') + + // First, get the total count of untagged photos + const statusResponse = await fetch('/api/classify/batch') + if (!statusResponse.ok) { + throw new Error('Failed to get classification status') + } + const status = await statusResponse.json() + const totalUntagged = status.untagged + + if (totalUntagged === 0) { + alert('All photos are already tagged!') + return + } + + console.log(`[PHOTO GRID] Found ${totalUntagged} untagged photos to process`) + + // Initialize progress tracking + setClassificationProgress({ + total: totalUntagged, + processed: 0, + successful: 0, + failed: 0, + totalTagsAdded: 0 + }) + + const batchSize = 5 // Process 5 photos at a time + let offset = 0 + let totalProcessed = 0 + let totalSuccessful = 0 + let totalFailed = 0 + let totalTagsAdded = 0 + + // Process photos in batches + while (totalProcessed < totalUntagged) { + console.log(`[PHOTO GRID] Processing batch: offset=${offset}, batchSize=${batchSize}`) + + const response = await fetch('/api/classify/batch', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + limit: batchSize, + offset: offset, + minConfidence: 0.3, + onlyUntagged: true + }), + }) + + if (!response.ok) { + throw new Error('Failed to classify batch') + } + + const result = await response.json() + console.log(`[PHOTO GRID] Batch result:`, result.summary) + + // Update totals + totalProcessed += result.summary.processed + totalSuccessful += result.summary.successful + totalFailed += result.summary.failed + totalTagsAdded += result.summary.totalTagsAdded + + // Update progress + setClassificationProgress({ + total: totalUntagged, + processed: totalProcessed, + successful: totalSuccessful, + failed: totalFailed, + totalTagsAdded + }) + + // If no more photos to process, break + if (!result.hasMore || result.summary.processed === 0) { + console.log('[PHOTO GRID] No more photos to process') + break + } + + offset += result.summary.processed + + // Small delay between batches to prevent overwhelming the system + await new Promise(resolve => setTimeout(resolve, 500)) + } + + console.log(`[PHOTO GRID] Classification complete: ${totalSuccessful} successful, ${totalFailed} failed, ${totalTagsAdded} total tags added`) + + alert(`Classification complete!\n${totalSuccessful} photos processed\n${totalTagsAdded} tags added\n${totalFailed > 0 ? `${totalFailed} failed` : ''}`) + + // Refresh photos to show new tags + fetchPhotos() + + // Refresh classification status + fetchClassificationStatus() + + } catch (error) { + console.error('[PHOTO GRID] Classification error:', error) + alert('Failed to classify photos. Check console for details.') + } finally { + setIsClassifying(false) + setClassificationProgress(null) + } + } + + const fetchClassificationStatus = async () => { + try { + const response = await fetch(`/api/classify/batch${directoryPath ? `?directory=${encodeURIComponent(directoryPath)}` : ''}`) + if (response.ok) { + const status = await response.json() + setClassificationStatus(status) + } + } catch (error) { + console.warn('Failed to fetch classification status:', error) + } + } + + const fetchCaptionStatus = async () => { + try { + const response = await fetch(`/api/caption/batch${directoryPath ? `?directory=${encodeURIComponent(directoryPath)}` : ''}`) + if (response.ok) { + const status = await response.json() + setCaptionStatus(status) + } + } catch (error) { + console.warn('Failed to fetch caption status:', error) + } + } + + const fetchClearStatus = async () => { + try { + const response = await fetch(`/api/tags/clear${directoryPath ? `?directory=${encodeURIComponent(directoryPath)}` : ''}`) + if (response.ok) { + const status = await response.json() + setClearStatus(status) + } + } catch (error) { + console.warn('Failed to fetch clear status:', error) + } + } + + // Clear all tags function + const handleClearAllTags = async () => { + if (isClearing) return + + // Get status for confirmation if not available + let statusToUse = clearStatus + if (!statusToUse) { + try { + const response = await fetch(`/api/tags/clear${directoryPath ? `?directory=${encodeURIComponent(directoryPath)}` : ''}`) + if (response.ok) { + statusToUse = await response.json() + setClearStatus(statusToUse) + } + } catch (error) { + console.warn('Failed to fetch clear status:', error) + } + } + + if (!statusToUse || statusToUse.totalTags === 0) { + alert('No tags found to clear.') + return + } + + const confirmMessage = directoryPath + ? `This will permanently delete ${statusToUse.totalTags} tags from ${statusToUse.photosWithTags} photos in "${directoryPath}".\n\nThis action cannot be undone. Are you sure?` + : `This will permanently delete ${statusToUse.totalTags} tags from ${statusToUse.photosWithTags} photos across your entire database.\n\nThis action cannot be undone. Are you sure?` + + if (!confirm(confirmMessage)) { + return + } + + setIsClearing(true) + + try { + console.log('[PHOTO GRID] Starting clear all tags...') + + const response = await fetch('/api/tags/clear', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + directory: directoryPath, + confirmClear: true + }), + }) + + if (!response.ok) { + throw new Error('Failed to clear tags') + } + + const result = await response.json() + console.log('[PHOTO GRID] Clear tags result:', result) + + alert(`Tags cleared successfully!\n${result.summary.totalTagsCleared} tags removed from ${result.summary.photosProcessed} photos`) + + // Refresh photos to reflect cleared tags + fetchPhotos() + + // Refresh statuses + fetchClassificationStatus() + fetchClearStatus() + + } catch (error) { + console.error('[PHOTO GRID] Clear tags error:', error) + alert('Failed to clear tags. Check console for details.') + } finally { + setIsClearing(false) + } + } + + // Caption functions + const handleBatchCaption = async () => { + if (isCaptioning) return + + setIsCaptioning(true) + setCaptionProgress(null) + + try { + console.log('[PHOTO GRID] Starting full database batch captioning...') + + // First, get the total count of uncaptioned photos + const statusResponse = await fetch('/api/caption/batch') + if (!statusResponse.ok) { + throw new Error('Failed to get caption status') + } + const status = await statusResponse.json() + const totalUncaptioned = status.uncaptioned + + if (totalUncaptioned === 0) { + alert('All photos already have captions!') + return + } + + console.log(`[PHOTO GRID] Found ${totalUncaptioned} uncaptioned photos to process`) + + // Initialize progress tracking + setCaptionProgress({ + total: totalUncaptioned, + processed: 0, + successful: 0, + failed: 0, + skipped: 0 + }) + + const batchSize = 3 // Process 3 photos at a time (captioning is more intensive) + let offset = 0 + let totalProcessed = 0 + let totalSuccessful = 0 + let totalFailed = 0 + let totalSkipped = 0 + + // Process photos in batches + while (totalProcessed < totalUncaptioned) { + console.log(`[PHOTO GRID] Processing caption batch: offset=${offset}, batchSize=${batchSize}`) + + const response = await fetch('/api/caption/batch', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + limit: batchSize, + offset: offset, + onlyUncaptioned: true, + overwrite: false + }), + }) + + if (!response.ok) { + throw new Error('Failed to caption batch') + } + + const result = await response.json() + console.log(`[PHOTO GRID] Caption batch result:`, result.summary) + + // Update totals + totalProcessed += result.summary.processed + totalSuccessful += result.summary.successful + totalFailed += result.summary.failed + totalSkipped += result.summary.skipped + + // Update progress + setCaptionProgress({ + total: totalUncaptioned, + processed: totalProcessed, + successful: totalSuccessful, + failed: totalFailed, + skipped: totalSkipped + }) + + // If no more photos to process, break + if (!result.hasMore || result.summary.processed === 0) { + console.log('[PHOTO GRID] No more photos to caption') + break + } + + offset += result.summary.processed + + // Longer delay between batches for captioning (more intensive) + await new Promise(resolve => setTimeout(resolve, 1000)) + } + + console.log(`[PHOTO GRID] Captioning complete: ${totalSuccessful} successful, ${totalSkipped} skipped, ${totalFailed} failed`) + + alert(`Captioning complete!\n${totalSuccessful} photos captioned\n${totalSkipped > 0 ? `${totalSkipped} skipped\n` : ''}${totalFailed > 0 ? `${totalFailed} failed` : ''}`) + + // Refresh photos to show new captions + fetchPhotos() + + // Refresh caption status + fetchCaptionStatus() + + } catch (error) { + console.error('[PHOTO GRID] Captioning error:', error) + alert('Failed to caption photos. Check console for details.') + } finally { + setIsCaptioning(false) + setCaptionProgress(null) + } + } + + // Fetch classification, caption, and clear status when component mounts or directory changes + useEffect(() => { + fetchClassificationStatus() + fetchCaptionStatus() + fetchClearStatus() + }, [directoryPath]) + // Grid classes based on thumbnail size - made thumbnails larger const gridClasses = { small: 'grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 gap-2', @@ -224,6 +580,71 @@ export default function PhotoGrid({ return (
+ {/* Classification Progress Bar */} + {isClassifying && classificationProgress && ( +
+
+
+ + + Auto-tagging in progress... + +
+ + {classificationProgress.processed}/{classificationProgress.total} + +
+
+
+
+
+ + ✓ {classificationProgress.successful} successful + {classificationProgress.failed > 0 && ` • ✗ ${classificationProgress.failed} failed`} + + {classificationProgress.totalTagsAdded} tags added +
+
+ )} + + {/* Captioning Progress Bar */} + {isCaptioning && captionProgress && ( +
+
+
+ + + Generating captions... + +
+ + {captionProgress.processed}/{captionProgress.total} + +
+
+
+
+
+ + ✓ {captionProgress.successful} successful + {captionProgress.skipped > 0 && ` • ⏭ ${captionProgress.skipped} skipped`} + {captionProgress.failed > 0 && ` • ✗ ${captionProgress.failed} failed`} + + {captionProgress.successful} captions generated +
+
+ )} + {/* Controls */}
@@ -237,6 +658,92 @@ export default function PhotoGrid({
+ {/* Classification Button */} + + + {/* Caption Button */} + + + {/* Clear All Tags Button */} + + {/* Search */}
diff --git a/src/components/PhotoThumbnail.tsx b/src/components/PhotoThumbnail.tsx index 22ddda1..6493850 100644 --- a/src/components/PhotoThumbnail.tsx +++ b/src/components/PhotoThumbnail.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { IconCamera, IconMapPin, IconCalendar, IconEye, IconHeart, IconStar } from '@tabler/icons-react' import { Photo } from '@/types/photo' @@ -19,6 +19,23 @@ export default function PhotoThumbnail({ }: PhotoThumbnailProps) { const [imageError, setImageError] = useState(false) const [showDetails, setShowDetails] = useState(false) + const [tags, setTags] = useState>([]) + + // Fetch tags for this photo + useEffect(() => { + const fetchTags = async () => { + try { + const response = await fetch(`/api/photos/${photo.id}/tags`) + if (response.ok) { + const photoTags = await response.json() + setTags(photoTags) + } + } catch (error) { + console.warn('Failed to fetch tags for photo:', photo.id) + } + } + fetchTags() + }, [photo.id]) // Parse metadata let metadata: any = {} @@ -137,6 +154,29 @@ export default function PhotoThumbnail({ ))}
)} + + {/* Tag pills */} + {tags.length > 0 && ( +
+
+ {tags.slice(0, size === 'large' ? 6 : size === 'medium' ? 4 : 2).map((tag) => ( + + {tag.name} + + ))} + {tags.length > (size === 'large' ? 6 : size === 'medium' ? 4 : 2) && ( + + +{tags.length - (size === 'large' ? 6 : size === 'medium' ? 4 : 2)} + + )} +
+
+ )} {/* Metadata overlay - appears at bottom, leaving image visible */} {showMetadata && (showDetails || size === 'large') && ( diff --git a/src/lib/image-classifier.ts b/src/lib/image-classifier.ts new file mode 100644 index 0000000..9502eb4 --- /dev/null +++ b/src/lib/image-classifier.ts @@ -0,0 +1,593 @@ +import { pipeline, env, RawImage } from '@xenova/transformers' + +// Configure to use local models (no internet required after first download) +env.allowLocalModels = true +env.allowRemoteModels = false + +export interface ClassificationResult { + label: string + score: number +} + +export interface CaptionResult { + caption: string + confidence?: number +} + +export interface ClassifierConfig { + minConfidence?: number + maxResults?: number + customLabels?: string[] + categories?: { + general?: string[] + time?: string[] + weather?: string[] + subjects?: string[] + locations?: string[] + style?: string[] + seasons?: string[] + } +} + +export class ImageClassifier { + private classifier: any = null + private captioner: any = null + private zeroShotClassifier: any = null // For custom concepts + private isInitialized: boolean = false + private isCaptionerInitialized: boolean = false + private isZeroShotInitialized: boolean = false + private initializationPromise: Promise | null = null + private captionerInitializationPromise: Promise | null = null + private zeroShotInitializationPromise: Promise | null = null + private config: ClassifierConfig = { + minConfidence: 0.1, + maxResults: 10 + } + + async initialize(): Promise { + if (this.isInitialized) return + + if (this.initializationPromise) { + return this.initializationPromise + } + + this.initializationPromise = this._initializeModel() + return this.initializationPromise + } + + async initializeCaptioner(): Promise { + if (this.isCaptionerInitialized) return + + if (this.captionerInitializationPromise) { + return this.captionerInitializationPromise + } + + this.captionerInitializationPromise = this._initializeCaptioner() + return this.captionerInitializationPromise + } + + async initializeZeroShot(): Promise { + if (this.isZeroShotInitialized) return + + if (this.zeroShotInitializationPromise) { + return this.zeroShotInitializationPromise + } + + this.zeroShotInitializationPromise = this._initializeZeroShot() + return this.zeroShotInitializationPromise + } + + private async _initializeModel(): Promise { + try { + console.log('[IMAGE CLASSIFIER] Initializing ViT model...') + + // Use Vision Transformer for standard image classification + this.classifier = await pipeline( + 'image-classification', + 'Xenova/vit-base-patch16-224', + { + revision: 'main' + } + ) + + this.isInitialized = true + console.log('[IMAGE CLASSIFIER] ViT model initialized successfully') + } catch (error) { + console.error('[IMAGE CLASSIFIER] Failed to initialize ViT model:', error) + throw error + } + } + + private async _initializeCaptioner(): Promise { + try { + console.log('[IMAGE CAPTIONER] Initializing BLIP model...') + + // Use BLIP for detailed image captioning + this.captioner = await pipeline( + 'image-to-text', + 'Xenova/blip-image-captioning-base', + { + revision: 'main' + } + ) + + this.isCaptionerInitialized = true + console.log('[IMAGE CAPTIONER] BLIP model initialized successfully') + } catch (error) { + console.error('[IMAGE CAPTIONER] Failed to initialize BLIP model:', error) + throw error + } + } + + private async _initializeZeroShot(): Promise { + try { + console.log('[ZERO-SHOT CLASSIFIER] Initializing CLIP model...') + + // Use CLIP for zero-shot classification of artistic/style concepts + this.zeroShotClassifier = await pipeline( + 'zero-shot-image-classification', + 'Xenova/clip-vit-base-patch32', + { + revision: 'main' + } + ) + + this.isZeroShotInitialized = true + console.log('[ZERO-SHOT CLASSIFIER] CLIP model initialized successfully') + } catch (error) { + console.error('[ZERO-SHOT CLASSIFIER] Failed to initialize CLIP model:', error) + throw error + } + } + + // Update classifier configuration + updateConfig(newConfig: Partial): void { + this.config = { ...this.config, ...newConfig } + console.log('[IMAGE CLASSIFIER] Configuration updated:', this.config) + } + + // Get current labels based on config (for backward compatibility) + // Note: Standard image classification uses ImageNet labels, not custom ones + private getCurrentLabels(customLabels?: string[]): string[] { + console.log('[IMAGE CLASSIFIER] Note: Standard image classification uses ImageNet classes, custom labels are ignored') + return [] // Not used in standard image classification + } + + async classifyImage(imageSource: string | Buffer, customLabels?: string[], config?: Partial): Promise { + await this.initialize() + + if (!this.classifier) { + throw new Error('Classifier not initialized') + } + + // Merge temporary config if provided + const activeConfig = config ? { ...this.config, ...config } : this.config + + try { + const sourceDesc = typeof imageSource === 'string' ? imageSource : 'thumbnail blob' + console.log(`[IMAGE CLASSIFIER] Classifying image: ${sourceDesc}`) + + // Handle different input types for Transformers.js + let processedSource: string | RawImage + + if (Buffer.isBuffer(imageSource)) { + console.log(`[IMAGE CLASSIFIER] Converting Buffer to RawImage (${imageSource.length} bytes)`) + + // Validate buffer has minimum size + if (imageSource.length < 100) { + console.warn(`[IMAGE CLASSIFIER] Buffer too small (${imageSource.length} bytes), likely corrupted`) + throw new Error('Thumbnail data too small or corrupted') + } + + try { + // Validate image format from magic bytes + const header = imageSource.subarray(0, 4) + let isValidImage = false + + if (header[0] === 0xFF && header[1] === 0xD8) { // JPEG + isValidImage = true + } else if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4E && header[3] === 0x47) { // PNG + isValidImage = true + } else if (header[0] === 0x47 && header[1] === 0x49 && header[2] === 0x46) { // GIF + isValidImage = true + } else if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46) { // WebP + isValidImage = true + } + + if (!isValidImage) { + console.warn(`[IMAGE CLASSIFIER] Invalid image format detected in buffer: [${header[0]}, ${header[1]}, ${header[2]}, ${header[3]}]`) + throw new Error('Invalid image format in thumbnail data') + } + + // Create RawImage directly from buffer + processedSource = await RawImage.fromBlob(new Blob([imageSource])) + console.log(`[IMAGE CLASSIFIER] Successfully created RawImage from buffer`) + } catch (error) { + console.error(`[IMAGE CLASSIFIER] Failed to create RawImage from buffer:`, error) + throw new Error(`Failed to process thumbnail data: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } else { + // String path - pass through as is + processedSource = imageSource + } + + // Standard image classification doesn't use custom labels - it returns ImageNet classes + const results = await this.classifier(processedSource, { + top_k: activeConfig.maxResults || 10 + }) + + // Filter results by confidence threshold and format + const filteredResults = results + .filter((result: any) => result.score > (activeConfig.minConfidence || 0.1)) + .map((result: any) => ({ + label: this.cleanLabel(result.label), + score: Math.round(result.score * 1000) / 1000 // Round to 3 decimal places + })) + + console.log(`[IMAGE CLASSIFIER] Found ${filteredResults.length} classifications for ${sourceDesc} (min confidence: ${activeConfig.minConfidence})`) + return filteredResults + } catch (error) { + console.error(`[IMAGE CLASSIFIER] Failed to classify image ${typeof imageSource === 'string' ? imageSource : 'thumbnail blob'}:`, error) + throw error + } + } + + // Clean ImageNet labels to be more user-friendly + private cleanLabel(label: string): string { + // ImageNet labels often have format like "Egyptian cat, Egyptian Mau" or technical IDs + // Take the first part and clean it up + const cleaned = label + .split(',')[0] // Take first part before comma + .trim() + .toLowerCase() + .replace(/[_-]/g, ' ') // Replace underscores/hyphens with spaces + .replace(/\b\w/g, l => l.toUpperCase()) // Capitalize first letter of each word + + return cleaned + } + + // Convenience method for classifying from file path (legacy support) + async classifyImageFromPath(imagePath: string, customLabels?: string[]): Promise { + return this.classifyImage(imagePath, customLabels) + } + + // New method for classifying from thumbnail blob + async classifyImageFromBlob(imageBlob: Buffer, customLabels?: string[]): Promise { + return this.classifyImage(imageBlob, customLabels) + } + + async classifyImageWithCategories(imageSource: string | Buffer, config?: Partial): Promise<{ [category: string]: ClassificationResult[] }> { + // Standard image classification returns ImageNet classes + // We'll categorize them post-classification + const classifications = await this.classifyImage(imageSource, undefined, config) + + const results: { [category: string]: ClassificationResult[] } = { + animals: [], + objects: [], + nature: [], + people: [], + vehicles: [], + food: [], + other: [] + } + + // Categorize ImageNet results based on common patterns + classifications.forEach(result => { + const label = result.label.toLowerCase() + + if (this.isAnimal(label)) { + results.animals.push(result) + } else if (this.isNature(label)) { + results.nature.push(result) + } else if (this.isVehicle(label)) { + results.vehicles.push(result) + } else if (this.isFood(label)) { + results.food.push(result) + } else if (this.isPerson(label)) { + results.people.push(result) + } else if (this.isObject(label)) { + results.objects.push(result) + } else { + results.other.push(result) + } + }) + + // Remove empty categories + Object.keys(results).forEach(key => { + if (results[key].length === 0) { + delete results[key] + } + }) + + return results + } + + // Helper methods to categorize ImageNet labels + private isAnimal(label: string): boolean { + const animalKeywords = ['dog', 'cat', 'bird', 'horse', 'cow', 'sheep', 'goat', 'pig', 'chicken', 'duck', 'tiger', 'lion', 'bear', 'elephant', 'giraffe', 'zebra', 'monkey', 'ape', 'wolf', 'fox', 'deer', 'rabbit', 'mouse', 'rat', 'hamster', 'fish', 'shark', 'whale', 'dolphin', 'seal', 'penguin', 'eagle', 'hawk', 'owl', 'parrot', 'canary', 'snake', 'lizard', 'turtle', 'frog', 'spider', 'butterfly', 'bee', 'ant', 'beetle', 'fly'] + return animalKeywords.some(keyword => label.includes(keyword)) + } + + private isNature(label: string): boolean { + const natureKeywords = ['tree', 'flower', 'plant', 'leaf', 'grass', 'mountain', 'ocean', 'sea', 'lake', 'river', 'forest', 'beach', 'sky', 'cloud', 'sun', 'moon', 'star', 'rock', 'stone', 'sand', 'snow', 'ice', 'rain', 'landscape'] + return natureKeywords.some(keyword => label.includes(keyword)) + } + + private isVehicle(label: string): boolean { + const vehicleKeywords = ['car', 'truck', 'bus', 'motorcycle', 'bicycle', 'plane', 'airplane', 'helicopter', 'boat', 'ship', 'train', 'locomotive', 'taxi', 'ambulance', 'fire truck', 'police car', 'van', 'suv', 'convertible', 'limousine'] + return vehicleKeywords.some(keyword => label.includes(keyword)) + } + + private isFood(label: string): boolean { + const foodKeywords = ['pizza', 'burger', 'sandwich', 'bread', 'cake', 'cookie', 'apple', 'banana', 'orange', 'grape', 'strawberry', 'tomato', 'carrot', 'broccoli', 'potato', 'corn', 'rice', 'pasta', 'meat', 'chicken', 'beef', 'pork', 'fish', 'cheese', 'milk', 'coffee', 'tea', 'wine', 'beer', 'juice', 'water', 'soup', 'salad'] + return foodKeywords.some(keyword => label.includes(keyword)) + } + + private isPerson(label: string): boolean { + const personKeywords = ['person', 'people', 'man', 'woman', 'child', 'baby', 'boy', 'girl', 'human', 'face', 'portrait'] + return personKeywords.some(keyword => label.includes(keyword)) + } + + private isObject(label: string): boolean { + const objectKeywords = ['chair', 'table', 'bed', 'sofa', 'lamp', 'book', 'phone', 'computer', 'laptop', 'tv', 'camera', 'watch', 'clock', 'bottle', 'cup', 'glass', 'plate', 'bowl', 'knife', 'fork', 'spoon', 'bag', 'backpack', 'umbrella', 'hat', 'shoe', 'shirt', 'dress', 'jacket', 'pants'] + return objectKeywords.some(keyword => label.includes(keyword)) + } + + // Generate tags for database storage + async generateTags(imageSource: string | Buffer, minConfidence?: number, config?: Partial): Promise { + const activeConfig = config ? { ...this.config, ...config } : this.config + const confidenceThreshold = minConfidence ?? activeConfig.minConfidence ?? 0.3 + + const results = await this.classifyImage(imageSource, undefined, { ...activeConfig, minConfidence: confidenceThreshold }) + return results.map(result => result.label) + } + + // Get current configuration + getConfig(): ClassifierConfig { + return { ...this.config } + } + + // Reset to default configuration + resetConfig(): void { + this.config = { + minConfidence: 0.1, + maxResults: 10 + } + console.log('[IMAGE CLASSIFIER] Configuration reset to defaults') + } + + // Generate detailed caption for image + async captionImage(imageSource: string | Buffer): Promise { + await this.initializeCaptioner() + + if (!this.captioner) { + throw new Error('Captioner not initialized') + } + + try { + const sourceDesc = typeof imageSource === 'string' ? imageSource : 'thumbnail blob' + console.log(`[IMAGE CAPTIONER] Generating caption for: ${sourceDesc}`) + + // Handle different input types for Transformers.js + let processedSource: string | RawImage + + if (Buffer.isBuffer(imageSource)) { + console.log(`[IMAGE CAPTIONER] Converting Buffer to RawImage (${imageSource.length} bytes)`) + + // Validate buffer has minimum size + if (imageSource.length < 100) { + console.warn(`[IMAGE CAPTIONER] Buffer too small (${imageSource.length} bytes), likely corrupted`) + throw new Error('Thumbnail data too small or corrupted') + } + + try { + // Validate image format from magic bytes + const header = imageSource.subarray(0, 4) + let isValidImage = false + + if (header[0] === 0xFF && header[1] === 0xD8) { // JPEG + isValidImage = true + } else if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4E && header[3] === 0x47) { // PNG + isValidImage = true + } else if (header[0] === 0x47 && header[1] === 0x49 && header[2] === 0x46) { // GIF + isValidImage = true + } else if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46) { // WebP + isValidImage = true + } + + if (!isValidImage) { + console.warn(`[IMAGE CAPTIONER] Invalid image format detected in buffer: [${header[0]}, ${header[1]}, ${header[2]}, ${header[3]}]`) + throw new Error('Invalid image format in thumbnail data') + } + + // Create RawImage directly from buffer + processedSource = await RawImage.fromBlob(new Blob([imageSource])) + console.log(`[IMAGE CAPTIONER] Successfully created RawImage from buffer`) + } catch (error) { + console.error(`[IMAGE CAPTIONER] Failed to create RawImage from buffer:`, error) + throw new Error(`Failed to process thumbnail data: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } else { + // String path - pass through as is + processedSource = imageSource + } + + // Generate caption + const result = await this.captioner(processedSource) + + // Extract caption text (BLIP returns array with generated_text) + const caption = Array.isArray(result) && result.length > 0 + ? result[0].generated_text + : result.generated_text || 'No caption generated' + + console.log(`[IMAGE CAPTIONER] Generated caption for ${sourceDesc}: "${caption}"`) + + return { + caption: caption.trim(), + confidence: 1.0 // BLIP doesn't provide confidence scores + } + } catch (error) { + console.error(`[IMAGE CAPTIONER] Failed to caption image ${typeof imageSource === 'string' ? imageSource : 'thumbnail blob'}:`, error) + throw error + } + } + + // Comprehensive classification using multiple models + async classifyImageComprehensive(imageSource: string | Buffer, config?: Partial): Promise<{ + objectClassification: ClassificationResult[] + styleClassification: ClassificationResult[] + combinedResults: ClassificationResult[] + }> { + try { + console.log(`[COMPREHENSIVE CLASSIFIER] Starting comprehensive analysis`) + + const activeConfig = config ? { ...this.config, ...config } : this.config + + // Initialize both models + await Promise.all([ + this.initialize(), + this.initializeZeroShot() + ]) + + // Define style/artistic labels for zero-shot + const styleLabels = [ + // Photography styles + 'portrait photography', 'landscape photography', 'street photography', 'macro photography', + 'architectural photography', 'nature photography', 'wildlife photography', 'sports photography', + 'black and white photography', 'vintage photography', 'artistic photography', 'documentary photography', + + // Lighting and mood + 'golden hour lighting', 'blue hour lighting', 'dramatic lighting', 'soft lighting', 'natural lighting', + 'moody atmosphere', 'bright and cheerful', 'dark and mysterious', 'romantic mood', 'energetic mood', + + // Composition and style + 'minimalist composition', 'symmetrical composition', 'rule of thirds', 'leading lines', 'depth of field', + 'bokeh effect', 'high contrast', 'low contrast', 'saturated colors', 'muted colors', + + // Time and weather + 'sunrise', 'sunset', 'dawn', 'dusk', 'midday sun', 'cloudy weather', 'sunny day', 'overcast sky', + 'stormy weather', 'foggy conditions', 'winter scene', 'spring scene', 'summer scene', 'autumn scene', + + // Location types + 'indoor scene', 'outdoor scene', 'urban environment', 'rural setting', 'beach scene', 'mountain landscape', + 'forest setting', 'desert landscape', 'cityscape', 'countryside', 'waterfront', 'garden scene' + ] + + // Run both classifications in parallel + const [objectResults, styleResults] = await Promise.all([ + this.classifyImage(imageSource, undefined, activeConfig), + this.classifyZeroShot(imageSource, styleLabels, activeConfig) + ]) + + // Combine and deduplicate results + const combined = [...objectResults, ...styleResults] + .sort((a, b) => b.score - a.score) + .slice(0, activeConfig.maxResults || 15) + + console.log(`[COMPREHENSIVE CLASSIFIER] Found ${objectResults.length} object tags, ${styleResults.length} style tags`) + + return { + objectClassification: objectResults, + styleClassification: styleResults, + combinedResults: combined + } + } catch (error) { + console.error(`[COMPREHENSIVE CLASSIFIER] Failed to classify image:`, error) + throw error + } + } + + // Zero-shot classification for artistic/style concepts + private async classifyZeroShot(imageSource: string | Buffer, labels: string[], config?: Partial): Promise { + if (!this.zeroShotClassifier) { + throw new Error('Zero-shot classifier not initialized') + } + + const activeConfig = config ? { ...this.config, ...config } : this.config + + // Handle different input types (same logic as main classifier) + let processedSource: string | RawImage + + if (Buffer.isBuffer(imageSource)) { + if (imageSource.length < 100) { + throw new Error('Thumbnail data too small or corrupted') + } + + // Validate image format + const header = imageSource.subarray(0, 4) + let isValidImage = false + + if (header[0] === 0xFF && header[1] === 0xD8) isValidImage = true // JPEG + else if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4E && header[3] === 0x47) isValidImage = true // PNG + else if (header[0] === 0x47 && header[1] === 0x49 && header[2] === 0x46) isValidImage = true // GIF + else if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46) isValidImage = true // WebP + + if (!isValidImage) { + throw new Error('Invalid image format in thumbnail data') + } + + processedSource = await RawImage.fromBlob(new Blob([imageSource])) + } else { + processedSource = imageSource + } + + const results = await this.zeroShotClassifier(processedSource, labels) + + return results + .filter((result: any) => result.score > (activeConfig.minConfidence || 0.1)) + .slice(0, Math.floor((activeConfig.maxResults || 10) / 2)) // Take half of max results + .map((result: any) => ({ + label: result.label, + score: Math.round(result.score * 1000) / 1000 + })) + } + + // Generate both classification tags and detailed caption + async analyzeImage(imageSource: string | Buffer, customLabels?: string[], config?: Partial): Promise<{ + classifications: ClassificationResult[] + caption: CaptionResult + comprehensive?: { + objectClassification: ClassificationResult[] + styleClassification: ClassificationResult[] + } + }> { + try { + console.log(`[IMAGE ANALYZER] Starting full analysis for image`) + + // Run all analyses in parallel for better performance + const [classifications, caption, comprehensive] = await Promise.all([ + this.classifyImage(imageSource, customLabels, config), + this.captionImage(imageSource), + this.classifyImageComprehensive(imageSource, config).catch(error => { + console.warn('[IMAGE ANALYZER] Comprehensive classification failed:', error) + return null + }) + ]) + + return { + classifications, + caption, + comprehensive: comprehensive ? { + objectClassification: comprehensive.objectClassification, + styleClassification: comprehensive.styleClassification + } : undefined + } + } catch (error) { + console.error(`[IMAGE ANALYZER] Failed to analyze image:`, error) + throw error + } + } + + // Check if models are ready + isReady(): boolean { + return this.isInitialized && this.classifier !== null + } + + // Check if captioner is ready + isCaptionerReady(): boolean { + return this.isCaptionerInitialized && this.captioner !== null + } +} + +// Singleton instance +export const imageClassifier = new ImageClassifier() \ No newline at end of file diff --git a/src/lib/photo-service.ts b/src/lib/photo-service.ts index 5da3ec0..aa25692 100644 --- a/src/lib/photo-service.ts +++ b/src/lib/photo-service.ts @@ -505,6 +505,109 @@ export class PhotoService { const selectConflicts = this.db.prepare('SELECT * FROM photo_conflicts ORDER BY conflict_detected_at DESC') return selectConflicts.all() as Array } + + // Auto-tagging methods + createOrGetTag(tagName: string, color?: string): Tag { + // Try to get existing tag + const selectTag = this.db.prepare('SELECT * FROM tags WHERE name = ?') + let tag = selectTag.get(tagName.toLowerCase()) as Tag | null + + if (tag) { + return tag + } + + // Create new tag + const id = randomUUID() + const insertTag = this.db.prepare(` + INSERT INTO tags (id, name, color) VALUES (?, ?, ?) + `) + + insertTag.run(id, tagName.toLowerCase(), color || '#3B82F6') + + return { + id, + name: tagName.toLowerCase(), + color: color || '#3B82F6', + created_at: new Date().toISOString() + } + } + + addPhotoTag(photoId: string, tagName: string, confidence?: number): boolean { + const tag = this.createOrGetTag(tagName) + + // Check if association already exists + const existingAssociation = this.db.prepare( + 'SELECT * FROM photo_tags WHERE photo_id = ? AND tag_id = ?' + ).get(photoId, tag.id) + + if (existingAssociation) { + return false // Already exists + } + + // Create association + const insertPhotoTag = this.db.prepare(` + INSERT INTO photo_tags (photo_id, tag_id, added_at) VALUES (?, ?, ?) + `) + + try { + insertPhotoTag.run(photoId, tag.id, new Date().toISOString()) + return true + } catch (error) { + console.error('Error adding photo tag:', error) + return false + } + } + + addPhotoTags(photoId: string, tags: Array<{ name: string, confidence?: number }>): number { + let addedCount = 0 + + for (const tagInfo of tags) { + if (this.addPhotoTag(photoId, tagInfo.name, tagInfo.confidence)) { + addedCount++ + } + } + + return addedCount + } + + getPhotoTags(photoId: string): Tag[] { + const selectTags = this.db.prepare(` + SELECT t.* FROM tags t + INNER JOIN photo_tags pt ON t.id = pt.tag_id + WHERE pt.photo_id = ? + ORDER BY t.name + `) + + return selectTags.all(photoId) as Tag[] + } + + removePhotoTag(photoId: string, tagId: string): boolean { + const deletePhotoTag = this.db.prepare('DELETE FROM photo_tags WHERE photo_id = ? AND tag_id = ?') + const result = deletePhotoTag.run(photoId, tagId) + return result.changes > 0 + } + + clearPhotoTags(photoId: string): number { + const deleteAllPhotoTags = this.db.prepare('DELETE FROM photo_tags WHERE photo_id = ?') + const result = deleteAllPhotoTags.run(photoId) + return result.changes + } + + searchPhotosByTags(tagNames: string[]): Photo[] { + if (tagNames.length === 0) return [] + + const placeholders = tagNames.map(() => '?').join(',') + const query = ` + SELECT DISTINCT p.* FROM photos p + INNER JOIN photo_tags pt ON p.id = pt.photo_id + INNER JOIN tags t ON pt.tag_id = t.id + WHERE t.name IN (${placeholders}) + ORDER BY p.created_at DESC + ` + + const selectPhotos = this.db.prepare(query) + return selectPhotos.all(...tagNames.map(name => name.toLowerCase())) as Photo[] + } } export const photoService = new PhotoService() \ No newline at end of file