[object Object]

← 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

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 →