A virtual camera is a software camera. It registers with the operating system's capture stack, plays a video file or a set of images in a loop, and every application that opens a camera receives those frames as if they came from hardware. That includes a browser calling getUserMedia().
For camera-based recognition this is the difference between a test and a demo. A recorded input runs the same way on every machine, so a change in decode rate comes from the code, not from the room. This article builds that camera for Windows, Linux and macOS and verifies it in the browser, on the free online barcode scanner.
What you'll build
- A controller — one Qt 6 binary, GUI or headless CLI — that takes a video, an image or a folder of images, scales it to a fixed size, converts it to YUYV and writes frames at a fixed rate.
- Three transports that make those frames look like a camera: a DirectShow filter on Windows, a v4l2loopback device on Linux, a CMIO system extension on macOS.
- A repeatable check: the camera always plays the same clip, so the browser scanner returns the same decoded strings on every run. Those strings, not the preview, are what a test asserts.
Prerequisites
- Windows 10/11 x64 with the Visual Studio 2022 build tools and CMake 3.21+, or the prebuilt installer that registers the filter.
-
Debian 12 / Ubuntu 22.04+ with
v4l2loopback-dkmsand the headers for the running kernel. - macOS 13+ with the Xcode command line tools to build the camera extension.
- Qt 6.8 for the controller; the transports are plain C++17, Swift and COM.
-
Prebuilt packages for all three platforms — Windows
.exeand portable ZIP, Debian.deb, macOS.pkg— are attached to the latest Virtual Camera release. Building from source is only needed if you want to change the transports. - A Dynamsoft license for self-hosted decoding. The Codepool demo carries its own domain-bound license; for your own deployment, Get a 30-day free trial license.
Why barcode testing needs a virtual camera
A webcam gives you different pixels on every run. Autofocus hunts, auto-exposure reacts to a hand entering the frame, and the label sits at a slightly different angle each time. Two runs of the same test produce two different inputs, so a drop in detection rate cannot be attributed to anything.
A virtual camera removes that variable. The decoder receives the same frames in the same order on a developer laptop and on a CI runner. Four things follow from that:
- Real failures become regression tests. When a customer reports a label that will not scan, ask for a short clip or a photo. It becomes a permanent test case instead of a ticket nobody can reproduce.
- Conditions you cannot stage can still be covered. Curved cans, shrink wrap, a screen refresh band across a code, glare from a warehouse lamp: capture each one once, then replay all of them in the same run.
-
CI runs without hardware. A Linux runner can load
v4l2loopbackin a privileged container; a Windows runner can register the filter withregsvr32. No camera, no USB hub, no one holding a phone. - Performance comparisons become meaningful. Detection rate, decode time and CPU cost are only comparable when the input is identical. This is the main reason to record once and replay on every platform you support.
The demo used here is the same one a visitor uses: Free Online Barcode Scanner. It reads from a live camera, from images and PDF files, and from video files. A file that decodes in file mode but fails in camera mode isolates the capture pipeline, which is the class of bug a virtual camera is meant to expose.
Why build one instead of installing one
OBS Studio already ships a virtual camera on all three platforms, and paid virtual camera utilities exist for Windows and macOS. Both are reasonable for manual testing. Neither is a good fit for automated tests.
- Size. OBS is a capture and streaming application: installer close to 100 MB, a compositor, encoders, a plugin system and a scene model. A CI image that only needs to replay one file should not carry that.
- Control. OBS takes its output from a scene you configure by hand. A test needs to choose the source, the resolution, the frame rate and the start time from a command line.
- Licensing and telemetry. Paid utilities need a license per machine, and most of them are GUI-only. A small in-house tool has no activation step and no network traffic.
- Extensibility. Once frames pass through your own code, you can feed them from anywhere: a recorded clip, a folder of fixtures, a generator that renders barcodes with known values, or frames captured from a failing device.
- Cost. The implementation in this article is a few hundred lines per platform, has no runtime dependencies on Windows, and is MIT licensed.
The same camera is not barcode-specific. It works for any consumer that reads a camera stream: MRZ and passport scanning, document edge detection, OCR, face or gesture models, GS1 label checks. The input changes; the transport does not.
Step 1: Choose the source
The controller accepts three kinds of input, and all three are first-class:
| Source | What it gives you | When to use it |
|---|---|---|
| A single image | One frame, repeated at the configured rate | A fixed label, a fixed distance, a baseline |
| A folder of images | A slideshow, about two seconds per image | Several fixtures in one run, side by side |
| A video file | Continuous frames, decoded and paced | Motion, focus changes, a hand moving the label |
Images are the simplest way to get a deterministic frame, and a folder is enough to cover a dozen edge cases in twenty seconds. Video matters when the test depends on movement: a decoder that reads a sharp frame but fails on a moving one will pass an image test and fail in the field.
Two rules apply to all three:
- Encode at the camera's pixel size. The controller scales whatever it gets to the configured resolution, and that resampling can blur the narrow modules a barcode needs. A 4032×3024 photo squeezed into 1280×720 is the most common reason a virtual camera decodes worse than a still image.
- Keep the aspect ratio. The controller fits a source that is not 16:9 inside the frame and fills the rest with black. Stretching it to fill 1280×720 makes a square QR code rectangular and changes every module the decoder measures.
If you produce the video from images with ffmpeg, do it once, offline, and keep the file in the repository next to the other fixtures. The virtual camera itself does not need ffmpeg: it plays MP4 through the platform media stack (Media Foundation, GStreamer, AVFoundation) and reads images through Qt.
Step 2: Convert once, to YUYV
All three transports take YUYV (also written YUY2). It is a packed 4:2:2 format: every pixel keeps its own luma byte, and each pair of pixels shares one U and one V. Barcode decoding works on luma, so sharing chroma costs the decoder nothing and saves a third of the bandwidth of RGB24.
for (int x = 0; x < width; x += 2) {
const uint8_t r0 = src[x * 3 + 0], g0 = src[x * 3 + 1], b0 = src[x * 3 + 2];
const int x1 = x + 1 < width ? x + 1 : x; // odd width: repeat the last pixel
const uint8_t r1 = src[x1 * 3 + 0], g1 = src[x1 * 3 + 1], b1 = src[x1 * 3 + 2];
out[0] = rgbToY(r0, g0, b0); // Y0
uint8_t u, v;
rgbToUV((r0 + r1) / 2, (g0 + g1) / 2, (b0 + b1) / 2, &u, &v);
out[1] = u; // Cb, shared by both pixels
out[2] = rgbToY(r1, g1, b1); // Y1
out[3] = v; // Cr, shared
out += 4;
}
The coefficients are BT.601 limited range, in integer form:
inline uint8_t rgbToY(int r, int g, int b)
{ return static_cast<uint8_t>(((66 * r + 129 * g + 25 * b + 128) >> 8) + 16); }
inline void rgbToUV(int r, int g, int b, uint8_t* u, uint8_t* v)
{
*u = static_cast<uint8_t>(((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128);
*v = static_cast<uint8_t>(((112 * r - 94 * g - 18 * b + 128) >> 8) + 128);
}
Y lands in 16–235 and the chroma channels in 16–240, which is the range consumers expect from a camera. Full-range values make whites clip, and a clipped white patch is one more place where a binarizer can find an edge that is not a barcode.
The bandwidth difference is worth a calculation. At 1280×720 a YUYV frame is 1,843,200 bytes; at 1920×1080 it is 4,147,200. At 30 fps that is 55 MB/s versus 124 MB/s before the consumer converts anything, which is why 720p is the right default.
Step 3: Windows — a DirectShow filter that runs inside the consumer
On Windows the camera provider that every application can enumerate without a kernel driver, and that browsers still go through, is DirectShow. A source filter is an in-process COM DLL registered under the video input device category. Once it is registered, the ICreateDevEnum enumerator behind getUserMedia() lists it next to USB webcams. That is how Edge found it in this test, with no browser flag and no extension.
regsvr32 "C:\Program Files\VirtualCamera\driver\VirtualCameraDriver.dll"
Registration is why the installer asks for administrator rights once, and why the built-in Windows Camera app still does not show the device: it enumerates through Media Foundation. Browsers, Zoom and OBS do show it.
The media type is RGB24, bottom-up
The pin offers a single format — MEDIASUBTYPE_RGB24 with FORMAT_VideoInfo — because RGB24 is what browser sinks accept without an extra converter in the graph. Two details break naive implementations:
-
DIB rows are bottom-up. With a positive
biHeight, the last row in the buffer is the top row of the image. Write rows in image order and every consumer shows an upside-down picture. -
Rows are DWORD-aligned. The stride is
(width * 3 + 3) & ~3, notwidth * 3, so a width that is not a multiple of four leaves padding bytes at the end of each row.
The conversion happens in FillBuffer, which also has to pace itself. A source pin that delivers as fast as the CPU allows pushes timestamps far ahead of the reference clock; the renderer then drops samples to catch up and the preview goes black or stutters. Sleeping for the rest of the frame interval keeps the sample times honest:
HRESULT VirtualCameraStream::FillBuffer(IMediaSample* sample)
{
const DWORD intervalMs = static_cast<DWORD>(m_interval / 10000); // 100 ns -> ms
const ULONGLONG now = GetTickCount64();
if (m_lastDeliverTick != 0 && now - m_lastDeliverTick < intervalMs) {
::Sleep(static_cast<DWORD>(intervalMs - (now - m_lastDeliverTick)));
}
m_lastDeliverTick = GetTickCount64();
// ... pointer, stride and size checks ...
uint32_t sharedCount = 0;
const HRESULT shared = deliverSharedFrame(data, width, height, stride,
m_frameIndex, &sharedCount);
if (shared == S_OK) {
if (!m_hasFrame || sharedCount != m_lastFrameCount) {
memcpy(m_lastFrame.data(), data, size); // fresh conversion
m_lastFrameCount = sharedCount;
m_hasFrame = true;
} else {
memcpy(data, m_lastFrame.data(), size); // writer slower than the pin
}
m_lastFrameTick = now;
} else if (m_hasFrame && now - m_lastFrameTick < 2000) {
memcpy(data, m_lastFrame.data(), size); // repeat, do not flash
} else {
m_hasFrame = false;
deliverTestPattern(data, width, height, stride, m_frameIndex);
}
// ... SetActualDataLength / SetTime ...
}
The last branch fixes a visible bug, described under Common Issues & Edge Cases: a filter that answers a missed read with its colour-bar pattern makes the camera blink every few seconds.
Shared memory, guarded by a seqlock
The controller cannot call into the filter. It is a different process, and the filter only exists inside whichever application opened the camera. The transport is a named section, Local\VirtualCameraFrame, with a fixed layout: a 64-byte header and a payload sized for the largest supported frame.
A cross-process mutex would work, but it ties the consumer's streaming thread to the controller's pacing. A seqlock does not: the writer never waits, and a reader either gets a complete frame or knows it did not.
// live = the 64-byte header at the start of the mapped region
for (int attempt = 0; attempt < 64; ++attempt) {
const uint32_t seq1 = vcam::seqLoadAcquire(&live->frameSeq);
if (seq1 & 1u) { Sleep(0); continue; } // writer is mid-copy
const uint32_t w = live->width, h = live->height, s = live->stride;
memcpy(dst, m_view + vcam::kSharedHeaderSize, static_cast<size_t>(s) * h);
if (vcam::seqLoadAcquire(&live->frameSeq) != seq1) continue; // torn: retry
*width = w; *height = h; *stride = s;
return true;
}
Two portability details are easy to get wrong. The reader maps the section read-only, so its sequence load must be a plain acquire load: an interlocked compare-exchange writes even when the values match, and that faults on a read-only view. And the region is allocated once for 1920×1080, so a resolution change never forces consumers to re-map.
Step 4: Linux — write into the kernel's device
Linux already has the device. The v4l2loopback kernel module creates /dev/videoN as an output-only V4L2 node, and the controller only has to open it and write frames:
// fd = open("/dev/videoN", O_WRONLY)
v4l2_format fmt{};
fmt.type = V4L2_BUF_TYPE_VIDEO_OUTPUT;
fmt.fmt.pix.width = cfg.width;
fmt.fmt.pix.height = cfg.height;
fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV;
fmt.fmt.pix.field = V4L2_FIELD_NONE;
fmt.fmt.pix.bytesperline = cfg.width * 2;
fmt.fmt.pix.sizeimage = cfg.width * cfg.height * 2;
ioctl(fd, VIDIOC_S_FMT, &fmt);
// then, once per frame:
::write(fd, data, static_cast<size_t>(width) * height * 2);
There is no shared memory on this platform because the kernel buffer is the shared memory: every consumer that opens the node receives the frames, and the module handles the fan-out. The module does not rate-limit anything, so the pacing stays in the controller.
Two module options matter:
sudo modprobe v4l2loopback exclusive_caps=1 card_label="Virtual Camera" max_devices=1
-
exclusive_caps=1makes the node advertise capture or output depending on who opened it. Without it, Chromium and Zoom skip the device — the most common reason a virtual camera does not appear on Linux. -
card_labelis the string the browser shows. The controller prefers a node with that exact label, falls back to any output-capable node, and honoursVCAM_V4L2_DEVICEwhen a machine has several.
Step 5: macOS — a CMIO Camera Extension
macOS has no user-space camera driver to register. Since 12.3 the supported mechanism is a CMIO Camera Extension: a system extension that runs in its own process and publishes a device through CMIOExtensionProvider. The host app activates it once:
OSSystemExtensionRequest* request =
[OSSystemExtensionRequest activationRequestForExtension:nsIdentifier
queue:dispatch_get_main_queue()];
request.delegate = delegate;
[[OSSystemExtensionManager sharedManager] submitRequest:request];
The user then approves the extension in System Settings > Login Items & Extensions > Camera Extension. That approval is required for every camera extension, signed or not, and systemextensionsctl list should show it as [activated enabled].
Inside the extension, three objects do the work: a provider, a device and a stream. The stream advertises one format, 'yuvs', the four-character code for YUYV, and pushes a CMSampleBuffer on a timer:
let attributes: NSDictionary = [
kCVPixelBufferWidthKey: width,
kCVPixelBufferHeightKey: height,
kCVPixelBufferPixelFormatTypeKey: yuvs,
kCVPixelBufferIOSurfacePropertiesKey: NSDictionary(),
]
CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attributes, &pool)
// per frame, from a DispatchSourceTimer at the configured rate:
guard frameStore.readLatest(into: buffer, capacity: SharedFrame.maxFrameBytes) else {
return // keep the previous frame; see the test-pattern note below
}
CVPixelBufferLockBaseAddress(imageBuffer, [])
memcpy(CVPixelBufferGetBaseAddress(imageBuffer), buffer, frameBytes)
CVPixelBufferUnlockBaseAddress(imageBuffer, [])
var timing = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: CMTimeScale(fps)),
presentationTimeStamp: CMClockGetTime(CMClockGetHostTimeClock()),
decodeTimeStamp: .invalid)
stream.send(sampleBuffer, discontinuity: [], hostTimeInNanoseconds: hostTimeNs)
The pixel buffer comes from a pool, so the buffers stay IOSurface-backed and the consumer can hand them to the GPU without a copy. Frames arrive through POSIX shared memory (shm_open + mmap), which the extension re-opens lazily; the controller can start and stop without the extension caring. The early return follows the same rule as on Windows: re-sending the last frame is what keeps a camera from blinking, and only a stream that has never received a frame should draw the built-in pattern.
One constraint for the test plan: Safari only sees extension-backed cameras from 17.4, while Chrome, Edge and Firefox on macOS 13+ work regardless.
Step 6: Verify in the browser, then assert
With the controller running, the camera is just a camera:
const devices = (await navigator.mediaDevices.enumerateDevices())
.filter(d => d.kind === 'videoinput');
const cam = devices.find(d => /virtual camera/i.test(d.label));
const stream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: cam.deviceId }, width: 1280, height: 720 },
audio: false
});
console.log(stream.getVideoTracks()[0].getSettings()); // { width: 1280, height: 720, frameRate: 30 }
Open the Free Online Barcode Scanner, set Scan Mode to Live camera, choose Virtual Camera, and the input plays as it would from a webcam. The recording below is that setup: no fake-device flag, no injected frames, just the DirectShow filter receiving YUYV frames from the controller.
The result box is where a test gets its assertion. As the clip plays, the scanner appends every symbol it decodes, so a run ends with a list of strings instead of a picture to look at. Feed it fixtures whose contents you already know, and that list becomes the expected result.
To run it automatically, drive the page with Playwright and read the result box:
await page.selectOption('#dropdown', 'camera');
await page.waitForFunction(() => /Virtual Camera/.test(
document.querySelector('#camera_source').selectedOptions[0].textContent));
await page.selectOption('#camera_source', { label: 'Virtual Camera' });
// Wait for output, then compare it with the list recorded from the fixtures.
await page.waitForFunction(() =>
document.querySelector('#scan_result').value.trim().length > 0, null, { timeout: 15000 });
const decoded = await page.$eval('#scan_result', el => el.value.trim().split('\n'));
// loadExpected() is your helper that reads the baseline recorded from the fixtures.
expect(decoded).toEqual(await loadExpected('fixtures/expected.json'));
No fake-camera flags are needed, and that matters: --use-fake-device-for-media-stream injects frames below the capture stack, so it cannot catch a bug in the capture stack.
Source Code
Get the complete sample project source code on GitHub: yushulx/virtual-camera.



Top comments (0)