-
Notifications
You must be signed in to change notification settings - Fork 0
Virtual camera
src/vcam/ — vcam_shared.h, virtual_camera.cpp, vcam_filter.cpp,
vcam_dll.cpp, capview_vcam.def
The picture can be offered to other programs as a webcam — OBS, Discord, a browser — under the name CapView Virtual Camera. Switched on in Settings → Recording. The filter is registered machine-wide, so the camera stays in every device list from the moment it is installed, whether CapView is running or not; while nothing is feeding it, it shows the idle picture.
In 3.0 it delivers the source 1:1: the source's own resolution at the source's own frame rate. Consumers that cannot take that get what they ask for instead, scaled inside their own process.
This page used to make the opposite case, and the case was not wrong. Media
Foundation genuinely is the superset: a camera registered through
MFCreateVirtualCamera appears in both backends, because Windows bridges
frame-server cameras into DirectShow enumeration and not the other way about. A
DirectShow camera cannot reach the Windows Camera app, the camera list in
Settings, packaged apps, Windows Hello, or the current Teams.
What the argument left out is what it cost, which only became clear once the thing was finished and in daily use:
| Media Foundation (2.x) | DirectShow (3.0) | |
|---|---|---|
| Where the camera code runs | Frame Server service, session 0 | inside the consuming program |
| Shared objects |
Global\, needs SeCreateGlobalPrivilege
|
Local\, needs nothing |
| Formats offered | 1080p, 720p, 480p — all at 30 | the source's own, plus the ordinary sizes below it |
| Updating the DLL | stop a system service | replace a file |
| Windows version | 11 (build 22000) and later | anything CapView runs on |
| Reaches packaged apps | yes | no |
The fixed list is the one that mattered. A 576i50 SNES came out of the 2.x
camera upscaled to 1080p and stuttering at 30, and there was nowhere in that
design to put the real answer, because a Media Foundation source has to declare
a finite list of media types up front. DirectShow does not: IAMStreamConfig
advertises ranges, and a consumer states what it wants rather than picking
from a menu.
Running in the consumer's process is the second thing. The camera is the same
user in the same session as the program reading it now, so Global\ goes away
along with the privilege it needed, the frame server goes away along with the
service restarts, and Windows 11 stops being a requirement.
The price is app containers. Local\ names are invisible inside one, so Store
apps do not see the camera. That is a deliberate trade: nothing anyone wants to
feed a capture card into is a Store app, and the alternative was asking every
user for administrator rights on every start.
Pin::BuildTypeList() puts the source's own shape first:
if (srcPixel == kPixelP010) add(kPixelP010, srcW, srcH);
add(kPixelNv12, srcW, srcH);A consumer that takes the first thing offered — OBS does — gets the picture
untouched. There is no scaling step at all in that case: ScalePlane takes a
memcpy fast path when source and destination are the same size.
Then the ordinary sizes follow, for consumers that read the list instead of the ranges. Chromium does that, which means Discord and every browser do:
1920×1080 1600×900 1280×720 960×540 854×480 640×480 640×360 320×240
Nothing larger than the source is offered, and there is no exception. 720p used to be in the list even from a 576i source, on the reasoning that a consumer with a fixed wish list needs its wish to be present. What that actually bought was a 576i console permanently displayed at 720p: a program picks the largest entry it likes and then keeps that choice for as long as it holds the camera. Discord did exactly that. Offering only what exists costs such a consumer nothing — it takes the next size down, or upscales at its own end, where the work belongs.
While CapView is not running there is no source to be smaller than, so the list is offered whole. A consumer that opens the camera before CapView starts should not be pinned to the stand-in's shape once a real picture arrives.
GetStreamCaps fills a VIDEO_STREAM_CONFIG_CAPS alongside each type, and each
one describes that type and nothing else:
c->MinOutputSize = { w, h }; // this entry's own size,
c->MaxOutputSize = { w, h }; // both ends
c->OutputGranularityX = 2; // chroma is half resolution
c->OutputGranularityY = 2;
c->MinFrameInterval = srcInterval; // fastest: the source's own rate
c->MaxFrameInterval = kSlowestInterval;There is no ceiling of its own — the largest entry follows the source. Put 8K at 120 in front of it and 8K at 120 is what it advertises.
It used to advertise a size range per entry instead, 32×32 up to
max(srcW, 1920) × max(srcH, 1080), on the reasoning that a consumer which
reads ranges could then ask for anything without finding it in a list. The
reasoning was sound and the result was not, because OBS reads those bounds
straight into its resolution dropdown. From a 576i console the list therefore
offered 32×32 at one end and 1920×1080 at the other, neither of which the
camera had, and both of which survived restarting OBS — they were never a
description of the source, so nothing about the source could change them.
Asking for a size that is not advertised still works: QueryAccept takes
anything from 32×32 up to the source's size, or 1920×1080 when the source is
smaller. Advertise narrowly, accept broadly.
MinFrameInterval is the source's interval rather than something invented: the
camera will divide a rate down but never make frames up.
Windows identifies a loaded module by name, which is why the DLL's filename carries a hash of its contents — installing a new CapView lays down a new file rather than replacing one that may be in use. The other half of that is worth knowing: a program that was already running when the camera was installed or updated keeps the filter it loaded until it is restarted. It is not a stale picture but stale code, so it can show behaviour that no longer exists in the build sitting on disk. Restarting the consuming program is the whole fix.
Measured with capview_vcam_test (below), against a source at 1920×1080 / 59.94:
| Asked for | Delivered | Samples in 5 s |
|---|---|---|
| nothing (first offer) | 1920×1080 @ 59.94 | 301 — 59.94 fps |
| 1280×720 @ 30 | 1280×720 @ 30 | 151 — 30.00 fps |
| 854×480 @ 24 | 854×480 @ 24 | 97 — 24.00 fps |
| 640×480 @ 15 | 640×480 @ 15, pillarboxed | 61 — 15.00 fps |
Three of those running at once, each at its own size and its own rate, is the normal case rather than a stress test — there is one filter instance per consumer and they share nothing but the pictures.
This is the part that surprises people, and it is DirectShow's rule rather than CapView's. A consumer connects, the two agree a media type, and the allocator commits buffers of exactly that size. From then on the shape is fixed for as long as that connection lives. There is no way to grow it underneath a running graph: the buffers the consumer already holds are the size they were agreed at, and a larger picture would not fit in them.
So swapping a 1080p console for a 576i one does everything on CapView's side immediately — a new frame section, a new generation, the new shape published — and changes nothing on the consumer's side. It goes on asking for what it negotiated, and the filter goes on fitting the new picture into it, pillarboxed. The camera is not stuck; the connection is.
Reopening the camera in the consumer is the whole fix. Worth knowing per program:
- OBS — a Video Capture Device has Resolution/FPS Type. On Custom it asks for the resolution written in the box and will keep asking for it forever. On Device Default it takes the first thing the camera offers, which is the source's own shape. Set it to Device Default and it follows the console; leave it on Custom and it never will. What Custom offers is now only what the camera actually has — that is what narrowing the capabilities above bought — so a stale 1080p in that box no longer has a matching entry to keep it looking legitimate.
- Discord — settles a format when the camera goes live and holds it for that call. Turning the camera off and on again in Discord renegotiates. Observed in the consumer table asking for 1280×720 at 60, not the 30 assumed here before anyone had watched it. From a 576i console there is no 720p entry to find any more, so it takes 720×576 and the upscale stops happening at all.
-
Browsers — take whatever the page's
getUserMediaconstraints ask for, pergetUserMediacall.
Nothing here can be fixed from CapView's side, which is worth stating plainly because the symptom looks exactly like a bug in the camera: CapView's own window shows the new console correctly while OBS shows it letterboxed into the old size.
A one-time step with a UAC prompt, with an uninstall button next to it. Using the camera afterwards needs no rights at all.
The reason is narrower than it was: a DirectShow filter is found through
HKEY_CLASSES_ROOT\CLSID and the filter mapper, both of which are machine-wide.
Nothing has to run as a service any more.
if (!WriteFilterDll(error)) return false;
if (!RunRegsvr(false, error)) return false;
CleanUpOldSources();RunRegsvr() runs regsvr32 through ShellExecuteEx with the runas verb,
which is what raises the prompt. UninstallSource() is the same call with /u.
DllRegisterServer writes the class, its InprocServer32, and an
IFilterMapper2::RegisterFilter entry under CLSID_VideoInputDeviceCategory.
The merit is MERIT_DO_NOT_USE, which is what every virtual camera uses: it
keeps the entry in the device list where a user can choose it, while stopping
the graph builder from wiring it into unrelated graphs on its own initiative.
The UAC prompt is avoidable. COM resolves a class through HKEY_CLASSES_ROOT,
which is a merged view: HKEY_CURRENT_USER\Software\Classes shadows
HKEY_LOCAL_MACHINE\Software\Classes. Writing the class, its InprocServer32
and an Instance entry under the video-input category into the per-user hive
needs no rights at all, and it was tried:
[4] CapView Virtual Camera
DevicePath : @device:sw:{860BB310-...}\{A326E6EC-...}
Gemeldete Capability-Entraege: 6
That is capview_probe going through the ordinary system device enumerator, so
the camera really does appear and really does answer GetStreamCaps with no
administrator anywhere in the picture. There is no FilterData blob in that
registration — IFilterMapper2::RegisterFilter was not involved — and with
MERIT_DO_NOT_USE nothing should want to read one.
3.0 ships the machine-wide install anyway. What has been shown is that the
filter enumerates; what has not been shown is that OBS, Discord and the
browsers are happy with a registration that carries no FilterData, and a
camera that quietly fails to appear for everyone is not a thing to find out
after release. It is written down here as the first candidate for 3.1.
One piece of it did ship, because it is a correctness fix rather than a change
of behaviour: RegisteredPath() now reads the per-user hive before the
machine-wide one, in the order COM itself uses. If the two ever disagree, the
per-user entry names the DLL that would really be loaded, and CapView reporting
the other one would send someone hunting for an evening.
CapView 3.0 uses a different CLSID from the Media Foundation source, so
installing it does not touch the old registration and the old camera would
otherwise go on appearing in device lists, pointing at a DLL that no longer
exists. RemoveLegacyRegistration() runs from both DllRegisterServer and
DllUnregisterServer and deletes the 2.x class along with its entry under
Windows Media Foundation\Platform\VirtualCameras.
Anyone upgrading from 2.x has to install the camera again. There is no way around it: the old filter and the new one are different COM classes.
The filter has to be a separate DLL because DirectShow loads it into the
consuming program. It is not a separate download: it travels inside
CapView.exe as an RT_RCDATA resource (kFilterResourceId = 101) and is
written out when the camera is installed.
A release is one file, and the executable can only ever install a filter that matches itself.
capview_vcam.def exists because STDAPI carries no dllexport — without the
module definition file the DLL exported nothing at all and regsvr32 had
nothing to call.
uint64_t hash = 1469598103934665603ull; // FNV-1a
for (DWORD i = 0; i < size; ++i) hash = (hash ^ data[i]) * 1099511628211ull;
swprintf(buffer, 64, L"capview_vcam_%016llx.dll", hash);This is not tidiness, and it took a long time to arrive at.
Windows identifies a loaded module inside a process by its file name, not by
its path. A later LoadLibrary of a different file with the same name hands
back the module already loaded rather than reading the new one. Renaming the old
file out of the way does not help either — measured: a loaded module keeps its
pages, not its directory entry.
Between them those two facts made this look unfixable from the outside.
Installing appeared to work, regsvr32 reported success, and the old code kept
answering. Every symptom pointed at the new DLL being broken; nothing pointed at
it never being read.
A name that changes with the contents cannot be confused with anything, and an install becomes idempotent: the file for this build either is already there or is not.
Under DirectShow this matters less than it did — there is no long-lived service holding the file, so a program that closes releases it — but a consumer that stays open for days is ordinary, and the mechanism costs nothing.
CleanUpOldSources() sweeps every file matching the pattern except the one this
build uses. Anything still mapped refuses to delete and gets another chance at a
later start. RegisteredPath() reads back what the registration actually
points at, so "installed" means the file is there rather than merely that a key
exists.
Three kinds of object, all under Local\:
Local\CapViewVCamControl one control block
Local\CapViewVCamFrames-<gen> the pictures, named by generation
Local\CapViewVCamWake-<0..7> one auto-reset event per consumer slot
CapView creates all of them. Under Media Foundation it was the other way
about, because only the service could create under Global\; now both halves
are the same user and the producer can own its own objects.
Because their size follows the source, and the source changes. A mapped section
cannot grow in place. So a format change makes a new section, and the
control block's frameGeneration names the current one; consumers notice the
number moved and remap. Releasing the old handle is safe while a consumer is
still reading from it, because that consumer holds a handle of its own.
The alternative is sizing one section for the largest picture the camera will ever carry, and at 8K that is three hundred megabytes reserved to show a SNES.
Generations never start at zero. A fresh control block seeds from
GetTickCount() | 1u, so a consumer that survived a CapView which did not clean
up is never handed a generation it already has mapped.
struct alignas(64) SlotHeader {
volatile uint32_t sequence; // odd while being written, even when whole
uint32_t width, height, strideY, pixel;
uint32_t frameIndex; // running count: new picture or repeat
int64_t timestamp100ns;
};A reader that sees the same even value before and after copying knows the copy is not torn. Neither side ever waits for the other. Three slots is enough that a reader normally never collides with the writer: one being written, one being read, one spare.
Normally. A torn read used to mean giving up until the next frame, and at
large sizes the copy is long enough that contention stops being rare — measured
at 8K, that turned into a black picture that never recovered: 0 fresh frames
out of 94 served. ReadInto now retries up to three times, which is enough
because the writer has moved to a different slot by then. The same test after
the change: 239 fresh out of 269.
A single shared event cannot wake several readers exactly once — whoever waited first eats it. So there is one auto-reset event per consumer slot, and the producer signals the ones whose slots are in use.
Missing a wake is not a correctness problem: the delivery thread wakes on a timer regardless, and a reader that misses a signal simply serves the picture it already has. The event only saves it from spinning.
struct ConsumerSlot {
volatile uint32_t inUse;
uint32_t pid;
wchar_t name[64]; // the consuming executable
volatile uint32_t width, height, pixel;
volatile int64_t frameInterval100ns; // 0 until a format is settled
volatile uint32_t streaming, framesServed, framesFresh, lastSeenMs;
};Eight slots. This is not a nicety — it is the shape of the design. There is one
filter instance per consumer, each negotiating its own format, and two of them
disagreeing is the normal case. Each instance claims a slot, writes its own
answers into it, and heartbeats lastSeenMs.
Settings → Recording lists the table: program name, negotiated format, and whether it is merely connected or actually reading.
A quiet slot is not a dead one. A filter that has been instantiated but not
yet started only touches its slot when its format is negotiated, and a consumer
sitting in a preview dialog can be slow about that. So PruneConsumers() treats
a slot quiet for more than three seconds as a candidate, and reclaims it only
after confirming with OpenProcess/WaitForSingleObject that the process has
actually exited.
vcam_filter.cpp. One IBaseFilter with one output pin, which also implements
IAMStreamConfig (the ranges), IKsPropertySet (PIN_CATEGORY_CAPTURE, which
is how applications recognise a capture pin) and IAMFilterMiscFlags
(AM_FILTER_MISC_FLAGS_IS_SOURCE).
STDMETHODIMP Filter::Pause() {
// A live source has nothing to hold still for. Streaming starts here rather
// than in Run because a graph pauses first and expects the first sample to
// be on its way before it ever calls Run.A graph that pauses and waits for a sample that only arrives on Run deadlocks.
Every live source does this.
Pin::ThreadLoop() keeps a QueryPerformanceCounter deadline and adds one
frame interval per sample. Being woken by a new picture is explicitly not a
reason to deliver early:
// Woken by a new picture is not a reason to deliver early: the consumer
// asked for a rate and gets that rate. It only means the wait ends.
continue;If it falls more than four frames behind — the machine slept, the consumer blocked — the deadline resets to now rather than delivering the backlog as fast as the loop can run.
This is the part 2.x got wrong in the other direction: the Media Foundation source served samples as fast as they were requested, measured at over 2000 a second for a 30 fps camera, and consumers showed nothing at all.
Compose() scales and letterboxes into whatever the consumer negotiated. That
work happening inside Discord rather than inside CapView is the point: a 4:3
console in a 16:9 camera is pillarboxed rather than stretched, and the cost is
paid by the program that asked for the odd shape, not by everything else reading
the same camera.
src/vcam/vcam_idle.cpp
The camera is in every device list from the moment it is installed, and a camera that is always listed and shows black when idle is indistinguishable from a broken one. So when nothing is feeding it, it draws the CapView mark over a dark ground with CapView is not running underneath, and a smaller line saying to start CapView and turn the camera on.
The second line covers the other case honestly: CapView running with the camera switched off looks identical from here. The two cannot be told apart — a consumer that mapped the control section keeps that mapping alive after CapView exits, so the section still being there proves nothing about the process.
What separates black from the idle picture is producerAlive. A read that fails
while the producer is alive is a torn slot or the moment before the first frame:
that lasts a frame or two and gets black, because a sentence flashing up between
two pictures would be worse than nothing. A read that fails with no producer is
not going to succeed, and gets the explanation.
Drawn with GDI, in the consumer's process like everything else here:
-
GDI rather than a baked bitmap font, because the size is not known until
the consumer has negotiated one. A font baked at one size looks like a font
baked at one size at every other.
ANTIALIASED_QUALITYand never ClearType — subpixel fringes become red and blue edges on the letters once they have been through a 4:2:0 chroma pass. -
The mark is a resource in the DLL, palette-and-run-length packed by
tools/make_vcam_icon.py: 213 colours in 1729 runs, 7.8 KiB instead of the 256 KiB the raw 256×256 image costs. It is paid for twice — once in the DLL, once again inCapView.exe, which carries the DLL — which is why it is packed at all. The decoder is twenty lines and the generator round-trips its own output before writing it. - Drawn once per shape and kept. Laying out text thirty times a second to say the same thing would be thirty times too many.
- Both layouts. NV12 and P010, BT.709 limited range, converted from the BGRA canvas. P010 is only reachable by a consumer that negotiated it against an HDR source and then lost the producer, since an idle camera offers NV12 only — but that path exists, so it is tested.
Below about a hundred lines there is no room for a sentence and the subtitle is dropped; below that the mark is drawn alone. The pin does advertise down to 32×32, and a frame of one flat colour says nothing at all.
Off by default, and deliberately so: something that takes the ten bits without understanding them shows a wrong picture. When the source is HDR and the switch is on, P010 is offered first and NV12 immediately behind it, so a consumer that has never heard of an HDR webcam still finds something it understands.
The 2.x design needed a file in ProgramData to carry this switch across the
session boundary before the shared section existed. That is gone: sourcePixel
in the control block is the whole of it, and it doubles as the answer to whether
there is anything to offer the ten bit form from — one field rather than two
that can disagree.
SetSourceShape() is called every frame whether or not anybody is watching, and
that ordering matters. Readback is only enabled once a consumer streams, so if
the source shape were noted when a frame was pushed, it would still be zero at
the moment a consumer negotiated — and every consumer would fall back to the
idle 1280×720.
capview_vcam_test.exe (built from src/tools/vcam_test.cpp, not shipped) is
both halves of the contract in one program.
capview_vcam_test.exe --caps-only
capview_vcam_test.exe --size 1280x720 --fps 30 --seconds 5
capview_vcam_test.exe --produce --size 7680x4320 --fps 120
As a consumer it loads the DLL by path and calls DllGetClassObject
directly, so it needs no registration and no administrator rights. It prints
every capability with its ranges, optionally calls SetFormat, connects a
minimal IPin + IMemInputPin sink — a DirectShow source needs nothing else on
the other end — runs the filter, and reports the rate that actually arrived and
whether the picture was blank.
As a producer it publishes a moving pattern into the same shared section
CapView publishes into, written straight against vcam_shared.h rather than
against VirtualCamera. That is deliberate: an independent second
implementation of the same contract, so where the two agree the contract is
unambiguous. It also means the reading half can be exercised with no capture
card, no installed camera, and no CapView running at all.
The 8K seqlock failure above was found this way and would not have been found any other way.
The test tool standing in for CapView leaves one half untested: VirtualCamera
itself, the code in CapView that owns the section and fills it from the capture
card. A temporary per-user registration was enough to get past the "installed?"
gate and run the real thing, with the PAL source live on the SA7160:
| consumer asked for | arrived | picture |
|---|---|---|
| native | 300 samples in 5.005 s (59.94 fps) | luma 17..234, mean 143 |
| 1280x720 @ 30 | 150 samples in 5.000 s (30 fps) | luma 17..233, mean 172 |
| 640x480 @ 15 | 61 samples in 4.067 s (15 fps) | luma 16..233, mean 153 |
The luma ranges are the tell: the synthetic producer paints a 16..235 ramp, so
values that stop just short of it are the capture card's picture rather than the
test pattern. CapView's own log agreed from the other side — Leser da,
Leser weg, and a published count climbing by about 61 a second.
- Not visible to packaged apps. See the trade at the top.
- A consumer keeps the size it opened with. Changing console mid-session does not change what OBS or Discord are asking for. See A format is settled once — in OBS the setting is Resolution/FPS Type: Device Default.
- Slightly choppy in Discord under 2.x. Whether the DirectShow path changes that is untested — it was Discord's own handling of virtual cameras rather than CapView's pacing, and the same source was smooth in OBS on the same machine at the same time.