Skip to content

▣ shipped · 603 GB · 1,620 clips

A wedding, 603 GB, and one laptop

1,620 clips multicam-synced by cross-correlation, 5,000+ photos graded to a learned house style, 14,077 duplicates proven — on a laptop and a subscription.

where it started

In December 2023, Vaidehi and I got married over two days at Rockford Resort in Mahabaleshwar — a Marathi wedding on the 20th and 21st. By the time the celebration ended, our memories existed as a sprawl of files: a Sony shooter's RAW frames, a Nikon shooter's JPGs, drone footage, and hours of video from a small army of cameras. Two photographers, a drone, five video cameras, and a room full of phones.

Those files had been copied, re-copied, half-edited and stashed across multiple drives for over a year. Nobody had the full picture of what we actually had. I didn't want a folder I would never open again. I wanted a real, browsable archive: every event named correctly, the best photos edited, the dance performances cut together, and all of it living on my own server so it stays ours.

What follows is the honest account of how that got done — the wins, the wrong turns, and the techniques that actually moved the needle. Where I have hard numbers I give them; where I don't, I say so. It was not a one-shot prompt. It was many sessions of real back-and-forth with Claude: I'd review a batch, say "too pink," and we'd re-think the approach and re-run it. The good result came from iteration.

A photograph from the sangeet: the bride and groom in evening formal wear, caught mid-frame under a hanging white floral canopy on the night's chevron walkway, one frame out of the terabyte-plus of raw footage the rest of this piece is about wrangling.

01 — one frame of 33,302

A photograph from the sangeet: the bride and groom in evening formal wear, caught mid-frame under a hanging white floral canopy on the night's chevron walkway, one frame out of the terabyte-plus of raw footage the rest of this piece is about wrangling.

sangeet · rockford resort, mahabaleshwar · 20–21 december 2023

why it took two and a half years

The wedding was December 2023. This work finished in July 2026. Two and a half years passed, and the reason was not technical.

Shortlisting the photos, watching every video to find the moments worth keeping, then the edits and the enhancements — it was going to take too many man-hours for me, or for me and Vaidehi together. So it kept getting pushed. That is the honest reason: not a blocker, a budget of human attention we didn't have. Which also means the honest counterfactual for this project is not "a studio would have charged us X." It is that this would never have got done at all.

The turning point was specific, and it was recent. Not "AI got good" in the abstract — two concrete things landed together: Claude Code gained the ability to read image files directly, and I finished getting Immich fully set up with its ML stack running locally. Once both were true, I could ask Claude Code to use Immich's ML to parse the images — find the bride and groom across the archive, build their timeline, and build the photo and video timeline. The timestamps on the assets had problems, so the chronology had to be reconstructed and corrected too. That is the part that had been out of reach. I also had a lot of time to explore using AI this way, which matters more than it sounds.

Why not Adobe, CapCut, or the DJI app. Those tools exist and they work. But they come with their own templates and styles, and the output looks like their output. More decisively: even with their AI features, they are not built to sit in front of half a terabyte of unsorted, mis-timestamped footage across six folders on different drives. The bottleneck was never "apply an effect." It was inventory, dedup, chronology, and finding the good moments. That is the part no editing suite does for you, and it is the part that made everything else possible.

Everything below ran on one Windows laptop with 16 GB of RAM and a GTX 1660 Ti — the same box that hosts my Docker homelab — one Claude subscription, and open-source software.

01 — make sense of the pile

The first job was simply to know what existed. Claude cataloged 33,302 files totalling ~603 GB spread across six source folders on different drives — a master raw source, a rescued set of earlier conversions, a hopelessly redundant working folder, and a few "for edits / shortlisted / edited" folders left over from a previous attempt that had stalled.

Then came the part I cared about most: nothing gets deleted on a guess. Every duplicate claim had to be proven byte-for-byte. The pipeline was two-stage, for cost reasons:

  1. Group every file by exact byte size — only same-size files can possibly be identical.
  2. For each size group, take a fast head + tail partial hash to drop coincidental size matches cheaply.
  3. Run full SHA-256 on the ~26,760 survivors (~410 GB) to prove true byte-identity.
  4. Group by hash; the copy living in the master raw source always wins as keeper, and everything matching it elsewhere is flagged a duplicate.

The verdict: 14,077 exact-duplicate files, about 219 GB of pure redundancy, including one working folder that turned out to be 99.8% duplicate. Every duplicate was written to a CSV pointing at its canonical keeper before a single byte was touched. Only after I'd read the report did anything get deleted, and even then the protected master, the rescue set, and the professional finals were marked never-delete. We reclaimed ~217 GB and kept a clean, unique archive of roughly 383 GB.

What was actually in there, by volume: 22,754 JPGs at 266 GB, 1,628 MP4s at 174 GB, 7,193 Sony ARW raws at 162 GB, and about a gigabyte of DNGs and XML/SRT sidecars. The two-stage approach meant full SHA-256 only ever ran where it could possibly matter, which is both fast and provably safe — the two properties that mattered, in that order.

02 — make them beautiful

The photos came from two very different cameras — a Sony A7 III shooting ARW raw plus JPG, and a Nikon D810 shooting JPG — with a DJI drone adding aerials. Rather than impose a generic look, we tried to learn our photographers' look.

Claude analysed 41 original→edited sample pairs from our photographers' own finals and measured what they had actually done: brightness, contrast curve, vibrance, and a mild warm channel balance. That became the starting grade, applied to Sony RAW developed through rawpy.

It backfired. The rich grade pushed a pink cast, and the correction for the pink over-shot into a yellow cast. The fix was humility rather than cleverness: drop the fixed channel pushes entirely and switch to per-image auto_wb(), which neutralises casts off low-saturation surfaces while preserving the colours that were meant to be there — haldi-yellow, mandap-pink. Gentle contrast, mild vibrance, a touch of sharpening. Done.

The details that made or broke a batch were all small and all necessary:

  • Adaptive gamma for the sangeet. Night-time dance shots came out too dark and midtone lifts couldn't reach. A dedicated profile applies an adaptive gamma lift, with gamma chosen per image by luma, to rescue faces out of deep shadow while protecting the stage LEDs from blowing out.
  • Brightness-robust blur filtering. A naive sharpness metric punishes dark-but-sharp night photos. We scored focus from high-percentile edge energy on a resolution-normalised copy, so the question became "is anything actually in focus?" instead of "is this image bright?" The threshold stayed conservative — it is easier to delete a keeper later than to recover a discard.
  • EXIF orientation. Roughly a fifth of the frames were shot vertical and the JPG path was rendering them sideways, because PIL ignores the EXIF orientation flag. One exif_transpose() call on load set them upright.
  • Camera-clock corrections. The Nikon's clock read 2014:01:01 and sat at an offset from the Sony — and it had been reset between the two days, so the gap wasn't even constant. We computed a per-day, per-folder shift so every album would sort in real time order.
  • Posed shots get a passthrough. Learned the hard way: the photographers' finished portraits do not want my enhancement pipeline. A resize-only passthrough preserves their deliberate deep blacks. Candids get the light edit; posed finals are left alone.

