Product

Xangle Camera Server

Speed & throughput

Optimization

Reduce capture, transfer, processing, and delivery delays by checking the whole path from camera to output.

Frames thumbnail optimization proposal

The target workload is a Camera Server rig with 1 to 250 cameras. The reference case has 168 cameras capturing RAW+JPG, which delivers 336 files while the host is already writing several gigabytes to the dataset drive. Frames needs one preview for each completed JPG, but thumbnail work must not compete with capture transfer or repeatedly refresh the UI while files are arriving.

Recommendation

Use a post-JPG frame atlas job for /frames:

  1. Do not enqueue image thumbnails as individual JPG files finish.
  2. Wait until every expected JPG is terminal and all JPG writer chains are drained. RAW transfers do not have to be finished.
  3. Start one dedicated child process for the dataset.
  4. Extract each completed JPG's embedded EXIF thumbnail with exifr.thumbnail(), normalize only those tiny images, and compose them into one JPEG atlas per visible iteration. Decode the full JPG only when no usable embedded thumbnail exists.
  5. Write the atlas and its small manifest to temporary files, then rename them atomically.
  6. Publish one frames:thumbnails-ready event.
  7. Keep /frames in a single preparing state until that event arrives, then load the atlas once.

This removes thumbnail work from the JPG delivery interval and avoids decoding the 5400-pixel image in the normal case. With Download JPGs first, atlas generation can begin as soon as the complete JPG wave is durable while RAW files continue afterward. It also replaces up to 250 thumbnail HTTP requests and cache files with one atlas request per iteration.

Current behavior

Camera Server already creates thumbnails itself; the browser does not resize the 5400-pixel JPG files into cached thumbnails.

The current capture path calls enqueueDashboardThumbnail() whenever an image file is registered. The shared thumbnail queue has a concurrency of one, and Sharp operations are also serialized process-wide. The thumbnail writes therefore are not all simultaneous, but they begin while RAW and JPG files can still be arriving. Each frame is decoded, resized to a 256-pixel longest edge, and written as a separate JPEG under the dataset cache.

The Frames frontend requests thumbnail=256 for every visible snapshot. It initially exposes four image requests and adds one request whenever another image settles. While capture is active, dataset and file events can also cause repeated snapshot-list refreshes and grid state changes.

The existing warmThumbnailsOnCapture path does not solve this image workload. Its gallery helper currently warms video posters and deliberately avoids forcing image thumbnails. The per-image enqueueDashboardThumbnail() call is independent of that option.

Better source strategy: embedded EXIF thumbnails

The atlas is useful, but its source matters more than its container. Building an atlas by resizing all 168 full 5400-pixel JPGs still performs 168 large JPEG reads and decodes. A better host-only path is to extract the embedded EXIF thumbnail already present in each Canon JPG and compose the atlas from those small JPEG buffers.

Camera Server already depends on exifr 7.1.3. Its exifr.thumbnail(filePath) API reads only enough EXIF data to locate and return the embedded thumbnail. This avoids loading or decoding the full image and requires no new dependency. The existing Media Browser and card-hold code also prove that Canon embedded thumbnails can need normalization before browser display.

Required source-selection gate

Choose the thumbnail strategy once per dataset after the JPG writer barrier:

  1. Select one representative completed JPG, preferably the first file in stable camera-number order.
  2. Call exifr.thumbnail(jpgPath) for that file.
  3. Validate that the result is a non-empty, renderable JPEG with usable dimensions.
  4. If the probe succeeds, set the dataset atlas source mode to embedded-exif and extract embedded thumbnails from the remaining JPGs.
  5. If the probe fails, set the source mode to manual-resize and generate every thumbnail from its full-resolution JPG with Sharp.

Do not attempt all embedded extractions before deciding whether the camera model supports them. The one-file probe makes the normal path explicit and avoids producing a partially mixed atlas when embedded thumbnails are generally unavailable.

Within embedded-exif mode, an isolated malformed or missing embedded thumbnail may still use manual Sharp resizing for that one frame. Record the fallback count in the manifest and job metrics. If failures exceed a small threshold, such as 5% of sources, abandon that build and regenerate the complete atlas in manual-resize mode for consistent output.

