Skip to content
← Back to Blog · · By Swaroop Shere

Six Times Faster

Fixing Nikon Capture Latency on macOS

Every Nikon frame was paying for a 22MB card write in order to produce a file path we immediately threw away. Removing that wait took the median from 2540ms to 406ms. The faster version we didn't ship is the more interesting half of the story.

NikonmacOSlibgphoto2PTPPerformanceBackpressureBenchmarks

TL;DR

Nikon capture-to-card on macOS ran at a 2540ms median per frame on a D7200 — on a body that shoots six frames a second. libgphoto2's Nikon capture call doesn't return until the camera has written the entire ~22MB NEF to the card, because it owes you a file path. eclipseClick is capture-to-card and discards that path. Switching to gp_camera_trigger_capture, which waits through the exposure but not the write, took the median to 406ms — 6.3× faster. Dropping the post-capture event drain as well looked even better (341ms, flat) until we ran forty frames instead of twelve and lost twenty-six of them: that drain was the backpressure holding the camera's buffer in check. We shipped the slower, correct version.

The symptom: a schedule that slides

A total solar eclipse gives you about two minutes, and eclipseClick fires a scripted exposure sequence against contact times computed to the millisecond. Every capture line has a scheduled moment and a measured error against it, so drift is visible in the logs rather than something you discover in the photographs afterwards.

Here is a 45-line dry run on a Nikon D7200 over USB on macOS:

Script line Timing error
Line 1 2,092 ms
Line 14 9,733 ms
Line 24 12,760 ms
Line 44 22,282 ms

That isn't jitter, it's drift. Each capture blocked for longer than its slot allowed and the whole schedule slid behind. By the end of the run the app was firing frames twenty-two seconds after it intended to. Inside a 103-second totality, that is the difference between photographing the diamond ring and photographing an ordinary sky.

Where the time actually went

Nikon on macOS runs through libgphoto2 — the MAID SDK's .md3 modules are x86_64-only and won't load on Apple Silicon, which is a story of its own. So the first job was splitting the per-frame cost between libgphoto2's capture call and our own event handling, with temporary instrumentation around each half.

D7200, 1/640, f/8, ISO 100, RAW to card:

[TIMING] capture=2448ms drain=124ms
[TIMING] capture=2496ms drain=111ms
[TIMING] capture=2548ms drain=109ms
[TIMING] capture=1924ms drain=120ms

About 95% of the time was inside gp_camera_capture. Our own code was noise by comparison. Whatever was wrong, it wasn't in our event loop.

Root cause: waiting for a file nobody reads

In libgphoto2 2.5.34, camera_nikon_capture (camlibs/ptp2/library.c:4320-4392) runs a completion loop that will not exit until:

if (done == 3)
    break;

done == 3 means both CaptureComplete and ObjectAdded have arrived. ObjectAdded only fires once the camera has finished writing the file — and a D7200 NEF is about 22MB. Every capture was blocking on a full RAW card write.

Two smaller overshoots ride on top of that. nikon_wait_busy polls device-ready at 100ms granularity, and the completion loop's backoff (waiting_for_timeout, library.c:161) grows +50ms per cycle to a 200ms cap — so up to 200ms of dead sleep after the event is already sitting there.

None of this is a libgphoto2 bug. It is the correct contract for a tethered-download workflow: don't return until the host can actually fetch the file. It is simply the wrong contract for us — eclipseClick captures to card by product policy, and our capture path discards the returned CameraFilePath. We were paying for a file write to obtain a path we threw away on the next line.

libgphoto2 offers a second entry point. camera_trigger_capture's Nikon branch issues InitiateCaptureRecInMedia and waits only for device-ready — through the exposure, but not through the card write. Our Sony path had been using that call for an unrelated reason: on ZV-E10-class bodies gp_camera_capture blocks for around thirty seconds waiting on a FILE_ADDED event Sony never emits. Nikon now joins it.

The numbers

D7200, Burst drive, 1/640 f/8 ISO 100, RAW to card, macOS arm64, libgphoto2 2.5.34:

Configuration Median / frame Max / frame Frames landed
gp_camera_capture (before) 2540 ms 3363 ms 8/8, 25/25
trigger_capture 476 ms 5774 ms 8/8, 40/40
trigger_capture + 20ms event poll (shipped) 406 ms 5544 ms 40/40

6.3× on the median. The last row's extra gain came from a related constant. Our post-capture event drain used a 100ms poll timeout, and every drain pays that once on the terminating poll — the one that finds the queue empty. A single-iteration drain still cost 102ms, so it was a flat ~100ms tax on every frame regardless of how many events were actually waiting. Cutting it to 20ms took another 70ms off.

Long exposures still behave correctly — the trigger call waits through the shutter, it just doesn't wait through the write. Four frames at a 2-second shutter:

2437 ms   3629 ms   2416 ms   2447 ms      (4/4 landed)

The optimisation we measured and rejected

This is the part worth reading.

If trigger_capture returns before the write completes, the obvious next move is to drop the post-capture event drain as well. We tried it. It looked superb — dead flat, no spikes at all:

[TIMING] capture=343ms drain=0ms
[TIMING] capture=335ms drain=0ms
[TIMING] capture=347ms drain=0ms
        ... twelve frames, 341 ms median, 995 ms max

Twelve frames fired, twelve files on the card. Better median and a far better worst case than the version we shipped.

Then we ran forty.

40 shots fired  →  14 files on the card
26 × "Capture failed, error=0xFFFFFFFF"

Twenty-six lost frames. The drain was never bookkeeping — it was the backpressure. Blocking on the PTP event queue is precisely the pause the camera needs in order to keep up. Remove it and nothing throttles the trigger rate, the body's buffer fills, and captures start failing outright.

A faster card raises that ceiling. It does not remove it, and an application that runs on other people's hardware cannot assume the card. So the drain stays, and it now carries a comment marking it load-bearing with the 14/40 measurement attached — because it is exactly the sort of call a future reader would delete as obviously pointless.

The configuration we shipped has a worse maximum — 5544ms against 995ms — than the one we rejected. That spike is the camera catching up, it is bound by card speed, and it shrinks as cards get faster. We took the worse headline number and the correct behaviour. A benchmark that only reports the median would have chosen the version that silently loses two thirds of your eclipse.

What the change introduced

Because the capture call now returns while the body may still be writing, a disconnect immediately after the last frame could call gp_camera_exit mid-write — something the old blocking path structurally could not do. A wedged PTP session costs the user a power cycle, which mid-eclipse is unrecoverable. The teardown path now drains first, bounded at roughly 400ms.

Worth stating plainly, because it generalises: this is the standing cost of turning a synchronous call asynchronous. Anything downstream that quietly relied on "the write has finished by the time this returns" needs auditing rather than assuming. We found this one by wedging a camera on the bench, which is the cheap place to find it.

What we verified, and what we didn't

Verified on hardware:

  • Frame accounting exact at 8, 25 and 40 shots — no silent drops
  • Long exposures (2s) still wait correctly for the exposure to finish
  • Continuous drive throughout — on a D7200 capturemode is read-only over PTP and driven by the physical dial, so the body sat on Burst for every run
  • The captured-frame counter behind FPS measurement still converges to N

Not verified, and we would rather say so than imply otherwise:

  • One body, one card, one format. A D7200 writing NEF. RAW+JPEG produces two events per frame and has not been measured.
  • Other Nikon bodies. The completion-loop behaviour is generic to libgphoto2's Nikon path, but the absolute numbers will move with buffer depth and card speed.
  • Sony. Untested here — no Sony body on the bench for this work. Sony's cadence is governed by a separate 500ms post-capture gate that we deliberately left alone.

The 6.3× is real and measured. It is measured on one camera.

Footnote: check which version you're quoting

While writing this work up we cited "libgphoto2 2.5.32" in a source comment and a commit message. Both were wrong. gphoto2 --version prints two versions:

gphoto2         2.5.32     ← the CLI tool
libgphoto2      2.5.34     ← the library that does the work

Every measurement here was taken against 2.5.34, the current release. Nothing in 2.5.34's changelog addresses Nikon capture latency, so no upstream bump would have delivered this — but it is a reminder to check which number you are reading before it ends up in a commit message, or a blog post.