The pipeline produced about 5,113 edited photos; after my own re-culling it settled near 4,900 — auto-graded day and night, oriented, blur-filtered, with corrected timestamps written back into EXIF.

A sample straight out of the pipeline, in ceremony order: arrival at the resort, a bouquet held in mehndi-decorated hands from Seemant Poojan, a Haldi portrait of the groom in a yellow kurta beside a flowering hedge, Lagna Vidhi, the bride's entry at Mangalashtak, the Varat shot from the drone, reception portraits (photographer-graded passthrough), and a Sangeet stage performance under the adaptive-gamma night grade. A sample straight out of the pipeline, in ceremony order: arrival at the resort, a bouquet held in mehndi-decorated hands from Seemant Poojan, a Haldi portrait of the groom in a yellow kurta beside a flowering hedge, Lagna Vidhi, the bride's entry at Mangalashtak, the Varat shot from the drone, reception portraits (photographer-graded passthrough), and a Sangeet stage performance under the adaptive-gamma night grade. A sample straight out of the pipeline, in ceremony order: arrival at the resort, a bouquet held in mehndi-decorated hands from Seemant Poojan, a Haldi portrait of the groom in a yellow kurta beside a flowering hedge, Lagna Vidhi, the bride's entry at Mangalashtak, the Varat shot from the drone, reception portraits (photographer-graded passthrough), and a Sangeet stage performance under the adaptive-gamma night grade. A sample straight out of the pipeline, in ceremony order: arrival at the resort, a bouquet held in mehndi-decorated hands from Seemant Poojan, a Haldi portrait of the groom in a yellow kurta beside a flowering hedge, Lagna Vidhi, the bride's entry at Mangalashtak, the Varat shot from the drone, reception portraits (photographer-graded passthrough), and a Sangeet stage performance under the adaptive-gamma night grade. A sample straight out of the pipeline, in ceremony order: arrival at the resort, a bouquet held in mehndi-decorated hands from Seemant Poojan, a Haldi portrait of the groom in a yellow kurta beside a flowering hedge, Lagna Vidhi, the bride's entry at Mangalashtak, the Varat shot from the drone, reception portraits (photographer-graded passthrough), and a Sangeet stage performance under the adaptive-gamma night grade. A sample straight out of the pipeline, in ceremony order: arrival at the resort, a bouquet held in mehndi-decorated hands from Seemant Poojan, a Haldi portrait of the groom in a yellow kurta beside a flowering hedge, Lagna Vidhi, the bride's entry at Mangalashtak, the Varat shot from the drone, reception portraits (photographer-graded passthrough), and a Sangeet stage performance under the adaptive-gamma night grade. A sample straight out of the pipeline, in ceremony order: arrival at the resort, a bouquet held in mehndi-decorated hands from Seemant Poojan, a Haldi portrait of the groom in a yellow kurta beside a flowering hedge, Lagna Vidhi, the bride's entry at Mangalashtak, the Varat shot from the drone, reception portraits (photographer-graded passthrough), and a Sangeet stage performance under the adaptive-gamma night grade. A sample straight out of the pipeline, in ceremony order: arrival at the resort, a bouquet held in mehndi-decorated hands from Seemant Poojan, a Haldi portrait of the groom in a yellow kurta beside a flowering hedge, Lagna Vidhi, the bride's entry at Mangalashtak, the Varat shot from the drone, reception portraits (photographer-graded passthrough), and a Sangeet stage performance under the adaptive-gamma night grade.
A sample straight out of the pipeline, in ceremony order: arrival at the resort, a bouquet held in mehndi-decorated hands from Seemant Poojan, a Haldi portrait of the groom in a yellow kurta beside a flowering hedge, Lagna Vidhi, the bride's entry at Mangalashtak, the Varat shot from the drone, reception portraits (photographer-graded passthrough), and a Sangeet stage performance under the adaptive-gamma night grade.

03 — give it a structure

To organise thousands of photos, Claude first auto-clustered the timeline into scenes using a mix of time gaps and visual similarity, and rendered contact-sheet montages so I could eyeball each cluster at a glance. That got us into the right ballpark — morning prep, haldi, sangeet, the wedding day, reception.

Then I took over. I re-organised everything into a proper Marathi-wedding taxonomy, because the ceremony names carry meaning a clustering algorithm has no way to know: Seemant Poojan, Haldi, Sangeet, Lagna Vidhi (with Kanyadan and Sunmukh), Mangalashtak covering the bride's entry and the Varat, the lunch, the photoshoots and venue sets, and the Reception. My folder structure became the authoritative event map, and the clustering became what it should have been all along — a first draft.

Lagna Vidhi, Rockford Resort, Mahabaleshwar, 21 December 2023: under the flower-hung mandap, the Sahyadri ridgeline falls away behind us into haze — the reason we picked a hilltop venue in the first place.
Lagna Vidhi, Rockford Resort, Mahabaleshwar, 21 December 2023: under the flower-hung mandap, the Sahyadri ridgeline falls away behind us into haze — the reason we picked a hilltop venue in the first place.

Before uploading, one more safety pass: a timestamp-parity check. Because the Nikon clock had been reset between the two days, a naive upload would have scattered each album across the timeline. A per-folder clock alignment shifted the Nikon frames to match the Sony's true times, and a parity script verified it before anything left the laptop.

The archive then went up to a self-hosted Immich server running on my homelab box, through the official Immich CLI and REST API — 27 albums mirroring the taxonomy, with tags, ratings, folders and face recognition enabled. Immich dedups uploads by checksum, so the whole upload is safely re-runnable, which matters when you are going to re-run it.

An auto-generated scene contact sheet for the Sangeet cluster. Rendering a whole cluster as one montage is what let me judge a scene boundary in a second instead of scrolling a thousand thumbnails.
An auto-generated scene contact sheet for the Sangeet cluster. Rendering a whole cluster as one montage is what let me judge a scene boundary in a second instead of scrolling a thousand thumbnails.

04 — the hardest part

The video side was a beast: 1,620 raw 1080p clips, ~174 GB, shot by several cameras — two main videographers, a fixed centre camera, and the drone. First surprise: zero exact-duplicate video files. The apparent "duplicates" were multi-camera coverage of the same moment, which is a gift rather than a problem, once you can line the angles up.

Two gnarly issues surfaced immediately. One videographer's camera clock ran about 9.5 hours fast, which made performances look like they had happened twice; we re-anchored it using medians of shared performances. And video creation_time is stored in UTC, so everything needed a +5:30 shift to read in IST.

The breakthrough: GCC-PHAT audio sync. To assemble multi-camera edits, every angle has to lock to the same audio timeline. Plain envelope cross-correlation barely worked — confidence around 0.2, which is a polite way of saying noise. Switching to GCC-PHAT (generalised cross-correlation with phase transform), anchored to a clean reference song track, moved confidence into the 25.8–352.6 range — a median around 105 against a lock threshold of 25 — and produced frame-accurate locks. The key insight was directional: instead of matching cameras to each other, where a repeated song creates ambiguity, each clip syncs to the clean song track — one unique audio reference that appears once. That gives a precise song-position for every angle, and an assembly script places each clip at its true song-time and cuts between cameras over the clean audio.