For embedded thumbnails, normalize Canon's non-renderable EXIF wrapper when present. Preserve a usable 160 x 120 preview without enlargement. A fixed larger tile setting intentionally selects manual-resize, because a 160-pixel embedded source cannot provide the requested detail.

This keeps the first implementation entirely in Camera Server. A later node optimization could call the already implemented Canon EdsDownloadThumbnail() during capture and attach FileInfo.thumbnail to the normal new_file event. The proto field and host normalization already exist. That would remove even the host EXIF reads, but it requires a camera-node rebuild across the fleet and adds an EDSDK operation to the camera's capture/download queue. It should be benchmarked separately before becoming the default.

Do not write 168 extracted thumbnail files. Keep the small buffers inside the atlas child process and write only the final atlas and manifest.

Verification on production-rig dataset

Verified against C:\Users\info\xangle\cameraserver\datasets\260828-205245\input on 2026-08-29 without modifying the dataset.

Dataset inventory:

  • 167 JPG files totaling 653,323,602 bytes;
  • 167 CR2 files totaling 3,616,705,314 bytes;
  • source JPG dimensions sampled as 5184 x 3456.

Embedded-thumbnail extraction results across all 167 JPGs:

  • 167 succeeded and 0 failed: 100% coverage;
  • every embedded preview was a valid 160 x 120 JPEG;
  • embedded JPEG size ranged from 6,460 to 13,561 bytes, averaging 11,036 bytes;
  • all embedded previews together were 1,843,011 bytes;
  • sequential exifr.thumbnail() extraction took 67 ms on the first measured pass and 26-29 ms on subsequent passes.

A real 14 x 12 atlas using native 160 x 120 tiles and 2-pixel gaps measured:

  • atlas dimensions: 2266 x 1462;
  • atlas JPEG size: 690,079 bytes;
  • extraction: 29.1 ms;
  • compose and write: 171.2 ms;
  • total: 200.3 ms.

Visual inspection confirmed that all tiles are usable. The embedded thumbnails are 4:3 renditions of 3:2 originals, not padded 3:2 images. Preserve them at 160 x 120 in the Frames grid instead of cropping to 160 x 107. Opening a tile should still load the full original for detailed inspection.

These timings are a local isolated benchmark, not an end-to-end capture measurement. The integrated implementation must still measure child-process startup, JPG writer-barrier release, RAW overlap, event delivery, and browser atlas decode.

Orientation verification and handling

The embedded Canon JPEG does not carry the presentation needed by Frames on its own. Read the EXIF orientation from each original JPG and apply it to that file's embedded preview before atlas composition. Orientation is per camera, not per dataset.

Dataset 260829-114308 contained three orientation groups:

  • orientation 1: 14 JPGs;
  • orientation 6: 69 JPGs;
  • orientation 8: 84 JPGs.

The v2 atlas worker applies EXIF orientations 1 through 8. The real 167-camera v2 atlas completed in 628 ms, used embedded previews for every frame, and had zero manual fallbacks. Visual inspection confirmed that portrait cameras are upright while orientation-1 cameras remain landscape.

Single-build guarantee

Mark a live dataset waiting-for-transfer on its first file announcement. Repeated JPG and RAW announcements must leave that state unchanged and must not emit more status events. The gallery endpoint cannot lazily generate an atlas while this lock is active.

Only the all-expected-JPG plus JPG-writer-drained barrier releases the lock and queues generation. Transfer Service records the dataset ID before enqueueing, while the atlas service rejects another job when that folder is already queued or generating. This gives one atlas build per source signature and selected size. A later source or size change creates a new versioned artifact intentionally.

RAW-wave dashboard stability

After an atlas becomes ready, RAW-wave dataset:updated events for the same folder must not reset Frames to waiting-for-transfer. Only a strictly newer folder may trigger the auto-follow reset. Likewise, cameras:new-file events from the RAW wave must not restart the capture reveal timer once the atlas is ready.

