

A private, reproducible AI pipeline for photo curation, captioning, music tagging, and folder export. No cloud uploads. No subscriptions.
I had 585 photos in one folder and no plan for them. I built a workflow with GitHub Copilot in VS Code that fixed this in one extended session. It ran on a Python script and a Markdown file, CURATION.md, that tracked every decision, across six phases. The folder became 62 ready-to-post directories. Every step happened on my own laptop, and no photo ever reached a server.
585 Images Named IMG_20241103_182344.jpg

Files in the folder looked like IMG_20241103_182344.jpg, sitting next to screenshot_edit_final_v3_REAL.png and remini-enhanced-kgwv6b.jpg, in no order at all. Every photographer collects a folder exactly like this one.
Lightroom already solves editing. It does not solve curation at scale. That work means deciding which 12 photos from a three-day trip belong together. It means keeping the pacing across 60 posts from repeating the same mood, twice on adjacent posts.
Music has to match the visual energy of each post, without reusing a track on the post next to it. And certain people and certain locations have to stay off public content entirely. An AI can close that gap, but only when you hand it the structure to work from.
From One Folder to 62 Named Directories

I used GitHub Copilot in VS Code and local Python 3. In one extended session, I went from a raw image dump to this:


Every operation happened locally. Copilot read filenames and the descriptions I had logged, not pixels, and my own curation criteria decided every grouping. My photos stayed on my machine the entire time.

The CURATION.md summary table: 62 active posts, 585 images assigned, zero cuts. This single document replaced every folder, sticky note, and spreadsheet I would have otherwise needed.
The Input Prompt That Started Everything
Here's the prompt architecture. Adapt it to your project:
I have [N] photos in [local folder path].
My goal: group them into [platform] posts for publishing over [timeframe].
Curation criteria:
- Timeline: roughly chronological, anchored to real events/trips
- Location: photos from the same place should travel together
- Vibe / mood: visual tone should be internally consistent per post
- People: flag posts that include [specific people] so I can decide on privacy
- Quality gate: no blurry, test shots, or obvious duplicates in any post
I have [an existing metadata log / no log — build one].
My image folder is [path]. Compressed derivatives live at [path].
My music taste: [genres, artists, references].
Deliverables I want:
1. A CURATION.md with all posts, image assignments, captions, music picks
2. A Python export script that builds a FINAL/ folder from CURATION.md
3. A post.txt inside each FINAL folder with caption + music ready to paste
4. An audit flag if any post breaks the [platform] image cap
Privacy requirement: all processing must be local. No uploads.The Six-Phase Workflow