The first test run was the bride-groom couple's first stage performance (they performed twice that night) — three cameras, the locked-off centre as anchor plus the two side angles. GCC-PHAT brought them together perfectly. I assembled six versions, v1–v5 as full edits for quality review and v6 as a GPU-compressed final. At 23 seconds into the v6 encode, it froze. ffmpeg hung, deadlocked somewhere in the compression pipeline.

Rather than debug the timeout, I built around it. The insight was that reviewing complete edits was the wrong loop to be in anyway: I didn't need a finished render to decide which seconds were good. So the review tool became an interactive marking dashboard — a Flask backend with an HTML5 Vue front end, loading a synced multicam preview, letting me click the timeline to set in-points and out-points, drag to adjust, and use a pencil icon to mark exact seconds. Every mark is a JSON record: timestamp, duration, label. A batch encoder then reads the JSON and cuts only the marked segments, side-stepping the full-encode hang entirely and letting me iterate without waiting an hour for a render I was going to reject.

With the marking tool working, syncing became a production line. All nine sangeet dance performances went through GCC-PHAT alignment and came out as clean preview videos — the bride-groom performance at 271 s across 3 angles, Prathamesh & Jasmine at 276 s with 14 of 15 clips synced, Tanishka at 234 s (14/15), Anishka & Arshiya at 204 s (9/11), my family's performance at 234 s (10/11), Shatakshi Joshi at 96 s (3/4), Vaidehi & Mrunal at 120 s (7/8), Avni & Divija at 192 s (5/6), and Shruti & Shreeja at 54 s (2/3). Confidence scores ran from the mid-20s to 353. Every one of them cleared the sync stage and went into the marking queue.

fighting the flicker and the flashing lights

The locked-off centre camera on the couple's performance was gorgeous — steady, wide, never missing a beat — and also a mess. Two LED video walls flanked the stage and flickered hard on camera, and pulsing, shifting-colour stage lights swept across the dancers, swinging skin tone from warm to sickly cyan and back within a handful of frames.

The flicker was the more mechanical of the two problems. Claude built a masked, gradient temporal treatment that touches only the two side screens: a five-frame, centre-weighted blend plus a deflicker pass, smoothing the strobing without softening the dancers, who sit outside the mask entirely. A second pass adds radial brightness-dampening to the blown-out screen centres — a flat top with a smooth falloff, so the hottest pixels get pulled down without banding. All of it confined by feathered masks, so the couple were never touched by either treatment. (That one sentence is doing a lot of hiding; Act 07 is the version of it that tells the truth.)

The skin-tone problem took longer, and the dead ends were the interesting part. First attempt: correct a whole region of the frame around the couple. Result: an ugly yellow circle sitting on the floor wherever the correction region happened to land — obviously wrong the moment it moved. Pivot: detect actual faces with YuNet and correct only those. Much better, except the temporal smoothing between frames now looked odd, with faces drifting warmer and cooler out of sync with the light. Dropping the smoothing and correcting per-frame fixed the drift, but exposed the real problem underneath: we were correcting for a flash we weren't actually measuring, only guessing at from noisy face pixels.

The breakthrough was to stop reading faces and read the floor. The stage had a static black-and-white pattern that lights up in perfect sync with the flash — mostly unmoving, always in frame, exactly what a locked-off camera wants. Sample brightness at a handful of fixed spots along a horizontal line across that pattern, then take the median across spots rather than trusting any single one. When the roaming cameraman or the dancing couple covers a spot or two, the rest still carry the signal, so one occlusion never kills the read. Simple, and robust in exactly the way this footage needed. The floor signal recovered the flash frequency cleanly — a proper waveform where the face-based signal had been a shrug.

That solved when. What remained was where to apply a fix, and how to apply it without wrecking the skin.

The where turned out to be its own small research project. A single full-frame segmentation pass — one model call asked to find and mask every person in the shot — kept losing the bride: her gown was dark, the LED wall behind her was blazing, and a full-frame pass simply doesn't have the resolution budget to resolve a dark, detailed edge against a bright, busy background. The fix was to stop asking one call to do everything at once. YOLO11 first finds each person's face, a much easier target even against a hot background; the pipeline then crops tightly around each detected face, upscales the crop, and runs YOLO11's segmentation model on the enlarged patch alone. At that scale the gown edge against the LED wall stops being a coin-flip. Each per-person mask composites back into full-frame coordinates, and a pose-skeleton pass tracks raised arms and hands through the same crops so gestures mid-dance stay covered instead of sliding out from under the mask. The LED wall, being outside every person crop, is never touched by any of it — excluded by construction rather than by a hand-tuned exclusion zone.

The how was the last piece, and it is where the earlier dampening idea failed specifically on skin: crudely pulling down brightness inside a region made faces look flat and grey, because it wasn't dampening the flash, it was dampening the person. The idea that worked was to stop treating this as a special-case dimming problem and treat it as a normal edit, tightly gated. Inside the body mask only, apply the same highlight recovery, exposure pull-down, contrast and skin-tone correction the rest of the project already used — pulled back toward a reference skin tone sampled from the couple in non-flash frames — and scale the strength of that correction by the floor signal itself, frame by frame. A dim floor reading means light correction; a blown-out flash frame means the full correction. Nothing outside the mask moves, and nothing inside it goes grey.

Put together: the floor signal says when and how much, the per-person crop-and-upscale mask says where, and ordinary photo-editing math gated by both says how. Three separate, boring, well-understood problems instead of one hard one. That reframing is the single most useful thing this project taught me.

The floor-signal detector: fixed sample points across the stage's black-and-white pattern, median-voted so a dancer or cameraman covering one spot never breaks the read. This is what turned "when does the flash fire" from a guess into a measurement.
The floor-signal detector: fixed sample points across the stage's black-and-white pattern, median-voted so a dancer or cameraman covering one spot never breaks the read. This is what turned "when does the flash fire" from a guess into a measurement.
The earlier attempt, kept for comparison: reading the flash straight off two face and cheek patches (S1, S2). Workable, and far noisier than the floor — the reason the correction had been chasing a signal it couldn't actually see.
The earlier attempt, kept for comparison: reading the flash straight off two face and cheek patches (S1, S2). Workable, and far noisier than the floor — the reason the correction had been chasing a signal it couldn't actually see.
Before: the cyan-shifted stage lights wash straight over the couple's skin during a flash frame.
Before: the cyan-shifted stage lights wash straight over the couple's skin during a flash frame.
After: per-frame face correction de-cyans just the faces, leaving the rest of the frame — and the light show — alone. Beautiful on a still, and, as the next section explains, wrong in motion.
After: per-frame face correction de-cyans just the faces, leaving the rest of the frame — and the light show — alone. Beautiful on a still, and, as the next section explains, wrong in motion.
The final answer to "where": a per-person YOLO11 segmentation mask (green fill, red outline) plus pose skeleton, computed on an upscaled face-crop mid-flash. A clean cutout of the couple with the blazing LED wall entirely excluded, and both raised hands still tracked.
The final answer to "where": a per-person YOLO11 segmentation mask (green fill, red outline) plus pose skeleton, computed on an upscaled face-crop mid-flash. A clean cutout of the couple with the blazing LED wall entirely excluded, and both raised hands still tracked.
Two overlapping dancers, each segmented independently. The crop-and-upscale approach holds up even when bodies cross, which the single full-frame pass never did.
Two overlapping dancers, each segmented independently. The crop-and-upscale approach holds up even when bodies cross, which the single full-frame pass never did.
Choosing the "how": a contact sheet of eight candidate grading approaches side by side. Comparing them as a sheet rather than one at a time is what made mask-gated normal editing the obvious pick.
Choosing the "how": a contact sheet of eight candidate grading approaches side by side. Comparing them as a sheet rather than one at a time is what made mask-gated normal editing the obvious pick.
Per-face recovery variants — dialling in how much highlight and skin-tone correction to pull back toward the non-flash reference before it starts looking synthetic.
Per-face recovery variants — dialling in how much highlight and skin-tone correction to pull back toward the non-flash reference before it starts looking synthetic.