The embedded dashboard tile also stops its three-second gallery/snapshot polling after atlas readiness. The atlas is immutable and the final readiness event is authoritative, so repeated polling only causes unnecessary state updates and visible repainting. WebSocket events continue to select a genuinely newer dataset.

Other thumbnail cache files

Files under cache/thumbnails/512 are separate from the Frames atlas. FrameInspector requests a 512-pixel preview for the selected full-screen frame and preloads the previous and next frames. For example, opening 0003.jpg creates 0002.jpg, 0003.jpg, and 0004.jpg in that cache. Quickview is not the caller in this case.

Correct completion boundary

Start the atlas after the complete JPG wave, not after the complete RAW+JPG transfer. This is especially valuable with Download JPGs first, because Frames can become ready while the much larger RAW wave continues.

The existing imagesReadyAt and completeJpgTransfer() signals are close, but not sufficient by themselves. File status is updated before the asynchronous writer promise necessarily finishes. Starting Sharp from that signal alone could read a JPG that is still flushing and would reintroduce contention with the last JPG writes.

Add a JPG-specific writer barrier and start the job only after all of these are true:

  • the expected JPG count is known from the camera count and RAW+JPG capture contract;
  • every expected JPG is terminal, with failed or cancelled cameras accounted for;
  • every successfully completed JPG has a finalized local path and non-zero size;
  • no pending writer chain for this dataset belongs to a JPG stream;
  • atlas scheduling is executing exactly once for the dataset and source signature.

TransferWriter.getPendingDatasetWorkCount() currently counts all pending streams and cannot filter by media kind. Extend its stream ownership metadata or add getPendingDatasetWorkCount(datasetId, predicate) so Transfer Service can distinguish pending JPG writes from pending RAW writes. Run the barrier check again from each terminal writer promise, not only from protocol progress, so the final JPG flush can release it.

For JPG-only capture, this is also the final transfer boundary. For RAW+JPG without split ordering, JPG and RAW may arrive interleaved; the atlas still starts when the JPG subset is durable. For Download JPGs first, queue it immediately at the handoff between the JPG and RAW phases.

Atlas generation can therefore overlap RAW transfer. Keep the worker at below-normal process priority, use one atlas job globally, and retain conservative Sharp concurrency. If qualification shows increased RAW transfer time or write latency, add a previewPriority policy:

  • earliest-preview: start after the JPG barrier while RAW continues;
  • protect-transfer: wait for every transfer writer to drain.

Use earliest-preview with Download JPGs first and protect-transfer as the fallback for slow or saturated storage.

For missing or failed cameras, generate from successfully completed JPG files once the JPG failure-aware gate is terminal. Record missing entries in the manifest rather than waiting forever.

Why an atlas helps

An atlas built from full JPGs does not remove the expensive part, because Camera Server still reads and decodes every source JPG. The embedded-thumbnail source strategy does remove most of that work: it reads a small EXIF region and decodes only the tiny embedded preview. The atlas then adds these benefits:

  • thumbnail work no longer overlaps JPG delivery writes, though earliest-preview mode can overlap RAW writes;
  • one final atlas write replaces up to 250 small thumbnail writes;
  • one browser request and decode replaces up to 250 requests and decodes;
  • the complete grid appears atomically;
  • cache validation happens once per atlas instead of once per frame;
  • the Frames page no longer drives progressive per-file thumbnail generation.

For this Canon rig, embedded previews are 160 x 120. With 2-pixel gaps, a 14 x 12 atlas is 2266 x 1462 pixels for up to 168 cameras. A 16 x 16 atlas is 2590 x 1950 pixels for up to 250 cameras. Both are comfortably within normal Chromium and libvips image limits and are small compared with the source JPG set.

Use a 2-pixel gap between tiles to prevent JPEG block or chroma bleed between adjacent images. The manifest should provide each frame's source path, display name, camera number when known, iteration, and atlas rectangle.

Artifact layout

Use a versioned cache layout so format changes do not conflict with existing thumbnails:

<dataset>/cache/frame-atlas/
  manifest.json
  default.jpg
  0001.jpg
  0002.jpg

default.jpg covers a normal single-iteration capture. Multi-iteration datasets should use one atlas per iteration because Frames displays only one iteration at a time. Do not create one very tall atlas containing every iteration.

