One of my small tool sites is getting a "convert a whole folder to JPG" button. Shop owners sort product photos into folders like shoes/ and bags/, and phone cameras all start at IMG_0001.jpg, so the same name in two subfolders is easy to get. My first loop wrote each result under its file name, and a test folder with 7 images came out as 5 files, with no error. Everything here ran in desktop Chromium 149 on my 16 GB Mac.
The relative path starts with the picked folder
The test folder holds shoes/IMG_0001.jpg and bags/IMG_0001.jpg, a cover.jpg next to a cover.png, summer_sale/hat.jpg next to summer/sale_hat.jpg, one image down in shoes/2026/new/, an empty folder and a .DS_Store. Each image is a flat block in its own color, so an output's center pixel tells me which source survived (JPEG turned my orange 240, 130, 20 into 240, 130, 19). After <input type="file" webkitdirectory>, each File has a webkitRelativePath like products/shoes/2026/new/IMG_0002.jpg, starting with the picked folder's name, while file.name is only IMG_0002.jpg. I didn't expect .DS_Store to be in the list (with an empty type), and the empty folder doesn't show up at all, so this list can't recreate it. The order isn't alphabetical either, though two runs gave the same order.
Writing by file name lost two images silently
Each image goes through createImageBitmap, an OffscreenCanvas and convertToBlob (JPEG 0.8), then getFileHandle(name, { create: true }) and createWritable(). The writes went to the browser's private file system (OPFS), not a folder from showDirectoryPicker(). Same handle API, but I haven't checked a real folder on disk.
| Naming | Writes | Errors | Files left | Lost |
|---|---|---|---|---|
| File name only | 7 | 0 | 5 | shoes/IMG_0001, bags/cover.jpg |
Path joined with _ | 7 | 0 | 5 | summer/sale_hat, bags/cover.jpg |
| Subfolders rebuilt | 7 | 0 | 6 | bags/cover.jpg |
createWritable() on an existing file just starts from an empty file, so the later image wins. The red shoe photo vanished under the green bag photo. My loop counted seven successful writes every time, so a write counter would never catch this.
Flattening the path fixed one clash and made another
Joining the path into the name (drop the top folder, swap / for _) was my first quick fix. It separates shoes_IMG_0001.jpg from bags_IMG_0001.jpg, and it also turns summer/sale_hat.jpg and summer_sale/hat.jpg into one summer_sale_hat.jpg, because people already use _ in folder names. Still 5. The clash that survives every path scheme comes from the conversion itself: cover.jpg and cover.png live side by side in the source and both want to be cover.jpg.
Rebuilding the folders plus a counter kept all seven
The version I'm keeping does two things. It drops the picked folder's own name from webkitRelativePath, then rebuilds each remaining subfolder under the output folder with getDirectoryHandle(name, { create: true }), one level at a time, so the shoe photo lands in shoes/ and the bag photo in bags/. It also keeps a Set of every output path it has handed out, lowercased, and when a path is already taken it tries -2, -3 and so on before opening the file for writing. That's about fifteen lines of JavaScript, and none of it is clever.
On the same folder it logged skipped products/.DS_Store, seven moves including products/bags/cover.png -> bags/cover-2.jpg, and files on disk: 7. The same counter on path-joined names also ended at 7. I lowercase because default macOS and Windows file systems treat IMG_0001.JPG and img_0001.jpg as one name, but my folder had no such pair, so that part is untested. Which file gets the -2 depends on the order the browser hands files over, so I'm adding a source-to-output list users can download.
Downloads flatten the path all over again
Per MDN's compatibility data (2026-10-15), showDirectoryPicker() is listed only for Chrome and Edge 86+ and Chrome on Android 132+, so on Firefox and Safari the results leave as downloads. I gave Chromium <a download="shoes/IMG_0001.jpg"> and it offered shoes_IMG_0001.jpg. A backslash got the same underscore. In other words the browser applies my first quick fix on its own, and by the same rule the summer_sale_hat.jpg clash would come straight back, so download names go through the counter before they reach the link. I haven't checked what Firefox and Safari do with a slash in download, and they're the browsers that will actually take this path.
When I don't want to code it, I use ImgIng (https://imging.ai/) in desktop Chromium. Its batch mode handles one image at a time and saves into a new folder it creates inside the one I choose. In the Firefox and WebKit engine builds I tried, not the release browsers, its save option was down to one-by-one downloads, which is the same Chromium-or-downloads split.
What I check before turning the button on
The seven-image folder now lives next to the code, .DS_Store and empty folder included. After each run I compare images in with files actually in the output folder, never with the write counter. The source-to-output list will go out with every batch, so a shop owner can find which IMG_0001 became IMG_0001-2. Slash names in Firefox and Safari downloads are the next thing I'll try.