what looked right on a still looked wrong in motion

This is the humbling part, and my favourite part of the whole saga. On a frozen frame, the mask-gated skin-tone repaint was gorgeous: pull the cyan out of the faces, warm them back toward the reference, and the still looked like the flash had never happened. I was ready to call it done. Then we watched it move.

In motion it fell apart. Frame to frame, the correction was chasing a light that was itself strobing, so the couple's skin seemed to shimmer and crawl — subtly re-tinting a few times a second in a way that no single still ever revealed. We tried the more careful versions too: a full skin-tone infusion toward the reference tone, and a matched-pair infusion that paired each flash frame with a nearby clean one and borrowed its colour. Both looked lovely paused and wrong playing. On video, the eye forgives a slightly-too-warm face far more readily than it forgives a face that won't hold still.

So we got humbler, which is the thing this project kept teaching. The version that shipped — the one I kept calling the fallback — touches no colour at all. Inside the same YOLO body-mask, driven by the same floor-flash signal, it applies a single gentle brightness-only pull-down on the couple during the hottest flash frames. No hue shift, no skin repaint, nothing that can crawl. Because it only ever lowers luminance there is nothing to shimmer, and because it stays inside the feathered body-mask there is no rectangle left sitting on the floor. The flash stopped shouting, the couple stayed themselves, and in motion it finally just looked right. The prettier idea lost to the calmer one; on video that is usually how it goes.

That version rendered out as the full 271.7-second performance with its audio at CRF 16, about 686 MB. After all the false starts, the centre camera was genuinely done.

05 — bringing it all together

This was the chapter all the sync work had been saved for. The corrected centre video became the audio spine, and Camera A's and Camera B's angles cut in over it, all three locked to the same GCC-PHAT timeline — and this time we took it the whole way: full length, opening entrance to the couple's finale embrace, one continuous edit instead of a proof-of-concept clip. A warm cinematic grade sits over the whole thing, a soft bloom lifts the highlights, and a gentle vignette pulled the eye back to the couple. Camera movement — slow pushes and pulls — appears only on the calm, held shots, never on anything already busy. Where the angles change they change with true cross-dissolves rather than hard cuts, so the transitions read as breathing rather than switching.

The zoom that had to go. The first pass at "make it fancy" put a beat-synced zoom-punch on every beat of the song. Machine-precise, and relentless. Watched back to back it was nauseating: a strobing push-in every half second reads as anxious rather than celebratory, and it fought the couple's own choreography instead of framing it. We scrapped it outright. Restraint reads as premium. The edit needed to move slowly and cut on the phrases of the dance — the places where the couple actually change what they're doing — not on the beat grid a metronome would pick.

The side cameras were still raw. Once the centre was graded and gorgeous, Camera A's and Camera B's angles suddenly looked worse by comparison — not merely less colour-matched, but genuinely flickering, dusty and raw next to the polish of the centre. Each side camera got its own per-camera deflicker pass plus a de-haze and saturation lift. The catch we hit along the way: reaching for a contrast or sharpen boost to make the image "pop" backfires. It doesn't add clarity, it re-amplifies the very flicker you just removed, because contrast punches up whatever brightness noise is already bouncing frame to frame. The pop had to come from colour, not contrast.

The GPU that wasn't the bottleneck. With three full-length, effects-laden angles to render, we went looking for speed and tried moving encoding to the NVIDIA GPU. That didn't pan out — NVENC was blocked by an old driver, with no GPU compute available to fall back on. But chasing the dead end turned up the real win: rewriting the effects themselves as fast ffmpeg C-filters, instead of Python-side per-frame processing, made the whole pipeline roughly 4–6× faster. The encoder was never the bottleneck. The slow part was always the effects math running frame by frame in Python, and fixing that mattered far more than which chip stamped out the final H.264.

By this point the bride-groom sangeet dance was done: full length, warmly graded, cross-dissolved between three cameras, moving slowly on purpose. The zoom-happy first draft went in the bin where it belonged, and the side cameras finally looked like they belonged in the same film as the centre.

06 — the finale

With the bride-groom dance finished, there was no reason left to edit everything else one performance at a time. So the shape of the work changed. Instead of one long conversation walking through one video, subagents fanned out across all the remaining guest dances and the rest of the ceremony footage at once — each one applying the house style we'd spent months learning: the colour grade, the deflicker logic, the cut-on-phrases discipline, all of it by then understood well enough to hand off.

The one place that couldn't parallelize was the laptop itself. It can think about many things at once, but it can only render one thing at a time — one GPU, one ffmpeg process that wants the machine to itself. So all that parallel editing funnelled into a single serial render queue: every cut gets prepared in parallel, then rendered one at a time in the order it becomes ready, instead of half a dozen renders fighting over the same encoder and the same 16 GB of RAM.

Editing, parallel. Rendering, serial. It is a small piece of infrastructure, and it is the one that let the whole wedding get finished together instead of one video at a time, months apart.

07 — the director's cut of a footnote

Back in Act 04 the LED-wall flicker fix got one sentence — "a five-frame, centre-weighted blend plus a deflicker pass." That line hid weeks of work, several dead ends, and a couple of ideas that were mine rather than the model's. This is that story told properly.

The problem, precisely. The giant LED wall behind the couple wasn't just bright, it was beating. LED walls refresh at their own frequency; our cameras shot at 100 fps; the two rates don't share a clean common denominator, so frame to frame the wall's brightness rides a slow, visible pulse. That's worse than a simple strobe, because it drifts in and out of phase with the shutter instead of repeating on a fixed cadence you could subtract out.

The dead ends, honestly reported. First move: reach for ffmpeg's built-in deflicker filter and work through its modes. median softened it a little. am (averaging) softened it a little more, differently. pm (previous-minimum) was the most aggressive and also the most broken — on the hottest flash frames it didn't smooth the wall, it blacked entire frames out. Next attempt: a foreground-masked brightness detrend, fitting and subtracting a slow trend from just the background region. Better than nothing, worse than acceptable — it flattened some of the pulse and left a visible, rhythmic breathing behind. Three ideas, three partial results, none shippable.

