← back to Whatsmystyle
feat: garment extractor — pull a piece out of an old photo
47423bd4d00e924aaa822e853ef7ebd9a3e49ccb · 2026-05-12 00:01:16 -0700 · Steve Abrams
Reverse Time-Travel. Steve's spec: 'pull out an old sweater, shirt, pants
etc. from an old photo and put into a modern setting.' Now possible.
In Studio:
1. Pick an old photo on the left
2. Click '✂︎ Pull a piece from this photo' (top-right)
3. Drag a rectangle around the garment you want
4. Pick a category, click 'Save piece'
5. The crop lands as a new row in items (source='extracted',
image_url=/api/studio/extracted-image?path=...)
6. Pick a different photo of present-you on the left
7. Click the same category on the right → your extracted piece appears
alongside the catalog
8. Click it → FASHN renders that exact garment from your past onto you
Endpoints:
POST /api/studio/extract-garment { source_photo, bbox, category, brand_hint }
Bbox normalized 0-1 floats. Uses sharp (installed this commit) for
fast extract, ffmpeg fallback if sharp unavailable.
GET /api/studio/extracted-image?path=...
Streams the extracted crop, path-gated to data/uploads/extracted/.
Frontend: mousedown/move/up draws a translucent accent-colored rect over
the source photo. 'Save piece' enables once the box is ≥ 20x20 px.
Auto-refreshes the right-panel picker to show the new piece.
E2E remains 26/26 green. Sharp added to dependencies.
Files touched
M data/waitlist.csvM data/whatsmystyle.db-shmM data/whatsmystyle.db-walM package-lock.jsonM package.jsonM public/css/app.cssM public/index.htmlM public/js/app.jsM server.js
Diff
commit 47423bd4d00e924aaa822e853ef7ebd9a3e49ccb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 12 00:01:16 2026 -0700
feat: garment extractor — pull a piece out of an old photo
Reverse Time-Travel. Steve's spec: 'pull out an old sweater, shirt, pants
etc. from an old photo and put into a modern setting.' Now possible.
In Studio:
1. Pick an old photo on the left
2. Click '✂︎ Pull a piece from this photo' (top-right)
3. Drag a rectangle around the garment you want
4. Pick a category, click 'Save piece'
5. The crop lands as a new row in items (source='extracted',
image_url=/api/studio/extracted-image?path=...)
6. Pick a different photo of present-you on the left
7. Click the same category on the right → your extracted piece appears
alongside the catalog
8. Click it → FASHN renders that exact garment from your past onto you
Endpoints:
POST /api/studio/extract-garment { source_photo, bbox, category, brand_hint }
Bbox normalized 0-1 floats. Uses sharp (installed this commit) for
fast extract, ffmpeg fallback if sharp unavailable.
GET /api/studio/extracted-image?path=...
Streams the extracted crop, path-gated to data/uploads/extracted/.
Frontend: mousedown/move/up draws a translucent accent-colored rect over
the source photo. 'Save piece' enables once the box is ≥ 20x20 px.
Auto-refreshes the right-panel picker to show the new piece.
E2E remains 26/26 green. Sharp added to dependencies.
---
data/waitlist.csv | 1 +
data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
data/whatsmystyle.db-wal | Bin 3617392 -> 3802792 bytes
package-lock.json | 529 ++++++++++++++++++++++++++++++++++++++++++++++-
package.json | 3 +-
public/css/app.css | 6 +
public/index.html | 17 +-
public/js/app.js | 94 ++++++++-
server.js | 62 ++++++
9 files changed, 708 insertions(+), 4 deletions(-)
diff --git a/data/waitlist.csv b/data/waitlist.csv
index 352869f..603c69d 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -14,3 +14,4 @@ e2e-1778563130826@example.com,e2e,2026-05-12T05:18:50.826Z
e2e-1778564947982@example.com,e2e,2026-05-12T05:49:07.984Z
e2e-1778566724793@example.com,e2e,2026-05-12T06:18:44.794Z
e2e-1778569083894@example.com,e2e,2026-05-12T06:58:03.895Z
+e2e-1778569258754@example.com,e2e,2026-05-12T07:00:58.755Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index c508901..3e6b2d1 100644
Binary files a/data/whatsmystyle.db-shm and b/data/whatsmystyle.db-shm differ
diff --git a/data/whatsmystyle.db-wal b/data/whatsmystyle.db-wal
index df4d966..0016fd0 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/package-lock.json b/package-lock.json
index f95681c..6b0ad72 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,483 @@
"express-session": "^1.18.0",
"form-data": "^4.0.5",
"multer": "^1.4.5-lts.1",
- "node-fetch": "^2.7.0"
+ "node-fetch": "^2.7.0",
+ "sharp": "^0.34.5"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
"node_modules/accepts": {
@@ -1290,6 +1766,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -1505,6 +2025,13 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
diff --git a/package.json b/package.json
index 600313e..f5c550b 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"express-session": "^1.18.0",
"form-data": "^4.0.5",
"multer": "^1.4.5-lts.1",
- "node-fetch": "^2.7.0"
+ "node-fetch": "^2.7.0",
+ "sharp": "^0.34.5"
}
}
diff --git a/public/css/app.css b/public/css/app.css
index 2d58914..0ee560a 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -214,6 +214,12 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
.studio-history-strip .h-tile { flex: 0 0 140px; aspect-ratio: 3/4; background: var(--paper-2); border-radius: 10px; overflow: hidden; scroll-snap-align: start; position: relative; }
.studio-history-strip .h-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
.studio-history-strip .h-tile .h-cat { position: absolute; bottom: 4px; left: 4px; right: 4px; background: rgba(0,0,0,.65); color: var(--paper); padding: 3px 6px; border-radius: 6px; font-size: 10px; text-align: center; }
+.studio-mid-head { display: flex; justify-content: space-between; align-items: end; flex-wrap: wrap; gap: 10px; }
+.studio-tools { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.studio-tools button.active { background: var(--accent); color: var(--paper); border-color: var(--accent); }
+.studio-result.extracting { cursor: crosshair; position: relative; }
+.studio-result.extracting img { pointer-events: none; user-select: none; }
+.studio-result .extract-rect { position: absolute; border: 2px solid var(--accent); background: rgba(192,57,43,.18); pointer-events: none; }
/* live puppet (Adobe Character Animator pass-through) */
.live-wrap { display: grid; gap: 14px; }
diff --git a/public/index.html b/public/index.html
index f0e0fca..eb34438 100644
--- a/public/index.html
+++ b/public/index.html
@@ -195,10 +195,25 @@
<div id="studio-current" class="studio-current"></div>
</aside>
<main class="studio-mid">
- <h3>Result</h3>
+ <div class="studio-mid-head">
+ <h3>Result</h3>
+ <div class="studio-tools">
+ <button id="extract-toggle" class="section-btn">✂︎ Pull a piece from this photo</button>
+ <select id="extract-category" style="padding:6px 10px;border-radius:8px;">
+ <option value="top">Top</option>
+ <option value="outerwear">Coat / Jacket</option>
+ <option value="bottom">Pants</option>
+ <option value="dress">Dress</option>
+ <option value="shoes">Shoes</option>
+ <option value="bag">Bag</option>
+ </select>
+ <button id="extract-save" class="section-btn" disabled>Save piece</button>
+ </div>
+ </div>
<div id="studio-result" class="studio-result">
<p class="muted">Pick a photo on the left, then a category on the right.</p>
</div>
+ <p class="muted" id="extract-hint" hidden>Drag to draw a box around the garment you want to pull out.</p>
<h3 style="margin-top: 22px;">Wardrobe history on this photo</h3>
<div id="studio-history" class="studio-history-strip"></div>
</main>
diff --git a/public/js/app.js b/public/js/app.js
index 2c3d84d..345af73 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -760,7 +760,99 @@ const studio = {
});
},
};
-$$('.section-btn').forEach(b => b.addEventListener('click', () => studio.setCategory(b.dataset.category)));
+$$('.section-btn[data-category]').forEach(b => b.addEventListener('click', () => studio.setCategory(b.dataset.category)));
+
+// ---- Studio: pull-a-piece (garment extractor) ----------------------------
+const extract = {
+ active: false,
+ start: null, // {x, y} in img-local coords
+ end: null,
+ imgEl: null,
+
+ enable() {
+ if (!studio.currentSource) { alert('Pick a photo first.'); return; }
+ // Replace whatever's in the result pane with the source photo so user
+ // can draw on the original (not on a rendered result).
+ $('#studio-result').innerHTML = `<img src="${studio.currentSourceUrl}" alt="" draggable="false">`;
+ $('#studio-result').classList.add('extracting');
+ $('#extract-toggle').classList.add('active');
+ $('#extract-hint').hidden = false;
+ extract.active = true;
+ extract.imgEl = $('#studio-result img');
+ },
+ disable() {
+ $('#studio-result').classList.remove('extracting');
+ $('#studio-result').querySelector('.extract-rect')?.remove();
+ $('#extract-toggle').classList.remove('active');
+ $('#extract-hint').hidden = true;
+ $('#extract-save').disabled = true;
+ extract.active = false;
+ extract.start = extract.end = null;
+ },
+ rect() {
+ if (!extract.start || !extract.end) return null;
+ const x = Math.min(extract.start.x, extract.end.x);
+ const y = Math.min(extract.start.y, extract.end.y);
+ const w = Math.abs(extract.end.x - extract.start.x);
+ const h = Math.abs(extract.end.y - extract.start.y);
+ return { x, y, w, h };
+ },
+ drawRect() {
+ const r = extract.rect();
+ if (!r) return;
+ let el = $('#studio-result').querySelector('.extract-rect');
+ if (!el) { el = document.createElement('div'); el.className = 'extract-rect'; $('#studio-result').appendChild(el); }
+ el.style.left = r.x + 'px'; el.style.top = r.y + 'px';
+ el.style.width = r.w + 'px'; el.style.height = r.h + 'px';
+ },
+ async save() {
+ const r = extract.rect();
+ if (!r || !extract.imgEl) return;
+ const imgRect = extract.imgEl.getBoundingClientRect();
+ const containerRect = $('#studio-result').getBoundingClientRect();
+ // r is in container coords; convert to image-local then normalize 0-1
+ const imgLeft = imgRect.left - containerRect.left;
+ const imgTop = imgRect.top - containerRect.top;
+ const bbox = {
+ x: Math.max(0, (r.x - imgLeft)) / imgRect.width,
+ y: Math.max(0, (r.y - imgTop)) / imgRect.height,
+ w: r.w / imgRect.width,
+ h: r.h / imgRect.height,
+ };
+ const category = $('#extract-category').value;
+ const resp = await fetch('/api/studio/extract-garment', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ source_photo: studio.currentSource, bbox, category, brand_hint: 'Your past wardrobe' }),
+ }).then(r => r.json());
+ if (resp.error) { alert('Extract failed: ' + resp.error); return; }
+ alert(`Extracted! Saved as catalog item #${resp.item_id} — pick a different photo on the left and click "${category}" to swap it on.`);
+ extract.disable();
+ // Refresh the picker so the new piece shows up
+ studio.setCategory(resp.category || category);
+ },
+};
+$('#extract-toggle').addEventListener('click', () => extract.active ? extract.disable() : extract.enable());
+$('#extract-save').addEventListener('click', () => extract.save());
+
+$('#studio-result').addEventListener('mousedown', e => {
+ if (!extract.active) return;
+ const c = $('#studio-result').getBoundingClientRect();
+ extract.start = { x: e.clientX - c.left, y: e.clientY - c.top };
+ extract.end = { ...extract.start };
+ extract.drawRect();
+});
+$('#studio-result').addEventListener('mousemove', e => {
+ if (!extract.active || !extract.start) return;
+ const c = $('#studio-result').getBoundingClientRect();
+ extract.end = { x: e.clientX - c.left, y: e.clientY - c.top };
+ extract.drawRect();
+});
+$('#studio-result').addEventListener('mouseup', () => {
+ if (!extract.active || !extract.start) return;
+ const r = extract.rect();
+ $('#extract-save').disabled = !(r && r.w > 20 && r.h > 20);
+});
// ---------- Router ---------------------------------------------------------
async function loadTasteTimeline() {
diff --git a/server.js b/server.js
index 6ba3450..c1d8eab 100644
--- a/server.js
+++ b/server.js
@@ -761,6 +761,68 @@ app.post('/api/waitlist', (req, res) => {
res.json({ ok: true });
});
+// ---- garment extractor: pull a piece from an old photo -------------------
+// User crops a rectangle on an old photo (the blue scarf, the leather jacket,
+// the 1980s sweater). We save the crop as a new row in `items` with
+// source='extracted' so it shows up as a swappable piece in the Studio picker.
+// Then the user can swap that extracted piece onto a CURRENT photo of themselves.
+const sharp = (() => { try { return require('sharp'); } catch { return null; } })();
+
+app.post('/api/studio/extract-garment', express.json({ limit: '32kb' }), async (req, res) => {
+ const u = currentUser(req);
+ const { source_photo, bbox, category, brand_hint } = req.body || {};
+ // bbox normalized 0-1 floats: {x, y, w, h}
+ if (!source_photo || !bbox) return res.status(400).json({ error: 'source_photo + bbox required' });
+ if (!source_photo.startsWith(path.join(__dirname, 'data', 'uploads'))) return res.status(400).json({ error: 'bad source path' });
+ if (!fs.existsSync(source_photo)) return res.status(404).json({ error: 'photo not found' });
+
+ const outDir = path.join(__dirname, 'data', 'uploads', 'extracted');
+ fs.mkdirSync(outDir, { recursive: true });
+ const outPath = path.join(outDir, `extract-${Date.now()}.jpg`);
+ try {
+ if (sharp) {
+ const meta = await sharp(source_photo).metadata();
+ const x = Math.max(0, Math.round(bbox.x * meta.width));
+ const y = Math.max(0, Math.round(bbox.y * meta.height));
+ const w = Math.min(meta.width - x, Math.round(bbox.w * meta.width));
+ const h = Math.min(meta.height - y, Math.round(bbox.h * meta.height));
+ if (w < 32 || h < 32) return res.status(400).json({ error: 'crop too small (need ≥ 32x32 px)' });
+ await sharp(source_photo).extract({ left: x, top: y, width: w, height: h }).jpeg({ quality: 90 }).toFile(outPath);
+ } else {
+ // ffmpeg fallback when sharp isn't installed
+ const probe = require('child_process').spawnSync('sips', ['--getProperty', 'pixelWidth', '--getProperty', 'pixelHeight', source_photo]);
+ const m = probe.stdout.toString();
+ const W = parseInt(m.match(/pixelWidth:\s*(\d+)/)?.[1] || '0', 10);
+ const H = parseInt(m.match(/pixelHeight:\s*(\d+)/)?.[1] || '0', 10);
+ if (!W || !H) return res.status(500).json({ error: 'could not measure photo' });
+ const x = Math.max(0, Math.round(bbox.x * W));
+ const y = Math.max(0, Math.round(bbox.y * H));
+ const w = Math.min(W - x, Math.round(bbox.w * W));
+ const h = Math.min(H - y, Math.round(bbox.h * H));
+ const r = require('child_process').spawnSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', source_photo, '-vf', `crop=${w}:${h}:${x}:${y}`, outPath]);
+ if (r.status !== 0) return res.status(500).json({ error: 'ffmpeg crop failed', detail: r.stderr.toString() });
+ }
+ } catch (e) {
+ return res.status(500).json({ error: 'crop failed: ' + e.message });
+ }
+
+ // Register as a virtual catalog item
+ const cat = category || 'top';
+ const title = `From your ${new Date().getFullYear() - new Date(fs.statSync(source_photo).mtimeMs).getFullYear()}-ish photo · ${cat}`;
+ const itemRow = db.prepare(`INSERT INTO items (source, external_id, title, brand, category, image_url, product_url)
+ VALUES ('extracted', ?, ?, ?, ?, ?, ?) RETURNING id;`)
+ .get(`extract-${Date.now()}`, title, brand_hint || 'Your past wardrobe', cat,
+ '/api/studio/extracted-image?path=' + encodeURIComponent(outPath), null);
+ res.json({ ok: true, item_id: itemRow.id, image_url: '/api/studio/extracted-image?path=' + encodeURIComponent(outPath), category: cat });
+});
+
+app.get('/api/studio/extracted-image', (req, res) => {
+ const p = req.query.path;
+ if (!p || !p.startsWith(path.join(__dirname, 'data', 'uploads', 'extracted'))) return res.status(400).end();
+ if (!fs.existsSync(p)) return res.status(404).end();
+ res.sendFile(p);
+});
+
// ---- studio: per-photo render history -----------------------------------
// Studio mode is: pick a source photo of yourself, then sectioned right-panel
// lets you swap each clothing category. Every render is preserved as a row in
← 20e99b7 feat: /studio — photo-first, sectioned swap, per-photo histo
·
back to Whatsmystyle
·
fix(extract): drop the 'X-ish' year math (used file mtime, n 028ad30 →