Phase 1 Audit and Inventory (Don't Skip This)
The AI didn't start with grouping. It read the existing metadata, counted files, and ran a structural audit first, and that audit found three problems before a single post got named. One post had 24 images, past Instagram's hard cap of 20, so it got split into two. Index 208 in my image log turned out to be a session boundary marker, not a real image, silently included in a post assignment. Thirty-two images from my oldest batch had never been individually logged.
Lia Haberman reported the carousel limit change when Instagram rolled it out.
Skipping this step would have shipped a broken export. The oversized carousel would have silently dropped four images at upload time. The ghost index would have thrown a file-not-found error during export. Neither failure would have announced itself without a deliberate check first. That is why the audit runs before anything else now, on every batch.

A section of image_log.md. SESSION 3, images 33 through 48. Each image logged with its compressed filename, a plain-English description, and a semantic tag. The AI read this file and built groupings from it. Not a single pixel crossed the wire.
Phase 2 Grouping by Timeline, Location, and Vibe
Grouping 585 images from 18 months of shooting was the hardest part of the whole project. Photos cluster naturally by trip or life phase first. Location matters within that: a Mumbai weekend and a Goa weekend can happen close together in time and still look nothing alike, so they need separate posts.
Vibe breaks the tie when two image sets share a timeline and a location but split by mood. One trip can hold a golden-hour outdoor set and a nightlife indoor set, and those still need separate posts. People acted as a privacy gate throughout. Any image with certain people in it got flagged for my own review before it could go into a public post.
The AI proposed 60 posts initially. After the audit, that became 62, two splits from the carousel-cap fix, plus the 32-image unlogged batch resolving into two dedicated archive posts.
Each post got a real name instead of a filename, something like P41 — Kerala — Backwaters + Garage Café + Coast. Getting there took a few passes at the title until it matched what was actually in the frame, rather than just where the photos were taken.

Key Clusters and Themes Discovered: the AI's location map of the library before a single post was named. Clusters like "KATHA Records Series" and "CSMVS Museum" emerged from overlapping session ranges and shared visual context.

The Post Details section of CURATION.md. Each cluster converted into a named post with exact image indices and a one-line description of the visual brief. P05 (OOTDs and Mirror Selfies) came in at a full 20 images, right at Instagram's carousel cap.

SESSION 6 in image_log.md, images 81 through 96. The tagging discipline held across every batch: filename, description, and a semantic tag. Enough for the AI to reason about visual groupings without ever seeing the photos.
Phase 3 Building the Export Script
This is what made everything reusable. export_final.py is a Python script that:
Reads
CURATION.mdas the source of truthParses the image index list for each post
Resolves each index to the actual filename on disk (handling AI-enhanced variants, shortened UUIDs, WhatsApp-formatted names)
Copies the HD original into
FINAL/<PostID> - <Title>/P01a.jpg, P01b.jpg...Writes a
post.txtalongside the images with caption, music, and rationaleOutputs an
_EXPORT_REPORT.md, a full accounting of every post, file count, and any resolution errors
The resolver was the interesting part, because my filenames came in three different formats depending on which app had touched them last: IMG-20250103-WA0012.remini-enhanced.jpg from WhatsApp plus a suffix from Remini, an AI photo enhancer app, FF92EE38.jpg as a shortened UUID from a different app, and -kgwv6b.remini-enhanced.jpg as a partial hash format. The resolver cascades through them in order: exact match, then strip the .remini-enhanced suffix, then match on the first 8 characters as a prefix. Zero images failed to resolve.
The export script is also idempotent. Every run wipes FINAL/ and rebuilds it straight from CURATION.md. Edit a caption in CURATION.md and run the script, and the change is live in FINAL/. Move an image to a different post and run the script, and the folders reflect it instantly. CURATION.md is always correct, and FINAL/ is only ever a derived artifact of it.

P19, Golconda Fort Hyderabad after export: 10 images renamed sequentially (P19a through P19j), plus a post.txt with caption and music. Every file moved into place by the script, zero manual work.

D:\Remini\FINAL. 62 named, themed folders generated from CURATION.md. From "P01 - Desk & Dev Setup" to "P48 - Hotel Mirror Selfie + Canal Night" and beyond. One script run, fully reproducible.
Phase 4 Captions and Music
The caption philosophy was simple: terse and atmospheric for travel and scenery posts, since a Taj Mahal photo doesn't need the Taj Mahal explained under it, and warmer and more personal for portrait or home posts. No hashtag spam went into the draft, since that's a posting-time decision, and every caption had to add something the image didn't already say on its own.

The live VS Code session: post.txt open for P01, Desk & Dev Setup, CURATION.md active in adjacent tabs, the AI mid-way through captioning P21 to P30. Caption, music, and rationale drafted in one continuous pass.
Music followed a different rule: each track's mood had to amplify the image rather than fight it, and no generic "lo-fi beats" filler was allowed to stand in for an actual choice. After all 62 posts were drafted, a deduplication pass caught one track assigned to three different posts, plus several more repeating on posts that sat next to each other or shared a mood. Six swaps fixed it, each one a deliberate match for the post it landed on rather than a random substitution.
The export script stores a TRACK_REASONS dictionary for every post, recording why the music works. Months later, when I no longer remember the session, that reasoning is still sitting right there next to the file.

The Phase 3 Queue in CURATION.md. 13 of the 62 caption and music drafts. Disclosure for a locked-in desk setup, Cigarettes After Sex for a low-light portrait series. Each track justified by the visual energy of the post, not picked at random.
Phase 5 The QC Pass
Before calling the project done, we ran a coherence check across four questions. Does every image in a post share a real through-line, visual, narrative, or emotional? Does the title name what's actually in the post, or just where the photos were taken? Is the music still clean of duplicates after the Phase 4 fix? Are any captions too generic, too try-hard, or over-explaining the photo underneath them?
One title fix came directly out of this pass. P41 — Kerala became P41 — Kerala — Backwaters + Garage Café + Coast, because the images underneath it were three genuinely different subjects. The vague title would have misled anyone looking at the archive later, myself included.

Phase 6 Visual Sanity Check
The final step was a visual review, guided by AI-generated contact sheets. For the two most uncertain posts, P60 (Early Heritage & Portrait Archive) and P62 (Early Interiors, Culture & Misc Vibes), we generated contact sheets with PIL, the Python Imaging Library, a grid of every image in the post rendered at thumbnail size:
from PIL import Image
import os, math
def make_contact_sheet(folder, output_path, thumb_size=(300, 300), cols=5):
files = sorted([f for f in os.listdir(folder) if f.lower().endswith(('.jpg','.jpeg','.png'))])
imgs = [Image.open(os.path.join(folder, f)).convert('RGB') for f in files]
thumbs = [i.copy() for i in imgs]
for t in thumbs: t.thumbnail(thumb_size)
rows = math.ceil(len(thumbs) / cols)
w, h = thumb_size
sheet = Image.new('RGB', (cols * w, rows * h), (30, 30, 30))
for idx, t in enumerate(thumbs):
x, y = (idx % cols) * w, (idx // cols) * h
sheet.paste(t, (x, y))
sheet.save(output_path)Within seconds of viewing the P60 contact sheet, two images looked wrong next to the rest of it. A white fabric close-up and a lakefront scene had ended up alongside Jantar Mantar stonework and Mughal architectural portraits. Both moved to P62, which had a looser, more eclectic brief anyway and could absorb them without breaking its own coherence.
A filename list would never have caught those two images. The contact sheet did, in the time it took to look at it once.

P50, Goa Resort Morning after export: 14 images renamed P50a through P50n, with post.txt alongside. The original filenames were a mix of remini-enhanced variants, abbreviated UUIDs, and WhatsApp-formatted timestamps. The resolver handled all of them.
What Made This Different From Auto-Tagging Tools

Nothing left my machine during any of this. Python read local files, Copilot read filenames and logged descriptions rather than pixels, and the export script copied files within the same drive. That mattered more than speed did. The alternative was uploading 585 personal photos to a service to get them auto-labeled. I wasn't willing to do that for a set that includes people I'd rather keep off a server I don't control.
The bigger difference was that the AI worked from criteria I actually wrote down, not a fixed label set. I reviewed every grouping before it became final, so the curation stayed mine even though the sorting was automated.
CURATION.md also turned into something more than a working file I'll delete later. It holds every grouping decision and every caption, plus the reasoning behind each music pick, so six months from now I can still see why two images landed in the same post. The same setup handles 100 images or 5,000 without changing shape. Next time I dump a new batch, I run the same prompt architecture, add any new filename pattern to the resolver, and get another FINAL/ folder out the other end.

Take It With You
I packaged the entire workflow into a SKILL.md. Any AI assistant that supports the VS Code Copilot skill format can invoke it. The file contains the six-phase workflow, the CURATION.md schema, and the export script skeleton, along with fixes for the failure points this project actually hit: the carousel cap, the ghost index, and the three filename formats.
Drop it into your VS Code setup. Point it at your folder. If you want the SKILL.md file itself, ask. I'll send the version that already handles WhatsApp exports and Remini-enhanced filenames, so you are not rebuilding the resolver from scratch.
Run It Yourself
Here's the full starter prompt:
I have [N] photos in [local folder path].
My target platform is [Instagram / Substack / portfolio / personal archive].
Curation criteria:
- Timeline: [yes/no, describe the date range]
- Location: [yes/no, describe the geography]
- Vibe/mood: [describe your visual style — e.g., "film grain, golden hour, blue interiors"]
- People: [flag posts with [names] for my privacy review]
I have [an existing metadata log at path / no log — build one from filenames].
Compressed derivatives are at [path / same folder].
Music: I like [genre / artists / specific tracks file at path].
Please:
1. Audit: count files, find cap violations, flag ghost indices
2. Group: propose posts with names, index assignments, and image counts
3. Export: write export_final.py that builds FINAL/ from CURATION.md
4. Caption + Music: draft all posts, log TRACK_REASONS
5. QC: flag music collisions, title vagueness, grouping incoherence
6. Visual: generate PIL contact sheets for any flagged posts
All processing must be local. No uploads.