The frame-rate insight. The turn didn't come from trying a fourth filter. It came from looking at the footage and deciding the flicker's rate mattered more than its brightness — that this was a frequency problem, not a levels problem. So instead of guessing, we built tools to actually look. A small local web server hosted two of them: an fps comparator, playing the same few seconds of the same clip side by side at 25, 30, 50, 60 and 100 fps so the eye could catch where the pulse got worse or better; and a frame inspector, stepping through a hundred consecutive frames one at a time so I could tag the bad ones by hand — no algorithm in between, just my eye and a "bad frame" button. The hand-tagging found the pattern every filter had missed: a clean ~40 Hz beat, roughly one bad frame in every 2.5. That number was the whole ballgame. Once the flicker had a period, it stopped being noise and became something you could design around.

The astrophotography idea. The fix that shipped came from a completely different hobby. In astrophotography you don't fight noise frame by frame — you take many exposures of the same static thing and stack them, so the noise that doesn't agree with itself averages away while the signal that does holds firm. Same thing here: fuse each frame with its neighbours using gradient weights, heaviest on the centre frame and falling off toward the edges, so the flickering wall blends into its own average while the anchor frame keeps the moment sharp.

It worked. The gradient-weighted stack cut the flicker by roughly 82%, and because it is just an ffmpeg filter chain — no per-frame Python, no phase detection, no chasing the 40 Hz beat one frame at a time — it ran fast and didn't care whether a given clip's flicker happened to land in or out of phase. That one idea became the deflicker for the entire bride-groom dance.

The last wrinkle: a ghost on the dance floor. Stacking frames to kill a static wall's flicker has an obvious cost if you apply it everywhere: anything that moves gets blurred across those same neighbouring frames. Sure enough, the fused version left a faint motion trail behind the dancers, a soft ghost following the couple's arms and turns. The fix was as direct as the diagnosis: don't fuse the whole frame, fuse only the LED screens. I opened a frame and clicked the exact screen rectangles myself — no auto-detection, just my eye and a mouse — and that hand-drawn mask became the boundary. Inside it, frames stack and the flicker disappears. Outside it, the couple stay exactly as sharp as the camera shot them.

The division of labour in this act is the one I'd point at if someone asked what working with a coding agent actually feels like when it works. The hunches were mine: that frame rate was the real variable, the stacking analogy, catching the motion trail, the hand-clicked mask. Claude's part was building the fps comparator and frame inspector fast enough to test those hunches within the hour, and turning "stack with gradient weights, masked to the screens" into an ffmpeg filter chain that actually ran.

08 — it grew

For the longest time the project had exactly one deliverable: the bride and groom's sangeet dance. That was the mountain. The sync, the flicker fight, the flash correction, the grade — all of it was in service of a single four-minute video. Then, quietly, the goal moved. Once the machinery for one multicam edit existed, making a second one stopped feeling impossible, and a third stopped feeling greedy. The slate grew to four fully polished, multi-camera family performances:

  • Bride & groom — the hero. They performed twice; three cameras (centre plus Camera A and Camera B) covered each stage performance, plus a drone cutaway on the second — seven videos in all, cut into the full 271-second arc from entrance to finale embrace.
  • Bride's parents — Vaidehi's mum and dad on stage, cut the same way.
  • Groom's parents — mine, given the identical treatment so the set reads as one film.
  • Nikhil & Aditi — a single continuous centre take (C0004) holding two back-to-back songs, 34 segments, its own marks-aware cut.

Each one is its own multicam edit — its own sync offsets, cut list, deflicker mezzanines and grade — but all built from the same shared playbook. What made four tractable instead of four times the work is the thing this act is really about: I stopped reviewing finished edits and started marking the raw footage in a language the edit could read.

The review server, and a four-key marking language. Somewhere in here the browser review tool grew up into a real local web app, served over my Tailscale homelab so I could open it from any device in the house without it ever touching the public internet. Instead of watching an assembled cut and saying "the bit around 3:20 is bad," I sat and watched every source clip and tagged moments as they went past, entirely by keypress. Four keys, one grammar:

  • xbad: a 0.5 s window the cut must dodge entirely. An out-of-focus lurch, someone crossing the lens, a dead beat.
  • hhighlight: a moment worth featuring. The cut actively steers toward these.
  • aaudience: a good crowd reaction, ideal for a cutaway when the stage needs a breath.
  • ccouple reaction: the dancers or the couple reacting — the emotional beats to hold on.

The bad windows land in bad_marks.json, the highlight/audience/couple tags in tags.json, both keyed by the source clip's path — so any tag maps onto any performance's centre timeline just by adding that clip's sync offset.

That was the real unlock: the marks drive the edit. An assembly planner reads them and builds a cut that provably steps around every x window of every on-screen source — including the 0.6-second cross-dissolve tails, which is exactly where a naive edit sneaks a bad frame back in — while featuring the h highlights and dropping to a audience shots where the main angle went soft. Where the centre has a bad block, a Camera B couple-closeup or a Camera A wide covers it. The edit stopped being me guessing at a timeline and became a direct compilation of what I had actually marked, one keypress at a time, across the whole slate.

Around the marking view, the same little server grew the tools I kept wishing for: the frame inspector, the fps comparator, and finally a finished-videos section with HQ playback of every completed cut in the browser and a fallback transcode toggle for machines that choked on the full-quality stream. What started as a throwaway http.server to sanity-check a timestamp had become the cockpit for the entire back half of the project.

09 — the right camera all along

Here is a plot twist I did not see coming after spending weeks rescuing the centre camera. The hero's centre angle — C0013, the beautiful locked-off wide the whole edit was built around — had a stubborn 10 Hz flicker beat, exactly five frames at its 50 fps, the LED wall pulsing against the shutter. We beat it, and the result was fine. But "fine after a fight" is not the same as "good to begin with."

Then, deep in the footage, we found C0026: a different centre angle of the exact same performance, shot from a steadier camera, that barely flickered at all. Same moment, same framing intent, but clean. Audio-synced, it slotted in at +279.96 s on the master timeline and simply worked. So the roles flipped. C0013 became audio-only — we kept its track as the spine, because that is what the whole edit was locked to — and C0026 became the actual centre video. Because it doesn't beat, it needs no fusion at all, which means it keeps all its frames and is sharper and richer than any deflickered version of C0013 could ever have been. All that flicker-fighting on C0013 wasn't wasted; it taught us the physics. But the best answer turned out to be a better camera, not a better filter.

