2. Architecture & Design Overview

2.1 Two-Tier Architecture

duvc-ctl uses a layered design with two distinct Python APIs sitting on top of a shared C++ core. Both APIs access the same underlying DirectShow functionality but present different models to the user.

High-level overview

┌─────────────────────────────────────────────────┐
│          Python User Code                       │
├──────────────────────┬──────────────────────────┤
│ Pythonic API         │ Result-Based API         │
│ (CameraController)   │ (open_camera, Result<T>)│
├──────────────────────┴──────────────────────────┤
│   pybind11 Python Bindings                      │
├─────────────────────────────────────────────────┤
│        C++ Core Library (duvc-ctl)              │
│  (Device enumeration, property access, etc.)   │
├─────────────────────────────────────────────────┤
│      Windows DirectShow & COM                   │
└─────────────────────────────────────────────────┘

Both Python APIs call the same C++ code. The difference is in how they handle device lifecycle, errors, and API design.

Pythonic API (Exception-based)

The Pythonic API, implemented in CameraController, wraps the C++ core and adds Python conventions:

  • Error handling: Exceptions (DuvcError and subclasses) are raised for failures. Callers use try-except blocks.

  • Device management: Automatic device selection (first camera, or by name). Connection/disconnection handled via context manager (with statement).

  • API style: Property-based access (cam.brightness = 75) and method calls (cam.pan_relative(15)).

  • State: The controller maintains internal state—the active device, connection handle, property cache—so the user doesn’t think about it.

Example:

import duvc_ctl as duvc

try:
    with duvc.CameraController() as cam:
        cam.brightness = 80
        print(f"Brightness: {cam.brightness}")
except duvc.PropertyNotSupportedError as e:
    print(f"Cannot set brightness: {e}")

When to use: Learning, scripting, simple applications where you want minimal boilerplate.

Result-Based API (Explicit error handling)

The Result-Based API, in the init.py module, provides low-level control without exceptions:

  • Error handling: Functions return Result<T> types. Callers check is_ok() or is_error() and access the value or error explicitly.

  • Device management: Explicit. You pass a Device or device index to each function.

  • API style: Functional (open_camera(device), camera.get(property) returns Result<PropSetting>).

  • State: Minimal and explicit. You own the Device and Camera objects.

Example:

import duvc_ctl as duvc

devices = duvc.list_devices()
if not devices:
    print("No devices found")
else:
    device = devices[0]
    camera_result = duvc.open_camera(device)
    
    if camera_result.is_ok():
        camera = camera_result.value()
        brightness_result = camera.get(duvc.VidProp.Brightness)
        
        if brightness_result.is_ok():
            setting = brightness_result.value()
            print(f"Brightness: {setting.value}")
        else:
            print(f"Error: {brightness_result.error().description()}")
    else:
        print(f"Error: {camera_result.error().description()}")

When to use: Production code, detailed error recovery, systems that need to handle failures gracefully without exception overhead.

Design rationale

Two APIs instead of one?

The Pythonic API is easier to use for most cases. But production systems often need explicit error handling and minimal overhead. Rather than compromise with a single API, we provide both:

  • Beginners and scripts use the simpler Pythonic API.

  • Production code that needs fine-grained control uses the Result-Based API.

  • Both use the same C++ backend, so there’s no code duplication in the core logic.

Why exceptions vs. results?

  • Pythonic API (exceptions): Python developers expect exceptions. They’re idiomatic. Errors are detected early (at the point of failure) and propagate up without explicit checks.

  • Result-Based API (results): No exceptions mean no exception-throwing overhead. Each operation is explicit—you see exactly where errors are handled. This is valuable in production systems where you need to recover from failures gracefully.

C++ bindings layer (pybind11)

The bridge between Python and C++ is pybind11. It handles:

  • Type conversion (Python int ↔ C++ int32_t, Python str ↔ C++ std::string, etc.)

  • Lifetime management (Python garbage collection ↔ C++ RAII and reference counting)

  • Exception translation (C++ exceptions to Python exceptions in the Pythonic API)

  • Module organization (separate namespaces for Pythonic and Result-based APIs)