The manifest should include:

{
  "version": 2,
  "status": "ready",
  "tileWidth": 160,
  "tileHeight": 120,
  "gutter": 2,
  "sourceCount": 168,
  "expectedSourceCount": 168,
  "sourceSignature": "...",
  "sourceMode": "embedded-exif",
  "sourceFallbackCount": 0,
  "atlases": [
    {
      "iteration": null,
      "file": "default.jpg",
      "width": 2266,
      "height": 1462,
      "frames": []
    }
  ]
}

Build sourceSignature from the sorted dataset-relative JPG paths plus file size and modification time. Reuse a ready atlas when the signature and selected tile width match. Generate into .tmp files and rename the atlas first and manifest last, so manifest.json is the readiness marker and the UI can never observe a partial sheet.

Dedicated process

Run atlas generation in a child process rather than the Electron/server process. This isolates JPG decoding, native Sharp/libvips memory, CPU use, and failures from transfer orchestration and UI APIs.

The child process should:

  • accept an immutable job containing dataset root, source list, iteration grouping, output directory, and tile policy;
  • process source JPGs sequentially initially;
  • resize each source directly to a small in-memory buffer without writing intermediate thumbnails;
  • composite only the resized buffers into the atlas;
  • use Sharp cache disabled and conservative libvips concurrency;
  • report progress and a final manifest over IPC;
  • exit after the job, releasing all native memory and source handles.

Start with one atlas job at a time globally. Multiple captures can finish close together, and parallel atlas jobs would reintroduce disk and CPU contention. A later benchmark can justify concurrency two on fast workstations, but it should not be the default.

Do not decode all 5400-pixel sources into full raw buffers simultaneously. Resize each source before retaining its small tile buffer. At 168 tiles, the final uncompressed 160 x 120 RGB atlas is only about 9.2 MB; full-size decoded sources would consume gigabytes.

Thumbnail size policy

Make server generation size a persistent Media setting because it controls a shared backend artifact, not only one browser's layout:

  • auto (recommended default)
  • 160 px
  • 240 px
  • 320 px

The width means tile width. In embedded-thumbnail mode, preserve the embedded preview's native aspect ratio. The verified Canon files provide 160 x 120 previews even though the originals are 3:2. Do not crop those previews to 3:2 because that discards additional content. The atlas manifest must carry exact rectangles so other cameras and mixed aspect ratios remain valid.

Recommended automatic policy based on the number of JPG previews in the active iteration:

JPG previews Tile width Intended use
1-24 320 px Full-JPG fallback for larger inspection tiles
25-96 240 px Full-JPG fallback when embedded preview is smaller
97-250 160 px Native embedded thumbnails for large rigs

Use JPG preview count, not total transferred file count. A 168-camera RAW+JPG capture has 336 transferred files but only 168 frame previews.

The setting should also allow a fixed width so an operator can favor sharpness or generation speed. Frames automatic grid sizing should cap its normal displayed tile width near the generated width to avoid stretching a 160-pixel tile to 190 pixels or more. Opening the inspector should continue to use the original image, not the atlas.

Frames readiness contract

Add a dataset-level atlas status with these states:

  • waiting-for-transfer
  • queued
  • generating
  • ready
  • failed

Expose it through a small endpoint such as:

GET /api/gallery/:folder/frame-atlas

Return 202 with status and progress until ready, 200 with the manifest when ready, and a clear error payload when generation fails. The atlas JPG can be served through a versioned cache URL from the manifest.

For the newest active dataset, /frames should:

  • show one stable "Preparing frame previews" state;
  • avoid requesting the snapshot list repeatedly during transfer;
  • ignore per-file cameras:new-file and intermediate transfers:dataset:updated events for grid rendering;
  • react to frames:thumbnails-ready and fetch the manifest once;
  • render every frame from the decoded atlas in one state update.

A CSS background-position implementation is sufficient, but drawing atlas rectangles from one shared ImageBitmap into tile canvases gives cleaner responsive scaling and one browser-side decode. Keep names and accessible labels in normal DOM elements.