The deflicker toolkit, in full. By the end there wasn't one deflicker trick, there was a small toolkit, and the art was matching the tool to the camera. Each of these was tried, measured, and either kept or explicitly rejected:

  • Gradient temporal fusiontmix=frames=5:weights='1 2 4 2 1', then fps=24. The astrophotography-stacking idea from Act 07, for the 100 fps side cameras. A weighted five-frame blend — centre 4×, neighbours 2×, edges 1× — that averages the 40 Hz refresh beat into its own mean while the heavy centre weight keeps the anchor sharp. Measured −82% flicker, phase-independent, and a fast ffmpeg C-filter. This became the standard side-cam mezzanine for the whole slate.
  • lumasoft — for a centre that does beat, like C0013's 10 Hz. A uniform five-frame fusion (weights='1 1 1 1 1') fully nulls the exact five-frame period across the whole frame, but would blur the dancers too — so the sharp original is composited back onto the dark (dancer) pixels through a feathered luma key. The bright LED screens fuse clean; the dark silhouettes in front of them stay crisp. No rectangle, no hard border — a mask made of brightness itself.
  • tmedian (radius=2) — the runner-up for beating centres, lowest motion-trail of the bunch. Kept in the kit, out-voted by lumasoft on the hero.
  • Full frames, no fusion — the C0026 lesson, and the Nikhil & Aditi centre (C0004) too. When a centre only shimmers ~1%, you don't touch it; you keep every frame for the richness and tame the screens with grading instead.
  • Highlight-rolloff curve — for over-bright LED walls on steady centres: curves=all='0/0 0.5/0.5 0.75/0.72 0.88/0.80 1/0.82'. A gentle shoulder that pulls the blown-out screen tops back down without banding, so the wall reads as a wall and not a lamp.

The de-haze grade. Even deflickered, the side cameras looked hazy — a milky veil over the frame that made the colours read flat. The culprit was mundane: those cameras lifted their black floor, and the old side grade only rolled off highlights, so nothing ever pulled the greyed-out blacks back to black. The fix we settled on (we called it S2) does three honest things: crush the milky black floor with a curve that pulls 0.22 down to 0.08 — the actual de-haze; a wide-radius clarity pass (unsharp=13:13) for local contrast and dimensionality, since ffmpeg has no real dehaze filter; and vibrance rather than blunt saturation, so the under-saturated colours come up while skin and the pink LEDs stay protected. Contrast stays deliberately mild, because as Act 05 learned the hard way, heavy contrast or sharpening after deflicker re-amplifies the strobe you just removed.

The look rules, finally locked. After a year of "too pink, too dark, too dusty," the house look for the sangeet stopped being a moving target and became three flat rules every performance obeys. No pink or magenta tint — this one would not die, the working docs kept quietly reintroducing a red/blue curve lift as a "subtle romantic hint," I'd see pink and ask for it gone, and it would creep back on the next render, until it was explicitly killed: neutral grade, no red or blue curve at all. Any pink still visible is the magenta stage lighting in the source, and we do not try to cancel it with green, because that looks unnatural. No vignette — the old cinematic assembly darkened the corners and I wanted the frame crisp edge to edge. And de-hazed, rich, crisp, which is just the positive statement of the first two.

The through-line of this act: the best fix is usually the humblest one. A steadier camera beats a cleverer filter. A curve that crushes the black floor beats a romantic tint that keeps causing arguments. Measure, keep what's true, stop decorating.

10 — a fleet of me

By the time the slate was four performances plus a pile of ceremony footage, the bottleneck wasn't the technique any more. It was that a single conversation works on one thing at a time, and there were a dozen things left to finish. So the shape changed again. Claude stopped being the editor and became the orchestrator: a fleet of Claude subagents, roughly one per performance, each handed the same playbook — the sync offsets, the marks, the deflicker mezzanines, the S2 grade, the no-pink/no-vignette rules — running in parallel. Each agent owned its edit end to end.

The one thing that must never parallelize. There is exactly one resource on this laptop you cannot share: the render. It is a RAM-tight 16 GB box that also hosts a Docker homelab, and a single heavy ffmpeg pass with a dozen decoders open is already flirting with the Windows commit limit. Two at once don't run slower — they OOM-crash the machine, sometimes taking the homelab's DNS down with it. So the agents could think in parallel but had to render in single file. The mechanism was a machine-wide file lock: every heavy encode went through render_with_lock.py, which grabs a lockfile before it starts and releases it when it's done. An agent that wants to render and finds the lock held waits its turn.

The honest beats, because a fleet is messy. Directing autonomous agents is not the clean org chart the diagram implies. The real run had all the failure modes you'd expect from a crew on a night shift, and a few I didn't:

  • Agents stuck in re-render loops. A couple would render, run their own quality check, decide it wasn't perfect, and render again — burning the shared lock on the same clip over and over while everyone else waited. Left alone, one anxious agent could starve the queue.
  • An agent that hit its session limit mid-render. One ran out of runway partway through its performance and simply stopped existing, leaving a half-finished job and no author to come back to it.
  • Detached renders that outlived their agent. Because heavy encodes were launched detached, so a foreground call couldn't hit a tool timeout, a render would keep going happily after its own agent wrapper had been culled — a headless ffmpeg dutifully finishing a video for an agent that no longer existed to notice.
  • Finishing by hand. When a render stalled, or its agent evaporated, or a loop needed breaking, someone had to step in directly: take the lock, run the final encode, verify it, move on. A good chunk of the finish line was quiet mopping up after the crew.

The orchestration wasn't magic and it wasn't tidy. It was a fleet doing the bulk of the work in parallel, a hard lock keeping them from killing the machine, and an orchestrator willing to roll up its sleeves and finish the jobs the agents couldn't. That is what actually got a whole wedding rendered.

11 — the finish line, verified

The war on frozen frames. One earlier render shipped with a flaw I still wince about: a single frozen frame, a moment where the picture just stuck for an instant. On a keepsake video that is the kind of thing you cannot un-see once you know it is there. So "no frozen frames" stopped being a hope and became an enforced discipline, from two directions.

After every render, a detector called check_frozen.py samples a few hundred frames, average-hashes them, and computes two numbers: the unique-ratio, meaning how many distinct frames there actually are — a healthy clip is near 1.0 — and the longest run of identical frames. A low ratio or a long identical run fails the video. Catching it after the fact isn't enough, so a second guard runs before the render: a pre-flight validate() pass that asserts no segment, including its 0.6-second cross-dissolve tail, ever trims past the end of its source clip's real footage. That overrun is the usual cause of a freeze — a cross-dissolve reaching for frames that don't exist and holding the last one instead. Assert it can't happen and it doesn't. Every final-round video in the slate is verified frozen-clean, both pre-flight and post-render hash check, green.

The GPU reality check. With a stack of full-length, effects-heavy renders to grind through, the obvious move was to put it on the GPU. The obvious move did not cooperate. The box's GTX 1660 Ti has NVENC hardware encoding, but this ffmpeg build wanted driver ≥610 and the machine was on 581.95, so h264_nvenc flatly refused. Intel QuickSync (h264_qsv) did work, and became the encoder of choice for the final passes — with a slightly deflating punchline: it barely mattered. Benchmarked, these renders are filter-bound, not encoder-bound. QuickSync and CPU x264 finish in about the same wall-time. The real speedup, back in Act 05, came from rewriting the effects as ffmpeg C-filters, not from any encoder. A clean lesson in measure-don't-assume: I went hunting for a GPU win and found the GPU had never been the bottleneck.