The Result-Based API types (Result<T>, Device, PropSetting, etc.) are bound directly from C++ to Python. The Pythonic API (CameraController) is written entirely in Python and uses the Result-based bindings internally.

Thread safety

Both APIs are thread-safe at the module level, but individual devices are not thread-safe:

  • Multiple threads can safely call list_devices(), open different cameras, etc.

  • If two threads access the same camera (same CameraController or Camera object), data races can occur. Use a lock or ensure only one thread accesses a camera at a time.

Example of thread-safe usage (different cameras per thread):

import duvc_ctl as duvc
import threading

def camera_worker(camera_index):
    with duvc.CameraController(camera_index) as cam:
        cam.brightness = 75
        print(f"Camera {camera_index}: brightness set")

devices = duvc.list_devices()
threads = [threading.Thread(target=camera_worker, args=(i,)) for i in range(len(devices))]
for t in threads:
    t.start()
for t in threads:
    t.join()

The C++ core uses a mutex internally to protect shared state (DirectShow device enumeration). Individual camera operations do not hold this mutex long; property get/set operations lock only the minimal necessary time to avoid contention.

Connection state machine

A camera connection goes through these states:

  1. Closed: No active connection.

  2. Opening: Connecting to the device (transitional).

  3. Open: Connected; properties can be read and written.

  4. Closing: Disconnecting (transitional).

The Pythonic API (CameraController) manages these transitions automatically via the context manager. You enter the with block → connection opens. You exit the with block → connection closes.

The Result-Based API requires you to manage state explicitly:

camera_result = duvc.open_camera(device)  # Opens connection
if camera_result.is_ok():
    camera = camera_result.value()
    # Use camera...
    # Connection closes when camera object is destroyed or explicitly closed

Error propagation

Pythonic API:

  • Errors are raised as exceptions. The exception propagates up the call stack until caught or reaches the top level (where Python prints a traceback).

Result-Based API:

  • Errors are wrapped in the Result type. You check is_ok() or is_error() and access the error via error(). No automatic propagation; you decide how to handle each error.

Why this design?

  1. Separation of concerns: Pythonic API handles convenience; Result-Based API handles control.

  2. Flexibility: Users pick the API that fits their use case.

  3. Performance: The Result-Based API avoids exception overhead if desired.

  4. Production-ready: Both are suitable for production; the choice is about error handling philosophy.

2.2 API Comparison & Selection

This section compares both APIs head-to-head so you can choose the right one for your use case.

When to use each API

Scenario

Recommended API

Why

Learning or prototyping

Pythonic

Fewer lines of code, quick iteration

Quick script or automation

Pythonic

Automatic device selection, built-in defaults

Production application with error handling

Result-based

Explicit error checks, no exceptions, full control

High-frequency camera control loop

Result-based

Lower overhead; no exception throwing

Handling multiple cameras concurrently

Either

Both are thread-safe; choose based on error handling preference

Interactive or CLI application

Pythonic

Users expect exceptions; easier debugging

Embedded or headless system

Result-based

Explicit error handling fits production patterns

Real-time or time-critical code

Result-based

Predictable performance; no exception overhead

Detailed feature comparison

Feature

Pythonic

Result-Based

Device Selection

Automatic (first device or by name)

Manual (you pass Device)

Error Handling

Exceptions (try-except)

Result types (is_ok() / is_error())

Property Access

Direct (cam.brightness = 75)

Method call (camera.set(VidProp.Brightness, ...))

Code Style

Object-oriented

Functional

Boilerplate

Minimal

More verbose

Exception Overhead

Yes (thrown on error)

No (checks Result)

Error Details

Exception message + type

Error code + detailed description

Device Lifecycle

Context manager (with)

Manual (you control lifetime)

State Management

Automatic (maintained by controller)

Manual (you own state)

Learning Curve

Beginner-friendly

Moderate

IDE Autocomplete

Good (Python class)

Good (types exported)

Type Hints

Yes (CameraController class)

Yes (Result and types)

Device management lifecycle

Both APIs handle device discovery, connection, and disconnection, but at different levels.

Pythonic API lifecycle:

import duvc_ctl as duvc

# 1. Discovery (implicit)
# First camera found automatically during construction
with duvc.CameraController() as cam:
    # 2. Connected
    cam.brightness = 75
    # 3. Auto-disconnect on exit

Result-based API lifecycle:

import duvc_ctl as duvc

# 1. Explicit discovery
devices = duvc.list_devices()

# 2. Explicit connection
camera_result = duvc.open_camera(devices[0])
if camera_result.is_ok():
    camera = camera_result.value()
    
    # 3. Use camera
    brightness_result = camera.get(duvc.VidProp.Brightness)
    
    # 4. Implicit disconnect when camera object is destroyed

Error handling strategies

Pythonic API (exception-based):

import duvc_ctl as duvc

try:
    with duvc.CameraController() as cam:
        cam.brightness = 999  # Out of range
except duvc.InvalidValueError as e:
    print(f"Invalid value: {e}")
except duvc.PropertyNotSupportedError:
    print("Brightness not supported")
except duvc.DeviceNotFoundError:
    print("No camera found")

Advantages:

  • Errors interrupt execution; you can’t accidentally ignore them.

  • Idiomatic Python; most libraries use exceptions.

  • Compact code for the happy path.

Disadvantages:

  • Exception handling adds overhead (though typically small).

  • Stack unwinding can complicate debugging in some cases.

Result-based API (explicit):

import duvc_ctl as duvc

devices = duvc.list_devices()
if not devices:
    print("No camera found")
else:
    device = devices[0]
    camera_result = duvc.open_camera(device)
    
    if camera_result.is_ok():
        camera = camera_result.value()
        brightness_result = camera.get(duvc.VidProp.Brightness)
        
        if brightness_result.is_ok():
            setting = brightness_result.value()
            print(f"Brightness: {setting.value}")
        else:
            error = brightness_result.error()
            print(f"Error: {error.description()}")
    else:
        error = camera_result.error()
        print(f"Failed to open camera: {error.description()}")

Advantages:

  • No hidden control flow; every error is explicit.

  • No exception overhead.

  • Detailed error codes and descriptions available.

Disadvantages:

  • More boilerplate (more if statements).

  • Error handling is mandatory; harder to “forget.”

Performance comparison

Both APIs ultimately call the same C++ code, so performance differences are small. However, there are measurable differences in specific scenarios:

Operation

Pythonic

Result-Based

Notes

Single property read (success)

~100 µs

~95 µs

Pythonic has minimal overhead; difference negligible

Single property read (error path)

~200 µs

~100 µs

Exceptions slow down error handling

Rapid property reads (1000x)

~100 ms

~95 ms

Exception overhead adds up over many calls

Device enumeration

~10 ms

~10 ms

Same code path; no difference

Context manager overhead

~5 µs

N/A

Negligible

(These values are estimates and not entirely accurate, timings may differ depending on the system and devices) Practical takeaway: For most use cases, the performance difference is imperceptible. Only if you’re performing thousands of property operations in a tight loop should you consider the Result-based API for performance.

Trade-offs summary

Choose Pythonic if:

  • You’re learning or prototyping.

  • Your code is straightforward with few error cases.

  • You like Python idioms and exceptions.

  • You want less boilerplate.

  • Performance is not critical.

Choose Result-based if:

  • You need detailed error recovery logic.

  • You’re building a production system that must handle failures gracefully.

  • You want explicit, predictable control flow (no exception throwing).

  • You’re processing high volumes of property operations.

  • You need to distinguish between different error types programmatically.

  • You prefer functional APIs and explicit error handling.

Mixing both APIs

You can use both APIs in the same application. The Pythonic API’s CameraController has a .core property that gives you the underlying Result-based Camera object:

import duvc_ctl as duvc

with duvc.CameraController() as cam:
    # Use Pythonic API
    cam.brightness = 75
    
    # Drop down to Result-based API for detailed control
    core_camera = cam.core
    brightness_result = core_camera.get(duvc.VidProp.Brightness)
    
    if brightness_result.is_ok():
        setting = brightness_result.value()
        print(f"Current brightness: {setting.value}")