Failure and compatibility behavior

The page must not remain blank forever when atlas generation fails. Show a visible retry action and log the child-process error. After the transfer is terminal, an explicit fallback may allow the existing individual-thumbnail path, but it must remain post-transfer and must not silently restart capture-time thumbnail generation.

Handle these cases explicitly:

  • JPG-only capture: generate after all expected JPG writes finish.
  • RAW+JPG capture: wait for all expected JPGs and JPG writer chains, then generate from JPG sources while RAW may continue.
  • RAW-only capture: report that no browser-supported JPG preview sources exist unless a separate RAW preview extractor is configured.
  • Missing cameras: generate completed sources and mark missing cameras in the manifest.
  • Imported or older dataset: lazily queue one atlas job when first opened, provided no active transfer owns the folder.
  • Dataset changed after generation: invalidate by source signature and regenerate atomically.
  • Application restart: inspect the manifest and signature; resume or requeue jobs left in queued or generating state.
  • Dataset deletion: cancel the worker when possible and discard late output.

Keep the existing individual thumbnail endpoint for other modules and backward compatibility. Stop calling enqueueDashboardThumbnail() from the capture registration path once Frames uses the atlas; otherwise Camera Server would pay for both representations.

Implementation sequence

  1. Add the expected-JPG and JPG-writer-drained barrier, schedule previews there, and remove per-file capture-time enqueueing.
  2. Add the one-file EXIF probe and persist the selected embedded-exif or manual-resize source mode.
  3. Add embedded-thumbnail extraction with Canon normalization, fallback metrics, and full-atlas manual regeneration when the failure threshold is exceeded.
  4. Add the child-process atlas generator, atomic cache format, manifest signature, and a global single-job queue.
  5. Add the Media setting and auto policy for 1-250 previews.
  6. Add the frame-atlas status endpoint and final readiness event.
  7. Change Frames to show a stable preparing state and render only a ready atlas.
  8. Add retry, restart recovery, missing-camera handling, and legacy-dataset lazy generation.
  9. Keep the old individual endpoint as a non-capture fallback until the atlas path is qualified.

Estimated gain for 168 cameras

These are engineering estimates, not measured results. The reference XCS26 shot completed its transfer step in 33.211 seconds with a 28.521-second payload-active span. Host CPU averaged 20.32%, the target disk was 11% busy with 0.38 ms average write latency, and the host NIC averaged about 1.28 Gb/s while active. Those measurements show substantial host CPU and NVMe headroom, so they do not support predicting a large capture-time reduction from thumbnail changes alone.

Expected impact for 168 JPG previews:

Metric Current path Proposed atlas path Estimated effect
Thumbnail output files 168 1 atlas + 1 manifest 98.8% fewer image files
Browser image requests Up to 168 1 atlas + 1 manifest 98.8% fewer image requests
Full-resolution JPG decodes Up to 168 Normally 0; fallback only Up to 100% fewer
Thumbnail/atlas image writes 168 1 99.4% fewer image writes
Grid reveal transitions Up to 168 progressive settles 1 ready transition Effectively one atomic reveal
Preview pixels decoded 168 full 5400 px images 168 embedded thumbnails Orders of magnitude fewer pixels

Likely user-visible result:

  • On the supplied 167-camera dataset, the native embedded-thumbnail atlas completed in 200 ms in an isolated local benchmark. Allowing for process startup, endpoint work, cache state, and event delivery, target under 1 second after the final JPG writer drains on the production host while RAW continues. A full-JPG atlas remains closer to the earlier 2 to 6 second estimate.
  • Once ready, loading and revealing the complete Frames grid should typically fall from many incremental requests and renders to well under 1 second on the local host.
  • The app should avoid the repeated /frames refresh and layout work throughout the JPG download, producing a much calmer UI and lower event-loop/request overhead.