Iterating to the actual finish. The performances didn't cross the line in one render either. Each reached a "final round" — fully marks-aware, dodging every bad window, featuring the highlights, frozen-checked clean. For the hero that was 28 segments, the C0026 centre, the drone aerials, zero frozen frames. And then, because good is the enemy of done-right, a hand-tuned v2 on top: a few cuts retimed by feel where the automatic plan was technically correct but rhythmically stiff, and a slow zoom-in on the finale, one deliberate push toward the couple's last embrace so the film lands instead of just stopping. Superseded versions weren't deleted, they were archived, so the history of how each cut reached done is still there.

This is where a year of iteration actually ended: not with a clever new technique, but with a boring checklist held to. Marks obeyed. No pink, no vignette, de-hazed and crisp. Frozen-clean, twice over. One last slow push into the finale.

12 — the signal that lied

The groom's-parents edit had a fault I could not shake: it kept using the wrong footage. One of Camera A's clips landed on the wrong moment of the dance — my parents doing one thing on the centre camera and something else entirely on that angle — and to cover the seams, the cut leaned far too hard on couple and audience reactions. Something underneath was lying about when things happened. It took three attempts to find out what, and the frightening part is that two of those attempts were confidently wrong.

Attempt one: ask the cameras what time it was. The Sony clips carry XML sidecars, each holding a CreationDate — a wall-clock stamp of the moment the camera rolled. Easy, then: read the sidecars, find which of Camera A's clips overlap the centre camera's C0008, the 199.2-second anchor this performance hangs on, and drop them onto the timeline where the clock says they go. Except Camera A's clock was about 146 seconds off from the centre camera's. Nobody had synced these cameras on the day, and why would they. Wall-clock alone lied.

Attempt two: ask the audio. So we did what the project already knew how to do and cross-correlated the sound. An onset-envelope correlation came back at about 0.10 confidence, which is a polite way of saying noise. Switching to GCC-PHAT jumped confidence 7–8×, and handed clip C0281 an offset of L = −126.77 s. After the clock fiasco, that felt like solid ground.

The false confirmation. Naturally we verified it. Pull a frame from the centre camera and a frame from Camera A at the same computed moment, put them side by side, and look. And there he was — my dad, doing the identical arm-raised pointing move in both frames. Same gesture, same beat. Pose matched. Confirmed, on to the edit.

It wasn't confirmed. It was wrong by well over two minutes, and it had just handed me a picture that looked like proof.

Here is why, and it is the thing I most want on the record: the song loops. Dad repeats that pointing move every single time the phrase comes back around. So a frame from the correct moment and a frame from a different repeat of the chorus look exactly alike — because they are alike. A single matching frame does not prove you found the right moment. It only proves you found a moment. The music that made the sync possible was also the thing making it ambiguous, and the confidence score had no way to tell me that. It was 7–8× sure of the wrong answer.

I caught it by watching the footage rather than the numbers: if the song has repeats, then matching one segment may not be matching the right segment — match a longer section, then do the time sync. That is the whole insight. If your signal repeats every few seconds, any match shorter than the repeat is guaranteed to be ambiguous, and the algorithm will pick one of the repeats and report high confidence, because from where it stands all of them are correct. The fix isn't a better correlator. It's a longer window, and ideally a signal that doesn't loop.

The real fix: stop listening, start looking. So we stopped trusting the audio entirely and cross-correlated the video brightness instead — the stage-light cues, the camera flashes, the LED-wall changes. Unlike the music, the light show mostly doesn't repeat: a particular flash lands at a particular second and never again. And we ran it across the entire 172-second overlap rather than a convenient window. A long section, on a signal close to unique in time.

The answer came back at L ≈ +13.7 s — about 141 seconds away from the confident, verified, pose-matched answer we'd had before. Two minutes and twenty-one seconds of wrong, wearing a photograph of itself as an alibi.

Verifying it properly this time. The lesson from the false confirmation is that one matching frame is worthless. So we compared frames at two well-separated moments — centre-time 42 s and 101 s — and checked not just the poses but the LED-wall content at both. That is the part that makes it real: a coincidental repeat of the choreography can line up at one point. It cannot line up at two points nearly a minute apart and have the giant screen behind the dancers showing the same thing at both. One match is a coincidence; two separated matches are a sync.

Proof point one — centre (left) and Camera A (right) at centre-time 42.5 s. Same pose, and the same angular red pattern on the LED wall behind them. A single frame like this is what fooled us the first time.
Proof point one — centre (left) and Camera A (right) at centre-time 42.5 s. Same pose, and the same angular red pattern on the LED wall behind them. A single frame like this is what fooled us the first time.
Proof point two — the same two cameras at centre-time 101.2 s, 59 seconds later. Poses match, and the wall shows the same hearts-and-starburst frame on both. Two separated matches is what a real sync looks like. Note the crew member kneeling in the centre camera's shot; he becomes relevant below.
Proof point two — the same two cameras at centre-time 101.2 s, 59 seconds later. Poses match, and the wall shows the same hearts-and-starburst frame on both. Two separated matches is what a real sync looks like. Note the crew member kneeling in the centre camera's shot; he becomes relevant below.

What the correct offset revealed. With C0281 finally in the right place, it stopped being the small cutaway we'd assumed. It covers the entire performance — centre 14 s to 186 s — a roaming wide angle of the whole dance that had been sitting there unused the entire time.

It also cleared up the other half of the mystery. Camera A was one camera: one operator, one body, one lens. So the clips that had appeared to match inside the dance — C0282, C0283, C0284 — were physically impossible. It cannot be shooting a wide of the stage and a close-up of the audience at the same instant. Taking C0281's verified offset and adding the exact gaps between his clips, they land after the dance ended; they belong to the next performance entirely. Their earlier "matches" were the loop fooling the algorithm a second time, and that phantom footage is exactly why the edit had been over-using reaction shots — it was reaching for cutaways that didn't exist in this song.

The last 0.7 seconds. Even after all that, watching it back, the 27–28 second mark drifted. The culprit was a fine-tune pass that had run in a short window where the music is nearly identical to itself — the exact mistake we'd just spent a week learning about, sneaking back in through the polish step — and landed 0.7 s off. Redone as a frame-accurate brightness match, sampling at 24 fps across the full overlap, it settled at L = +13.708 s on a sharp correlation peak of 0.6825, falling to 0.63 just one frame either side. Pose-verified, and the drift was gone.

The last 0.7 seconds, made visible. Left: the centre camera's truth. Middle: the corrected sync — same arms, same heart on the wall. Right: the old fine-tune, 0.7 s off — close enough to pass a glance, wrong enough to see in motion.
The last 0.7 seconds, made visible. Left: the centre camera's truth. Middle: the corrected sync — same arms, same heart on the wall. Right: the old fine-tune, 0.7 s off — close enough to pass a glance, wrong enough to see in motion.

The moral, and it cost three tries to earn: when your source signal loops, any short-window match will confidently hand you a wrong answer. Audio, video, or a single eyeballed frame — it doesn't matter which, and the confidence score won't save you, because the algorithm is genuinely right about a moment that just isn't the moment. Only a long-section match on a signal that is unique in time can be trusted. And the human watching the footage noticed what the correlation peak never could.