This gives you flexibility: start simple with the Pythonic API, and switch to explicit Result handling when you need more control.

2.3 Core Design Patterns

This section explains the key design principles that shape duvc-ctl’s architecture, threading model, and internal behavior.

Resource ownership and RAII

duvc-ctl follows the Resource Acquisition Is Initialization (RAII) principle throughout its C++ core. Every resource (COM objects, device handles, property state) is tied to an object’s lifetime.

In C++:

class Device {
public:
    Device() { /* acquire DirectShow resources */ }
    ~Device() { /* release DirectShow resources */ }
private:
    IMFDeviceEnumerator* enumerator;  // Owned; freed in destructor
};

When a Device object is destroyed, all associated DirectShow resources are automatically released. This prevents memory leaks and dangling pointers.

In Python (Pythonic API):

The CameraController manages a device lifecycle implicitly:

with duvc.CameraController() as cam:
    # Resources acquired at __enter__
    cam.brightness = 75
# Resources released at __exit__, even if an exception occurred

The with statement ensures cleanup happens, following Python’s context manager protocol.

In Python (Result-based API):

Resources are explicitly managed:

camera_result = duvc.open_camera(device)
if camera_result.is_ok():
    camera = camera_result.value()
    # Use camera
    # Destructor called when camera goes out of scope

Python’s garbage collector triggers the destructor, which releases C++ resources via pybind11.

Threading and concurrency model

duvc-ctl is designed for multi-threaded environments but with careful constraints.

Module-level thread safety:

The module’s global state (device enumeration cache, module initialization) is protected by an internal mutex. Multiple threads can safely call:

import duvc_ctl as duvc
import threading

def thread_worker(index):
    devices = duvc.list_devices()  # Safe; uses module lock internally
    print(f"Thread {index} found {len(devices)} devices")