Likely capture-time result:

  • The atlas format alone would probably save less than 1 second of the 34-second full capture. Using embedded EXIF thumbnails is materially better because it normally avoids all 168 full-image decodes, but the measured host CPU and NVMe were not saturated, so a large capture-time reduction is still unlikely.
  • Deferring thumbnail work until the JPG barrier may recover roughly 0 to 2 seconds if current Sharp reads and per-thumbnail writes are measurably interfering with active transfer. A larger claim is not supported by the available telemetry.
  • In earliest-preview mode, atlas generation overlaps RAW transfer and could instead add a small RAW-transfer regression on slower disks. Qualification should require the RAW payload span and total transfer step to remain within 2% of the no-thumbnail baseline.

The strongest expected gain is therefore predictable early preview and sharply reduced file/request/UI churn, not a guaranteed large reduction in the 34-second capture duration. Record jpg-writers-drained, atlas-started, atlas-ready, RAW payload span, and transfer completion timestamps to replace these estimates with rig measurements.

Measured 168-camera-class A/B

Compared two captures on the same production rig:

  • without atlas during capture: dataset 260828-205245;
  • with atlas: dataset 260829-120323.

The runs are close but not perfectly matched: the baseline delivered 167 JPG + 167 RAW files, while the atlas run delivered 166 JPG + 166 RAW files. Total payload bytes were effectively identical at approximately 4.27 GB.

Metric Without atlas With atlas Difference
Transfer duration 29.821 s 30.344 s +0.523 s
Payload-active span 24.867 s 24.716 s -0.151 s
Average transfer rate 136.56 MiB/s 134.20 MiB/s -1.7%
JPG active span 7.619 s 8.487 s +0.868 s
RAW active span 22.883 s 22.754 s -0.129 s
Host NIC receive average 1229.55 Mb/s 1193.08 Mb/s -3.0%
Host CPU average 22.71% 28.75% +6.04 points
Dataset disk busy 11% 10% -1 point
Average write latency 0.42 ms 0.37 ms -0.05 ms
Normalized write fraction 15.14% 3.39% -11.75 points
Queue fraction 2.82% 0.11% -2.71 points

The result is neutral for end-to-end speed. The 0.523-second total difference and 0.151-second payload difference are within normal run-to-run variation. Atlas generation starts only after the JPG wave, so it cannot explain the slower JPG span in the atlas run. RAW active time was essentially unchanged.

The write-count reduction is confirmed. The baseline capture created 167 individual cached thumbnail JPEGs totaling about 1.34 MB. The atlas capture created zero individual capture thumbnails and one atlas JPEG plus one manifest totaling about 549 KB. This changes preview cache output from 167 image writes to two atomic artifact writes, but the host NVMe already had substantial headroom, so fewer writes did not shorten the transfer.

The baseline folder now also contains atlas artifacts created later during development verification. They were not present during its original capture and must not be counted as baseline capture writes.

Qualification plan

Compare the old and proposed paths using repeated 24-, 96-, 168-, and 250-camera-equivalent datasets. Use the verified 5184 x 3456 JPGs plus representative 5400-pixel 3:2 JPGs and include RAW+JPG transfer on the 168-camera production rig.

Measure these intervals separately:

  • capture start to last network payload;
  • last payload to all host writer chains drained;
  • atlas queued to atlas ready;
  • atlas source-read bytes and output bytes;
  • process CPU, peak RSS, dataset-drive busy time, and write latency;
  • number of thumbnail output files and HTTP requests;
  • /frames time from atlas-ready event to complete grid display.

Acceptance criteria:

  • no atlas source reads or output writes before every expected JPG is terminal and its writer chain is drained;
  • no material regression in RAW payload span, total transfer step time, or write latency;
  • exactly one ready event and one visible grid transition per capture;
  • one atlas image per displayed iteration plus one small manifest;
  • complete and correctly mapped previews for 1, 24, 96, 168, and 250 cameras;
  • bounded worker memory and no Electron/server crash if the child process fails;
  • a failed or missing camera does not block readiness indefinitely.

Decision

Adopt a probe-selected atlas for the Frames grid: try one embedded EXIF thumbnail first, use embedded extraction when valid, and otherwise generate thumbnails manually from the full JPGs. The sprite alone is not enough if it starts before the JPG files are durable. The essential ordering is: finish all expected JPGs, drain JPG writers, generate in an isolated low-priority process, publish atomically, then display once while RAW may continue.