diff options
| author | Anton <anton@picapica.im> | 2026-08-24 06:11:27 +0400 |
|---|---|---|
| committer | Anton <anton@picapica.im> | 2026-08-24 06:11:27 +0400 |
| commit | acba2cd3eec20b31415e4b1d8e3bed85e9ab821b (patch) | |
| tree | 30f817b1a270e9bc9861827fecd1f9b294098d6b | |
| parent | e4067492b4025509a94ac499106a69de356d740b (diff) | |
| download | pica-pica-acba2cd3eec20b31415e4b1d8e3bed85e9ab821b.tar.gz pica-pica-acba2cd3eec20b31415e4b1d8e3bed85e9ab821b.zip | |
pica-client: compressed camera formats on Windows
Lets a camera's own H.264 or MJPEG go straight onto the network on Windows as
it already could on Linux, so nothing has to be decoded and re-encoded.
Finding out what a camera offers takes rather more than the single v4l2 ioctl
it takes on Linux: the camera has to be instantiated, its output pins found,
and each pin's stream capabilities walked through IAMStreamConfig, reading the
FourCC out of the BITMAPINFOHEADER in every media type. Asking for the result
is spelled differently too - dshow has no input_format option and takes the
choice from AVFormatContext::video_codec_id instead, which is what
"-vcodec mjpeg" ahead of "-i" sets, and which has to be set on a context
allocated by us rather than by avformat_open_input().
Opening now loosens its constraints a step at a time rather than giving up on
the format at the first refusal. Listing a format, producing it at a given
size, and producing it at a given frame rate are three separate claims, and
cameras honour them independently: an integrated webcam here offers MJPEG but
not at 640x480 15fps, and was falling all the way back to re-encoding. The
frame rate is given up first, then the size, and the format last, since the
format is the one that decides whether anything has to be re-encoded at all.
What the camera settles on is what gets announced to the peer, so this changes
what is sent rather than what is claimed about it.
That relaxation exposed an assumption that was already wrong: the encoder was
hard-wired to kFrameRate with a plain incrementing pts, while the pre-existing
"let the camera choose" fallback had always been able to open without a frame
rate at all. A camera delivering 30fps therefore produced a stream whose
timestamps claimed half that. It now takes the rate from the opened stream,
and the keyframe interval with it, so two seconds between keyframes stays two
seconds - kGopSize is gone, a fixed frame count no longer meaning a fixed
duration.
Every FourCC a camera reports is logged, mapped or not. An integrated camera
offering nothing but uncompressed types while advertising MJPEG under v4l2
would have meant the Windows Camera Frame Server had already decoded it, with
nothing to be done from here - worth being able to tell apart from a FourCC we
simply failed to map, which is what this turned out not to be.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBqDgvq3Ty6C6BjLFH16bw
| -rw-r--r-- | pica-client/videodevice.cpp | 233 | ||||
| -rw-r--r-- | pica-client/videodshow.cpp | 261 |
2 files changed, 438 insertions, 56 deletions
diff --git a/pica-client/videodevice.cpp b/pica-client/videodevice.cpp index 8ca5cdc..5b7d5fb 100644 --- a/pica-client/videodevice.cpp +++ b/pica-client/videodevice.cpp @@ -58,10 +58,6 @@ extern "C" { // the feature plan; a fixed, widely supported set of parameters for now. static const int kFrameRate = 15; static const int64_t kBitrate = 600000; -// Keyframe every ~2 seconds. There is no retransmission or keyframe request -// machinery yet, so this doubles as how quickly a receiver that missed data -// can resynchronize. -static const int kGopSize = kFrameRate * 2; // Largest amount of encoded data one 0x77 message can carry over a c2c or // directc2c connection: the protocol's per-message payload cap, minus the @@ -82,6 +78,14 @@ static QString ff_errstr(int err) return QString::fromLocal8Bit(buf); } +#ifdef Q_OS_WIN +// Both defined in videodshow.cpp. FFmpeg's dshow demuxer can capture but can +// neither enumerate the cameras nor say what formats one offers, so both +// answers come from DirectShow itself. +QList<MediaDeviceInfo> pica_enumerate_dshow_video(); +QStringList pica_dshow_compressed_formats(const QString &device); +#endif + #ifdef Q_OS_LINUX // Compressed formats worth forwarding untouched, best first: HEVC gives the // most picture per byte, H.264 is the widely supported middle ground, and @@ -153,6 +157,8 @@ QStringList VideoDevice::CompressedFormats(const QString &device) if (offered.contains(kCompressedFormatPreference[i].v4l2_pixelformat) && !result.contains(codec)) result << codec; } +#elif defined(Q_OS_WIN) + result = pica_dshow_compressed_formats(device); #else Q_UNUSED(device); #endif @@ -323,6 +329,61 @@ static QByteArray cameraOpenUrl(const QString &device) #endif } +// One attempt at opening the camera. +// +// passthroughCodec empty asks for whatever the camera gives; a width of zero +// drops the size and frame rate constraints as well. *ifmt_ctx is left null on +// failure - avformat_open_input() frees and clears it - so the caller can just +// try again. +static int openCamera(const AVInputFormat *ifmt, const QByteArray &url, + int width, int height, int framerate, + const QString &passthroughCodec, AVFormatContext **ifmt_ctx) +{ + AVDictionary *opts = nullptr; + + if (width > 0 && height > 0) + av_dict_set(&opts, "video_size", QString("%1x%2").arg(width).arg(height).toUtf8().constData(), 0); + + if (framerate > 0) + av_dict_set(&opts, "framerate", QString::number(framerate).toUtf8().constData(), 0); + + if (!passthroughCodec.isEmpty()) + { +#ifdef Q_OS_WIN + // dshow has no input_format option. It picks the compressed stream + // from AVFormatContext::video_codec_id instead, which is what + // "-vcodec mjpeg" ahead of "-i" sets on the command line - and that + // has to be set on a context allocated here, since + // avformat_open_input() would otherwise make one only after the point + // where the demuxer reads it. + const AVCodec *dec = avcodec_find_decoder_by_name(passthroughCodec.toUtf8().constData()); + + if (!dec) + { + av_dict_free(&opts); + return AVERROR(EINVAL); + } + + *ifmt_ctx = avformat_alloc_context(); + + if (!*ifmt_ctx) + { + av_dict_free(&opts); + return AVERROR(ENOMEM); + } + + (*ifmt_ctx)->video_codec_id = dec->id; +#else + av_dict_set(&opts, "input_format", passthroughCodec.toUtf8().constData(), 0); +#endif + } + + int ret = avformat_open_input(ifmt_ctx, url.constData(), ifmt, &opts); + av_dict_free(&opts); + + return ret; +} + void VideoDevice::Capture() { m_abort.storeRelaxed(0); @@ -347,54 +408,75 @@ void VideoDevice::Capture() // Ask the camera for a compressed stream when told to and it offers one: // its packets can then go straight onto the network, with no decoding and // re-encoding in between. The formats are queried rather than guessed, so - // this asks for one the camera has already said it can deliver. - QString passthroughCodec; + // these are ones the camera has already said it can deliver, best first. + // The empty string on the end is the fallback of taking whatever comes and + // re-encoding it. + QStringList attempts; if (m_preferCompressed) + attempts = CompressedFormats(m_deviceName); + + attempts << QString(); + + // Listing a format and being able to produce it at a given size and frame + // rate are three separate claims, and cameras routinely honour the first + // without the others - an integrated webcam offering MJPEG but not at + // 640x480 15fps is what prompted this. So each candidate is tried with the + // constraints loosened a step at a time, giving up the frame rate before + // the size and both before giving up the format, since the format is worth + // the most: it decides whether anything has to be re-encoded at all. + // + // Whatever comes back is what gets announced to the peer - see + // captureStarted() and AudioVideoCallController::video_capture_started() - + // so relaxing these changes what is sent, not what is claimed. + static const struct { - QStringList formats = CompressedFormats(m_deviceName); - - if (!formats.isEmpty()) - passthroughCodec = formats.first(); - } - - AVDictionary *opts = nullptr; - av_dict_set(&opts, "video_size", QString("%1x%2").arg(m_width).arg(m_height).toUtf8().constData(), 0); - av_dict_set(&opts, "framerate", QString::number(kFrameRate).toUtf8().constData(), 0); - if (!passthroughCodec.isEmpty()) - av_dict_set(&opts, "input_format", passthroughCodec.toUtf8().constData(), 0); + bool size; + bool rate; + } kConstraints[] = + { + { true, true }, + { true, false }, + { false, false }, + }; + QString passthroughCodec; AVFormatContext *ifmt_ctx = nullptr; - int ret = avformat_open_input(&ifmt_ctx, deviceUtf8.constData(), ifmt, &opts); - av_dict_free(&opts); + int ret = AVERROR(EINVAL); + bool opened = false; - if (ret < 0 && !passthroughCodec.isEmpty()) + for (int a = 0; a < attempts.size() && !opened; a++) { - // The camera listed the format but will not actually hand it over at - // this size or frame rate. Fall back to whatever it does give. - qWarning() << QString("Camera '%1' would not deliver %2 (%3), falling back to re-encoding") - .arg(m_deviceName, passthroughCodec, ff_errstr(ret)); - passthroughCodec.clear(); - - opts = nullptr; - av_dict_set(&opts, "video_size", QString("%1x%2").arg(m_width).arg(m_height).toUtf8().constData(), 0); - av_dict_set(&opts, "framerate", QString::number(kFrameRate).toUtf8().constData(), 0); - - ret = avformat_open_input(&ifmt_ctx, deviceUtf8.constData(), ifmt, &opts); - av_dict_free(&opts); - } + for (unsigned int c = 0; c < sizeof(kConstraints) / sizeof(kConstraints[0]); c++) + { + ret = openCamera(ifmt, deviceUtf8, + kConstraints[c].size ? m_width : 0, + kConstraints[c].size ? m_height : 0, + kConstraints[c].rate ? kFrameRate : 0, + attempts.at(a), &ifmt_ctx); - if (ret < 0) - { - // Last resort: let the camera pick. DirectShow in particular will - // refuse a video_size or framerate it cannot produce exactly, rather - // than settling for the nearest it can, so a camera with an unusual - // set of modes would otherwise give no picture at all. Whatever comes - // back is scaled to the negotiated size by the transcode loop anyway. - qWarning() << QString("Camera '%1' would not open at %2x%3 @%4 (%5), letting it choose") - .arg(m_deviceName).arg(m_width).arg(m_height).arg(kFrameRate).arg(ff_errstr(ret)); - - ret = avformat_open_input(&ifmt_ctx, deviceUtf8.constData(), ifmt, nullptr); + if (ret >= 0) + { + passthroughCodec = attempts.at(a); + opened = true; + break; + } + + QString asked; + + if (!kConstraints[c].size) + asked = QStringLiteral("any size or rate"); + else if (kConstraints[c].rate) + asked = QString("%1x%2 @%3").arg(m_width).arg(m_height).arg(kFrameRate); + else + asked = QString("%1x%2 at any rate").arg(m_width).arg(m_height); + + qWarning() << QString("Camera '%1': no %2 at %3 (%4)") + .arg(m_deviceName, + attempts.at(a).isEmpty() ? QStringLiteral("stream") : attempts.at(a), + asked, + ff_errstr(ret)); + } } if (ret < 0) @@ -431,6 +513,26 @@ void VideoDevice::Capture() AVStream *in_st = ifmt_ctx->streams[0]; + // Check what actually came back rather than trusting the request. A + // camera can list a format and then open in a different one, and asking + // for it is spelled differently per demuxer - so the one thing worth + // relying on is the stream in front of us. Handing raw frames to the + // passthrough loop would put unencoded video on the wire labelled as + // something it is not. + if (!passthroughCodec.isEmpty()) + { + const AVCodec *wanted = avcodec_find_decoder_by_name(passthroughCodec.toUtf8().constData()); + + if (!wanted || in_st->codecpar->codec_id != wanted->id) + { + qWarning() << QString("Camera '%1' opened as %2 rather than the %3 that was asked for, re-encoding") + .arg(m_deviceName, + QLatin1String(avcodec_get_name(in_st->codecpar->codec_id)), + passthroughCodec); + passthroughCodec.clear(); + } + } + if (!passthroughCodec.isEmpty()) runPassthroughLoop(ifmt_ctx, in_st, passthroughCodec); else @@ -498,8 +600,33 @@ void VideoDevice::runPassthroughLoop(AVFormatContext *ifmt_ctx, AVStream *in_st, av_packet_free(&pkt); } +// The frame rate the camera actually settled on, which is not necessarily the +// one that was asked for - and when the open had to fall back to letting the +// camera choose, was not asked for at all. Encoding at a rate the frames do +// not arrive at gives a stream whose timestamps disagree with reality. +static AVRational cameraFrameRate(AVStream *in_st) +{ + AVRational fr = in_st->avg_frame_rate; + + if (fr.num <= 0 || fr.den <= 0) + fr = in_st->r_frame_rate; + + if (fr.num <= 0 || fr.den <= 0) + fr = AVRational{ kFrameRate, 1 }; + + return fr; +} + void VideoDevice::runTranscodeLoop(AVFormatContext *ifmt_ctx, AVStream *in_st) { + const AVRational frameRate = cameraFrameRate(in_st); + + // Two seconds between keyframes. A receiver that joins late or loses a + // packet waits this long for a picture, so it is a latency figure as much + // as a bandwidth one - hence deriving it from the real rate rather than + // from a fixed frame count. + const int gopSize = qMax(1, (int)av_q2d(frameRate) * 2); + int ret; // The camera hands us either raw frames or a compressed stream that is @@ -555,10 +682,10 @@ void VideoDevice::runTranscodeLoop(AVFormatContext *ifmt_ctx, AVStream *in_st) enc_ctx->width = m_width; enc_ctx->height = m_height; enc_ctx->pix_fmt = AV_PIX_FMT_VAAPI; - enc_ctx->time_base = AVRational{1, kFrameRate}; - enc_ctx->framerate = AVRational{kFrameRate, 1}; + enc_ctx->time_base = av_inv_q(frameRate); + enc_ctx->framerate = frameRate; enc_ctx->bit_rate = kBitrate; - enc_ctx->gop_size = kGopSize; + enc_ctx->gop_size = gopSize; enc_ctx->max_b_frames = 0; enc_ctx->hw_frames_ctx = av_buffer_ref(hw_frames_ref); @@ -614,10 +741,10 @@ void VideoDevice::runTranscodeLoop(AVFormatContext *ifmt_ctx, AVStream *in_st) enc_ctx->width = m_width; enc_ctx->height = m_height; enc_ctx->pix_fmt = AV_PIX_FMT_YUV420P; - enc_ctx->time_base = AVRational{1, kFrameRate}; - enc_ctx->framerate = AVRational{kFrameRate, 1}; + enc_ctx->time_base = av_inv_q(frameRate); + enc_ctx->framerate = frameRate; enc_ctx->bit_rate = kBitrate; - enc_ctx->gop_size = kGopSize; + enc_ctx->gop_size = gopSize; // B-frames reorder output, which would cost latency in a live call. enc_ctx->max_b_frames = 0; @@ -1034,12 +1161,6 @@ void VideoDevice::Play() avcodec_free_context(&dec_ctx); } -#ifdef Q_OS_WIN -// Defined in videodshow.cpp - FFmpeg's dshow demuxer can capture but cannot -// enumerate, so the list comes from DirectShow itself. -QList<MediaDeviceInfo> pica_enumerate_dshow_video(); -#endif - QList<MediaDeviceInfo> VideoDevice::Enumerate(enum MediaDeviceStreamDirection dir) { QList<MediaDeviceInfo> result; diff --git a/pica-client/videodshow.cpp b/pica-client/videodshow.cpp index 394798a..150e8a6 100644 --- a/pica-client/videodshow.cpp +++ b/pica-client/videodshow.cpp @@ -23,6 +23,8 @@ #include <QDebug> #include <QList> #include <QString> +#include <QStringList> +#include <QVector> // INITGUID before the DirectShow headers so CLSID_SystemDeviceEnum and the // rest get defined here rather than needing strmiids at link time. The GUIDs @@ -34,12 +36,271 @@ #include <windows.h> #include <objbase.h> #include <dshow.h> +// VIDEOINFOHEADER2 lives here rather than in dshow.h, which only brings in the +// FORMAT_VideoInfo2 GUID that names it. +#include <dvdmedia.h> static QString wideToQString(LPCWSTR s) { return s ? QString::fromWCharArray(s) : QString(); } +// Compressed formats worth forwarding untouched, best first. Same order and +// the same FFmpeg codec names as kCompressedFormatPreference in +// videodevice.cpp - that table is keyed by v4l2 pixel format, this one by the +// FourCC DirectShow reports in BITMAPINFOHEADER::biCompression, but they mean +// the same thing and should be kept in step. +// +// Cameras are inconsistent about which FourCC they use for the same stream, +// so several map to one codec. +static const struct +{ + const char *fourcc; + const char *ffmpeg_codec; +} kDshowCompressedFormats[] = +{ + { "HEVC", "hevc" }, + { "hevc", "hevc" }, + { "H265", "hevc" }, + { "h265", "hevc" }, + { "H264", "h264" }, + { "h264", "h264" }, + { "X264", "h264" }, + { "x264", "h264" }, + { "AVC1", "h264" }, + { "avc1", "h264" }, + { "MJPG", "mjpeg" }, + { "mjpg", "mjpeg" }, + { "dmb1", "mjpeg" }, + { "JPEG", "mjpeg" }, +}; + +static QString fourccToString(DWORD fourcc) +{ + char c[5]; + c[0] = (char)(fourcc & 0xff); + c[1] = (char)((fourcc >> 8) & 0xff); + c[2] = (char)((fourcc >> 16) & 0xff); + c[3] = (char)((fourcc >> 24) & 0xff); + c[4] = 0; + + // Uncompressed types use small numbers rather than four characters - + // BI_RGB is 0 - which would otherwise come out as an unreadable string. + for (int i = 0; i < 4; i++) + { + if (c[i] < 0x20 || c[i] > 0x7e) + return QString("0x%1").arg((quint32)fourcc, 8, 16, QLatin1Char('0')); + } + + return QString::fromLatin1(c); +} + +// AM_MEDIA_TYPE owns two allocations of its own. DeleteMediaType() would do +// this, but it lives in the DirectShow base classes rather than in any library +// worth pulling in for one function. +static void freeMediaType(AM_MEDIA_TYPE *mt) +{ + if (!mt) + return; + + if (mt->cbFormat && mt->pbFormat) + CoTaskMemFree(mt->pbFormat); + if (mt->pUnk) + mt->pUnk->Release(); + + CoTaskMemFree(mt); +} + +// Finds the camera behind one of the device strings enumeration handed out, +// matching on either name for the same reason FFmpeg's demuxer does: the +// friendly name for the common case, the device path where that was not +// unique. Caller releases the moniker. +static IMoniker *findCamera(const QString &device) +{ + ICreateDevEnum *devEnum = nullptr; + if (FAILED(CoCreateInstance(CLSID_SystemDeviceEnum, nullptr, CLSCTX_INPROC_SERVER, + IID_ICreateDevEnum, (void **)&devEnum)) || !devEnum) + return nullptr; + + IEnumMoniker *enumMoniker = nullptr; + HRESULT hr = devEnum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory, &enumMoniker, 0); + devEnum->Release(); + + if (hr != S_OK || !enumMoniker) + return nullptr; + + IMoniker *moniker = nullptr; + IMoniker *found = nullptr; + + while (!found && enumMoniker->Next(1, &moniker, nullptr) == S_OK) + { + IPropertyBag *bag = nullptr; + if (SUCCEEDED(moniker->BindToStorage(nullptr, nullptr, IID_IPropertyBag, (void **)&bag)) && bag) + { + VARIANT var; + + VariantInit(&var); + if (SUCCEEDED(bag->Read(L"FriendlyName", &var, nullptr)) && var.vt == VT_BSTR && + wideToQString(var.bstrVal) == device) + { + found = moniker; + } + VariantClear(&var); + + if (!found) + { + VariantInit(&var); + if (SUCCEEDED(bag->Read(L"DevicePath", &var, nullptr)) && var.vt == VT_BSTR && + wideToQString(var.bstrVal).replace(QLatin1Char(':'), QLatin1Char('_')) == device) + { + found = moniker; + } + VariantClear(&var); + } + + bag->Release(); + } + + if (!found) + moniker->Release(); + } + + enumMoniker->Release(); + return found; +} + +// Which compressed formats a camera can hand over ready-made, best first. +// +// The counterpart of the v4l2 VIDIOC_ENUM_FMT walk in +// VideoDevice::CompressedFormats(). DirectShow has no single call for it: the +// camera has to be instantiated, its output pin found, and the pin's stream +// capabilities enumerated one media type at a time. +// +// Note this instantiates the capture filter, which is also the step that fails +// when Windows is refusing the app access to the camera - so an empty list +// here can mean either "offers nothing compressed" or "cannot be opened at +// all". Both lead to the same place, the caller simply transcodes instead. +QStringList pica_dshow_compressed_formats(const QString &device) +{ + QStringList result; + + if (device.isEmpty()) + return result; + + bool didInit = false; + HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + if (SUCCEEDED(hr)) + didInit = true; + else if (hr != RPC_E_CHANGED_MODE) + return result; + + IMoniker *moniker = findCamera(device); + if (!moniker) + { + if (didInit) + CoUninitialize(); + return result; + } + + IBaseFilter *filter = nullptr; + hr = moniker->BindToObject(nullptr, nullptr, IID_IBaseFilter, (void **)&filter); + moniker->Release(); + + if (FAILED(hr) || !filter) + { + qWarning() << "DirectShow: could not instantiate" << device + << "to ask what formats it offers"; + if (didInit) + CoUninitialize(); + return result; + } + + QStringList offered; + + IEnumPins *enumPins = nullptr; + if (SUCCEEDED(filter->EnumPins(&enumPins)) && enumPins) + { + IPin *pin = nullptr; + + while (enumPins->Next(1, &pin, nullptr) == S_OK) + { + PIN_DIRECTION dir; + IAMStreamConfig *config = nullptr; + + if (SUCCEEDED(pin->QueryDirection(&dir)) && dir == PINDIR_OUTPUT && + SUCCEEDED(pin->QueryInterface(IID_IAMStreamConfig, (void **)&config)) && config) + { + int count = 0, size = 0; + + if (SUCCEEDED(config->GetNumberOfCapabilities(&count, &size)) && size > 0) + { + QVector<quint8> caps(size); + + for (int i = 0; i < count; i++) + { + AM_MEDIA_TYPE *mt = nullptr; + if (FAILED(config->GetStreamCaps(i, &mt, caps.data())) || !mt) + continue; + + // Only the VIDEOINFOHEADER shapes carry a + // BITMAPINFOHEADER, which is where the FourCC lives. + const BITMAPINFOHEADER *bih = nullptr; + + if (mt->formattype == FORMAT_VideoInfo && + mt->cbFormat >= sizeof(VIDEOINFOHEADER)) + bih = &((VIDEOINFOHEADER *)mt->pbFormat)->bmiHeader; + else if (mt->formattype == FORMAT_VideoInfo2 && + mt->cbFormat >= sizeof(VIDEOINFOHEADER2)) + bih = &((VIDEOINFOHEADER2 *)mt->pbFormat)->bmiHeader; + + if (bih) + { + QString fourcc = fourccToString(bih->biCompression); + if (!offered.contains(fourcc)) + offered << fourcc; + } + + freeMediaType(mt); + } + } + } + + if (config) + config->Release(); + pin->Release(); + } + + enumPins->Release(); + } + + filter->Release(); + + // Worth logging whole, not just the ones we recognise. A camera offering + // nothing but uncompressed types here - NV12, YUY2 - while advertising + // MJPEG on the same hardware under v4l2 is the signature of the Windows + // Camera Frame Server, which mediates integrated cameras and hands + // DirectShow the already-decoded output. Nothing to be done about that + // from here, but it is worth being able to tell it apart from a FourCC we + // simply failed to map. + qDebug() << "DirectShow:" << device << "offers" + << (offered.isEmpty() ? QStringLiteral("(nothing)") : offered.join(QLatin1String(", "))); + + // Report in our preference order rather than in the order the camera + // happened to list them. + for (unsigned int i = 0; i < sizeof(kDshowCompressedFormats) / sizeof(kDshowCompressedFormats[0]); i++) + { + QString codec = QLatin1String(kDshowCompressedFormats[i].ffmpeg_codec); + + if (offered.contains(QLatin1String(kDshowCompressedFormats[i].fourcc)) && !result.contains(codec)) + result << codec; + } + + if (didInit) + CoUninitialize(); + + return result; +} + // Lists the cameras DirectShow knows about. // // Capture itself goes through FFmpeg's "dshow" demuxer rather than through |