threads = [threading.Thread(target=thread_worker, args=(i,)) for i in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

Per-camera thread safety:

Once a device is opened, accessing a single camera from multiple threads is not thread-safe without external synchronization. Each camera is a single-threaded object and must be accessed from the thread it was marshalled from. This is a result of the COM model used by windows.

# BAD: Race condition
import duvc_ctl as duvc
import threading

with duvc.CameraController() as cam:
    def thread_worker():
        cam.brightness = 50  # Unsafe if another thread accesses cam simultaneously
    
    threads = [threading.Thread(target=thread_worker) for _ in range(4)]
    for t in threads: t.start()
    for t in threads: t.join()

# GOOD: Multiple cameras, one per thread
import duvc_ctl as duvc
import threading

devices = duvc.list_devices()

def thread_worker(device_index):
    with duvc.CameraController(device_index) as cam:
        cam.brightness = 50  # Each thread has its own camera; safe

threads = [threading.Thread(target=thread_worker, args=(i,)) for i in range(len(devices))]
for t in threads: t.start()
for t in threads: t.join()

Concurrency strategy:

  • Share devices across threads: Use a lock around camera operations.

  • Don’t share devices: Each thread gets its own camera (preferred).

  • Module-level operations (list_devices(), open_camera()) are always safe.

C++ core vs. Python wrapper layer

duvc-ctl’s architecture has two distinct layers:

C++ Core:

  • Pure C++17 with minimal dependencies.

  • Direct access to Windows COM interfaces (DirectShow).

  • Handles device enumeration, property access, and DirectShow API calls.

  • No Python knowledge; can be used independently or from other languages via C bindings.

Python Wrapper (pybind11):

  • Binds C++ types to Python (Device, Camera, Result, etc.).

  • Translates Python exceptions to C++ exceptions and vice versa.

  • Manages object lifetime (Python GC ↔ C++ destructors).

  • Provides type conversion (Python str ↔ C++ std::string).

The Pythonic API (CameraController) is written entirely in Python and uses the pybind11 bindings internally:

User Python Code
CameraController (Python)
pybind11 Bindings (Device, Camera, Result<T>)
C++ Core
DirectShow & Windows COM

This layering allows:

  • The C++ core to evolve independently of Python bindings.

  • Python code to be simple and Pythonic (using Python idioms).

  • The pybind11 layer to be thin (mostly type translations, not business logic).

Move semantics in Python bindings

The C++ core uses move semantics to efficiently transfer ownership of resources without copying:

// C++
Result<Camera> open_camera(const Device& device) {
    auto camera = std::make_unique<Camera>(device);
    return Result<Camera>(std::move(camera));  // Move, don't copy
}

pybind11 preserves this semantic when binding to Python. When you receive a Camera from Python, it’s a move-constructed object with full ownership:

camera_result = duvc.open_camera(device)
if camera_result.is_ok():
    camera = camera_result.value()  # Efficient transfer of ownership

Internally, pybind11 uses holder types to manage the C++ object’s lifetime through Python’s garbage collector. When camera goes out of scope in Python, the C++ object is destroyed and its resources freed.

Connection management state machine

A camera connection follows these states:

State

Description

Valid Transitions

Closed

No active connection to device

→ Opening

Opening

Attempting to connect

→ Open, → Closed (on failure)

Open

Connected; properties can be read/written

→ Closing

Closing

Disconnecting

→ Closed

Error

Connection failed or lost

→ Opening (retry)

Pythonic API state transitions:

with duvc.CameraController() as cam:  # Closed → Opening → Open
    cam.brightness = 75                # Operations in Open state
# Open → Closing → Closed

Result-based API state transitions:

camera_result = duvc.open_camera(device)  # Closed → Opening
if camera_result.is_ok():                  # Succeeded; now Open
    camera = camera_result.value()
    brightness_result = camera.get(duvc.VidProp.Brightness)
    if brightness_result.is_ok():
        setting = brightness_result.value()
# Open → Closing → Closed (on camera destruction)

State violations (e.g., trying to access a property in Closed state) raise an exception or return an error.

Device discovery patterns

Device discovery follows a lazy enumeration pattern:

  1. On demand: Devices are discovered when list_devices() is called, not at module import.

  2. Caching: The device list is cached; repeated calls are fast.

  3. Invalidation: The cache is invalidated if device hotplug events are detected (if callback registered).

Example:

import duvc_ctl as duvc

# First call: actual enumeration via DirectShow
devices1 = duvc.list_devices()

# Second call: returns cached list (no DirectShow overhead)
devices2 = duvc.list_devices()

# Register for device change notifications
def on_device_change(added, device_path):
    print(f"Device {'added' if added else 'removed'}: {device_path}")

duvc.register_device_change_callback(on_device_change)

# When a device is plugged/unplugged, the callback fires and the cache is invalidated

Thread safety guarantees

duvc-ctl provides these thread-safety guarantees:

Operation

Thread-Safe

Notes

list_devices()

✓ Yes

Internally synchronized

open_camera(device)

✓ Yes

Returns independent Camera object

camera.get(property) (single camera)

✗ No

Use lock if accessing from multiple threads

camera.set(property, value) (single camera)

✗ No

Use lock if accessing from multiple threads

Multiple cameras in different threads

✓ Yes

Each camera is isolated

Device change callbacks

✓ Yes

Callbacks are thread-safe; executed on internal worker thread

register_device_change_callback()

✓ Yes

Safe to call from any thread

Safe multi-threaded pattern:

import duvc_ctl as duvc
import threading
from threading import Lock

devices = duvc.list_devices()

# One camera per thread: no lock needed
def worker(device_index):
    with duvc.CameraController(device_index) as cam:
        cam.brightness = 75

threads = [threading.Thread(target=worker, args=(i,)) for i in range(len(devices))]
for t in threads: t.start()
for t in threads: t.join()

Unsafe pattern (and how to fix it):

# UNSAFE: Multiple threads accessing same camera
with duvc.CameraController() as cam:
    def worker():
        cam.brightness = 75  # Race condition!
    
    threads = [threading.Thread(target=worker) for _ in range(4)]
    # ...

# SAFE: Use a lock
with duvc.CameraController() as cam:
    lock = Lock()
    
    def worker():
        with lock:
            cam.brightness = 75  # Protected by lock
    
    threads = [threading.Thread(target=worker) for _ in range(4)]
    # ...