diff --git a/AGENTS.md b/AGENTS.md index 18f94e4..945c90b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,6 +186,20 @@ Frames the device must deliver after a recovery before the one-shot port reset r Controls the silence-triggered wedge-recovery port reset. The default remains `true` and preserves the always-on recovery behavior. Set it to `false` for a device or scenario where issuing `USBDEVFS_RESET` risks stranding the camera; sustained silence then skips the reset and falls through to the normal `RESOURCE/READ` disconnect error path. +### `deliverable-caps` (GstCaps, read-only) + +The post-quirk mode ladder `negotiate()` actually selects from — what this element will **accept**, not what the device **advertises**. `NULL` until a device has been negotiated; a consumer must read `NULL` as *unknown*, never as *no modes*. + +For a camera with a `QUIRK_MAX_PIXEL_RATE` row these differ: the DJI Osmo Pocket 3 advertises `3840x2160@60/50/48` and this property omits all three, because negotiation refuses them. + +### Action signal: `filter-deliverable-caps(advertised, vendor-id, product-id)` → GstCaps + +The same exclusion applied to a **caller-supplied** ladder, with no device involved. Pure caps arithmetic — it does not open, probe, or touch any camera. + +This exists for consumers that enumerate devices. cerastream already holds the advertised ladder (from `GstDevice::caps()`, a v4l2 enumeration) and the USB ids, and must not open the camera to learn which modes are real — opening one through libuvc detaches `uvcvideo` and destroys `/dev/videoN` for seconds. It emits this on a throwaway element instance instead. + +Both surfaces and `negotiate()` route through the single `uvc_quirks_filter_caps()` in `quirks.c`, so the modes an operator is **offered** are by construction the modes negotiation will **accept**. + ### Action signal: `set-ptz(pan, tilt, zoom)` → boolean Drives all three PTZ axes in one emission. Each axis is applied only when the device reports it. Returns `TRUE` if at least one supported axis was driven and every attempted set succeeded. @@ -404,4 +418,6 @@ The `.deb` version is derived **purely from git tags** at publish time via the ` - Do NOT read an `Unable to get stream control: Invalid mode` failure as a caps-logic or descriptor problem before checking what mode the device last committed. libuvc rejects the mode when the device's SET_CUR/GET_CUR readback disagrees, and a camera that answers the first probe from its previously committed mode fails EVERY negotiation that asks for a larger mode — deterministically, though it looks intermittent in the field. That is what `QUIRK_DOUBLE_PROBE` is for; the Osmo Pocket 3 row sets it. - Do NOT "fix" the `quirks_ladder_no_quirk_picks_4k60` test because it asserts the buggy 3840x2160@60 outcome. That is deliberate: it pins the untouched max-area-then-max-fps behavior that every camera WITHOUT a quirk row still gets, and it is the control half of the pair whose other half proves the Osmo cap works. - Do NOT special-case a device inside `gst_libuvc_h264_negotiate()` (`if (vid == ... && w == 3840 ...)`). The quirk table exists so device knowledge stays data-driven — one row, no branching in the selection loop. +- Do NOT re-derive the quirk exclusion outside `uvc_quirks_filter_caps()` — not in the engine, not in the UI, not in a second helper here. That split is precisely the shipped defect this function was added to close: the filtered ladder lived only inside `negotiate()`, so cerastream and CeraUI advertised `3840x2160@50` for a camera the element was guaranteed to refuse, and an operator picked it and lost a stream (board `192.168.78.131`, 2026-07-30 — twelve requests, twelve `Unable to negotiate common caps`). `negotiate()`, `deliverable-caps` and `filter-deliverable-caps` must all keep calling the one function. +- Do NOT make `deliverable-caps` return an EMPTY caps when nothing is known. Empty means "this camera has no modes"; a consumer that trusts it will hide every option and strand the operator. `NULL` is the unknown answer, and `filter-deliverable-caps` on an unquirked vid:pid returns its input unchanged for the same reason — the filter may only ever REMOVE modes it has a positive verdict for. - This plugin is **not** in the device image REPOS list by default — don't assume it's always present on device. diff --git a/libuvch264src/src/gstlibuvch264src.c b/libuvch264src/src/gstlibuvch264src.c index eec8dd2..c1925dc 100644 --- a/libuvch264src/src/gstlibuvch264src.c +++ b/libuvch264src/src/gstlibuvch264src.c @@ -34,6 +34,7 @@ enum { PROP_RESET_SETTLE_MAX_MS, PROP_RESET_REARM_FRAMES, PROP_AUTO_PORT_RESET, + PROP_DELIVERABLE_CAPS, PROP_LAST }; @@ -136,6 +137,9 @@ static GstFlowReturn gst_libuvc_h264_src_create(GstPushSrc *src, GstBuffer **buf static void gst_libuvc_h264_src_finalize(GObject *object); static gboolean gst_libuvc_h264_src_set_ptz(GstLibuvcH264Src *self, gint pan, gint tilt, gint zoom); +static GstCaps *gst_libuvc_h264_src_filter_deliverable_caps(GstLibuvcH264Src *self, + GstCaps *advertised, + guint vendor_id, guint product_id); static gboolean gst_libuvc_h264_src_negotiate_clean_payload(GstLibuvcH264Src *self, gint width, gint height, gint fps); static void gst_libuvc_h264_src_apply_max_payload(GstLibuvcH264Src *self, @@ -302,6 +306,31 @@ static void gst_libuvc_h264_src_class_init(GstLibuvcH264SrcClass *klass) { "silent; disable to use normal disconnect handling", TRUE, G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS)); + /* The mode ladder negotiate() actually selects from, i.e. what this element + * will ACCEPT - not what the device advertises. NULL until a device has been + * negotiated. Read it to publish honest options to an operator instead of the + * raw descriptor ladder, which for a quirked camera contains modes + * negotiation is guaranteed to refuse. */ + g_object_class_install_property(gobject_class, PROP_DELIVERABLE_CAPS, + g_param_spec_boxed("deliverable-caps", "Deliverable caps", + "Post-quirk mode ladder negotiation selects from; " + "NULL before a device is negotiated", + GST_TYPE_CAPS, + G_PARAM_READABLE | G_PARAM_STATIC_STRINGS)); + + /* The same filter, without a device. + * + * A consumer enumerating cameras already holds the advertised ladder (from + * v4l2) and the vid:pid, and must NOT open the camera to learn which of those + * modes are real - opening one through libuvc detaches uvcvideo and destroys + * /dev/videoN. This applies uvc_quirks_filter_caps() to a caller-supplied + * ladder, so the enumeration answer and the negotiation answer come from one + * implementation. Pure caps arithmetic: no device I/O. */ + g_signal_new_class_handler("filter-deliverable-caps", G_TYPE_FROM_CLASS(klass), + G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION, + G_CALLBACK(gst_libuvc_h264_src_filter_deliverable_caps), NULL, NULL, NULL, + GST_TYPE_CAPS, 3, GST_TYPE_CAPS, G_TYPE_UINT, G_TYPE_UINT); + /* Action signal driving all three axes in one emission; each axis is applied * only when the device supports it (gated in ptz_control.c). */ g_signal_new_class_handler("set-ptz", G_TYPE_FROM_CLASS(klass), @@ -331,6 +360,7 @@ static void gst_libuvc_h264_src_init(GstLibuvcH264Src *self) { self->uvc_ctx = NULL; self->uvc_dev = NULL; self->uvc_devh = NULL; + self->deliverable_caps = NULL; self->clock = NULL; self->frame_queue = g_async_queue_new(); self->streaming = FALSE; @@ -528,6 +558,37 @@ static void gst_libuvc_h264_src_log_format_inventory(GstLibuvcH264Src *self) { } } +/* The highest discrete rate a mode offers, or -1 when it carries a continuous + * RANGE. The -1 is deliberate and load-bearing: the preference below compares it, + * and fixating "nearest" to -1/1 snaps a range to its LOWEST rate - the behavior + * continuous-frame-interval devices have always had here. */ +static gint gst_libuvc_h264_src_top_rate(const GstStructure *structure) { + const GValue *rates = gst_structure_get_value(structure, "framerate"); + if (rates == NULL || !GST_VALUE_HOLDS_LIST(rates)) { + return -1; + } + + gint top = -1; + for (guint i = 0; i < gst_value_list_get_size(rates); i++) { + const GValue *rate = gst_value_list_get_value(rates, i); + gint num = gst_value_get_fraction_numerator(rate); + gint den = gst_value_get_fraction_denominator(rate); + + if (den > 0 && num / den > top) { + top = num / den; + } + } + return top; +} + +static void gst_libuvc_h264_src_publish_ladder(GstLibuvcH264Src *self, + GstCaps *ladder) { + GST_OBJECT_LOCK(self); + gst_caps_replace(&self->deliverable_caps, ladder); + GST_OBJECT_UNLOCK(self); + gst_caps_unref(ladder); +} + static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) { GstLibuvcH264Src *self = GST_LIBUVC_H264_SRC(basesrc); @@ -553,6 +614,11 @@ static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) { gboolean result = FALSE; gboolean found_codec_format = FALSE; + /* Every mode that survives the quirk filter, accumulated as it is selected + * from and published on "deliverable-caps". This is the list an enumerating + * consumer needs and could not see before. */ + GstCaps *ladder = gst_caps_new_empty(); + // vid:pid quirk seam (A14), resolved BEFORE the selection loop because a quirk // can rule advertised-but-undeliverable modes OUT of the selection, not just // change how the winning mode is probed. A device with no row gets zeroed @@ -589,49 +655,18 @@ static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) { "height", G_TYPE_INT, frame_desc->wHeight, NULL); - gint fps = -1; if (frame_desc->intervals) { GValue framerates = G_VALUE_INIT; g_value_init(&framerates, GST_TYPE_LIST); - guint excluded = 0; for (const uint32_t *interval = frame_desc->intervals; *interval; interval++) { - gint _fps = 1e7 / *interval; - - // A quirked device advertises rates it cannot deliver. Drop - // them here, before they can win the preference below OR reach - // the caps we publish downstream. - if (!uvc_quirk_mode_selectable(&quirk_limits, frame_desc->wWidth, - frame_desc->wHeight, (guint)_fps)) { - excluded++; - continue; - } - - if (_fps > fps) { - fps = _fps; - } - GValue fps = G_VALUE_INIT; g_value_init(&fps, GST_TYPE_FRACTION); - gst_value_set_fraction(&fps, (gint)_fps, 1); + gst_value_set_fraction(&fps, (gint)(1e7 / *interval), 1); gst_value_list_append_value(&framerates, &fps); g_value_unset(&fps); } - if (excluded > 0) { - GST_INFO_OBJECT(self, - "quirk: dropped %u non-deliverable rate(s) at %ux%u", - excluded, frame_desc->wWidth, frame_desc->wHeight); - } - - if (fps < 0) { - // Every rate this descriptor advertises is above the cap, so - // the whole mode is unusable; an empty framerate list would - // otherwise fixate to nothing. - g_value_unset(&framerates); - continue; - } - // gst_structure_set_value() copies the list, so the local GValue // owns a GST_TYPE_LIST that must be released or it leaks per call. gst_structure_set_value(tmp_structure, "framerate", &framerates); @@ -649,25 +684,24 @@ static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) { gint fps_min = 1e7 / frame_desc->dwMaxFrameInterval; gint fps_max = 1e7 / frame_desc->dwMinFrameInterval; - // Same quirk cap as the discrete branch, applied to the top of the - // range instead of to a list. If even the slowest rate is over the - // cap the whole descriptor goes. - guint fps_cap = uvc_quirk_max_fps(&quirk_limits, frame_desc->wWidth, - frame_desc->wHeight); - if ((guint)fps_max > fps_cap) { - fps_max = (gint)fps_cap; - } - if (fps_max < fps_min) { - GST_INFO_OBJECT(self, - "quirk: %ux%u dropped, its whole interval range exceeds the cap", - frame_desc->wWidth, frame_desc->wHeight); - continue; - } - gst_structure_set(tmp_structure, "framerate", GST_TYPE_FRACTION_RANGE, fps_min, 1, fps_max, 1, NULL); } - if (gst_caps_can_intersect(caps, tmp_caps)) { + // A quirked device advertises rates it cannot deliver. The exclusion + // lives in ONE function, which the "deliverable-caps" property and the + // "filter-deliverable-caps" signal publish too, so the modes offered to + // an operator are by construction the modes this loop can accept. + GstCaps *deliverable = uvc_quirks_filter_caps(&quirk_limits, tmp_caps); + if (gst_caps_is_empty(deliverable)) { + gst_caps_unref(deliverable); + continue; + } + gst_caps_append(ladder, gst_caps_copy(deliverable)); + + gint fps = gst_libuvc_h264_src_top_rate( + gst_caps_get_structure(deliverable, 0)); + + if (gst_caps_can_intersect(caps, deliverable)) { if (resolution > (width * height) || (resolution == (width * height) && fps > framerate)) { width = frame_desc->wWidth; @@ -677,7 +711,7 @@ static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) { if (best_caps) { gst_caps_unref(best_caps); } - best_caps = gst_caps_intersect(caps, tmp_caps); + best_caps = gst_caps_intersect(caps, deliverable); GstStructure *s = gst_caps_get_structure(best_caps, 0); gst_structure_fixate_field_nearest_fraction(s, "framerate", fps, 1); @@ -686,6 +720,7 @@ static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) { framerate = fr_num / fr_den; } } + gst_caps_unref(deliverable); } // for frame_desc @@ -760,6 +795,10 @@ static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) { // Single cleanup path: the working caps and the chosen caps are owned locals. // gst_base_src_set_caps() takes its own reference, so best_caps must be freed // here on success too, and both must be freed on every error path. + // The ladder is published on EVERY path, including the failures: a device + // that could not negotiate is exactly when a consumer needs to know which + // modes were on offer. + gst_libuvc_h264_src_publish_ladder(self, ladder); if (caps) gst_caps_unref(caps); if (best_caps) @@ -925,12 +964,37 @@ static void gst_libuvc_h264_src_get_property(GObject *object, guint prop_id, g_value_set_boolean(value, self->auto_port_reset); GST_OBJECT_UNLOCK(self); break; + case PROP_DELIVERABLE_CAPS: + GST_OBJECT_LOCK(self); + gst_value_set_caps(value, self->deliverable_caps); + GST_OBJECT_UNLOCK(self); + break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec); break; } } +/* "filter-deliverable-caps" action handler. Stateless: it reads the quirk table + * for the caller's vid:pid and runs the caller's ladder through the SAME filter + * negotiate() uses. `self` is only the emission target - no device is touched, + * so this is safe to call per enumeration on an element that was never started. */ +static GstCaps *gst_libuvc_h264_src_filter_deliverable_caps(GstLibuvcH264Src *self, + GstCaps *advertised, + guint vendor_id, + guint product_id) { + g_return_val_if_fail(advertised != NULL, NULL); + + uvc_quirk_limits_t limits = {0}; + uvc_quirks_limits((guint16)vendor_id, (guint16)product_id, &limits); + + GstCaps *deliverable = uvc_quirks_filter_caps(&limits, advertised); + GST_DEBUG_OBJECT(self, + "filter-deliverable-caps %04x:%04x: %" GST_PTR_FORMAT + " -> %" GST_PTR_FORMAT, vendor_id, product_id, advertised, deliverable); + return deliverable; +} + /* "set-ptz" action handler: apply pan, tilt and zoom in one call. Each axis is * driven only when the device reports it; returns TRUE only if at least one * supported axis was driven and every attempted set succeeded. */ @@ -1995,6 +2059,8 @@ static void gst_libuvc_h264_src_finalize(GObject *object) { self->index = NULL; } + gst_caps_replace(&self->deliverable_caps, NULL); + // stop() above already unlinked the socket; free the owned path string. if (self->control_socket_path) { g_free(self->control_socket_path); diff --git a/libuvch264src/src/gstlibuvch264src_internal.h b/libuvch264src/src/gstlibuvch264src_internal.h index 6ec4545..010c379 100644 --- a/libuvch264src/src/gstlibuvch264src_internal.h +++ b/libuvch264src/src/gstlibuvch264src_internal.h @@ -28,6 +28,10 @@ struct _GstLibuvcH264Src { /* The framerate negotiate() resolved, kept so the opt-in reconnect path can * re-run uvc_get_stream_ctrl_format_size() with the original geometry. */ gint negotiated_framerate; + /* The post-quirk mode ladder negotiate() last selected from, published + * read-only as "deliverable-caps". Guarded by the GST_OBJECT lock because a + * consumer reads it from a different thread than negotiate() writes it. */ + GstCaps *deliverable_caps; GAsyncQueue *frame_queue; gboolean streaming; gint flushing; /* atomic: set by unlock(), checked by create() to bail out */ diff --git a/libuvch264src/src/quirks.c b/libuvch264src/src/quirks.c index 0c59465..f903519 100644 --- a/libuvch264src/src/quirks.c +++ b/libuvch264src/src/quirks.c @@ -196,3 +196,122 @@ guint32 uvc_quirks_lookup(guint16 vid, guint16 pid) { uvc_quirks_limits(vid, pid, &limits); return limits.flags; } + +/* The integer rate a caps fraction represents, rounded UP. A non-integral rate + * (30000/1001) is judged by the rate it can peak at rather than by a truncation + * that would let it slip under the cap. */ +static guint quirks_fraction_fps(const GValue *fraction) { + gint num = gst_value_get_fraction_numerator(fraction); + gint den = gst_value_get_fraction_denominator(fraction); + + if (num <= 0 || den <= 0) { + return 0; + } + return (guint)(((gint64)num + den - 1) / den); +} + +/* Filter ONE mode's framerate field in place. FALSE means the mode has no + * deliverable rate left and the caller must drop the structure. */ +static gboolean quirks_filter_structure(const uvc_quirk_limits_t *limits, + GstStructure *structure) { + gint width = 0, height = 0; + if (!gst_structure_get_int(structure, "width", &width) + || !gst_structure_get_int(structure, "height", &height)) { + /* No geometry means no pixel rate to judge it by, so it passes through + * untouched rather than being guessed at. */ + return TRUE; + } + + const GValue *rates = gst_structure_get_value(structure, "framerate"); + if (rates == NULL) { + return TRUE; + } + + if (GST_VALUE_HOLDS_FRACTION_RANGE(rates)) { + /* A continuous-frame-interval descriptor advertises a whole RANGE, so + * the cap clamps its top instead of removing entries. */ + guint cap = uvc_quirk_max_fps(limits, (guint)width, (guint)height); + const GValue *min = gst_value_get_fraction_range_min(rates); + const GValue *max = gst_value_get_fraction_range_max(rates); + + if (cap == G_MAXUINT || quirks_fraction_fps(max) <= cap) { + return TRUE; + } + + gint min_num = gst_value_get_fraction_numerator(min); + gint min_den = gst_value_get_fraction_denominator(min); + if (min_den <= 0 || quirks_fraction_fps(min) > cap) { + GST_INFO("quirk: %dx%d dropped, its whole interval range exceeds " + "the cap", width, height); + return FALSE; + } + + gst_structure_set(structure, "framerate", GST_TYPE_FRACTION_RANGE, + min_num, min_den, (gint)cap, 1, NULL); + return TRUE; + } + + if (GST_VALUE_HOLDS_FRACTION(rates)) { + return uvc_quirk_mode_selectable(limits, (guint)width, (guint)height, + quirks_fraction_fps(rates)); + } + + if (!GST_VALUE_HOLDS_LIST(rates)) { + return TRUE; + } + + GValue kept = G_VALUE_INIT; + g_value_init(&kept, GST_TYPE_LIST); + + guint excluded = 0; + for (guint i = 0; i < gst_value_list_get_size(rates); i++) { + const GValue *rate = gst_value_list_get_value(rates, i); + + if (!uvc_quirk_mode_selectable(limits, (guint)width, (guint)height, + quirks_fraction_fps(rate))) { + excluded++; + continue; + } + gst_value_list_append_value(&kept, rate); + } + + if (excluded > 0) { + GST_INFO("quirk: dropped %u non-deliverable rate(s) at %dx%d", + excluded, width, height); + } + + if (gst_value_list_get_size(&kept) == 0) { + /* Every advertised rate is above the cap, so the whole mode is + * unusable; an empty framerate list would otherwise fixate to nothing. */ + g_value_unset(&kept); + return FALSE; + } + + gst_structure_set_value(structure, "framerate", &kept); + g_value_unset(&kept); + return TRUE; +} + +GstCaps *uvc_quirks_filter_caps(const uvc_quirk_limits_t *limits, + const GstCaps *advertised) { + g_return_val_if_fail(advertised != NULL, NULL); + + GstCaps *deliverable = gst_caps_copy(advertised); + + if (limits == NULL || limits->max_pixel_rate == 0) { + /* No cap is armed, so every advertised mode is deliverable. Every camera + * without a quirk row must keep getting its ladder back unchanged. */ + return deliverable; + } + + guint i = 0; + while (i < gst_caps_get_size(deliverable)) { + if (quirks_filter_structure(limits, + gst_caps_get_structure(deliverable, i))) { + i++; + } else { + gst_caps_remove_structure(deliverable, i); + } + } + return deliverable; +} diff --git a/libuvch264src/src/quirks.h b/libuvch264src/src/quirks.h index dc9b534..3fd4ad2 100644 --- a/libuvch264src/src/quirks.h +++ b/libuvch264src/src/quirks.h @@ -25,6 +25,7 @@ */ #include +#include G_BEGIN_DECLS @@ -95,6 +96,21 @@ gboolean uvc_quirk_mode_selectable(const uvc_quirk_limits_t *limits, guint uvc_quirk_max_fps(const uvc_quirk_limits_t *limits, guint width, guint height); +/* The deliverable subset of an ADVERTISED caps set: every framerate `limits` + * rules out is removed, a continuous framerate RANGE is clamped to the ceiling, + * and a mode left with no usable rate is dropped entirely. Returns a new GstCaps + * (transfer full); limits that arm no cap yield an unchanged copy. + * + * This is the ONE place the exclusion is implemented. negotiate() runs each + * advertised descriptor through it before selecting a mode, and the element's + * "deliverable-caps" property and "filter-deliverable-caps" action signal + * publish the output of this same function - so the modes an operator is OFFERED + * and the modes negotiation will ACCEPT cannot drift apart. Re-deriving the + * exclusion anywhere else (in the engine, in the UI) would recreate exactly the + * split this function exists to close. */ +GstCaps *uvc_quirks_filter_caps(const uvc_quirk_limits_t *limits, + const GstCaps *advertised); + #ifdef LIBUVCH264SRC_TESTING /* Test-only seam (A14): override the table the production lookups consult so a * static-registration test can key a quirk against the mock device's vid:pid diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8570dca..df4df00 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -938,6 +938,10 @@ set(_quirks_cases "quirks_ladder_no_quirk_picks_4k60:test_quirks_ladder_without_quirk_picks_phantom_4k60" "quirks_ladder_osmo_avoids_phantom:test_quirks_ladder_with_osmo_quirk_avoids_phantom" "quirks_osmo_row_double_probes:test_quirks_osmo_row_double_probes_and_still_caps" + "quirks_deliverable_caps_excludes_phantom:test_quirks_deliverable_caps_excludes_the_phantom_rates" + "quirks_deliverable_caps_unquirked_intact:test_quirks_deliverable_caps_unquirked_keeps_every_rate" + "quirks_filter_signal_agrees:test_quirks_filter_signal_agrees_with_the_open_device_ladder" + "quirks_filter_signal_unquirked_noop:test_quirks_filter_signal_leaves_an_unquirked_device_untouched" ) # The double-probe, empty-table and two ladder cases enter PLAYING -> start(), # which may spawn the control thread, so keep them off the shared control-socket @@ -1741,6 +1745,7 @@ set(_compat_cases "compat_caps_contract:test_compat_caps_contract" "compat_transfer_buffers_property:test_compat_transfer_buffers_property" "compat_auto_port_reset_property:test_compat_auto_port_reset_property" + "compat_deliverable_caps_api:test_compat_deliverable_caps_api" "compat_pipeline_parse_h264:test_compat_pipeline_parse_h264" "compat_pipeline_parse_h265:test_compat_pipeline_parse_h265" ) diff --git a/tests/test_compat.c b/tests/test_compat.c index cae29aa..2d2d161 100644 --- a/tests/test_compat.c +++ b/tests/test_compat.c @@ -379,6 +379,57 @@ GST_START_TEST (test_compat_auto_port_reset_property) GST_END_TEST; +/* The capability-publication contract cerastream binds to AT RUNTIME, by name, + * against the installed plugin. A rename or signature change here does not break + * any build in this repo - it breaks device enumeration in another one, silently + * - so the names, types and arity are pinned on the real factory-made element. */ +GST_START_TEST (test_compat_deliverable_caps_api) +{ + GstElement *element = gst_element_factory_make (ELEMENT_NAME, NULL); + fail_unless (element != NULL, "could not instantiate '%s'", ELEMENT_NAME); + + GParamSpec *pspec = g_object_class_find_property ( + G_OBJECT_GET_CLASS (element), "deliverable-caps"); + fail_unless (pspec != NULL, "expected 'deliverable-caps' property is missing"); + fail_unless (pspec->value_type == GST_TYPE_CAPS, + "'deliverable-caps' should be GstCaps"); + fail_if (pspec->flags & G_PARAM_WRITABLE, + "'deliverable-caps' must stay read-only: it reports what the device can " + "deliver, it does not configure it"); + + /* Nothing negotiated yet, so there is no ladder to report. NULL is the honest + * answer and is what a consumer must treat as "unknown", never as "no modes". */ + GstCaps *caps = NULL; + g_object_get (element, "deliverable-caps", &caps, NULL); + fail_unless (caps == NULL, + "'deliverable-caps' must be NULL before a device is negotiated"); + + guint signal_id = g_signal_lookup ("filter-deliverable-caps", + G_OBJECT_TYPE (element)); + fail_unless (signal_id != 0, + "expected 'filter-deliverable-caps' action signal is missing"); + + GSignalQuery query; + g_signal_query (signal_id, &query); + fail_unless (query.return_type == GST_TYPE_CAPS, + "'filter-deliverable-caps' must return GstCaps"); + fail_unless (query.n_params == 3, + "'filter-deliverable-caps' takes (caps, vendor-id, product-id); got %u " + "parameter(s)", query.n_params); + fail_unless (query.param_types[0] == GST_TYPE_CAPS, + "parameter 1 must be the advertised GstCaps"); + fail_unless (query.param_types[1] == G_TYPE_UINT + && query.param_types[2] == G_TYPE_UINT, + "parameters 2 and 3 must be the USB vendor and product ids"); + fail_unless (query.signal_flags & G_SIGNAL_ACTION, + "'filter-deliverable-caps' must be an ACTION signal so a consumer can " + "emit it directly"); + + gst_object_unref (element); +} + +GST_END_TEST; + /* --------------------------------------------------------------------------- * GROUP 2 test - caps contract * ------------------------------------------------------------------------- */ @@ -513,6 +564,7 @@ compat_suite (void) tcase_add_test (tc, test_compat_caps_contract); tcase_add_test (tc, test_compat_transfer_buffers_property); tcase_add_test (tc, test_compat_auto_port_reset_property); + tcase_add_test (tc, test_compat_deliverable_caps_api); tcase_add_test (tc, test_compat_pipeline_parse_h264); tcase_add_test (tc, test_compat_pipeline_parse_h265); diff --git a/tests/test_quirks.c b/tests/test_quirks.c index dea4384..834f5a4 100644 --- a/tests/test_quirks.c +++ b/tests/test_quirks.c @@ -510,6 +510,271 @@ GST_START_TEST (test_quirks_ladder_with_osmo_quirk_avoids_phantom) GST_END_TEST; +/* ------------------------------------------------------------------------- * + * The capability ladder the element PUBLISHES, which is what an operator is + * offered. Negotiation has always excluded the phantom rates correctly; the + * defect these cases lock is that the exclusion was invisible outside + * negotiate(), so cerastream/CeraUI advertised 4K@60/50/48 for a camera the + * element is guaranteed to refuse them on. + * ------------------------------------------------------------------------- */ + +/* Does `caps` offer exactly `fps`/1 at this geometry? Handles all three shapes a + * frame descriptor can produce: a discrete list, a single fraction, and the + * continuous RANGE a device with no interval list yields. */ +static gboolean +caps_offers_rate (GstCaps * caps, gint w, gint h, gint fps) +{ + if (caps == NULL) + return FALSE; + + for (guint i = 0; i < gst_caps_get_size (caps); i++) { + GstStructure *s = gst_caps_get_structure (caps, i); + gint sw = 0, sh = 0; + + if (!gst_structure_get_int (s, "width", &sw) + || !gst_structure_get_int (s, "height", &sh)) + continue; + if (sw != w || sh != h) + continue; + + const GValue *rates = gst_structure_get_value (s, "framerate"); + if (rates == NULL) + continue; + + if (GST_VALUE_HOLDS_LIST (rates)) { + for (guint j = 0; j < gst_value_list_get_size (rates); j++) { + const GValue *r = gst_value_list_get_value (rates, j); + if (gst_value_get_fraction_numerator (r) == fps + && gst_value_get_fraction_denominator (r) == 1) + return TRUE; + } + } else if (GST_VALUE_HOLDS_FRACTION (rates)) { + if (gst_value_get_fraction_numerator (rates) == fps + && gst_value_get_fraction_denominator (rates) == 1) + return TRUE; + } else if (GST_VALUE_HOLDS_FRACTION_RANGE (rates)) { + const GValue *lo = gst_value_get_fraction_range_min (rates); + const GValue *hi = gst_value_get_fraction_range_max (rates); + gint lo_n = gst_value_get_fraction_numerator (lo); + gint lo_d = gst_value_get_fraction_denominator (lo); + gint hi_n = gst_value_get_fraction_numerator (hi); + gint hi_d = gst_value_get_fraction_denominator (hi); + if (lo_d > 0 && hi_d > 0 && fps * lo_d >= lo_n && fps * hi_d <= hi_n) + return TRUE; + } + } + return FALSE; +} + +/* Play, read the ladder the element published for the OPEN device, and tear the + * pipeline down before returning - like play_and_get_negotiated() above, so a + * failing assertion never leaves the mock feeder thread running. */ +static GstCaps * +play_take_deliverable_and_stop (GstElement * pipeline) +{ + GstCaps *deliverable = NULL; + + if (play_until_buffer (pipeline)) { + GstElement *src = gst_bin_get_by_name (GST_BIN (pipeline), "src"); + g_object_get (src, "deliverable-caps", &deliverable, NULL); + gst_object_unref (src); + } + + gst_element_set_state (pipeline, GST_STATE_NULL); + gst_object_unref (pipeline); + return deliverable; +} + +/* The Osmo's REAL advertised H.264 ladder as an ENUMERATING consumer sees it: + * cerastream reads it from `GstDevice::caps()` (a v4l2 enumeration), never by + * opening the camera through libuvc. Transcribed from the same byte-verified + * 2ca3:0023 descriptor dump MOCK_UVC_FORMAT_OSMO_LADDER replays. */ +#define OSMO_ADVERTISED_LADDER \ + "video/x-h264, width=(int)1280, height=(int)720, " \ + "framerate=(fraction){ 30/1, 25/1 };" \ + "video/x-h264, width=(int)1920, height=(int)1080, " \ + "framerate=(fraction){ 30/1, 25/1, 24/1 };" \ + "video/x-h264, width=(int)720, height=(int)1280, " \ + "framerate=(fraction){ 30/1, 25/1 };" \ + "video/x-h264, width=(int)1080, height=(int)1920, " \ + "framerate=(fraction){ 30/1, 25/1, 24/1 };" \ + "video/x-h264, width=(int)3840, height=(int)2160, " \ + "framerate=(fraction){ 60/1, 50/1, 48/1, 30/1, 25/1, 24/1 }" + +/* Every (w, h, fps) the Osmo advertises, so the agreement case below can sweep + * the WHOLE ladder rather than spot-check the interesting rows. */ +static const struct +{ + gint w, h, fps; +} osmo_advertised_modes[] = { + { 1280, 720, 30 }, { 1280, 720, 25 }, + { 1920, 1080, 30 }, { 1920, 1080, 25 }, { 1920, 1080, 24 }, + { 720, 1280, 30 }, { 720, 1280, 25 }, + { 1080, 1920, 30 }, { 1080, 1920, 25 }, { 1080, 1920, 24 }, + { 3840, 2160, 60 }, { 3840, 2160, 50 }, { 3840, 2160, 48 }, + { 3840, 2160, 30 }, { 3840, 2160, 25 }, { 3840, 2160, 24 }, +}; + +/* THE OPERATOR-FACING DEFECT, locked. `negotiate()` already refused 4K@50 twelve + * times out of twelve on the board (2026-07-30), but the ladder it refused from + * was never published, so the encoder dialog kept offering the rate. The element + * must expose the post-quirk ladder it actually selects from. */ +GST_START_TEST (test_quirks_deliverable_caps_excludes_the_phantom_rates) +{ + mock_uvc_set_format_mode (MOCK_UVC_FORMAT_OSMO_LADDER); + mock_uvc_set_device_descriptor (0, OSMO_VID, OSMO_PID, NULL, 0, 0); + + GstElement *pipeline = build_pipeline (); + GstCaps *deliverable = play_take_deliverable_and_stop (pipeline); + + fail_unless (deliverable != NULL, + "an open device must publish the ladder negotiate() selects from"); + + /* The three phantom rates: advertised by the descriptor, provably deliver + * nothing, and today reach the operator as selectable options. */ + fail_if (caps_offers_rate (deliverable, 3840, 2160, 60), + "4K@60 is undeliverable and must not be published: %" GST_PTR_FORMAT, + deliverable); + fail_if (caps_offers_rate (deliverable, 3840, 2160, 50), + "4K@50 is the rate the operator actually picked and lost a stream to"); + fail_if (caps_offers_rate (deliverable, 3840, 2160, 48), + "4K@48 must be excluded with the rest of the set"); + + /* Nothing the cap permits may be lost: over-filtering would silently take + * working modes away from the operator, which is its own defect. */ + fail_unless (caps_offers_rate (deliverable, 3840, 2160, 30), + "4K@30 is the board-proven ceiling and MUST stay published"); + fail_unless (caps_offers_rate (deliverable, 3840, 2160, 25), "4K@25"); + fail_unless (caps_offers_rate (deliverable, 3840, 2160, 24), "4K@24"); + fail_unless (caps_offers_rate (deliverable, 1920, 1080, 30), "1080p30"); + fail_unless (caps_offers_rate (deliverable, 1920, 1080, 24), "1080p24"); + fail_unless (caps_offers_rate (deliverable, 1080, 1920, 30), "portrait 1080p30"); + fail_unless (caps_offers_rate (deliverable, 1280, 720, 30), "720p30"); + fail_unless (caps_offers_rate (deliverable, 720, 1280, 25), "portrait 720p25"); + + gst_caps_unref (deliverable); +} + +GST_END_TEST; + +/* The control half: a camera with no quirk row loses NOTHING. This is what keeps + * the new publication surface from becoming a second place device knowledge can + * leak into. */ +GST_START_TEST (test_quirks_deliverable_caps_unquirked_keeps_every_rate) +{ + mock_uvc_set_format_mode (MOCK_UVC_FORMAT_OSMO_LADDER); + mock_uvc_set_device_descriptor (0, QUIRK_TEST_VID, QUIRK_TEST_PID, NULL, 0, 0); + + GstElement *pipeline = build_pipeline (); + GstCaps *deliverable = play_take_deliverable_and_stop (pipeline); + + fail_unless (deliverable != NULL, "an open device must publish its ladder"); + for (gsize i = 0; i < G_N_ELEMENTS (osmo_advertised_modes); i++) { + fail_unless (caps_offers_rate (deliverable, osmo_advertised_modes[i].w, + osmo_advertised_modes[i].h, osmo_advertised_modes[i].fps), + "an unquirked device must keep every advertised rate; lost %dx%d@%d", + osmo_advertised_modes[i].w, osmo_advertised_modes[i].h, + osmo_advertised_modes[i].fps); + } + + gst_caps_unref (deliverable); +} + +GST_END_TEST; + +/* THE ANTI-DRIFT ASSERTION, and the reason the enumeration surface is a signal + * rather than a second copy of the rule. + * + * cerastream enumerates devices with NO camera open - opening one through libuvc + * detaches uvcvideo and destroys /dev/videoN, which is a defect in its own right + * - so it cannot read the property above. It instead hands the element the + * advertised ladder it already has from `GstDevice::caps()` plus the device's + * vid:pid. This case proves the two surfaces answer IDENTICALLY across the whole + * ladder: if a future change filtered one path and not the other, the operator's + * offered modes and negotiation's accepted modes would diverge again, which is + * exactly the bug. */ +GST_START_TEST (test_quirks_filter_signal_agrees_with_the_open_device_ladder) +{ + mock_uvc_set_format_mode (MOCK_UVC_FORMAT_OSMO_LADDER); + mock_uvc_set_device_descriptor (0, OSMO_VID, OSMO_PID, NULL, 0, 0); + + GstCaps *advertised = gst_caps_from_string (OSMO_ADVERTISED_LADDER); + fail_unless (advertised != NULL, "the advertised ladder must parse"); + + GstElement *pipeline = build_pipeline (); + GstCaps *live = NULL; + GstCaps *filtered = NULL; + + if (play_until_buffer (pipeline)) { + GstElement *src = gst_bin_get_by_name (GST_BIN (pipeline), "src"); + g_object_get (src, "deliverable-caps", &live, NULL); + g_signal_emit_by_name (src, "filter-deliverable-caps", advertised, + (guint) OSMO_VID, (guint) OSMO_PID, &filtered); + gst_object_unref (src); + } + gst_element_set_state (pipeline, GST_STATE_NULL); + gst_object_unref (pipeline); + + fail_unless (live != NULL, "an open device must publish its ladder"); + fail_unless (filtered != NULL, + "the stateless filter must answer without a device open"); + + for (gsize i = 0; i < G_N_ELEMENTS (osmo_advertised_modes); i++) { + gint w = osmo_advertised_modes[i].w; + gint h = osmo_advertised_modes[i].h; + gint fps = osmo_advertised_modes[i].fps; + fail_unless (caps_offers_rate (filtered, w, h, fps) + == caps_offers_rate (live, w, h, fps), + "the enumeration filter and the open-device ladder disagree at " + "%dx%d@%d - offered and accepted modes would drift apart again", + w, h, fps); + } + + /* Belt and braces: the disputed rate is gone from the surface cerastream + * actually reads, not merely equal to whatever the other surface said. */ + fail_if (caps_offers_rate (filtered, 3840, 2160, 50), + "4K@50 must not survive the enumeration filter: %" GST_PTR_FORMAT, + filtered); + fail_unless (caps_offers_rate (filtered, 3840, 2160, 30), + "4K@30 must survive the enumeration filter"); + + gst_caps_unref (filtered); + gst_caps_unref (advertised); + gst_caps_unref (live); +} + +GST_END_TEST; + +/* A vid:pid with no row must come back byte-identical, so enumerating any OTHER + * camera through this filter is a no-op. No device is opened at all here: the + * filter is pure caps arithmetic, which is what makes it safe to run per + * enumeration. */ +GST_START_TEST (test_quirks_filter_signal_leaves_an_unquirked_device_untouched) +{ + GstElement *src = gst_element_factory_make ("libuvch264src", "filter-only"); + fail_unless (src != NULL, "failed to create libuvch264src"); + + GstCaps *advertised = gst_caps_from_string (OSMO_ADVERTISED_LADDER); + GstCaps *filtered = NULL; + + g_signal_emit_by_name (src, "filter-deliverable-caps", advertised, + (guint) QUIRK_TEST_VID, (guint) QUIRK_TEST_PID, &filtered); + + fail_unless (filtered != NULL, "the filter must always answer"); + fail_unless (gst_caps_is_equal (filtered, advertised), + "an unquirked vid:pid must pass the ladder through unchanged; got " + "%" GST_PTR_FORMAT " for %" GST_PTR_FORMAT, filtered, advertised); + fail_unless (mock_uvc_open_count () == 0, + "the enumeration filter must not open the camera; got %d open(s)", + mock_uvc_open_count ()); + + gst_caps_unref (filtered); + gst_caps_unref (advertised); + gst_object_unref (src); +} + +GST_END_TEST; + static Suite * quirks_suite (void) { @@ -531,6 +796,10 @@ quirks_suite (void) tcase_add_test (tc, test_quirks_ladder_without_quirk_picks_phantom_4k60); tcase_add_test (tc, test_quirks_ladder_with_osmo_quirk_avoids_phantom); tcase_add_test (tc, test_quirks_osmo_row_double_probes_and_still_caps); + tcase_add_test (tc, test_quirks_deliverable_caps_excludes_the_phantom_rates); + tcase_add_test (tc, test_quirks_deliverable_caps_unquirked_keeps_every_rate); + tcase_add_test (tc, test_quirks_filter_signal_agrees_with_the_open_device_ladder); + tcase_add_test (tc, test_quirks_filter_signal_leaves_an_unquirked_device_untouched); return s; }