The tool that came out of it. A saga like that earns a tool, so the review server grew one: a sync view that puts the centre camera up as the anchor, lays all 19 clips of the performance onto its timeline by audio-sync, and gives the whole thing one Play button. Hit it and every camera plays the same real instant together; scrub anywhere and they all follow. Making around twenty videos decode at once on this laptop meant tiny 240p proxies, built once and cached. Above them sits a Gantt strip showing each camera's coverage with a moving playhead, so you can see at a glance who was rolling when. Each clip gets offset nudge buttons that auto-save, so a frame that looks off by a hair gets fixed in the browser instead of in JSON, and Dance / Couple / Audience tag buttons that write straight back to the project's tag file — the four-key marking language from Act 08, attached to the sync view.

The detail that matters most is how the clips got into that list at all: they were found by audio-syncing every clip against the centre camera itself, rather than by trusting the folder sorting we already had. Which is precisely why the Camera A wide had been invisible for so long — C0281 had never been filed under "groom's parents" in the first place. No amount of reviewing that folder was ever going to surface it. It took a tool that asked the audio, not the filing.

The sync view playing at 1:29 of 3:19. The gold bar is the C0008 anchor; the green bar is C0281, the wide angle that turned out to span nearly the whole performance, tagged verified once we'd stopped believing the audio. Every purple bar is another camera, each with its own offset nudges and Dance/Couple/Audience tags.
The sync view playing at 1:29 of 3:19. The gold bar is the C0008 anchor; the green bar is C0281, the wide angle that turned out to span nearly the whole performance, tagged verified once we'd stopped believing the audio. Every purple bar is another camera, each with its own offset nudges and Dance/Couple/Audience tags.

The second thing the server grew was a completed-videos view: every finished cut in one list, playable in the browser. Which promptly refused to work — the 1080p masters wouldn't buffer. Two separate causes, both mundane, both invisible until measured. First, they were encoded at 19–64 Mbps, which is beautiful and far more than a browser wants to stream off a spinning drive. Second, and sneakier, two of them were yuv444p (4:4:4 chroma), which browsers cannot hardware-decode; they fell back to software decoding and choked, on a laptop that was already busy being a homelab. The fix was to stop asking the browser to do something it can't: build a Web-HQ 1080p variant at ~10 Mbps in yuv420p, and put a quality selector on the page — Web-HQ, HQ original, or 720p — so I could choose smooth playback for review and the full-fat master when I actually wanted to judge the grade.

The finished-videos view — every completed master in one list, with the quality selector on Web-HQ 1080p (~10 Mbps). The fix for a page that wouldn't buffer wasn't a better player; it was a stream the browser could decode in hardware.
The finished-videos view — every completed master in one list, with the quality selector on Web-HQ 1080p (~10 Mbps). The fix for a page that wouldn't buffer wasn't a better player; it was a stream the browser could decode in hardware.

The payoff. All of it — the three attempts, the 141-second lie, the two-point proof, the tool — was in service of one unglamorous outcome. Once I sat down and tagged the clips myself in the sync view, the edit had something true to build from. The recovered wide angle carries 64.6 s of the final cut, placed on my tagged dance windows. And the amount of compromised centre footage in the film — those stretches where a crew member walks straight through frame, the man kneeling in the proof shot above — fell from 27.3 s to 7.4 s. The footage to fix it had existed all along, three folders away, 141 seconds from where the algorithm swore it was.

the honest part

None of this was clean on the first try. The good outcomes were the second or third idea, and the sequence is worth keeping straight:

  • The colour cast that wouldn't sit still. The rich pro-derived grade pushed pink, then over-corrected to yellow. Fix: abandon fixed channel pushes, use per-image auto white balance that respects intentional colours. Light touch wins.
  • Dark sangeet faces. Midtone lifts couldn't reach deep shadows without wrecking the LEDs. Fix: an adaptive-gamma profile that brightens shadows per image, with strong highlight protection.
  • Sideways portraits and a 2014 clock. Vertical JPGs rendered sideways; the Nikon's clock was a decade off and reset mid-event. Fix: EXIF transpose on load plus a per-day, per-folder time alignment, verified by a parity check before upload.
  • Videos that looked done twice. A camera clock running ~9.5 h fast plus UTC timestamps made the timeline lie. Fix: median-anchored clock correction and a UTC→IST shift, confirmed in a browser review tool.
  • Multi-cam audio that wouldn't lock. Envelope correlation sat near 0.2, basically guessing. Fix: GCC-PHAT against a unique clean-song reference, confidence into the hundreds, frame-accurate.
  • I crashed my own homelab. This laptop also runs a Docker homelab. A 10-worker parallel reprocess pegged the CPU and exhausted Windows commit memory; Docker's backend died and took my Pi-hole DNS with it. Fix: a hard rule — cap heavy batches at three workers or fewer, below-normal priority, and check memory headroom before launching. The constraints became part of the design.

under the hood

Everything ran locally on a Windows laptop with a GTX 1660 Ti and 16 GB of RAM — the same box that hosts my Docker homelab — so every step had to respect tight memory limits.

On the photo and data side: Python, rawpy for RAW development, OpenCV, NumPy, SciPy, Pillow, piexif for EXIF writes, imagehash for perceptual hashing, and SHA-256 for dedup. On the video, server and glue side: ffmpeg and ffprobe, NVENC and QuickSync encoding, tmix gradient fusion, GCC-PHAT sync, YOLO11 for masks and pose, a Flask plus Vue review server, the render_with_lock serial gate, the check_frozen QA pass, the Immich CLI and REST API, a throwaway http.server that grew into the review cockpit, PowerShell and Bash.

Every pipeline step is a small, re-runnable script that writes its findings to CSV first, so there was always something to review before anything destructive happened.

on the scale of it

I want to be straight about this part. I didn't instrument exact token or compute counts, so I won't pretend to a precise number. What I can describe is the shape of the work: hundreds of gigabytes moved, hashed and re-encoded — ~603 GB inventoried, ~410 GB fully SHA-256'd, ~174 GB of video processed; tens of thousands of files touched — 33,302 cataloged, 14,077 duplicates proven, 5,000+ photos developed and graded; many processing hours across multiple sessions with multi-pass encodes for the synced multicam edits; and a long, tool-heavy conversation with dozens of bespoke scripts written, run, reviewed and revised.

If I had to put an estimated, clearly-not-measured figure on the assistant side of the collaboration, it is comfortably in the millions of tokens once you count all the file listings, CSV inspections, code and iteration across sessions. Treat that as a ballpark, not a meter reading.

what I actually got out of it

I didn't outsource my wedding to an AI. I sat with one and did the work, at a scale and rigour I could not have reached alone. The parts that would have defeated me solo are exactly the parts that went well: provably-safe deduplication, a colour grade learned from our own photographers, ceremony-accurate albums, and dance edits where the camera angles snap to the music.

The best moments were the course-corrections. Too pink. Too dark. That's not how the multicam should sync. Each time, instead of doubling down, we stepped back and found a better method. Two and a half years after the wedding, the whole celebration lives on our own server, organised the way a wedding actually unfolds. Nothing in the archive ever left home.