opengraphlabs/syncfield-swift
Lightweight Swift SDK for [SyncField](https://opengraphlabs.com) multi-stream synchronization. Captures precise timestamps during multi-camera and sensor recording and produces JSONL files that the SyncField Docker service consumes for frame-level temporal alignment.
Install
Swift Package Manager
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/OpenGraphLabs/syncfield-swift.git", from: "0.1.0"),
]Or in Xcode: File > Add Package Dependencies > paste the repository URL.
Zero dependencies -- uses only the Swift standard library and Foundation.
Quick Start
Video Streams
Use stamp() to capture timestamps and link() to associate the saved video file with the stream.
import SyncField
let session = SyncSession(hostId: "iphone_01", outputDir: outputURL)
try session.start()
for i in 0..<numFrames {
let frame = camera.read()
try session.stamp("cam_ego", frameNumber: i)
saveFrame(frame, to: "cam_ego.mp4")
}
session.link("cam_ego", path: "/data/cam_ego.mp4")
try session.stop()Output:
sync_data/
sync_point.json
cam_ego.timestamps.jsonl
manifest.jsonSensor Streams
Use record() to capture timestamps and sensor data in one call. This writes both a .timestamps.jsonl file (for alignment) and a .jsonl file (sensor channel values).
import SyncField
let session = SyncSession(hostId: "iphone_01", outputDir: outputURL)
try session.start()
for i in 0..<numSamples {
let data = imu.read()
try session.record("imu", frameNumber: i, channels: [
"accel_x": data.ax,
"accel_y": data.ay,
"accel_z": data.az,
])
}
try session.stop()Output:
sync_data/
sync_point.json
imu.timestamps.jsonl
imu.jsonl
manifest.jsonComplex Sensor Data
Sensors like hand trackers, tactile arrays, and robot joints produce nested data. The SDK handles these natively -- leaf values must be numeric (Double or Int).
// Hand tracker -- nested joint positions and gestures
try session.record("hand_tracker", frameNumber: i, channels: [
"joints": [
"wrist": [0.1, 0.2, 0.3],
"thumb_tip": [0.4, 0.5, 0.6],
"index_tip": [0.7, 0.8, 0.9],
] as [String: Any],
"gestures": ["pinch": 0.95, "fist": 0.02] as [String: Any],
"finger_angles": [12.5, 45.0, 30.0, 15.0, 5.0],
])
// Tactile grid -- 2D pressure array
try session.record("tactile", frameNumber: i, channels: [
"pressure_grid": [[0.1, 0.2, 0.3, 0.4],
[0.5, 0.6, 0.7, 0.8]],
"total_force": 12.5,
])
// Robot arm -- joint states
try session.record("robot_arm", frameNumber: i, channels: [
"joint_positions": [0.0, -1.57, 0.0, -1.57, 0.0, 0.0],
"joint_velocities": [0.01, -0.02, 0.0, 0.01, 0.0, 0.0],
"gripper": ["width": 0.04, "force": 5.2] as [String: Any],
])SyncField automatically flattens nested channels for aggregation using dot-notation keys (e.g., joints.wrist.0, gripper.width).
Multi-Stream Example
A complete example with 2 cameras and 1 IMU, each on its own DispatchQueue.
import SyncField
let session = SyncSession(hostId: "iphone_01", outputDir: outputURL)
try session.start()
var recording = true
func cameraLoop(cam: Camera, streamId: String, videoPath: String) {
var i = 0
while recording {
let frame = cam.read()
try? session.stamp(streamId, frameNumber: i)
saveFrame(frame, to: videoPath)
i += 1
}
session.link(streamId, path: videoPath)
}
func imuLoop(imu: IMU, streamId: String) {
var i = 0
while recording {
let data = imu.read()
try? session.record(streamId, frameNumber: i, channels: [
"accel_x": data.ax, "accel_y": data.ay, "accel_z": data.az,
"gyro_x": data.gx, "gyro_y": data.gy, "gyro_z": data.gz,
])
i += 1
}
}
let queue = DispatchQueue(label: "capture", attributes: .concurrent)
queue.async { cameraLoop(cam: camLeft, streamId: "cam_left", videoPath: "/data/cam_left.mp4") }
queue.async { cameraLoop(cam: camRight, streamId: "cam_right", videoPath: "/data/cam_right.mp4") }
queue.async { imuLoop(imu: imuDevice, streamId: "imu") }
// ... record for desired duration ...
recording = false
let counts = try session.stop()
// counts == ["cam_left": 900, "cam_right": 900, "imu": 9000]Output directory:
sync_data/
sync_point.json
cam_left.timestamps.jsonl
cam_right.timestamps.jsonl
imu.timestamps.jsonl
imu.jsonl
manifest.jsonBest Practices
Call stamp()/record() immediately after I/O read
The timestamp should reflect when data arrived on the host, not when processing finished.
// GOOD -- timestamp reflects when data arrived on the host
let data = device.read()
try session.stamp("sensor", frameNumber: i) // immediately after read
// BAD -- processing delay adds jitter to timestamp
let data = device.read()
let processed = expensiveTransform(data)
try session.stamp("sensor", frameNumber: i) // too late!Use one thread per device
Each device should have its own thread or DispatchQueue with a tight read loop. Both stamp() and record() are thread-safe.
let queue = DispatchQueue(label: "capture", attributes: .concurrent)
queue.async {
var i = 0
while recording {
let frame = cam.read()
try? session.stamp("cam_left", frameNumber: i)
i += 1
}
}
queue.async {
var i = 0
while recording {
let data = imu.read()
try? session.record("imu", frameNumber: i, channels: [
"accel_x": data.ax, "accel_y": data.ay, "accel_z": data.az,
])
i += 1
}
}Pre-captured timestamps for minimum jitter
If your capture callback provides its own timestamp, pass it directly to avoid lock-acquisition delay:
let captureNs = MonotonicClock.now() // capture immediately
// ... some unavoidable overhead ...
try session.stamp("cam", frameNumber: i, captureNs: captureNs)API Reference
SyncSession
| Method | Description | |--------|-------------| | init(hostId:outputDir:) | Create a session. outputDir accepts URL or String. | | start() -> SyncPoint | Begin recording. Captures the clock reference point. | | stamp(:frameNumber:uncertaintyNs:captureNs:) -> UInt64 | Record a timestamp for one frame. | | record(:frameNumber:channels:uncertaintyNs:captureNs:) -> UInt64 | Record timestamp + sensor data. | | link(_:path:) | Associate an external file with a stream. | | stop() -> [String: Int] | End session. Writes manifest and sync point. Returns frame counts. |
Thread Safety
All methods are thread-safe. stamp() and record() can be called from multiple threads concurrently. The timestamp is captured before acquiring the internal lock, so lock contention does not affect timing precision.
Timestamp Precision
Uses clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) for nanosecond-precision monotonic timestamps -- the iOS/macOS equivalent of Python's time.monotonic_ns(). This clock is not affected by NTP adjustments, ensuring consistent intervals for high-frequency capture.
Integration with SyncField Docker
Using manifest.json (recommended)
After stop(), the SDK writes a manifest.json that maps all streams to their files. Use it to construct the API request body programmatically.
import Foundation
let manifestData = try Data(contentsOf: outputURL.appendingPathComponent("manifest.json"))
let manifest = try JSONSerialization.jsonObject(with: manifestData) as! [String: Any]
let hostId = manifest["host_id"] as! String
let streamsMap = manifest["streams"] as! [String: [String: Any]]
var streams: [[String: Any]] = []
for (streamId, info) in streamsMap {
var entry: [String: Any] = ["stream_id": streamId]
if let path = info["path"] { entry["path"] = path }
if info["type"] as? String == "sensor" { entry["stream_type"] = "sensor" }
streams.append(entry)
}
// Mark first video as primary
if let idx = streams.firstIndex(where: {
streamsMap[$0["stream_id"] as! String]?["type"] as? String == "video"
}) {
streams[idx]["is_primary"] = true
}
let body: [String: Any] = [
"hosts": [["host_id": hostId, "streams": streams]],
"timestamps_dir": "/timestamps",
]
// POST to http://localhost:8080/api/v1/syncVolume-mounted mode
Mount your data and timestamp directories into the container and call the API directly.
docker run -v ./data:/data -v ./sync_data:/timestamps \
syncfield-app:latestcurl -X POST http://localhost:8080/api/v1/sync \
-H "Content-Type: application/json" \
-d '{
"hosts": [
{
"host_id": "iphone_01",
"streams": [
{"path": "/data/cam_ego.mp4", "stream_id": "cam_ego", "is_primary": true},
{"path": "/data/cam_wrist.mp4", "stream_id": "cam_wrist"},
{"stream_id": "imu", "stream_type": "sensor"}
]
}
],
"timestamps_dir": "/timestamps"
}'The service automatically matches {stream_id}.timestamps.jsonl and {stream_id}.jsonl files to streams using the timestamps_dir path.
File upload mode
Upload files directly without volume mounts.
curl -X POST http://localhost:8080/api/v1/sync/upload \
-F "files=@cam_ego.mp4" \
-F "files=@cam_wrist.mp4" \
-F "timestamp_files=@sync_data/cam_ego.timestamps.jsonl" \
-F "timestamp_files=@sync_data/cam_wrist.timestamps.jsonl" \
-F "stream_ids=cam_ego,cam_wrist" \
-F "host_ids=iphone_01,iphone_01" \
-F "primary_id=cam_ego"Format Specification
This section defines the output format for implementors in other languages.
sync_point.json
{
"sdk_version": "0.1.0",
"monotonic_ns": 1234567890123456789,
"wall_clock_ns": 1709890101000000000,
"host_id": "iphone_01",
"timestamp_ms": 1709890101000,
"iso_datetime": "2024-03-08T12:00:01.000000"
}{stream_id}.timestamps.jsonl
One JSON object per line (no trailing comma, no array wrapper):
{"capture_ns":1234567890123456789,"clock_domain":"iphone_01","clock_source":"host_monotonic","frame_number":0,"uncertainty_ns":5000000}
{"capture_ns":1234567890156789012,"clock_domain":"iphone_01","clock_source":"host_monotonic","frame_number":1,"uncertainty_ns":5000000}| Field | Type | Description | |-------|------|-------------| | frame_number | int | 0-based sequential index | | capture_ns | int | Monotonic nanoseconds at data arrival | | clock_source | string | Always "host_monotonic" for SDK output | | clock_domain | string | Must match host_id -- identifies the clock | | uncertainty_ns | int | Timing uncertainty (default: 5000000 = 5ms) |
Key rules:
capture_nsmust be monotonically non-decreasing within each streamclock_domainmust be identical across all streams on the same host- File name must be
{stream_id}.timestamps.jsonlfor auto-matching
{stream_id}.jsonl (Sensor Data)
One JSON object per line, combining timestamp and channel values:
{"capture_ns":1234567890123456789,"channels":{"accel_x":0.12,"accel_y":-9.8,"accel_z":0.05},"clock_domain":"iphone_01","clock_source":"host_monotonic","frame_number":0,"uncertainty_ns":5000000}| Field | Type | Description | |-------|------|-------------| | frame_number | int | 0-based sequential index | | capture_ns | int | Monotonic nanoseconds at data arrival (same clock as video timestamps) | | clock_source | string | Origin of the timestamp (always "host_monotonic" for SDK) | | clock_domain | string | Host identifier -- must match across all streams on the same host | | uncertainty_ns | int | Timing uncertainty (default: 5000000 = 5ms) | | channels | object | Sensor values as key-value pairs (e.g. {"accel_x": 0.12}) |
manifest.json
Written by stop(). Maps all streams in the session to their output files.
{
"sdk_version": "0.1.0",
"host_id": "iphone_01",
"streams": {
"cam_ego": {
"type": "video",
"timestamps_path": "cam_ego.timestamps.jsonl",
"frame_count": 900,
"path": "/data/cam_ego.mp4"
},
"cam_wrist": {
"type": "video",
"timestamps_path": "cam_wrist.timestamps.jsonl",
"frame_count": 900,
"path": "/data/cam_wrist.mp4"
},
"imu": {
"type": "sensor",
"sensor_path": "imu.jsonl",
"timestamps_path": "imu.timestamps.jsonl",
"frame_count": 9000
}
}
}| Field | Type | Description | |-------|------|-------------| | sdk_version | string | SDK version that produced this file | | host_id | string | Host identifier for this recording session | | streams | object | Map of stream_id to stream metadata | | streams..type | string | "video" or "sensor" | | streams..timestamps_path | string | Relative path to the timestamps JSONL file | | streams..frame_count | int | Number of frames/samples recorded | | streams..path | string | (video only) Path set via link() | | streams.*.sensor_path | string | (sensor only) Relative path to the sensor data JSONL file |
Platforms
- iOS 15+
- macOS 12+
License
Apache-2.0
Package Metadata
Repository: opengraphlabs/syncfield-swift
Default branch: main
README: README.md