Apps

Splice: two cameras, 37 seconds apart

I have two clips of one Christmas. Same room, same people, two cameras in different corners. One was switched on 37 seconds before the other. They sat in a folder for years because I had no way to cut between them.

Professional editors solve this. They are also where I spend the evening learning the editor instead of cutting the video. I already had a small tool for assembling family video: one page in a browser, clips as cards, trim in and out, ffmpeg renders it. Teaching that to hold two cameras looked like less work.

I assumed the hard part was the interface. It was not. The data model I wrote on day one never changed: one timeline item holding the synced cameras, plus a gap-free list of start, end, which camera. The work was elsewhere: getting the cameras to agree on what time it was, then keeping four video elements honest about it. I was wrong about the way in, too. Duplicating a clip made a group, which is how the old tool worked and not what I wanted with two real files. It takes two files now.

Getting two cameras to agree on the time

Autosync v1 was the textbook version: pull mono audio from both files at 8 kHz, cross correlate with an FFT, take the peak. On a test clip offset one second it was exact. On the real clips it returned nonsense.

Two reasons, and only the first was obvious. I had capped the search at plus or minus 20 seconds; the gap was 37. The second I would have missed. Cross correlation follows whichever frequencies are loudest, and these were two microphones in opposite corners of a room. One mostly heard the television, the other whoever stood next to it. Their loud parts do not line up even when their clocks do.

The fix was GCC-PHAT: the same FFT correlation, but divide the cross spectrum by its own magnitude before transforming back. That discards how loud each frequency was and keeps only phase, which is the part carrying timing. The peak went from a smear to a spike. Widened to plus or minus 180 seconds over four minutes of audio, it found minus 37.0 seconds on the first run.

Once it worked, I wrote down what I was not going to build:

Four things I decided against, and the reasons are not the same shape. More than two cameras was allowed by the design and killed by arithmetic: three angles means a bigger decode budget, more colors and more keys, and I own two cameras. The freehand draw line for choosing angles demoed beautifully and lost to colored blocks I could actually grab. The elegant render was designed as one big filtergraph and I shipped the dumb version instead, bake each segment and concatenate, which is still what runs. Audio that cuts with the picture I tried and abandoned, because it clicks at every seam; one camera’s audio runs under the whole thing.

4K made scrubbing unusable, so anything wider than 1280 pixels gets a cached 960 wide proxy for preview. The render still reads the originals.

What only showed up when I used it

I played the group, tapped 2 to switch to camera B, and the picture stopped. Not black. Still. B had never been told to play: the old code started an angle only if its position was already at or past zero, and B starts at minus 37 seconds. It sat paused on its first frame, so switching to it was switching to a photograph. Playback stopped being something you set up once; every frame now drives every angle against the master clock, starting one when the clock reaches its footage.

Fixing that exposed something worse I had been doing on purpose. A stopped camera looked frozen, frozen looked like a bug, so I had been cutting to cameras that were not rolling. In one group I cut to B at 4.2 seconds; B’s footage begins at 7.53. The renderer clamps to the first frame it has, so that cut would have baked about 3.3 seconds out of sync, and I would have found out in the finished file. Each camera’s strip now tints where it has footage and hatches where it does not, the quieter of the two mockups I drew.

Cut at the playhead did nothing for days. It split the block into two halves naming the same camera, and the server merges same camera neighbors on every save, so the cut was made and immediately unmade. It now gives the second half the other camera.

Opening a second project showed cards wearing the first project’s pictures, though the server was sending the right images. Item ids restart at zero in every project, thumbnails came from an id-addressed URL with no cache header, and the browser reasonably kept the one it had. The lesson outlived the bug: once projects existed, every id addressed URL became project relative.

Two months of frozen frames

Two months later I measured a finished export frame by frame and found it had been freezing a frame at every cut: sixteen gaps, 0.64 seconds of frozen picture in a 34-item render. A trim was returning 89 frames where 90 were asked, and a silent audio bed padded the container enough to hide it. It shipped that way because I watched the video and never counted the frames.

It cut the Christmas clip. That was the whole brief, and it is the only reason the tool exists, so I have never gone looking for anyone else to use it. What I would build next, if a second person ever needed it, is the export: handing a project to a real editor carries only a group’s master angle today, so every A/B decision I made stays trapped in my browser.

Back to portfolio
send a transmission

this goes straight to my inbox.