diff --git a/.github/build-config.json b/.github/build-config.json index 09a1fa8c8f5d..b9a235428635 100644 --- a/.github/build-config.json +++ b/.github/build-config.json @@ -7,37 +7,66 @@ "android_min_sdk": "28", "android_platform": "35", "cmake_minimum_version": "3.25", - "gstreamer_android_version": "1.28.1", - "gstreamer_ios_version": "1.28.1", - "gstreamer_macos_version": "1.28.1", - "gstreamer_minimum_version": "1.20.0", - "gstreamer_default_version": "1.28.1", - "gstreamer_windows_version": "1.28.1", + "gstreamer": { + "version": { + "default": "1.28.3", + "minimum": "1.20.0", + "android": "1.28.3", + "ios": "1.28.3", + "macos": "1.28.3", + "windows": "1.28.3" + }, + "plugins": { + "common": [ + "app", + "coreelements", + "isomp4", + "libav", + "matroska", + "mpegtsdemux", + "opengl", + "openh264", + "playback", + "rtp", + "rtpmanager", + "rtsp", + "sdpelem", + "tcp", + "typefindfunctions", + "udp", + "videoparsersbad", + "vpx", + "videoconvertscale", + "videoconvert", + "videoscale" + ], + "android": ["androidmedia", "dav1d"], + "apple": ["applemedia", "dav1d"], + "windows": ["d3d", "d3d11", "d3d12", "dav1d", "nvcodec"], + "linux": ["nvcodec", "qsv", "va", "vulkan"] + }, + "checksums": { + "1.28.3": { + "android": "4c8932344f43735df5fcdb709f72f11af38c967a88a89e3efc8d31cbb2d7b98f", + "windows_msvc_x64": "f01b4fb30b3aefdcdf4251d4a7464fb68197d15a763a2a94b667b93f88cc6216", + "windows_msvc_arm64": "bf82db1f8320358212b73483be9a671a88f3768d471d29b5819bc9b6d64f47ab", + "macos": "d2940ed843baae348f51e6c014ef4b39502e8dc419e99aac441f5b8d96d02456", + "macos_devel": "12c01972acb5668416248606bac6148c8d6f0395969650d05a18be9a08de0260", + "ios": "e815dd14338eb01702a1d9891dd379002daf07613412f080abb0715e07bedb1b" + } + }, + "ca_bundle_sha256": "86a1f3366afac7c6f8ae9f3c779ac221129328c43f0ab2b8817eb2f362a5025c" + }, "ios_deployment_target": "14.0", "java_version": "17", "macos_deployment_target": "13.0", "ndk_full_version": "27.2.12479018", - "platform_workflows": "Linux,Windows,MacOS,Android", "ndk_version": "r27c", + "platform_workflows": "Linux,Windows,MacOS,Android", "qt_minimum_version": "6.10.0", "qt_modules": "qtgraphs qtlocation qtpositioning qtspeech qtmultimedia qtserialport qtimageformats qtshadertools qtconnectivity qtquick3d qtsensors qtscxml qtwebsockets qthttpserver", "qt_version": "6.10.3", "vulkan_sdk_version": "1.4.304.1", "xcode_ios_version": "latest-stable", - "xcode_version": "16.x", - "gstreamer_checksums": { - "1.28.1": { - "android": "7b3f7cfd289aa1ac237899220b3d8fbfa722337c23175c047e06ec881c505481", - "windows_msvc_x64": "2ec50356d2d0937a9ead0f99d322f81d8413b9514c9d58ed41ca58fbcf25bfde", - "windows_msvc_arm64": "0a1938b7a8568ee5695c4c1755743cacc4a1643538cacdfc5be3c82426c0e193", - "macos": "02803f73435daabe8fb12b79c38c6775d0efb83af001474558ba25c4f874d305", - "macos_devel": "df167b41559afbcd743276c6b068cba2ada8f5b69eb68095415a7a5a7515e52c", - "ios": "3255cd01f8c4d92322be0c5825a192b998fef05989a161dcae3cef22c517ae71", - "good_plugins": "738e26aee41b7a62050e40b81adc017a110a7f32d1ec49fa6a0300846c44368d" - }, - "1.22.12": { - "android": "be92cf477d140c270b480bd8ba0e26b1e01c8db042c46b9e234d87352112e485", - "windows_msvc_x64": "e5cbc6fb9f40fc2850806163df4b9d92012f967c842dc000a2b254cbcd7901d6" - } - } + "xcode_version": "16.x" } diff --git a/.github/build-config.schema.json b/.github/build-config.schema.json index 3510ef469a3f..b246fa67dd97 100644 --- a/.github/build-config.schema.json +++ b/.github/build-config.schema.json @@ -13,8 +13,7 @@ "android_min_sdk", "android_build_tools", "java_version", - "gstreamer_default_version", - "gstreamer_minimum_version", + "gstreamer", "platform_workflows" ], "properties": { @@ -76,39 +75,60 @@ "pattern": "^[0-9]+$", "description": "Java version for Android builds" }, - "gstreamer_default_version": { - "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" - }, - "gstreamer_minimum_version": { - "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" - }, - "gstreamer_android_version": { - "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" - }, - "gstreamer_ios_version": { - "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" - }, - "gstreamer_macos_version": { - "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" - }, - "gstreamer_windows_version": { - "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" - }, - "gstreamer_checksums": { + "gstreamer": { "type": "object", - "description": "Per-version checksums for GStreamer SDK downloads", - "patternProperties": { - "^[0-9]+\\.[0-9]+\\.[0-9]+$": { + "required": ["version"], + "additionalProperties": false, + "properties": { + "version": { "type": "object", - "patternProperties": { - ".*": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + "required": ["default", "minimum"], + "additionalProperties": false, + "properties": { + "default": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "minimum": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "android": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "ios": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "macos": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "windows": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } + } + }, + "plugins": { + "type": "object", + "description": "GStreamer plugin allow-list. 'common' applies to every platform; per-platform keys are appended.", + "required": ["common"], + "additionalProperties": false, + "properties": { + "common": { "type": "array", "items": { "type": "string", "pattern": "^[a-z0-9_]+$" } }, + "android": { "type": "array", "items": { "type": "string", "pattern": "^[a-z0-9_]+$" } }, + "apple": { "type": "array", "items": { "type": "string", "pattern": "^[a-z0-9_]+$" } }, + "windows": { "type": "array", "items": { "type": "string", "pattern": "^[a-z0-9_]+$" } }, + "linux": { "type": "array", "items": { "type": "string", "pattern": "^[a-z0-9_]+$" } } } + }, + "checksums": { + "type": "object", + "description": "Per-version SHA256 checksums for SDK downloads", + "patternProperties": { + "^[0-9]+\\.[0-9]+\\.[0-9]+$": { + "type": "object", + "properties": { + "android": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "ios": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "macos": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "macos_devel": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "windows_msvc_x64": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "windows_msvc_arm64": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "ca_bundle_sha256": { + "type": "string", + "description": "SHA256 of the curl.se Mozilla CA bundle baked into the iOS app; refresh on intentional bundle bumps (https://curl.se/ca/cacert.pem.sha256)", + "pattern": "^[a-f0-9]{64}$" } } }, diff --git a/.github/scripts/mirror_gstreamer.py b/.github/scripts/mirror_gstreamer.py new file mode 100644 index 000000000000..674d1bd0e268 --- /dev/null +++ b/.github/scripts/mirror_gstreamer.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Mirror official upstream GStreamer release artifacts to the QGC S3 bucket. + +Downloads the prebuilt SDK packages published at gstreamer.freedesktop.org for a +given version, verifies each against its upstream ``.sha256sum`` sidecar, then +uploads them to ``s3:///dependencies/gstreamer//`` — +the layout the build's S3 fallback mirror reads (see +``cmake/GStreamer/Download.cmake::gstreamer_get_s3_mirror_url``). + +The artifact set per platform mirrors ``gstreamer_get_package_url`` exactly so the +mirror and the primary download can't drift on filename. +""" + +from __future__ import annotations + +import argparse +import hashlib +import sys +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +from ci_bootstrap import ensure_tools_dir + +ensure_tools_dir(__file__) + +from common.gh_actions import write_step_summary +from common.proc import run_captured + +PKG_BASE = "https://gstreamer.freedesktop.org/data/pkg" +ALLOWED_BUCKETS = frozenset({"qgroundcontrol"}) +PLATFORMS = ("android", "ios", "macos", "windows") +WINDOWS_MIN_VERSION = (1, 28, 0) + + +@dataclass(frozen=True) +class Artifact: + """One mirrored file: its upstream URL and target S3 directory.""" + + url: str + s3_dir: str + + @property + def filename(self) -> str: + return self.url.rsplit("/", 1)[-1] + + def s3_key(self) -> str: + return f"dependencies/gstreamer/{self.s3_dir}/{self.filename}" + + +def _version_tuple(version: str) -> tuple[int, ...]: + return tuple(int(part) for part in version.split(".") if part.isdigit()) + + +def artifacts_for(platform: str, version: str) -> list[Artifact]: + """Return the upstream artifact(s) to mirror for *platform* at *version*.""" + if platform == "android": + return [Artifact(f"{PKG_BASE}/android/{version}/gstreamer-1.0-android-universal-{version}.tar.xz", "android")] + if platform == "ios": + return [Artifact(f"{PKG_BASE}/ios/{version}/gstreamer-1.0-devel-{version}-ios-universal.pkg", "ios")] + if platform == "macos": + return [ + Artifact(f"{PKG_BASE}/macos/{version}/gstreamer-1.0-{version}-universal.pkg", "macos"), + Artifact(f"{PKG_BASE}/macos/{version}/gstreamer-1.0-devel-{version}-universal.pkg", "macos"), + ] + if platform == "windows": + if _version_tuple(version) < WINDOWS_MIN_VERSION: + raise ValueError(f"Windows GStreamer SDK requires version >= 1.28.0 (got {version!r})") + return [ + Artifact(f"{PKG_BASE}/windows/{version}/msvc/gstreamer-1.0-msvc-x86_64-{version}.exe", "windows"), + Artifact(f"{PKG_BASE}/windows/{version}/msvc/gstreamer-1.0-msvc-arm64-{version}.exe", "windows"), + ] + raise ValueError(f"Unknown platform: {platform!r}") + + +def resolve_platforms(value: str) -> list[str]: + """Expand 'all' or a comma list into validated platform names.""" + if not value or value == "all": + return list(PLATFORMS) + requested = [p.strip() for p in value.split(",") if p.strip()] + unknown = [p for p in requested if p not in PLATFORMS] + if unknown: + raise ValueError(f"Unknown platform(s): {', '.join(unknown)}; valid: {', '.join(PLATFORMS)}") + return requested + + +def _download(url: str, dest: Path) -> None: + with urllib.request.urlopen(url, timeout=120) as resp, dest.open("wb") as fh: + while chunk := resp.read(1 << 20): + fh.write(chunk) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as fh: + while chunk := fh.read(1 << 20): + digest.update(chunk) + return digest.hexdigest() + + +def _fetch_expected_sha(url: str) -> str: + with urllib.request.urlopen(f"{url}.sha256sum", timeout=60) as resp: + return resp.read().decode().split()[0].strip().lower() + + +def _s3_object_exists(bucket: str, key: str) -> bool: + result = run_captured(["aws", "s3api", "head-object", "--bucket", bucket, "--key", key]) + return result.returncode == 0 + + +def mirror_artifact(artifact: Artifact, *, bucket: str, work_dir: Path, dry_run: bool, force: bool) -> str: + """Download, verify, and upload one artifact. Returns a status word for the summary.""" + key = artifact.s3_key() + if not force and not dry_run and _s3_object_exists(bucket, key): + print(f"skip (exists): {key}") + return "skipped" + + local = work_dir / artifact.filename + print(f"download: {artifact.url}") + _download(artifact.url, local) + + expected = _fetch_expected_sha(artifact.url) + actual = _sha256(local) + if actual != expected: + raise RuntimeError(f"checksum mismatch for {artifact.filename}: expected {expected}, got {actual}") + print(f"verified sha256: {actual}") + + if dry_run: + print(f"dry-run: would upload -> s3://{bucket}/{key}") + return "dry-run" + + run_captured( + ["aws", "s3", "cp", str(local), f"s3://{bucket}/{key}", "--acl", "public-read"], + check=True, + ) + print(f"uploaded: s3://{bucket}/{key}") + local.unlink(missing_ok=True) + return "uploaded" + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True, help="GStreamer version to mirror (e.g. 1.28.3)") + parser.add_argument("--platforms", default="all", help="'all' or comma list: android,ios,macos,windows") + parser.add_argument("--bucket", default="qgroundcontrol", help="Target S3 bucket") + parser.add_argument("--work-dir", default=".", help="Scratch directory for downloads") + parser.add_argument("--dry-run", action="store_true", help="Download and verify but do not upload") + parser.add_argument("--force", action="store_true", help="Re-upload even if the object already exists") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + if args.bucket not in ALLOWED_BUCKETS: + print(f"::error::Bucket {args.bucket!r} not in allowlist: {sorted(ALLOWED_BUCKETS)}", file=sys.stderr) + return 1 + try: + platforms = resolve_platforms(args.platforms) + artifacts = [a for p in platforms for a in artifacts_for(p, args.version)] + except ValueError as exc: + print(f"::error::{exc}", file=sys.stderr) + return 1 + + work_dir = Path(args.work_dir) + work_dir.mkdir(parents=True, exist_ok=True) + + rows: list[str] = [] + for artifact in artifacts: + try: + status = mirror_artifact( + artifact, bucket=args.bucket, work_dir=work_dir, dry_run=args.dry_run, force=args.force + ) + except (OSError, RuntimeError) as exc: + print(f"::error::Failed to mirror {artifact.filename}: {exc}", file=sys.stderr) + return 1 + rows.append(f"| `{artifact.s3_dir}` | `{artifact.filename}` | {status} |") + + write_step_summary( + f"### GStreamer {args.version} mirror -> `s3://{args.bucket}/dependencies/gstreamer/`\n\n" + "| Platform | File | Status |\n| --- | --- | --- |\n" + "\n".join(rows) + "\n" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/tests/test_mirror_gstreamer.py b/.github/scripts/tests/test_mirror_gstreamer.py new file mode 100644 index 000000000000..ce1db07ff4bf --- /dev/null +++ b/.github/scripts/tests/test_mirror_gstreamer.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Tests for mirror_gstreamer.py.""" + +from __future__ import annotations + +import pytest +from mirror_gstreamer import PLATFORMS, Artifact, artifacts_for, resolve_platforms + + +def test_android_single_artifact() -> None: + arts = artifacts_for("android", "1.28.3") + assert len(arts) == 1 + assert arts[0].filename == "gstreamer-1.0-android-universal-1.28.3.tar.xz" + assert arts[0].s3_key() == "dependencies/gstreamer/android/gstreamer-1.0-android-universal-1.28.3.tar.xz" + + +def test_macos_has_runtime_and_devel() -> None: + names = sorted(a.filename for a in artifacts_for("macos", "1.28.3")) + assert names == [ + "gstreamer-1.0-1.28.3-universal.pkg", + "gstreamer-1.0-devel-1.28.3-universal.pkg", + ] + assert {a.s3_dir for a in artifacts_for("macos", "1.28.3")} == {"macos"} + + +def test_windows_has_both_arches() -> None: + names = sorted(a.filename for a in artifacts_for("windows", "1.28.3")) + assert names == [ + "gstreamer-1.0-msvc-arm64-1.28.3.exe", + "gstreamer-1.0-msvc-x86_64-1.28.3.exe", + ] + + +def test_windows_rejects_pre_1_28() -> None: + with pytest.raises(ValueError, match="requires version"): + artifacts_for("windows", "1.26.5") + + +def test_ios_devel_pkg() -> None: + (art,) = artifacts_for("ios", "1.28.3") + assert art.filename == "gstreamer-1.0-devel-1.28.3-ios-universal.pkg" + assert art.url.startswith("https://gstreamer.freedesktop.org/data/pkg/ios/") + + +def test_unknown_platform_raises() -> None: + with pytest.raises(ValueError, match="Unknown platform"): + artifacts_for("plugin", "1.28.3") + + +def test_resolve_platforms_all() -> None: + assert resolve_platforms("all") == list(PLATFORMS) + assert resolve_platforms("") == list(PLATFORMS) + + +def test_resolve_platforms_subset_preserves_order() -> None: + assert resolve_platforms("windows, android") == ["windows", "android"] + + +def test_resolve_platforms_rejects_unknown() -> None: + with pytest.raises(ValueError, match="Unknown platform"): + resolve_platforms("android,plugin") + + +def test_s3_key_uses_url_basename() -> None: + art = Artifact("https://example/data/pkg/android/1.28.3/file-1.28.3.tar.xz", "android") + assert art.s3_key() == "dependencies/gstreamer/android/file-1.28.3.tar.xz" diff --git a/.github/workflows/mirror-gstreamer.yml b/.github/workflows/mirror-gstreamer.yml new file mode 100644 index 000000000000..5d5063206c33 --- /dev/null +++ b/.github/workflows/mirror-gstreamer.yml @@ -0,0 +1,91 @@ +name: Mirror GStreamer + +# Mirror official upstream GStreamer release artifacts into the QGC S3 bucket +# (s3://qgroundcontrol/dependencies/gstreamer//) — the +# fallback mirror the build reads when the upstream download is unavailable. +# Run this after bumping gstreamer.version in build-config.json. + +on: + workflow_dispatch: + inputs: + version: + description: 'GStreamer version (empty = build-config default)' + required: false + default: '' + type: string + platforms: + description: "Platforms to mirror ('all' or comma list: android,ios,macos,windows)" + required: false + default: 'all' + type: string + dry_run: + description: 'Download and verify only; do not upload' + required: false + default: false + type: boolean + force: + description: 'Re-upload even if the object already exists' + required: false + default: false + type: boolean + +concurrency: + group: mirror-gstreamer-${{ inputs.version }}-${{ inputs.platforms }} + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + mirror: + name: Mirror GStreamer ${{ inputs.version || 'default' }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + + steps: + - name: Harden Runner + uses: step-security/harden-runner@v2 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + sparse-checkout: | + .github + tools + sparse-checkout-cone-mode: false + + - name: Build Config + id: buildconfig + uses: ./.github/actions/build-config + + - name: Configure AWS Credentials + if: ${{ !inputs.dry_run }} + uses: ./.github/actions/aws-credentials + with: + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + aws-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Mirror artifacts + shell: bash + env: + VERSION: ${{ inputs.version || steps.buildconfig.outputs.gstreamer_version }} + PLATFORMS: ${{ inputs.platforms }} + DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + FORCE: ${{ inputs.force && 'true' || 'false' }} + WORK_DIR: ${{ runner.temp }} + AWS_DEFAULT_REGION: us-west-2 + run: | + set -euo pipefail + args=( + --version "$VERSION" + --platforms "$PLATFORMS" + --work-dir "$WORK_DIR" + ) + [[ "$DRY_RUN" == "true" ]] && args+=(--dry-run) + [[ "$FORCE" == "true" ]] && args+=(--force) + python3 "${GITHUB_WORKSPACE}/.github/scripts/mirror_gstreamer.py" "${args[@]}" diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 99f448082e7a..caa187bd4ac5 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -56,18 +56,23 @@ jobs: host: windows arch: win64_msvc2022_64 package: QGroundControl-installer-AMD64 + gstreamer: true - variant: arm64 runs_on: windows-11-arm host: windows_arm64 arch: win64_msvc2022_arm64 package: QGroundControl-installer-ARM64 + # Native arm64 has no GStreamer SDK (setup steps are skipped on the + # windows-11-arm runner), so build without video streaming. + gstreamer: false - variant: arm64-cross runs_on: ${{ github.repository_owner == 'mavlink' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('runs-on={0}/family=c8i.2xlarge/image=windows22-full-x64/spot=false/extras=s3-cache/volume=100gb', github.run_id) || 'windows-2022' }} host: windows arch: win64_msvc2022_arm64_cross_compiled package: QGroundControl-installer-AMD64-ARM64 + gstreamer: false aqt_source: 'git+https://github.com/miurahr/aqtinstall.git@c84e1470349e5bedbeee977bd62ec495d50d65b6' defaults: @@ -116,12 +121,12 @@ jobs: build-dir: ${{ runner.temp }}/build build-type: ${{ matrix.build_type }} extra-args: >- - -DQGC_ENABLE_GST_VIDEOSTREAMING=${{ matrix.arch != 'win64_msvc2022_arm64_cross_compiled' && 'ON' || 'OFF' }} + -DQGC_ENABLE_GST_VIDEOSTREAMING=${{ matrix.gstreamer && 'ON' || 'OFF' }} ${{ matrix.arch == 'win64_msvc2022_arm64_cross_compiled' && format('-DQT_HOST_PATH={0}', steps.build-setup.outputs.host_qt_root_dir) || '' }} - name: Resolve GStreamer root id: gst-root - if: matrix.arch != 'win64_msvc2022_arm64_cross_compiled' + if: matrix.gstreamer shell: bash run: | python3 "${GITHUB_WORKSPACE}/.github/scripts/cmake_helper.py" cache-var \ @@ -131,7 +136,7 @@ jobs: --output-key gst_root - name: Add GStreamer to PATH - if: matrix.arch != 'win64_msvc2022_arm64_cross_compiled' + if: matrix.gstreamer shell: bash run: echo "${{ steps.gst-root.outputs.gst_root }}/bin" >> "$GITHUB_PATH" @@ -174,8 +179,11 @@ jobs: Get-ChildItem -Path $installDir -Recurse | Select-Object FullName exit 1 } + $expectGstreamer = "${{ matrix.gstreamer }}" -eq "true" $pluginDir = Join-Path $installDir "lib\gstreamer-1.0" - if (Test-Path $pluginDir) { + if (-not $expectGstreamer) { + Write-Host "GStreamer disabled for this build; skipping plugin verification" + } elseif (Test-Path $pluginDir) { $pluginCount = (Get-ChildItem -Path $pluginDir -Filter "*.dll" | Measure-Object).Count Write-Host "GStreamer plugins found: $pluginCount" if ($pluginCount -eq 0) { @@ -188,7 +196,7 @@ jobs: } - name: Remove build SDK from PATH for installed verification - if: matrix.build_type == 'Release' && matrix.arch != 'win64_msvc2022_arm64_cross_compiled' + if: matrix.build_type == 'Release' && matrix.gstreamer shell: pwsh env: GST_ROOT: ${{ steps.gst-root.outputs.gst_root }} @@ -198,10 +206,13 @@ jobs: "QGC_VERIFY_INSTALLED_PATH=$sanitizedPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - name: Verify installed executable + # Arch-based, not matrix.gstreamer: still smoke-tests the GStreamer-less arm64-native binary (env.PATH fallback below). if: matrix.build_type == 'Release' && matrix.arch != 'win64_msvc2022_arm64_cross_compiled' uses: ./.github/actions/verify-executable env: - PATH: ${{ env.QGC_VERIFY_INSTALLED_PATH }} + # Non-GStreamer builds skip the SDK-PATH sanitize step above, so fall + # back to the runner PATH when QGC_VERIFY_INSTALLED_PATH is unset. + PATH: ${{ env.QGC_VERIFY_INSTALLED_PATH || env.PATH }} with: binary-path: ${{ runner.temp }}\qgc-install-test\bin\QGroundControl.exe type: exe diff --git a/.gitignore b/.gitignore index 859060bb59d8..9ed02e2a2229 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,12 @@ ios/iOSForAppStore-Info.plist android/local.properties .gradle/ *.class +# GStreamer plugin Java + fonts/CA assets copied in by the copyjavasource_ / +# copyfontsres_ / copycacertificatesres_ CMake targets +# (cmake/GStreamer/platform/Android.cmake) at build time — version-matched, regenerated. +android/src/org/freedesktop/ +android/assets/fontconfig/ +android/assets/ssl/ # Linux *.nfs diff --git a/CMakeLists.txt b/CMakeLists.txt index aebc8e44b3a0..f15ea98ec0bd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -349,6 +349,8 @@ if(QGC_BUILD_TESTING) add_subdirectory(test) # Exclude test directory from translation scanning set_property(DIRECTORY test PROPERTY QT_EXCLUDE_FROM_TRANSLATION ON) + # Pure-CMake unit tests for cmake/GStreamer helpers (no project build needed). + add_subdirectory(cmake/GStreamer/tests) endif() # ---------------------------------------------------------------------------- diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro index 17d99d40d709..83fb96bb515d 100644 --- a/android/proguard-rules.pro +++ b/android/proguard-rules.pro @@ -29,8 +29,9 @@ # SDL - native method stubs required for JNI registration -keep class org.libsdl.app.** { *; } -# GStreamer - native callbacks +# GStreamer - Java callback classes are reached from native plugin code -keep class org.freedesktop.gstreamer.** { *; } +-keep class org.freedesktop.gstreamer.androidmedia.** { *; } # usb-serial-for-android -keep class com.hoho.android.usbserial.** { *; } diff --git a/android/src/org/freedesktop/gstreamer/GStreamer.java b/android/src/org/freedesktop/gstreamer/GStreamer.java deleted file mode 100644 index 34cae7422e6e..000000000000 --- a/android/src/org/freedesktop/gstreamer/GStreamer.java +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copy this file into your Android project and call init(). If your project - * contains fonts and/or certificates in assets, uncomment copyFonts() and/or - * copyCaCertificates() lines in init(). - */ -package org.freedesktop.gstreamer; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -import android.content.Context; -import android.content.res.AssetManager; -import android.system.Os; -import android.util.Log; - -public class GStreamer { - private static final String TAG = "GStreamer"; - - private static native void nativeInit(Context context) throws Exception; - - public static void init(Context context) throws Exception { - copyFonts(context); - copyCaCertificates(context); - nativeInit(context); - } - - private static void copyFonts(Context context) { - AssetManager assetManager = context.getAssets(); - File filesDir = context.getFilesDir(); - File fontsFCDir = new File (filesDir, "fontconfig"); - File fontsDir = new File (fontsFCDir, "fonts"); - File fontsCfg = new File (fontsFCDir, "fonts.conf"); - - fontsDir.mkdirs(); - - try { - /* Copy the config file */ - copyFile (assetManager, "fontconfig/fonts.conf", fontsCfg); - /* Copy the fonts */ - for(String filename : assetManager.list("fontconfig/fonts/truetype")) { - File font = new File(fontsDir, filename); - copyFile (assetManager, "fontconfig/fonts/truetype/" + filename, font); - } - } catch (IOException e) { - Log.e(TAG, "Failed to copy fonts", e); - } - } - - private static void copyCaCertificates(Context context) { - AssetManager assetManager = context.getAssets(); - File filesDir = context.getFilesDir(); - File sslDir = new File (filesDir, "ssl"); - File certsDir = new File (sslDir, "certs"); - File certs = new File (certsDir, "ca-certificates.crt"); - - certsDir.mkdirs(); - - try { - /* Copy the certificates file */ - copyFile (assetManager, "ssl/certs/ca-certificates.crt", certs); - } catch (IOException e) { - Log.e(TAG, "Failed to copy CA certificates", e); - } - } - - private static void copyFile(AssetManager assetManager, String assetPath, File outFile) throws IOException { - if (outFile.exists()) - outFile.delete(); - - try (InputStream in = assetManager.open(assetPath); - OutputStream out = new FileOutputStream(outFile)) { - byte[] buffer = new byte[1024]; - int read; - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); - } - out.flush(); - } - } -} diff --git a/android/src/org/freedesktop/gstreamer/androidmedia/GstAhcCallback.java b/android/src/org/freedesktop/gstreamer/androidmedia/GstAhcCallback.java deleted file mode 100644 index 53811a9d33b5..000000000000 --- a/android/src/org/freedesktop/gstreamer/androidmedia/GstAhcCallback.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (C) 2012, Collabora Ltd. - * Author: Youness Alaoui - * - * Copyright (C) 2015, Collabora Ltd. - * Author: Justin Kim - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation - * version 2.1 of the License. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - * - */ - -package org.freedesktop.gstreamer.androidmedia; - -import android.hardware.Camera; - -public class GstAhcCallback implements Camera.PreviewCallback, - Camera.ErrorCallback, - Camera.AutoFocusCallback { - public long mUserData; - public long mCallback; - - public static native void gst_ah_camera_on_preview_frame(byte[] data, Camera camera, - long callback, long user_data); - public static native void gst_ah_camera_on_error(int error, Camera camera, - long callback, long user_data); - public static native void gst_ah_camera_on_auto_focus(boolean success, Camera camera, - long callback, long user_data); - - public GstAhcCallback(long callback, long user_data) { - mCallback = callback; - mUserData = user_data; - } - - @Override - public void onPreviewFrame(byte[] data, Camera camera) { - gst_ah_camera_on_preview_frame(data, camera, mCallback, mUserData); - } - - @Override - public void onError(int error, Camera camera) { - gst_ah_camera_on_error(error, camera, mCallback, mUserData); - } - - @Override - public void onAutoFocus(boolean success, Camera camera) { - gst_ah_camera_on_auto_focus(success, camera, mCallback, mUserData); - } -} diff --git a/android/src/org/freedesktop/gstreamer/androidmedia/GstAhsCallback.java b/android/src/org/freedesktop/gstreamer/androidmedia/GstAhsCallback.java deleted file mode 100644 index b6fb0158cd51..000000000000 --- a/android/src/org/freedesktop/gstreamer/androidmedia/GstAhsCallback.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (C) 2016 SurroundIO - * Author: Martin Kelly - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Library General Public - * License as published by the Free Software Foundation; either - * version 2 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Library General Public License for more details. - * - * You should have received a copy of the GNU Library General Public - * License along with this library; if not, write to the - * Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, - * Boston, MA 02110-1301, USA. - */ - -package org.freedesktop.gstreamer.androidmedia; - -import android.hardware.Sensor; -import android.hardware.SensorEvent; -import android.hardware.SensorEventListener; - -public class GstAhsCallback implements SensorEventListener { - public long mUserData; - public long mSensorCallback; - public long mAccuracyCallback; - - public static native void gst_ah_sensor_on_sensor_changed(SensorEvent event, - long callback, long user_data); - public static native void gst_ah_sensor_on_accuracy_changed(Sensor sensor, int accuracy, - long callback, long user_data); - - public GstAhsCallback(long sensor_callback, - long accuracy_callback, long user_data) { - mSensorCallback = sensor_callback; - mAccuracyCallback = accuracy_callback; - mUserData = user_data; - } - - @Override - public void onSensorChanged(SensorEvent event) { - gst_ah_sensor_on_sensor_changed(event, mSensorCallback, mUserData); - } - - @Override - public void onAccuracyChanged(Sensor sensor, int accuracy) { - gst_ah_sensor_on_accuracy_changed(sensor, accuracy, - mAccuracyCallback, mUserData); - } -} diff --git a/android/src/org/freedesktop/gstreamer/androidmedia/GstAmcOnFrameAvailableListener.java b/android/src/org/freedesktop/gstreamer/androidmedia/GstAmcOnFrameAvailableListener.java deleted file mode 100644 index f34bcf7ae2b4..000000000000 --- a/android/src/org/freedesktop/gstreamer/androidmedia/GstAmcOnFrameAvailableListener.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (C) 2015, Collabora Ltd. - * Author: Matthieu Bouron - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation - * version 2.1 of the License. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - * - */ - -package org.freedesktop.gstreamer.androidmedia; - -import android.graphics.SurfaceTexture; -import android.graphics.SurfaceTexture.OnFrameAvailableListener; - -public class GstAmcOnFrameAvailableListener implements OnFrameAvailableListener -{ - private long context = 0; - - public synchronized void onFrameAvailable (SurfaceTexture surfaceTexture) { - native_onFrameAvailable(context, surfaceTexture); - } - - public synchronized long getContext () { - return context; - } - - public synchronized void setContext (long c) { - context = c; - } - - private native void native_onFrameAvailable (long context, SurfaceTexture surfaceTexture); -} diff --git a/android/src/org/mavlink/qgroundcontrol/QGCActivity.java b/android/src/org/mavlink/qgroundcontrol/QGCActivity.java index 2ff0fa63fbe7..c44a47e00eb9 100644 --- a/android/src/org/mavlink/qgroundcontrol/QGCActivity.java +++ b/android/src/org/mavlink/qgroundcontrol/QGCActivity.java @@ -18,8 +18,6 @@ import org.qtproject.qt.android.bindings.QtActivity; -import org.freedesktop.gstreamer.GStreamer; - public class QGCActivity extends QtActivity { private static final String TAG = QGCActivity.class.getSimpleName(); private static final String MULTICAST_LOCK_TAG = "QGroundControl"; diff --git a/cmake/BuildConfig.cmake b/cmake/BuildConfig.cmake index a634013773e9..5cd2fd996e79 100644 --- a/cmake/BuildConfig.cmake +++ b/cmake/BuildConfig.cmake @@ -11,9 +11,10 @@ endif() # Read the JSON file file(READ "${QGC_BUILD_CONFIG_FILE}" QGC_BUILD_CONFIG_CONTENT) -# Extract value from JSON using CMake's native JSON parser +# Extract value from JSON using CMake's native JSON parser; supports dotted paths function(qgc_config_get_value VAR_NAME JSON_KEY) - string(JSON _value ERROR_VARIABLE _err GET "${QGC_BUILD_CONFIG_CONTENT}" "${JSON_KEY}") + string(REPLACE "." ";" _path "${JSON_KEY}") + string(JSON _value ERROR_VARIABLE _err GET "${QGC_BUILD_CONFIG_CONTENT}" ${_path}) if(_err) message(FATAL_ERROR "QGC: BuildConfig: Key '${JSON_KEY}' not found in ${QGC_BUILD_CONFIG_FILE}") endif() @@ -22,12 +23,12 @@ endfunction() qgc_config_get_value(QGC_CONFIG_QT_VERSION "qt_version") qgc_config_get_value(QGC_CONFIG_QT_MINIMUM_VERSION "qt_minimum_version") -qgc_config_get_value(QGC_CONFIG_GSTREAMER_VERSION "gstreamer_default_version") -qgc_config_get_value(QGC_CONFIG_GSTREAMER_MIN_VERSION "gstreamer_minimum_version") -qgc_config_get_value(QGC_CONFIG_GSTREAMER_ANDROID_VERSION "gstreamer_android_version") -qgc_config_get_value(QGC_CONFIG_GSTREAMER_IOS_VERSION "gstreamer_ios_version") -qgc_config_get_value(QGC_CONFIG_GSTREAMER_MACOS_VERSION "gstreamer_macos_version") -qgc_config_get_value(QGC_CONFIG_GSTREAMER_WIN_VERSION "gstreamer_windows_version") +qgc_config_get_value(QGC_CONFIG_GSTREAMER_VERSION "gstreamer.version.default") +qgc_config_get_value(QGC_CONFIG_GSTREAMER_MIN_VERSION "gstreamer.version.minimum") +qgc_config_get_value(QGC_CONFIG_GSTREAMER_ANDROID_VERSION "gstreamer.version.android") +qgc_config_get_value(QGC_CONFIG_GSTREAMER_IOS_VERSION "gstreamer.version.ios") +qgc_config_get_value(QGC_CONFIG_GSTREAMER_MACOS_VERSION "gstreamer.version.macos") +qgc_config_get_value(QGC_CONFIG_GSTREAMER_WIN_VERSION "gstreamer.version.windows") qgc_config_get_value(QGC_CONFIG_NDK_VERSION "ndk_version") qgc_config_get_value(QGC_CONFIG_NDK_FULL_VERSION "ndk_full_version") qgc_config_get_value(QGC_CONFIG_JAVA_VERSION "java_version") diff --git a/cmake/CustomOptions.cmake b/cmake/CustomOptions.cmake index 36aaa204def1..16a5261216e1 100644 --- a/cmake/CustomOptions.cmake +++ b/cmake/CustomOptions.cmake @@ -59,6 +59,13 @@ option(GIT_SUBMODULE "Update submodules during configuration" OFF) # Link parallelism (Ninja only) set(QGC_LINK_PARALLEL_LEVEL 2 CACHE STRING "Maximum parallel link jobs (prevents OOM during LTO)") +# ---- GStreamer SDK download / debug ---- +# Fail closed: only the SDK-download platforms (Android/macOS/iOS/Windows) hit this path; +# Linux uses system pkg-config and never downloads, so ON is a no-op there. +option(GStreamer_REQUIRE_CHECKSUM "Fail if an SDK download's checksum cannot be verified (set OFF to bypass)" ON) +option(GStreamer_DEBUG "Print GStreamer CMake debug messages" OFF) +set(QGC_GST_DOWNLOAD_TIMEOUT "" CACHE STRING "GStreamer SDK download wall-clock timeout (seconds, default 1200)") +set(QGC_GST_DOWNLOAD_INACTIVITY_TIMEOUT "" CACHE STRING "GStreamer SDK download inactivity timeout (seconds, default 60)") # Coverage thresholds set(QGC_COVERAGE_LINE_THRESHOLD 30 CACHE STRING "Minimum line coverage percentage") @@ -86,9 +93,7 @@ option(QGC_NO_SERIAL_LINK "Disable serial port communication" OFF) # Video Streaming Options # ============================================================================ -option(QGC_ENABLE_UVC "Enable UVC (USB Video Class) device support" ON) option(QGC_ENABLE_GST_VIDEOSTREAMING "Enable GStreamer video backend" ON) -option(QGC_ENABLE_QT_VIDEOSTREAMING "Enable QtMultimedia video backend" OFF) # ============================================================================ # MAVLink Configuration diff --git a/cmake/GStreamer/Components.cmake b/cmake/GStreamer/Components.cmake new file mode 100644 index 000000000000..e1a821ae257f --- /dev/null +++ b/cmake/GStreamer/Components.cmake @@ -0,0 +1,195 @@ +# Component registry, api/pc-name mapping, and plugin scanning helpers. +# GSTREAMER_COMPONENT_REGISTRY below is the single source of truth for the +# component → api_name → pc_name → mandatory mapping. + +# Single source of truth for platform plugin shared-library naming triple +# (extension, prefix, glob). Used by plugin discovery and post-install verify. +function(gstreamer_platform_plugin_attrs EXT_OUT PREFIX_OUT GLOB_OUT) + if(WIN32) + set(_ext "dll") + set(_prefix "gst") + elseif(APPLE) + set(_ext "dylib") + set(_prefix "libgst") + else() + set(_ext "so") + set(_prefix "libgst") + endif() + set(${EXT_OUT} "${_ext}" PARENT_SCOPE) + set(${PREFIX_OUT} "${_prefix}" PARENT_SCOPE) + set(${GLOB_OUT} "${_prefix}*.${_ext}" PARENT_SCOPE) +endfunction() + +# Returns plugin basenames (e.g. "videoconvert", "x264") found in PLUGIN_PATH, +# stripped of the platform's lib prefix and extension. Empty string if path missing. +function(gstreamer_scan_plugin_basenames OUTPUT_VAR PLUGIN_PATH) + if(NOT EXISTS "${PLUGIN_PATH}") + set(${OUTPUT_VAR} "" PARENT_SCOPE) + return() + endif() + gstreamer_platform_plugin_attrs(_ext _prefix _glob) + # Underscore included: a hypothetical libgstvideo_foo.so must not truncate to "video". + set(_re "^${_prefix}([a-zA-Z0-9_]+)") + file(GLOB _files "${PLUGIN_PATH}/${_glob}") + set(_names "") + foreach(_p IN LISTS _files) + get_filename_component(_n "${_p}" NAME) + if(_n MATCHES "${_re}") + list(APPEND _names "${CMAKE_MATCH_1}") + endif() + endforeach() + list(REMOVE_DUPLICATES _names) + set(${OUTPUT_VAR} "${_names}" PARENT_SCOPE) +endfunction() + +# Component registry — SINGLE source of truth for the +# (component_name, api_name, pc_name, mandatory) tuple. +# +# Format per entry: "ComponentName:api_name:pc_name:mandatory" +# ComponentName - user-facing CamelCase component (Core, Base, GlPrototypes, …) +# api_name - snake_case CMake/imported-target stem (api_base, api_gl_prototypes, …); +# empty for components with no .pc file (Core) +# pc_name - pkg-config module name (gstreamer-base-1.0); empty when no .pc file +# mandatory - "1" if always present in any GStreamer install, "0" otherwise +# +# Mandatory entries seed gstreamer_build_apis_and_deps and the always-FOUND +# iteration in Orchestrator.cmake. Adding a new always-present API requires +# only an entry here. +set(GSTREAMER_COMPONENT_REGISTRY + "Core:::1" # umbrella; gstreamer-1.0 is queried directly + "Base:api_base:gstreamer-base-1.0:1" + "Gl:api_gl:gstreamer-gl-1.0:1" + "GlPrototypes:api_gl_prototypes:gstreamer-gl-prototypes-1.0:1" + "Rtsp:api_rtsp:gstreamer-rtsp-1.0:1" + "Video:api_video:gstreamer-video-1.0:1" + "App:api_app:gstreamer-app-1.0:0" + "Allocators:api_allocators:gstreamer-allocators-1.0:0" + "Pbutils:api_pbutils:gstreamer-pbutils-1.0:0" + "Tag:api_tag:gstreamer-tag-1.0:0" + "Audio:api_audio:gstreamer-audio-1.0:0" +) + +# Internal: split a registry entry "Component:api:pc:mandatory" into 4 OUT vars. +function(_gstreamer_registry_split ENTRY OUT_NAME OUT_API OUT_PC OUT_MAND) + string(REPLACE ":" ";" _parts "${ENTRY}") + list(LENGTH _parts _n) + if(NOT _n EQUAL 4) + message(FATAL_ERROR "GSTREAMER_COMPONENT_REGISTRY: malformed entry '${ENTRY}' (need exactly 4 colon-separated fields, got ${_n})") + endif() + list(GET _parts 0 _name) + list(GET _parts 1 _api) + list(GET _parts 2 _pc) + list(GET _parts 3 _mand) + string(STRIP "${_mand}" _mand) + set(${OUT_NAME} "${_name}" PARENT_SCOPE) + set(${OUT_API} "${_api}" PARENT_SCOPE) + set(${OUT_PC} "${_pc}" PARENT_SCOPE) + set(${OUT_MAND} "${_mand}" PARENT_SCOPE) +endfunction() + +# gstreamer_resolve_component( ) +# Looks up the registry by either ComponentName or api_name. Returns empty +# strings for all OUT vars when the query doesn't match any entry, with a +# fallback for unknown api_foo queries (out_pc set via the api_foo → +# gstreamer-foo-1.0 convention; out_name/out_mandatory left empty). +function(gstreamer_resolve_component QUERY OUT_NAME OUT_API OUT_PC OUT_MANDATORY) + foreach(_entry IN LISTS GSTREAMER_COMPONENT_REGISTRY) + _gstreamer_registry_split("${_entry}" _name _api _pc _mand) + if(QUERY STREQUAL _name OR (_api AND QUERY STREQUAL _api)) + set(${OUT_NAME} "${_name}" PARENT_SCOPE) + set(${OUT_API} "${_api}" PARENT_SCOPE) + set(${OUT_PC} "${_pc}" PARENT_SCOPE) + set(${OUT_MANDATORY} "${_mand}" PARENT_SCOPE) + return() + endif() + endforeach() + # Fallback for unknown api_foo_bar queries: gstreamer-foo-bar-1.0. + if(QUERY MATCHES "^api_(.+)$") + string(REPLACE "_" "-" _pc "${CMAKE_MATCH_1}") + set(${OUT_NAME} "" PARENT_SCOPE) + set(${OUT_API} "${QUERY}" PARENT_SCOPE) + set(${OUT_PC} "gstreamer-${_pc}-1.0" PARENT_SCOPE) + set(${OUT_MANDATORY} "0" PARENT_SCOPE) + return() + endif() + set(${OUT_NAME} "" PARENT_SCOPE) + set(${OUT_API} "" PARENT_SCOPE) + set(${OUT_PC} "" PARENT_SCOPE) + set(${OUT_MANDATORY} "" PARENT_SCOPE) +endfunction() + +# gstreamer_mandatory_components( ) +# Returns the registry's always-present component names and api_ names. Used +# to drive the FOUND-loop in Orchestrator.cmake and the api-seed in +# gstreamer_build_apis_and_deps so neither has to repeat the list. +function(gstreamer_mandatory_components OUT_NAMES OUT_APIS) + set(_names "") + set(_apis "") + foreach(_entry IN LISTS GSTREAMER_COMPONENT_REGISTRY) + _gstreamer_registry_split("${_entry}" _name _api _pc _mand) + if(_mand STREQUAL "1") + list(APPEND _names "${_name}") + if(_api) + list(APPEND _apis "${_api}") + endif() + endif() + endforeach() + set(${OUT_NAMES} "${_names}" PARENT_SCOPE) + set(${OUT_APIS} "${_apis}" PARENT_SCOPE) +endfunction() + +# gstreamer_component_to_api( ) +# CamelCase component -> api_snake_case (fallback for unregistered components). +function(gstreamer_component_to_api COMPONENT OUT_VAR) + string(REGEX REPLACE "([a-z0-9])([A-Z])" "\\1_\\2" _snake "${COMPONENT}") + string(TOLOWER "${_snake}" _snake) + set(${OUT_VAR} "api_${_snake}" PARENT_SCOPE) +endfunction() + +# gstreamer_build_apis_and_deps( ) +# Builds GSTREAMER_APIS and GSTREAMER_EXTRA_DEPS from a component list plus +# optional platform extras. The seed of always-present APIs comes from the +# registry's mandatory rows (no hardcoded list). +function(gstreamer_build_apis_and_deps APIS_OUT DEPS_OUT) + # Seed from registry-mandatory rows (single source of truth). + gstreamer_mandatory_components(_seed_names _apis) + + # Add extra apis from caller's component list — resolve via registry first + # to handle CamelCase → api_snake mapping for non-mandatory components. + foreach(_comp IN LISTS ARGN) + if(_comp STREQUAL "Core") + continue() + endif() + gstreamer_resolve_component("${_comp}" _name _api _pc _mand) + if(NOT _api) + # Unknown component name: derive api via CamelCase → snake_case. + gstreamer_component_to_api("${_comp}" _api) + endif() + if(NOT _api IN_LIST _apis) + list(APPEND _apis "${_api}") + endif() + endforeach() + + # Derive pkg-config deps from registry. + set(_deps) + foreach(_api IN LISTS _apis) + gstreamer_resolve_component("${_api}" _ _ _pc _) + if(_pc) + list(APPEND _deps "${_pc}") + endif() + endforeach() + + # Platform extra deps — kept here as the single registration point. + if(WIN32) + list(APPEND _deps graphene-1.0) + endif() + if(ANDROID OR IOS) + list(APPEND _deps gio-2.0) + endif() + if(ANDROID) + list(APPEND _deps gmodule-2.0 zlib) + endif() + + set(${APIS_OUT} "${_apis}" PARENT_SCOPE) + set(${DEPS_OUT} "${_deps}" PARENT_SCOPE) +endfunction() diff --git a/cmake/GStreamer/Download.cmake b/cmake/GStreamer/Download.cmake new file mode 100644 index 000000000000..53ddf008511b --- /dev/null +++ b/cmake/GStreamer/Download.cmake @@ -0,0 +1,302 @@ +# GStreamer SDK download / URL / checksum helpers. + +# Absolute path disambiguates from this file (also named Download.cmake): when +# test_download.cmake adds cmake/GStreamer/ to MODULE_PATH, a bare `include(Download)` +# would resolve to this file and self-recurse. +include("${CMAKE_CURRENT_LIST_DIR}/../modules/Download.cmake") # qgc_resilient_download / qgc_parse_expected_hash + +function(gstreamer_get_package_url PLATFORM VERSION OUTPUT_VAR) + set(_base "https://gstreamer.freedesktop.org") + + # URL templates for simple platforms — VERSION substituted via _base. + set(_url_android "${_base}/data/pkg/android/${VERSION}/gstreamer-1.0-android-universal-${VERSION}.tar.xz") + set(_url_ios "${_base}/data/pkg/ios/${VERSION}/gstreamer-1.0-devel-${VERSION}-ios-universal.pkg") + set(_url_macos "${_base}/data/pkg/macos/${VERSION}/gstreamer-1.0-${VERSION}-universal.pkg") + set(_url_macos_devel "${_base}/data/pkg/macos/${VERSION}/gstreamer-1.0-devel-${VERSION}-universal.pkg") + + if(PLATFORM MATCHES "^windows_msvc_(x64|arm64)$") + # Windows: 1.28+ ships .exe with arch-tagged filename (defensive guard against manual override). + if(VERSION VERSION_LESS "1.28.0") + message(FATAL_ERROR + "Windows GStreamer SDK requires version 1.28 or later (got '${VERSION}'). " + "Bump gstreamer.version.windows in build-config.json.") + endif() + if(CMAKE_MATCH_1 STREQUAL "x64") + set(_arch "x86_64") + else() + set(_arch "arm64") + endif() + set(_url "${_base}/data/pkg/windows/${VERSION}/msvc/gstreamer-1.0-msvc-${_arch}-${VERSION}.exe") + elseif(DEFINED _url_${PLATFORM}) + set(_url "${_url_${PLATFORM}}") + else() + message(FATAL_ERROR "gstreamer_get_package_url: Unknown platform '${PLATFORM}'") + endif() + + set(${OUTPUT_VAR} "${_url}" PARENT_SCOPE) +endfunction() + +function(gstreamer_get_s3_mirror_url PLATFORM VERSION OUTPUT_VAR) + set(_s3_base "https://qgroundcontrol.s3.us-west-2.amazonaws.com/dependencies/gstreamer") + + if(PLATFORM STREQUAL "android") + set(_dir "android") + elseif(PLATFORM STREQUAL "ios") + set(_dir "ios") + elseif(PLATFORM MATCHES "^macos") + set(_dir "macos") + elseif(PLATFORM MATCHES "^windows_msvc_") + set(_dir "windows") + else() + set(${OUTPUT_VAR} "" PARENT_SCOPE) + return() + endif() + + gstreamer_get_package_url("${PLATFORM}" "${VERSION}" _primary_url) + cmake_path(GET _primary_url FILENAME _filename) + + set(${OUTPUT_VAR} "${_s3_base}/${_dir}/${_filename}" PARENT_SCOPE) +endfunction() + +function(gstreamer_get_fallback_checksum PLATFORM VERSION OUTPUT_VAR) + set(_checksum "") + + if(DEFINED QGC_BUILD_CONFIG_CONTENT) + string(JSON _hash ERROR_VARIABLE _err + GET "${QGC_BUILD_CONFIG_CONTENT}" "gstreamer" "checksums" "${VERSION}" "${PLATFORM}") + if(NOT _err AND _hash) + set(_checksum "SHA256=${_hash}") + endif() + endif() + + set(${OUTPUT_VAR} "${_checksum}" PARENT_SCOPE) +endfunction() + +function(_gstreamer_fallback_or_warn PLATFORM VERSION OUTPUT_VAR MESSAGE) + gstreamer_get_fallback_checksum(${PLATFORM} ${VERSION} _fallback_hash) + if(_fallback_hash) + message(WARNING "GStreamer: ${MESSAGE} for ${PLATFORM} ${VERSION}; using pinned fallback checksum.") + set(${OUTPUT_VAR} "${_fallback_hash}" PARENT_SCOPE) + return() + endif() + if(GStreamer_REQUIRE_CHECKSUM) + message(FATAL_ERROR + "GStreamer: ${MESSAGE} for ${PLATFORM} ${VERSION} " + "and no pinned fallback exists. Set GStreamer_REQUIRE_CHECKSUM=OFF to bypass.") + endif() + message(WARNING + "GStreamer: ${MESSAGE} for ${PLATFORM} ${VERSION}; " + "the SDK will be used WITHOUT integrity verification — supply-chain " + "authenticity is NOT guaranteed. Only proceed on a trusted network/mirror. " + "(GStreamer_REQUIRE_CHECKSUM=ON makes this a hard error.)") + set(${OUTPUT_VAR} "" PARENT_SCOPE) +endfunction() + +# _gstreamer_download_sidecar( ) +# Downloads the .sha256sum sidecar to DEST_FILE; sets RESULT_VAR TRUE on success. +function(_gstreamer_download_sidecar URL DEST_FILE RESULT_VAR) + file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/_deps/checksums") + set(_tmp "${DEST_FILE}.tmp") + foreach(_attempt RANGE 1 3) + file(DOWNLOAD "${URL}" "${_tmp}" STATUS _status TIMEOUT 30 INACTIVITY_TIMEOUT 10 TLS_VERIFY ON) + list(GET _status 0 _code) + if(_code EQUAL 0) + file(RENAME "${_tmp}" "${DEST_FILE}") + set(${RESULT_VAR} TRUE PARENT_SCOPE) + return() + endif() + list(GET _status 1 _msg) + message(STATUS "GStreamer: Checksum download attempt ${_attempt}/3 failed: ${_msg}") + file(REMOVE "${_tmp}") + endforeach() + set(${RESULT_VAR} FALSE PARENT_SCOPE) +endfunction() + +function(gstreamer_fetch_checksum PLATFORM VERSION OUTPUT_VAR) + gstreamer_get_package_url(${PLATFORM} ${VERSION} _pkg_url) + + string(FIND "${_pkg_url}" "?" _qs_pos) + if(NOT _qs_pos EQUAL -1) + message(STATUS "GStreamer: Skipping checksum for ${PLATFORM} ${VERSION} (URL contains query string)") + set(${OUTPUT_VAR} "" PARENT_SCOPE) + return() + endif() + + set(_checksum_url "${_pkg_url}.sha256sum") + string(SHA256 _url_hash "${_checksum_url}") + set(_checksum_file "${CMAKE_BINARY_DIR}/_deps/checksums/${_url_hash}.sha256sum") + + # In-tree pin is authoritative: a fetched sidecar is attacker-influenceable + # (compromised mirror/MITM), so it can never silently override the pin. + gstreamer_get_fallback_checksum(${PLATFORM} ${VERSION} _pinned_hash) + + set(_dl_ok FALSE) + foreach(_round RANGE 1 2) + if(NOT EXISTS "${_checksum_file}") + _gstreamer_download_sidecar("${_checksum_url}" "${_checksum_file}" _dl_ok) + if(NOT _dl_ok) + _gstreamer_fallback_or_warn(${PLATFORM} ${VERSION} _result "Checksum sidecar unavailable") + set(${OUTPUT_VAR} "${_result}" PARENT_SCOPE) + return() + endif() + endif() + + file(READ "${_checksum_file}" _content) + string(STRIP "${_content}" _content) + string(REGEX MATCH "([0-9a-fA-F]{64})" _match "${_content}") + if(_match) + set(_fetched_hash "SHA256=${CMAKE_MATCH_1}") + if(_pinned_hash AND NOT _pinned_hash STREQUAL _fetched_hash) + message(FATAL_ERROR + "GStreamer: checksum mismatch for ${PLATFORM} ${VERSION} — the fetched " + ".sha256sum sidecar does NOT match the in-tree pinned hash. The pinned " + "hash is authoritative; a divergent sidecar indicates a compromised " + "mirror, MITM, or an out-of-date pin.\n" + " pinned (authoritative): ${_pinned_hash}\n" + " fetched (sidecar): ${_fetched_hash}\n" + "If the upstream SDK legitimately changed, update the pinned hash in " + ".github/build-config.json (gstreamer.checksums.${VERSION}.${PLATFORM}).") + endif() + # Hand the authoritative pinned hash to the integrity check when present. + if(_pinned_hash) + set(${OUTPUT_VAR} "${_pinned_hash}" PARENT_SCOPE) + else() + set(${OUTPUT_VAR} "${_fetched_hash}" PARENT_SCOPE) + endif() + return() + endif() + + message(STATUS "GStreamer: Could not parse checksum for ${PLATFORM} ${VERSION}") + file(REMOVE "${_checksum_file}") + endforeach() + + _gstreamer_fallback_or_warn(${PLATFORM} ${VERSION} _result "Checksum content invalid/unparseable") + set(${OUTPUT_VAR} "${_result}" PARENT_SCOPE) +endfunction() + +# GStreamer-specific wrapper around qgc_resilient_download (cmake/modules/Download.cmake). +# Adds the GStreamer log prefix, install hint, and honours legacy +# QGC_GST_DOWNLOAD_TIMEOUT / QGC_GST_DOWNLOAD_INACTIVITY_TIMEOUT cache vars +# (CI flake-fixer knobs that predate the project-level QGC_DOWNLOAD_* vars). +function(gstreamer_resilient_download) + cmake_parse_arguments(ARG "ALLOW_FAILURE" + "FILENAME;DESTINATION_DIR;RESULT_VAR;TIMEOUT;INACTIVITY_TIMEOUT;EXPECTED_HASH" + "URLS" ${ARGN}) + if(NOT ARG_TIMEOUT AND DEFINED QGC_GST_DOWNLOAD_TIMEOUT) + set(ARG_TIMEOUT ${QGC_GST_DOWNLOAD_TIMEOUT}) + endif() + if(NOT ARG_INACTIVITY_TIMEOUT AND DEFINED QGC_GST_DOWNLOAD_INACTIVITY_TIMEOUT) + set(ARG_INACTIVITY_TIMEOUT ${QGC_GST_DOWNLOAD_INACTIVITY_TIMEOUT}) + endif() + + # GStreamer SDKs are large (the Android universal tarball is ~1 GB); the module's + # 120 s wall-clock default kills slow-but-healthy CI downloads mid-transfer. Use a + # generous cap and let INACTIVITY_TIMEOUT (60 s) catch genuine stalls instead. + # Note: this 1200 s default overrides the project-level QGC_DOWNLOAD_TIMEOUT for + # GStreamer downloads — use QGC_GST_DOWNLOAD_TIMEOUT to tune the GStreamer cap. + if(NOT ARG_TIMEOUT) + set(ARG_TIMEOUT 1200) + endif() + if(NOT ARG_INACTIVITY_TIMEOUT) + set(ARG_INACTIVITY_TIMEOUT 60) + endif() + + set(_fwd + FILENAME "${ARG_FILENAME}" + DESTINATION_DIR "${ARG_DESTINATION_DIR}" + RESULT_VAR _qrd_result + URLS "${ARG_URLS}" + LOG_TAG "GStreamer" + FAILURE_HINT "Install manually from https://gstreamer.freedesktop.org/download/ or set GStreamer_ROOT_DIR." + ) + if(ARG_TIMEOUT) + list(APPEND _fwd TIMEOUT ${ARG_TIMEOUT}) + endif() + if(ARG_INACTIVITY_TIMEOUT) + list(APPEND _fwd INACTIVITY_TIMEOUT ${ARG_INACTIVITY_TIMEOUT}) + endif() + if(ARG_EXPECTED_HASH) + list(APPEND _fwd EXPECTED_HASH "${ARG_EXPECTED_HASH}") + endif() + if(ARG_ALLOW_FAILURE) + list(APPEND _fwd ALLOW_FAILURE) + endif() + qgc_resilient_download(${_fwd}) + + if(NOT ARG_RESULT_VAR) + message(FATAL_ERROR "gstreamer_resilient_download: RESULT_VAR is required") + endif() + set(${ARG_RESULT_VAR} "${_qrd_result}" PARENT_SCOPE) +endfunction() + +function(gstreamer_download_sdk PLATFORM VERSION FILENAME DESTINATION_DIR RESULT_VAR) + cmake_parse_arguments(_DL "ALLOW_FAILURE" "" "" ${ARGN}) + + gstreamer_get_package_url("${PLATFORM}" "${VERSION}" _url) + gstreamer_get_s3_mirror_url("${PLATFORM}" "${VERSION}" _s3_url) + gstreamer_fetch_checksum("${PLATFORM}" "${VERSION}" _hash) + + set(_urls "${_url}") + if(_s3_url) + list(APPEND _urls "${_s3_url}") + endif() + + set(_args + URLS ${_urls} + FILENAME "${FILENAME}" + DESTINATION_DIR "${DESTINATION_DIR}" + RESULT_VAR _result + ) + if(_hash) + list(APPEND _args EXPECTED_HASH "${_hash}") + endif() + if(_DL_ALLOW_FAILURE) + list(APPEND _args ALLOW_FAILURE) + endif() + + gstreamer_resilient_download(${_args}) + set(${RESULT_VAR} "${_result}" PARENT_SCOPE) +endfunction() + +# gstreamer_resolve_or_download_sdk +# Shared prologue for Windows/macOS/iOS: selects cache dir from CPM_SOURCE_CACHE +# or CMAKE_BINARY_DIR, downloads the SDK package(s) if not already cached, then +# calls VALIDATE_FN (a macro/function name with no args) to expand and validate +# the archive. The callee sets GStreamer_ROOT_DIR and GStreamer_AUTO_DOWNLOADED. +# +# OUT parameters (all set in PARENT_SCOPE if provided): +# CACHE_DIR_OUT — resolved cache directory +# ARCHIVE_OUT — path to the downloaded primary archive (FILENAME_PRIMARY) +# ARCHIVE2_OUT — path to the downloaded secondary archive (FILENAME_SECONDARY, optional) +# +# This is a function so scratch variables don't leak; callers must pass OUT vars +# and read them back to propagate GStreamer_ROOT_DIR / GStreamer_AUTO_DOWNLOADED. +function(gstreamer_resolve_or_download_sdk) + cmake_parse_arguments(ARG "" "PLATFORM;CACHE_SUBDIR;FILENAME_PRIMARY;FILENAME_SECONDARY;CACHE_DIR_OUT;ARCHIVE_OUT;ARCHIVE2_OUT" "" ${ARGN}) + if(NOT GStreamer_FIND_VERSION) + message(FATAL_ERROR "gstreamer_resolve_or_download_sdk: GStreamer_FIND_VERSION not set") + endif() + + foreach(_req IN ITEMS PLATFORM CACHE_SUBDIR FILENAME_PRIMARY CACHE_DIR_OUT ARCHIVE_OUT) + if(NOT ARG_${_req}) + message(FATAL_ERROR "gstreamer_resolve_or_download_sdk: ${_req} is required") + endif() + endforeach() + + if(CPM_SOURCE_CACHE) + set(_cache_dir "${CPM_SOURCE_CACHE}/${ARG_CACHE_SUBDIR}") + else() + set(_cache_dir "${CMAKE_BINARY_DIR}/_deps/${ARG_CACHE_SUBDIR}") + endif() + set(${ARG_CACHE_DIR_OUT} "${_cache_dir}" PARENT_SCOPE) + + gstreamer_download_sdk(${ARG_PLATFORM} ${GStreamer_FIND_VERSION} + "${ARG_FILENAME_PRIMARY}" "${_cache_dir}" _primary_archive) + set(${ARG_ARCHIVE_OUT} "${_primary_archive}" PARENT_SCOPE) + + if(ARG_FILENAME_SECONDARY AND ARG_ARCHIVE2_OUT) + gstreamer_download_sdk(${ARG_PLATFORM}_devel ${GStreamer_FIND_VERSION} + "${ARG_FILENAME_SECONDARY}" "${_cache_dir}" _secondary_archive) + set(${ARG_ARCHIVE2_OUT} "${_secondary_archive}" PARENT_SCOPE) + endif() +endfunction() diff --git a/cmake/GStreamer/Helpers.cmake b/cmake/GStreamer/Helpers.cmake new file mode 100644 index 000000000000..71dabc032400 --- /dev/null +++ b/cmake/GStreamer/Helpers.cmake @@ -0,0 +1,45 @@ +# Aggregator entry-point for the cmake/GStreamer/ namespace. Consumers do +# `include(GStreamer/Helpers)` once and get the full helper surface. New code +# should include the focused submodule directly via +# `include("${CMAKE_CURRENT_LIST_DIR}/.cmake")` from another GStreamer/ +# helper, or `include(GStreamer/)` from outside the namespace. +# +# Submodule contents: +# Json — _qgc_json_array_to_list (build-config.json reader) +# Download — URL templates, S3 mirror, checksum fetch, +# gstreamer_resilient_download (thin wrapper over +# project-level qgc_resilient_download in +# cmake/modules/Download.cmake) / gstreamer_download_sdk / +# gstreamer_resolve_or_download_sdk +# Components — GSTREAMER_COMPONENT_REGISTRY, +# gstreamer_build_apis_and_deps, gstreamer_platform_plugin_attrs, +# gstreamer_scan_plugin_basenames +# Link — _gst_IGNORED_SYSTEM_LIBRARIES, _gst_SRT_REGEX_PATCH, +# _gst_save_find_suffixes / _gst_restore_find_suffixes, +# _gst_resolve_and_link_libraries +# Layout — gstreamer_create_layout_target (FLAT / FRAMEWORK / +# XCFRAMEWORK / STATIC_TARBALL → GStreamer::Layout) +# Probe — qgc_check_gst_header (header/symbol check_cxx_source_compiles +# helper) +# PkgConfig — gstreamer_apply_pkgconfig_env (MODE SDK | +# SYSTEM_AUGMENT) — single PKG_CONFIG_PATH/LIBDIR +# mutator, replaces _gst_configure_pkg_config and +# the per-platform ad-hoc env writes +# PluginPolicy — gstreamer_plugins_for / gstreamer_filter_alternates / +# gstreamer_runtime_required_plugins / gstreamer_xcfw_skip / +# gstreamer_current_platform_key (alternate groups, runtime +# required, xcfw skip) +# Install — gstreamer_install_{plugins,gio_modules,libs} + +# gstreamer_install_{linux,windows,macos}_sdk + +# gstreamer_install_platform_sdk dispatcher + +# Order matters: Install depends on Components (gstreamer_platform_plugin_attrs). +include("${CMAKE_CURRENT_LIST_DIR}/Json.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/Components.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/Link.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/Layout.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/Probe.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/PkgConfig.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/Download.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/PluginPolicy.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/Install.cmake") diff --git a/cmake/GStreamer/Install.cmake b/cmake/GStreamer/Install.cmake new file mode 100644 index 000000000000..8e5c8e2bd764 --- /dev/null +++ b/cmake/GStreamer/Install.cmake @@ -0,0 +1,531 @@ +# Platform SDK install helpers — copy GStreamer plugins, GIO modules, helper +# binaries, and CA bundles into the install tree. Called from +# src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt after the ANDROID/IOS +# early-return. +# Depends on: Components.cmake (gstreamer_platform_plugin_attrs), +# PluginPolicy.cmake (gstreamer_runtime_required_plugins, gstreamer_plugin_satisfy_sets). + +# Shared install-from-glob helper. CALLER must specify GLOB_PATTERN; FILTER_PREFIX +# (optional) restricts results to files matching ${PREFIX}${name}.${EXT} where name +# is in GSTREAMER_PLUGINS. Used by the public install_* wrappers below. +# Invariant: GLOB is evaluated at configure time, so the SDK must already be +# expanded before any install_* wrapper runs; re-running cmake --install without +# re-configure reuses this stale list. + +# Pure (script-testable) plugin-name filter: keep only paths whose basename is +# ${PREFIX}(boundary) where is in GSTREAMER_PLUGINS. The trailing +# boundary stops gstvideo matching gstvideoconvert. +function(_gstreamer_filter_plugin_paths PREFIX PATHS OUT_VAR) + set(_filtered "") + foreach(_path IN LISTS PATHS) + get_filename_component(_name "${_path}" NAME) + foreach(_allowed IN LISTS GSTREAMER_PLUGINS) + string(REGEX REPLACE "([][.+*?^$()|\\\\])" "\\\\\\1" _allowed_re "${_allowed}") + if(_name MATCHES "^${PREFIX}${_allowed_re}([^a-zA-Z0-9]|$)") + list(APPEND _filtered "${_path}") + break() + endif() + endforeach() + endforeach() + set(${OUT_VAR} "${_filtered}" PARENT_SCOPE) +endfunction() + +function(_gstreamer_install_glob LABEL) + cmake_parse_arguments(ARG "REQUIRE_NONEMPTY" "SOURCE_DIR;DEST_DIR;GLOB_PATTERN;FILTER_PREFIX" "" ${ARGN}) + if(NOT EXISTS "${ARG_SOURCE_DIR}") + message(WARNING "${LABEL}: SOURCE_DIR does not exist: ${ARG_SOURCE_DIR}") + return() + endif() + file(GLOB _matches "${ARG_SOURCE_DIR}/${ARG_GLOB_PATTERN}") + if(ARG_FILTER_PREFIX) + _gstreamer_filter_plugin_paths("${ARG_FILTER_PREFIX}" "${_matches}" _matches) + endif() + if(_matches) + install(FILES ${_matches} DESTINATION "${ARG_DEST_DIR}") + elseif(ARG_REQUIRE_NONEMPTY) + # Empty here means a missing/unexpanded SDK — fail loudly rather than ship + # a video build with zero plugins. + message(FATAL_ERROR + "${LABEL}: glob '${ARG_GLOB_PATTERN}' in '${ARG_SOURCE_DIR}' matched no files. " + "The GStreamer SDK must be fully expanded at configure time before install. " + "Re-run cmake configure against a complete SDK.") + endif() +endfunction() + +function(gstreamer_install_gio_modules) + cmake_parse_arguments(ARG "" "SOURCE_DIR;DEST_DIR;EXTENSION" "" ${ARGN}) + _gstreamer_install_glob("gstreamer_install_gio_modules" + SOURCE_DIR "${ARG_SOURCE_DIR}" DEST_DIR "${ARG_DEST_DIR}" + GLOB_PATTERN "*.${ARG_EXTENSION}") +endfunction() + +function(gstreamer_install_plugins) + cmake_parse_arguments(ARG "" "SOURCE_DIR;DEST_DIR;EXTENSION;PREFIX" "" ${ARGN}) + _gstreamer_install_glob("gstreamer_install_plugins" + SOURCE_DIR "${ARG_SOURCE_DIR}" DEST_DIR "${ARG_DEST_DIR}" + GLOB_PATTERN "${ARG_PREFIX}*.${ARG_EXTENSION}" + FILTER_PREFIX "${ARG_PREFIX}" + REQUIRE_NONEMPTY) +endfunction() + +function(gstreamer_install_libs) + cmake_parse_arguments(ARG "" "SOURCE_DIR;DEST_DIR;EXTENSION" "" ${ARGN}) + + if(NOT EXISTS "${ARG_SOURCE_DIR}") + message(WARNING "gstreamer_install_libs: SOURCE_DIR does not exist: ${ARG_SOURCE_DIR}") + return() + endif() + + _gstreamer_is_blocked_lib_copy_source("${ARG_SOURCE_DIR}" _blocked_lib_source) + if(_blocked_lib_source) + message(FATAL_ERROR + "gstreamer_install_libs: refusing to copy from system/shared prefix '${ARG_SOURCE_DIR}'.\n" + "This function copies ALL shared libraries unfiltered. Use an auto-downloaded SDK " + "or set GStreamer_ROOT_DIR to an isolated installation.") + endif() + + # Deliberately copies every lib in the isolated SDK dir — shared transitive deps are + # not enumerable from the plugin set, and the system-prefix guard above bounds the blast. + # Note: on Windows this also ships unused codec DLLs from the SDK bin/ dir (bloat, + # attack surface) — acceptable tradeoff for not maintaining a transitive-dep allowlist. + file(GLOB _all_libs "${ARG_SOURCE_DIR}/*.${ARG_EXTENSION}") + if(_all_libs) + install(FILES ${_all_libs} DESTINATION "${ARG_DEST_DIR}") + else() + # An isolated SDK lib/bin dir with zero matching libraries means the SDK + # is missing or not yet expanded — fail rather than ship a libless bundle. + message(FATAL_ERROR + "gstreamer_install_libs: no *.${ARG_EXTENSION} found in '${ARG_SOURCE_DIR}'. " + "The GStreamer SDK must be fully expanded at configure time before install.") + endif() +endfunction() + +function(_gstreamer_windows_runtime_dependency_dirs ROOT_DIR OUT_VAR) + set(_runtime_dirs) + + # giolibproxy.dll loads proxy-1.dll, which loads pxbackend-1.0.dll from + # lib/libproxy in the official Windows SDK. Install those backend DLLs next + # to the executable because startup only prepends the app bin dir to PATH. + foreach(_relative_dir IN ITEMS "lib/libproxy") + set(_candidate_dir "${ROOT_DIR}/${_relative_dir}") + file(GLOB _candidate_dlls "${_candidate_dir}/*.dll") + if(_candidate_dlls) + list(APPEND _runtime_dirs "${_candidate_dir}") + endif() + endforeach() + + set(${OUT_VAR} "${_runtime_dirs}" PARENT_SCOPE) +endfunction() + +function(_gstreamer_is_blocked_lib_copy_source SOURCE_DIR OUT_VAR) + set(_blocked_prefixes + /usr/lib /usr/local/lib /opt/homebrew/lib /opt/homebrew/opt) + set(_allowed_prefixes) + set(_is_windows_guard ${WIN32}) + if(QGC_GSTREAMER_TEST_WIN32) + set(_is_windows_guard TRUE) + endif() + + if(_is_windows_guard) + list(APPEND _blocked_prefixes + "C:/Windows" "C:/Program Files" "C:/Program Files (x86)") + # The official GStreamer MSVC SDK installs here by default; it is an SDK + # root, not an arbitrary shared system DLL directory. + list(APPEND _allowed_prefixes + "C:/Program Files/gstreamer" "C:/Program Files (x86)/gstreamer") + endif() + + # Normalize case + separators so the guard isn't bypassed by C:\Windows or c:/windows. + cmake_path(SET _src_norm NORMALIZE "${SOURCE_DIR}") + if(_is_windows_guard) + string(TOLOWER "${_src_norm}" _src_norm) + endif() + + foreach(_prefix IN LISTS _allowed_prefixes) + if(_is_windows_guard) + string(TOLOWER "${_prefix}" _prefix) + string(FIND "${_src_norm}/" "${_prefix}/" _is_allowed_pos) + if(_is_allowed_pos EQUAL 0) + set(${OUT_VAR} FALSE PARENT_SCOPE) + return() + endif() + continue() + endif() + cmake_path(IS_PREFIX _prefix "${_src_norm}" NORMALIZE _is_allowed) + if(_is_allowed) + set(${OUT_VAR} FALSE PARENT_SCOPE) + return() + endif() + endforeach() + + foreach(_prefix IN LISTS _blocked_prefixes) + if(_is_windows_guard) + string(TOLOWER "${_prefix}" _prefix) + string(FIND "${_src_norm}/" "${_prefix}/" _is_system_pos) + if(_is_system_pos EQUAL 0) + set(${OUT_VAR} TRUE PARENT_SCOPE) + return() + endif() + continue() + endif() + cmake_path(IS_PREFIX _prefix "${_src_norm}" NORMALIZE _is_system) + if(_is_system) + set(${OUT_VAR} TRUE PARENT_SCOPE) + return() + endif() + endforeach() + set(${OUT_VAR} FALSE PARENT_SCOPE) +endfunction() + +# ───────────────────────────────────────────────────────────────────────────── +# Per-platform install helpers. Each function uses variables set by +# cmake/GStreamer/Orchestrator.cmake (GSTREAMER_*_PATH, GStreamer_ROOT_DIR, +# GStreamer_AUTO_DOWNLOADED, etc.). +# ───────────────────────────────────────────────────────────────────────────── + +function(gstreamer_install_linux_sdk) + gstreamer_install_plugins( + SOURCE_DIR "${GSTREAMER_PLUGIN_PATH}" + DEST_DIR "lib/gstreamer-1.0" + EXTENSION "so" + PREFIX "libgst" + ) + gstreamer_install_gio_modules( + SOURCE_DIR "${GSTREAMER_LIB_PATH}/gio/modules" + DEST_DIR "lib/gio/modules" + EXTENSION "so" + ) + # Helper binary path varies by distro: Debian lib//gstreamer1.0/, + # Fedora libexec/gstreamer-1.0/, Arch lib/gstreamer-1.0/. + set(_gst_helper_search_paths + "${GSTREAMER_LIB_PATH}/gstreamer1.0/gstreamer-1.0" + "${GStreamer_ROOT_DIR}/libexec/gstreamer-1.0" + "${GSTREAMER_PLUGIN_PATH}" + ) + foreach(_helper IN ITEMS gst-plugin-scanner gst-ptp-helper) + string(MAKE_C_IDENTIFIER "_GST_${_helper}_PROG" _helper_var) + find_program(${_helper_var} ${_helper} + PATHS ${_gst_helper_search_paths} NO_DEFAULT_PATH) + if(${_helper_var}) + install(PROGRAMS "${${_helper_var}}" + DESTINATION "lib/gstreamer1.0/gstreamer-1.0") + elseif(_helper STREQUAL "gst-plugin-scanner") + message(WARNING "gst-plugin-scanner not found; AppImage video may not work") + endif() + endforeach() +endfunction() + +function(gstreamer_install_windows_sdk) + gstreamer_install_libs( + SOURCE_DIR "${GStreamer_ROOT_DIR}/bin" + DEST_DIR "${CMAKE_INSTALL_BINDIR}" + EXTENSION "dll" + ) + _gstreamer_windows_runtime_dependency_dirs("${GStreamer_ROOT_DIR}" _gst_win_runtime_dependency_dirs) + foreach(_dependency_dir IN LISTS _gst_win_runtime_dependency_dirs) + gstreamer_install_libs( + SOURCE_DIR "${_dependency_dir}" + DEST_DIR "${CMAKE_INSTALL_BINDIR}" + EXTENSION "dll" + ) + endforeach() + gstreamer_install_gio_modules( + SOURCE_DIR "${GSTREAMER_LIB_PATH}/gio/modules" + DEST_DIR "${CMAKE_INSTALL_LIBDIR}/gio/modules" + EXTENSION "dll" + ) + gstreamer_install_plugins( + SOURCE_DIR "${GSTREAMER_PLUGIN_PATH}" + DEST_DIR "${CMAKE_INSTALL_LIBDIR}/gstreamer-1.0" + EXTENSION "dll" + PREFIX "gst" + ) + install( + DIRECTORY "${GStreamer_ROOT_DIR}/libexec/gstreamer-1.0/" + DESTINATION "${CMAKE_INSTALL_LIBEXECDIR}/gstreamer-1.0" + FILE_PERMISSIONS + OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE + FILES_MATCHING + PATTERN "*.exe" + ) + # CA bundle for gioopenssl.dll — OpenSSL's compiled-in default path is + # relative to the SDK root; ship the bundle so HTTPS/RTSPS sources can + # verify certs after install (paired with SSL_CERT_FILE wiring at startup). + if(EXISTS "${GStreamer_ROOT_DIR}/etc/ssl/certs/ca-certificates.crt") + install( + FILES "${GStreamer_ROOT_DIR}/etc/ssl/certs/ca-certificates.crt" + DESTINATION etc/ssl/certs + ) + endif() +endfunction() + +# Stage GStreamer.framework into the app bundle's Contents/Frameworks/. Used +# when the SDK is consumed via the Cerbero macOS framework (Layout target +# stashed FRAMEWORK_BUNDLE). +function(gstreamer_install_macos_framework_sdk PROJECT_NAME) + gstreamer_layout_get(FRAMEWORK_BUNDLE _gst_framework_bundle) + if(NOT _gst_framework_bundle) + message(FATAL_ERROR "gstreamer_install_macos_framework_sdk: FRAMEWORK_BUNDLE not set on GStreamer::Layout") + endif() + get_filename_component(_gst_framework_real "${_gst_framework_bundle}" REALPATH) + set(_gst_fw_dest "${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}.app/Contents/Frameworks/GStreamer.framework") + + if(NOT _gst_fw_dest MATCHES "\\.app/Contents/Frameworks/GStreamer\\.framework$") + message(FATAL_ERROR "gstreamer_install_macos_framework_sdk: refusing REMOVE_RECURSE on unexpected dest '${_gst_fw_dest}'") + endif() + install(CODE "file(REMOVE_RECURSE \"${_gst_fw_dest}\")") + install( + DIRECTORY "${_gst_framework_real}/Versions/1.0/" + DESTINATION "${_gst_fw_dest}/Versions/1.0" + USE_SOURCE_PERMISSIONS + PATTERN "*.la" EXCLUDE + PATTERN "*.a" EXCLUDE + PATTERN "*/bin" EXCLUDE + PATTERN "*/gst-validate-launcher" EXCLUDE + PATTERN "*/Headers" EXCLUDE + PATTERN "*/include" EXCLUDE + PATTERN "*/pkgconfig" EXCLUDE + PATTERN "*/share/aclocal" EXCLUDE + PATTERN "*/share/bash-completion" EXCLUDE + PATTERN "*/share/gdb" EXCLUDE + PATTERN "*/share/gst-android" EXCLUDE + PATTERN "*/share/gtk-doc" EXCLUDE + PATTERN "*/share/installed-tests" EXCLUDE + PATTERN "*/share/locale" EXCLUDE + PATTERN "*/share/man" EXCLUDE + PATTERN "*gstpython*" EXCLUDE + PATTERN "Commands" EXCLUDE + REGEX ".*/lib/gstreamer-1.0/libgst.*" EXCLUDE + ) + + install(CODE " + set(_fw \"${_gst_fw_dest}\") + if(NOT EXISTS \"\${_fw}/Versions/1.0/GStreamer\") + message(FATAL_ERROR \"GStreamer framework: Versions/1.0/GStreamer not found — SDK layout may have changed\") + endif() + execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink 1.0 \"\${_fw}/Versions/Current\") + execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink Versions/Current/GStreamer \"\${_fw}/GStreamer\") + if(IS_DIRECTORY \"\${_fw}/Versions/1.0/Resources\") + execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink Versions/Current/Resources \"\${_fw}/Resources\") + endif() + ") + + gstreamer_install_plugins( + SOURCE_DIR "${_gst_framework_real}/Versions/1.0/lib/gstreamer-1.0" + DEST_DIR "${_gst_fw_dest}/Versions/1.0/lib/gstreamer-1.0" + EXTENSION "dylib" + PREFIX "libgst" + ) +endfunction() + +# Stage flat-layout dylibs/plugins/gio modules + rpath fixups + helpers + CA +# bundle. Used for Homebrew/CPM-downloaded macOS GStreamer (no framework wrapper). +function(gstreamer_install_macos_flat_sdk PROJECT_NAME) + set(_mac_fw_dest "${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}.app/Contents/Frameworks") + set(_mac_lib_dest "${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}.app/Contents/lib") + set(_mac_libexec_dest "${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}.app/Contents/libexec/gstreamer-1.0") + + if(GStreamer_AUTO_DOWNLOADED) + gstreamer_install_libs( + SOURCE_DIR "${GSTREAMER_LIB_PATH}" + DEST_DIR "${_mac_fw_dest}" + EXTENSION "dylib" + ) + else() + file(GLOB _gst_libs + "${GSTREAMER_LIB_PATH}/libgst*.dylib" + "${GSTREAMER_LIB_PATH}/libglib*.dylib" + "${GSTREAMER_LIB_PATH}/libgobject*.dylib" + "${GSTREAMER_LIB_PATH}/libgmodule*.dylib" + "${GSTREAMER_LIB_PATH}/libgthread*.dylib" + "${GSTREAMER_LIB_PATH}/libgio*.dylib" + "${GSTREAMER_LIB_PATH}/libintl*.dylib" + "${GSTREAMER_LIB_PATH}/liborc*.dylib" + "${GSTREAMER_LIB_PATH}/libffi*.dylib" + "${GSTREAMER_LIB_PATH}/libpcre2*.dylib" + "${GSTREAMER_LIB_PATH}/libgraphene*.dylib" + "${GSTREAMER_LIB_PATH}/libssl*.dylib" + "${GSTREAMER_LIB_PATH}/libcrypto*.dylib" + ) + if(_gst_libs) + install(FILES ${_gst_libs} DESTINATION "${_mac_fw_dest}") + else() + message(FATAL_ERROR + "gstreamer_install_macos_flat_sdk: no GStreamer/GLib dylibs found in " + "'${GSTREAMER_LIB_PATH}'. The SDK lib dir must be populated at configure " + "time before install.") + endif() + endif() + gstreamer_install_plugins( + SOURCE_DIR "${GSTREAMER_PLUGIN_PATH}" + DEST_DIR "${_mac_lib_dest}/gstreamer-1.0" + EXTENSION "dylib" + PREFIX "libgst" + ) + gstreamer_install_gio_modules( + SOURCE_DIR "${GSTREAMER_LIB_PATH}/gio/modules" + DEST_DIR "${_mac_lib_dest}/gio/modules" + EXTENSION "dylib" + ) + + foreach(_rpath_dir IN ITEMS + "${_mac_lib_dest}/gstreamer-1.0:@loader_path/../../Frameworks" + "${_mac_lib_dest}/gio/modules:@loader_path/../../../Frameworks" + "${_mac_libexec_dest}:@loader_path/../../Frameworks" + ) + string(REPLACE ":" ";" _parts "${_rpath_dir}") + list(GET _parts 0 _dir) + list(GET _parts 1 _rpath) + install(CODE " + file(GLOB _libs \"${_dir}/*.dylib\") + foreach(_lib \${_libs}) + execute_process( + COMMAND otool -l \"\${_lib}\" + OUTPUT_VARIABLE _otool_out + RESULT_VARIABLE _otool_rc + ERROR_VARIABLE _otool_err + ) + if(NOT _otool_rc EQUAL 0) + # otool failed: empty output would make the FIND below wrongly + # conclude 'rpath absent' and add a duplicate — skip and warn. + message(WARNING \"GStreamer: otool -l failed for \${_lib} (rc=\${_otool_rc}): \${_otool_err}; skipping rpath fixup\") + continue() + endif() + string(FIND \"\${_otool_out}\" \"${_rpath}\" _has_rpath) + if(_has_rpath EQUAL -1) + execute_process( + COMMAND install_name_tool -add_rpath \"${_rpath}\" \"\${_lib}\" + RESULT_VARIABLE _int_rc + ERROR_VARIABLE _int_err + ) + if(NOT _int_rc EQUAL 0) + message(WARNING \"GStreamer: install_name_tool -add_rpath failed for \${_lib}: \${_int_err}\") + endif() + endif() + endforeach() + ") + endforeach() + + if(EXISTS "${GStreamer_ROOT_DIR}/libexec/gstreamer-1.0") + install( + DIRECTORY "${GStreamer_ROOT_DIR}/libexec/gstreamer-1.0/" + DESTINATION "${_mac_libexec_dest}" + FILE_PERMISSIONS + OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE + FILES_MATCHING + PATTERN "gst-plugin-scanner" + PATTERN "gst-ptp-helper" + ) + endif() + + # CA bundle for libgioopenssl.so — Cerbero's compiled-in OpenSSL trust + # path doesn't exist on a user's machine; ship the bundle so the + # SSL_CERT_FILE wiring at startup can point gioopenssl at it. + if(EXISTS "${GStreamer_ROOT_DIR}/etc/ssl/certs/ca-certificates.crt") + install( + FILES "${GStreamer_ROOT_DIR}/etc/ssl/certs/ca-certificates.crt" + DESTINATION "${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}.app/Contents/Resources/etc/ssl/certs" + ) + endif() +endfunction() + +# Dispatch to the framework or flat installer based on whether the Layout +# target stashed FRAMEWORK_BUNDLE (set when GStreamer.framework is in use). +function(gstreamer_install_macos_sdk PROJECT_NAME) + gstreamer_layout_get(FRAMEWORK_BUNDLE _gst_framework_bundle) + if(_gst_framework_bundle) + gstreamer_install_macos_framework_sdk("${PROJECT_NAME}") + else() + gstreamer_install_macos_flat_sdk("${PROJECT_NAME}") + endif() +endfunction() + +# Top-level dispatcher — call this from CMakeLists.txt after the ANDROID/IOS guard. +# PROJECT_NAME is CMAKE_PROJECT_NAME in the caller; passed explicitly to avoid +# macro-scope shadowing issues inside functions. +# gstreamer_install_platform_sdk(PROJECT_NAME [REQUIRED_PLUGINS ]) +# REQUIRED_PLUGINS: list of plugin basenames (no platform prefix or extension) to +# verify exist post-install; default is the minimum needed to run any pipeline. +function(gstreamer_install_platform_sdk PROJECT_NAME) + cmake_parse_arguments(GIPS "" "" "REQUIRED_PLUGINS" ${ARGN}) + if(NOT GIPS_REQUIRED_PLUGINS) + gstreamer_runtime_required_plugins(GIPS_REQUIRED_PLUGINS) + endif() + + # Recover the SDK dirs from the GLOBAL Layout target when the Orchestrator's + # PARENT_SCOPE propagation didn't reach this scope — otherwise the per-platform + # installers below silently glob empty dirs. Locals here inherit into them. + if(NOT GSTREAMER_LIB_PATH) + gstreamer_layout_get(LIB_DIR GSTREAMER_LIB_PATH) + endif() + if(NOT GSTREAMER_PLUGIN_PATH) + gstreamer_layout_get(PLUGIN_DIR GSTREAMER_PLUGIN_PATH) + endif() + if(NOT GSTREAMER_INCLUDE_PATH) + gstreamer_layout_get(INCLUDE_DIR GSTREAMER_INCLUDE_PATH) + endif() + + if(LINUX) + gstreamer_install_linux_sdk() + elseif(WIN32) + gstreamer_install_windows_sdk() + elseif(MACOS) + gstreamer_install_macos_sdk("${PROJECT_NAME}") + endif() + + # Post-install plugin verification (all desktop platforms). + gstreamer_platform_plugin_attrs(_verify_ext _verify_prefix _verify_glob) + if(WIN32) + set(_verify_dest "${CMAKE_INSTALL_LIBDIR}/gstreamer-1.0") + elseif(MACOS) + gstreamer_layout_get(FRAMEWORK_BUNDLE _verify_fw) + if(_verify_fw) + set(_verify_dest "${PROJECT_NAME}.app/Contents/Frameworks/GStreamer.framework/Versions/1.0/lib/gstreamer-1.0") + else() + set(_verify_dest "${PROJECT_NAME}.app/Contents/lib/gstreamer-1.0") + endif() + elseif(LINUX) + set(_verify_dest "lib/gstreamer-1.0") + endif() + + if(_verify_dest) + # Build an OR-of-AND existence test per required plugin so an alternate + # set (videoconvert+videoscale on 1.20) satisfies the fused name + # (videoconvertscale on 1.22+) instead of tripping the verifier. + set(_verify_blocks "") + foreach(_p IN LISTS GIPS_REQUIRED_PLUGINS) + gstreamer_plugin_satisfy_sets(PLUGIN "${_p}" OUT_VAR _sets) + set(_checks "") + set(_alt_labels "") + foreach(_set IN LISTS _sets) + string(REPLACE "+" ";" _members "${_set}") + set(_conds "") + foreach(_m IN LISTS _members) + # Honor DESTDIR: CPack stages into $ENV{DESTDIR}, so this check must + # prepend it like install(FILES) does, or DEB/RPM/pacman packaging false-fails. + list(APPEND _conds "EXISTS \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${_verify_dest}/${_verify_prefix}${_m}.${_verify_ext}\"") + endforeach() + string(JOIN " AND " _and_cond ${_conds}) + string(APPEND _checks " if(${_and_cond})\n set(_ok TRUE)\n endif()\n") + list(APPEND _alt_labels "${_set}") + endforeach() + string(JOIN " or " _plugin_label ${_alt_labels}) + string(APPEND _verify_blocks " set(_ok FALSE)\n${_checks} if(NOT _ok)\n list(APPEND _missing_plugins \"${_plugin_label}\")\n endif()\n") + endforeach() + + install(CODE " + set(_missing_plugins) +${_verify_blocks} + if(_missing_plugins) + list(JOIN _missing_plugins \", \" _missing_list) + message(FATAL_ERROR \"GStreamer install verification: missing required plugins in ${_verify_dest}: \${_missing_list} — built bundle cannot run any pipeline\") + else() + message(STATUS \"GStreamer install verification: all required plugins present in ${_verify_dest}\") + endif() + ") + endif() +endfunction() diff --git a/cmake/GStreamer/Json.cmake b/cmake/GStreamer/Json.cmake new file mode 100644 index 000000000000..8ff55169df31 --- /dev/null +++ b/cmake/GStreamer/Json.cmake @@ -0,0 +1,27 @@ +# JSON helpers for build-config.json access. + +# Read a JSON array at the given path into a CMake list. Empty if path absent. +# Usage: _qgc_json_array_to_list(OUT JSON_TEXT key1 key2 …) +function(_qgc_json_array_to_list OUTPUT_VAR JSON_TEXT) + string(JSON _count ERROR_VARIABLE _err LENGTH "${JSON_TEXT}" ${ARGN}) + set(_result "") + if(NOT _err AND _count GREATER 0) + math(EXPR _last "${_count} - 1") + foreach(_i RANGE 0 ${_last}) + string(JSON _v ERROR_VARIABLE _get_err GET "${JSON_TEXT}" ${ARGN} ${_i}) + if(_get_err) + # Name the path+index so a non-scalar element fails clearly. + message(FATAL_ERROR "_qgc_json_array_to_list: failed to read element " + "at index ${_i} of path '${ARGN}': ${_get_err}") + endif() + # Empty list elements vanish in CMake (list(APPEND x "") is a no-op), + # which would silently desync length/index semantics downstream. + if(_v STREQUAL "") + message(FATAL_ERROR "_qgc_json_array_to_list: empty array element at " + "index ${_i} of path '${ARGN}' is unsupported.") + endif() + list(APPEND _result "${_v}") + endforeach() + endif() + set(${OUTPUT_VAR} "${_result}" PARENT_SCOPE) +endfunction() diff --git a/cmake/GStreamer/Layout.cmake b/cmake/GStreamer/Layout.cmake new file mode 100644 index 000000000000..19991bcfdacc --- /dev/null +++ b/cmake/GStreamer/Layout.cmake @@ -0,0 +1,155 @@ +# SDK layout target — captures the on-disk shape of the GStreamer SDK +# (FLAT / FRAMEWORK / XCFRAMEWORK / STATIC_TARBALL) as a single IMPORTED +# INTERFACE target with properties, so consumers can read GSTREAMER_*_DIR +# without depending on PARENT_SCOPE propagation chains. +# +# Properties on GStreamer::Layout (single source of truth): +# GSTREAMER_LAYOUT_TYPE - FLAT | FRAMEWORK | XCFRAMEWORK | STATIC_TARBALL +# GSTREAMER_LIB_DIR - directory containing libgstreamer-1.0.* and friends +# GSTREAMER_PLUGIN_DIR - directory containing libgst.* (== LIB_DIR/gstreamer-1.0 for FLAT) +# GSTREAMER_INCLUDE_DIR - root of gstreamer-1.0/ + glib-2.0/ headers +# GSTREAMER_FRAMEWORK_BUNDLE- discovered macOS GStreamer.framework path (set by overlay, optional) +# GSTREAMER_XCFRAMEWORK_PATH- iOS GStreamer.xcframework path (set when TYPE=XCFRAMEWORK) +# +# Use gstreamer_layout_get( ) / gstreamer_layout_set( ) +# to read/write so callers don't replicate the NOTFOUND-handling boilerplate. + +# gstreamer_create_layout_target(SDK_ROOT TYPE +# [INCLUDE_PATH ] [FRAMEWORK_BUNDLE ] [XCFRAMEWORK_BUNDLE ]) +# TYPE: FLAT | FRAMEWORK | XCFRAMEWORK | STATIC_TARBALL +# Sets GSTREAMER_LIB_PATH, GSTREAMER_PLUGIN_PATH, GSTREAMER_INCLUDE_PATH, +# GSTREAMER_FRAMEWORK_PATH (FRAMEWORK only), GSTREAMER_XCFRAMEWORK_PATH (XCFRAMEWORK only) +# in PARENT_SCOPE and creates/updates IMPORTED INTERFACE target GStreamer::Layout. +# +# The FRAMEWORK_BUNDLE / XCFRAMEWORK_BUNDLE keyword names are deliberately +# distinct from their TYPE values — cmake_parse_arguments treats keyword strings +# as reserved at parse time, so reusing "FRAMEWORK" or "XCFRAMEWORK" both as a +# TYPE value and as a sibling keyword would make `TYPE FRAMEWORK FRAMEWORK +# ` ambiguous (TYPE's value gets eaten by the keyword recognizer). +function(gstreamer_create_layout_target) + cmake_parse_arguments(GCLT "" "SDK_ROOT;TYPE;LIB_PATH;INCLUDE_PATH;FRAMEWORK_BUNDLE;XCFRAMEWORK_BUNDLE" "" ${ARGN}) + + if(NOT GCLT_SDK_ROOT) + message(FATAL_ERROR "gstreamer_create_layout_target: SDK_ROOT is required") + endif() + if(NOT GCLT_TYPE) + message(FATAL_ERROR "gstreamer_create_layout_target: TYPE is required") + endif() + + cmake_path(CONVERT "${GCLT_SDK_ROOT}" TO_CMAKE_PATH_LIST _gclt_root NORMALIZE) + if(NOT EXISTS "${_gclt_root}") + message(FATAL_ERROR "GStreamer: SDK not found at '${_gclt_root}' — " + "check installation or set GStreamer_ROOT_DIR") + endif() + set(GStreamer_ROOT_DIR "${_gclt_root}" PARENT_SCOPE) + + if(GCLT_TYPE STREQUAL "XCFRAMEWORK") + # Slice dir IS the lib root; no lib/ subdir exists. + set(_gclt_lib "${_gclt_root}") + set(_gclt_plugins "${_gclt_root}") + set(_gclt_include "${GCLT_INCLUDE_PATH}") + if(NOT _gclt_include) + set(_gclt_include "${_gclt_root}/Headers") + endif() + if(NOT EXISTS "${_gclt_root}") + message(FATAL_ERROR "GStreamer: xcframework slice dir not found: ${_gclt_root}") + endif() + if(GCLT_XCFRAMEWORK_BUNDLE AND NOT EXISTS "${GCLT_XCFRAMEWORK_BUNDLE}") + message(FATAL_ERROR "GStreamer: xcframework not found at ${GCLT_XCFRAMEWORK_BUNDLE}") + endif() + set(GSTREAMER_XCFRAMEWORK_PATH "${GCLT_XCFRAMEWORK_BUNDLE}" PARENT_SCOPE) + elseif(GCLT_TYPE STREQUAL "FRAMEWORK") + set(_gclt_lib "${_gclt_root}/lib") + set(_gclt_plugins "${_gclt_root}/lib/gstreamer-1.0") + if(GCLT_INCLUDE_PATH) + set(_gclt_include "${GCLT_INCLUDE_PATH}") + else() + set(_gclt_include "${_gclt_root}/include") + endif() + foreach(_p IN ITEMS "${_gclt_root}" "${_gclt_lib}" "${_gclt_include}") + if(NOT EXISTS "${_p}") + message(FATAL_ERROR "GStreamer (FRAMEWORK): required path does not exist: ${_p}") + endif() + endforeach() + if(GCLT_FRAMEWORK_BUNDLE) + set(GSTREAMER_FRAMEWORK_PATH "${GCLT_FRAMEWORK_BUNDLE}" PARENT_SCOPE) + endif() + elseif(GCLT_TYPE STREQUAL "FLAT" OR GCLT_TYPE STREQUAL "STATIC_TARBALL") + if(GCLT_LIB_PATH) + set(_gclt_lib "${GCLT_LIB_PATH}") + else() + set(_gclt_lib "${_gclt_root}/lib") + endif() + set(_gclt_plugins "${_gclt_lib}/gstreamer-1.0") + if(GCLT_INCLUDE_PATH) + set(_gclt_include "${GCLT_INCLUDE_PATH}") + else() + set(_gclt_include "${_gclt_root}/include") + endif() + foreach(_p IN ITEMS "${_gclt_root}" "${_gclt_lib}" "${_gclt_include}") + if(NOT EXISTS "${_p}") + message(FATAL_ERROR "GStreamer (${GCLT_TYPE}): required path does not exist: ${_p}") + endif() + endforeach() + else() + message(FATAL_ERROR "gstreamer_create_layout_target: unknown TYPE \'${GCLT_TYPE}\' — must be FLAT, FRAMEWORK, XCFRAMEWORK, or STATIC_TARBALL") + endif() + + set(GSTREAMER_LIB_PATH "${_gclt_lib}" PARENT_SCOPE) + set(GSTREAMER_PLUGIN_PATH "${_gclt_plugins}" PARENT_SCOPE) + set(GSTREAMER_INCLUDE_PATH "${_gclt_include}" PARENT_SCOPE) + + # Target creation is not scriptable (cmake -P) — guard so cmake -P unit tests + # can exercise scalar-path computation without a full configure context. + if(NOT CMAKE_SCRIPT_MODE_FILE) + if(NOT TARGET GStreamer::Layout) + add_library(GStreamer::Layout INTERFACE IMPORTED GLOBAL) + endif() + set_target_properties(GStreamer::Layout PROPERTIES + GSTREAMER_LAYOUT_TYPE "${GCLT_TYPE}" + GSTREAMER_LIB_DIR "${_gclt_lib}" + GSTREAMER_PLUGIN_DIR "${_gclt_plugins}" + GSTREAMER_INCLUDE_DIR "${_gclt_include}" + ) + if(GCLT_TYPE STREQUAL "XCFRAMEWORK" AND GCLT_XCFRAMEWORK_BUNDLE) + set_target_properties(GStreamer::Layout PROPERTIES + GSTREAMER_XCFRAMEWORK_PATH "${GCLT_XCFRAMEWORK_BUNDLE}") + endif() + if(GCLT_TYPE STREQUAL "FRAMEWORK" AND GCLT_FRAMEWORK_BUNDLE) + set_target_properties(GStreamer::Layout PROPERTIES + GSTREAMER_FRAMEWORK_BUNDLE "${GCLT_FRAMEWORK_BUNDLE}") + endif() + endif() +endfunction() + +# gstreamer_layout_get( ) +# Read a layout property. Returns empty string when the target doesn't exist +# or the property isn't set, so callers can skip the NOTFOUND-handling dance. +# KEY is the bare name (e.g. FRAMEWORK_BUNDLE); the GSTREAMER_ prefix is added +# automatically. +function(gstreamer_layout_get KEY OUT_VAR) + if(NOT TARGET GStreamer::Layout) + set(${OUT_VAR} "" PARENT_SCOPE) + return() + endif() + get_target_property(_val GStreamer::Layout "GSTREAMER_${KEY}") + if(_val STREQUAL "_val-NOTFOUND" OR NOT _val) + set(${OUT_VAR} "" PARENT_SCOPE) + else() + set(${OUT_VAR} "${_val}" PARENT_SCOPE) + endif() +endfunction() + +# gstreamer_layout_set( ) +# Write a layout property. KEY is the bare name (e.g. FRAMEWORK_BUNDLE); the +# GSTREAMER_ prefix is added automatically. Skipped silently in cmake -P +# script mode (target creation isn't scriptable). +function(gstreamer_layout_set KEY VALUE) + if(CMAKE_SCRIPT_MODE_FILE) + return() + endif() + if(NOT TARGET GStreamer::Layout) + message(FATAL_ERROR "gstreamer_layout_set: GStreamer::Layout target does not exist; call gstreamer_create_layout_target first.") + endif() + set_target_properties(GStreamer::Layout PROPERTIES "GSTREAMER_${KEY}" "${VALUE}") +endfunction() diff --git a/cmake/GStreamer/Link.cmake b/cmake/GStreamer/Link.cmake new file mode 100644 index 000000000000..5f3bf3475724 --- /dev/null +++ b/cmake/GStreamer/Link.cmake @@ -0,0 +1,248 @@ +# Library resolution and link-time helpers (find-suffix juggling, system +# library passthrough, SRT regex patching, hidden-symbol linking). + +# Shared list of system libraries that should be linked by name, not resolved via find_library. +# Used by both FindGStreamer.cmake and the Android mobile-target macro. +if(NOT DEFINED _gst_IGNORED_SYSTEM_LIBRARIES) + set(_gst_IGNORED_SYSTEM_LIBRARIES c c++ unwind m dl atomic) + if(ANDROID) + list(APPEND _gst_IGNORED_SYSTEM_LIBRARIES log GLESv2 EGL OpenSLES android vulkan) + elseif(APPLE) + list(APPEND _gst_IGNORED_SYSTEM_LIBRARIES iconv resolv System) + elseif(WIN32) + list(APPEND _gst_IGNORED_SYSTEM_LIBRARIES + ws2_32 ole32 oleaut32 winmm shlwapi secur32 iphlpapi dnsapi + userenv bcrypt crypt32 advapi32 kernel32 shell32 uuid) + endif() +endif() +if(NOT DEFINED _gst_SRT_REGEX_PATCH) + set(_gst_SRT_REGEX_PATCH "^:lib(.+)\\.(a|so|lib|dylib)$") +endif() + +# CMake's FindPkgConfig can split pkg-config paths containing escaped spaces +# (for example `C:/Program\ Files/...`) into multiple list items. Rejoin adjacent +# tokens when they form a real path, preserving unresolved tokens so callers can +# still warn or fall back normally. +function(_gst_coalesce_existing_paths _gcep_VAR) + set(_gcep_in ${${_gcep_VAR}}) + set(_gcep_out "") + list(LENGTH _gcep_in _gcep_len) + set(_gcep_i 0) + while(_gcep_i LESS _gcep_len) + list(GET _gcep_in ${_gcep_i} _gcep_candidate) + set(_gcep_joined "${_gcep_candidate}") + set(_gcep_found FALSE) + if(EXISTS "${_gcep_joined}") + set(_gcep_found TRUE) + else() + math(EXPR _gcep_j "${_gcep_i} + 1") + while(_gcep_j LESS _gcep_len) + list(GET _gcep_in ${_gcep_j} _gcep_next) + string(APPEND _gcep_joined " ${_gcep_next}") + if(EXISTS "${_gcep_joined}") + set(_gcep_i ${_gcep_j}) + set(_gcep_found TRUE) + break() + endif() + math(EXPR _gcep_j "${_gcep_j} + 1") + endwhile() + endif() + + if(_gcep_found) + list(APPEND _gcep_out "${_gcep_joined}") + else() + list(APPEND _gcep_out "${_gcep_candidate}") + endif() + math(EXPR _gcep_i "${_gcep_i} + 1") + endwhile() + set(${_gcep_VAR} "${_gcep_out}" PARENT_SCOPE) +endfunction() + +# Some Windows pkg-config builds split `C:/Program\ Files/...` across different +# FindPkgConfig output variables: the path list gets `C:/Program`, while the +# suffixes land in CFLAGS_OTHER/LDFLAGS_OTHER as `Files/...`. Rebuild paths only +# when root + suffix exists, then remove the consumed suffixes from options. +function(_gst_recover_split_pkgconfig_paths _grspp_PREFIX) + set(_grspp_pairs ${ARGN}) + list(LENGTH _grspp_pairs _grspp_len) + math(EXPR _grspp_remainder "${_grspp_len} % 2") + if(_grspp_remainder) + message(FATAL_ERROR "_gst_recover_split_pkgconfig_paths requires path/option suffix pairs") + endif() + + set(_grspp_i 0) + while(_grspp_i LESS _grspp_len) + list(GET _grspp_pairs ${_grspp_i} _grspp_path_suffix) + math(EXPR _grspp_next_i "${_grspp_i} + 1") + list(GET _grspp_pairs ${_grspp_next_i} _grspp_option_suffix) + + set(_grspp_path_var "${_grspp_PREFIX}_${_grspp_path_suffix}") + set(_grspp_option_var "${_grspp_PREFIX}_${_grspp_option_suffix}") + if(DEFINED ${_grspp_path_var} AND DEFINED ${_grspp_option_var}) + set(_grspp_paths ${${_grspp_path_var}}) + set(_grspp_options ${${_grspp_option_var}}) + _gst_coalesce_existing_paths(_grspp_paths) + + set(_grspp_recovered_paths "") + set(_grspp_consumed_options "") + foreach(_grspp_path IN LISTS _grspp_paths) + if(EXISTS "${_grspp_path}") + list(APPEND _grspp_recovered_paths "${_grspp_path}") + continue() + endif() + + set(_grspp_recovered FALSE) + foreach(_grspp_option IN LISTS _grspp_options) + if(_grspp_option MATCHES "^-") + continue() + endif() + set(_grspp_joined "${_grspp_path} ${_grspp_option}") + if(EXISTS "${_grspp_joined}") + list(APPEND _grspp_recovered_paths "${_grspp_joined}") + list(APPEND _grspp_consumed_options "${_grspp_option}") + set(_grspp_recovered TRUE) + endif() + endforeach() + if(NOT _grspp_recovered) + list(APPEND _grspp_recovered_paths "${_grspp_path}") + endif() + endforeach() + + list(REMOVE_DUPLICATES _grspp_recovered_paths) + set(_grspp_filtered_options "") + foreach(_grspp_option IN LISTS _grspp_options) + if(NOT _grspp_option IN_LIST _grspp_consumed_options) + list(APPEND _grspp_filtered_options "${_grspp_option}") + endif() + endforeach() + + set(${_grspp_path_var} "${_grspp_recovered_paths}" PARENT_SCOPE) + set(${_grspp_option_var} "${_grspp_filtered_options}" PARENT_SCOPE) + endif() + + math(EXPR _grspp_i "${_grspp_i} + 2") + endwhile() +endfunction() + +# Strip link libs that the prebuilt macOS GStreamer distribution references but does not ship. +# gstreamer-gl-1.0.pc carries gstvulkan-1.0 in Libs.private, yet no libgstvulkan-1.0 exists on +# macOS — and these names reach INTERFACE_LINK_LIBRARIES verbatim (the shared-lib path bypasses +# _gst_resolve_and_link_libraries), so the bare token hits the linker as 'library not found'. +# Operates on the named list variable in the caller's scope; no-op off APPLE. +function(_gst_strip_macos_absent_link_libs _gsmal_VAR) + if(NOT APPLE) + return() + endif() + set(_gsmal_absent gstvulkan-1.0 gstvulkan) + set(_gsmal_out "") + foreach(_gsmal_lib IN LISTS ${_gsmal_VAR}) + if(NOT _gsmal_lib IN_LIST _gsmal_absent) + list(APPEND _gsmal_out "${_gsmal_lib}") + endif() + endforeach() + set(${_gsmal_VAR} "${_gsmal_out}" PARENT_SCOPE) +endfunction() + +# Save/restore macros for CMAKE_FIND_LIBRARY_SUFFIXES/PREFIXES. +# Used by FindGStreamer.cmake and the Android mobile-target macro when resolving static libs. +macro(_gst_save_find_suffixes) + set(_gst_saved_suffixes ${CMAKE_FIND_LIBRARY_SUFFIXES}) + set(_gst_saved_prefixes ${CMAKE_FIND_LIBRARY_PREFIXES}) + set(CMAKE_FIND_LIBRARY_PREFIXES "" "lib") + if(GStreamer_USE_STATIC_LIBS) + set(CMAKE_FIND_LIBRARY_SUFFIXES ".a") + elseif(APPLE) + set(CMAKE_FIND_LIBRARY_SUFFIXES ".a" ".dylib" ".so" ".tbd") + elseif(UNIX) + set(CMAKE_FIND_LIBRARY_SUFFIXES ".a" ".so") + else() + set(CMAKE_FIND_LIBRARY_SUFFIXES ".a" ".lib") + endif() +endmacro() + +macro(_gst_restore_find_suffixes) + set(CMAKE_FIND_LIBRARY_SUFFIXES ${_gst_saved_suffixes}) + set(CMAKE_FIND_LIBRARY_PREFIXES ${_gst_saved_prefixes}) +endmacro() + +# _gst_resolve_and_link_libraries( [HIDE] [WARN_MISSING]) +# +# Resolves a list of library names via find_library and links them to TARGET with the given SCOPE +# (PRIVATE, INTERFACE, or PUBLIC). Handles SRT regex patching and system library passthrough. +# +# HIDE - Use -hidden-l (Apple) or --exclude-libs (Unix) to hide symbols +# WARN_MISSING - Warn and skip missing libraries instead of failing +function(_gst_resolve_and_link_libraries _grll_TARGET _grll_SCOPE _grll_LIBS _grll_HINTS) + cmake_parse_arguments(_grll "HIDE;WARN_MISSING" "" "" ${ARGN}) + _gst_coalesce_existing_paths(_grll_HINTS) + + if(_grll_HIDE AND APPLE) + target_link_directories(${_grll_TARGET} ${_grll_SCOPE} ${_grll_HINTS}) + endif() + + _gst_save_find_suffixes() + + foreach(_grll_LIB IN LISTS _grll_LIBS) + if(_grll_LIB MATCHES "${_gst_SRT_REGEX_PATCH}") + string(REGEX REPLACE "${_gst_SRT_REGEX_PATCH}" "\\1" _grll_LIB "${_grll_LIB}") + endif() + + if("${_grll_LIB}" IN_LIST _gst_IGNORED_SYSTEM_LIBRARIES) + target_link_libraries(${_grll_TARGET} ${_grll_SCOPE} "${_grll_LIB}") + continue() + endif() + + # ABI/arch must be part of the cache key — Android builds multiple ABIs in + # one tree and would otherwise reuse the first ABI's resolved .so/.a path. + set(_grll_ABI_TAG "") + if(ANDROID AND CMAKE_ANDROID_ARCH_ABI) + set(_grll_ABI_TAG "${CMAKE_ANDROID_ARCH_ABI}_") + endif() + string(MAKE_C_IDENTIFIER "_gst_${_grll_ABI_TAG}${_grll_LIB}" _grll_CACHE_VAR) + if(DEFINED ${_grll_CACHE_VAR} AND "${${_grll_CACHE_VAR}}" MATCHES "NOTFOUND$") + unset(${_grll_CACHE_VAR} CACHE) + endif() + if(NOT DEFINED ${_grll_CACHE_VAR} OR "${${_grll_CACHE_VAR}}" MATCHES "NOTFOUND$") + if(_grll_WARN_MISSING) + find_library(${_grll_CACHE_VAR} NAMES ${_grll_LIB} HINTS ${_grll_HINTS} NO_DEFAULT_PATH) + else() + find_library(${_grll_CACHE_VAR} NAMES ${_grll_LIB} HINTS ${_grll_HINTS} + NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH REQUIRED) + endif() + endif() + + if(NOT ${_grll_CACHE_VAR}) + if(_grll_WARN_MISSING) + message(WARNING "GStreamer: Library '${_grll_LIB}' not found in ${_grll_HINTS}, skipping") + endif() + continue() + endif() + + if(_grll_HIDE AND APPLE) + get_filename_component(_grll_NAME_WE "${${_grll_CACHE_VAR}}" NAME_WE) + string(REGEX REPLACE "^lib" "" _grll_NAME_WE "${_grll_NAME_WE}") + target_link_libraries(${_grll_TARGET} ${_grll_SCOPE} "-hidden-l${_grll_NAME_WE}") + elseif(_grll_HIDE AND (UNIX OR ANDROID)) + # --exclude-libs keys on the archive basename, not the resolved full path. + get_filename_component(_grll_EXCL_NAME "${${_grll_CACHE_VAR}}" NAME) + target_link_libraries(${_grll_TARGET} ${_grll_SCOPE} -Wl,--exclude-libs,${_grll_EXCL_NAME}) + else() + target_link_libraries(${_grll_TARGET} ${_grll_SCOPE} "${${_grll_CACHE_VAR}}") + endif() + endforeach() + + _gst_restore_find_suffixes() +endfunction() + +# Emit GST_PLUGIN_STATIC_DECLARE(...) / QGC_REGISTER_STATIC_PLUGIN(...) blocks for +# the gst_static_plugins.c.in template. Shared by the iOS and desktop-static backends. +function(_gst_emit_static_plugin_registration PLUGIN_LIST DECL_OUT REG_OUT) + set(_decl "") + set(_reg "") + foreach(_p IN LISTS ${PLUGIN_LIST}) + string(APPEND _decl "GST_PLUGIN_STATIC_DECLARE(${_p});\n") + string(APPEND _reg " QGC_REGISTER_STATIC_PLUGIN(${_p});\n") + endforeach() + set(${DECL_OUT} "${_decl}" PARENT_SCOPE) + set(${REG_OUT} "${_reg}" PARENT_SCOPE) +endfunction() diff --git a/cmake/GStreamer/Orchestrator.cmake b/cmake/GStreamer/Orchestrator.cmake new file mode 100644 index 000000000000..f85072831567 --- /dev/null +++ b/cmake/GStreamer/Orchestrator.cmake @@ -0,0 +1,381 @@ +include("${CMAKE_CURRENT_LIST_DIR}/Helpers.cmake") + +if(NOT DEFINED GStreamer_FIND_VERSION) + if(LINUX AND NOT ANDROID) + set(GStreamer_FIND_VERSION ${QGC_CONFIG_GSTREAMER_MIN_VERSION}) + elseif(WIN32 AND NOT ANDROID) + set(GStreamer_FIND_VERSION ${QGC_CONFIG_GSTREAMER_WIN_VERSION}) + elseif(ANDROID) + set(GStreamer_FIND_VERSION ${QGC_CONFIG_GSTREAMER_ANDROID_VERSION}) + elseif(IOS) + set(GStreamer_FIND_VERSION ${QGC_CONFIG_GSTREAMER_IOS_VERSION}) + elseif(MACOS) + set(GStreamer_FIND_VERSION ${QGC_CONFIG_GSTREAMER_MACOS_VERSION}) + else() + # Defense-in-depth: every supported platform is covered above; this + # fires only if a new platform bool is added without a version branch. + message(WARNING "GStreamer: unrecognized platform — using fallback version ${QGC_CONFIG_GSTREAMER_VERSION}") + set(GStreamer_FIND_VERSION ${QGC_CONFIG_GSTREAMER_VERSION}) + endif() +endif() + +if(NOT GStreamer_FIND_VERSION) + message(FATAL_ERROR "GStreamer version not configured. Ensure BuildConfig.cmake has been included " + "and .github/build-config.json contains the appropriate gstreamer.version. entry.") +endif() + +if(NOT DEFINED GStreamer_ROOT_DIR) + if(DEFINED GSTREAMER_ROOT) + set(GStreamer_ROOT_DIR ${GSTREAMER_ROOT}) + elseif(DEFINED GStreamer_ROOT) + set(GStreamer_ROOT_DIR ${GStreamer_ROOT}) + endif() + + if(DEFINED GStreamer_ROOT_DIR AND NOT EXISTS "${GStreamer_ROOT_DIR}") + message(FATAL_ERROR "GStreamer: User-provided directory does not exist: ${GStreamer_ROOT_DIR}\n" + "Correct the path or unset GStreamer_ROOT_DIR to allow auto-download.") + endif() +endif() + +# CONTRACT: include() at directory scope, never inside a function — the layout +# flags below are directory-scope normal vars (if(NOT DEFINED)-guarded for override). +if(NOT DEFINED GStreamer_USE_STATIC_LIBS) + if(ANDROID OR IOS) + set(GStreamer_USE_STATIC_LIBS ON) + else() + set(GStreamer_USE_STATIC_LIBS OFF) + endif() +endif() + +if(NOT DEFINED GStreamer_USE_FRAMEWORK) + if(APPLE) + set(GStreamer_USE_FRAMEWORK ON) + else() + set(GStreamer_USE_FRAMEWORK OFF) + endif() +endif() + +# xcframework is the iOS-only layout; platforms override this to ON only when they +# confirm a GStreamer.xcframework path exists (see platform/IOS.cmake). +if(NOT DEFINED GStreamer_USE_XCFRAMEWORK) + set(GStreamer_USE_XCFRAMEWORK OFF) +endif() + +# User-supplied PKG_CONFIG_ARGN must survive reconfigures; only seed an empty default. +set(PKG_CONFIG_ARGN "" CACHE STRING "Extra arguments for pkg-config") +set(GStreamer_AUTO_DOWNLOADED FALSE) + +# Per-platform discovery macros — extracted to keep this file focused on orchestration. +# _qgc_validate_expanded_pkg lives in platform/Apple.cmake (used only by Mac+iOS). +include("${CMAKE_CURRENT_LIST_DIR}/platform/Apple.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/platform/Windows.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/platform/Linux.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/platform/Android.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/platform/MacOS.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/platform/IOS.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/platform/PkgConfigTargets.cmake") + +# Dispatch to the appropriate platform discovery macro +if(WIN32 AND NOT ANDROID) + _qgc_discover_windows_sdk() +elseif(LINUX AND NOT ANDROID) + _qgc_discover_linux_sdk() +elseif(ANDROID) + _qgc_discover_android_sdk() +elseif(MACOS AND NOT IOS) + _qgc_discover_macos_sdk() +elseif(IOS) + _qgc_discover_ios_sdk() +endif() + +# Post-dispatch invariant: at most one of the three layout modes may be ON. +set(_qgc_gst_active_modes) +if(GStreamer_USE_XCFRAMEWORK) + list(APPEND _qgc_gst_active_modes GStreamer_USE_XCFRAMEWORK) +endif() +if(GStreamer_USE_FRAMEWORK) + list(APPEND _qgc_gst_active_modes GStreamer_USE_FRAMEWORK) +endif() +if(GStreamer_USE_STATIC_LIBS) + list(APPEND _qgc_gst_active_modes GStreamer_USE_STATIC_LIBS) +endif() +list(LENGTH _qgc_gst_active_modes _qgc_gst_active_count) +if(_qgc_gst_active_count GREATER 1) + message(FATAL_ERROR + "GStreamer: conflicting layout modes — only one of GStreamer_USE_XCFRAMEWORK, " + "GStreamer_USE_FRAMEWORK, GStreamer_USE_STATIC_LIBS may be ON simultaneously. " + "Active: ${_qgc_gst_active_modes}") +endif() +unset(_qgc_gst_active_modes) +unset(_qgc_gst_active_count) + +# xcframework sets GSTREAMER_LIB_PATH / GSTREAMER_PLUGIN_PATH to the slice dir +# (which has no lib/ subdir), so the existence checks still pass. +if(NOT GStreamer_USE_XCFRAMEWORK) + set(_gst_missing_paths) + if(NOT EXISTS "${GStreamer_ROOT_DIR}") + list(APPEND _gst_missing_paths "GStreamer_ROOT_DIR=${GStreamer_ROOT_DIR}") + endif() + if(NOT EXISTS "${GSTREAMER_LIB_PATH}") + list(APPEND _gst_missing_paths "GSTREAMER_LIB_PATH=${GSTREAMER_LIB_PATH}") + endif() + if(NOT EXISTS "${GSTREAMER_PLUGIN_PATH}") + list(APPEND _gst_missing_paths "GSTREAMER_PLUGIN_PATH=${GSTREAMER_PLUGIN_PATH}") + endif() + if(NOT EXISTS "${GSTREAMER_INCLUDE_PATH}") + list(APPEND _gst_missing_paths "GSTREAMER_INCLUDE_PATH=${GSTREAMER_INCLUDE_PATH}") + endif() + if(_gst_missing_paths) + string(REPLACE ";" "\n " _gst_missing_str "${_gst_missing_paths}") + message(FATAL_ERROR + "GStreamer: required directories do not exist on disk:\n ${_gst_missing_str}\n" + "GSTREAMER_FRAMEWORK_PATH=${GSTREAMER_FRAMEWORK_PATH}\n" + "Check installation or set GStreamer_ROOT_DIR.") + endif() + + if(GStreamer_USE_FRAMEWORK AND NOT EXISTS "${GSTREAMER_FRAMEWORK_PATH}") + message(FATAL_ERROR "GStreamer: Could not locate framework at ${GSTREAMER_FRAMEWORK_PATH}") + endif() +else() + if(NOT EXISTS "${GSTREAMER_XCFRAMEWORK_LIB}") + message(FATAL_ERROR "GStreamer: xcframework library not found at ${GSTREAMER_XCFRAMEWORK_LIB}") + endif() +endif() + +# Always recompute from the current component set — a stale list from a prior +# configure would miss a newly-requested component. +gstreamer_build_apis_and_deps(GSTREAMER_APIS GSTREAMER_EXTRA_DEPS ${QGCGStreamer_FIND_COMPONENTS}) + +# Plugin list lives in .github/build-config.json. Alternate groups (e.g. the +# videoconvertscale↔videoconvert+videoscale 1.22 split) are owned by +# GStreamerPluginPolicy and applied below in the missing-plugin scan. +# Always recompute (see GSTREAMER_APIS above) — never carry a stale plugin list +# across reconfigures. +gstreamer_current_platform_key(_qgc_gst_plat_key) +gstreamer_plugins_for(PLATFORM "${_qgc_gst_plat_key}" OUT_VAR GSTREAMER_PLUGINS) +unset(_qgc_gst_plat_key) + +if(ANDROID) + set(GStreamer_Mobile_MODULE_NAME gstreamer_android) + set(G_IO_MODULES openssl) + set(G_IO_MODULES_PATH "${GStreamer_ROOT_DIR}/lib/gio/modules") + set(GStreamer_NDK_BUILD_PATH "${GStreamer_ROOT_DIR}/share/gst-android/ndk-build") + if(QT_IS_ANDROID_MULTI_ABI_EXTERNAL_PROJECT AND DEFINED QT_INTERNAL_ANDROID_MULTI_ABI_BINARY_DIR) + set(_gst_android_build_base "${QT_INTERNAL_ANDROID_MULTI_ABI_BINARY_DIR}") + else() + set(_gst_android_build_base "${CMAKE_BINARY_DIR}") + endif() + if(QT_USE_TARGET_ANDROID_BUILD_DIR) + set(_gst_android_build_dir "${_gst_android_build_base}/android-build-${CMAKE_PROJECT_NAME}") + else() + set(_gst_android_build_dir "${_gst_android_build_base}/android-build") + endif() + set(GStreamer_JAVA_SRC_DIR "${_gst_android_build_dir}/src") + set(GStreamer_ASSETS_DIR "${_gst_android_build_dir}/assets") +elseif(IOS) + # xcframework bundles GIO modules and assets into libGStreamer.a — no separate paths needed. + set(GStreamer_Mobile_MODULE_NAME gstreamer_mobile) + set(GStreamer_ASSETS_DIR "${CMAKE_BINARY_DIR}/assets") +endif() + +if(GStreamer_USE_FRAMEWORK) + list(APPEND CMAKE_FRAMEWORK_PATH "${GSTREAMER_FRAMEWORK_PATH}") +endif() + +# Create GStreamer::* IMPORTED targets via the appropriate platform helper. +if(GStreamer_USE_XCFRAMEWORK) + _qgc_create_xcframework_targets() +else() + _qgc_create_pkgconfig_targets() +endif() + +# Mark mandatory components found unconditionally — drives the registry +# (GSTREAMER_COMPONENT_REGISTRY in cmake/GStreamer/Components.cmake), so adding +# a new always-present API doesn't require editing this file. +gstreamer_mandatory_components(_qgc_gst_mandatory _qgc_gst_mandatory_apis) +foreach(_comp IN LISTS _qgc_gst_mandatory) + set(QGCGStreamer_${_comp}_FOUND TRUE) + set(GStreamer_${_comp}_FOUND TRUE) +endforeach() +unset(_qgc_gst_mandatory) +unset(_qgc_gst_mandatory_apis) +# User-requested optional components: mark FOUND only if the corresponding +# api_ target was actually created by find_package(GStreamer). +foreach(_comp IN LISTS QGCGStreamer_FIND_COMPONENTS) + gstreamer_resolve_component("${_comp}" _resolved_name _api _ _) + # A registry match (non-empty resolved name) includes umbrella entries like + # Core whose api/pc are intentionally empty; keying off _api alone mis-flags + # Core as a typo. Derive a fallback api / flag unknown only when nothing matched. + if(_resolved_name OR _api) + set(_registry_known TRUE) + else() + set(_registry_known FALSE) + gstreamer_component_to_api("${_comp}" _api) + endif() + # Require the api to be one we actually built, not just any same-named + # imported target, before reporting the component FOUND. + if(TARGET GStreamer::${_api} AND _api IN_LIST GSTREAMER_APIS) + set(QGCGStreamer_${_comp}_FOUND TRUE) + set(GStreamer_${_comp}_FOUND TRUE) + elseif(NOT _registry_known AND NOT TARGET GStreamer::${_api}) + # Neither a registry entry nor a target — almost always a typo'd or + # unsupported component name; surface it loudly. + message(WARNING + "QGCGStreamer: requested component '${_comp}' is not a known registry " + "component and produced no GStreamer::${_api} target — check spelling " + "(components are CamelCase, e.g. App, Audio, Pbutils) or platform support.") + endif() +endforeach() + +if(GStreamer_USE_STATIC_LIBS) + target_compile_definitions(GStreamer::GStreamer INTERFACE QGC_GST_STATIC_BUILD) + if(ANDROID) + # FFmpeg static libs have arch-specific assembly using page-relative + # relocations against global symbols. -Bsymbolic guarantees no symbol + # interposition, making those relocations valid in the final .so. + target_link_options(GStreamer::GStreamer INTERFACE "-Wl,-Bsymbolic") + endif() +endif() + +# Shared-layout only: Android/iOS bake plugins into the static lib/xcframework +# and are NOT runtime-verified (Install.cmake's _verify_dest is desktop-only). +if(NOT GStreamer_USE_STATIC_LIBS AND NOT GStreamer_USE_XCFRAMEWORK AND EXISTS "${GSTREAMER_PLUGIN_PATH}") + gstreamer_scan_plugin_basenames(_gst_available_basenames "${GSTREAMER_PLUGIN_PATH}") +else() + set(_gst_available_basenames "") +endif() + +foreach(plugin IN LISTS GSTREAMER_PLUGINS) + if(TARGET GStreamer::${plugin} OR plugin IN_LIST _gst_available_basenames) + set(GST_PLUGIN_${plugin}_FOUND TRUE) + else() + set(GST_PLUGIN_${plugin}_FOUND FALSE) + endif() +endforeach() + +if(NOT GStreamer_USE_STATIC_LIBS AND NOT GStreamer_USE_XCFRAMEWORK AND EXISTS "${GSTREAMER_PLUGIN_PATH}") + set(_gst_check_plugins "${GSTREAMER_PLUGINS}") + gstreamer_filter_alternates(IN_OUT_PLUGINS _gst_check_plugins AVAILABLE ${_gst_available_basenames}) + set(_gst_missing_plugins) + foreach(_plugin IN LISTS _gst_check_plugins) + if(NOT _plugin IN_LIST _gst_available_basenames) + list(APPEND _gst_missing_plugins "${_plugin}") + endif() + endforeach() + if(_gst_missing_plugins) + message(WARNING "GStreamer: The following plugins are listed in GSTREAMER_PLUGINS " + "but not found in ${GSTREAMER_PLUGIN_PATH}: ${_gst_missing_plugins}\n" + "Video features depending on these plugins will not work at runtime.") + endif() +endif() + +# Resolves ALIAS targets and applies INTERFACE compile definitions — the cycle +# below (and the feature probe further down) need this twice. +# target_compile_definitions can't be called on ALIAS targets, so unwrap first. +function(_qgc_gst_apply_def GST_TARGET) + if(NOT TARGET ${GST_TARGET}) + return() + endif() + get_target_property(_aliased ${GST_TARGET} ALIASED_TARGET) + if(_aliased) + target_compile_definitions(${_aliased} INTERFACE ${ARGN}) + else() + target_compile_definitions(${GST_TARGET} INTERFACE ${ARGN}) + endif() +endfunction() + +# Mobile (iOS/Android) builds consume GStreamerMobile (alias GStreamer::mobile) instead +# of GStreamer::GStreamer, so propagate version defines and feature-test results to both. +if(GStreamer_VERSION) + string(REGEX MATCH "^([0-9]+)\\.([0-9]+)" _gst_ver_match "${GStreamer_VERSION}") + if(_gst_ver_match) + foreach(_gst_target IN ITEMS GStreamer::GStreamer GStreamerMobile) + _qgc_gst_apply_def(${_gst_target} + QGC_GST_BUILD_VERSION_MAJOR=${CMAKE_MATCH_1} + QGC_GST_BUILD_VERSION_MINOR=${CMAKE_MATCH_2} + ) + endforeach() + endif() +endif() + +# GstVideoOrientationMeta is officially in 1.26+, but bundled iOS/Android SDKs sometimes +# strip it. Feature-test the actual header instead of trusting the version number. +foreach(_gst_target IN ITEMS GStreamer::GStreamer GStreamerMobile) + if(TARGET ${_gst_target}) + qgc_check_gst_header( + VAR QGC_GST_HAS_VIDEO_ORIENTATION_META_${_gst_target} + HEADER gst/video/gstvideometa.h + SYMBOL "sizeof(GstVideoOrientationMeta)" + TARGET ${_gst_target} + ) + if(QGC_GST_HAS_VIDEO_ORIENTATION_META_${_gst_target}) + _qgc_gst_apply_def(${_gst_target} QGC_HAS_GST_VIDEO_ORIENTATION_META=1) + endif() + endif() +endforeach() + +# Zero-copy DMABuf GPU path probe — Linux-only, defined in platform/Linux.cmake. +if(LINUX) + _qgc_detect_dmabuf() +endif() + +# One-line diagnostic — surfaces which discovery path won when CI logs are the +# only forensics available. +if(GStreamer_USE_XCFRAMEWORK) + set(_qgc_gst_path "xcframework") +elseif(GStreamer_USE_FRAMEWORK) + set(_qgc_gst_path "framework") +elseif(GStreamer_USE_STATIC_LIBS) + set(_qgc_gst_path "static") +else() + set(_qgc_gst_path "shared") +endif() +list(LENGTH GSTREAMER_PLUGINS _qgc_gst_plugin_total) +if(GStreamer_USE_STATIC_LIBS OR GStreamer_USE_XCFRAMEWORK) + # Static/xcframework register every requested plugin into the generated C by + # construction; there is no per-plugin target or scannable dir to count against. + set(_qgc_gst_plugin_count ${_qgc_gst_plugin_total}) +else() + # Reuse the basenames scanned above instead of re-globbing the same dir. + set(_qgc_gst_plugin_count 0) + foreach(_p IN LISTS GSTREAMER_PLUGINS) + if(TARGET GStreamer::${_p} OR _p IN_LIST _gst_available_basenames) + math(EXPR _qgc_gst_plugin_count "${_qgc_gst_plugin_count}+1") + endif() + endforeach() +endif() +message(STATUS "QGCGStreamer: version=${GStreamer_VERSION} path=${_qgc_gst_path} root=${GStreamer_ROOT_DIR} plugins=${_qgc_gst_plugin_count}/${_qgc_gst_plugin_total} auto_downloaded=${GStreamer_AUTO_DOWNLOADED}") + +# Name the discovery macro that should have set these, for a clear failure. +if(WIN32 AND NOT ANDROID) + set(_qgc_disco_macro "_qgc_discover_windows_sdk (platform/Windows.cmake)") +elseif(LINUX AND NOT ANDROID) + set(_qgc_disco_macro "_qgc_discover_linux_sdk (platform/Linux.cmake)") +elseif(ANDROID) + set(_qgc_disco_macro "_qgc_discover_android_sdk (platform/Android.cmake)") +elseif(IOS) + set(_qgc_disco_macro "_qgc_discover_ios_sdk (platform/IOS.cmake)") +elseif(MACOS) + set(_qgc_disco_macro "_qgc_discover_macos_sdk (platform/MacOS.cmake)") +else() + set(_qgc_disco_macro "the platform discovery dispatch") +endif() +set(_qgc_required_vars GStreamer_ROOT_DIR) +if(NOT GStreamer_USE_XCFRAMEWORK) + list(APPEND _qgc_required_vars GSTREAMER_LIB_PATH GSTREAMER_PLUGIN_PATH) +endif() +foreach(_qgc_req IN LISTS _qgc_required_vars) + if(NOT ${_qgc_req}) + message(FATAL_ERROR "QGCGStreamer: required variable ${_qgc_req} is not set after " + "platform discovery — ${_qgc_disco_macro} failed to set it.") + endif() +endforeach() +unset(_qgc_required_vars) +unset(_qgc_disco_macro) + +# Promote the resolved root into the cache so post-configure tooling +# (.github/scripts/verify_executable.py / cmake_helper.py cache-var) can read it from CMakeCache.txt; +# the discovery macros set it only as a normal/PARENT_SCOPE variable. +set(GStreamer_ROOT_DIR "${GStreamer_ROOT_DIR}" CACHE PATH "GStreamer SDK root directory" FORCE) + +set(QGCGStreamer_FOUND TRUE) diff --git a/cmake/GStreamer/PkgConfig.cmake b/cmake/GStreamer/PkgConfig.cmake new file mode 100644 index 000000000000..797a3284a253 --- /dev/null +++ b/cmake/GStreamer/PkgConfig.cmake @@ -0,0 +1,59 @@ +# Unified pkg-config env management for GStreamer discovery — single mutator +# for PKG_CONFIG_PATH / PKG_CONFIG_LIBDIR / PKG_CONFIG_DONT_DEFINE_PREFIX, +# used by FindGStreamer.cmake (SDK fallback), platform/Linux.cmake +# (system-augment), and platform/PkgConfigTargets.cmake (SDK lock-in for +# Windows/macOS/Android). + +include_guard(GLOBAL) + +# gstreamer_apply_pkgconfig_env( +# MODE +# LIBDIR ... # one or more pkgconfig dirs (semicolon list) +# [PKG_CONFIG_EXE ] # optional explicit pkg-config binary +# [DONT_DEFINE_PREFIX] # appends --dont-define-prefix to PKG_CONFIG_ARGN +# ) +# +# MODE SDK — lock pkg-config to the SDK: PKG_CONFIG_LIBDIR=, +# PKG_CONFIG_PATH cleared. System .pc files invisible. +# MODE SYSTEM_AUGMENT — prepend to PKG_CONFIG_PATH so SDK .pc files +# win but system glib/gobject .pc files stay discoverable. +# Used by Linux distro-installed GStreamer. +function(gstreamer_apply_pkgconfig_env) + cmake_parse_arguments(ARG "DONT_DEFINE_PREFIX" "MODE;PKG_CONFIG_EXE" "LIBDIR" ${ARGN}) + if(NOT ARG_MODE) + message(FATAL_ERROR "gstreamer_apply_pkgconfig_env: MODE is required (SDK or SYSTEM_AUGMENT)") + endif() + if(NOT ARG_LIBDIR) + message(FATAL_ERROR "gstreamer_apply_pkgconfig_env: LIBDIR is required") + endif() + + if(ARG_PKG_CONFIG_EXE) + set(ENV{PKG_CONFIG} "${ARG_PKG_CONFIG_EXE}") + set(PKG_CONFIG_EXECUTABLE "${ARG_PKG_CONFIG_EXE}" CACHE FILEPATH "pkg-config executable" FORCE) + endif() + + if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") + set(_sep ";") + else() + set(_sep ":") + endif() + string(REPLACE ";" "${_sep}" _libdir_str "${ARG_LIBDIR}") + + if(ARG_MODE STREQUAL "SDK") + set(ENV{PKG_CONFIG_PATH} "") + set(ENV{PKG_CONFIG_LIBDIR} "${_libdir_str}") + elseif(ARG_MODE STREQUAL "SYSTEM_AUGMENT") + if(DEFINED ENV{PKG_CONFIG_PATH} AND NOT "$ENV{PKG_CONFIG_PATH}" STREQUAL "") + set(ENV{PKG_CONFIG_PATH} "${_libdir_str}${_sep}$ENV{PKG_CONFIG_PATH}") + else() + set(ENV{PKG_CONFIG_PATH} "${_libdir_str}") + endif() + else() + message(FATAL_ERROR "gstreamer_apply_pkgconfig_env: invalid MODE='${ARG_MODE}' (expected SDK or SYSTEM_AUGMENT)") + endif() + + if(ARG_DONT_DEFINE_PREFIX AND NOT "--dont-define-prefix" IN_LIST PKG_CONFIG_ARGN) + list(APPEND PKG_CONFIG_ARGN --dont-define-prefix) + set(PKG_CONFIG_ARGN "${PKG_CONFIG_ARGN}" PARENT_SCOPE) + endif() +endfunction() diff --git a/cmake/GStreamer/PluginPolicy.cmake b/cmake/GStreamer/PluginPolicy.cmake new file mode 100644 index 000000000000..67b0a5fbd895 --- /dev/null +++ b/cmake/GStreamer/PluginPolicy.cmake @@ -0,0 +1,161 @@ +# Centralised GStreamer plugin policy — single source of truth for plugin +# alternate groups, runtime-required plugins, and xcframework skip list. +# Consumed by Orchestrator (build-config plugin loader / alternate-satisfied +# check), Install (runtime-required verifier), and platform/IOS (xcfw skip). + +include_guard(GLOBAL) + +# Plugin alternate groups — within each group, only one member typically ships +# per SDK (e.g. videoconvertscale is the 1.22 fusion of videoconvert+videoscale). +# Format: each entry is a "|"-separated alternate set; member sub-groups are +# "+"-separated for AND-satisfaction. Example: "videoconvertscale|videoconvert+videoscale" +# means satisfied if videoconvertscale is present OR if both videoconvert AND +# videoscale are present. Plugin basenames must not contain '|' or '+' — those +# are the group/AND delimiters and are not escapable. +set(GSTREAMER_PLUGIN_ALTERNATES + "videoconvertscale|videoconvert+videoscale" +) + +# Structural plugins guaranteed to exist post-install — verifier in +# gstreamer_install_platform_sdk fails the build if any are missing. Codec +# implementations such as openh264/libav are intentionally not listed here: +# distro bundles such as Fedora may omit one implementation while still +# shipping another usable decode path from the configured plugin set. +# opengl supplies glupload/glcolorconvert — load-bearing for the GL zero-copy +# video path; without it the receiver silently degrades to a CPU memcpy. +set(GSTREAMER_RUNTIME_REQUIRED_PLUGINS + coreelements opengl playback rtsp rtp rtpmanager tcp udp videoconvertscale +) + +# iOS xcframework: plugins whose dependent static libs aren't bundled in the +# slice. Auto-registering them would cause unresolved-symbol link failures. +# x265: gstreamer-ios 1.28.2 ships libgstx265.a but not libx265.a. +set(GSTREAMER_XCFRAMEWORK_SKIP_PLUGINS + x265 +) + +# Invariant: no plugin may be both runtime-required and xcframework-skipped. +function(_gstreamer_assert_policy_consistent) + foreach(_skip IN LISTS GSTREAMER_XCFRAMEWORK_SKIP_PLUGINS) + if(_skip IN_LIST GSTREAMER_RUNTIME_REQUIRED_PLUGINS) + message(FATAL_ERROR + "GStreamer policy: plugin '${_skip}' is in both GSTREAMER_XCFRAMEWORK_SKIP_PLUGINS " + "and GSTREAMER_RUNTIME_REQUIRED_PLUGINS — a required plugin cannot be skipped on iOS.") + endif() + endforeach() +endfunction() +_gstreamer_assert_policy_consistent() + +# gstreamer_plugins_for(PLATFORM OUT_VAR ) +# Reads the plugin list for ("android"|"apple"|"windows"|"linux"|"") from +# build-config.json (gstreamer.plugins.common + gstreamer.plugins.). +# Requires QGC_BUILD_CONFIG_CONTENT to be set by BuildConfig.cmake. +function(gstreamer_plugins_for) + cmake_parse_arguments(ARG "" "PLATFORM;OUT_VAR" "" ${ARGN}) + if(NOT ARG_OUT_VAR) + message(FATAL_ERROR "gstreamer_plugins_for: OUT_VAR is required") + endif() + if(NOT DEFINED QGC_BUILD_CONFIG_CONTENT) + message(FATAL_ERROR "gstreamer_plugins_for: build-config.json not loaded; " + "expected QGC_BUILD_CONFIG_CONTENT to be set by BuildConfig.cmake.") + endif() + _qgc_json_array_to_list(_plugins "${QGC_BUILD_CONFIG_CONTENT}" gstreamer plugins common) + if(ARG_PLATFORM) + _qgc_json_array_to_list(_extra "${QGC_BUILD_CONFIG_CONTENT}" gstreamer plugins ${ARG_PLATFORM}) + list(APPEND _plugins ${_extra}) + endif() + set(${ARG_OUT_VAR} "${_plugins}" PARENT_SCOPE) +endfunction() + +# gstreamer_current_platform_key(OUT_VAR) +# Maps the current CMake platform booleans to the build-config.json key. +function(gstreamer_current_platform_key OUT_VAR) + if(ANDROID) + set(_key android) + elseif(APPLE) + set(_key apple) + elseif(WIN32) + set(_key windows) + elseif(LINUX) + set(_key linux) + else() + set(_key "") + endif() + set(${OUT_VAR} "${_key}" PARENT_SCOPE) +endfunction() + +# gstreamer_filter_alternates(IN_OUT_PLUGINS AVAILABLE ) +# Removes plugins belonging to satisfied alternate groups from the requested +# list, suppressing false missing-plugin warnings when only one member of an +# alternate set ships in the SDK. Modifies the variable named by IN_OUT_PLUGINS +# in the caller's scope. +function(gstreamer_filter_alternates) + cmake_parse_arguments(ARG "" "IN_OUT_PLUGINS" "AVAILABLE" ${ARGN}) + if(NOT ARG_IN_OUT_PLUGINS) + message(FATAL_ERROR "gstreamer_filter_alternates: IN_OUT_PLUGINS is required") + endif() + set(_plugins "${${ARG_IN_OUT_PLUGINS}}") + foreach(_group IN LISTS GSTREAMER_PLUGIN_ALTERNATES) + string(REPLACE "|" ";" _alts "${_group}") + set(_satisfied FALSE) + set(_all_members "") + foreach(_alt IN LISTS _alts) + string(REPLACE "+" ";" _members "${_alt}") + list(APPEND _all_members ${_members}) + set(_alt_ok TRUE) + foreach(_m IN LISTS _members) + if(NOT _m IN_LIST ARG_AVAILABLE) + set(_alt_ok FALSE) + break() + endif() + endforeach() + if(_alt_ok) + set(_satisfied TRUE) + endif() + endforeach() + if(_satisfied) + # Remove only the absent members of the satisfied group — these are the + # ones that would raise false missing-plugin warnings. Present members + # already pass the on-disk scan, so leaving them is harmless and keeps + # an explicitly-requested basename visible if it actually shipped. + list(REMOVE_DUPLICATES _all_members) + foreach(_m IN LISTS _all_members) + if(NOT _m IN_LIST ARG_AVAILABLE) + list(REMOVE_ITEM _plugins "${_m}") + endif() + endforeach() + endif() + endforeach() + set(${ARG_IN_OUT_PLUGINS} "${_plugins}" PARENT_SCOPE) +endfunction() + +# gstreamer_runtime_required_plugins(OUT_VAR) +function(gstreamer_runtime_required_plugins OUT_VAR) + set(${OUT_VAR} "${GSTREAMER_RUNTIME_REQUIRED_PLUGINS}" PARENT_SCOPE) +endfunction() + +# gstreamer_plugin_satisfy_sets(PLUGIN OUT_VAR ) +# Returns the alternate-satisfaction sets for : a ";"-list where each entry +# is a "+"-joined set of plugin basenames that together satisfy . If +# belongs to an alternate group, returns that group's alternatives; otherwise just +# "". Lets the install verifier accept videoconvert+videoscale (1.20) in +# place of the fused videoconvertscale (1.22+) instead of failing the build. +function(gstreamer_plugin_satisfy_sets) + cmake_parse_arguments(ARG "" "PLUGIN;OUT_VAR" "" ${ARGN}) + foreach(_group IN LISTS GSTREAMER_PLUGIN_ALTERNATES) + string(REPLACE "|" ";" _alts "${_group}") + foreach(_alt IN LISTS _alts) + string(REPLACE "+" ";" _members "${_alt}") + if(ARG_PLUGIN IN_LIST _members) + set(${ARG_OUT_VAR} "${_alts}" PARENT_SCOPE) + return() + endif() + endforeach() + endforeach() + set(${ARG_OUT_VAR} "${ARG_PLUGIN}" PARENT_SCOPE) +endfunction() + +# gstreamer_xcfw_skip(OUT_VAR) +function(gstreamer_xcfw_skip OUT_VAR) + set(${OUT_VAR} "${GSTREAMER_XCFRAMEWORK_SKIP_PLUGINS}" PARENT_SCOPE) +endfunction() diff --git a/cmake/GStreamer/Probe.cmake b/cmake/GStreamer/Probe.cmake new file mode 100644 index 000000000000..91602c49a323 --- /dev/null +++ b/cmake/GStreamer/Probe.cmake @@ -0,0 +1,105 @@ +# Feature-test helpers for GStreamer headers/symbols — wraps +# check_cxx_source_compiles for callers that need to gate features on a header +# or symbol being present (HwBuffers, Orchestrator, Linux DMABuf probe). +# +# Also hosts gst-plugins-bad D3D11/D3D12 SDK shim resolution +# (`_qgc_probe_gst_d3d_path`) — pkg-config supplies include/lib dirs, then +# find_library resolves the absolute import lib (the bare pkg-config name doesn't +# reliably land on the MSVC link path). Probing only: source registration is done +# by the caller (HwBuffers) so the d3d/ source paths resolve against HwBuffers' +# CMAKE_CURRENT_SOURCE_DIR, not this module's. + +include_guard(GLOBAL) +include(CheckCXXSourceCompiles) +include(CMakePushCheckState) + +# qgc_check_gst_header(VAR HEADER SYMBOL [TARGET ] [INCLUDES ]) +# Compile-tests that
can be included and referenced when +# linked against (defaults to GStreamer::GStreamer, falling back +# to GStreamerMobile on Android where that's the primary target). +# Result is set in OUT_VAR (TRUE/FALSE in PARENT_SCOPE) and cached so the test +# isn't re-run on incremental reconfigures (check_cxx_source_compiles caches by +# VAR name). The cache is sticky: a result from a stale toolchain/SDK survives +# until the CMake cache is cleared — clear it after changing the GStreamer SDK. +function(qgc_check_gst_header) + cmake_parse_arguments(ARG "" "VAR;HEADER;SYMBOL;TARGET" "INCLUDES" ${ARGN}) + foreach(_req IN ITEMS VAR HEADER SYMBOL) + if(NOT ARG_${_req}) + message(FATAL_ERROR "qgc_check_gst_header: ${_req} is required") + endif() + endforeach() + + if(NOT ARG_TARGET) + if(ANDROID AND TARGET GStreamerMobile) + set(ARG_TARGET GStreamerMobile) + else() + set(ARG_TARGET GStreamer::GStreamer) + endif() + endif() + + cmake_push_check_state(RESET) + set(CMAKE_REQUIRED_LIBRARIES ${ARG_TARGET}) + # INCLUDES: extra dirs for headers that pull SDK deps not on the default path (e.g. gst/cuda/gstcuda.h -> cuda.h). + set(CMAKE_REQUIRED_INCLUDES ${ARG_INCLUDES}) + set(CMAKE_REQUIRED_QUIET TRUE) + check_cxx_source_compiles(" + #include <${ARG_HEADER}> + int main() { (void)${ARG_SYMBOL}; return 0; } + " ${ARG_VAR}) + cmake_pop_check_state() + set(${ARG_VAR} "${${ARG_VAR}}" PARENT_SCOPE) +endfunction() + +# _qgc_probe_gst_d3d_path(<11|12> ) +# gst_is_d3d{N}_memory exports live in gstd3d{N}-1.0.lib (gst-plugins-bad shared +# helper); pkg-config supplies the search dirs, find_library resolves the absolute +# import lib used for linking. Probing only — on success sets in PARENT_SCOPE: +# _FOUND TRUE, _LIBS, _INCLUDE_DIRS, +# _LIBRARY_DIRS. +# The caller registers the sources (their d3d/ paths must resolve against the +# caller's CMAKE_CURRENT_SOURCE_DIR, not this module's). +function(_qgc_probe_gst_d3d_path VERSION OUT) + set(${OUT}_FOUND FALSE PARENT_SCOPE) + set(_pc_var "PC_GStreamer_D3D${VERSION}") + qgc_check_gst_header( + VAR QGC_GST_HAS_D3D${VERSION} + HEADER gst/d3d${VERSION}/gstd3d${VERSION}.h + SYMBOL gst_is_d3d${VERSION}_memory) + set(QGC_GST_HAS_D3D${VERSION} "${QGC_GST_HAS_D3D${VERSION}}" PARENT_SCOPE) + if(NOT QGC_GST_HAS_D3D${VERSION} OR NOT TARGET Qt6::MultimediaPrivate OR NOT TARGET Qt6::GuiPrivate) + return() + endif() + + if(PkgConfig_FOUND) + pkg_check_modules(${_pc_var} QUIET gstreamer-d3d${VERSION}-1.0) + _gst_recover_split_pkgconfig_paths(${_pc_var} + INCLUDE_DIRS CFLAGS_OTHER + LIBRARY_DIRS LDFLAGS_OTHER + ) + _gst_coalesce_existing_paths(${_pc_var}_INCLUDE_DIRS) + _gst_coalesce_existing_paths(${_pc_var}_LIBRARY_DIRS) + endif() + + # Always resolve the absolute import-lib path and link THAT, not the bare + # pkg-config name: on Windows the gstreamer-d3d${VERSION}-1.0.pc libdir doesn't put + # gstd3d${VERSION}-1.0.lib on the MSVC link path, so the bare `gstd3d${VERSION}-1.0` + # fails at link (LNK1181). find_library locates the lib via the .pc's dirs or + # GSTREAMER_LIB_PATH; linking the absolute path sidesteps the -L mismatch. + if(DEFINED GST_D3D${VERSION}_LIB AND + (GST_D3D${VERSION}_LIB MATCHES "NOTFOUND$" OR NOT EXISTS "${GST_D3D${VERSION}_LIB}")) + unset(GST_D3D${VERSION}_LIB CACHE) + endif() + find_library(GST_D3D${VERSION}_LIB NAMES gstd3d${VERSION}-1.0 gstd3d${VERSION} + HINTS ${${_pc_var}_LIBRARY_DIRS} "${GSTREAMER_LIB_PATH}") + if(NOT GST_D3D${VERSION}_LIB) + message(STATUS "QGCGStreamer: gstd3d${VERSION}-1.0 not on link path - D3D${VERSION} GPU path disabled") + return() + endif() + set(${_pc_var}_LIBRARIES "${GST_D3D${VERSION}_LIB}") + + set(${OUT}_FOUND TRUE PARENT_SCOPE) + set(${OUT}_LIBS "d3d${VERSION};${${_pc_var}_LIBRARIES}" PARENT_SCOPE) + set(${OUT}_INCLUDE_DIRS "${${_pc_var}_INCLUDE_DIRS}" PARENT_SCOPE) + set(${OUT}_LIBRARY_DIRS "${${_pc_var}_LIBRARY_DIRS}" PARENT_SCOPE) + set(${OUT}_PC_LIBRARIES "${${_pc_var}_LIBRARIES}" PARENT_SCOPE) +endfunction() diff --git a/cmake/GStreamer/platform/Android.cmake b/cmake/GStreamer/platform/Android.cmake new file mode 100644 index 000000000000..7b1dfb9cf2f7 --- /dev/null +++ b/cmake/GStreamer/platform/Android.cmake @@ -0,0 +1,531 @@ +# Android GStreamer SDK discovery — invoked by Orchestrator.cmake. + +if(_qgc_gstreamer_android_included) + return() +endif() +set(_qgc_gstreamer_android_included TRUE) + +# Single source of truth for Android ABI → cerbero dir + textrel/bsymbolic flags. +# Sets, in PARENT_SCOPE: _DIR, _NEEDS_TEXTREL_ERROR, _NEEDS_BSYMBOLIC_FIX. +function(_qgc_android_abi_info ABI PFX) + if(ABI STREQUAL "armeabi-v7a") + set(_dir "armv7") + set(_textrel TRUE) + set(_bsym TRUE) + elseif(ABI STREQUAL "arm64-v8a") + set(_dir "arm64") + set(_textrel FALSE) + set(_bsym FALSE) + elseif(ABI STREQUAL "x86") + set(_dir "x86") + set(_textrel TRUE) + set(_bsym TRUE) + elseif(ABI STREQUAL "x86_64") + set(_dir "x86_64") + set(_textrel FALSE) + set(_bsym TRUE) + else() + message(FATAL_ERROR "Unsupported Android ABI: ${ABI}") + endif() + set(${PFX}_DIR "${_dir}" PARENT_SCOPE) + set(${PFX}_NEEDS_TEXTREL_ERROR "${_textrel}" PARENT_SCOPE) + set(${PFX}_NEEDS_BSYMBOLIC_FIX "${_bsym}" PARENT_SCOPE) +endfunction() + +macro(_qgc_discover_android_sdk) + if(NOT DEFINED GStreamer_ROOT_DIR) + if(CPM_SOURCE_CACHE) + set(_gst_android_cache "${CPM_SOURCE_CACHE}/gstreamer-android") + else() + set(_gst_android_cache "${CMAKE_BINARY_DIR}/_deps/gstreamer-android") + endif() + gstreamer_download_sdk(android ${GStreamer_FIND_VERSION} + "gstreamer-android-${GStreamer_FIND_VERSION}.tar.xz" "${_gst_android_cache}" _gst_android_archive) + + CPMAddPackage( + NAME gstreamer + VERSION ${GStreamer_FIND_VERSION} + URL "file://${_gst_android_archive}" + ) + + _qgc_android_abi_info("${CMAKE_ANDROID_ARCH_ABI}" GStreamer_ABI) + set(GStreamer_ROOT_DIR "${gstreamer_SOURCE_DIR}/${GStreamer_ABI_DIR}") + set(GStreamer_AUTO_DOWNLOADED TRUE) + endif() + + gstreamer_create_layout_target( + SDK_ROOT "${GStreamer_ROOT_DIR}" + TYPE STATIC_TARBALL + ) + + if(CMAKE_HOST_WIN32) + gstreamer_apply_pkgconfig_env( + MODE SDK + PKG_CONFIG_EXE "${GStreamer_ROOT_DIR}/share/gst-android/ndk-build/tools/windows/pkg-config.exe" + LIBDIR "${GSTREAMER_LIB_PATH}/pkgconfig" "${GSTREAMER_PLUGIN_PATH}/pkgconfig" + DONT_DEFINE_PREFIX + ) + elseif(CMAKE_HOST_UNIX) + if(CMAKE_HOST_APPLE) + _qgc_find_apple_pkg_config(PKG_CONFIG_EXECUTABLE) + endif() + gstreamer_apply_pkgconfig_env( + MODE SDK + LIBDIR "${GSTREAMER_LIB_PATH}/pkgconfig" "${GSTREAMER_PLUGIN_PATH}/pkgconfig" + ) + endif() +endmacro() + +# ───────────────────────────────────────────────────────────────────────────── +# _qgc_create_android_mobile_target +# Build the libgstreamer_android.so wrapper, register static plugins via the +# shared gst_static_plugins.c.in template, copy fonts/CA assets into the APK. +# Caller must set GStreamerMobile_FIND_COMPONENTS (the COMPONENTS list a +# find_package-style call would have set). +# Macro (not function): GStreamerMobile target / GStreamer_*_FOUND / cache +# variables must propagate to caller scope. +# ───────────────────────────────────────────────────────────────────────────── +# Wrap each item of INPUT_LIST as MACRO_NAME(item), join, return via OUT_VAR. +function(_gst_generate_macro_list INPUT_LIST MACRO_NAME OUT_VAR) + list(TRANSFORM ${INPUT_LIST} PREPEND "\n${MACRO_NAME}\(" OUTPUT_VARIABLE _result) + list(TRANSFORM _result APPEND "\)") + if(_result) + set(_result "${_result};") + endif() + set(${OUT_VAR} "${_result}" PARENT_SCOPE) +endfunction() + +# Pure-computation helper — no add_library / target_* calls. +# Reads GStreamer* globals set by the parent scope; returns results via OUT params. +function(_qgc_compute_android_mobile_target + PLUGINS_OUT APIS_OUT FOUND_COMPONENTS_OUT + WA_LIBS_OUT PLUGINS_DECL_OUT PLUGINS_REG_OUT + GIO_DECL_OUT GIO_LOAD_OUT +) + set(_gst_plugins ${GStreamerMobile_FIND_COMPONENTS}) + list(REMOVE_ITEM _gst_plugins fonts ca_certificates mobile) + list(REMOVE_DUPLICATES _gst_plugins) + + set(_gst_mobile_plugins ${_gst_plugins}) + list(FILTER _gst_mobile_plugins EXCLUDE REGEX "^api_") + set(_gst_mobile_apis ${_gst_plugins}) + list(FILTER _gst_mobile_apis INCLUDE REGEX "^api_") + + if(GStreamer_DEBUG) + message(STATUS "[GstMobile] Requested plugins: ${_gst_mobile_plugins}") + message(STATUS "[GstMobile] Requested APIs: ${_gst_mobile_apis}") + endif() + + # Resolve each plugin to a static .a; drop those without one. + _gst_save_find_suffixes() + set(_gst_found_plugins) + foreach(_p IN LISTS _gst_mobile_plugins) + if(GStreamer_${_p}_FOUND) + find_library(_gst_plugin_lib_${CMAKE_ANDROID_ARCH_ABI}_${_p} gst${_p} + HINTS "${GStreamer_ROOT_DIR}/lib/gstreamer-1.0" + NO_DEFAULT_PATH + ) + if(NOT _gst_plugin_lib_${CMAKE_ANDROID_ARCH_ABI}_${_p}) + message(STATUS "GStreamerMobile: Plugin '${_p}' has pkg-config but no static library, excluding from init") + continue() + endif() + list(APPEND _gst_found_plugins ${_p}) + else() + message(STATUS "GStreamerMobile: Plugin '${_p}' not found in SDK, excluding from init") + endif() + endforeach() + _gst_restore_find_suffixes() + set(_gst_mobile_plugins ${_gst_found_plugins}) + + if(GStreamer_DEBUG) + message(STATUS "[GstMobile] Found plugins with .a: ${_gst_mobile_plugins}") + foreach(_dbg_p IN LISTS _gst_mobile_plugins) + message(STATUS "[GstMobile] ${_dbg_p} -> ${_gst_plugin_lib_${CMAKE_ANDROID_ARCH_ABI}_${_dbg_p}}") + endforeach() + endif() + + _gst_generate_macro_list(_gst_mobile_plugins "GST_PLUGIN_STATIC_DECLARE" _decl) + _gst_generate_macro_list(_gst_mobile_plugins "QGC_REGISTER_STATIC_PLUGIN" _reg) + _gst_generate_macro_list(G_IO_MODULES "GST_G_IO_MODULE_DECLARE" _gio_decl) + _gst_generate_macro_list(G_IO_MODULES "GST_G_IO_MODULE_LOAD" _gio_load) + + set(_gst_validate_components) + foreach(_component IN ITEMS mobile ca_certificates fonts) + if(_component IN_LIST GStreamerMobile_FIND_COMPONENTS) + list(APPEND _gst_validate_components ${_component}) + endif() + endforeach() + foreach(_api IN LISTS _gst_mobile_apis) + if(GStreamer_${_api}_FOUND) + list(APPEND _gst_validate_components ${_api}) + endif() + endforeach() + list(APPEND _gst_validate_components ${_gst_mobile_plugins}) + list(REMOVE_DUPLICATES _gst_validate_components) + + # Build whole-archive list from resolved paths. + set(_gst_wa_libs) + foreach(_gst_PLUGIN IN LISTS _gst_mobile_plugins) + if(GStreamer_${_gst_PLUGIN}_FOUND AND _gst_plugin_lib_${CMAKE_ANDROID_ARCH_ABI}_${_gst_PLUGIN}) + list(APPEND _gst_wa_libs "${_gst_plugin_lib_${CMAKE_ANDROID_ARCH_ABI}_${_gst_PLUGIN}}") + endif() + endforeach() + + set(${PLUGINS_OUT} "${_gst_mobile_plugins}" PARENT_SCOPE) + set(${APIS_OUT} "${_gst_mobile_apis}" PARENT_SCOPE) + set(${FOUND_COMPONENTS_OUT} "${_gst_validate_components}" PARENT_SCOPE) + set(${WA_LIBS_OUT} "${_gst_wa_libs}" PARENT_SCOPE) + set(${PLUGINS_DECL_OUT} "${_decl}" PARENT_SCOPE) + set(${PLUGINS_REG_OUT} "${_reg}" PARENT_SCOPE) + set(${GIO_DECL_OUT} "${_gio_decl}" PARENT_SCOPE) + set(${GIO_LOAD_OUT} "${_gio_load}" PARENT_SCOPE) +endfunction() + +macro(_qgc_create_android_mobile_target) + +if(NOT GSTREAMER_LIB_PATH) + set(GSTREAMER_LIB_PATH "${GStreamer_ROOT_DIR}/lib") +endif() + +if(NOT DEFINED _gst_IGNORED_SYSTEM_LIBRARIES OR NOT DEFINED _gst_SRT_REGEX_PATCH) + include("${CMAKE_CURRENT_LIST_DIR}/../Helpers.cmake") +endif() + +if(ANDROID) + # Match Cerbero gstreamer-1.0.mk:NEEDS_TEXTREL_ERROR / NEEDS_BSYMBOLIC_FIX. + _qgc_android_abi_info("${CMAKE_ANDROID_ARCH_ABI}" _GST_MOBILE) +endif() + +if(ANDROID) + if (NOT DEFINED GStreamer_JAVA_SRC_DIR AND DEFINED GSTREAMER_JAVA_SRC_DIR) + set(GStreamer_JAVA_SRC_DIR "${GSTREAMER_JAVA_SRC_DIR}") + elseif(NOT DEFINED GStreamer_JAVA_SRC_DIR) + set(GStreamer_JAVA_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../src/") + endif() + # Normalize once: the legacy-var branch copies its value verbatim, so a + # relative path would otherwise stay un-anchored. + if(NOT IS_ABSOLUTE "${GStreamer_JAVA_SRC_DIR}") + set(GStreamer_JAVA_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../${GStreamer_JAVA_SRC_DIR}") + endif() + + if(NOT DEFINED GStreamer_NDK_BUILD_PATH AND DEFINED GSTREAMER_NDK_BUILD_PATH) + set(GStreamer_NDK_BUILD_PATH "${GSTREAMER_NDK_BUILD_PATH}") + elseif(NOT DEFINED GStreamer_NDK_BUILD_PATH) + set(GStreamer_NDK_BUILD_PATH "${GStreamer_ROOT_DIR}/share/gst-android/ndk-build/") + endif() +endif() + +if(NOT DEFINED GStreamer_Mobile_MODULE_NAME) + if(DEFINED GSTREAMER_ANDROID_MODULE_NAME) + set(GStreamer_Mobile_MODULE_NAME "${GSTREAMER_ANDROID_MODULE_NAME}") + else() + set(GStreamer_Mobile_MODULE_NAME gstreamer_android) + endif() +endif() + +if(ANDROID) + if(NOT DEFINED GStreamer_ASSETS_DIR AND DEFINED GSTREAMER_ASSETS_DIR) + set(GStreamer_ASSETS_DIR "${GSTREAMER_ASSETS_DIR}") + elseif(NOT DEFINED GStreamer_ASSETS_DIR) + set(GStreamer_ASSETS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../src/assets/") + elseif(NOT IS_ABSOLUTE "${GStreamer_ASSETS_DIR}") + set(GStreamer_ASSETS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../${GStreamer_ASSETS_DIR}") + endif() + + # GStreamer.java is not consumed (init runs in C++); used only as a stable + # SDK-layout anchor present regardless of plugin selection. + if(NOT EXISTS "${GStreamer_NDK_BUILD_PATH}/GStreamer.java") + message(FATAL_ERROR "GStreamer Android SDK not found at ${GStreamer_NDK_BUILD_PATH} " + "(expected ndk-build layout anchor GStreamer.java is missing). " + "Verify GStreamer Android SDK installation.") + endif() +endif() + +if(ANDROID) + set(GSTREAMER_IS_MOBILE ON) +else() + set(GSTREAMER_IS_MOBILE OFF) +endif() + +if (GSTREAMER_IS_MOBILE) + if (NOT DEFINED GStreamer_USE_STATIC_LIBS) + set(GStreamer_USE_STATIC_LIBS ON) + endif() + if (NOT GStreamer_USE_STATIC_LIBS) + message(FATAL_ERROR "Shared library GStreamer is not supported on mobile platforms") + endif() +endif() + +# Pre-condition: find_package(GStreamer) must have run before any computation +# that reads GStreamer_*_FOUND or creates targets. +if (NOT GStreamer_FOUND) + message(FATAL_ERROR "find_package(GStreamer) must complete before _qgc_create_android_mobile_target(). " + "Ensure find_package(GStreamer) has completed successfully.") +endif() + +# ── Computation phase (function to avoid variable leakage) ─────────────────── +_qgc_compute_android_mobile_target( + _gst_mobile_plugins _gst_mobile_apis _gst_validate_components + _gst_wa_libs PLUGINS_DECLARATION PLUGINS_REGISTRATION + G_IO_MODULES_DECLARE G_IO_MODULES_LOAD +) +list(TRANSFORM G_IO_MODULES PREPEND "gio" OUTPUT_VARIABLE G_IO_MODULES_LIBS) + +if(GStreamer_DEBUG) + message(STATUS "[GstMobile] GStreamer_ROOT_DIR = ${GStreamer_ROOT_DIR}") + message(STATUS "[GstMobile] GSTREAMER_LIB_PATH = ${GSTREAMER_LIB_PATH}") +endif() + +# ── Side-effect phase: target creation ────────────────────────────────────── +if (GSTREAMER_IS_MOBILE AND (NOT TARGET GStreamer::mobile)) + if (NOT ANDROID) + message(FATAL_ERROR "_qgc_create_android_mobile_target is Android-only; iOS uses the xcframework path.") + endif() + + # Single-core model: GStreamer::mobile is an INTERFACE target that whole-archives the plugin .a's + + # helpers + GIO modules into the app, which carries the one static core (GStreamer::GStreamer). A + # separate .so would carry a duplicate GstElement core and fail g_type_is_a for every plugin. + add_library(GStreamerMobile INTERFACE IMPORTED GLOBAL) + add_library(GStreamer::mobile ALIAS GStreamerMobile) +endif() + +if(GSTREAMER_IS_MOBILE AND TARGET GStreamerMobile) + # INTERFACE-only: contributes plugin .a's + helpers + GIO modules to the app, which already carries + # the single core. Do NOT re-link the core here — that produced the duplicate-GstElement-core failure. + target_include_directories( + GStreamerMobile + INTERFACE + $ + ) + + if (ANDROID) + set(GSTREAMER_PLUGINS_CLASSES) + foreach(LOCAL_PLUGIN IN LISTS _gst_mobile_plugins) + file(GLOB_RECURSE + LOCAL_PLUGIN_CLASS + FOLLOW_SYMLINKS + CONFIGURE_DEPENDS + RELATIVE "${GStreamer_NDK_BUILD_PATH}" + "${GStreamer_NDK_BUILD_PATH}/${LOCAL_PLUGIN}/*.java" + ) + list(APPEND GSTREAMER_PLUGINS_CLASSES ${LOCAL_PLUGIN_CLASS}) + endforeach() + + # androiddeployqt mirrors the package source dir into android-build/src at deploy, deleting + # anything else there — so plugin java must land in the package source dir, not the build dir. + if(QGC_ANDROID_PACKAGE_SOURCE_DIR) + set(_gst_java_dest_dir "${QGC_ANDROID_PACKAGE_SOURCE_DIR}/src") + else() + set(_gst_java_dest_dir "${GStreamer_JAVA_SRC_DIR}") + endif() + + add_custom_target("copyjavasource_${CMAKE_ANDROID_ARCH_ABI}") + + foreach(LOCAL_FILE IN LISTS GSTREAMER_PLUGINS_CLASSES) + cmake_path(GET LOCAL_FILE FILENAME _java_filename) + cmake_path(GET LOCAL_FILE PARENT_PATH _java_subdir) + string(MAKE_C_IDENTIFIER "cp_${LOCAL_FILE}" COPYJAVASOURCE_TGT) + add_custom_target( + ${COPYJAVASOURCE_TGT} + COMMAND + "${CMAKE_COMMAND}" -E make_directory + "${_gst_java_dest_dir}/org/freedesktop/gstreamer/${_java_subdir}" + COMMAND + "${CMAKE_COMMAND}" -E copy_if_different + "${GStreamer_NDK_BUILD_PATH}/${LOCAL_FILE}" + "${_gst_java_dest_dir}/org/freedesktop/gstreamer/${_java_subdir}/" + BYPRODUCTS + "${_gst_java_dest_dir}/org/freedesktop/gstreamer/${_java_subdir}/${_java_filename}" + ) + add_dependencies(copyjavasource_${CMAKE_ANDROID_ARCH_ABI} ${COPYJAVASOURCE_TGT}) + endforeach() + endif() + + if (NOT G_IO_MODULES_PATH) + pkg_get_variable(G_IO_MODULES_PATH gio-2.0 giomoduledir) + endif() + if (NOT G_IO_MODULES_PATH) + set(G_IO_MODULES_PATH "${GStreamer_ROOT_DIR}/lib/gio/modules") + endif() + + if (G_IO_MODULES_LIBS) + add_library(GStreamer::gio_modules INTERFACE IMPORTED) + + _gst_save_find_suffixes() + foreach(_gio_lib IN LISTS G_IO_MODULES_LIBS) + if(_gio_lib MATCHES "${_gst_SRT_REGEX_PATCH}") + string(REGEX REPLACE "${_gst_SRT_REGEX_PATCH}" "\\1" _gio_lib "${_gio_lib}") + endif() + string(MAKE_C_IDENTIFIER "_gst_${CMAKE_ANDROID_ARCH_ABI}_${_gio_lib}" _gio_cache_var) + if(NOT ${_gio_cache_var}) + find_library(${_gio_cache_var} + NAMES ${_gio_lib} + HINTS ${G_IO_MODULES_PATH} + NO_DEFAULT_PATH + NO_CMAKE_FIND_ROOT_PATH + ) + endif() + if(${_gio_cache_var}) + target_link_libraries(GStreamer::gio_modules INTERFACE "${${_gio_cache_var}}") + else() + message(WARNING "GStreamerMobile: GIO module '${_gio_lib}' not found in ${G_IO_MODULES_PATH}") + endif() + endforeach() + _gst_restore_find_suffixes() + + if ("openssl" IN_LIST G_IO_MODULES) + target_link_directories(GStreamer::gio_modules INTERFACE + "${GStreamer_ROOT_DIR}/lib" + "${GStreamer_ROOT_DIR}/lib/gstreamer-1.0" + ) + find_library(_gst_ssl_lib_${CMAKE_ANDROID_ARCH_ABI} ssl + HINTS "${GStreamer_ROOT_DIR}/lib" NO_DEFAULT_PATH) + find_library(_gst_crypto_lib_${CMAKE_ANDROID_ARCH_ABI} crypto + HINTS "${GStreamer_ROOT_DIR}/lib" NO_DEFAULT_PATH) + if(_gst_ssl_lib_${CMAKE_ANDROID_ARCH_ABI} AND _gst_crypto_lib_${CMAKE_ANDROID_ARCH_ABI}) + target_link_libraries(GStreamer::gio_modules INTERFACE + "${_gst_ssl_lib_${CMAKE_ANDROID_ARCH_ABI}}" "${_gst_crypto_lib_${CMAKE_ANDROID_ARCH_ABI}}") + else() + message(WARNING "GStreamerMobile: ssl/crypto not found under ${GStreamer_ROOT_DIR}/lib; " + "falling back to NDK sysroot link names (may be wrong/absent)") + target_link_libraries(GStreamer::gio_modules INTERFACE ssl crypto) + endif() + endif() + + target_link_libraries(GStreamerMobile INTERFACE GStreamer::gio_modules) + endif() + set(GStreamerMobile_mobile_FOUND TRUE) + # The static-plugin registration shim (gst_init_static_plugins) is generated on the + # APP target (single core) by the app CMakeLists, not as a separate .so source here. +endif() + +set(GStreamerMobile_FIND_COMPONENTS ${_gst_validate_components}) + +# Same clobber rule as copyjavasource: androiddeployqt mirrors the package source dir into +# android-build/assets at deploy, deleting anything else there — so fonts/CA assets must land in +# the package source dir's assets/, not the build dir, or they never reach the APK. +if(QGC_ANDROID_PACKAGE_SOURCE_DIR) + set(_gst_assets_dest_dir "${QGC_ANDROID_PACKAGE_SOURCE_DIR}/assets") +else() + set(_gst_assets_dest_dir "${GStreamer_ASSETS_DIR}") +endif() + +if(fonts IN_LIST GStreamerMobile_FIND_COMPONENTS) + set(GStreamer_UBUNTU_R_TTF "${GStreamer_NDK_BUILD_PATH}/fontconfig/fonts/Ubuntu-R.ttf" + CACHE FILEPATH "Path to Ubuntu-R.ttf") + set(GStreamer_FONTS_CONF "${GStreamer_NDK_BUILD_PATH}/fontconfig/fonts.conf" + CACHE FILEPATH "Path to fonts.conf") + if(EXISTS "${GStreamer_UBUNTU_R_TTF}" AND EXISTS "${GStreamer_FONTS_CONF}") + set(GStreamerMobile_fonts_FOUND ON) + add_custom_target( + copyfontsres_${CMAKE_ANDROID_ARCH_ABI} + COMMAND + "${CMAKE_COMMAND}" -E make_directory + "${_gst_assets_dest_dir}/fontconfig/fonts/truetype/" + COMMAND + "${CMAKE_COMMAND}" -E copy_if_different + "${GStreamer_UBUNTU_R_TTF}" + "${_gst_assets_dest_dir}/fontconfig/fonts/truetype/" + COMMAND + "${CMAKE_COMMAND}" -E copy_if_different + "${GStreamer_FONTS_CONF}" + "${_gst_assets_dest_dir}/fontconfig/" + BYPRODUCTS + "${_gst_assets_dest_dir}/fontconfig/fonts/truetype/Ubuntu-R.ttf" + "${_gst_assets_dest_dir}/fontconfig/fonts.conf" + ) + # INTERFACE GStreamer::mobile can't carry build deps; attach asset copy to the app. + if(TARGET ${CMAKE_PROJECT_NAME}) + add_dependencies(${CMAKE_PROJECT_NAME} copyfontsres_${CMAKE_ANDROID_ARCH_ABI}) + endif() + else() + set(GStreamerMobile_fonts_FOUND OFF) + endif() +endif() + +if(ca_certificates IN_LIST GStreamerMobile_FIND_COMPONENTS) + set(GStreamer_CA_BUNDLE "${GStreamer_ROOT_DIR}/etc/ssl/certs/ca-certificates.crt" + CACHE FILEPATH "Path to ca-certificates bundle") + if(EXISTS "${GStreamer_CA_BUNDLE}") + set(GStreamerMobile_ca_certificates_FOUND ON) + add_custom_target( + copycacertificatesres_${CMAKE_ANDROID_ARCH_ABI} + COMMAND + "${CMAKE_COMMAND}" -E make_directory + "${_gst_assets_dest_dir}/ssl/certs/" + COMMAND + "${CMAKE_COMMAND}" -E copy_if_different + "${GStreamer_CA_BUNDLE}" + "${_gst_assets_dest_dir}/ssl/certs/" + BYPRODUCTS "${_gst_assets_dest_dir}/ssl/certs/ca-certificates.crt" + ) + if(TARGET ${CMAKE_PROJECT_NAME}) + add_dependencies(${CMAKE_PROJECT_NAME} copycacertificatesres_${CMAKE_ANDROID_ARCH_ABI}) + endif() + else() + set(GStreamerMobile_ca_certificates_FOUND OFF) + endif() +endif() + +if(TARGET ${CMAKE_PROJECT_NAME} AND TARGET copyjavasource_${CMAKE_ANDROID_ARCH_ABI}) + add_dependencies(${CMAKE_PROJECT_NAME} copyjavasource_${CMAKE_ANDROID_ARCH_ABI}) +endif() + +include(FindPackageHandleStandardArgs) +set(_gst_plugins ${GStreamerMobile_FIND_COMPONENTS}) +list(REMOVE_ITEM _gst_plugins fonts ca_certificates mobile) +list(REMOVE_DUPLICATES _gst_plugins) +foreach(_gst_PLUGIN IN LISTS _gst_plugins) + set(GStreamerMobile_${_gst_PLUGIN}_FOUND "${GStreamer_${_gst_PLUGIN}_FOUND}") +endforeach() +if(GSTREAMER_IS_MOBILE AND TARGET GStreamerMobile) + if(GStreamer_DEBUG) + message(STATUS "[GstMobile] WHOLE_ARCHIVE plugin libs: ${_gst_wa_libs}") + endif() + + if(_gst_wa_libs) + # Whole-archive the plugin .a's into the app so every gst_plugin__register() and its + # GType objects survive the linker's dead-strip and register into the app's single core. + target_link_options(GStreamerMobile INTERFACE + "LINKER:--whole-archive" + ${_gst_wa_libs} + "LINKER:--no-whole-archive" + ) + endif() + + # The whole-archived plugin .a's reference GStreamer helper libraries + # (gstbase/rtp/rtpbase/app/video/audio/tag/pbutils/net/sdp/rtsp/controller) + # absent from PC_GStreamer_LIBRARIES; each found GStreamer:: target + # carries them as absolute .a paths via PC_GStreamer__STATIC_LIBRARIES. + set(_gst_mobile_helper_targets) + foreach(_gst_comp IN LISTS _gst_mobile_plugins) + if(TARGET GStreamer::${_gst_comp}) + list(APPEND _gst_mobile_helper_targets GStreamer::${_gst_comp}) + endif() + endforeach() + foreach(_gst_comp IN LISTS _gst_mobile_apis) + if(GStreamer_${_gst_comp}_FOUND AND TARGET GStreamer::${_gst_comp}) + list(APPEND _gst_mobile_helper_targets GStreamer::${_gst_comp}) + endif() + endforeach() + if(_gst_mobile_helper_targets) + target_link_options(GStreamerMobile INTERFACE "LINKER:--start-group") + target_link_libraries(GStreamerMobile INTERFACE ${_gst_mobile_helper_targets}) + target_link_options(GStreamerMobile INTERFACE "LINKER:--end-group") + endif() + + # GIO core (g_tls_*) for the static-plugin TLS shim; gioopenssl is a GIO + # module and does not pull gio core, so add it explicitly. + _gst_resolve_and_link_libraries(GStreamerMobile INTERFACE "gio-2.0" "${GStreamer_ROOT_DIR}/lib" WARN_MISSING) + + if(GStreamer_DEBUG) + get_target_property(_dbg_deps_libs GStreamer::deps INTERFACE_LINK_LIBRARIES) + message(STATUS "[GstMobile] GStreamer::deps INTERFACE_LINK_LIBRARIES = ${_dbg_deps_libs}") + message(STATUS "[GstMobile] Helper component targets: ${_gst_mobile_helper_targets}") + endif() + + target_link_libraries(GStreamerMobile INTERFACE GStreamer::deps) +endif() +set(GStreamerMobile_FOUND TRUE) + +endmacro() diff --git a/cmake/GStreamer/platform/Apple.cmake b/cmake/GStreamer/platform/Apple.cmake new file mode 100644 index 000000000000..fc5793eee956 --- /dev/null +++ b/cmake/GStreamer/platform/Apple.cmake @@ -0,0 +1,49 @@ +# Shared Apple (macOS + iOS) GStreamer helpers — invoked by Orchestrator.cmake. + +function(_qgc_find_apple_pkg_config OUT_VAR) + if(DEFINED CACHE{${OUT_VAR}} AND EXISTS "$CACHE{${OUT_VAR}}") + return() + endif() + find_program(_qgc_pkg_config + NAMES pkg-config pkgconf + PATHS /opt/homebrew/bin /usr/local/bin + NO_DEFAULT_PATH + ) + if(NOT _qgc_pkg_config) + find_program(_qgc_pkg_config NAMES pkg-config pkgconf) + endif() + if(NOT _qgc_pkg_config) + message(FATAL_ERROR + "Could not find pkg-config.\n" + "Install dependencies with: python3 tools/setup/install_dependencies.py --platform macos\n" + "or install pkg-config manually (for example: brew install pkg-config).") + endif() + set(${OUT_VAR} "${_qgc_pkg_config}" CACHE FILEPATH "pkg-config executable" FORCE) + unset(_qgc_pkg_config CACHE) +endfunction() + +# Validates a pkgutil-expanded GStreamer package directory. Used by the +# CPM-downloaded SDK paths in macOS and iOS discovery macros. +function(_qgc_validate_expanded_pkg EXPANDED_DIR LABEL) + file(GLOB _payloads "${EXPANDED_DIR}/*.pkg/Payload") + if(NOT _payloads) + file(REMOVE_RECURSE "${EXPANDED_DIR}") + message(FATAL_ERROR + "pkgutil expanded GStreamer ${LABEL} package but no payloads were found in ${EXPANDED_DIR}") + endif() +endfunction() + +# Expand a .pkg with pkgutil --expand-full into EXPANDED_DIR, FATAL on failure, +# then validate payloads. Shared by macOS (runtime+devel) and iOS discovery. +function(_qgc_pkgutil_expand_and_validate PKG EXPANDED_DIR LABEL) + message(STATUS "Expanding GStreamer ${LABEL} package...") + execute_process( + COMMAND pkgutil --expand-full "${PKG}" "${EXPANDED_DIR}" + RESULT_VARIABLE _pkgutil_rc + ) + if(NOT _pkgutil_rc EQUAL 0) + file(REMOVE_RECURSE "${EXPANDED_DIR}") + message(FATAL_ERROR "pkgutil failed to expand GStreamer ${LABEL} .pkg (exit code: ${_pkgutil_rc})") + endif() + _qgc_validate_expanded_pkg("${EXPANDED_DIR}" "${LABEL}") +endfunction() diff --git a/cmake/GStreamer/platform/IOS.cmake b/cmake/GStreamer/platform/IOS.cmake new file mode 100644 index 000000000000..da3bd942df13 --- /dev/null +++ b/cmake/GStreamer/platform/IOS.cmake @@ -0,0 +1,560 @@ +# iOS GStreamer SDK discovery — invoked by Orchestrator.cmake. +# GStreamer 1.28 for iOS ships a universal GStreamer.framework (cerbero +# versioned bundle) whose Versions/1.0/GStreamer binary is a single merged +# static archive containing every library and plugin. Older/future SDKs may +# ship a GStreamer.xcframework with a fat libGStreamer.a per slice. Both reduce +# to the same single-merged-archive consumption model +# (_qgc_create_xcframework_targets) — discovery just locates the binary + Headers. + +macro(_qgc_discover_ios_sdk) + if(NOT CMAKE_HOST_APPLE) + message(FATAL_ERROR "GStreamer for iOS can only be built on macOS") + endif() + + if(GStreamer_FIND_VERSION VERSION_LESS "1.28.0") + message(FATAL_ERROR + "GStreamer for iOS requires version 1.28 or later. " + "Got '${GStreamer_FIND_VERSION}' — bump gstreamer.version.ios in build-config.json.") + endif() + + # Resolved below: _gst_ios_bundle (abs path to .xcframework or .framework) + # and _gst_ios_bundle_kind ("xcframework" | "framework"). + set(_gst_ios_bundle "") + set(_gst_ios_bundle_kind "") + + # ── System install ──────────────────────────────────────────────────────── + if(NOT DEFINED GStreamer_ROOT_DIR) + if(EXISTS "/Library/Developer/GStreamer/iPhone.sdk/GStreamer.xcframework") + set(_gst_ios_bundle "/Library/Developer/GStreamer/iPhone.sdk/GStreamer.xcframework") + set(_gst_ios_bundle_kind "xcframework") + elseif(EXISTS "/Library/Developer/GStreamer/iPhone.sdk/GStreamer.framework") + set(_gst_ios_bundle "/Library/Developer/GStreamer/iPhone.sdk/GStreamer.framework") + set(_gst_ios_bundle_kind "framework") + endif() + endif() + + # ── Auto-download / expand ──────────────────────────────────────────────── + if(NOT _gst_ios_bundle) + gstreamer_resolve_or_download_sdk( + PLATFORM ios + CACHE_SUBDIR "gstreamer-ios-${GStreamer_FIND_VERSION}" + FILENAME_PRIMARY "gstreamer-ios.pkg" + CACHE_DIR_OUT _gst_ios_cache_dir + ARCHIVE_OUT _gst_ios_pkg + ) + set(_gst_ios_expanded "${_gst_ios_cache_dir}/expanded") + + # Anchors that prove a complete expansion for either SDK layout. + set(_gst_ios_anchor_globs + "${_gst_ios_expanded}/*/GStreamer.xcframework/Info.plist" + "${_gst_ios_expanded}/GStreamer.xcframework/Info.plist" + "${_gst_ios_expanded}/*/GStreamer.framework/Versions/1.0/GStreamer" + "${_gst_ios_expanded}/*/GStreamer.framework/GStreamer" + "${_gst_ios_expanded}/*/GStreamer.framework/Versions/1.0/Headers/gst/gst.h" + "${_gst_ios_expanded}/*/GStreamer.framework/Headers/gst/gst.h" + ) + + if(EXISTS "${_gst_ios_expanded}") + set(_cached_anchor "") + foreach(_glob IN LISTS _gst_ios_anchor_globs) + file(GLOB_RECURSE _cached_anchor "${_glob}") + if(_cached_anchor) + break() + endif() + endforeach() + if(NOT _cached_anchor) + message(STATUS "GStreamer: cached iOS expansion is incomplete; re-expanding") + file(REMOVE_RECURSE "${_gst_ios_expanded}") + endif() + endif() + + if(NOT EXISTS "${_gst_ios_expanded}") + _qgc_pkgutil_expand_and_validate("${_gst_ios_pkg}" "${_gst_ios_expanded}" "iOS") + endif() + + # Prefer xcframework when present (future SDKs); fall back to framework. + file(GLOB_RECURSE _xcfw_info_plists LIST_DIRECTORIES false + "${_gst_ios_expanded}/*/GStreamer.xcframework/Info.plist" + "${_gst_ios_expanded}/GStreamer.xcframework/Info.plist" + ) + if(_xcfw_info_plists) + list(GET _xcfw_info_plists 0 _xcfw_info_first) + cmake_path(GET _xcfw_info_first PARENT_PATH _gst_ios_bundle) + set(_gst_ios_bundle_kind "xcframework") + else() + file(GLOB_RECURSE _fw_dirs LIST_DIRECTORIES true + "${_gst_ios_expanded}/*/GStreamer.framework" + "${_gst_ios_expanded}/GStreamer.framework") + foreach(_d IN LISTS _fw_dirs) + if(IS_DIRECTORY "${_d}" AND _d MATCHES "/GStreamer\\.framework$") + set(_gst_ios_bundle "${_d}") + set(_gst_ios_bundle_kind "framework") + break() + endif() + endforeach() + endif() + + if(NOT _gst_ios_bundle) + file(GLOB _top_entries LIST_DIRECTORIES true "${_gst_ios_expanded}/*") + file(GLOB_RECURSE _all_xcfw LIST_DIRECTORIES true "${_gst_ios_expanded}/*.xcframework") + file(GLOB_RECURSE _all_fw LIST_DIRECTORIES true "${_gst_ios_expanded}/*.framework") + string(REPLACE ";" "\n " _top_entries_str "${_top_entries}") + string(REPLACE ";" "\n " _all_xcfw_str "${_all_xcfw}") + string(REPLACE ";" "\n " _all_fw_str "${_all_fw}") + message(FATAL_ERROR + "Could not locate GStreamer.xcframework or GStreamer.framework in expanded iOS SDK at" + " '${_gst_ios_expanded}'. The .pkg layout may have changed.\n" + "Top-level entries:\n ${_top_entries_str}\n" + "All *.xcframework directories:\n ${_all_xcfw_str}\n" + "All *.framework directories:\n ${_all_fw_str}") + endif() + set(GStreamer_AUTO_DOWNLOADED TRUE) + endif() + + # ── Resolve merged static binary + Headers; force single-archive mode ───── + # Both kinds consume one merged archive via _qgc_create_xcframework_targets. + # Keep the three layout-mode flags mutually exclusive (Orchestrator validates). + set(GStreamer_USE_XCFRAMEWORK ON) + set(GStreamer_USE_FRAMEWORK OFF) + set(GStreamer_USE_STATIC_LIBS OFF) + + if(_gst_ios_bundle_kind STREQUAL "xcframework") + if(CMAKE_OSX_SYSROOT MATCHES "iphonesimulator") + file(GLOB _gst_ios_slice_dirs LIST_DIRECTORIES true "${_gst_ios_bundle}/ios-*-simulator") + else() + file(GLOB _gst_ios_slice_dirs LIST_DIRECTORIES true "${_gst_ios_bundle}/ios-*") + list(FILTER _gst_ios_slice_dirs EXCLUDE REGEX "-(simulator|maccatalyst)$") + endif() + if(NOT _gst_ios_slice_dirs) + message(FATAL_ERROR + "GStreamer xcframework: no matching iOS slice found in ${_gst_ios_bundle}.\n" + "Check ${_gst_ios_bundle}/Info.plist AvailableLibraries for the available slices.") + endif() + # Prefer a slice whose name carries the requested arch; fall back to first. + set(_gst_ios_slice_dir "") + if(CMAKE_OSX_ARCHITECTURES) + foreach(_slice IN LISTS _gst_ios_slice_dirs) + foreach(_arch IN LISTS CMAKE_OSX_ARCHITECTURES) + if(_slice MATCHES "(^|[-_/])${_arch}([-_]|$)") + set(_gst_ios_slice_dir "${_slice}") + break() + endif() + endforeach() + if(_gst_ios_slice_dir) + break() + endif() + endforeach() + endif() + if(NOT _gst_ios_slice_dir) + list(GET _gst_ios_slice_dirs 0 _gst_ios_slice_dir) + endif() + if(NOT EXISTS "${_gst_ios_slice_dir}") + message(FATAL_ERROR + "GStreamer xcframework slice '${_gst_ios_slice_dir}' is not a directory.") + endif() + set(GStreamer_ROOT_DIR "${_gst_ios_slice_dir}") + set(GSTREAMER_XCFRAMEWORK_LIB "${_gst_ios_slice_dir}/libGStreamer.a") + set(_gst_ios_include "${_gst_ios_slice_dir}/Headers") + set(_gst_ios_layout_root "${_gst_ios_slice_dir}") + set(_gst_ios_xcfw_bundle "${_gst_ios_bundle}") + else() + # cerbero universal .framework: one merged fat binary covers all arches. + # Versioned bundle (Versions/1.0/...) is canonical; tolerate a flat bundle. + if(EXISTS "${_gst_ios_bundle}/Versions/1.0/GStreamer") + set(_gst_ios_layout_root "${_gst_ios_bundle}/Versions/1.0") + elseif(EXISTS "${_gst_ios_bundle}/GStreamer") + set(_gst_ios_layout_root "${_gst_ios_bundle}") + else() + message(FATAL_ERROR + "GStreamer.framework at ${_gst_ios_bundle} has no merged binary at " + "Versions/1.0/GStreamer or GStreamer. The SDK layout may have changed.") + endif() + set(GStreamer_ROOT_DIR "${_gst_ios_layout_root}") + set(GSTREAMER_XCFRAMEWORK_LIB "${_gst_ios_layout_root}/GStreamer") + # Headers may live under Versions/1.0/Headers or be a symlink at the root. + if(EXISTS "${_gst_ios_layout_root}/Headers") + set(_gst_ios_include "${_gst_ios_layout_root}/Headers") + elseif(EXISTS "${_gst_ios_bundle}/Headers") + set(_gst_ios_include "${_gst_ios_bundle}/Headers") + else() + message(FATAL_ERROR + "GStreamer.framework at ${_gst_ios_bundle} has no Headers/ directory.") + endif() + set(_gst_ios_xcfw_bundle "${_gst_ios_bundle}") + endif() + + if(NOT EXISTS "${GSTREAMER_XCFRAMEWORK_LIB}") + message(FATAL_ERROR + "GStreamer iOS merged binary not found at ${GSTREAMER_XCFRAMEWORK_LIB}") + endif() + + # Resolve Info.plist so the real CFBundleShortVersionString can re-check the + # floor — the check above only sees the requested version, missing downgrades. + set(GStreamer_IOS_INFO_PLIST "") + if(_gst_ios_bundle_kind STREQUAL "xcframework") + # Each slice carries a per-arch GStreamer.framework with its own Info.plist. + foreach(_pl + "${_gst_ios_slice_dir}/GStreamer.framework/Info.plist" + "${_gst_ios_slice_dir}/GStreamer.framework/Resources/Info.plist") + if(EXISTS "${_pl}") + set(GStreamer_IOS_INFO_PLIST "${_pl}") + break() + endif() + endforeach() + else() + # cerbero universal .framework: versioned Resources/Info.plist is canonical. + foreach(_pl + "${_gst_ios_layout_root}/Resources/Info.plist" + "${_gst_ios_bundle}/Resources/Info.plist" + "${_gst_ios_bundle}/Info.plist") + if(EXISTS "${_pl}") + set(GStreamer_IOS_INFO_PLIST "${_pl}") + break() + endif() + endforeach() + endif() + + # gstreamer_create_layout_target sets GSTREAMER_LIB/PLUGIN/INCLUDE/XCFRAMEWORK_PATH. + # TYPE XCFRAMEWORK collapses lib/plugin paths to the layout root (no lib/ subdir + # in a merged binary). XCFRAMEWORK_BUNDLE = the .xcframework or .framework dir; + # Layout.cmake only EXISTS-checks SDK_ROOT and the bundle, both real dirs here. + gstreamer_create_layout_target( + SDK_ROOT "${_gst_ios_layout_root}" + TYPE XCFRAMEWORK + INCLUDE_PATH "${_gst_ios_include}" + XCFRAMEWORK_BUNDLE "${_gst_ios_xcfw_bundle}" + ) + + # iOS SDK ships no CA bundle reachable from libgioopenssl; fetch Mozilla NSS. + _qgc_download_ios_ca_bundle() +endmacro() + +# Downloads ca-certificates.crt to the iOS SDK cache and sets +# GStreamer_IOS_CA_BUNDLE (cache var) to its absolute path. Re-uses the same +# CPM_SOURCE_CACHE root the SDK download uses so a `rm -rf build` doesn't +# re-fetch it. +function(_qgc_download_ios_ca_bundle) + if(CPM_SOURCE_CACHE) + set(_ca_dir "${CPM_SOURCE_CACHE}/gstreamer-ios-ca") + else() + set(_ca_dir "${CMAKE_BINARY_DIR}/_deps/gstreamer-ios-ca") + endif() + + set(_ca_hash_arg "") + if(QGC_BUILD_CONFIG_CONTENT) + string(JSON _ca_sha256 ERROR_VARIABLE _ca_err + GET "${QGC_BUILD_CONFIG_CONTENT}" "gstreamer" "ca_bundle_sha256") + if(_ca_sha256 AND NOT _ca_err) + set(_ca_hash_arg EXPECTED_HASH "SHA256=${_ca_sha256}") + endif() + endif() + if(NOT _ca_hash_arg) + # This bundle becomes the app's TLS trust root; an unpinned fetch lets a + # MITM inject one. Pinning is default — opt out via QGC_GST_ALLOW_UNVERIFIED_CA. + if(NOT QGC_GST_ALLOW_UNVERIFIED_CA) + message(FATAL_ERROR + "GStreamer: gstreamer.ca_bundle_sha256 is missing from build-config.json, so the " + "iOS CA bundle (the app's TLS trust root) cannot be verified. Pin it from " + "https://curl.se/ca/cacert.pem.sha256 and commit it to build-config.json. " + "To fetch the bundle UNVERIFIED anyway (NOT for CI/release builds), set " + "-DQGC_GST_ALLOW_UNVERIFIED_CA=ON.") + endif() + message(WARNING "GStreamer: gstreamer.ca_bundle_sha256 missing from build-config.json and " + "QGC_GST_ALLOW_UNVERIFIED_CA is set; iOS CA bundle will be fetched UNVERIFIED. " + "Pin it from https://curl.se/ca/cacert.pem.sha256") + endif() + + qgc_resilient_download( + FILENAME ca-certificates.crt + DESTINATION_DIR "${_ca_dir}" + URLS "https://curl.se/ca/cacert.pem" + RESULT_VAR _ca_path + LOG_TAG "iOS CA bundle" + FAILURE_HINT "Network is required at first iOS configure to fetch the Mozilla CA bundle from curl.se." + ${_ca_hash_arg} + ) + + set(GStreamer_IOS_CA_BUNDLE "${_ca_path}" CACHE FILEPATH + "Mozilla CA bundle bundled into iOS app resources at ssl/certs/ca-certificates.crt" FORCE) +endfunction() + +# ───────────────────────────────────────────────────────────────────────────── +# _qgc_create_xcframework_targets +# Build IMPORTED targets directly from the iOS xcframework's fat .a — bypasses +# pkg-config entirely. Caller must have set GSTREAMER_XCFRAMEWORK_LIB and +# GSTREAMER_INCLUDE_PATH (done by _qgc_discover_ios_sdk above). +# Function: exports GStreamer_FOUND / GStreamer_VERSION via PARENT_SCOPE and creates GLOBAL IMPORTED targets. +# ───────────────────────────────────────────────────────────────────────────── +function(_qgc_create_xcframework_targets) + # Read the bundle's real version from Info.plist to re-check the floor — the + # _discover check only sees the requested version. Warn + fall back if unreadable. + set(_xcfw_bundle_version "") + if(GStreamer_IOS_INFO_PLIST AND EXISTS "${GStreamer_IOS_INFO_PLIST}") + # PlistBuddy (always present on macOS hosts) reads binary or XML plists; + # fall back to a regex over the XML form if it is ever unavailable. + find_program(_xcfw_plistbuddy NAMES PlistBuddy + PATHS /usr/libexec NO_DEFAULT_PATH NO_CACHE) + if(_xcfw_plistbuddy) + execute_process( + COMMAND "${_xcfw_plistbuddy}" -c "Print :CFBundleShortVersionString" + "${GStreamer_IOS_INFO_PLIST}" + OUTPUT_VARIABLE _xcfw_bundle_version + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + RESULT_VARIABLE _xcfw_plist_rc) + if(NOT _xcfw_plist_rc EQUAL 0) + set(_xcfw_bundle_version "") + endif() + endif() + if(NOT _xcfw_bundle_version) + file(READ "${GStreamer_IOS_INFO_PLIST}" _xcfw_plist_xml) + string(REGEX MATCH + "CFBundleShortVersionString[ \t\r\n]*([^<]+)" + _xcfw_plist_match "${_xcfw_plist_xml}") + if(_xcfw_plist_match) + set(_xcfw_bundle_version "${CMAKE_MATCH_1}") + endif() + endif() + endif() + + if(_xcfw_bundle_version) + if(_xcfw_bundle_version VERSION_LESS "1.28.0") + message(FATAL_ERROR + "GStreamer iOS bundle reports version '${_xcfw_bundle_version}' " + "(from ${GStreamer_IOS_INFO_PLIST}), which is older than the required " + "1.28 floor. A downgraded or stale GStreamer.framework was found — " + "remove it / clear the SDK cache and re-fetch the pinned version.") + endif() + if(GStreamer_FIND_VERSION AND _xcfw_bundle_version VERSION_LESS GStreamer_FIND_VERSION) + message(WARNING + "GStreamer iOS bundle version '${_xcfw_bundle_version}' is older than the " + "requested '${GStreamer_FIND_VERSION}'. Using the bundle's actual version.") + endif() + set(GStreamer_VERSION "${_xcfw_bundle_version}" PARENT_SCOPE) + set(GStreamer_VERSION "${_xcfw_bundle_version}") + elseif(NOT GStreamer_VERSION) + message(WARNING + "GStreamer iOS: could not read CFBundleShortVersionString from the bundle " + "Info.plist (looked at '${GStreamer_IOS_INFO_PLIST}'). Falling back to the " + "REQUESTED version '${GStreamer_FIND_VERSION}' — the bundle's actual version " + "is UNVERIFIED, so a downgraded SDK cannot be detected.") + set(GStreamer_VERSION "${GStreamer_FIND_VERSION}" PARENT_SCOPE) + set(GStreamer_VERSION "${GStreamer_FIND_VERSION}") + endif() + + # ── xcframework path: create IMPORTED targets directly from the fat .a ─── + # No pkg-config or .framework; all APIs and plugins live in one archive. + if(NOT TARGET GStreamer::GStreamer) + add_library(GStreamer_static STATIC IMPORTED GLOBAL) + set_target_properties(GStreamer_static PROPERTIES + IMPORTED_LOCATION "${GSTREAMER_XCFRAMEWORK_LIB}" + ) + add_library(GStreamer::GStreamer INTERFACE IMPORTED GLOBAL) + target_link_libraries(GStreamer::GStreamer INTERFACE + GStreamer_static + ) + # iOS .framework has flat Headers/; macOS-style installs have the gstreamer-1.0 / + # glib-2.0 subdirs. Add only what exists — INTERFACE_INCLUDE_DIRECTORIES is validated. + target_include_directories(GStreamer::GStreamer INTERFACE "${GSTREAMER_INCLUDE_PATH}") + foreach(_inc_sub IN ITEMS gstreamer-1.0 glib-2.0) + if(IS_DIRECTORY "${GSTREAMER_INCLUDE_PATH}/${_inc_sub}") + target_include_directories(GStreamer::GStreamer INTERFACE "${GSTREAMER_INCLUDE_PATH}/${_inc_sub}") + endif() + endforeach() + # System frameworks required by GStreamer on iOS. + # Note: gstreamer-ios 1.28+ bundles libass (CoreText), MoltenVK (Metal/IOSurface/QuartzCore), + # applemedia iosassetsrc (AssetsLibrary), and EAGL/CAMetalLayer (QuartzCore) — all of which + # demand additional system frameworks at the *consumer* link step. + set(_xcfw_required_frameworks + Foundation AVFoundation AudioToolbox VideoToolbox CoreMedia CoreVideo + CoreAudio CoreGraphics Security UIKit CoreFoundation CoreText + IOSurface Metal QuartzCore + ) + set(_xcfw_resolved_libs) + foreach(_fw IN LISTS _xcfw_required_frameworks) + string(TOLOWER "_xcfw_${_fw}" _fw_var) + find_library(${_fw_var} ${_fw} REQUIRED) + list(APPEND _xcfw_resolved_libs "${${_fw_var}}") + endforeach() + # AssetsLibrary is deprecated since iOS 9 but the headers/lib are still present; + # libgstapplemedia iosassetsrc references _OBJC_CLASS_$_ALAssetsLibrary. + find_library(GStreamer_xcfw_assetslibrary AssetsLibrary) + set(_xcfw_assetslibrary "${GStreamer_xcfw_assetslibrary}") + target_link_libraries(GStreamer::GStreamer INTERFACE + ${_xcfw_resolved_libs} + "-lresolv" "-liconv" "-lz" "-lbz2" + ) + if(_xcfw_assetslibrary) + target_link_libraries(GStreamer::GStreamer INTERFACE "${_xcfw_assetslibrary}") + else() + # If AssetsLibrary truly isn't present in the SDK, weak-link by name so dyld + # tolerates absence at load time (iosassetsrc is not invoked by QGC). + target_link_options(GStreamer::GStreamer INTERFACE "-Wl,-weak_framework,AssetsLibrary") + endif() + # OpenGLES is absent from pure-Metal SDK slices; weak-link rather than hard-require. + find_library(GStreamer_xcfw_opengles OpenGLES) + set(_xcfw_opengles "${GStreamer_xcfw_opengles}") + if(_xcfw_opengles) + target_link_libraries(GStreamer::GStreamer INTERFACE "${_xcfw_opengles}") + else() + target_link_options(GStreamer::GStreamer INTERFACE "-Wl,-weak_framework,OpenGLES") + endif() + # gstreamer-ios 1.28 bundles many Rust-built static libs (gst-plugins-rs); each contributes + # a `_rust_eh_personality` reference and Mach-O compact unwind can encode only ~4 unique + # personalities. Switch to DWARF unwind tables to sidestep the limit. Slightly larger + # binary; runtime perf impact is negligible (only used during exception unwinding, and + # GStreamer doesn't throw C++ exceptions across its API surface). + target_link_options(GStreamer::GStreamer INTERFACE "-Wl,-no_compact_unwind") + target_compile_definitions(GStreamer::GStreamer INTERFACE + QGC_GST_STATIC_BUILD + ) + # Keep the find_library cache entries — re-resolving 17 system frameworks + # on every reconfigure is the dominant configure-time cost on iOS. + endif() + + # Per-API alias targets — load-bearing for the optional-component FOUND + # check in Orchestrator.cmake (`if(TARGET GStreamer::${_api})`). On the + # pkg-config flow, FindGStreamer creates these targets directly; on the + # xcframework flow the entire library lives in one .a, so we synthesize + # empty INTERFACE shims that link back to GStreamer::GStreamer. Iterating + # GSTREAMER_APIS keeps a future find_package(... COMPONENTS NewOne) call + # working without editing this file. + foreach(_xcfw_comp IN LISTS GSTREAMER_APIS GSTREAMER_PLUGINS) + if(NOT TARGET GStreamer::${_xcfw_comp}) + add_library(GStreamer::${_xcfw_comp} INTERFACE IMPORTED GLOBAL) + target_link_libraries(GStreamer::${_xcfw_comp} INTERFACE GStreamer::GStreamer) + endif() + set(GStreamer_${_xcfw_comp}_FOUND TRUE PARENT_SCOPE) + endforeach() + + # Build the xcframework mobile init shim — calls gst_init_static_plugins(). + if(NOT TARGET GStreamer::mobile) + enable_language(OBJC OBJCXX) + + # GStreamer 1.28+ ships every plugin compiled into libGStreamer.a but + # provides no auto-registration entrypoint; enumerate plugin descriptors + # from the archive and emit explicit GST_PLUGIN_STATIC_REGISTER() calls. + # Skip list (plugins whose static deps aren't in the slice) is owned by + # GStreamerPluginPolicy. + gstreamer_xcfw_skip(_xcfw_skip_plugins) + + # Cache the nm-extracted plugin descriptor list — running nm -gjU on the + # ~300MB libGStreamer.a costs 2-5s per reconfigure. Key on archive + # mtime+size and SDK version so a swapped xcframework re-invokes nm. + file(SIZE "${GSTREAMER_XCFRAMEWORK_LIB}" _xcfw_lib_size) + file(TIMESTAMP "${GSTREAMER_XCFRAMEWORK_LIB}" _xcfw_lib_mtime "%s") + # Hash the archive so a same-size, mtime-normalized rebuild (CI cache restore) + # still re-invokes nm instead of reusing a stale plugin list. + file(SHA256 "${GSTREAMER_XCFRAMEWORK_LIB}" _xcfw_lib_hash) + set(_xcfw_cache_key "${GStreamer_VERSION}|${_xcfw_lib_size}|${_xcfw_lib_mtime}|${_xcfw_lib_hash}|${_xcfw_skip_plugins}") + set(_xcfw_cache_file "${CMAKE_BINARY_DIR}/qgc_xcfw_plugins.cmake") + set(_xcfw_descs "") + if(EXISTS "${_xcfw_cache_file}") + include("${_xcfw_cache_file}") + if(NOT "${_xcfw_cached_key}" STREQUAL "${_xcfw_cache_key}") + set(_xcfw_descs "") + endif() + endif() + if(NOT _xcfw_descs) + # Prefer llvm-nm: it reads fat Mach-O natively, whereas Apple's BSD nm exits non-zero on a fat + # binary unless given -arch. -gjU (global, names-only, defined) is portable across both. + find_program(_xcfw_nm NAMES llvm-nm nm REQUIRED) + + # No -arch first (works for llvm-nm + thin archives); on failure (BSD nm on a fat binary) retry per slice. + execute_process( + COMMAND "${_xcfw_nm}" -gjU "${GSTREAMER_XCFRAMEWORK_LIB}" + OUTPUT_VARIABLE _xcfw_nm_out + ERROR_VARIABLE _xcfw_nm_err + RESULT_VARIABLE _xcfw_nm_rc + ) + if(NOT _xcfw_nm_rc EQUAL 0) + foreach(_xcfw_arch IN LISTS CMAKE_OSX_ARCHITECTURES) + execute_process( + COMMAND "${_xcfw_nm}" -arch "${_xcfw_arch}" -gjU "${GSTREAMER_XCFRAMEWORK_LIB}" + OUTPUT_VARIABLE _xcfw_nm_out + ERROR_VARIABLE _xcfw_nm_err + RESULT_VARIABLE _xcfw_nm_rc + ) + if(_xcfw_nm_rc EQUAL 0) + break() + endif() + endforeach() + endif() + + # Apple's nm (Xcode 26+) exits non-zero on objects it can't parse (e.g. the + # Rust gstaws bitcode: "Unknown attribute kind") but still prints symbols for + # every object it can read. Extract from whatever it produced; fail only if + # that yielded no descriptors, not on a non-zero exit alone. + string(REGEX MATCHALL "_gst_plugin_[A-Za-z0-9_]+_get_desc" _xcfw_descs "${_xcfw_nm_out}") + list(REMOVE_DUPLICATES _xcfw_descs) + if(NOT _xcfw_descs) + message(FATAL_ERROR + "nm produced no plugin descriptors for ${GSTREAMER_XCFRAMEWORK_LIB} (rc=${_xcfw_nm_rc})\n" + " tool: ${_xcfw_nm}\n" + " archs tried: ${CMAKE_OSX_ARCHITECTURES} (plus a no-arch attempt)\n" + " stderr: ${_xcfw_nm_err}\n" + "If this is a fat Mach-O, install llvm (brew install llvm) so " + "llvm-nm is on PATH, or verify the framework slice arch matches " + "CMAKE_OSX_ARCHITECTURES.") + elseif(NOT _xcfw_nm_rc EQUAL 0) + message(STATUS + "GStreamer xcframework: nm exited ${_xcfw_nm_rc} (unparseable objects, " + "e.g. Rust gstaws bitcode) but emitted usable symbols — continuing.") + endif() + file(WRITE "${_xcfw_cache_file}" + "# Auto-generated cache of plugin descriptors enumerated from the iOS xcframework.\n" + "# Regenerated when GStreamer_VERSION or libGStreamer.a (size+mtime) changes.\n" + "set(_xcfw_cached_key \"${_xcfw_cache_key}\")\n" + "set(_xcfw_descs \"${_xcfw_descs}\")\n" + ) + endif() + set(_xcfw_names "") + foreach(_sym IN LISTS _xcfw_descs) + string(REGEX REPLACE "^_gst_plugin_(.+)_get_desc$" "\\1" _name "${_sym}") + if(_name IN_LIST _xcfw_skip_plugins) + continue() + endif() + list(APPEND _xcfw_names "${_name}") + endforeach() + _gst_emit_static_plugin_registration(_xcfw_names _xcfw_decl _xcfw_reg) + list(LENGTH _xcfw_names _xcfw_used) + list(LENGTH _xcfw_descs _xcfw_n) + message(STATUS "GStreamer xcframework: registering ${_xcfw_used}/${_xcfw_n} static plugins (skipped: ${_xcfw_skip_plugins})") + # Match the substitution variable names _qgc_create_android_mobile_target populates so the + # template at GStreamer/gst_static_plugins.c.in works for both call sites. + set(PLUGINS_DECLARATION "${_xcfw_decl}") + set(PLUGINS_REGISTRATION "${_xcfw_reg}") + # gioopenssl is statically linked into libGStreamer.a — load it so it + # registers as the default GIO TLS backend (verified via nm: symbol + # _g_io_openssl_load is exported from the xcframework slice). + set(G_IO_MODULES_DECLARE "GST_G_IO_MODULE_DECLARE(openssl);") + set(G_IO_MODULES_LOAD " GST_G_IO_MODULE_LOAD(openssl);") + + set(_xcfw_static_shim "${CMAKE_BINARY_DIR}/${GStreamer_Mobile_MODULE_NAME}_static.c") + # Template lives in src/VideoManager/VideoReceiver/GStreamer/ alongside + # the C++ code that calls gst_init_static_plugins(). + set(_qgc_gst_src "${CMAKE_SOURCE_DIR}/src/VideoManager/VideoReceiver/GStreamer") + configure_file( + "${_qgc_gst_src}/gst_static_plugins.c.in" + "${_xcfw_static_shim}" + @ONLY + ) + add_library(GStreamerMobileXcfw SHARED) + target_sources(GStreamerMobileXcfw PRIVATE "${_xcfw_static_shim}") + set_source_files_properties("${_xcfw_static_shim}" PROPERTIES GENERATED TRUE) + target_link_libraries(GStreamerMobileXcfw PRIVATE GStreamer::GStreamer) + set_target_properties(GStreamerMobileXcfw PROPERTIES + LIBRARY_OUTPUT_NAME ${GStreamer_Mobile_MODULE_NAME} + FRAMEWORK TRUE + FRAMEWORK_VERSION A + MACOSX_FRAMEWORK_IDENTIFIER org.gstreamer.GStreamerMobile + LINKER_LANGUAGE CXX + ) + add_library(GStreamer::mobile ALIAS GStreamerMobileXcfw) + add_library(GStreamerMobile ALIAS GStreamerMobileXcfw) + set(GStreamerMobile_FOUND TRUE PARENT_SCOPE) + set(GStreamerMobile_mobile_FOUND TRUE PARENT_SCOPE) + endif() + + set(GStreamer_FOUND TRUE PARENT_SCOPE) +endfunction() diff --git a/cmake/GStreamer/platform/Linux.cmake b/cmake/GStreamer/platform/Linux.cmake new file mode 100644 index 000000000000..22f9752ad39f --- /dev/null +++ b/cmake/GStreamer/platform/Linux.cmake @@ -0,0 +1,78 @@ +# Linux GStreamer SDK discovery — invoked by Orchestrator.cmake. + +macro(_qgc_discover_linux_sdk) + if(NOT DEFINED GStreamer_ROOT_DIR) + if(CMAKE_SYSROOT) + set(GStreamer_ROOT_DIR "${CMAKE_SYSROOT}/usr") + else() + set(GStreamer_ROOT_DIR "/usr") + endif() + endif() + + # Candidate multiarch triplets: explicit CMAKE_LIBRARY_ARCHITECTURE first, then + # any lib/ that actually contains gstreamer-1.0 (handles musl, + # arm-linux-gnueabihf, and non-Debian layouts where the triplet is unset). + set(_gst_linux_triplet_candidates) + if(CMAKE_LIBRARY_ARCHITECTURE) + list(APPEND _gst_linux_triplet_candidates "${CMAKE_LIBRARY_ARCHITECTURE}") + endif() + list(APPEND _gst_linux_triplet_candidates "${CMAKE_SYSTEM_PROCESSOR}-linux-gnu") + # musl sysroots use a -linux-musl triplet and often leave CMAKE_LIBRARY_ARCHITECTURE unset. + list(APPEND _gst_linux_triplet_candidates "${CMAKE_SYSTEM_PROCESSOR}-linux-musl") + file(GLOB _gst_linux_multiarch_dirs LIST_DIRECTORIES true "${GStreamer_ROOT_DIR}/lib/*-linux-*") + foreach(_d IN LISTS _gst_linux_multiarch_dirs) + cmake_path(GET _d FILENAME _d_name) + list(APPEND _gst_linux_triplet_candidates "${_d_name}") + endforeach() + list(REMOVE_DUPLICATES _gst_linux_triplet_candidates) + + set(_gst_linux_lib "") + foreach(_triplet IN LISTS _gst_linux_triplet_candidates) + if(EXISTS "${GStreamer_ROOT_DIR}/lib/${_triplet}/gstreamer-1.0") + set(_gst_linux_lib "${GStreamer_ROOT_DIR}/lib/${_triplet}") + break() + endif() + endforeach() + if(NOT _gst_linux_lib) + if(EXISTS "${GStreamer_ROOT_DIR}/lib64/gstreamer-1.0") + set(_gst_linux_lib "${GStreamer_ROOT_DIR}/lib64") + elseif(EXISTS "${GStreamer_ROOT_DIR}/lib/gstreamer-1.0") + set(_gst_linux_lib "${GStreamer_ROOT_DIR}/lib") + else() + message(FATAL_ERROR "Could not locate GStreamer libraries - check installation or set environment/cmake variables") + endif() + endif() + + gstreamer_create_layout_target( + SDK_ROOT "${GStreamer_ROOT_DIR}" + TYPE FLAT + LIB_PATH "${_gst_linux_lib}" + ) + + # Prepend SDK pkgconfig dir so system glib/gobject .pc files remain discoverable. + gstreamer_apply_pkgconfig_env( + MODE SYSTEM_AUGMENT + LIBDIR "${GSTREAMER_LIB_PATH}/pkgconfig" + ) +endmacro() + +# Zero-copy DMABuf GPU path probe. Sets QGC_GST_HAS_DMABUF; consumed by +# HwBuffers/CMakeLists.txt to compile GstDmaBufVideoBuffer when present. +# Requires gst-allocators (pulled in via the Allocators QGCGStreamer component +# requested by the Linux-only branch in src/.../GStreamer/CMakeLists.txt). +# NOTE: QGC_GST_HAS_DMABUF is a cached try-compile result; if the GStreamer +# include dirs change, clear the cache var to force a re-probe. +macro(_qgc_detect_dmabuf) + include(CheckCXXSourceCompiles) + if(TARGET GStreamer::GStreamer) + set(_gst_dmabuf_req_libs_backup "${CMAKE_REQUIRED_LIBRARIES}") + set(CMAKE_REQUIRED_LIBRARIES GStreamer::GStreamer) + check_cxx_source_compiles(" + #include + int main() { (void)gst_is_dmabuf_memory; return 0; } + " QGC_GST_HAS_DMABUF) + set(CMAKE_REQUIRED_LIBRARIES "${_gst_dmabuf_req_libs_backup}") + else() + message(STATUS "GStreamer: DMABuf probe skipped — GStreamer::GStreamer target not defined") + endif() +endmacro() diff --git a/cmake/GStreamer/platform/MacOS.cmake b/cmake/GStreamer/platform/MacOS.cmake new file mode 100644 index 000000000000..caa6dd5e2ca2 --- /dev/null +++ b/cmake/GStreamer/platform/MacOS.cmake @@ -0,0 +1,165 @@ +# macOS GStreamer SDK discovery — invoked by Orchestrator.cmake. + +macro(_qgc_discover_macos_sdk) + if(NOT DEFINED GStreamer_ROOT_DIR) + if(EXISTS "/Library/Frameworks/GStreamer.framework") + set(GStreamer_ROOT_DIR "/Library/Frameworks/GStreamer.framework/Versions/1.0") + else() + foreach(_brew_prefix IN ITEMS "/opt/homebrew/opt/gstreamer" "/usr/local/opt/gstreamer") + if(EXISTS "${_brew_prefix}") + set(GStreamer_ROOT_DIR "${_brew_prefix}") + set(GStreamer_USE_FRAMEWORK OFF) + message(STATUS "GStreamer: Using Homebrew at ${GStreamer_ROOT_DIR}") + break() + endif() + endforeach() + endif() + endif() + + if(NOT DEFINED GStreamer_ROOT_DIR OR NOT EXISTS "${GStreamer_ROOT_DIR}") + gstreamer_resolve_or_download_sdk( + PLATFORM macos + CACHE_SUBDIR "gstreamer-mac-${GStreamer_FIND_VERSION}" + FILENAME_PRIMARY "gstreamer.pkg" + FILENAME_SECONDARY "gstreamer-devel.pkg" + CACHE_DIR_OUT _gst_mac_cache_dir + ARCHIVE_OUT _gst_mac_pkg + ARCHIVE2_OUT _gst_mac_devel_pkg + ) + set(_gst_mac_expanded "${_gst_mac_cache_dir}/expanded") + set(_gst_mac_devel_expanded "${_gst_mac_cache_dir}/expanded-devel") + set(_gst_mac_root "${_gst_mac_cache_dir}/root") + set(_gst_mac_required_plugin_dir "${_gst_mac_root}/lib/gstreamer-1.0") + set(_gst_mac_required_include_dir "${_gst_mac_root}/include/gstreamer-1.0") + set(_gst_mac_required_pc_file "${_gst_mac_root}/lib/pkgconfig/gstreamer-1.0.pc") + + if(EXISTS "${_gst_mac_root}/.merge_complete") + if(NOT EXISTS "${_gst_mac_required_plugin_dir}" + OR NOT EXISTS "${_gst_mac_required_include_dir}" + OR NOT EXISTS "${_gst_mac_required_pc_file}") + message(STATUS "GStreamer: cached macOS SDK is incomplete; rebuilding cache") + file(REMOVE_RECURSE "${_gst_mac_root}" "${_gst_mac_expanded}" "${_gst_mac_devel_expanded}") + endif() + endif() + + if(NOT EXISTS "${_gst_mac_root}/.merge_complete") + file(REMOVE_RECURSE "${_gst_mac_root}") + file(REMOVE_RECURSE "${_gst_mac_expanded}" "${_gst_mac_devel_expanded}") + + _qgc_pkgutil_expand_and_validate("${_gst_mac_pkg}" "${_gst_mac_expanded}" "macOS runtime") + _qgc_pkgutil_expand_and_validate("${_gst_mac_devel_pkg}" "${_gst_mac_devel_expanded}" "macOS devel") + + file(MAKE_DIRECTORY "${_gst_mac_root}") + foreach(_expanded_dir IN ITEMS "${_gst_mac_expanded}" "${_gst_mac_devel_expanded}") + file(GLOB _sub_pkg_dirs "${_expanded_dir}/*.pkg") + foreach(_pkg_dir IN LISTS _sub_pkg_dirs) + if(EXISTS "${_pkg_dir}/Payload") + file(GLOB _payload_entries "${_pkg_dir}/Payload/*") + foreach(_entry IN LISTS _payload_entries) + cmake_path(GET _entry FILENAME _entry_name) + if(_entry_name STREQUAL "Headers") + continue() + endif() + cmake_path(IS_PREFIX _gst_mac_root "${_gst_mac_root}/${_entry_name}" NORMALIZE _is_safe) + if(NOT _is_safe) + message(FATAL_ERROR "GStreamer: Path traversal detected in extracted SDK entry: ${_entry_name}") + endif() + if(IS_SYMLINK "${_entry}") + get_filename_component(_link_target "${_entry}" REALPATH) + cmake_path(IS_PREFIX _expanded_dir "${_link_target}" NORMALIZE _link_safe) + if(NOT _link_safe) + message(FATAL_ERROR "GStreamer: SDK entry '${_entry_name}' symlinks outside the payload: ${_link_target}") + endif() + endif() + file(COPY "${_entry}" DESTINATION "${_gst_mac_root}") + endforeach() + endif() + endforeach() + endforeach() + file(TOUCH "${_gst_mac_root}/.merge_complete") + endif() + + if(NOT EXISTS "${_gst_mac_required_plugin_dir}" + OR NOT EXISTS "${_gst_mac_required_include_dir}" + OR NOT EXISTS "${_gst_mac_required_pc_file}") + file(REMOVE_RECURSE "${_gst_mac_root}" "${_gst_mac_expanded}" "${_gst_mac_devel_expanded}") + message(FATAL_ERROR "Downloaded macOS GStreamer SDK is incomplete " + "(required runtime/devel artifacts were not found).\n" + "Install manually from https://gstreamer.freedesktop.org/download/ or set GStreamer_ROOT_DIR.") + endif() + + set(GStreamer_ROOT_DIR "${_gst_mac_root}") + set(GStreamer_USE_FRAMEWORK OFF) + set(GStreamer_AUTO_DOWNLOADED TRUE) + endif() + + # User-supplied GStreamer_ROOT_DIR skips detection above; re-derive flat-vs-framework from the layout so a + # flat (Homebrew/CPM) tree doesn't hit the framework branch and FATAL_ERROR. + if(GStreamer_USE_FRAMEWORK AND NOT GStreamer_ROOT_DIR MATCHES "\\.framework" + AND EXISTS "${GStreamer_ROOT_DIR}/lib/gstreamer-1.0") + message(STATUS "GStreamer: ${GStreamer_ROOT_DIR} is a flat SDK layout; disabling framework mode") + set(GStreamer_USE_FRAMEWORK OFF) + endif() + + if(GStreamer_USE_FRAMEWORK) + if(NOT DEFINED GSTREAMER_FRAMEWORK_PATH) + cmake_path(CONVERT "${GStreamer_ROOT_DIR}/../.." TO_CMAKE_PATH_LIST _gst_mac_fwpath NORMALIZE) + else() + set(_gst_mac_fwpath "${GSTREAMER_FRAMEWORK_PATH}") + endif() + gstreamer_create_layout_target( + SDK_ROOT "${GStreamer_ROOT_DIR}" + TYPE FRAMEWORK + FRAMEWORK_BUNDLE "${_gst_mac_fwpath}" + ) + else() + gstreamer_create_layout_target( + SDK_ROOT "${GStreamer_ROOT_DIR}" + TYPE FLAT + ) + endif() + + if(GStreamer_USE_FRAMEWORK) + gstreamer_apply_pkgconfig_env( + MODE SDK + PKG_CONFIG_EXE "${GStreamer_ROOT_DIR}/bin/pkg-config" + LIBDIR "${GSTREAMER_LIB_PATH}/pkgconfig" "${GSTREAMER_PLUGIN_PATH}/pkgconfig" + ) + else() + _qgc_find_apple_pkg_config(PKG_CONFIG_EXECUTABLE) + gstreamer_apply_pkgconfig_env( + MODE SDK + LIBDIR "${GSTREAMER_LIB_PATH}/pkgconfig" "${GSTREAMER_PLUGIN_PATH}/pkgconfig" + ) + endif() +endmacro() + +# Apple framework overlay — when GStreamer.framework is in use, link the framework +# directly and add its Headers to the include path. Called from the pkg-config +# branch only (iOS uses xcframework which bypasses this entirely). +# Converted from macro to function in PR5: only mutates GLOBAL imported targets +# and the layout target, so PARENT_SCOPE leakage isn't needed. +function(_qgc_apply_macos_framework_overlay) + if(NOT GSTREAMER_FRAMEWORK_PATH) + message(FATAL_ERROR + "_qgc_apply_macos_framework_overlay requires GSTREAMER_FRAMEWORK_PATH; " + "ensure gstreamer_create_layout_target(TYPE FRAMEWORK FRAMEWORK_BUNDLE ...) ran first.") + endif() + set(CMAKE_FIND_FRAMEWORK ONLY) + cmake_path(GET GSTREAMER_FRAMEWORK_PATH PARENT_PATH _gst_framework_parent) + find_library(_gst_framework_bundle GStreamer + PATHS + "${_gst_framework_parent}" + "${GSTREAMER_FRAMEWORK_PATH}" + "/Library/Frameworks" + ) + if(NOT _gst_framework_bundle) + message(FATAL_ERROR "GStreamer: Could not locate GStreamer.framework") + endif() + target_link_libraries(GStreamer::GStreamer INTERFACE ${_gst_framework_bundle}) + target_include_directories(GStreamer::GStreamer INTERFACE "${_gst_framework_bundle}/Headers") + target_compile_definitions(GStreamer::GStreamer INTERFACE QGC_GST_MACOS_FRAMEWORK) + # Stash the discovered framework bundle path on GStreamer::Layout so the + # install helpers read it via gstreamer_layout_get instead of via cache pickup. + gstreamer_layout_set(FRAMEWORK_BUNDLE "${_gst_framework_bundle}") +endfunction() diff --git a/cmake/GStreamer/platform/PkgConfigTargets.cmake b/cmake/GStreamer/platform/PkgConfigTargets.cmake new file mode 100644 index 000000000000..52e9d15c371a --- /dev/null +++ b/cmake/GStreamer/platform/PkgConfigTargets.cmake @@ -0,0 +1,65 @@ +# Pkg-config target creation — shared by Linux/Windows/macOS/Android +# (everything except iOS xcframework). Pkg-config env management lives in +# cmake/GStreamer/PkgConfig.cmake (gstreamer_apply_pkgconfig_env). Macro form +# is required so GStreamer_FOUND / IMPORTED targets / cache vars propagate to +# the caller. + +macro(_qgc_create_pkgconfig_targets) + +# MACOS is set out-of-band by cmake/Toolchain.cmake; the overlay below gates on +# it, so warn loudly if it's unset rather than silently no-op the overlay. +if(APPLE AND NOT IOS AND NOT DEFINED MACOS) + message(WARNING + "GStreamer: MACOS is undefined on an Apple host — cmake/Toolchain.cmake " + "must run before _qgc_create_pkgconfig_targets or the macOS framework " + "overlay will be skipped.") +endif() + +if(GStreamer_USE_STATIC_LIBS AND NOT "--static" IN_LIST PKG_CONFIG_ARGN) + list(APPEND PKG_CONFIG_ARGN "--static") +endif() + +find_package(PkgConfig REQUIRED QUIET) + +list(PREPEND CMAKE_PREFIX_PATH ${GStreamer_ROOT_DIR}) + +# FORCE so derived flags from the helpers above (--static, --dont-define-prefix, +# --define-variable=…) propagate to the cache; guards above prevent duplicate appends. +set(PKG_CONFIG_ARGN "${PKG_CONFIG_ARGN}" CACHE STRING "Arguments to supply to pkg-config" FORCE) + +# CPM creates a stub gstreamer-config.cmake that shadows our vendored module +if(DEFINED CMAKE_FIND_PACKAGE_REDIRECTS_DIR) + file(REMOVE + "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/gstreamer-config.cmake" + "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/gstreamer-config-version.cmake" + "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/GStreamerConfig.cmake" + "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/GStreamerConfigVersion.cmake" + ) +endif() +unset(GStreamer_FOUND CACHE) +unset(GStreamer_FOUND) +# Use the per-platform version chosen at the top of this file as the floor — Linux honors the distro +# minimum (1.20), bundled-SDK platforms enforce ≥1.28 so a downgraded SDK fails configure loudly. +find_package(GStreamer ${GStreamer_FIND_VERSION} REQUIRED MODULE) + +# Apple framework overlay — defined in platform/MacOS.cmake; iOS uses xcframework. +if(MACOS AND GStreamer_USE_FRAMEWORK AND TARGET GStreamer::GStreamer) + _qgc_apply_macos_framework_overlay() +endif() + +# Android mobile-target setup: bundle plugins + optional fonts/CA assets into the +# GStreamerMobile target. iOS uses the xcframework path upstream, so this is Android-only. +if(ANDROID) + set(_mobile_components ${GSTREAMER_PLUGINS} mobile) + if(EXISTS "${GStreamer_ROOT_DIR}/share/gst-android/ndk-build/fontconfig") + list(APPEND _mobile_components fonts) + endif() + if(EXISTS "${GStreamer_ROOT_DIR}/etc/ssl/certs/ca-certificates.crt") + list(APPEND _mobile_components ca_certificates) + endif() + set(GStreamerMobile_FIND_COMPONENTS ${_mobile_components}) + _qgc_create_android_mobile_target() + set(GStreamerMobile_FOUND TRUE) +endif() + +endmacro() diff --git a/cmake/GStreamer/platform/Windows.cmake b/cmake/GStreamer/platform/Windows.cmake new file mode 100644 index 000000000000..3a33398855a1 --- /dev/null +++ b/cmake/GStreamer/platform/Windows.cmake @@ -0,0 +1,198 @@ +# Windows GStreamer SDK discovery — invoked by Orchestrator.cmake. + +function(_qgc_windows_sdk_complete ROOT_DIR OUT_VAR) + set(_required_paths + "bin/pkg-config.exe" + "include/gstreamer-1.0" + "lib/gstreamer-1.0" + "lib/pkgconfig/gstreamer-1.0.pc" + ) + + set(_is_complete TRUE) + foreach(_path IN LISTS _required_paths) + if(NOT EXISTS "${ROOT_DIR}/${_path}") + set(_is_complete FALSE) + break() + endif() + endforeach() + + set(${OUT_VAR} "${_is_complete}" PARENT_SCOPE) +endfunction() + +function(_qgc_find_win_sdk_root EXTRACTED_DIR OUT_VAR) + if(EXISTS "${EXTRACTED_DIR}/bin/pkg-config.exe") + set(${OUT_VAR} "${EXTRACTED_DIR}" PARENT_SCOPE) + return() + endif() + file(GLOB_RECURSE _pkg_config_files "${EXTRACTED_DIR}/pkg-config.exe") + if(_pkg_config_files) + # A bundled second pkg-config.exe would yield a non-canonical root via + # element 0 — pick the shortest path so the canonical SDK root wins. + set(_first_pkg_config "") + set(_shortest_depth -1) + foreach(_candidate IN LISTS _pkg_config_files) + string(REGEX MATCHALL "/" _candidate_slashes "${_candidate}") + list(LENGTH _candidate_slashes _candidate_depth) + # Lower-case the equal-depth tiebreak so it stays deterministic on + # Windows' case-insensitive filesystem. + string(TOLOWER "${_candidate}" _candidate_lc) + string(TOLOWER "${_first_pkg_config}" _first_pkg_config_lc) + if(_shortest_depth EQUAL -1 + OR _candidate_depth LESS _shortest_depth + OR (_candidate_depth EQUAL _shortest_depth AND _candidate_lc STRLESS "${_first_pkg_config_lc}")) + set(_first_pkg_config "${_candidate}") + set(_shortest_depth ${_candidate_depth}) + endif() + endforeach() + cmake_path(GET _first_pkg_config PARENT_PATH _bin_dir) + cmake_path(GET _bin_dir PARENT_PATH _found_root) + set(${OUT_VAR} "${_found_root}" PARENT_SCOPE) + else() + set(${OUT_VAR} "" PARENT_SCOPE) + endif() +endfunction() + +macro(_qgc_discover_windows_sdk) + if(CMAKE_GENERATOR_PLATFORM MATCHES "ARM64|aarch64" OR + (NOT CMAKE_GENERATOR_PLATFORM AND CMAKE_SYSTEM_PROCESSOR MATCHES "ARM64|aarch64")) + set(_gst_win_arch "arm64") + set(_gst_win_platform "windows_msvc_arm64") + else() + set(_gst_win_arch "x86_64") + set(_gst_win_platform "windows_msvc_x64") + endif() + + if(NOT DEFINED GStreamer_ROOT_DIR) + if(_gst_win_arch STREQUAL "arm64") + if(DEFINED ENV{GSTREAMER_1_0_ROOT_ARM64} AND EXISTS "$ENV{GSTREAMER_1_0_ROOT_ARM64}") + set(GStreamer_ROOT_DIR "$ENV{GSTREAMER_1_0_ROOT_ARM64}") + elseif(MSVC AND DEFINED ENV{GSTREAMER_1_0_ROOT_MSVC_ARM64} AND EXISTS "$ENV{GSTREAMER_1_0_ROOT_MSVC_ARM64}") + set(GStreamer_ROOT_DIR "$ENV{GSTREAMER_1_0_ROOT_MSVC_ARM64}") + elseif(EXISTS "C:/Program Files/gstreamer/1.0/msvc_arm64") + set(GStreamer_ROOT_DIR "C:/Program Files/gstreamer/1.0/msvc_arm64") + elseif(EXISTS "C:/gstreamer/1.0/msvc_arm64") + set(GStreamer_ROOT_DIR "C:/gstreamer/1.0/msvc_arm64") + endif() + else() + if(DEFINED ENV{GSTREAMER_1_0_ROOT_X86_64} AND EXISTS "$ENV{GSTREAMER_1_0_ROOT_X86_64}") + set(GStreamer_ROOT_DIR "$ENV{GSTREAMER_1_0_ROOT_X86_64}") + elseif(MSVC AND DEFINED ENV{GSTREAMER_1_0_ROOT_MSVC_X86_64} AND EXISTS "$ENV{GSTREAMER_1_0_ROOT_MSVC_X86_64}") + set(GStreamer_ROOT_DIR "$ENV{GSTREAMER_1_0_ROOT_MSVC_X86_64}") + elseif(EXISTS "C:/Program Files/gstreamer/1.0/msvc_x86_64") + set(GStreamer_ROOT_DIR "C:/Program Files/gstreamer/1.0/msvc_x86_64") + elseif(EXISTS "C:/gstreamer/1.0/msvc_x86_64") + set(GStreamer_ROOT_DIR "C:/gstreamer/1.0/msvc_x86_64") + endif() + endif() + endif() + + if(DEFINED GStreamer_ROOT_DIR AND EXISTS "${GStreamer_ROOT_DIR}") + _qgc_windows_sdk_complete("${GStreamer_ROOT_DIR}" _gst_win_sdk_complete) + if(NOT _gst_win_sdk_complete) + message(WARNING + "Existing GStreamer SDK at ${GStreamer_ROOT_DIR} is incomplete " + "(missing devel/runtime artifacts). Falling back to auto-download.") + unset(GStreamer_ROOT_DIR) + endif() + endif() + + if(NOT DEFINED GStreamer_ROOT_DIR OR NOT EXISTS "${GStreamer_ROOT_DIR}") + if(NOT MSVC OR NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(FATAL_ERROR + "Automatic GStreamer download on Windows requires MSVC 64-bit (x64 or ARM64).\n" + "MinGW and Clang/Windows toolchains are NOT supported by the auto-download " + "path (the MSVC SDK ABI is incompatible).\n" + "Set GStreamer_ROOT_DIR to a complete SDK matching your toolchain/architecture.") + endif() + + gstreamer_resolve_or_download_sdk( + PLATFORM ${_gst_win_platform} + CACHE_SUBDIR "gstreamer-win-${_gst_win_arch}-${GStreamer_FIND_VERSION}" + FILENAME_PRIMARY "gstreamer-${_gst_win_arch}.exe" + CACHE_DIR_OUT _gst_win_cache_dir + ARCHIVE_OUT _gst_win_exe + ) + set(_gst_win_extracted "${_gst_win_cache_dir}/sdk") + + set(_gst_win_root "") + if(EXISTS "${_gst_win_extracted}") + _qgc_find_win_sdk_root("${_gst_win_extracted}" _gst_win_root) + endif() + + if(NOT _gst_win_root + OR NOT EXISTS "${_gst_win_root}/lib/gstreamer-1.0" + OR NOT EXISTS "${_gst_win_root}/lib/pkgconfig/gstreamer-1.0.pc") + file(REMOVE_RECURSE "${_gst_win_extracted}") + cmake_path(NATIVE_PATH _gst_win_exe _gst_win_exe_native) + cmake_path(NATIVE_PATH _gst_win_extracted _gst_win_extracted_native) + set(_gst_win_installer_log "${_gst_win_cache_dir}/installer-${_gst_win_arch}.log") + cmake_path(NATIVE_PATH _gst_win_installer_log _gst_win_installer_log_native) + + # The .exe is auto-run with /VERYSILENT below; with checksum verification + # off it may be unverified — refuse to silently execute it. + if(NOT GStreamer_REQUIRE_CHECKSUM) + message(FATAL_ERROR + "GStreamer: refusing to silently run the downloaded Windows installer " + "'${_gst_win_exe_native}' because GStreamer_REQUIRE_CHECKSUM is OFF, so its " + "integrity was not verified. Re-enable checksum verification " + "(-DGStreamer_REQUIRE_CHECKSUM=ON) or install the SDK manually and point " + "GStreamer_ROOT_DIR at it.") + endif() + + message(STATUS "Installing GStreamer ${_gst_win_arch} SDK (silent)...") + execute_process( + COMMAND "${_gst_win_exe_native}" + /VERYSILENT /SUPPRESSMSGBOXES /SP- /NORESTART + "/LOG=${_gst_win_installer_log_native}" + "/DIR=${_gst_win_extracted_native}" + RESULT_VARIABLE _installer_rc + ERROR_VARIABLE _installer_err + TIMEOUT 600 + ) + if(NOT _installer_rc EQUAL 0) + file(REMOVE_RECURSE "${_gst_win_extracted}") + message(FATAL_ERROR "GStreamer installer failed (exit code: ${_installer_rc}).\n" + "stderr: ${_installer_err}\n" + "See installer log: ${_gst_win_installer_log}") + endif() + + _qgc_find_win_sdk_root("${_gst_win_extracted}" _gst_win_root) + if(_gst_win_root AND NOT _gst_win_root STREQUAL "${_gst_win_extracted}") + message(STATUS "GStreamer: SDK root at ${_gst_win_root}") + endif() + + set(_gst_win_post_complete FALSE) + if(_gst_win_root) + _qgc_windows_sdk_complete("${_gst_win_root}" _gst_win_post_complete) + endif() + if(NOT _gst_win_post_complete) + file(REMOVE_RECURSE "${_gst_win_extracted}") + message(FATAL_ERROR "GStreamer SDK extracted but required files are missing.\n" + "Delete ${_gst_win_cache_dir} and re-run cmake to retry.") + endif() + endif() + + set(GStreamer_ROOT_DIR "${_gst_win_root}") + set(GStreamer_AUTO_DOWNLOADED TRUE) + endif() + + gstreamer_create_layout_target( + SDK_ROOT "${GStreamer_ROOT_DIR}" + TYPE FLAT + ) + # Strip any prior define-variable entries (from a cached PKG_CONFIG_ARGN on + # reconfigure) before re-deriving them, so values can't double-apply or go stale. + list(FILTER PKG_CONFIG_ARGN EXCLUDE REGEX "^--define-variable=(prefix|libdir|includedir)=") + list(APPEND PKG_CONFIG_ARGN + "--define-variable=prefix=${GStreamer_ROOT_DIR}" + "--define-variable=libdir=${GSTREAMER_LIB_PATH}" + "--define-variable=includedir=${GSTREAMER_INCLUDE_PATH}" + ) + + gstreamer_apply_pkgconfig_env( + MODE SDK + PKG_CONFIG_EXE "${GStreamer_ROOT_DIR}/bin/pkg-config.exe" + LIBDIR "${GSTREAMER_LIB_PATH}/pkgconfig" "${GSTREAMER_PLUGIN_PATH}/pkgconfig" + DONT_DEFINE_PREFIX + ) +endmacro() diff --git a/cmake/GStreamer/tests/CMakeLists.txt b/cmake/GStreamer/tests/CMakeLists.txt new file mode 100644 index 000000000000..84be765239d1 --- /dev/null +++ b/cmake/GStreamer/tests/CMakeLists.txt @@ -0,0 +1,64 @@ +# Standalone CTest harness for cmake/GStreamer/* — runs cmake -P scripts +# that exercise the focused submodules without configuring/building the +# project. Wired into the build via add_subdirectory() from the root +# CMakeLists.txt under if(QGC_BUILD_TESTING). +# +# Each test is a self-contained .cmake script that: +# 1. Adds cmake/GStreamer to CMAKE_MODULE_PATH +# 2. include()s the focused submodule under test +# 3. Invokes _assert.cmake helpers; aborts on first mismatch. + +include_guard(GLOBAL) + +set(_qgc_findmod_tests + test_json + test_components + test_download + test_layout + test_plugin_scan + test_plugin_policy + test_path_list + test_pkgconfig_env + test_download_helper + test_install_filter + test_windows_runtime_libproxy + test_install_libs_guard_windows + test_orchestrator_idempotent +) + +foreach(_t IN LISTS _qgc_findmod_tests) + add_test( + NAME FindModule.${_t} + COMMAND "${CMAKE_COMMAND}" -P "${CMAKE_CURRENT_SOURCE_DIR}/${_t}.cmake" + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" + ) + set_tests_properties(FindModule.${_t} PROPERTIES + LABELS "Unit;FindModule" + TIMEOUT 15 + FAIL_REGULAR_EXPRESSION "ASSERT|FATAL_ERROR|CMake Error" + ) +endforeach() + +# Negative tests: each script is expected to abort (FATAL_ERROR / non-zero exit). +# WILL_FAIL inverts the verdict, so a clean exit here is the real failure. No +# FAIL_REGULAR_EXPRESSION — the FATAL_ERROR output is the expected success path. +set(_qgc_findmod_willfail_tests + test_components_malformed + test_plugin_policy_errors + test_plugin_policy_conflict + test_install_libs_guard + test_download_win_version_guard +) + +foreach(_t IN LISTS _qgc_findmod_willfail_tests) + add_test( + NAME FindModule.${_t} + COMMAND "${CMAKE_COMMAND}" -P "${CMAKE_CURRENT_SOURCE_DIR}/${_t}.cmake" + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" + ) + set_tests_properties(FindModule.${_t} PROPERTIES + LABELS "Unit;FindModule" + TIMEOUT 15 + WILL_FAIL TRUE + ) +endforeach() diff --git a/cmake/GStreamer/tests/_assert.cmake b/cmake/GStreamer/tests/_assert.cmake new file mode 100644 index 000000000000..e013f5a84043 --- /dev/null +++ b/cmake/GStreamer/tests/_assert.cmake @@ -0,0 +1,38 @@ +# Tiny assertion helpers for cmake -P test scripts. +# Each helper aborts the script via message(FATAL_ERROR) on failure so ctest +# captures the mismatch and the test is marked failed. + +function(qgc_test_assert_streq label expected actual) + if(NOT "${actual}" STREQUAL "${expected}") + message(FATAL_ERROR "ASSERT [${label}]: expected='${expected}' actual='${actual}'") + endif() +endfunction() + +function(qgc_test_assert_in_list label expected_item list_var) + if(NOT "${expected_item}" IN_LIST ${list_var}) + message(FATAL_ERROR "ASSERT [${label}]: '${expected_item}' not in ${list_var}=${${list_var}}") + endif() +endfunction() + +function(qgc_test_assert_not_in_list label unexpected_item list_var) + if("${unexpected_item}" IN_LIST ${list_var}) + message(FATAL_ERROR "ASSERT [${label}]: '${unexpected_item}' should not be in ${list_var}=${${list_var}}") + endif() +endfunction() + +function(qgc_test_assert_target label tgt) + if(NOT TARGET ${tgt}) + message(FATAL_ERROR "ASSERT [${label}]: target '${tgt}' was not created") + endif() +endfunction() + +function(qgc_test_assert_property label tgt prop expected) + get_target_property(_actual ${tgt} ${prop}) + if(NOT "${_actual}" STREQUAL "${expected}") + message(FATAL_ERROR "ASSERT [${label}]: ${tgt}.${prop} expected='${expected}' actual='${_actual}'") + endif() +endfunction() + +function(qgc_test_pass label) + message(STATUS "PASS: ${label}") +endfunction() diff --git a/cmake/GStreamer/tests/test_components.cmake b/cmake/GStreamer/tests/test_components.cmake new file mode 100644 index 000000000000..184678356801 --- /dev/null +++ b/cmake/GStreamer/tests/test_components.cmake @@ -0,0 +1,151 @@ +# Test: GSTREAMER_COMPONENT_REGISTRY, gstreamer_build_apis_and_deps. + +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(Components) + +# ── resolve_component: hit by ComponentName ───────────────────────────────── +gstreamer_resolve_component(Base _n _a _p _m) +qgc_test_assert_streq("Base.name" "Base" "${_n}") +qgc_test_assert_streq("Base.api" "api_base" "${_a}") +qgc_test_assert_streq("Base.pc" "gstreamer-base-1.0" "${_p}") +qgc_test_assert_streq("Base.mandatory" "1" "${_m}") +qgc_test_pass("resolve_component by ComponentName") + +# ── resolve_component: hit by api_name ────────────────────────────────────── +gstreamer_resolve_component(api_video _n _a _p _m) +qgc_test_assert_streq("api_video.name" "Video" "${_n}") +qgc_test_assert_streq("api_video.api" "api_video" "${_a}") +qgc_test_assert_streq("api_video.pc" "gstreamer-video-1.0" "${_p}") +qgc_test_pass("resolve_component by api_name") + +# ── resolve_component: optional component ─────────────────────────────────── +gstreamer_resolve_component(App _n _a _p _m) +qgc_test_assert_streq("App.mandatory" "0" "${_m}") +qgc_test_pass("resolve_component mandatory flag") + +# ── resolve_component: Core (no api / no pc) ──────────────────────────────── +gstreamer_resolve_component(Core _n _a _p _m) +qgc_test_assert_streq("Core.name" "Core" "${_n}") +qgc_test_assert_streq("Core.api" "" "${_a}") +qgc_test_assert_streq("Core.pc" "" "${_p}") +qgc_test_assert_streq("Core.mandatory" "1" "${_m}") +qgc_test_pass("resolve_component Core") + +# ── resolve_component: unknown api fallback ───────────────────────────────── +gstreamer_resolve_component(api_foo_bar _n _a _p _m) +qgc_test_assert_streq("unknown api name" "" "${_n}") +qgc_test_assert_streq("unknown api" "api_foo_bar" "${_a}") +qgc_test_assert_streq("unknown api pc" "gstreamer-foo-bar-1.0" "${_p}") +qgc_test_pass("resolve_component fallback") + +# ── resolve_component: empty query must not match Core's empty api field ───── +gstreamer_resolve_component("" _n _a _p _m) +qgc_test_assert_streq("empty query name" "" "${_n}") +qgc_test_assert_streq("empty query api" "" "${_a}") +qgc_test_assert_streq("empty query pc" "" "${_p}") +qgc_test_pass("resolve_component empty query") + +# ── mandatory_components ──────────────────────────────────────────────────── +gstreamer_mandatory_components(_mn _ma) +qgc_test_assert_in_list("mandatory: Core" Core _mn) +qgc_test_assert_in_list("mandatory: Base" Base _mn) +qgc_test_assert_in_list("mandatory: Gl" Gl _mn) +qgc_test_assert_in_list("mandatory: GlPrototypes" GlPrototypes _mn) +qgc_test_assert_in_list("mandatory: Rtsp" Rtsp _mn) +qgc_test_assert_in_list("mandatory: Video" Video _mn) +qgc_test_assert_not_in_list("optional: App" App _mn) +qgc_test_assert_in_list("mandatory api: api_base" api_base _ma) +qgc_test_assert_not_in_list("Core has no api" Core _ma) +qgc_test_pass("mandatory_components") + +# ── component_to_api: CamelCase → api_snake_case (digit/letter boundaries) ──── +gstreamer_component_to_api("GlPrototypes" _c2a_glp) +qgc_test_assert_streq("GlPrototypes -> api_gl_prototypes" "api_gl_prototypes" "${_c2a_glp}") +gstreamer_component_to_api("HwBuffers" _c2a_hw) +qgc_test_assert_streq("HwBuffers -> api_hw_buffers" "api_hw_buffers" "${_c2a_hw}") +gstreamer_component_to_api("App" _c2a_app) +qgc_test_assert_streq("App -> api_app" "api_app" "${_c2a_app}") +qgc_test_pass("component_to_api CamelCase") + +# ── build_apis_and_deps: minimum (Core only) ──────────────────────────────── +# Reset platform vars to a known posix host +set(WIN32 OFF) +set(ANDROID OFF) +set(IOS OFF) + +gstreamer_build_apis_and_deps(_apis _deps Core) +qgc_test_assert_in_list("seed: api_base" api_base _apis) +qgc_test_assert_in_list("seed: api_gl" api_gl _apis) +qgc_test_assert_in_list("seed: api_gl_prototypes" api_gl_prototypes _apis) +qgc_test_assert_in_list("seed: api_rtsp" api_rtsp _apis) +qgc_test_assert_in_list("seed: api_video" api_video _apis) +qgc_test_assert_in_list("seed deps: gstreamer-base-1.0" gstreamer-base-1.0 _deps) +qgc_test_pass("build_apis_and_deps Core seed") + +# ── build_apis_and_deps: extra component (App) ────────────────────────────── +gstreamer_build_apis_and_deps(_apis2 _deps2 Core App) +qgc_test_assert_in_list("App -> api_app" api_app _apis2) +qgc_test_assert_in_list("App -> gstreamer-app-1.0" gstreamer-app-1.0 _deps2) +qgc_test_pass("build_apis_and_deps App") + +# ── build_apis_and_deps: CamelCase -> snake_case ──────────────────────────── +gstreamer_build_apis_and_deps(_apis3 _deps3 Core GlPrototypes) +qgc_test_assert_in_list("GlPrototypes -> api_gl_prototypes" api_gl_prototypes _apis3) +qgc_test_pass("CamelCase -> snake_case") + +# ── build_apis_and_deps: Allocators (Linux-only callsite, but mapping is platform-neutral) +gstreamer_build_apis_and_deps(_apis4 _deps4 Core Allocators) +qgc_test_assert_in_list("Allocators -> api_allocators" api_allocators _apis4) +qgc_test_assert_in_list("Allocators -> gstreamer-allocators-1.0" gstreamer-allocators-1.0 _deps4) +qgc_test_pass("Allocators mapping") + +# ── build_apis_and_deps: platform extras (WIN32) ──────────────────────────── +set(WIN32 ON) +gstreamer_build_apis_and_deps(_apis_win _deps_win Core) +qgc_test_assert_in_list("WIN32 -> graphene-1.0" graphene-1.0 _deps_win) +set(WIN32 OFF) +qgc_test_pass("WIN32 platform extras") + +# ── build_apis_and_deps: platform extras (ANDROID) ────────────────────────── +set(ANDROID ON) +gstreamer_build_apis_and_deps(_apis_a _deps_a Core) +qgc_test_assert_in_list("ANDROID -> gio-2.0" gio-2.0 _deps_a) +qgc_test_assert_in_list("ANDROID -> gmodule-2.0" gmodule-2.0 _deps_a) +qgc_test_assert_in_list("ANDROID -> zlib" zlib _deps_a) +set(ANDROID OFF) +qgc_test_pass("ANDROID platform extras") + +# ── build_apis_and_deps: idempotence (same component twice) ───────────────── +gstreamer_build_apis_and_deps(_apis_idem _deps_idem Core App App) +list(LENGTH _apis_idem _apis_idem_len) +# dedup: 5 seed + 1 App = 6 +qgc_test_assert_streq("idempotent apis length" "6" "${_apis_idem_len}") +qgc_test_pass("idempotence") + +# ── platform_plugin_attrs ─────────────────────────────────────────────────── +# Reset platform flags +set(WIN32 OFF) +set(APPLE OFF) +gstreamer_platform_plugin_attrs(_ext _prefix _glob) +qgc_test_assert_streq("linux ext" "so" "${_ext}") +qgc_test_assert_streq("linux prefix" "libgst" "${_prefix}") +qgc_test_assert_streq("linux glob" "libgst*.so" "${_glob}") +qgc_test_pass("platform_plugin_attrs linux") + +set(WIN32 ON) +gstreamer_platform_plugin_attrs(_ext _prefix _glob) +qgc_test_assert_streq("win ext" "dll" "${_ext}") +qgc_test_assert_streq("win prefix" "gst" "${_prefix}") +qgc_test_assert_streq("win glob" "gst*.dll" "${_glob}") +set(WIN32 OFF) +qgc_test_pass("platform_plugin_attrs windows") + +set(APPLE ON) +gstreamer_platform_plugin_attrs(_ext _prefix _glob) +qgc_test_assert_streq("apple ext" "dylib" "${_ext}") +qgc_test_assert_streq("apple prefix" "libgst" "${_prefix}") +qgc_test_assert_streq("apple glob" "libgst*.dylib" "${_glob}") +set(APPLE OFF) +qgc_test_pass("platform_plugin_attrs apple") diff --git a/cmake/GStreamer/tests/test_components_malformed.cmake b/cmake/GStreamer/tests/test_components_malformed.cmake new file mode 100644 index 000000000000..6a9ec993c080 --- /dev/null +++ b/cmake/GStreamer/tests/test_components_malformed.cmake @@ -0,0 +1,5 @@ +# Negative test (WILL_FAIL): a registry entry with the wrong field count must FATAL. +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include(Components) +_gstreamer_registry_split("OnlyThree:fields:here" _n _a _p _m) diff --git a/cmake/GStreamer/tests/test_download.cmake b/cmake/GStreamer/tests/test_download.cmake new file mode 100644 index 000000000000..20eb830ab6ea --- /dev/null +++ b/cmake/GStreamer/tests/test_download.cmake @@ -0,0 +1,57 @@ +# Test: gstreamer_get_package_url, gstreamer_get_s3_mirror_url. +# (qgc_parse_expected_hash now lives in cmake/modules/Download.cmake and is +# covered by test_download_helper.cmake.) +# Network-touching helpers (resilient_download / fetch_checksum) are NOT +# exercised here — that belongs in an integration test gated by a Network +# label. + +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH + "${CMAKE_CURRENT_LIST_DIR}/.." + "${CMAKE_CURRENT_LIST_DIR}/../../modules" +) +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(Download) + +# ── package_url for known platforms ───────────────────────────────────────── +gstreamer_get_package_url(android 1.28.2 _u_android) +qgc_test_assert_streq("android url" "https://gstreamer.freedesktop.org/data/pkg/android/1.28.2/gstreamer-1.0-android-universal-1.28.2.tar.xz" "${_u_android}") + +gstreamer_get_package_url(ios 1.28.2 _u_ios) +qgc_test_assert_streq("ios url" "https://gstreamer.freedesktop.org/data/pkg/ios/1.28.2/gstreamer-1.0-devel-1.28.2-ios-universal.pkg" "${_u_ios}") + +gstreamer_get_package_url(macos 1.28.2 _u_mac) +qgc_test_assert_streq("macos url" "https://gstreamer.freedesktop.org/data/pkg/macos/1.28.2/gstreamer-1.0-1.28.2-universal.pkg" "${_u_mac}") + +gstreamer_get_package_url(macos_devel 1.28.2 _u_macd) +qgc_test_assert_streq("macos_devel url" "https://gstreamer.freedesktop.org/data/pkg/macos/1.28.2/gstreamer-1.0-devel-1.28.2-universal.pkg" "${_u_macd}") + +gstreamer_get_package_url(windows_msvc_x64 1.28.2 _u_winx) +qgc_test_assert_streq("windows x64 url" "https://gstreamer.freedesktop.org/data/pkg/windows/1.28.2/msvc/gstreamer-1.0-msvc-x86_64-1.28.2.exe" "${_u_winx}") + +gstreamer_get_package_url(windows_msvc_arm64 1.28.2 _u_wina) +qgc_test_assert_streq("windows arm64 url" "https://gstreamer.freedesktop.org/data/pkg/windows/1.28.2/msvc/gstreamer-1.0-msvc-arm64-1.28.2.exe" "${_u_wina}") + +qgc_test_pass("get_package_url known platforms") + +# ── s3 mirror routing ─────────────────────────────────────────────────────── +gstreamer_get_s3_mirror_url(android 1.28.2 _s3_android) +qgc_test_assert_streq("s3 android" "https://qgroundcontrol.s3.us-west-2.amazonaws.com/dependencies/gstreamer/android/gstreamer-1.0-android-universal-1.28.2.tar.xz" "${_s3_android}") + +gstreamer_get_s3_mirror_url(macos 1.28.2 _s3_mac) +qgc_test_assert_streq("s3 macos" "https://qgroundcontrol.s3.us-west-2.amazonaws.com/dependencies/gstreamer/macos/gstreamer-1.0-1.28.2-universal.pkg" "${_s3_mac}") + +gstreamer_get_s3_mirror_url(windows_msvc_x64 1.28.2 _s3_win) +qgc_test_assert_streq("s3 windows" "https://qgroundcontrol.s3.us-west-2.amazonaws.com/dependencies/gstreamer/windows/gstreamer-1.0-msvc-x86_64-1.28.2.exe" "${_s3_win}") + +# Unsupported platform returns empty +gstreamer_get_s3_mirror_url(linux 1.28.2 _s3_linux) +qgc_test_assert_streq("s3 linux empty (no mirror)" "" "${_s3_linux}") + +# macos_devel mirror routes to the macos dir with the devel filename +gstreamer_get_s3_mirror_url(macos_devel 1.28.2 _s3_macd) +qgc_test_assert_streq("s3 macos_devel" + "https://qgroundcontrol.s3.us-west-2.amazonaws.com/dependencies/gstreamer/macos/gstreamer-1.0-devel-1.28.2-universal.pkg" + "${_s3_macd}") + +qgc_test_pass("get_s3_mirror_url routing") diff --git a/cmake/GStreamer/tests/test_download_helper.cmake b/cmake/GStreamer/tests/test_download_helper.cmake new file mode 100644 index 000000000000..46d8c2ffa76f --- /dev/null +++ b/cmake/GStreamer/tests/test_download_helper.cmake @@ -0,0 +1,61 @@ +# Test: qgc_resilient_download cache-hit + checksum branches, and +# qgc_parse_expected_hash validation. Network paths aren't exercised in +# script mode — full download flow is covered by per-platform configures. + +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/../../modules") +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(Download) + +set(_sandbox "${CMAKE_BINARY_DIR}/qgc_download_test") +file(REMOVE_RECURSE "${_sandbox}") +file(MAKE_DIRECTORY "${_sandbox}") + +# ── parse_expected_hash: happy path ───────────────────────────────────────── +qgc_parse_expected_hash("SHA256=abcdef" _algo _hash) +qgc_test_assert_streq("algo" "SHA256" "${_algo}") +qgc_test_assert_streq("hash" "abcdef" "${_hash}") +qgc_test_pass("parse_expected_hash valid") + +# ── cache hit, no hash ────────────────────────────────────────────────────── +file(WRITE "${_sandbox}/cached.txt" "hello world") +qgc_resilient_download( + FILENAME cached.txt + DESTINATION_DIR "${_sandbox}" + RESULT_VAR _r1 + URLS "https://invalid.example.invalid/never.txt" + LOG_TAG test +) +qgc_test_assert_streq("cache hit no-hash" "${_sandbox}/cached.txt" "${_r1}") +qgc_test_pass("cache hit no-hash") + +# ── cache hit, valid hash ────────────────────────────────────────────────── +file(SHA256 "${_sandbox}/cached.txt" _real_hash) +qgc_resilient_download( + FILENAME cached.txt + DESTINATION_DIR "${_sandbox}" + RESULT_VAR _r2 + URLS "https://invalid.example.invalid/never.txt" + EXPECTED_HASH "SHA256=${_real_hash}" + LOG_TAG test +) +qgc_test_assert_streq("cache hit valid hash" "${_sandbox}/cached.txt" "${_r2}") +qgc_test_pass("cache hit valid hash") + +# ── ALLOW_FAILURE keeps configure alive when every URL fails ──────────────── +qgc_resilient_download( + FILENAME nonexistent.bin + DESTINATION_DIR "${_sandbox}" + RESULT_VAR _r3 + URLS "https://invalid.example.invalid/missing.bin" + LOG_TAG test + ALLOW_FAILURE + TIMEOUT 1 + INACTIVITY_TIMEOUT 1 + RETRIES 1 + RETRY_BACKOFF 0 +) +qgc_test_assert_streq("ALLOW_FAILURE empty result" "" "${_r3}") +qgc_test_pass("ALLOW_FAILURE") + +file(REMOVE_RECURSE "${_sandbox}") diff --git a/cmake/GStreamer/tests/test_download_win_version_guard.cmake b/cmake/GStreamer/tests/test_download_win_version_guard.cmake new file mode 100644 index 000000000000..a06e7dd49d06 --- /dev/null +++ b/cmake/GStreamer/tests/test_download_win_version_guard.cmake @@ -0,0 +1,5 @@ +# Negative test (WILL_FAIL): Windows GStreamer SDK older than 1.28 must FATAL. +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/.." "${CMAKE_CURRENT_LIST_DIR}/../../modules") +include(Download) +gstreamer_get_package_url(windows_msvc_x64 1.27.0 _u) diff --git a/cmake/GStreamer/tests/test_install_filter.cmake b/cmake/GStreamer/tests/test_install_filter.cmake new file mode 100644 index 000000000000..3d7926e7c23f --- /dev/null +++ b/cmake/GStreamer/tests/test_install_filter.cmake @@ -0,0 +1,23 @@ +# Test: _gstreamer_filter_plugin_paths — prefix-collision boundary + regex escaping. +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(Install) + +set(GSTREAMER_PLUGINS video coreelements) + +set(_paths + /sdk/libgstvideo.so + /sdk/libgstvideoconvert.so + /sdk/libgstcoreelements.so + /sdk/libgstbadplugin.so +) +_gstreamer_filter_plugin_paths("libgst" "${_paths}" _kept) + +qgc_test_assert_in_list("keeps exact video" "/sdk/libgstvideo.so" _kept) +qgc_test_assert_in_list("keeps coreelements" "/sdk/libgstcoreelements.so" _kept) +list(FIND _kept "/sdk/libgstvideoconvert.so" _vc_idx) +qgc_test_assert_streq("rejects videoconvert (boundary)" "-1" "${_vc_idx}") +list(FIND _kept "/sdk/libgstbadplugin.so" _bad_idx) +qgc_test_assert_streq("rejects non-allowlisted" "-1" "${_bad_idx}") +qgc_test_pass("filter_plugin_paths boundary") diff --git a/cmake/GStreamer/tests/test_install_libs_guard.cmake b/cmake/GStreamer/tests/test_install_libs_guard.cmake new file mode 100644 index 000000000000..edfbcf9e6092 --- /dev/null +++ b/cmake/GStreamer/tests/test_install_libs_guard.cmake @@ -0,0 +1,14 @@ +# Negative test (WILL_FAIL): copying libs from a system prefix must FATAL. +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include(Install) + +if(WIN32) + set(_system_lib_dir "C:/Windows/System32") + set(_system_lib_ext "dll") +else() + set(_system_lib_dir "/usr/lib") + set(_system_lib_ext "so") +endif() + +gstreamer_install_libs(SOURCE_DIR "${_system_lib_dir}" DEST_DIR "x" EXTENSION "${_system_lib_ext}") diff --git a/cmake/GStreamer/tests/test_install_libs_guard_windows.cmake b/cmake/GStreamer/tests/test_install_libs_guard_windows.cmake new file mode 100644 index 000000000000..078e035c0e2e --- /dev/null +++ b/cmake/GStreamer/tests/test_install_libs_guard_windows.cmake @@ -0,0 +1,18 @@ +# Test: Windows official GStreamer SDK under Program Files is not treated as a system DLL source. +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(Install) + +set(QGC_GSTREAMER_TEST_WIN32 TRUE) + +_gstreamer_is_blocked_lib_copy_source("C:/Program Files/gstreamer/1.0/msvc_x86_64/bin" _blocked_gst) +qgc_test_assert_streq("allows Program Files GStreamer SDK" FALSE "${_blocked_gst}") + +_gstreamer_is_blocked_lib_copy_source("C:/Program Files/Common Files/vendor/bin" _blocked_common) +qgc_test_assert_streq("blocks unrelated Program Files source" TRUE "${_blocked_common}") + +_gstreamer_is_blocked_lib_copy_source("C:/Windows/System32" _blocked_windows) +qgc_test_assert_streq("blocks Windows system source" TRUE "${_blocked_windows}") + +qgc_test_pass("install_libs_guard_windows") diff --git a/cmake/GStreamer/tests/test_json.cmake b/cmake/GStreamer/tests/test_json.cmake new file mode 100644 index 000000000000..1d9259a4c198 --- /dev/null +++ b/cmake/GStreamer/tests/test_json.cmake @@ -0,0 +1,44 @@ +# Test: _qgc_json_array_to_list reads JSON arrays correctly and returns empty on missing path. + +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(Json) + +set(_json [=[ +{ + "gstreamer": { + "plugins": { + "common": ["coreelements", "playback", "udp"], + "linux": ["va"] + }, + "empty_section": {} + } +} +]=]) + +# Happy path: nested array +_qgc_json_array_to_list(_common "${_json}" gstreamer plugins common) +list(LENGTH _common _common_len) +qgc_test_assert_streq("common length" "3" "${_common_len}") +qgc_test_assert_in_list("common contains coreelements" coreelements _common) +qgc_test_assert_in_list("common contains udp" udp _common) +qgc_test_pass("happy path nested array") + +# Single-element platform array +_qgc_json_array_to_list(_linux "${_json}" gstreamer plugins linux) +qgc_test_assert_streq("linux length" "1" "1") +qgc_test_assert_in_list("linux has va" va _linux) +qgc_test_pass("single-element array") + +# Missing path returns empty (not an error) +_qgc_json_array_to_list(_missing "${_json}" gstreamer plugins darwin) +list(LENGTH _missing _missing_len) +qgc_test_assert_streq("missing path length" "0" "${_missing_len}") +qgc_test_pass("missing path returns empty") + +# Non-array (object) at path returns empty (not an error) +_qgc_json_array_to_list(_obj "${_json}" gstreamer empty_section) +list(LENGTH _obj _obj_len) +qgc_test_assert_streq("object-at-path length" "0" "${_obj_len}") +qgc_test_pass("object at path returns empty") diff --git a/cmake/GStreamer/tests/test_layout.cmake b/cmake/GStreamer/tests/test_layout.cmake new file mode 100644 index 000000000000..e6960d4beac8 --- /dev/null +++ b/cmake/GStreamer/tests/test_layout.cmake @@ -0,0 +1,102 @@ +# Test: gstreamer_create_layout_target computes the correct scalar paths +# (GSTREAMER_LIB_PATH / PLUGIN_PATH / INCLUDE_PATH / FRAMEWORK_PATH / +# XCFRAMEWORK_PATH) for each TYPE. cmake -P script mode can't create +# IMPORTED targets (add_library not scriptable), so target-property +# verification is deferred to a full-configure check that runs as part +# of the project's own configure step. + +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(Layout) + +# Sandbox under the script's run directory; cmake -P sets CMAKE_BINARY_DIR=PWD. +set(_sandbox "${CMAKE_BINARY_DIR}/qgc_layout_test_sandbox") +file(REMOVE_RECURSE "${_sandbox}") +file(MAKE_DIRECTORY "${_sandbox}") + +# ── FLAT layout ───────────────────────────────────────────────────────────── +set(_flat "${_sandbox}/flat-sdk") +file(MAKE_DIRECTORY "${_flat}/lib/gstreamer-1.0") +file(MAKE_DIRECTORY "${_flat}/include") + +gstreamer_create_layout_target(SDK_ROOT "${_flat}" TYPE FLAT) + +qgc_test_assert_streq("FLAT lib" "${_flat}/lib" "${GSTREAMER_LIB_PATH}") +qgc_test_assert_streq("FLAT plugins" "${_flat}/lib/gstreamer-1.0" "${GSTREAMER_PLUGIN_PATH}") +qgc_test_assert_streq("FLAT include" "${_flat}/include" "${GSTREAMER_INCLUDE_PATH}") +qgc_test_pass("FLAT layout") + +# ── FLAT with explicit LIB_PATH override (multiarch / lib64) ──────────────── +set(_flat_multi "${_sandbox}/flat-multi") +file(MAKE_DIRECTORY "${_flat_multi}/lib/x86_64-linux-gnu/gstreamer-1.0") +file(MAKE_DIRECTORY "${_flat_multi}/include") + +gstreamer_create_layout_target( + SDK_ROOT "${_flat_multi}" + TYPE FLAT + LIB_PATH "${_flat_multi}/lib/x86_64-linux-gnu" +) +qgc_test_assert_streq("FLAT multiarch lib" "${_flat_multi}/lib/x86_64-linux-gnu" "${GSTREAMER_LIB_PATH}") +qgc_test_assert_streq("FLAT multiarch plugins" "${_flat_multi}/lib/x86_64-linux-gnu/gstreamer-1.0" "${GSTREAMER_PLUGIN_PATH}") +qgc_test_pass("FLAT lib_path override") + +# ── STATIC_TARBALL (Android) ──────────────────────────────────────────────── +set(_static "${_sandbox}/static") +file(MAKE_DIRECTORY "${_static}/lib/gstreamer-1.0") +file(MAKE_DIRECTORY "${_static}/include") + +gstreamer_create_layout_target(SDK_ROOT "${_static}" TYPE STATIC_TARBALL) +qgc_test_assert_streq("STATIC_TARBALL lib" "${_static}/lib" "${GSTREAMER_LIB_PATH}") +qgc_test_pass("STATIC_TARBALL layout") + +# ── FRAMEWORK (macOS) ─────────────────────────────────────────────────────── +set(_fw_root "${_sandbox}/fw-root") +file(MAKE_DIRECTORY "${_fw_root}/lib/gstreamer-1.0") +file(MAKE_DIRECTORY "${_fw_root}/include") +set(_fw_bundle "${_sandbox}/GStreamer.framework") +file(MAKE_DIRECTORY "${_fw_bundle}") + +gstreamer_create_layout_target( + SDK_ROOT "${_fw_root}" + TYPE FRAMEWORK + FRAMEWORK_BUNDLE "${_fw_bundle}" +) +qgc_test_assert_streq("FRAMEWORK lib" "${_fw_root}/lib" "${GSTREAMER_LIB_PATH}") +qgc_test_assert_streq("FRAMEWORK plugins" "${_fw_root}/lib/gstreamer-1.0" "${GSTREAMER_PLUGIN_PATH}") +qgc_test_assert_streq("FRAMEWORK_PATH" "${_fw_bundle}" "${GSTREAMER_FRAMEWORK_PATH}") +qgc_test_pass("FRAMEWORK layout") + +# ── XCFRAMEWORK (iOS) ─────────────────────────────────────────────────────── +set(_xcfw_root "${_sandbox}/xcfw-root") +file(MAKE_DIRECTORY "${_xcfw_root}/Headers") +set(_xcfw_bundle "${_sandbox}/GStreamer.xcframework") +file(MAKE_DIRECTORY "${_xcfw_bundle}") + +gstreamer_create_layout_target( + SDK_ROOT "${_xcfw_root}" + TYPE XCFRAMEWORK + INCLUDE_PATH "${_xcfw_root}/Headers" + XCFRAMEWORK_BUNDLE "${_xcfw_bundle}" +) +qgc_test_assert_streq("XCFRAMEWORK lib (slice)" "${_xcfw_root}" "${GSTREAMER_LIB_PATH}") +qgc_test_assert_streq("XCFRAMEWORK plugins (slice)" "${_xcfw_root}" "${GSTREAMER_PLUGIN_PATH}") +qgc_test_assert_streq("XCFRAMEWORK include" "${_xcfw_root}/Headers" "${GSTREAMER_INCLUDE_PATH}") +qgc_test_assert_streq("XCFRAMEWORK_PATH" "${_xcfw_bundle}" "${GSTREAMER_XCFRAMEWORK_PATH}") +qgc_test_pass("XCFRAMEWORK layout") + +# ── gstreamer_layout_get when no target exists (script mode) ──────────────── +# In cmake -P script mode the IMPORTED target isn't created; the accessor +# must return empty for any key without surfacing NOTFOUND. +gstreamer_layout_get(LIB_DIR _none_lib) +qgc_test_assert_streq("layout_get LIB_DIR (no target)" "" "${_none_lib}") +gstreamer_layout_get(FRAMEWORK_BUNDLE _none_fw) +qgc_test_assert_streq("layout_get FRAMEWORK_BUNDLE (no target)" "" "${_none_fw}") +qgc_test_pass("layout_get script-mode safety") + +# ── gstreamer_layout_set is a no-op in script mode ────────────────────────── +# Should not error even though no target exists. +gstreamer_layout_set(FOO bar) +qgc_test_pass("layout_set script-mode safety") + +file(REMOVE_RECURSE "${_sandbox}") diff --git a/cmake/GStreamer/tests/test_orchestrator_idempotent.cmake b/cmake/GStreamer/tests/test_orchestrator_idempotent.cmake new file mode 100644 index 000000000000..cae5344cdc82 --- /dev/null +++ b/cmake/GStreamer/tests/test_orchestrator_idempotent.cmake @@ -0,0 +1,13 @@ +# Regression: GSTREAMER_APIS must recompute from the current component set, not +# reuse a stale value from a prior configure. +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(Components) + +set(GSTREAMER_APIS "stale-sentinel") +gstreamer_build_apis_and_deps(GSTREAMER_APIS _deps App) + +qgc_test_assert_not_in_list("stale GSTREAMER_APIS overwritten" "stale-sentinel" GSTREAMER_APIS) +qgc_test_assert_in_list("recomputed GSTREAMER_APIS includes added component" "api_app" GSTREAMER_APIS) +qgc_test_pass("orchestrator recompute contract") diff --git a/cmake/GStreamer/tests/test_path_list.cmake b/cmake/GStreamer/tests/test_path_list.cmake new file mode 100644 index 000000000000..20d573dbb109 --- /dev/null +++ b/cmake/GStreamer/tests/test_path_list.cmake @@ -0,0 +1,53 @@ +# Test: _gst_coalesce_existing_paths rejoins path tokens split at spaces. +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(Link) + +set(_sandbox "${CMAKE_CURRENT_BINARY_DIR}/path list sandbox") +set(_include_dir "${_sandbox}/sdk root/include/gstreamer-1.0") +file(MAKE_DIRECTORY "${_include_dir}") + +set(_paths + "${CMAKE_CURRENT_BINARY_DIR}/path" + "list" + "sandbox/sdk" + "root/include/gstreamer-1.0" + "/missing/prefix" +) + +_gst_coalesce_existing_paths(_paths) + +qgc_test_assert_in_list("coalesces split path" "${_include_dir}" _paths) +qgc_test_assert_not_in_list("removes first split token" "${CMAKE_CURRENT_BINARY_DIR}/path" _paths) +qgc_test_assert_not_in_list("removes middle split token" "list" _paths) +qgc_test_assert_in_list("keeps unresolved token for caller diagnostics" "/missing/prefix" _paths) + +set(_windows_sdk "${_sandbox}/Program Files/gstreamer/1.0/msvc_x86_64") +set(_pc_INCLUDE_DIRS "${_sandbox}/Program") +set(_pc_CFLAGS_OTHER + "Files/gstreamer/1.0/msvc_x86_64/include/gstreamer-1.0" + "Files/gstreamer/1.0/msvc_x86_64/include" + "-DKEEP_ME" +) +set(_pc_LIBRARY_DIRS "${_sandbox}/Program") +set(_pc_LDFLAGS_OTHER + "Files/gstreamer/1.0/msvc_x86_64/lib" + "-Wl,keep-me" +) +file(MAKE_DIRECTORY + "${_windows_sdk}/include/gstreamer-1.0" + "${_windows_sdk}/include" + "${_windows_sdk}/lib" +) + +_gst_recover_split_pkgconfig_paths(_pc INCLUDE_DIRS CFLAGS_OTHER LIBRARY_DIRS LDFLAGS_OTHER) + +qgc_test_assert_in_list("recovers first split include dir" "${_windows_sdk}/include/gstreamer-1.0" _pc_INCLUDE_DIRS) +qgc_test_assert_in_list("recovers second split include dir" "${_windows_sdk}/include" _pc_INCLUDE_DIRS) +qgc_test_assert_in_list("keeps real compile option" "-DKEEP_ME" _pc_CFLAGS_OTHER) +qgc_test_assert_not_in_list("removes split include suffix from options" "Files/gstreamer/1.0/msvc_x86_64/include" _pc_CFLAGS_OTHER) +qgc_test_assert_in_list("recovers split library dir" "${_windows_sdk}/lib" _pc_LIBRARY_DIRS) +qgc_test_assert_in_list("keeps real link option" "-Wl,keep-me" _pc_LDFLAGS_OTHER) +qgc_test_assert_not_in_list("removes split library suffix from options" "Files/gstreamer/1.0/msvc_x86_64/lib" _pc_LDFLAGS_OTHER) +qgc_test_pass("coalesce_existing_paths") diff --git a/cmake/GStreamer/tests/test_pkgconfig_env.cmake b/cmake/GStreamer/tests/test_pkgconfig_env.cmake new file mode 100644 index 000000000000..352947e36830 --- /dev/null +++ b/cmake/GStreamer/tests/test_pkgconfig_env.cmake @@ -0,0 +1,86 @@ +# Test: gstreamer_apply_pkgconfig_env (SDK / SYSTEM_AUGMENT modes, +# PKG_CONFIG_EXE forwarding, DONT_DEFINE_PREFIX flag plumbing). + +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(PkgConfig) + +# Pick the platform separator the function will use. +if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") + set(_sep ";") +else() + set(_sep ":") +endif() + +# ── SDK mode: clears PATH, sets LIBDIR ────────────────────────────────────── +set(ENV{PKG_CONFIG_PATH} "/system/lib/pkgconfig") +set(ENV{PKG_CONFIG_LIBDIR} "") +gstreamer_apply_pkgconfig_env( + MODE SDK + LIBDIR "/sdk/lib/pkgconfig" "/sdk/lib/gstreamer-1.0/pkgconfig" +) +qgc_test_assert_streq("SDK clears PATH" "" "$ENV{PKG_CONFIG_PATH}") +qgc_test_assert_streq("SDK sets LIBDIR" + "/sdk/lib/pkgconfig${_sep}/sdk/lib/gstreamer-1.0/pkgconfig" + "$ENV{PKG_CONFIG_LIBDIR}") +qgc_test_pass("SDK mode") + +# ── SDK mode: PKG_CONFIG_EXE forwards to env + cache ──────────────────────── +set(ENV{PKG_CONFIG} "") +unset(PKG_CONFIG_EXECUTABLE CACHE) +gstreamer_apply_pkgconfig_env( + MODE SDK + PKG_CONFIG_EXE "/opt/sdk/bin/pkg-config" + LIBDIR "/sdk/lib/pkgconfig" +) +qgc_test_assert_streq("PKG_CONFIG env" "/opt/sdk/bin/pkg-config" "$ENV{PKG_CONFIG}") +qgc_test_assert_streq("PKG_CONFIG_EXECUTABLE cache" "/opt/sdk/bin/pkg-config" "${PKG_CONFIG_EXECUTABLE}") +qgc_test_pass("PKG_CONFIG_EXE forwarding") + +# ── SYSTEM_AUGMENT mode: prepend to existing PATH ─────────────────────────── +set(ENV{PKG_CONFIG_PATH} "/system/lib/pkgconfig") +set(ENV{PKG_CONFIG_LIBDIR} "") +gstreamer_apply_pkgconfig_env( + MODE SYSTEM_AUGMENT + LIBDIR "/usr/lib/x86_64-linux-gnu/pkgconfig" +) +qgc_test_assert_streq("SYSTEM_AUGMENT prepends" + "/usr/lib/x86_64-linux-gnu/pkgconfig${_sep}/system/lib/pkgconfig" + "$ENV{PKG_CONFIG_PATH}") +qgc_test_pass("SYSTEM_AUGMENT prepend") + +# ── SYSTEM_AUGMENT mode: empty PATH ─ no leading separator ────────────────── +set(ENV{PKG_CONFIG_PATH} "") +gstreamer_apply_pkgconfig_env( + MODE SYSTEM_AUGMENT + LIBDIR "/sdk/lib/pkgconfig" +) +qgc_test_assert_streq("SYSTEM_AUGMENT empty path" "/sdk/lib/pkgconfig" "$ENV{PKG_CONFIG_PATH}") +qgc_test_pass("SYSTEM_AUGMENT empty path") + +# ── DONT_DEFINE_PREFIX: appended once, idempotent ─────────────────────────── +set(PKG_CONFIG_ARGN "--static") +gstreamer_apply_pkgconfig_env( + MODE SDK + LIBDIR "/sdk/lib/pkgconfig" + DONT_DEFINE_PREFIX +) +qgc_test_assert_in_list("ARGN gained --dont-define-prefix" "--dont-define-prefix" PKG_CONFIG_ARGN) +qgc_test_assert_in_list("ARGN preserved --static" "--static" PKG_CONFIG_ARGN) +list(LENGTH PKG_CONFIG_ARGN _n_after_first) +qgc_test_assert_streq("ARGN length after first call" "2" "${_n_after_first}") + +gstreamer_apply_pkgconfig_env( + MODE SDK + LIBDIR "/sdk/lib/pkgconfig" + DONT_DEFINE_PREFIX +) +list(LENGTH PKG_CONFIG_ARGN _n_after_second) +qgc_test_assert_streq("ARGN idempotent" "2" "${_n_after_second}") +qgc_test_pass("DONT_DEFINE_PREFIX idempotent") + +# Reset env so a parent ctest run isn't polluted. +set(ENV{PKG_CONFIG_PATH} "") +set(ENV{PKG_CONFIG_LIBDIR} "") +set(ENV{PKG_CONFIG} "") diff --git a/cmake/GStreamer/tests/test_plugin_policy.cmake b/cmake/GStreamer/tests/test_plugin_policy.cmake new file mode 100644 index 000000000000..e9fe4093564a --- /dev/null +++ b/cmake/GStreamer/tests/test_plugin_policy.cmake @@ -0,0 +1,108 @@ +# Test: GStreamerPluginPolicy — gstreamer_plugins_for, gstreamer_filter_alternates, +# gstreamer_runtime_required_plugins, gstreamer_xcfw_skip. + +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(Json) +include(PluginPolicy) + +# ── gstreamer_plugins_for: common only ────────────────────────────────────── +set(QGC_BUILD_CONFIG_CONTENT [[{ + "gstreamer": { + "plugins": { + "common": ["coreelements", "playback", "rtsp"], + "linux": ["va"], + "apple": ["vtenc", "vtdec"] + } + } +}]]) + +gstreamer_plugins_for(PLATFORM "" OUT_VAR _plugins_common) +qgc_test_assert_in_list("common has coreelements" coreelements _plugins_common) +qgc_test_assert_in_list("common has playback" playback _plugins_common) +qgc_test_assert_not_in_list("common excludes va" va _plugins_common) +qgc_test_pass("plugins_for common-only") + +# ── gstreamer_plugins_for: common + platform addenda ──────────────────────── +gstreamer_plugins_for(PLATFORM linux OUT_VAR _plugins_linux) +qgc_test_assert_in_list("linux has coreelements" coreelements _plugins_linux) +qgc_test_assert_in_list("linux has va" va _plugins_linux) +qgc_test_assert_not_in_list("linux excludes vtenc" vtenc _plugins_linux) +qgc_test_pass("plugins_for linux addenda") + +gstreamer_plugins_for(PLATFORM apple OUT_VAR _plugins_apple) +qgc_test_assert_in_list("apple has vtenc" vtenc _plugins_apple) +qgc_test_assert_in_list("apple has vtdec" vtdec _plugins_apple) +qgc_test_pass("plugins_for apple addenda") + +# ── repository build config: Windows ships both D3D plugin families ───────── +file(READ "${CMAKE_CURRENT_LIST_DIR}/../../../.github/build-config.json" QGC_BUILD_CONFIG_CONTENT) +gstreamer_plugins_for(PLATFORM windows OUT_VAR _plugins_windows_real) +qgc_test_assert_in_list("windows has d3d11" d3d11 _plugins_windows_real) +qgc_test_assert_in_list("windows has d3d12" d3d12 _plugins_windows_real) +qgc_test_pass("plugins_for real windows d3d addenda") + +# ── gstreamer_filter_alternates: scale alternate satisfied by fused plugin ── +set(_req videoconvertscale videoconvert videoscale x264enc) +set(_avail videoconvertscale x264enc) +gstreamer_filter_alternates(IN_OUT_PLUGINS _req AVAILABLE ${_avail}) +qgc_test_assert_in_list("filtered: videoconvertscale retained (present)" videoconvertscale _req) +qgc_test_assert_not_in_list("filtered: videoconvert removed (absent)" videoconvert _req) +qgc_test_assert_not_in_list("filtered: videoscale removed (absent)" videoscale _req) +qgc_test_assert_in_list("filtered: x264enc retained" x264enc _req) +qgc_test_pass("filter_alternates fused-satisfied") + +# ── gstreamer_filter_alternates: split-pair satisfies group ───────────────── +set(_req2 videoconvertscale videoconvert videoscale) +set(_avail2 videoconvert videoscale) +gstreamer_filter_alternates(IN_OUT_PLUGINS _req2 AVAILABLE ${_avail2}) +qgc_test_assert_not_in_list("split-sat: videoconvertscale removed (absent)" videoconvertscale _req2) +qgc_test_assert_in_list("split-sat: videoconvert retained (present)" videoconvert _req2) +qgc_test_assert_in_list("split-sat: videoscale retained (present)" videoscale _req2) +qgc_test_pass("filter_alternates split-pair satisfied") + +# ── gstreamer_filter_alternates: nothing satisfies → group untouched ──────── +set(_req3 videoconvertscale videoconvert videoscale x264enc) +set(_avail3 x264enc) +gstreamer_filter_alternates(IN_OUT_PLUGINS _req3 AVAILABLE ${_avail3}) +qgc_test_assert_in_list("unsat: videoconvertscale retained" videoconvertscale _req3) +qgc_test_assert_in_list("unsat: videoconvert retained" videoconvert _req3) +qgc_test_assert_in_list("unsat: videoscale retained" videoscale _req3) +qgc_test_pass("filter_alternates none-satisfied") + +# ── gstreamer_filter_alternates: only one '+' member present → AND unsatisfied ─ +set(_req_partial videoconvertscale videoconvert videoscale x264enc) +set(_avail_partial videoconvert x264enc) +gstreamer_filter_alternates(IN_OUT_PLUGINS _req_partial AVAILABLE ${_avail_partial}) +qgc_test_assert_in_list("partial: videoconvertscale retained" videoconvertscale _req_partial) +qgc_test_assert_in_list("partial: videoconvert retained" videoconvert _req_partial) +qgc_test_assert_in_list("partial: videoscale retained (absent half)" videoscale _req_partial) +qgc_test_assert_in_list("partial: x264enc retained" x264enc _req_partial) +qgc_test_pass("filter_alternates partial-pair unsatisfied") + +# ── runtime_required_plugins includes the minimum bundle ──────────────────── +gstreamer_runtime_required_plugins(_required) +foreach(_p IN ITEMS coreelements opengl playback rtsp rtp rtpmanager tcp udp) + qgc_test_assert_in_list("runtime required: ${_p}" "${_p}" _required) +endforeach() +qgc_test_assert_not_in_list("runtime required: openh264 is optional codec implementation" openh264 _required) +qgc_test_pass("runtime_required_plugins") + +# ── plugin_satisfy_sets: fused name expands to its alternate group ─────────── +gstreamer_plugin_satisfy_sets(PLUGIN videoconvertscale OUT_VAR _sets_fused) +qgc_test_assert_in_list("satisfy: fused alternative present" videoconvertscale _sets_fused) +qgc_test_assert_in_list("satisfy: split alternative present" "videoconvert+videoscale" _sets_fused) +qgc_test_pass("plugin_satisfy_sets grouped") + +# ── plugin_satisfy_sets: plain plugin returns just itself ──────────────────── +gstreamer_plugin_satisfy_sets(PLUGIN coreelements OUT_VAR _sets_plain) +qgc_test_assert_in_list("satisfy: plain returns self" coreelements _sets_plain) +list(LENGTH _sets_plain _plain_len) +qgc_test_assert_streq("satisfy: plain has one set" 1 "${_plain_len}") +qgc_test_pass("plugin_satisfy_sets ungrouped") + +# ── xcfw_skip includes x265 ───────────────────────────────────────────────── +gstreamer_xcfw_skip(_skip) +qgc_test_assert_in_list("xcfw skip: x265" x265 _skip) +qgc_test_pass("xcfw_skip") diff --git a/cmake/GStreamer/tests/test_plugin_policy_conflict.cmake b/cmake/GStreamer/tests/test_plugin_policy_conflict.cmake new file mode 100644 index 000000000000..45d83f432a9b --- /dev/null +++ b/cmake/GStreamer/tests/test_plugin_policy_conflict.cmake @@ -0,0 +1,9 @@ +# Negative test (WILL_FAIL): a plugin in both the runtime-required and +# xcframework-skip lists must FATAL via _gstreamer_assert_policy_consistent. +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include(Json) +include(PluginPolicy) +set(GSTREAMER_XCFRAMEWORK_SKIP_PLUGINS foo) +set(GSTREAMER_RUNTIME_REQUIRED_PLUGINS foo) +_gstreamer_assert_policy_consistent() diff --git a/cmake/GStreamer/tests/test_plugin_policy_errors.cmake b/cmake/GStreamer/tests/test_plugin_policy_errors.cmake new file mode 100644 index 000000000000..fd3c8ae10cae --- /dev/null +++ b/cmake/GStreamer/tests/test_plugin_policy_errors.cmake @@ -0,0 +1,6 @@ +# Negative test (WILL_FAIL): gstreamer_plugins_for without QGC_BUILD_CONFIG_CONTENT must FATAL. +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include(Json) +include(PluginPolicy) +gstreamer_plugins_for(PLATFORM "" OUT_VAR _x) diff --git a/cmake/GStreamer/tests/test_plugin_scan.cmake b/cmake/GStreamer/tests/test_plugin_scan.cmake new file mode 100644 index 000000000000..8d28ec783fc0 --- /dev/null +++ b/cmake/GStreamer/tests/test_plugin_scan.cmake @@ -0,0 +1,36 @@ +# Test: gstreamer_scan_plugin_basenames extracts plugin basenames from a +# directory of mock plugin files (only the names matter, not the contents). + +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(Components) + +set(_sandbox "${CMAKE_BINARY_DIR}/qgc_plugin_scan_sandbox") +file(REMOVE_RECURSE "${_sandbox}") +file(MAKE_DIRECTORY "${_sandbox}") + +# ── empty path ────────────────────────────────────────────────────────────── +gstreamer_scan_plugin_basenames(_empty "${_sandbox}/does-not-exist") +qgc_test_assert_streq("missing path returns empty" "" "${_empty}") +qgc_test_pass("missing path") + +# ── platform plugin set ───────────────────────────────────────────────────── +gstreamer_platform_plugin_attrs(_ext _prefix _glob) +file(TOUCH "${_sandbox}/${_prefix}coreelements.${_ext}") +file(TOUCH "${_sandbox}/${_prefix}playback.${_ext}") +file(TOUCH "${_sandbox}/${_prefix}videoconvertscale.${_ext}") +file(TOUCH "${_sandbox}/${_prefix}x264.${_ext}") +file(TOUCH "${_sandbox}/notaplugin.${_ext}") # Non-plugin file — must be excluded by the plugin glob +file(TOUCH "${_sandbox}/${_prefix}-helper.${_ext}") # Dash-prefixed name — alphanumeric regex captures empty group, deduped away + +gstreamer_scan_plugin_basenames(_names "${_sandbox}") + +qgc_test_assert_in_list("found coreelements" coreelements _names) +qgc_test_assert_in_list("found playback" playback _names) +qgc_test_assert_in_list("found videoconvertscale" videoconvertscale _names) +qgc_test_assert_in_list("found x264" x264 _names) +qgc_test_assert_not_in_list("notaplugin not scanned" notaplugin _names) +qgc_test_pass("scan_plugin_basenames platform") + +file(REMOVE_RECURSE "${_sandbox}") diff --git a/cmake/GStreamer/tests/test_windows_runtime_libproxy.cmake b/cmake/GStreamer/tests/test_windows_runtime_libproxy.cmake new file mode 100644 index 000000000000..e09da0112c27 --- /dev/null +++ b/cmake/GStreamer/tests/test_windows_runtime_libproxy.cmake @@ -0,0 +1,22 @@ +# Test: Windows runtime dependencies include libproxy's backend DLL directory. +cmake_minimum_required(VERSION 3.22) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/..") +include("${CMAKE_CURRENT_LIST_DIR}/_assert.cmake") +include(Install) + +set(_sandbox "${CMAKE_BINARY_DIR}/qgc_windows_runtime_libproxy_sandbox") +file(REMOVE_RECURSE "${_sandbox}") +file(MAKE_DIRECTORY "${_sandbox}/bin" "${_sandbox}/lib/libproxy") +file(TOUCH "${_sandbox}/bin/proxy-1.dll") +file(TOUCH "${_sandbox}/lib/libproxy/pxbackend-1.0.dll") + +_gstreamer_windows_runtime_dependency_dirs("${_sandbox}" _runtime_dirs) + +qgc_test_assert_in_list( + "includes libproxy backend dll directory" + "${_sandbox}/lib/libproxy" + _runtime_dirs +) + +file(REMOVE_RECURSE "${_sandbox}") +qgc_test_pass("windows runtime libproxy dependency dirs") diff --git a/cmake/PrintSummary.cmake b/cmake/PrintSummary.cmake index 07e5c80f9195..92474e8320c9 100644 --- a/cmake/PrintSummary.cmake +++ b/cmake/PrintSummary.cmake @@ -130,10 +130,8 @@ OptionOutput("Enable testing " QGC_BUILD_TESTING) OptionOutput("Enable QML debugging " QGC_DEBUG_QML) OptionOutput("Enable QML linting " QT_QML_GENERATE_QMLLINT) OptionOutput("Disable serial links " QGC_NO_SERIAL_LINK) -OptionOutput("Enable UVC devices " QGC_ENABLE_UVC) OptionOutput("Enable GStreamer video " QGC_ENABLE_GST_VIDEOSTREAMING) OptionOutput(" GStreamer auto-downloaded " GStreamer_AUTO_DOWNLOADED) -OptionOutput("Enable Qt video backend " QGC_ENABLE_QT_VIDEOSTREAMING) OptionOutput("Disable APM MAVLink dialect " QGC_DISABLE_APM_MAVLINK) OptionOutput("Disable APM plugin " QGC_DISABLE_APM_PLUGIN) OptionOutput("Disable PX4 plugin " QGC_DISABLE_PX4_PLUGIN) diff --git a/cmake/find-modules/FindGStreamer.cmake b/cmake/find-modules/FindGStreamer.cmake index f9a57f8850e0..37ab9e624a8f 100644 --- a/cmake/find-modules/FindGStreamer.cmake +++ b/cmake/find-modules/FindGStreamer.cmake @@ -1,5 +1,24 @@ # SPDX-FileCopyrightText: 2024 L. E. Segovia # SPDX-License-Identifier: LGPL-2.1-or-later +# +# QGC vendoring notes: +# Source: https://invent.kde.org/qt/qt/qtmultimedia (Qt 6.x branch) +# This module is consumed by cmake/GStreamer/Orchestrator.cmake → platform helpers. +# Local QGC patches (not in upstream): +# 1. _gst_resolve_and_link_libraries converted from macro to function for +# scope hygiene; callers pass values (not names). +# 2. Pkg-config env management (PKG_CONFIG_PATH/LIBDIR/DONT_DEFINE_PREFIX) +# moved out — every in-tree caller routes through +# gstreamer_apply_pkgconfig_env (cmake/GStreamer/PkgConfig.cmake) before +# find_package(GStreamer), so the upstream standalone-fallback block +# and the trailing DONT_DEFINE_PREFIX env-reset have been deleted. +# 3. Component target generation walks GSTREAMER_APIS instead of a fixed +# list so xcframework / mobile static-build paths can introduce new +# components without editing this file. +# 4. Hash parsing moved out — qgc_parse_expected_hash lives in +# cmake/modules/Download.cmake; this module no longer parses hashes. +# When syncing from upstream, re-apply each listed patch and update this +# block. Do NOT remove this block during sync. #[=======================================================================[.rst: FindGStreamer @@ -39,7 +58,7 @@ Configuration Variables if (GStreamer_FOUND) set(_gst_all_present TRUE) foreach(_gst_c IN LISTS GStreamer_FIND_COMPONENTS) - if (NOT TARGET GStreamer::${_gst_c}) + if (NOT TARGET GStreamer::${_gst_c} AND NOT _gst_c IN_LIST GStreamer_ABSENT_COMPONENTS) set(_gst_all_present FALSE) break() endif() @@ -49,6 +68,9 @@ if (GStreamer_FOUND) endif() endif() +# Rebuilt fresh each configure; the cache entry would otherwise keep stale absences. +unset(GStreamer_ABSENT_COMPONENTS CACHE) + if (NOT GStreamer_ROOT_DIR OR NOT EXISTS "${GStreamer_ROOT_DIR}") message(FATAL_ERROR "GStreamer_ROOT_DIR must be set to a valid directory before including FindGStreamer " "(current value: '${GStreamer_ROOT_DIR}')") @@ -58,33 +80,22 @@ if (NOT DEFINED GStreamer_USE_STATIC_LIBS) set(GStreamer_USE_STATIC_LIBS OFF) endif() -# Only set pkg-config paths if the orchestrator (FindQGCGStreamer) hasn't already -# configured them via PKG_CONFIG_LIBDIR / _gst_configure_pkg_config. -if (NOT DEFINED ENV{PKG_CONFIG_LIBDIR} OR "$ENV{PKG_CONFIG_LIBDIR}" STREQUAL "") - if (CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") - set(ENV{PKG_CONFIG_PATH} "${GStreamer_ROOT_DIR}/lib/pkgconfig;${GStreamer_ROOT_DIR}/lib/gstreamer-1.0/pkgconfig;${GStreamer_ROOT_DIR}/lib/gio/modules/pkgconfig") - # Block pkgconf's forced relocation for non-lib/pkgconfig modules on Windows - # https://github.com/pkgconf/pkgconf/commit/dcf529b83d621ed09e99e41fc35fdffd068bd87a - set(ENV{PKG_CONFIG_DONT_DEFINE_PREFIX} 1) - else() - set(ENV{PKG_CONFIG_PATH} "${GStreamer_ROOT_DIR}/lib/pkgconfig:${GStreamer_ROOT_DIR}/lib/gstreamer-1.0/pkgconfig:${GStreamer_ROOT_DIR}/lib/gio/modules/pkgconfig") - endif() -endif() +# Pkg-config env (PKG_CONFIG_PATH / LIBDIR / DONT_DEFINE_PREFIX) is configured +# by the orchestrator via gstreamer_apply_pkgconfig_env() before this module +# runs — see QGC patch #2 in the vendoring header. macro(_gst_filter_missing_directories GST_INCLUDE_DIRS) + _gst_coalesce_existing_paths(${GST_INCLUDE_DIRS}) set(_gst_include_dirs) foreach(DIR IN LISTS ${GST_INCLUDE_DIRS}) string(MAKE_C_IDENTIFIER "${DIR}" _gst_dir_id) - if (DEFINED _gst_exists_${_gst_dir_id}) - if (_gst_exists_${_gst_dir_id}) - list(APPEND _gst_include_dirs "${DIR}") - endif() + if (DEFINED _gst_exists_${_gst_dir_id} AND _gst_exists_${_gst_dir_id}) + list(APPEND _gst_include_dirs "${DIR}") elseif (EXISTS "${DIR}") list(APPEND _gst_include_dirs "${DIR}") set(_gst_exists_${_gst_dir_id} TRUE) else() message(WARNING "Skipping missing include folder ${DIR}.") - set(_gst_exists_${_gst_dir_id} FALSE) endif() endforeach() set(${GST_INCLUDE_DIRS} "${_gst_include_dirs}") @@ -98,11 +109,17 @@ macro(_gst_apply_frameworks PC_STATIC_LDFLAGS_OTHER GST_TARGET) foreach(_arg IN LISTS ${PC_STATIC_LDFLAGS_OTHER}) if (assemble_framework) set(assemble_framework FALSE) - find_library(GST_${_arg}_LIB ${_arg} REQUIRED) - target_link_libraries(${GST_TARGET} - INTERFACE - "${GST_${_arg}_LIB}" - ) + if (GStreamer_FIND_QUIETLY) + find_library(GStreamer_FW_${_arg}_LIB ${_arg}) + else() + find_library(GStreamer_FW_${_arg}_LIB ${_arg} REQUIRED) + endif() + if (GStreamer_FW_${_arg}_LIB) + target_link_libraries(${GST_TARGET} + INTERFACE + "${GStreamer_FW_${_arg}_LIB}" + ) + endif() elseif (_arg STREQUAL "-framework") set(assemble_framework TRUE) else() @@ -113,6 +130,7 @@ macro(_gst_apply_frameworks PC_STATIC_LDFLAGS_OTHER GST_TARGET) if (assemble_framework) message(WARNING "GStreamer: trailing -framework with no name in ${${PC_STATIC_LDFLAGS_OTHER}}") endif() + # Overwrites (not appends) INTERFACE_LINK_OPTIONS — sole writer for this target. set_target_properties(${GST_TARGET} PROPERTIES INTERFACE_LINK_OPTIONS "${new_ldflags}" ) @@ -133,6 +151,12 @@ if(GStreamer_FIND_VERSION) else() pkg_check_modules(PC_GStreamer REQUIRED gstreamer-1.0) endif() +_gst_recover_split_pkgconfig_paths(PC_GStreamer + INCLUDE_DIRS CFLAGS_OTHER + STATIC_INCLUDE_DIRS STATIC_CFLAGS_OTHER + LIBRARY_DIRS LDFLAGS_OTHER + STATIC_LIBRARY_DIRS STATIC_LDFLAGS_OTHER +) if(PC_GStreamer_VERSION) set(GStreamer_VERSION "${PC_GStreamer_VERSION}") @@ -146,7 +170,13 @@ if(GStreamer_DEBUG) message(STATUS "[GstFind] GSTREAMER_APIS = ${GSTREAMER_APIS}") endif() -# _gst_IGNORED_SYSTEM_LIBRARIES and _gst_SRT_REGEX_PATCH are defined in GStreamerHelpers.cmake +# _gst_IGNORED_SYSTEM_LIBRARIES, _gst_SRT_REGEX_PATCH, and _gst_resolve_and_link_libraries +# (used below) come from cmake/GStreamer/Link.cmake, included via GStreamer/Helpers before +# find_package(GStreamer). Fail loudly if that prerequisite is missing. +if(NOT COMMAND _gst_resolve_and_link_libraries) + message(FATAL_ERROR "FindGStreamer: _gst_resolve_and_link_libraries is undefined. " + "Include cmake/GStreamer/Link.cmake (via GStreamer/Helpers) before find_package(GStreamer).") +endif() if(PC_GStreamer_FOUND AND (NOT TARGET GStreamer::GStreamer)) add_library(GStreamer::GStreamer INTERFACE IMPORTED GLOBAL) @@ -154,6 +184,7 @@ if(PC_GStreamer_FOUND AND (NOT TARGET GStreamer::GStreamer)) if (GStreamer_USE_STATIC_LIBS) _gst_filter_missing_directories(PC_GStreamer_STATIC_INCLUDE_DIRS) + _gst_coalesce_existing_paths(PC_GStreamer_STATIC_LIBRARY_DIRS) set_target_properties(GStreamer::GStreamer PROPERTIES INTERFACE_COMPILE_OPTIONS "${PC_GStreamer_STATIC_CFLAGS_OTHER}" ) @@ -163,14 +194,17 @@ if(PC_GStreamer_FOUND AND (NOT TARGET GStreamer::GStreamer)) ) endif() _gst_apply_frameworks(PC_GStreamer_STATIC_LDFLAGS_OTHER GStreamer::GStreamer) - _gst_resolve_and_link_libraries(GStreamer::GStreamer INTERFACE PC_GStreamer_LIBRARIES PC_GStreamer_STATIC_LIBRARY_DIRS) - _gst_resolve_and_link_libraries(GStreamer::deps INTERFACE PC_GStreamer_STATIC_LIBRARIES PC_GStreamer_STATIC_LIBRARY_DIRS HIDE) + _gst_resolve_and_link_libraries(GStreamer::GStreamer INTERFACE "${PC_GStreamer_LIBRARIES}" "${PC_GStreamer_STATIC_LIBRARY_DIRS}") + _gst_resolve_and_link_libraries(GStreamer::deps INTERFACE "${PC_GStreamer_STATIC_LIBRARIES}" "${PC_GStreamer_STATIC_LIBRARY_DIRS}" HIDE) else() + _gst_filter_missing_directories(PC_GStreamer_INCLUDE_DIRS) + _gst_coalesce_existing_paths(PC_GStreamer_LIBRARY_DIRS) set_target_properties(GStreamer::GStreamer PROPERTIES INTERFACE_COMPILE_OPTIONS "${PC_GStreamer_CFLAGS_OTHER}" INTERFACE_INCLUDE_DIRECTORIES "${PC_GStreamer_INCLUDE_DIRS}" INTERFACE_LINK_OPTIONS "${PC_GStreamer_LDFLAGS_OTHER}" ) + _gst_strip_macos_absent_link_libs(PC_GStreamer_LINK_LIBRARIES) set_target_properties(GStreamer::deps PROPERTIES INTERFACE_LINK_LIBRARIES "${PC_GStreamer_LINK_LIBRARIES}" ) @@ -191,12 +225,21 @@ function(_gst_create_component_target _gst_PLUGIN _gst_PC_NAME) endif() pkg_check_modules(PC_GStreamer_${_gst_PLUGIN} ${_gst_PLUGIN_REQUIRED} "${_gst_PC_NAME}") + _gst_recover_split_pkgconfig_paths(PC_GStreamer_${_gst_PLUGIN} + INCLUDE_DIRS CFLAGS_OTHER + STATIC_INCLUDE_DIRS STATIC_CFLAGS_OTHER + LIBRARY_DIRS LDFLAGS_OTHER + STATIC_LIBRARY_DIRS STATIC_LDFLAGS_OTHER + ) set(GStreamer_${_gst_PLUGIN}_FOUND "${PC_GStreamer_${_gst_PLUGIN}_FOUND}" PARENT_SCOPE) if (NOT PC_GStreamer_${_gst_PLUGIN}_FOUND) if(GStreamer_DEBUG) message(STATUS "[GstFind] Component ${_gst_PLUGIN} (${_gst_PC_NAME}): NOT FOUND") endif() + list(APPEND GStreamer_ABSENT_COMPONENTS ${_gst_PLUGIN}) + set(GStreamer_ABSENT_COMPONENTS "${GStreamer_ABSENT_COMPONENTS}" + CACHE INTERNAL "GStreamer components probed and found absent") return() endif() if(GStreamer_DEBUG) @@ -228,9 +271,12 @@ function(_gst_create_component_target _gst_PLUGIN _gst_PC_NAME) endif() if (GStreamer_USE_STATIC_LIBS) + _gst_coalesce_existing_paths(${_pc}_STATIC_LIBRARY_DIRS) _gst_apply_frameworks(${_ldflags_var} GStreamer::${_gst_PLUGIN}) - _gst_resolve_and_link_libraries(GStreamer::${_gst_PLUGIN} INTERFACE ${_pc}_STATIC_LIBRARIES ${_pc}_STATIC_LIBRARY_DIRS) + _gst_resolve_and_link_libraries(GStreamer::${_gst_PLUGIN} INTERFACE "${${_pc}_STATIC_LIBRARIES}" "${${_pc}_STATIC_LIBRARY_DIRS}") else() + _gst_coalesce_existing_paths(${_pc}_LIBRARY_DIRS) + _gst_strip_macos_absent_link_libs(${_pc}_LINK_LIBRARIES) set_target_properties(GStreamer::${_gst_PLUGIN} PROPERTIES INTERFACE_LINK_OPTIONS "${${_ldflags_var}}" INTERFACE_LINK_LIBRARIES "${${_pc}_LINK_LIBRARIES}" @@ -265,13 +311,14 @@ if(TARGET GStreamer::GStreamer) endif() endif() -if (DEFINED ENV{PKG_CONFIG_DONT_DEFINE_PREFIX}) - set(ENV{PKG_CONFIG_DONT_DEFINE_PREFIX}) +if(PC_GStreamer_FOUND AND TARGET GStreamer::GStreamer) + set(GStreamer_CORE_TARGET GStreamer::GStreamer) endif() include(FindPackageHandleStandardArgs) find_package_handle_standard_args(GStreamer REQUIRED_VARS + GStreamer_CORE_TARGET GStreamer_VERSION GStreamer_ROOT_DIR VERSION_VAR GStreamer_VERSION diff --git a/cmake/find-modules/FindGStreamerMobile.cmake b/cmake/find-modules/FindGStreamerMobile.cmake deleted file mode 100644 index 98944d46aed9..000000000000 --- a/cmake/find-modules/FindGStreamerMobile.cmake +++ /dev/null @@ -1,633 +0,0 @@ -# SPDX-FileCopyrightText: 2024 L. E. Segovia -# SPDX-License-Identifier: LGPL-2.1-or-later - -#[=======================================================================[.rst: -FindGStreamerMobile -------- - -Creates additional mobile targets to install fonts and the CA certificate -bundle. Android and iOS only. - -Imported Targets -^^^^^^^^^^^^^^^^ - -This module defines the following :prop_tgt:`INTERFACE` targets: - -``GStreamer::fonts`` - A target that will install GStreamer's default fonts into the app. - -``GStreamer::ca_certificates`` - A target that will install the NSS CA certificate bundle into the app. - -This module defines the following :prop_tgt:`SHARED` targets: - -``GStreamer::mobile`` - A target that will build the shared library consisting of GStreamer plus all the selected plugin components. (Android/iOS only) - -Result Variables -^^^^^^^^^^^^^^^^ - -This will define the following variables: - -``GStreamerMobile_FOUND`` - ON if the system has the GStreamer library. - -Cache Variables -^^^^^^^^^^^^^^^ - -The following cache variables may also be set: - -``GStreamer_CA_BUNDLE`` - Path to /etc/ssl/certs/ca-certificates.crt. -``GStreamer_UBUNTU_R_TTF`` - Path to the TrueType font Ubuntu R. -``GStreamer_FONTS_CONF`` - Path to /etc/fonts.conf. - -Configuration Variables -^^^^^^^^^^^^^^^ - -Like with the main GStreamer library, setting the following variables is -required, depending on the operating system: - -``GStreamer_ROOT_DIR`` - Installation prefix of the GStreamer SDK. - -``GStreamer_JAVA_SRC_DIR`` - Target directory for deploying the selected plugins' Java classfiles to. (Android only) - -``GStreamer_Mobile_MODULE_NAME`` - Name for the GStreamer::mobile shared library. Default is ``gstreamer_android`` (Android) or ``gstreamer_mobile`` (iOS). - -``GStreamer_ASSETS_DIR`` - Target directory for deploying assets to. - -``G_IO_MODULES`` - Set this to the GIO modules you need, additional to any GStreamer plugins. (Usually set to ``gnutls`` or ``openssl``) - -``G_IO_MODULES_PATH`` - Path for the static GIO modules. - -#]=======================================================================] - -if (GStreamerMobile_FOUND) - return() -endif() - -if (NOT GStreamer_ROOT_DIR OR NOT EXISTS "${GStreamer_ROOT_DIR}") - message(FATAL_ERROR "GStreamer_ROOT_DIR must be set to a valid directory before including FindGStreamerMobile " - "(current value: '${GStreamer_ROOT_DIR}')") -endif() - -if(NOT GSTREAMER_LIB_PATH) - set(GSTREAMER_LIB_PATH "${GStreamer_ROOT_DIR}/lib") -endif() - -# _gst_IGNORED_SYSTEM_LIBRARIES and _gst_SRT_REGEX_PATCH are defined in GStreamerHelpers.cmake -if(NOT DEFINED _gst_IGNORED_SYSTEM_LIBRARIES OR NOT DEFINED _gst_SRT_REGEX_PATCH) - include(GStreamerHelpers) -endif() - -if(ANDROID) - if(CMAKE_ANDROID_ARCH_ABI MATCHES "^armeabi" OR CMAKE_ANDROID_ARCH_ABI STREQUAL "x86") - set(_GST_MOBILE_NEEDS_TEXTREL_ERROR TRUE) - set(_GST_MOBILE_NEEDS_BSYMBOLIC_FIX TRUE) - elseif(CMAKE_ANDROID_ARCH_ABI STREQUAL "x86_64" OR CMAKE_ANDROID_ARCH_ABI STREQUAL "arm64-v8a") - set(_GST_MOBILE_NEEDS_BSYMBOLIC_FIX TRUE) - endif() -endif() - -# Set up output variables for Android -if(ANDROID) - if (NOT DEFINED GStreamer_JAVA_SRC_DIR AND DEFINED GSTREAMER_JAVA_SRC_DIR) - set(GStreamer_JAVA_SRC_DIR "${GSTREAMER_JAVA_SRC_DIR}") - elseif(NOT DEFINED GStreamer_JAVA_SRC_DIR) - set(GStreamer_JAVA_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../src/") - elseif(NOT IS_ABSOLUTE "${GStreamer_JAVA_SRC_DIR}") - set(GStreamer_JAVA_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../${GStreamer_JAVA_SRC_DIR}") - endif() - - if(NOT DEFINED GStreamer_NDK_BUILD_PATH AND DEFINED GSTREAMER_NDK_BUILD_PATH) - set(GStreamer_NDK_BUILD_PATH "${GSTREAMER_NDK_BUILD_PATH}") - elseif(NOT DEFINED GStreamer_NDK_BUILD_PATH) - set(GStreamer_NDK_BUILD_PATH "${GStreamer_ROOT_DIR}/share/gst-android/ndk-build/") - endif() -endif() - -if(NOT DEFINED GStreamer_Mobile_MODULE_NAME) - if (DEFINED GSTREAMER_ANDROID_MODULE_NAME) - set(GStreamer_Mobile_MODULE_NAME "${GSTREAMER_ANDROID_MODULE_NAME}") - elseif(ANDROID) - set(GStreamer_Mobile_MODULE_NAME gstreamer_android) - else() - set(GStreamer_Mobile_MODULE_NAME gstreamer_mobile) - endif() -endif() - -if(ANDROID) - if(NOT DEFINED GStreamer_ASSETS_DIR AND DEFINED GSTREAMER_ASSETS_DIR) - set(GStreamer_ASSETS_DIR "${GSTREAMER_ASSETS_DIR}") - elseif(NOT DEFINED GStreamer_ASSETS_DIR) - set(GStreamer_ASSETS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../src/assets/") - elseif(NOT IS_ABSOLUTE "${GStreamer_ASSETS_DIR}") - set(GStreamer_ASSETS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../${GStreamer_ASSETS_DIR}") - endif() - -elseif(IOS) - if(NOT DEFINED GStreamer_ASSETS_DIR AND DEFINED GSTREAMER_ASSETS_DIR) - set(GStreamer_ASSETS_DIR "${GSTREAMER_ASSETS_DIR}") - elseif(NOT DEFINED GStreamer_ASSETS_DIR) - set(GStreamer_ASSETS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/assets") - elseif(NOT IS_ABSOLUTE "${GStreamer_ASSETS_DIR}") - set(GStreamer_ASSETS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../${GStreamer_ASSETS_DIR}") - endif() -endif() - -if (ANDROID) - if(NOT EXISTS "${GStreamer_NDK_BUILD_PATH}/GStreamer.java") - message(FATAL_ERROR "GStreamer.java not found at ${GStreamer_NDK_BUILD_PATH}. " - "Verify GStreamer Android SDK installation.") - endif() -endif() - -if (ANDROID OR IOS) - set(GSTREAMER_IS_MOBILE ON) -else() - set(GSTREAMER_IS_MOBILE OFF) -endif() - -if (GSTREAMER_IS_MOBILE) - if (NOT DEFINED GStreamer_USE_STATIC_LIBS) - set(GStreamer_USE_STATIC_LIBS ON) - endif() - if (NOT GStreamer_USE_STATIC_LIBS) - message(FATAL_ERROR "Shared library GStreamer is not supported on mobile platforms") - endif() -endif() - -set(_gst_plugins ${GStreamerMobile_FIND_COMPONENTS}) -list(REMOVE_ITEM _gst_plugins fonts ca_certificates mobile) -list(REMOVE_DUPLICATES _gst_plugins) - -set(_gst_mobile_plugins ${_gst_plugins}) -list(FILTER _gst_mobile_plugins EXCLUDE REGEX "^api_") -set(_gst_mobile_apis ${_gst_plugins}) -list(FILTER _gst_mobile_apis INCLUDE REGEX "^api_") - -if(GStreamer_DEBUG) - message(STATUS "[GstMobile] GStreamer_ROOT_DIR = ${GStreamer_ROOT_DIR}") - message(STATUS "[GstMobile] GSTREAMER_LIB_PATH = ${GSTREAMER_LIB_PATH}") - message(STATUS "[GstMobile] Requested plugins: ${_gst_mobile_plugins}") - message(STATUS "[GstMobile] Requested APIs: ${_gst_mobile_apis}") -endif() - -macro(_gst_generate_macro_list INPUT_LIST MACRO_NAME OUT_VAR) - list(TRANSFORM ${INPUT_LIST} PREPEND "\n${MACRO_NAME}\(" OUTPUT_VARIABLE ${OUT_VAR}) - list(TRANSFORM ${OUT_VAR} APPEND "\)") - if(${OUT_VAR}) - set(${OUT_VAR} "${${OUT_VAR}};") - endif() -endmacro() - -if (GSTREAMER_IS_MOBILE AND (NOT TARGET GStreamer::mobile)) - if (NOT G_IO_MODULES) - set(G_IO_MODULES) - endif() - list(TRANSFORM G_IO_MODULES PREPEND "gio" OUTPUT_VARIABLE G_IO_MODULES_LIBS) - _gst_generate_macro_list(G_IO_MODULES "GST_G_IO_MODULE_DECLARE" G_IO_MODULES_DECLARE) - _gst_generate_macro_list(G_IO_MODULES "GST_G_IO_MODULE_LOAD" G_IO_MODULES_LOAD) - - if (ANDROID) - set_source_files_properties("${GStreamer_Mobile_MODULE_NAME}.c" PROPERTIES GENERATED TRUE) - add_library(GStreamerMobile - SHARED - "${GStreamer_Mobile_MODULE_NAME}.c" - ) - - else() - add_library(GStreamerMobile SHARED) - enable_language(OBJC OBJCXX) - target_sources(GStreamerMobile - PRIVATE - "${GStreamer_Mobile_MODULE_NAME}.m" - ) - set_source_files_properties("${GStreamer_Mobile_MODULE_NAME}.m" - PROPERTIES - LANGUAGE OBJC - GENERATED TRUE - ) - find_library(Foundation_LIB Foundation REQUIRED) - target_link_libraries(GStreamerMobile - PRIVATE - ${Foundation_LIB} - ) - endif() - add_library(GStreamer::mobile ALIAS GStreamerMobile) - - set_target_properties( - GStreamerMobile - PROPERTIES - LIBRARY_OUTPUT_NAME ${GStreamer_Mobile_MODULE_NAME} - ) - if (APPLE) - set_target_properties( - GStreamerMobile - PROPERTIES - LINKER_LANGUAGE OBJCXX - FRAMEWORK TRUE - FRAMEWORK_VERSION A - MACOSX_FRAMEWORK_IDENTIFIER org.gstreamer.GStreamerMobile - ) - else() - set_target_properties( - GStreamerMobile - PROPERTIES - LINKER_LANGUAGE CXX - ) - endif() -endif() - -if (NOT GStreamer_FOUND) - message(FATAL_ERROR "FindGStreamer must be called before FindGStreamerMobile. " - "Ensure find_package(GStreamer) has completed successfully.") -endif() - -if(GSTREAMER_IS_MOBILE AND TARGET GStreamerMobile) - set(_gst_found_plugins) - foreach(_p IN LISTS _gst_mobile_plugins) - if(GStreamer_${_p}_FOUND) - find_library(_gst_plugin_lib_${_p} gst${_p} - HINTS "${GStreamer_ROOT_DIR}/lib/gstreamer-1.0" - NO_DEFAULT_PATH - ) - if(NOT _gst_plugin_lib_${_p}) - message(STATUS "GStreamerMobile: Plugin '${_p}' has pkg-config but no static library, excluding from init") - unset(_gst_plugin_lib_${_p} CACHE) - continue() - endif() - set(_gst_resolved_plugin_lib_${_p} "${_gst_plugin_lib_${_p}}") - unset(_gst_plugin_lib_${_p} CACHE) - list(APPEND _gst_found_plugins ${_p}) - else() - message(STATUS "GStreamerMobile: Plugin '${_p}' not found in SDK, excluding from init") - endif() - endforeach() - set(_gst_mobile_plugins ${_gst_found_plugins}) - - if(GStreamer_DEBUG) - message(STATUS "[GstMobile] Found plugins with .a: ${_gst_mobile_plugins}") - foreach(_dbg_p IN LISTS _gst_mobile_plugins) - message(STATUS "[GstMobile] ${_dbg_p} -> ${_gst_resolved_plugin_lib_${_dbg_p}}") - endforeach() - - set(_dbg_plugins_with_pc) - set(_dbg_plugins_without_pc) - foreach(_dbg_p IN LISTS _gst_mobile_plugins) - if(TARGET GStreamer::${_dbg_p}) - list(APPEND _dbg_plugins_with_pc ${_dbg_p}) - else() - list(APPEND _dbg_plugins_without_pc ${_dbg_p}) - endif() - endforeach() - message(STATUS "[GstMobile] Plugins WITH GStreamer:: target: ${_dbg_plugins_with_pc}") - message(STATUS "[GstMobile] Plugins WITHOUT GStreamer:: target: ${_dbg_plugins_without_pc}") - endif() - - _gst_generate_macro_list(_gst_mobile_plugins "GST_PLUGIN_STATIC_DECLARE" PLUGINS_DECLARATION) - _gst_generate_macro_list(_gst_mobile_plugins "GST_PLUGIN_STATIC_REGISTER" PLUGINS_REGISTRATION) - - if(ANDROID) - configure_file("${CMAKE_CURRENT_LIST_DIR}/GStreamer/gstreamer_android-1.0.c.in" "${GStreamer_Mobile_MODULE_NAME}.c") - else() - configure_file("${CMAKE_CURRENT_LIST_DIR}/GStreamer/gst_ios_init.m.in" "${GStreamer_Mobile_MODULE_NAME}.m") - endif() - - set(_gst_validate_components) - foreach(_component IN ITEMS mobile ca_certificates fonts) - if(_component IN_LIST GStreamerMobile_FIND_COMPONENTS) - list(APPEND _gst_validate_components ${_component}) - endif() - endforeach() - foreach(_api IN LISTS _gst_mobile_apis) - if(GStreamer_${_api}_FOUND) - list(APPEND _gst_validate_components ${_api}) - endif() - endforeach() - list(APPEND _gst_validate_components ${_gst_mobile_plugins}) - list(REMOVE_DUPLICATES _gst_validate_components) - set(GStreamerMobile_FIND_COMPONENTS ${_gst_validate_components}) -endif() - -if (NOT G_IO_MODULES_PATH) - pkg_get_variable(G_IO_MODULES_PATH gio-2.0 giomoduledir) -endif() -if (NOT G_IO_MODULES_PATH) - set(G_IO_MODULES_PATH "${GStreamer_ROOT_DIR}/lib/gio/modules") -endif() - -if (GSTREAMER_IS_MOBILE AND TARGET GStreamerMobile) - if(PC_GStreamer_VERSION) - set_target_properties( - GStreamerMobile - PROPERTIES - VERSION ${PC_GStreamer_VERSION} - SOVERSION ${PC_GStreamer_VERSION} - ) - endif() - - # Deduplicate to prevent --whole-archive duplicate symbol errors. - # Also exclude plugin libs that will be linked separately at the end of this - # file via _gst_resolved_plugin_lib — linking them here too causes duplicates. - set(_gst_mobile_core_libs ${PC_GStreamer_LIBRARIES}) - if(GStreamer_DEBUG) - message(STATUS "[GstMobile] PC_GStreamer_LIBRARIES (raw): ${PC_GStreamer_LIBRARIES}") - message(STATUS "[GstMobile] PC_GStreamer_STATIC_LIBRARIES (raw): ${PC_GStreamer_STATIC_LIBRARIES}") - message(STATUS "[GstMobile] PC_GStreamer_STATIC_LIBRARY_DIRS: ${PC_GStreamer_STATIC_LIBRARY_DIRS}") - endif() - list(REMOVE_DUPLICATES _gst_mobile_core_libs) - foreach(_p IN LISTS _gst_mobile_plugins) - list(REMOVE_ITEM _gst_mobile_core_libs "gst${_p}") - endforeach() - if(GStreamer_DEBUG) - message(STATUS "[GstMobile] Core libs after plugin filtering: ${_gst_mobile_core_libs}") - endif() - - set(_gst_mobile_core_hints ${GSTREAMER_LIB_PATH}) - _gst_resolve_and_link_libraries(GStreamerMobile PRIVATE _gst_mobile_core_libs _gst_mobile_core_hints WARN_MISSING) - - target_include_directories( - GStreamerMobile - PRIVATE - $ - ) - - if(DEFINED _GST_MOBILE_NEEDS_TEXTREL_ERROR) - target_link_options( - GStreamerMobile - PRIVATE - "-Wl,-z,text" - ) - endif() - - if(DEFINED _GST_MOBILE_NEEDS_BSYMBOLIC_FIX) - target_link_options( - GStreamerMobile - PRIVATE - "-Wl,-Bsymbolic" - ) - endif() - - if(ANDROID) - target_link_options( - GStreamerMobile - PRIVATE - "-Wl,--export-dynamic" - ) - endif() - - if (ANDROID) - set(GSTREAMER_PLUGINS_CLASSES) - foreach(LOCAL_PLUGIN IN LISTS _gst_mobile_plugins) - file(GLOB_RECURSE - LOCAL_PLUGIN_CLASS - FOLLOW_SYMLINKS - RELATIVE "${GStreamer_NDK_BUILD_PATH}" - "${GStreamer_NDK_BUILD_PATH}/${LOCAL_PLUGIN}/*.java" - ) - list(APPEND GSTREAMER_PLUGINS_CLASSES ${LOCAL_PLUGIN_CLASS}) - endforeach() - - add_custom_target( - "copyjavasource_${CMAKE_ANDROID_ARCH_ABI}" - ) - - foreach(LOCAL_FILE IN LISTS GSTREAMER_PLUGINS_CLASSES) - cmake_path(GET LOCAL_FILE FILENAME _java_filename) - cmake_path(GET LOCAL_FILE PARENT_PATH _java_subdir) - string(MAKE_C_IDENTIFIER "cp_${LOCAL_FILE}" COPYJAVASOURCE_TGT) - add_custom_target( - ${COPYJAVASOURCE_TGT} - COMMAND - "${CMAKE_COMMAND}" -E make_directory - "${GStreamer_JAVA_SRC_DIR}/org/freedesktop/gstreamer/${_java_subdir}" - COMMAND - "${CMAKE_COMMAND}" -E copy - "${GStreamer_NDK_BUILD_PATH}/${LOCAL_FILE}" - "${GStreamer_JAVA_SRC_DIR}/org/freedesktop/gstreamer/${_java_subdir}/" - BYPRODUCTS - "${GStreamer_JAVA_SRC_DIR}/org/freedesktop/gstreamer/${_java_subdir}/${_java_filename}" - ) - add_dependencies(copyjavasource_${CMAKE_ANDROID_ARCH_ABI} ${COPYJAVASOURCE_TGT}) - endforeach() - endif() - - if (G_IO_MODULES_LIBS) - add_library(GStreamer::gio_modules INTERFACE IMPORTED) - - _gst_save_find_suffixes() - foreach(_gio_lib IN LISTS G_IO_MODULES_LIBS) - if(_gio_lib MATCHES "${_gst_SRT_REGEX_PATCH}") - string(REGEX REPLACE "${_gst_SRT_REGEX_PATCH}" "\\1" _gio_lib "${_gio_lib}") - endif() - string(MAKE_C_IDENTIFIER "_gst_${_gio_lib}" _gio_cache_var) - if(NOT ${_gio_cache_var}) - find_library(${_gio_cache_var} - NAMES ${_gio_lib} - HINTS ${G_IO_MODULES_PATH} - NO_DEFAULT_PATH - NO_CMAKE_FIND_ROOT_PATH - ) - endif() - if(${_gio_cache_var}) - target_link_libraries(GStreamer::gio_modules INTERFACE "${${_gio_cache_var}}") - else() - message(WARNING "GStreamerMobile: GIO module '${_gio_lib}' not found in ${G_IO_MODULES_PATH}") - endif() - endforeach() - _gst_restore_find_suffixes() - - if ("openssl" IN_LIST G_IO_MODULES) - target_link_directories(GStreamer::gio_modules INTERFACE - "${GStreamer_ROOT_DIR}/lib" - "${GStreamer_ROOT_DIR}/lib/gstreamer-1.0" - ) - find_library(_gst_ssl_lib ssl - HINTS "${GStreamer_ROOT_DIR}/lib" NO_DEFAULT_PATH) - find_library(_gst_crypto_lib crypto - HINTS "${GStreamer_ROOT_DIR}/lib" NO_DEFAULT_PATH) - if(_gst_ssl_lib AND _gst_crypto_lib) - target_link_libraries(GStreamer::gio_modules INTERFACE - "${_gst_ssl_lib}" "${_gst_crypto_lib}") - else() - target_link_libraries(GStreamer::gio_modules INTERFACE ssl crypto) - endif() - unset(_gst_ssl_lib CACHE) - unset(_gst_crypto_lib CACHE) - endif() - - target_link_libraries( - GStreamerMobile - PRIVATE - GStreamer::gio_modules - ) - endif() - set(GStreamerMobile_mobile_FOUND TRUE) -endif() - -set(GSTREAMER_RESOURCES) - -if(fonts IN_LIST GStreamerMobile_FIND_COMPONENTS) - if(ANDROID) - set(GStreamer_UBUNTU_R_TTF "${GStreamer_NDK_BUILD_PATH}/fontconfig/fonts/Ubuntu-R.ttf" - CACHE FILEPATH "Path to Ubuntu-R.ttf") - set(GStreamer_FONTS_CONF "${GStreamer_NDK_BUILD_PATH}/fontconfig/fonts.conf" - CACHE FILEPATH "Path to fonts.conf") - elseif(IOS) - set(GStreamer_UBUNTU_R_TTF "${GStreamer_ROOT_DIR}/share/fontconfig/fonts/Ubuntu-R.ttf" - CACHE FILEPATH "Path to Ubuntu-R.ttf") - set(GStreamer_FONTS_CONF "${GStreamer_ROOT_DIR}/etc/fonts/fonts.conf" - CACHE FILEPATH "Path to fonts.conf") - endif() - if (EXISTS "${GStreamer_UBUNTU_R_TTF}" AND EXISTS "${GStreamer_FONTS_CONF}") - set(GStreamerMobile_fonts_FOUND ON) - - if (ANDROID) - add_custom_target( - copyfontsres_${CMAKE_ANDROID_ARCH_ABI} - COMMAND - "${CMAKE_COMMAND}" -E make_directory - "${GStreamer_ASSETS_DIR}/fontconfig/fonts/truetype/" - COMMAND - "${CMAKE_COMMAND}" -E copy - "${GStreamer_UBUNTU_R_TTF}" - "${GStreamer_ASSETS_DIR}/fontconfig/fonts/truetype/" - COMMAND - "${CMAKE_COMMAND}" -E copy - "${GStreamer_FONTS_CONF}" - "${GStreamer_ASSETS_DIR}/fontconfig/" - BYPRODUCTS - "${GStreamer_ASSETS_DIR}/fontconfig/fonts/truetype/Ubuntu-R.ttf" - "${GStreamer_ASSETS_DIR}/fontconfig/fonts.conf" - ) - - if (TARGET GStreamerMobile) - add_dependencies(GStreamerMobile copyfontsres_${CMAKE_ANDROID_ARCH_ABI}) - endif() - elseif(APPLE) - list(APPEND GSTREAMER_RESOURCES "${GStreamer_FONTS_CONF}" "${GStreamer_UBUNTU_R_TTF}") - else() - message(FATAL_ERROR "No fonts assets available for this operating system.") - endif() - else() - set(GStreamerMobile_fonts_FOUND OFF) - endif() -endif() - -if(ca_certificates IN_LIST GStreamerMobile_FIND_COMPONENTS) - set(GStreamer_CA_BUNDLE "${GStreamer_ROOT_DIR}/etc/ssl/certs/ca-certificates.crt" - CACHE FILEPATH "Path to ca-certificates bundle" - ) - if (EXISTS "${GStreamer_CA_BUNDLE}") - set(GStreamerMobile_ca_certificates_FOUND ON) - - if (ANDROID) - add_custom_target( - copycacertificatesres_${CMAKE_ANDROID_ARCH_ABI} - COMMAND - "${CMAKE_COMMAND}" -E make_directory - "${GStreamer_ASSETS_DIR}/ssl/certs/" - COMMAND - "${CMAKE_COMMAND}" -E copy - "${GStreamer_CA_BUNDLE}" - "${GStreamer_ASSETS_DIR}/ssl/certs/" - BYPRODUCTS "${GStreamer_ASSETS_DIR}/ssl/certs/ca-certificates.crt" - ) - - if (TARGET GStreamerMobile) - add_dependencies(GStreamerMobile copycacertificatesres_${CMAKE_ANDROID_ARCH_ABI}) - endif() - elseif (APPLE) - list(APPEND GSTREAMER_RESOURCES "${GStreamer_CA_BUNDLE}") - else() - message(FATAL_ERROR "No certificate bundle available for this operating system.") - endif() - else() - set(GStreamerMobile_ca_certificates_FOUND OFF) - endif() -endif() - -if(ANDROID) - if (TARGET GStreamerMobile AND TARGET copyjavasource_${CMAKE_ANDROID_ARCH_ABI}) - add_dependencies(GStreamerMobile copyjavasource_${CMAKE_ANDROID_ARCH_ABI}) - endif() -endif() - -if (TARGET GStreamerMobile AND GSTREAMER_RESOURCES) - set_target_properties( - GStreamerMobile - PROPERTIES - RESOURCE "${GSTREAMER_RESOURCES}" - ) -endif() - -include(FindPackageHandleStandardArgs) -foreach(_gst_PLUGIN IN LISTS _gst_plugins) - set(GStreamerMobile_${_gst_PLUGIN}_FOUND "${GStreamer_${_gst_PLUGIN}_FOUND}") -endforeach() -if(GSTREAMER_IS_MOBILE AND TARGET GStreamerMobile) - # Link plugin .a files with --whole-archive (needed for static registration symbols). - # Plugin transitive dependencies (e.g. libavcodec for the libav plugin) are linked - # separately via their GStreamer:: component targets. To avoid linking each - # plugin .a twice (once under --whole-archive, once via the component target), we - # extract only the transitive deps from the component target, excluding the plugin - # .a itself. - set(_gst_wa_libs) - set(_dbg_plugins_linked_component) - set(_dbg_plugins_no_component) - foreach(_gst_PLUGIN IN LISTS _gst_mobile_plugins) - if (GStreamer_${_gst_PLUGIN}_FOUND AND _gst_resolved_plugin_lib_${_gst_PLUGIN}) - list(APPEND _gst_wa_libs "${_gst_resolved_plugin_lib_${_gst_PLUGIN}}") - endif() - if(TARGET GStreamer::${_gst_PLUGIN}) - get_target_property(_plugin_iface_libs GStreamer::${_gst_PLUGIN} INTERFACE_LINK_LIBRARIES) - if(_plugin_iface_libs) - list(REMOVE_ITEM _plugin_iface_libs "${_gst_resolved_plugin_lib_${_gst_PLUGIN}}") - if(_plugin_iface_libs) - target_link_libraries(GStreamerMobile PRIVATE ${_plugin_iface_libs}) - endif() - endif() - list(APPEND _dbg_plugins_linked_component ${_gst_PLUGIN}) - if(GStreamer_DEBUG) - message(STATUS "[GstMobile] GStreamer::${_gst_PLUGIN} INTERFACE_LINK_LIBRARIES = ${_plugin_iface_libs}") - endif() - else() - list(APPEND _dbg_plugins_no_component ${_gst_PLUGIN}) - endif() - endforeach() - - if(GStreamer_DEBUG) - message(STATUS "[GstMobile] WHOLE_ARCHIVE plugin libs: ${_gst_wa_libs}") - message(STATUS "[GstMobile] Plugins linked via component target: ${_dbg_plugins_linked_component}") - message(STATUS "[GstMobile] Plugins WITHOUT component target (deps may be missing): ${_dbg_plugins_no_component}") - endif() - - if(_gst_wa_libs) - target_link_options(GStreamerMobile PRIVATE - "LINKER:--whole-archive" - ${_gst_wa_libs} - "LINKER:--no-whole-archive" - ) - endif() - - if(GStreamer_DEBUG) - get_target_property(_dbg_deps_libs GStreamer::deps INTERFACE_LINK_LIBRARIES) - message(STATUS "[GstMobile] GStreamer::deps INTERFACE_LINK_LIBRARIES = ${_dbg_deps_libs}") - endif() - - target_link_libraries(GStreamerMobile PRIVATE GStreamer::deps) -endif() -set(FPHSA_NAME_MISMATCHED TRUE) -find_package_handle_standard_args(GStreamerMobile - HANDLE_COMPONENTS -) -unset(FPHSA_NAME_MISMATCHED) diff --git a/cmake/find-modules/FindQGCGStreamer.cmake b/cmake/find-modules/FindQGCGStreamer.cmake deleted file mode 100644 index 4014881b200e..000000000000 --- a/cmake/find-modules/FindQGCGStreamer.cmake +++ /dev/null @@ -1,1246 +0,0 @@ -include(GStreamerHelpers) - -if(NOT DEFINED GStreamer_FIND_VERSION) - if(LINUX AND NOT ANDROID) - set(GStreamer_FIND_VERSION ${QGC_CONFIG_GSTREAMER_MIN_VERSION}) - elseif(WIN32 AND NOT ANDROID) - set(GStreamer_FIND_VERSION ${QGC_CONFIG_GSTREAMER_WIN_VERSION}) - elseif(ANDROID) - set(GStreamer_FIND_VERSION ${QGC_CONFIG_GSTREAMER_ANDROID_VERSION}) - elseif(IOS) - set(GStreamer_FIND_VERSION ${QGC_CONFIG_GSTREAMER_IOS_VERSION}) - elseif(MACOS) - set(GStreamer_FIND_VERSION ${QGC_CONFIG_GSTREAMER_MACOS_VERSION}) - else() - message(WARNING "GStreamer: unrecognized platform — using fallback version ${QGC_CONFIG_GSTREAMER_VERSION}") - set(GStreamer_FIND_VERSION ${QGC_CONFIG_GSTREAMER_VERSION}) - endif() -endif() - -if(NOT GStreamer_FIND_VERSION) - message(FATAL_ERROR "GStreamer version not configured. Ensure BuildConfig.cmake has been included " - "and .github/build-config.json contains the appropriate gstreamer_*_version entry.") -endif() - -if(NOT DEFINED GStreamer_ROOT_DIR) - if(DEFINED GSTREAMER_ROOT) - set(GStreamer_ROOT_DIR ${GSTREAMER_ROOT}) - elseif(DEFINED GStreamer_ROOT) - set(GStreamer_ROOT_DIR ${GStreamer_ROOT}) - endif() - - if(DEFINED GStreamer_ROOT_DIR AND NOT EXISTS "${GStreamer_ROOT_DIR}") - message(FATAL_ERROR "GStreamer: User-provided directory does not exist: ${GStreamer_ROOT_DIR}\n" - "Correct the path or unset GStreamer_ROOT_DIR to allow auto-download.") - endif() -endif() - -if(NOT DEFINED GStreamer_USE_STATIC_LIBS) - if(ANDROID OR IOS) - set(GStreamer_USE_STATIC_LIBS ON) - else() - set(GStreamer_USE_STATIC_LIBS OFF) - endif() -endif() - -if(NOT DEFINED GStreamer_USE_FRAMEWORK) - if(APPLE) - set(GStreamer_USE_FRAMEWORK ON) - else() - set(GStreamer_USE_FRAMEWORK OFF) - endif() -endif() - -set(PKG_CONFIG_ARGN "" CACHE STRING "Extra arguments for pkg-config" FORCE) -set(GStreamer_AUTO_DOWNLOADED FALSE) - -function(_qgc_find_apple_pkg_config OUT_VAR) - find_program(_qgc_pkg_config - NAMES pkg-config pkgconf - PATHS /opt/homebrew/bin /usr/local/bin - NO_DEFAULT_PATH - ) - if(NOT _qgc_pkg_config) - find_program(_qgc_pkg_config NAMES pkg-config pkgconf) - endif() - if(NOT _qgc_pkg_config) - message(FATAL_ERROR - "Could not find pkg-config.\n" - "Install dependencies with: python3 tools/setup/install_dependencies --platform macos\n" - "or install pkg-config manually (for example: brew install pkg-config).") - endif() - set(${OUT_VAR} "${_qgc_pkg_config}" CACHE FILEPATH "pkg-config executable" FORCE) - unset(_qgc_pkg_config CACHE) -endfunction() - -function(_qgc_windows_sdk_complete ROOT_DIR OUT_VAR) - set(_required_paths - "bin/pkg-config.exe" - "include/gstreamer-1.0" - "lib/gstreamer-1.0" - "lib/pkgconfig/gstreamer-1.0.pc" - ) - - set(_is_complete TRUE) - foreach(_path IN LISTS _required_paths) - if(NOT EXISTS "${ROOT_DIR}/${_path}") - set(_is_complete FALSE) - break() - endif() - endforeach() - - set(${OUT_VAR} "${_is_complete}" PARENT_SCOPE) -endfunction() - -function(_qgc_find_win_sdk_root EXTRACTED_DIR OUT_VAR) - if(EXISTS "${EXTRACTED_DIR}/bin/pkg-config.exe") - set(${OUT_VAR} "${EXTRACTED_DIR}" PARENT_SCOPE) - return() - endif() - file(GLOB_RECURSE _pkg_config_files "${EXTRACTED_DIR}/pkg-config.exe") - if(_pkg_config_files) - list(GET _pkg_config_files 0 _first_pkg_config) - cmake_path(GET _first_pkg_config PARENT_PATH _bin_dir) - cmake_path(GET _bin_dir PARENT_PATH _found_root) - set(${OUT_VAR} "${_found_root}" PARENT_SCOPE) - else() - set(${OUT_VAR} "" PARENT_SCOPE) - endif() -endfunction() - -function(_qgc_validate_expanded_pkg EXPANDED_DIR LABEL) - file(GLOB _payloads "${EXPANDED_DIR}/*.pkg/Payload") - if(NOT _payloads) - file(REMOVE_RECURSE "${EXPANDED_DIR}") - message(FATAL_ERROR - "pkgutil expanded GStreamer ${LABEL} package but no payloads were found in ${EXPANDED_DIR}") - endif() -endfunction() - -macro(_qgc_discover_windows_sdk) - if(CMAKE_SYSTEM_PROCESSOR MATCHES "ARM64|aarch64") - set(_gst_win_arch "arm64") - set(_gst_win_platform "windows_msvc_arm64") - else() - set(_gst_win_arch "x86_64") - set(_gst_win_platform "windows_msvc_x64") - endif() - - if(NOT DEFINED GStreamer_ROOT_DIR) - if(_gst_win_arch STREQUAL "arm64") - if(DEFINED ENV{GSTREAMER_1_0_ROOT_ARM64} AND EXISTS "$ENV{GSTREAMER_1_0_ROOT_ARM64}") - set(GStreamer_ROOT_DIR "$ENV{GSTREAMER_1_0_ROOT_ARM64}") - elseif(MSVC AND DEFINED ENV{GSTREAMER_1_0_ROOT_MSVC_ARM64} AND EXISTS "$ENV{GSTREAMER_1_0_ROOT_MSVC_ARM64}") - set(GStreamer_ROOT_DIR "$ENV{GSTREAMER_1_0_ROOT_MSVC_ARM64}") - elseif(EXISTS "C:/Program Files/gstreamer/1.0/msvc_arm64") - set(GStreamer_ROOT_DIR "C:/Program Files/gstreamer/1.0/msvc_arm64") - elseif(EXISTS "C:/gstreamer/1.0/msvc_arm64") - set(GStreamer_ROOT_DIR "C:/gstreamer/1.0/msvc_arm64") - endif() - else() - if(DEFINED ENV{GSTREAMER_1_0_ROOT_X86_64} AND EXISTS "$ENV{GSTREAMER_1_0_ROOT_X86_64}") - set(GStreamer_ROOT_DIR "$ENV{GSTREAMER_1_0_ROOT_X86_64}") - elseif(MSVC AND DEFINED ENV{GSTREAMER_1_0_ROOT_MSVC_X86_64} AND EXISTS "$ENV{GSTREAMER_1_0_ROOT_MSVC_X86_64}") - set(GStreamer_ROOT_DIR "$ENV{GSTREAMER_1_0_ROOT_MSVC_X86_64}") - elseif(MINGW AND DEFINED ENV{GSTREAMER_1_0_ROOT_MINGW_X86_64} AND EXISTS "$ENV{GSTREAMER_1_0_ROOT_MINGW_X86_64}") - set(GStreamer_ROOT_DIR "$ENV{GSTREAMER_1_0_ROOT_MINGW_X86_64}") - elseif(EXISTS "C:/Program Files/gstreamer/1.0/msvc_x86_64") - set(GStreamer_ROOT_DIR "C:/Program Files/gstreamer/1.0/msvc_x86_64") - elseif(EXISTS "C:/gstreamer/1.0/msvc_x86_64") - set(GStreamer_ROOT_DIR "C:/gstreamer/1.0/msvc_x86_64") - endif() - endif() - endif() - - if(DEFINED GStreamer_ROOT_DIR AND EXISTS "${GStreamer_ROOT_DIR}") - _qgc_windows_sdk_complete("${GStreamer_ROOT_DIR}" _gst_win_sdk_complete) - if(NOT _gst_win_sdk_complete) - message(WARNING - "Existing GStreamer SDK at ${GStreamer_ROOT_DIR} is incomplete " - "(missing devel/runtime artifacts). Falling back to auto-download.") - unset(GStreamer_ROOT_DIR) - endif() - endif() - - if(NOT DEFINED GStreamer_ROOT_DIR OR NOT EXISTS "${GStreamer_ROOT_DIR}") - if(NOT MSVC OR NOT CMAKE_SIZEOF_VOID_P EQUAL 8) - message(FATAL_ERROR - "Automatic GStreamer download on Windows requires MSVC 64-bit (x64 or ARM64).\n" - "Set GStreamer_ROOT_DIR to a complete SDK for this toolchain/architecture.") - endif() - - if(CPM_SOURCE_CACHE) - set(_gst_win_cache_dir "${CPM_SOURCE_CACHE}/gstreamer-win-${_gst_win_arch}-${GStreamer_FIND_VERSION}") - else() - set(_gst_win_cache_dir "${CMAKE_BINARY_DIR}/_deps/gstreamer-win-${_gst_win_arch}-${GStreamer_FIND_VERSION}") - endif() - set(_gst_win_extracted "${_gst_win_cache_dir}/sdk") - - gstreamer_download_sdk(${_gst_win_platform} ${GStreamer_FIND_VERSION} - "gstreamer-${_gst_win_arch}.exe" "${_gst_win_cache_dir}" _gst_win_exe) - - set(_gst_win_root "") - if(EXISTS "${_gst_win_extracted}") - _qgc_find_win_sdk_root("${_gst_win_extracted}" _gst_win_root) - endif() - - if(NOT _gst_win_root - OR NOT EXISTS "${_gst_win_root}/lib/gstreamer-1.0" - OR NOT EXISTS "${_gst_win_root}/lib/pkgconfig/gstreamer-1.0.pc") - file(REMOVE_RECURSE "${_gst_win_extracted}") - cmake_path(NATIVE_PATH _gst_win_exe _gst_win_exe_native) - cmake_path(NATIVE_PATH _gst_win_extracted _gst_win_extracted_native) - set(_gst_win_installer_log "${_gst_win_cache_dir}/installer-${_gst_win_arch}.log") - cmake_path(NATIVE_PATH _gst_win_installer_log _gst_win_installer_log_native) - - message(STATUS "Installing GStreamer ${_gst_win_arch} SDK (silent)...") - execute_process( - COMMAND "${_gst_win_exe_native}" - /VERYSILENT /SUPPRESSMSGBOXES /SP- /NORESTART - "/LOG=${_gst_win_installer_log_native}" - "/DIR=${_gst_win_extracted_native}" - RESULT_VARIABLE _installer_rc - ERROR_VARIABLE _installer_err - TIMEOUT 600 - ) - if(NOT _installer_rc EQUAL 0) - file(REMOVE_RECURSE "${_gst_win_extracted}") - message(FATAL_ERROR "GStreamer installer failed (exit code: ${_installer_rc}).\n" - "stderr: ${_installer_err}\n" - "See installer log: ${_gst_win_installer_log}") - endif() - - _qgc_find_win_sdk_root("${_gst_win_extracted}" _gst_win_root) - if(_gst_win_root AND NOT _gst_win_root STREQUAL "${_gst_win_extracted}") - message(STATUS "GStreamer: SDK root at ${_gst_win_root}") - endif() - - if(NOT _gst_win_root - OR NOT EXISTS "${_gst_win_root}/bin/pkg-config.exe" - OR NOT EXISTS "${_gst_win_root}/lib/gstreamer-1.0" - OR NOT EXISTS "${_gst_win_root}/lib/pkgconfig/gstreamer-1.0.pc") - file(REMOVE_RECURSE "${_gst_win_extracted}") - message(FATAL_ERROR "GStreamer SDK extracted but required files are missing.\n" - "Delete ${_gst_win_cache_dir} and re-run cmake to retry.") - endif() - endif() - - set(GStreamer_ROOT_DIR "${_gst_win_root}") - set(GStreamer_AUTO_DOWNLOADED TRUE) - endif() - - _gst_normalize_and_validate_root() - _gst_set_standard_paths() - - _gst_configure_pkg_config( - PKG_CONFIG_EXE "${GStreamer_ROOT_DIR}/bin/pkg-config.exe" - LIBDIR "${GSTREAMER_LIB_PATH}/pkgconfig" "${GSTREAMER_PLUGIN_PATH}/pkgconfig" - DONT_DEFINE_PREFIX - ) -endmacro() - -macro(_qgc_discover_linux_sdk) - if(NOT DEFINED GStreamer_ROOT_DIR) - # Cross-builds resolve system libs from the sysroot, not the host /usr. - if(CMAKE_SYSROOT) - set(GStreamer_ROOT_DIR "${CMAKE_SYSROOT}/usr") - else() - set(GStreamer_ROOT_DIR "/usr") - endif() - endif() - - _gst_normalize_and_validate_root() - - if((EXISTS "${GStreamer_ROOT_DIR}/lib/${CMAKE_SYSTEM_PROCESSOR}-linux-gnu") AND (EXISTS "${GStreamer_ROOT_DIR}/lib/${CMAKE_SYSTEM_PROCESSOR}-linux-gnu/gstreamer-1.0")) - set(GSTREAMER_LIB_PATH "${GStreamer_ROOT_DIR}/lib/${CMAKE_SYSTEM_PROCESSOR}-linux-gnu") - elseif((EXISTS "${GStreamer_ROOT_DIR}/lib64") AND (EXISTS "${GStreamer_ROOT_DIR}/lib64/gstreamer-1.0")) - set(GSTREAMER_LIB_PATH "${GStreamer_ROOT_DIR}/lib64") - elseif((EXISTS "${GStreamer_ROOT_DIR}/lib") AND (EXISTS "${GStreamer_ROOT_DIR}/lib/gstreamer-1.0")) - set(GSTREAMER_LIB_PATH "${GStreamer_ROOT_DIR}/lib") - else() - message(FATAL_ERROR "Could not locate GStreamer libraries - check installation or set environment/cmake variables") - endif() - - set(GSTREAMER_PLUGIN_PATH "${GSTREAMER_LIB_PATH}/gstreamer-1.0") - set(GSTREAMER_INCLUDE_PATH "${GStreamer_ROOT_DIR}/include") - - # Prepend (not replace) so system glib/gobject .pc files remain discoverable - set(ENV{PKG_CONFIG_PATH} "${GSTREAMER_LIB_PATH}/pkgconfig:$ENV{PKG_CONFIG_PATH}") -endmacro() - -macro(_qgc_discover_android_sdk) - if(NOT DEFINED GStreamer_ROOT_DIR) - if(CPM_SOURCE_CACHE) - set(_gst_android_cache "${CPM_SOURCE_CACHE}/gstreamer-android") - else() - set(_gst_android_cache "${CMAKE_BINARY_DIR}/_deps/gstreamer-android") - endif() - gstreamer_download_sdk(android ${GStreamer_FIND_VERSION} - "gstreamer-android-${GStreamer_FIND_VERSION}.tar.xz" "${_gst_android_cache}" _gst_android_archive) - - CPMAddPackage( - NAME gstreamer - VERSION ${GStreamer_FIND_VERSION} - URL "file://${_gst_android_archive}" - ) - - if(CMAKE_ANDROID_ARCH_ABI STREQUAL "armeabi-v7a") - set(GStreamer_ABI_DIR "armv7") - elseif(CMAKE_ANDROID_ARCH_ABI STREQUAL "arm64-v8a") - set(GStreamer_ABI_DIR "arm64") - elseif(CMAKE_ANDROID_ARCH_ABI STREQUAL "x86") - set(GStreamer_ABI_DIR "x86") - elseif(CMAKE_ANDROID_ARCH_ABI STREQUAL "x86_64") - set(GStreamer_ABI_DIR "x86_64") - else() - message(FATAL_ERROR "Unsupported Android ABI: ${CMAKE_ANDROID_ARCH_ABI}") - endif() - set(GStreamer_ROOT_DIR "${gstreamer_SOURCE_DIR}/${GStreamer_ABI_DIR}") - set(GStreamer_AUTO_DOWNLOADED TRUE) - endif() - - _gst_normalize_and_validate_root() - _gst_set_standard_paths() - - if(CMAKE_HOST_WIN32) - _gst_configure_pkg_config( - PKG_CONFIG_EXE "${GStreamer_ROOT_DIR}/share/gst-android/ndk-build/tools/windows/pkg-config.exe" - LIBDIR "${GSTREAMER_LIB_PATH}/pkgconfig" "${GSTREAMER_PLUGIN_PATH}/pkgconfig" - DONT_DEFINE_PREFIX - ) - elseif(CMAKE_HOST_UNIX) - if(CMAKE_HOST_APPLE) - _qgc_find_apple_pkg_config(PKG_CONFIG_EXECUTABLE) - endif() - _gst_configure_pkg_config( - LIBDIR "${GSTREAMER_LIB_PATH}/pkgconfig" "${GSTREAMER_PLUGIN_PATH}/pkgconfig" - ) - endif() -endmacro() - -macro(_qgc_discover_macos_sdk) - if(NOT DEFINED GStreamer_ROOT_DIR) - if(EXISTS "/Library/Frameworks/GStreamer.framework") - set(GStreamer_ROOT_DIR "/Library/Frameworks/GStreamer.framework/Versions/1.0") - else() - foreach(_brew_prefix IN ITEMS "/opt/homebrew/opt/gstreamer" "/usr/local/opt/gstreamer") - if(EXISTS "${_brew_prefix}") - set(GStreamer_ROOT_DIR "${_brew_prefix}") - set(GStreamer_USE_FRAMEWORK OFF) - message(STATUS "GStreamer: Using Homebrew at ${GStreamer_ROOT_DIR}") - break() - endif() - endforeach() - endif() - endif() - - if(NOT DEFINED GStreamer_ROOT_DIR OR NOT EXISTS "${GStreamer_ROOT_DIR}") - if(CPM_SOURCE_CACHE) - set(_gst_mac_cache_dir "${CPM_SOURCE_CACHE}/gstreamer-mac-${GStreamer_FIND_VERSION}") - else() - set(_gst_mac_cache_dir "${CMAKE_BINARY_DIR}/_deps/gstreamer-mac-${GStreamer_FIND_VERSION}") - endif() - set(_gst_mac_expanded "${_gst_mac_cache_dir}/expanded") - set(_gst_mac_devel_expanded "${_gst_mac_cache_dir}/expanded-devel") - set(_gst_mac_root "${_gst_mac_cache_dir}/root") - set(_gst_mac_required_plugin_dir "${_gst_mac_root}/lib/gstreamer-1.0") - set(_gst_mac_required_include_dir "${_gst_mac_root}/include/gstreamer-1.0") - set(_gst_mac_required_pc_file "${_gst_mac_root}/lib/pkgconfig/gstreamer-1.0.pc") - - gstreamer_download_sdk(macos ${GStreamer_FIND_VERSION} - "gstreamer.pkg" "${_gst_mac_cache_dir}" _gst_mac_pkg) - gstreamer_download_sdk(macos_devel ${GStreamer_FIND_VERSION} - "gstreamer-devel.pkg" "${_gst_mac_cache_dir}" _gst_mac_devel_pkg) - - if(EXISTS "${_gst_mac_root}/.merge_complete") - if(NOT EXISTS "${_gst_mac_required_plugin_dir}" - OR NOT EXISTS "${_gst_mac_required_include_dir}" - OR NOT EXISTS "${_gst_mac_required_pc_file}") - message(STATUS "GStreamer: cached macOS SDK is incomplete; rebuilding cache") - file(REMOVE_RECURSE "${_gst_mac_root}" "${_gst_mac_expanded}" "${_gst_mac_devel_expanded}") - endif() - endif() - - if(NOT EXISTS "${_gst_mac_root}/.merge_complete") - file(REMOVE_RECURSE "${_gst_mac_root}") - file(REMOVE_RECURSE "${_gst_mac_expanded}" "${_gst_mac_devel_expanded}") - - message(STATUS "Expanding GStreamer macOS runtime package...") - execute_process( - COMMAND pkgutil --expand-full "${_gst_mac_pkg}" "${_gst_mac_expanded}" - RESULT_VARIABLE _pkgutil_rc - ) - if(NOT _pkgutil_rc EQUAL 0) - file(REMOVE_RECURSE "${_gst_mac_expanded}") - message(FATAL_ERROR "pkgutil failed to expand GStreamer runtime .pkg (exit code: ${_pkgutil_rc})") - endif() - _qgc_validate_expanded_pkg("${_gst_mac_expanded}" "runtime") - - message(STATUS "Expanding GStreamer macOS devel package...") - execute_process( - COMMAND pkgutil --expand-full "${_gst_mac_devel_pkg}" "${_gst_mac_devel_expanded}" - RESULT_VARIABLE _pkgutil_rc - ) - if(NOT _pkgutil_rc EQUAL 0) - file(REMOVE_RECURSE "${_gst_mac_devel_expanded}") - message(FATAL_ERROR "pkgutil failed to expand GStreamer devel .pkg (exit code: ${_pkgutil_rc})") - endif() - _qgc_validate_expanded_pkg("${_gst_mac_devel_expanded}" "devel") - - file(MAKE_DIRECTORY "${_gst_mac_root}") - foreach(_expanded_dir IN ITEMS "${_gst_mac_expanded}" "${_gst_mac_devel_expanded}") - file(GLOB _sub_pkg_dirs "${_expanded_dir}/*.pkg") - foreach(_pkg_dir IN LISTS _sub_pkg_dirs) - if(EXISTS "${_pkg_dir}/Payload") - file(GLOB _payload_entries "${_pkg_dir}/Payload/*") - foreach(_entry IN LISTS _payload_entries) - cmake_path(GET _entry FILENAME _entry_name) - if(_entry_name STREQUAL "Headers") - continue() - endif() - cmake_path(IS_PREFIX _gst_mac_root "${_gst_mac_root}/${_entry_name}" NORMALIZE _is_safe) - if(NOT _is_safe) - message(FATAL_ERROR "GStreamer: Path traversal detected in extracted SDK entry: ${_entry_name}") - endif() - file(COPY "${_entry}" DESTINATION "${_gst_mac_root}") - endforeach() - endif() - endforeach() - endforeach() - file(TOUCH "${_gst_mac_root}/.merge_complete") - endif() - - if(NOT EXISTS "${_gst_mac_required_plugin_dir}" - OR NOT EXISTS "${_gst_mac_required_include_dir}" - OR NOT EXISTS "${_gst_mac_required_pc_file}") - file(REMOVE_RECURSE "${_gst_mac_root}" "${_gst_mac_expanded}" "${_gst_mac_devel_expanded}") - message(FATAL_ERROR "Downloaded macOS GStreamer SDK is incomplete " - "(required runtime/devel artifacts were not found).\n" - "Install manually from https://gstreamer.freedesktop.org/download/ or set GStreamer_ROOT_DIR.") - endif() - - set(GStreamer_ROOT_DIR "${_gst_mac_root}") - set(GStreamer_USE_FRAMEWORK OFF) - set(GStreamer_AUTO_DOWNLOADED TRUE) - endif() - - _gst_normalize_and_validate_root() - _gst_set_standard_paths() - - if(GStreamer_USE_FRAMEWORK AND NOT DEFINED GSTREAMER_FRAMEWORK_PATH) - set(GSTREAMER_FRAMEWORK_PATH "${GStreamer_ROOT_DIR}/../..") - cmake_path(NORMAL_PATH GSTREAMER_FRAMEWORK_PATH) - endif() - - if(GStreamer_USE_FRAMEWORK) - _gst_configure_pkg_config( - PKG_CONFIG_EXE "${GStreamer_ROOT_DIR}/bin/pkg-config" - LIBDIR "${GSTREAMER_LIB_PATH}/pkgconfig" "${GSTREAMER_PLUGIN_PATH}/pkgconfig" - ) - else() - _qgc_find_apple_pkg_config(PKG_CONFIG_EXECUTABLE) - _gst_configure_pkg_config( - LIBDIR "${GSTREAMER_LIB_PATH}/pkgconfig" "${GSTREAMER_PLUGIN_PATH}/pkgconfig" - ) - endif() -endmacro() - -macro(_qgc_discover_ios_sdk) - if(NOT CMAKE_HOST_APPLE) - message(FATAL_ERROR "GStreamer for iOS can only be built on macOS") - endif() - - # ── User-supplied root ──────────────────────────────────────────────────── - if(NOT DEFINED GStreamer_ROOT_DIR) - # Check well-known system install locations (framework and xcframework). - if(EXISTS "/Library/Developer/GStreamer/iPhone.sdk/GStreamer.xcframework") - set(_gst_ios_system_xcfw "/Library/Developer/GStreamer/iPhone.sdk/GStreamer.xcframework") - elseif(EXISTS "/Library/Developer/GStreamer/iPhone.sdk/GStreamer.framework") - set(GSTREAMER_FRAMEWORK_PATH "/Library/Developer/GStreamer/iPhone.sdk/GStreamer.framework") - set(GStreamer_ROOT_DIR "${GSTREAMER_FRAMEWORK_PATH}/Versions/1.0") - endif() - endif() - - # ── Auto-download / expand ──────────────────────────────────────────────── - if((NOT DEFINED GStreamer_ROOT_DIR OR NOT EXISTS "${GStreamer_ROOT_DIR}") AND NOT DEFINED _gst_ios_system_xcfw) - if(CPM_SOURCE_CACHE) - set(_gst_ios_cache_dir "${CPM_SOURCE_CACHE}/gstreamer-ios-${GStreamer_FIND_VERSION}") - else() - set(_gst_ios_cache_dir "${CMAKE_BINARY_DIR}/_deps/gstreamer-ios-${GStreamer_FIND_VERSION}") - endif() - set(_gst_ios_expanded "${_gst_ios_cache_dir}/expanded") - - gstreamer_download_sdk(ios ${GStreamer_FIND_VERSION} - "gstreamer-ios.pkg" "${_gst_ios_cache_dir}" _gst_ios_pkg) - - # Anchors that prove the expansion is complete for either SDK layout. - set(_gst_ios_anchor_globs - "${_gst_ios_expanded}/*/GStreamer.xcframework/Info.plist" - "${_gst_ios_expanded}/*/GStreamer.framework/Headers/gst/gst.h" - "${_gst_ios_expanded}/*/GStreamer.framework/Versions/1.0/Headers/gst/gst.h" - "${_gst_ios_expanded}/*/GStreamer.framework/GStreamer" - "${_gst_ios_expanded}/*/GStreamer.framework/Versions/1.0/GStreamer" - "${_gst_ios_expanded}/*/GStreamer.framework/Info.plist" - ) - - if(EXISTS "${_gst_ios_expanded}") - set(_cached_anchor "") - foreach(_glob IN LISTS _gst_ios_anchor_globs) - file(GLOB_RECURSE _cached_anchor "${_glob}") - if(_cached_anchor) - break() - endif() - endforeach() - if(NOT _cached_anchor) - message(STATUS "GStreamer: cached iOS expansion is incomplete; re-expanding") - file(REMOVE_RECURSE "${_gst_ios_expanded}") - endif() - endif() - - if(NOT EXISTS "${_gst_ios_expanded}") - message(STATUS "Expanding GStreamer iOS package...") - execute_process( - COMMAND pkgutil --expand-full "${_gst_ios_pkg}" "${_gst_ios_expanded}" - RESULT_VARIABLE _pkgutil_rc - ) - if(NOT _pkgutil_rc EQUAL 0) - file(REMOVE_RECURSE "${_gst_ios_expanded}") - message(FATAL_ERROR - "pkgutil failed to expand GStreamer iOS .pkg (exit code: ${_pkgutil_rc})") - endif() - _qgc_validate_expanded_pkg("${_gst_ios_expanded}" "iOS") - endif() - - # ── xcframework detection (GStreamer 1.28+) ─────────────────────────── - # Check xcframework first; fall through to .framework for older SDKs. - file(GLOB_RECURSE _xcfw_info_plists LIST_DIRECTORIES false - "${_gst_ios_expanded}/*/GStreamer.xcframework/Info.plist" - "${_gst_ios_expanded}/GStreamer.xcframework/Info.plist" - ) - if(NOT _xcfw_info_plists) - # Broader walk in case the pkg nests the xcframework further. - file(GLOB_RECURSE _all_dirs LIST_DIRECTORIES true "${_gst_ios_expanded}/*") - foreach(_d IN LISTS _all_dirs) - if(IS_DIRECTORY "${_d}" AND _d MATCHES "/GStreamer\.xcframework$") - if(EXISTS "${_d}/Info.plist") - list(APPEND _xcfw_info_plists "${_d}/Info.plist") - break() - endif() - endif() - endforeach() - endif() - - if(_xcfw_info_plists) - list(GET _xcfw_info_plists 0 _xcfw_info_first) - cmake_path(GET _xcfw_info_first PARENT_PATH _gst_ios_system_xcfw) - else() - # ── .framework fallback (pre-1.28 SDK) ─────────────────────────── - # Older SDK versions ship GStreamer.framework; try anchors first. - set(_anchor_hit "") - foreach(_glob IN LISTS _gst_ios_anchor_globs) - file(GLOB_RECURSE _anchor_hit "${_glob}") - if(_anchor_hit) - break() - endif() - endforeach() - - if(_anchor_hit) - list(GET _anchor_hit 0 _anchor_first) - set(_walk "${_anchor_first}") - while(_walk AND NOT _walk MATCHES "GStreamer\.framework$") - cmake_path(GET _walk PARENT_PATH _walk) - if(_walk STREQUAL "/" OR _walk STREQUAL "") - break() - endif() - endwhile() - if(_walk MATCHES "GStreamer\.framework$") - set(GSTREAMER_FRAMEWORK_PATH "${_walk}") - endif() - endif() - - if(NOT GSTREAMER_FRAMEWORK_PATH) - file(GLOB_RECURSE _all_dirs LIST_DIRECTORIES true "${_gst_ios_expanded}/*") - foreach(_d IN LISTS _all_dirs) - if(IS_DIRECTORY "${_d}" AND _d MATCHES "/GStreamer\.framework$") - if(EXISTS "${_d}/Headers" OR EXISTS "${_d}/Versions/1.0/Headers") - set(GSTREAMER_FRAMEWORK_PATH "${_d}") - break() - elseif(NOT GSTREAMER_FRAMEWORK_PATH) - set(GSTREAMER_FRAMEWORK_PATH "${_d}") - endif() - endif() - endforeach() - endif() - - if(NOT GSTREAMER_FRAMEWORK_PATH) - file(GLOB _top_entries LIST_DIRECTORIES true "${_gst_ios_expanded}/*") - file(GLOB_RECURSE _all_frameworks LIST_DIRECTORIES true - "${_gst_ios_expanded}/*.framework") - file(GLOB_RECURSE _all_xcframeworks LIST_DIRECTORIES true - "${_gst_ios_expanded}/*.xcframework") - file(GLOB_RECURSE _all_in_expanded "${_gst_ios_expanded}/*") - list(LENGTH _all_in_expanded _n) - string(REPLACE ";" "\n " _top_entries_str "${_top_entries}") - string(REPLACE ";" "\n " _all_frameworks_str "${_all_frameworks}") - string(REPLACE ";" "\n " _all_xcframeworks_str "${_all_xcframeworks}") - message(FATAL_ERROR - "Could not locate GStreamer.xcframework or GStreamer.framework in expanded iOS SDK at" - " '${_gst_ios_expanded}' (${_n} entries). The .pkg layout may" - " have changed; check the pkgutil --expand-full output.\n" - "Top-level entries:\n ${_top_entries_str}\n" - "All *.framework directories:\n ${_all_frameworks_str}\n" - "All *.xcframework directories:\n ${_all_xcframeworks_str}") - endif() - - set(GStreamer_ROOT_DIR "${GSTREAMER_FRAMEWORK_PATH}/Versions/1.0") - endif() - set(GStreamer_AUTO_DOWNLOADED TRUE) - endif() - - # ── xcframework path: pick the right slice ──────────────────────────────── - if(DEFINED _gst_ios_system_xcfw) - set(GStreamer_USE_XCFRAMEWORK ON) - - # Select slice based on sysroot: iphoneos=device, iphonesimulator=simulator. - if(CMAKE_OSX_SYSROOT MATCHES "iphonesimulator") - set(_xcfw_slice "ios-arm64_x86_64-simulator") - else() - set(_xcfw_slice "ios-arm64") - endif() - - set(_xcfw_slice_dir "${_gst_ios_system_xcfw}/${_xcfw_slice}") - if(NOT EXISTS "${_xcfw_slice_dir}") - message(FATAL_ERROR - "GStreamer xcframework slice '${_xcfw_slice}' not found in ${_gst_ios_system_xcfw}. -" - "Available slices: check ${_gst_ios_system_xcfw}/Info.plist AvailableLibraries.") - endif() - - # Synthetic path vars so guards and consumers have consistent variables. - # xcframework has no lib/ or lib/gstreamer-1.0/ — all code is in libGStreamer.a. - set(GStreamer_ROOT_DIR "${_xcfw_slice_dir}") - set(GSTREAMER_XCFRAMEWORK_PATH "${_gst_ios_system_xcfw}") - set(GSTREAMER_XCFRAMEWORK_LIB "${_xcfw_slice_dir}/libGStreamer.a") - set(GSTREAMER_INCLUDE_PATH "${_xcfw_slice_dir}/Headers") - # GSTREAMER_LIB_PATH and GSTREAMER_PLUGIN_PATH are synthetic — they point - # to the slice dir itself because there is no lib/ subdirectory in an xcframework. - set(GSTREAMER_LIB_PATH "${_xcfw_slice_dir}") - set(GSTREAMER_PLUGIN_PATH "${_xcfw_slice_dir}") - - _gst_normalize_and_validate_root() - endif() - - # ── Classic .framework path ─────────────────────────────────────────────── - _gst_normalize_and_validate_root() - - set(GStreamer_USE_FRAMEWORK ON) - if(NOT DEFINED GSTREAMER_FRAMEWORK_PATH) - set(GSTREAMER_FRAMEWORK_PATH "${GStreamer_ROOT_DIR}/../..") - cmake_path(NORMAL_PATH GSTREAMER_FRAMEWORK_PATH) - endif() - _gst_set_standard_paths(INCLUDE_PATH "${GSTREAMER_FRAMEWORK_PATH}/Headers") - - # Cerbero iOS framework lays out libraries under Versions/1.0/lib but the - # framework root also exposes Libraries -> Versions/Current/lib symlinks; - # if the default ${GStreamer_ROOT_DIR}/lib doesn't exist, try alternatives. - if(NOT EXISTS "${GSTREAMER_LIB_PATH}") - foreach(_cand IN ITEMS - "${GStreamer_ROOT_DIR}/Libraries" - "${GSTREAMER_FRAMEWORK_PATH}/Libraries" - "${GSTREAMER_FRAMEWORK_PATH}/lib" - ) - if(EXISTS "${_cand}") - set(GSTREAMER_LIB_PATH "${_cand}") - set(GSTREAMER_PLUGIN_PATH "${GSTREAMER_LIB_PATH}/gstreamer-1.0") - break() - endif() - endforeach() - endif() - # 1.28+ embeds everything in the framework binary — no lib/ subdir exists. - if(NOT EXISTS "${GSTREAMER_LIB_PATH}") - set(GSTREAMER_LIB_PATH "${GStreamer_ROOT_DIR}") - set(GSTREAMER_PLUGIN_PATH "${GStreamer_ROOT_DIR}") - endif() - - # When no pkgconfig dir is present the framework is a single fat binary (1.28+ - # Cerbero layout); reuse the xcframework target-creation path which already handles - # this: no pkg-config queries, direct link of the binary, system framework deps. - if(NOT EXISTS "${GSTREAMER_LIB_PATH}/pkgconfig") - # The framework binary lives at Versions/1.0/ (= GStreamer_ROOT_DIR/GStreamer). - set(GSTREAMER_XCFRAMEWORK_PATH "${GSTREAMER_FRAMEWORK_PATH}") - set(GSTREAMER_XCFRAMEWORK_LIB "${GStreamer_ROOT_DIR}/GStreamer") - set(GSTREAMER_INCLUDE_PATH "${GSTREAMER_FRAMEWORK_PATH}/Headers") - # Point synthetic lib/plugin paths at the root so downstream existence checks pass. - set(GSTREAMER_LIB_PATH "${GStreamer_ROOT_DIR}") - set(GSTREAMER_PLUGIN_PATH "${GStreamer_ROOT_DIR}") - set(GStreamer_USE_XCFRAMEWORK ON) - _gst_normalize_and_validate_root() - endif() - - if(NOT GStreamer_USE_XCFRAMEWORK) # xcframework has no .pc files; target already created - _qgc_find_apple_pkg_config(PKG_CONFIG_EXECUTABLE) - _gst_configure_pkg_config( - LIBDIR "${GSTREAMER_LIB_PATH}/pkgconfig" "${GSTREAMER_PLUGIN_PATH}/pkgconfig" - ) - endif() -endmacro() - -# Dispatch to the appropriate platform discovery macro -if(WIN32 AND NOT ANDROID) - _qgc_discover_windows_sdk() -elseif(LINUX AND NOT ANDROID) - _qgc_discover_linux_sdk() -elseif(ANDROID) - _qgc_discover_android_sdk() -elseif(MACOS AND NOT IOS) - _qgc_discover_macos_sdk() -elseif(IOS) - _qgc_discover_ios_sdk() -endif() - -# xcframework sets GSTREAMER_LIB_PATH / GSTREAMER_PLUGIN_PATH to the slice dir -# (which has no lib/ subdir), so the existence checks still pass. -if(NOT GStreamer_USE_XCFRAMEWORK) - set(_gst_required_paths - "GStreamer_ROOT_DIR=${GStreamer_ROOT_DIR}" - "GSTREAMER_LIB_PATH=${GSTREAMER_LIB_PATH}" - "GSTREAMER_PLUGIN_PATH=${GSTREAMER_PLUGIN_PATH}" - "GSTREAMER_INCLUDE_PATH=${GSTREAMER_INCLUDE_PATH}" - ) - set(_gst_missing_paths) - if(NOT EXISTS "${GStreamer_ROOT_DIR}") - list(APPEND _gst_missing_paths "GStreamer_ROOT_DIR=${GStreamer_ROOT_DIR}") - endif() - if(NOT EXISTS "${GSTREAMER_LIB_PATH}") - list(APPEND _gst_missing_paths "GSTREAMER_LIB_PATH=${GSTREAMER_LIB_PATH}") - endif() - if(NOT EXISTS "${GSTREAMER_PLUGIN_PATH}") - list(APPEND _gst_missing_paths "GSTREAMER_PLUGIN_PATH=${GSTREAMER_PLUGIN_PATH}") - endif() - if(NOT EXISTS "${GSTREAMER_INCLUDE_PATH}") - list(APPEND _gst_missing_paths "GSTREAMER_INCLUDE_PATH=${GSTREAMER_INCLUDE_PATH}") - endif() - if(_gst_missing_paths) - string(REPLACE ";" "\n " _gst_missing_str "${_gst_missing_paths}") - message(FATAL_ERROR - "GStreamer: required directories do not exist on disk:\n ${_gst_missing_str}\n" - "GSTREAMER_FRAMEWORK_PATH=${GSTREAMER_FRAMEWORK_PATH}\n" - "Check installation or set GStreamer_ROOT_DIR.") - endif() - - if(GStreamer_USE_FRAMEWORK AND NOT EXISTS "${GSTREAMER_FRAMEWORK_PATH}") - message(FATAL_ERROR "GStreamer: Could not locate framework at ${GSTREAMER_FRAMEWORK_PATH}") - endif() -else() - if(NOT EXISTS "${GSTREAMER_XCFRAMEWORK_LIB}") - message(FATAL_ERROR "GStreamer: xcframework library not found at ${GSTREAMER_XCFRAMEWORK_LIB}") - endif() -endif() - -function(_qgc_gstreamer_component_to_api_name INPUT_COMPONENT OUTPUT_VAR) - string(REGEX REPLACE "([a-z0-9])([A-Z])" "\\1_\\2" _component_snake "${INPUT_COMPONENT}") - string(TOLOWER "${_component_snake}" _component_snake) - set(${OUTPUT_VAR} "api_${_component_snake}" PARENT_SCOPE) -endfunction() - -set(GSTREAMER_APIS - api_base - api_gl - api_gl_prototypes - api_rtsp - api_video -) -# Map QGCGStreamer components to api_ names, skipping Core (already covered -# by the main gstreamer-1.0 pkg-config query — there is no gstreamer-core-1.0.pc). -foreach(_comp IN LISTS QGCGStreamer_FIND_COMPONENTS) - if(_comp STREQUAL "Core") - continue() - endif() - _qgc_gstreamer_component_to_api_name("${_comp}" _api_name) - if(NOT _api_name IN_LIST GSTREAMER_APIS) - list(APPEND GSTREAMER_APIS "${_api_name}") - endif() -endforeach() - -if(NOT DEFINED GSTREAMER_EXTRA_DEPS) - # Derive from GSTREAMER_APIS to avoid maintaining a duplicate list. - # Each api_foo entry maps to gstreamer-foo-1.0 (with underscores → hyphens). - set(GSTREAMER_EXTRA_DEPS) - foreach(_api IN LISTS GSTREAMER_APIS) - string(REGEX REPLACE "^api_(.+)" "\\1" _pc_name "${_api}") - string(REPLACE "_" "-" _pc_name "${_pc_name}") - list(APPEND GSTREAMER_EXTRA_DEPS "gstreamer-${_pc_name}-1.0") - endforeach() - if(WIN32) - list(APPEND GSTREAMER_EXTRA_DEPS graphene-1.0) - endif() - if(ANDROID OR IOS) - list(APPEND GSTREAMER_EXTRA_DEPS gio-2.0) - endif() - if(ANDROID) - list(APPEND GSTREAMER_EXTRA_DEPS gmodule-2.0 zlib) - endif() -endif() - -# Default plugin set assumes the videoconvert→appsink rendering path used everywhere. -# The base opengl / d3d11 plugins are kept because GStreamer auto-plugs gldownload / -# d3d11download when hardware decoders produce GPU memory. -if(NOT DEFINED GSTREAMER_PLUGINS) - set(GSTREAMER_PLUGINS - app - coreelements - isomp4 - libav - matroska - mpegtsdemux - opengl - openh264 - playback - rtp - rtpmanager - rtsp - sdpelem - tcp - typefindfunctions - udp - videoparsersbad - vpx - # gst >=1.22 split: only the one matching the SDK gets a target; #ifdef in GStreamer.cc picks it. - videoconvertscale - videoconvert - videoscale - ) - # Deferred for all platforms — GStreamer_VERSION is populated after find_package(GStreamer) below. - if(ANDROID) - list(APPEND GSTREAMER_PLUGINS androidmedia dav1d) - elseif(APPLE) - list(APPEND GSTREAMER_PLUGINS applemedia dav1d vulkan) - elseif(WIN32) - list(APPEND GSTREAMER_PLUGINS d3d d3d11 d3d12 dav1d dxva nvcodec) - elseif(LINUX) - list(APPEND GSTREAMER_PLUGINS dav1d nvcodec qsv va vulkan) - endif() -endif() - -if(ANDROID) - set(GStreamer_Mobile_MODULE_NAME gstreamer_android) - set(G_IO_MODULES openssl) - set(G_IO_MODULES_PATH "${GStreamer_ROOT_DIR}/lib/gio/modules") - set(GStreamer_NDK_BUILD_PATH "${GStreamer_ROOT_DIR}/share/gst-android/ndk-build") - if(QT_IS_ANDROID_MULTI_ABI_EXTERNAL_PROJECT AND DEFINED QT_INTERNAL_ANDROID_MULTI_ABI_BINARY_DIR) - set(_gst_android_build_base "${QT_INTERNAL_ANDROID_MULTI_ABI_BINARY_DIR}") - else() - set(_gst_android_build_base "${CMAKE_BINARY_DIR}") - endif() - if(QT_USE_TARGET_ANDROID_BUILD_DIR) - set(_gst_android_build_dir "${_gst_android_build_base}/android-build-${CMAKE_PROJECT_NAME}") - else() - set(_gst_android_build_dir "${_gst_android_build_base}/android-build") - endif() - set(GStreamer_JAVA_SRC_DIR "${_gst_android_build_dir}/src") - set(GStreamer_ASSETS_DIR "${_gst_android_build_dir}/assets") -elseif(IOS) - set(GStreamer_Mobile_MODULE_NAME gstreamer_mobile) - if(NOT GStreamer_USE_XCFRAMEWORK) - # xcframework bundles gio modules into libGStreamer.a; no separate module dir. - set(G_IO_MODULES openssl) - set(G_IO_MODULES_PATH "${GStreamer_ROOT_DIR}/lib/gio/modules") - endif() - set(GStreamer_ASSETS_DIR "${CMAKE_BINARY_DIR}/assets") -endif() - -set(GStreamer_ROOT_DIR "${GStreamer_ROOT_DIR}" CACHE PATH "GStreamer SDK root directory") - -if(GStreamer_USE_FRAMEWORK) - list(APPEND CMAKE_FRAMEWORK_PATH "${GSTREAMER_FRAMEWORK_PATH}") -endif() - -if(GStreamer_USE_XCFRAMEWORK) - # ── xcframework path: create IMPORTED targets directly from the fat .a ─── - # No pkg-config or .framework; all APIs and plugins live in one archive. - if(NOT TARGET GStreamer::GStreamer) - add_library(GStreamer_static STATIC IMPORTED GLOBAL) - set_target_properties(GStreamer_static PROPERTIES - IMPORTED_LOCATION "${GSTREAMER_XCFRAMEWORK_LIB}" - ) - add_library(GStreamer::GStreamer INTERFACE IMPORTED GLOBAL) - target_link_libraries(GStreamer::GStreamer INTERFACE - GStreamer_static - ) - # iOS .framework has flat Headers/; macOS-style installs have the gstreamer-1.0 / - # glib-2.0 subdirs. Add only what exists — INTERFACE_INCLUDE_DIRECTORIES is validated. - target_include_directories(GStreamer::GStreamer INTERFACE "${GSTREAMER_INCLUDE_PATH}") - foreach(_inc_sub IN ITEMS gstreamer-1.0 glib-2.0) - if(IS_DIRECTORY "${GSTREAMER_INCLUDE_PATH}/${_inc_sub}") - target_include_directories(GStreamer::GStreamer INTERFACE "${GSTREAMER_INCLUDE_PATH}/${_inc_sub}") - endif() - endforeach() - # System frameworks required by GStreamer on iOS. - # Note: gstreamer-ios 1.28+ bundles libass (CoreText), MoltenVK (Metal/IOSurface/QuartzCore), - # applemedia iosassetsrc (AssetsLibrary), and EAGL/CAMetalLayer (QuartzCore) — all of which - # demand additional system frameworks at the *consumer* link step. - find_library(_xcfw_foundation Foundation REQUIRED) - find_library(_xcfw_avfoundation AVFoundation REQUIRED) - find_library(_xcfw_audiotoolbox AudioToolbox REQUIRED) - find_library(_xcfw_videotoolbox VideoToolbox REQUIRED) - find_library(_xcfw_coremedia CoreMedia REQUIRED) - find_library(_xcfw_corevideo CoreVideo REQUIRED) - find_library(_xcfw_coreaudio CoreAudio REQUIRED) - find_library(_xcfw_coregraphics CoreGraphics REQUIRED) - find_library(_xcfw_security Security REQUIRED) - find_library(_xcfw_opengles OpenGLES REQUIRED) - find_library(_xcfw_uikit UIKit REQUIRED) - find_library(_xcfw_corefoundation CoreFoundation REQUIRED) - find_library(_xcfw_coretext CoreText REQUIRED) - find_library(_xcfw_iosurface IOSurface REQUIRED) - find_library(_xcfw_metal Metal REQUIRED) - find_library(_xcfw_quartzcore QuartzCore REQUIRED) - # AssetsLibrary is deprecated since iOS 9 but the headers/lib are still present; - # libgstapplemedia iosassetsrc references _OBJC_CLASS_$_ALAssetsLibrary. - find_library(_xcfw_assetslibrary AssetsLibrary) - target_link_libraries(GStreamer::GStreamer INTERFACE - "${_xcfw_foundation}" - "${_xcfw_avfoundation}" - "${_xcfw_audiotoolbox}" - "${_xcfw_videotoolbox}" - "${_xcfw_coremedia}" - "${_xcfw_corevideo}" - "${_xcfw_coreaudio}" - "${_xcfw_coregraphics}" - "${_xcfw_security}" - "${_xcfw_opengles}" - "${_xcfw_uikit}" - "${_xcfw_corefoundation}" - "${_xcfw_coretext}" - "${_xcfw_iosurface}" - "${_xcfw_metal}" - "${_xcfw_quartzcore}" - "-lresolv" "-liconv" "-lz" "-lbz2" - ) - if(_xcfw_assetslibrary) - target_link_libraries(GStreamer::GStreamer INTERFACE "${_xcfw_assetslibrary}") - else() - # If AssetsLibrary truly isn't present in the SDK, weak-link by name so dyld - # tolerates absence at load time (iosassetsrc is not invoked by QGC). - target_link_options(GStreamer::GStreamer INTERFACE "-Wl,-weak_framework,AssetsLibrary") - endif() - # gstreamer-ios 1.28 bundles many Rust-built static libs (gst-plugins-rs); each contributes - # a `_rust_eh_personality` reference and Mach-O compact unwind can encode only ~4 unique - # personalities. Switch to DWARF unwind tables to sidestep the limit. Slightly larger - # binary; runtime perf impact is negligible (only used during exception unwinding, and - # GStreamer doesn't throw C++ exceptions across its API surface). - target_link_options(GStreamer::GStreamer INTERFACE "-Wl,-no_compact_unwind") - target_compile_definitions(GStreamer::GStreamer INTERFACE - QGC_GST_STATIC_BUILD - ) - unset(_xcfw_foundation CACHE) - unset(_xcfw_avfoundation CACHE) - unset(_xcfw_audiotoolbox CACHE) - unset(_xcfw_videotoolbox CACHE) - unset(_xcfw_coremedia CACHE) - unset(_xcfw_corevideo CACHE) - unset(_xcfw_coreaudio CACHE) - unset(_xcfw_coregraphics CACHE) - unset(_xcfw_security CACHE) - unset(_xcfw_opengles CACHE) - unset(_xcfw_uikit CACHE) - unset(_xcfw_corefoundation CACHE) - unset(_xcfw_coretext CACHE) - unset(_xcfw_iosurface CACHE) - unset(_xcfw_metal CACHE) - unset(_xcfw_quartzcore CACHE) - unset(_xcfw_assetslibrary CACHE) - endif() - - # All API component targets alias the single mega-library. - foreach(_xcfw_comp IN ITEMS api_base api_gl api_gl_prototypes api_rtsp api_video api_app) - if(NOT TARGET GStreamer::${_xcfw_comp}) - add_library(GStreamer::${_xcfw_comp} INTERFACE IMPORTED GLOBAL) - target_link_libraries(GStreamer::${_xcfw_comp} INTERFACE GStreamer::GStreamer) - endif() - endforeach() - - # Build the xcframework mobile init shim — calls gst_init_static_plugins(). - if(NOT TARGET GStreamer::mobile) - enable_language(OBJC OBJCXX) - - # GStreamer 1.28+ ships every plugin compiled into libGStreamer.a but - # provides no auto-registration entrypoint; enumerate plugin descriptors - # from the archive and emit explicit GST_PLUGIN_STATIC_REGISTER() calls. - find_program(_xcfw_nm NAMES nm llvm-nm REQUIRED) - execute_process( - COMMAND "${_xcfw_nm}" -gjU "${GSTREAMER_XCFRAMEWORK_LIB}" - OUTPUT_VARIABLE _xcfw_nm_out - ERROR_QUIET - RESULT_VARIABLE _xcfw_nm_rc - ) - if(NOT _xcfw_nm_rc EQUAL 0) - message(FATAL_ERROR "nm failed on ${GSTREAMER_XCFRAMEWORK_LIB} (rc=${_xcfw_nm_rc})") - endif() - string(REGEX MATCHALL "_gst_plugin_[A-Za-z0-9_]+_get_desc" _xcfw_descs "${_xcfw_nm_out}") - list(REMOVE_DUPLICATES _xcfw_descs) - # Plugins whose dependent static libraries aren't bundled in the iOS xcframework. - # Auto-registering them would cause unresolved-symbol link failures on the consumer. - # x265: gstreamer-ios 1.28.1 ships libgstx265.a but not libx265.a (libx265 was likely - # filtered as a permissive-licensed CPU encoder QGC doesn't need on iOS). - set(_xcfw_skip_plugins x265) - set(_xcfw_decl "") - set(_xcfw_reg "") - set(_xcfw_used 0) - foreach(_sym IN LISTS _xcfw_descs) - string(REGEX REPLACE "^_gst_plugin_(.+)_get_desc$" "\\1" _name "${_sym}") - if(_name IN_LIST _xcfw_skip_plugins) - continue() - endif() - string(APPEND _xcfw_decl "GST_PLUGIN_STATIC_DECLARE(${_name});\n") - string(APPEND _xcfw_reg " GST_PLUGIN_STATIC_REGISTER(${_name});\n") - math(EXPR _xcfw_used "${_xcfw_used} + 1") - endforeach() - list(LENGTH _xcfw_descs _xcfw_n) - message(STATUS "GStreamer xcframework: registering ${_xcfw_used}/${_xcfw_n} static plugins (skipped: ${_xcfw_skip_plugins})") - set(GST_STATIC_PLUGIN_DECLARES "${_xcfw_decl}") - set(GST_STATIC_PLUGIN_REGISTERS "${_xcfw_reg}") - - set(_xcfw_shim "${CMAKE_BINARY_DIR}/${GStreamer_Mobile_MODULE_NAME}.m") - configure_file( - "${CMAKE_CURRENT_LIST_DIR}/GStreamer/gst_ios_xcframework_init.m.in" - "${_xcfw_shim}" - @ONLY - ) - add_library(GStreamerMobileXcfw SHARED) - target_sources(GStreamerMobileXcfw PRIVATE "${_xcfw_shim}") - set_source_files_properties("${_xcfw_shim}" PROPERTIES LANGUAGE OBJC GENERATED TRUE) - target_link_libraries(GStreamerMobileXcfw PRIVATE GStreamer::GStreamer) - set_target_properties(GStreamerMobileXcfw PROPERTIES - LIBRARY_OUTPUT_NAME ${GStreamer_Mobile_MODULE_NAME} - LINKER_LANGUAGE OBJCXX - FRAMEWORK TRUE - FRAMEWORK_VERSION A - MACOSX_FRAMEWORK_IDENTIFIER org.gstreamer.GStreamerMobile - ) - add_library(GStreamer::mobile ALIAS GStreamerMobileXcfw) - add_library(GStreamerMobile ALIAS GStreamerMobileXcfw) - set(GStreamerMobile_FOUND TRUE) - set(GStreamerMobile_mobile_FOUND TRUE) - endif() - - set(GStreamer_FOUND TRUE) - set(GStreamer_VERSION "${GStreamer_FIND_VERSION}") -else() - -if(GStreamer_USE_STATIC_LIBS) - list(APPEND PKG_CONFIG_ARGN "--static") -endif() - -find_package(PkgConfig REQUIRED QUIET) - -list(PREPEND CMAKE_PREFIX_PATH ${GStreamer_ROOT_DIR}) - -# CPM creates a stub gstreamer-config.cmake that shadows our vendored module -if(DEFINED CMAKE_FIND_PACKAGE_REDIRECTS_DIR) - file(REMOVE - "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/gstreamer-config.cmake" - "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/gstreamer-config-version.cmake" - "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/GStreamerConfig.cmake" - "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/GStreamerConfigVersion.cmake" - ) -endif() -unset(GStreamer_FOUND CACHE) -unset(GStreamer_FOUND) -find_package(GStreamer ${QGC_CONFIG_GSTREAMER_MIN_VERSION} REQUIRED MODULE) - -# Apple framework overlay: when using framework layout, link the framework -# directly and add its Headers to the include path. -if(APPLE AND GStreamer_USE_FRAMEWORK AND TARGET GStreamer::GStreamer) - set(_saved_find_framework "${CMAKE_FIND_FRAMEWORK}") - set(CMAKE_FIND_FRAMEWORK ONLY) - cmake_path(GET GSTREAMER_FRAMEWORK_PATH PARENT_PATH _gst_framework_parent) - find_library(GSTREAMER_FRAMEWORK GStreamer - PATHS - "${_gst_framework_parent}" - "${GSTREAMER_FRAMEWORK_PATH}" - "/Library/Frameworks" - "/usr/local/opt/gstreamer" - "/opt/homebrew/opt/gstreamer" - ) - set(CMAKE_FIND_FRAMEWORK "${_saved_find_framework}") - if(GSTREAMER_FRAMEWORK) - target_link_libraries(GStreamer::GStreamer INTERFACE ${GSTREAMER_FRAMEWORK}) - target_include_directories(GStreamer::GStreamer INTERFACE "${GSTREAMER_FRAMEWORK}/Headers") - if(MACOS) - target_compile_definitions(GStreamer::GStreamer INTERFACE QGC_GST_MACOS_FRAMEWORK) - endif() - else() - message(FATAL_ERROR "GStreamer: Could not locate GStreamer.framework") - endif() -endif() - -if(ANDROID OR IOS) - set(_mobile_components ${GSTREAMER_PLUGINS} mobile) - if(ANDROID AND EXISTS "${GStreamer_ROOT_DIR}/share/gst-android/ndk-build/fontconfig") - list(APPEND _mobile_components fonts) - elseif(IOS - AND EXISTS "${GStreamer_ROOT_DIR}/share/fontconfig/fonts/Ubuntu-R.ttf" - AND EXISTS "${GStreamer_ROOT_DIR}/etc/fonts/fonts.conf") - list(APPEND _mobile_components fonts) - endif() - if(EXISTS "${GStreamer_ROOT_DIR}/etc/ssl/certs/ca-certificates.crt") - list(APPEND _mobile_components ca_certificates) - endif() - find_package(GStreamerMobile REQUIRED COMPONENTS ${_mobile_components}) -endif() - -endif() # GStreamer_USE_XCFRAMEWORK - -foreach(_comp IN ITEMS Core Base Video Gl GlPrototypes Rtsp) - set(QGCGStreamer_${_comp}_FOUND TRUE) - set(GStreamer_${_comp}_FOUND TRUE) -endforeach() -foreach(_comp IN LISTS QGCGStreamer_FIND_COMPONENTS) - _qgc_gstreamer_component_to_api_name("${_comp}" _api_name) - if(TARGET GStreamer::${_api_name}) - set(QGCGStreamer_${_comp}_FOUND TRUE) - set(GStreamer_${_comp}_FOUND TRUE) - endif() -endforeach() - -if(GStreamer_USE_STATIC_LIBS) - target_compile_definitions(GStreamer::GStreamer INTERFACE QGC_GST_STATIC_BUILD) - if(ANDROID) - # FFmpeg static libs have arch-specific assembly using page-relative - # relocations against global symbols. -Bsymbolic guarantees no symbol - # interposition, making those relocations valid in the final .so. - target_link_options(GStreamer::GStreamer INTERFACE "-Wl,-Bsymbolic") - endif() -endif() - -foreach(plugin IN LISTS GSTREAMER_PLUGINS) - if(TARGET GStreamer::${plugin}) - set(GST_PLUGIN_${plugin}_FOUND TRUE) - else() - set(GST_PLUGIN_${plugin}_FOUND FALSE) - endif() -endforeach() - -if(NOT GStreamer_USE_STATIC_LIBS AND NOT GStreamer_USE_XCFRAMEWORK AND EXISTS "${GSTREAMER_PLUGIN_PATH}") - set(_gst_missing_plugins) - if(WIN32) - set(_gst_plugin_glob "gst*.dll") - elseif(APPLE) - set(_gst_plugin_glob "libgst*.dylib") - else() - set(_gst_plugin_glob "libgst*.so") - endif() - file(GLOB _gst_available_plugins "${GSTREAMER_PLUGIN_PATH}/${_gst_plugin_glob}") - set(_gst_available_basenames) - foreach(_path IN LISTS _gst_available_plugins) - get_filename_component(_fname "${_path}" NAME) - if(WIN32) - string(REGEX MATCH "^gst([a-zA-Z0-9]+)" _m "${_fname}") - else() - string(REGEX MATCH "^libgst([a-zA-Z0-9]+)" _m "${_fname}") - endif() - if(_m) - list(APPEND _gst_available_basenames "${CMAKE_MATCH_1}") - endif() - endforeach() - list(REMOVE_DUPLICATES _gst_available_basenames) - # videoconvert(scale)/videoscale alternate — only one ships per gst version; don't warn on the absent ones. - set(_gst_alternate_satisfied FALSE) - if("videoconvertscale" IN_LIST _gst_available_basenames - OR ("videoconvert" IN_LIST _gst_available_basenames - AND "videoscale" IN_LIST _gst_available_basenames)) - set(_gst_alternate_satisfied TRUE) - endif() - foreach(_plugin IN LISTS GSTREAMER_PLUGINS) - if(_gst_alternate_satisfied - AND _plugin MATCHES "^(videoconvertscale|videoconvert|videoscale)$") - continue() - endif() - if(NOT _plugin IN_LIST _gst_available_basenames) - list(APPEND _gst_missing_plugins "${_plugin}") - endif() - endforeach() - if(_gst_missing_plugins) - message(WARNING "GStreamer: The following plugins are listed in GSTREAMER_PLUGINS " - "but not found in ${GSTREAMER_PLUGIN_PATH}: ${_gst_missing_plugins}\n" - "Video features depending on these plugins will not work at runtime.") - endif() -endif() - -if(GStreamer_VERSION) - string(REGEX MATCH "^([0-9]+)\\.([0-9]+)" _gst_ver_match "${GStreamer_VERSION}") - if(_gst_ver_match) - # Mobile (iOS/Android) builds consume GStreamerMobile (alias GStreamer::mobile) instead - # of GStreamer::GStreamer, so propagate the version defines to whichever exists. - foreach(_gst_target IN ITEMS GStreamer::GStreamer GStreamerMobile) - if(TARGET ${_gst_target}) - # target_compile_definitions can't be called on ALIAS targets — resolve first. - get_target_property(_gst_aliased ${_gst_target} ALIASED_TARGET) - if(_gst_aliased) - set(_gst_real ${_gst_aliased}) - else() - set(_gst_real ${_gst_target}) - endif() - target_compile_definitions(${_gst_real} INTERFACE - QGC_GST_BUILD_VERSION_MAJOR=${CMAKE_MATCH_1} - QGC_GST_BUILD_VERSION_MINOR=${CMAKE_MATCH_2} - ) - endif() - endforeach() - endif() -endif() - -# GstVideoOrientationMeta is officially in 1.26+, but bundled iOS/Android SDKs sometimes -# strip it. Feature-test the actual header instead of trusting the version number. -include(CheckCXXSourceCompiles) -foreach(_gst_target IN ITEMS GStreamer::GStreamer GStreamerMobile) - if(TARGET ${_gst_target}) - set(CMAKE_REQUIRED_LIBRARIES_BACKUP "${CMAKE_REQUIRED_LIBRARIES}") - set(CMAKE_REQUIRED_LIBRARIES ${_gst_target}) - check_cxx_source_compiles(" - #include - int main() { GstVideoOrientationMeta *m = nullptr; (void)m; return 0; } - " QGC_GST_HAS_VIDEO_ORIENTATION_META_${_gst_target}) - set(CMAKE_REQUIRED_LIBRARIES "${CMAKE_REQUIRED_LIBRARIES_BACKUP}") - if(QGC_GST_HAS_VIDEO_ORIENTATION_META_${_gst_target}) - get_target_property(_gst_aliased ${_gst_target} ALIASED_TARGET) - if(_gst_aliased) - set(_gst_real ${_gst_aliased}) - else() - set(_gst_real ${_gst_target}) - endif() - target_compile_definitions(${_gst_real} INTERFACE - QGC_HAS_GST_VIDEO_ORIENTATION_META=1) - endif() - endif() -endforeach() - -# Zero-copy DMABuf GPU path (Linux only). Requires gst-allocators and Qt's -# private QHwVideoBuffer header. Both are detected here and the combined -# QGC_HAS_GST_DMABUF_GPU_PATH define is exposed to consumers via -# target_compile_definitions on the QGCGStreamerDmaBufFeature interface target -# below — the feature consumer picks it up by linking that target. -if(LINUX) - foreach(_gst_target IN ITEMS GStreamer::GStreamer GStreamerMobile) - if(TARGET ${_gst_target}) - set(CMAKE_REQUIRED_LIBRARIES_BACKUP "${CMAKE_REQUIRED_LIBRARIES}") - set(CMAKE_REQUIRED_LIBRARIES ${_gst_target}) - check_cxx_source_compiles(" - #include - int main() { (void)gst_is_dmabuf_memory; return 0; } - " QGC_GST_HAS_DMABUF_${_gst_target}) - set(CMAKE_REQUIRED_LIBRARIES "${CMAKE_REQUIRED_LIBRARIES_BACKUP}") - if(QGC_GST_HAS_DMABUF_${_gst_target}) - set(QGC_GST_HAS_DMABUF TRUE) - endif() - endif() - endforeach() -endif() - -include(FindPackageHandleStandardArgs) -find_package_handle_standard_args(QGCGStreamer - REQUIRED_VARS GStreamer_ROOT_DIR GSTREAMER_LIB_PATH GSTREAMER_PLUGIN_PATH - VERSION_VAR GStreamer_VERSION - HANDLE_COMPONENTS -) diff --git a/cmake/find-modules/GStreamer/gst_ios_init.m.in b/cmake/find-modules/GStreamer/gst_ios_init.m.in deleted file mode 100644 index bb01e083d537..000000000000 --- a/cmake/find-modules/GStreamer/gst_ios_init.m.in +++ /dev/null @@ -1,101 +0,0 @@ -/* - * GStreamer iOS Initialization - * Auto-generated from template by CMake configure_file() - * - * Based on GStreamer cerbero: data/xcode/templates/ios/GStreamer Base.xctemplate/gst_ios_init.m - * SPDX-License-Identifier: LGPL-2.1-or-later - */ - -#import -#include -#include - -#define GST_G_IO_MODULE_DECLARE(name) \ -extern void G_PASTE(g_io_, G_PASTE(name, _load)) (gpointer module) - -#define GST_G_IO_MODULE_LOAD(name) \ -G_PASTE(g_io_, G_PASTE(name, _load)) (NULL) - -/* Declaration of static plugins */ -@PLUGINS_DECLARATION@ - -/* Declaration of static gio modules */ -@G_IO_MODULES_DECLARE@ - -/* This is called by gst_init() to register static plugins. */ -void -gst_init_static_plugins (void) -{ -@PLUGINS_REGISTRATION@ -} - -/* Call before gst_init() to set up environment, GIO modules, and TLS. */ -void -gst_ios_pre_init (void) -{ - NSString *resources = [[NSBundle mainBundle] resourcePath]; - NSString *tmp = NSTemporaryDirectory(); - NSString *cache = [NSHomeDirectory() stringByAppendingPathComponent:@"Library/Caches"]; - NSString *docs = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"]; - - const gchar *resources_dir = [resources UTF8String]; - const gchar *tmp_dir = [tmp UTF8String]; - const gchar *cache_dir = [cache UTF8String]; - const gchar *docs_dir = [docs UTF8String]; - - g_setenv ("TMP", tmp_dir, TRUE); - g_setenv ("TEMP", tmp_dir, TRUE); - g_setenv ("TMPDIR", tmp_dir, TRUE); - g_setenv ("XDG_RUNTIME_DIR", resources_dir, TRUE); - g_setenv ("XDG_CACHE_HOME", cache_dir, TRUE); - g_setenv ("HOME", docs_dir, TRUE); - g_setenv ("XDG_DATA_DIRS", resources_dir, TRUE); - g_setenv ("XDG_CONFIG_DIRS", resources_dir, TRUE); - g_setenv ("XDG_CONFIG_HOME", cache_dir, TRUE); - g_setenv ("XDG_DATA_HOME", resources_dir, TRUE); - g_setenv ("FONTCONFIG_PATH", resources_dir, TRUE); - - /* Load GIO modules */ - @G_IO_MODULES_LOAD@ - - /* Set up TLS certificates if the bundle exists */ - gchar *ca_certificates = g_build_filename (resources_dir, "ssl", "certs", - "ca-certificates.crt", NULL); - if (ca_certificates && g_file_test (ca_certificates, G_FILE_TEST_EXISTS)) { - g_setenv ("CA_CERTIFICATES", ca_certificates, TRUE); - GTlsBackend *backend = g_tls_backend_get_default (); - if (backend) { - GError *error = NULL; - GTlsDatabase *db = g_tls_file_database_new (ca_certificates, &error); - if (db) { - g_tls_backend_set_default_database (backend, db); - g_object_unref (db); - } else { - g_warning ("Failed to create TLS database from %s: %s", - ca_certificates, error ? error->message : "unknown error"); - g_clear_error (&error); - } - } - } - g_free (ca_certificates); -} - -/* Call after gst_init() to adjust plugin priorities for iOS. */ -void -gst_ios_post_init (void) -{ - GstRegistry *reg = gst_registry_get (); - - /* Lower the ranks of filesrc and giosrc so iosavassetsrc is - * tried first in gst_element_make_from_uri() for file:// */ - GstPluginFeature *plugin = gst_registry_lookup_feature (reg, "filesrc"); - if (plugin) { - gst_plugin_feature_set_rank (plugin, GST_RANK_SECONDARY); - gst_object_unref (plugin); - } - plugin = gst_registry_lookup_feature (reg, "giosrc"); - if (plugin) { - gst_plugin_feature_set_rank (plugin, (GST_RANK_SECONDARY - 1)); - gst_object_unref (plugin); - } -} diff --git a/cmake/find-modules/GStreamer/gst_ios_xcframework_init.m.in b/cmake/find-modules/GStreamer/gst_ios_xcframework_init.m.in deleted file mode 100644 index 27cba114dda2..000000000000 --- a/cmake/find-modules/GStreamer/gst_ios_xcframework_init.m.in +++ /dev/null @@ -1,65 +0,0 @@ -/* - * GStreamer iOS xcframework initialization shim. - * Auto-generated from template by CMake configure_file(). - * - * GStreamer 1.28+ xcframework bundles every plugin into libGStreamer.a but - * exposes no auto-registration entrypoint; the CMake find module enumerates - * plugin descriptors via nm and substitutes the GST_PLUGIN_STATIC_*() calls - * below. - * - * SPDX-License-Identifier: LGPL-2.1-or-later - */ - -#import -#include - -@GST_STATIC_PLUGIN_DECLARES@ - -void -gst_init_static_plugins (void) -{ -@GST_STATIC_PLUGIN_REGISTERS@ -} - -void -gst_ios_pre_init (void) -{ - NSString *resources = [[NSBundle mainBundle] resourcePath]; - NSString *tmp = NSTemporaryDirectory(); - NSString *cache = [NSHomeDirectory() stringByAppendingPathComponent:@"Library/Caches"]; - NSString *docs = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"]; - - const gchar *resources_dir = [resources UTF8String]; - const gchar *tmp_dir = [tmp UTF8String]; - const gchar *cache_dir = [cache UTF8String]; - const gchar *docs_dir = [docs UTF8String]; - - g_setenv("TMP", tmp_dir, TRUE); - g_setenv("TEMP", tmp_dir, TRUE); - g_setenv("TMPDIR", tmp_dir, TRUE); - g_setenv("XDG_RUNTIME_DIR", resources_dir, TRUE); - g_setenv("XDG_CACHE_HOME", cache_dir, TRUE); - g_setenv("HOME", docs_dir, TRUE); - g_setenv("XDG_DATA_DIRS", resources_dir, TRUE); - g_setenv("XDG_CONFIG_DIRS", resources_dir, TRUE); - g_setenv("XDG_CONFIG_HOME", cache_dir, TRUE); - g_setenv("XDG_DATA_HOME", resources_dir, TRUE); -} - -void -gst_ios_post_init (void) -{ - GstRegistry *reg = gst_registry_get(); - - /* Prefer native iOS AVFoundation source over generic filesrc/giosrc. */ - GstPluginFeature *plugin = gst_registry_lookup_feature(reg, "filesrc"); - if (plugin) { - gst_plugin_feature_set_rank(plugin, GST_RANK_SECONDARY); - gst_object_unref(plugin); - } - plugin = gst_registry_lookup_feature(reg, "giosrc"); - if (plugin) { - gst_plugin_feature_set_rank(plugin, (GST_RANK_SECONDARY - 1)); - gst_object_unref(plugin); - } -} diff --git a/cmake/find-modules/GStreamer/gstreamer_android-1.0.c.in b/cmake/find-modules/GStreamer/gstreamer_android-1.0.c.in deleted file mode 100644 index df1b632ca7c5..000000000000 --- a/cmake/find-modules/GStreamer/gstreamer_android-1.0.c.in +++ /dev/null @@ -1,123 +0,0 @@ -#include -#include -#include - -#define GST_G_IO_MODULE_DECLARE(name) \ -extern void G_PASTE(g_io_, G_PASTE(name, _load)) (gpointer module) - -#define GST_G_IO_MODULE_LOAD(name) \ -G_PASTE(g_io_, G_PASTE(name, _load)) (NULL) - -/* Declaration of static plugins */ -@PLUGINS_DECLARATION@ - -/* Declaration of static gio modules */ -@G_IO_MODULES_DECLARE@ - -static void -gst_android_load_gio_modules (void) -{ - @G_IO_MODULES_LOAD@ -} - -static void -gst_android_load_certificates (void) -{ - const gchar *ca_certs = g_getenv ("CA_CERTIFICATES"); - if (!ca_certs) - return; - - GTlsBackend *backend = g_tls_backend_get_default (); - if (!backend) - return; - - GError *error = NULL; - GTlsDatabase *db = g_tls_file_database_new (ca_certs, &error); - if (db) { - g_tls_backend_set_default_database (backend, db); - g_object_unref (db); - } else { - g_warning ("Failed to create a database from file: %s", - error ? error->message : "Unknown"); - g_clear_error (&error); - } -} - -/* This is called by gst_init() */ -void -gst_init_static_plugins (void) -{ - @PLUGINS_REGISTRATION@ -} - -static gchar * -gst_android_get_dir (JNIEnv * env, jobject context, const char * method_name) -{ - jclass context_cls = (*env)->GetObjectClass (env, context); - jmethodID get_dir = - (*env)->GetMethodID (env, context_cls, method_name, "()Ljava/io/File;"); - (*env)->DeleteLocalRef (env, context_cls); - if (!get_dir) - return NULL; - - jobject dir = (*env)->CallObjectMethod (env, context, get_dir); - if (!dir) - return NULL; - - jclass file_cls = (*env)->GetObjectClass (env, dir); - jmethodID get_abs_path = - (*env)->GetMethodID (env, file_cls, "getAbsolutePath", - "()Ljava/lang/String;"); - (*env)->DeleteLocalRef (env, file_cls); - if (!get_abs_path) { - (*env)->DeleteLocalRef (env, dir); - return NULL; - } - - jstring path_jstr = - (jstring) (*env)->CallObjectMethod (env, dir, get_abs_path); - (*env)->DeleteLocalRef (env, dir); - if (!path_jstr) - return NULL; - - const char *utf = (*env)->GetStringUTFChars (env, path_jstr, NULL); - gchar *result = utf ? g_strdup (utf) : NULL; - if (utf) - (*env)->ReleaseStringUTFChars (env, path_jstr, utf); - (*env)->DeleteLocalRef (env, path_jstr); - return result; -} - -void -Java_org_freedesktop_gstreamer_GStreamer_nativeInit (JNIEnv * env, - jobject thiz, jobject context) -{ - (void) thiz; - - gchar *files_dir = gst_android_get_dir (env, context, "getFilesDir"); - gchar *cache_dir = gst_android_get_dir (env, context, "getCacheDir"); - - if (files_dir) { - gchar *fontconfig = g_build_filename (files_dir, "fontconfig", NULL); - gchar *certs = g_build_filename (files_dir, "ssl", "certs", - "ca-certificates.crt", NULL); - - g_setenv ("HOME", files_dir, TRUE); - g_setenv ("FONTCONFIG_PATH", fontconfig, TRUE); - g_setenv ("CA_CERTIFICATES", certs, TRUE); - - g_free (fontconfig); - g_free (certs); - } - - if (cache_dir) { - g_setenv ("XDG_CACHE_HOME", cache_dir, TRUE); - } - - g_free (files_dir); - g_free (cache_dir); - - gst_android_load_gio_modules (); - gst_android_load_certificates (); - gst_init (NULL, NULL); -} diff --git a/cmake/find-modules/GStreamerHelpers.cmake b/cmake/find-modules/GStreamerHelpers.cmake deleted file mode 100644 index 844aa97a20bf..000000000000 --- a/cmake/find-modules/GStreamerHelpers.cmake +++ /dev/null @@ -1,462 +0,0 @@ -include(Download) - -option(GStreamer_REQUIRE_CHECKSUM "Fail if SDK download checksum cannot be verified" OFF) -option(GStreamer_DEBUG "Print GStreamer CMake debug messages" OFF) - -function(gstreamer_get_package_url PLATFORM VERSION OUTPUT_VAR) - set(_base "https://gstreamer.freedesktop.org") - set(_gl_base "https://gitlab.freedesktop.org/gstreamer/gstreamer/-/archive/${VERSION}/gstreamer-${VERSION}.tar.gz") - - if(PLATFORM STREQUAL "android") - set(_url "${_base}/data/pkg/android/${VERSION}/gstreamer-1.0-android-universal-${VERSION}.tar.xz") - elseif(PLATFORM STREQUAL "ios") - set(_url "${_base}/data/pkg/ios/${VERSION}/gstreamer-1.0-devel-${VERSION}-ios-universal.pkg") - elseif(PLATFORM STREQUAL "macos") - set(_url "${_base}/data/pkg/macos/${VERSION}/gstreamer-1.0-${VERSION}-universal.pkg") - elseif(PLATFORM STREQUAL "macos_devel") - set(_url "${_base}/data/pkg/macos/${VERSION}/gstreamer-1.0-devel-${VERSION}-universal.pkg") - elseif(PLATFORM MATCHES "^windows_msvc_(x64|arm64)$") - if(VERSION VERSION_GREATER_EQUAL "1.28.0") - set(_ext "exe") - else() - set(_ext "msi") - endif() - if(CMAKE_MATCH_1 STREQUAL "x64") - set(_arch "x86_64") - else() - set(_arch "arm64") - endif() - set(_url "${_base}/data/pkg/windows/${VERSION}/msvc/gstreamer-1.0-msvc-${_arch}-${VERSION}.${_ext}") - elseif(PLATFORM STREQUAL "good_plugins") - set(_url "${_base}/src/gst-plugins-good/gst-plugins-good-${VERSION}.tar.xz") - elseif(PLATFORM STREQUAL "good_plugins_qt6") - set(_url "${_gl_base}?path=subprojects/gst-plugins-good/ext/qt6") - elseif(PLATFORM STREQUAL "bad_plugins_qt6d3d11") - set(_url "${_gl_base}?path=subprojects/gst-plugins-bad/ext/qt6d3d11") - elseif(PLATFORM STREQUAL "monorepo") - set(_url "${_gl_base}") - else() - message(FATAL_ERROR "gstreamer_get_package_url: Unknown platform '${PLATFORM}'") - endif() - - set(${OUTPUT_VAR} "${_url}" PARENT_SCOPE) -endfunction() - -function(gstreamer_get_s3_mirror_url PLATFORM VERSION OUTPUT_VAR) - set(_s3_base "https://qgroundcontrol.s3.us-west-2.amazonaws.com/dependencies/gstreamer") - - if(PLATFORM STREQUAL "android") - set(_dir "android") - elseif(PLATFORM STREQUAL "ios") - set(_dir "ios") - elseif(PLATFORM STREQUAL "macos" OR PLATFORM STREQUAL "macos_devel") - set(_dir "macos") - elseif(PLATFORM STREQUAL "windows_msvc_x64" OR PLATFORM STREQUAL "windows_msvc_arm64") - set(_dir "windows") - else() - set(${OUTPUT_VAR} "" PARENT_SCOPE) - return() - endif() - - gstreamer_get_package_url(${PLATFORM} ${VERSION} _primary_url) - cmake_path(GET _primary_url FILENAME _filename) - - set(${OUTPUT_VAR} "${_s3_base}/${_dir}/${_filename}" PARENT_SCOPE) -endfunction() - -function(gstreamer_get_fallback_checksum PLATFORM VERSION OUTPUT_VAR) - set(_checksum "") - - if(DEFINED QGC_BUILD_CONFIG_CONTENT) - string(JSON _hash ERROR_VARIABLE _err - GET "${QGC_BUILD_CONFIG_CONTENT}" "gstreamer_checksums" "${VERSION}" "${PLATFORM}") - if(NOT _err AND _hash) - set(_checksum "SHA256=${_hash}") - endif() - endif() - - set(${OUTPUT_VAR} "${_checksum}" PARENT_SCOPE) -endfunction() - -function(_gstreamer_fallback_or_warn PLATFORM VERSION OUTPUT_VAR MESSAGE) - gstreamer_get_fallback_checksum(${PLATFORM} ${VERSION} _fallback_hash) - if(_fallback_hash) - message(WARNING "GStreamer: ${MESSAGE} for ${PLATFORM} ${VERSION}; using pinned fallback checksum.") - set(${OUTPUT_VAR} "${_fallback_hash}" PARENT_SCOPE) - return() - endif() - if(GStreamer_REQUIRE_CHECKSUM) - message(FATAL_ERROR - "GStreamer: ${MESSAGE} for ${PLATFORM} ${VERSION} " - "and no pinned fallback exists. Set GStreamer_REQUIRE_CHECKSUM=OFF to bypass.") - endif() - message(WARNING "GStreamer: ${MESSAGE} for ${PLATFORM} ${VERSION}; continuing without verification.") - set(${OUTPUT_VAR} "" PARENT_SCOPE) -endfunction() - -function(_gstreamer_download_sidecar URL DEST_FILE) - file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/_deps/checksums") - set(_tmp "${DEST_FILE}.tmp") - foreach(_attempt RANGE 1 3) - file(DOWNLOAD "${URL}" "${_tmp}" STATUS _status TIMEOUT 30 TLS_VERIFY ON) - list(GET _status 0 _code) - if(_code EQUAL 0) - file(RENAME "${_tmp}" "${DEST_FILE}") - set(_dl_ok TRUE PARENT_SCOPE) - return() - endif() - list(GET _status 1 _msg) - message(STATUS "GStreamer: Checksum download attempt ${_attempt}/3 failed: ${_msg}") - file(REMOVE "${_tmp}") - endforeach() - set(_dl_ok FALSE PARENT_SCOPE) -endfunction() - -function(gstreamer_fetch_checksum PLATFORM VERSION OUTPUT_VAR) - gstreamer_get_package_url(${PLATFORM} ${VERSION} _pkg_url) - - string(FIND "${_pkg_url}" "?" _qs_pos) - if(NOT _qs_pos EQUAL -1) - message(STATUS "GStreamer: Skipping checksum for ${PLATFORM} ${VERSION} (URL contains query string)") - set(${OUTPUT_VAR} "" PARENT_SCOPE) - return() - endif() - - set(_checksum_url "${_pkg_url}.sha256sum") - string(MD5 _url_hash "${_checksum_url}") - set(_checksum_file "${CMAKE_BINARY_DIR}/_deps/checksums/${_url_hash}.sha256sum") - - foreach(_round RANGE 1 2) - if(NOT EXISTS "${_checksum_file}") - _gstreamer_download_sidecar("${_checksum_url}" "${_checksum_file}") - if(NOT _dl_ok) - _gstreamer_fallback_or_warn(${PLATFORM} ${VERSION} _result "Checksum sidecar unavailable") - set(${OUTPUT_VAR} "${_result}" PARENT_SCOPE) - return() - endif() - endif() - - file(READ "${_checksum_file}" _content) - string(STRIP "${_content}" _content) - string(REGEX MATCH "([0-9a-fA-F]{64})" _match "${_content}") - if(_match) - set(${OUTPUT_VAR} "SHA256=${CMAKE_MATCH_1}" PARENT_SCOPE) - return() - endif() - - message(STATUS "GStreamer: Could not parse checksum for ${PLATFORM} ${VERSION}") - file(REMOVE "${_checksum_file}") - endforeach() - - _gstreamer_fallback_or_warn(${PLATFORM} ${VERSION} _result "Checksum content invalid/unparseable") - set(${OUTPUT_VAR} "${_result}" PARENT_SCOPE) -endfunction() - -function(gstreamer_resilient_download) - cmake_parse_arguments(ARG "ALLOW_FAILURE" "FILENAME;DESTINATION_DIR;RESULT_VAR;TIMEOUT;INACTIVITY_TIMEOUT;EXPECTED_HASH" "URLS" ${ARGN}) - - set(_args - FILENAME "${ARG_FILENAME}" - DESTINATION_DIR "${ARG_DESTINATION_DIR}" - RESULT_VAR _gst_download_result - URLS ${ARG_URLS} - LOG_TAG "GStreamer" - FAILURE_HINT "Install manually from https://gstreamer.freedesktop.org/download/ or set GStreamer_ROOT_DIR." - ) - if(ARG_TIMEOUT) - list(APPEND _args TIMEOUT "${ARG_TIMEOUT}") - endif() - if(ARG_INACTIVITY_TIMEOUT) - list(APPEND _args INACTIVITY_TIMEOUT "${ARG_INACTIVITY_TIMEOUT}") - endif() - if(ARG_EXPECTED_HASH) - list(APPEND _args EXPECTED_HASH "${ARG_EXPECTED_HASH}") - endif() - if(ARG_ALLOW_FAILURE) - list(APPEND _args ALLOW_FAILURE) - endif() - - qgc_resilient_download(${_args}) - set(${ARG_RESULT_VAR} "${_gst_download_result}" PARENT_SCOPE) -endfunction() - -function(gstreamer_download_sdk PLATFORM VERSION FILENAME DESTINATION_DIR RESULT_VAR) - cmake_parse_arguments(_DL "ALLOW_FAILURE" "" "" ${ARGN}) - - gstreamer_get_package_url(${PLATFORM} ${VERSION} _url) - gstreamer_get_s3_mirror_url(${PLATFORM} ${VERSION} _s3_url) - gstreamer_fetch_checksum(${PLATFORM} ${VERSION} _hash) - - set(_urls "${_url}") - if(_s3_url) - list(APPEND _urls "${_s3_url}") - endif() - - set(_args - URLS ${_urls} - FILENAME "${FILENAME}" - DESTINATION_DIR "${DESTINATION_DIR}" - RESULT_VAR _result - ) - if(_hash) - list(APPEND _args EXPECTED_HASH "${_hash}") - endif() - if(_DL_ALLOW_FAILURE) - list(APPEND _args ALLOW_FAILURE) - endif() - - gstreamer_resilient_download(${_args}) - set(${RESULT_VAR} "${_result}" PARENT_SCOPE) -endfunction() - -# gstreamer_get_recommended_version( ) -# Parses major.minor from VERSION_STRING, looks up the patch from -# build-config.json (QGC_GSTREAMER_PATCH__), and returns M.N.P. -function(gstreamer_get_recommended_version VERSION_STRING OUTPUT_VAR) - string(REPLACE "." ";" _ver_list "${VERSION_STRING}") - list(GET _ver_list 0 _major) - list(GET _ver_list 1 _minor) - set(_detected_patch "") - if("${VERSION_STRING}" MATCHES "^${_major}\\.${_minor}\\.([0-9]+)") - set(_detected_patch "${CMAKE_MATCH_1}") - endif() - - set(_var_name "QGC_GSTREAMER_PATCH_${_major}_${_minor}") - if(DEFINED ${_var_name}) - set(_patch "${${_var_name}}") - elseif(_detected_patch) - set(_patch "${_detected_patch}") - message(STATUS - "No patch mapping for GStreamer ${_major}.${_minor}; using detected patch ${_patch}.") - else() - set(_patch 0) - message(WARNING "No patch mapping for GStreamer ${_major}.${_minor} — defaulting to .0. " - "This version is not used by any platform target in .github/build-config.json.") - endif() - - set(${OUTPUT_VAR} "${_major}.${_minor}.${_patch}" PARENT_SCOPE) -endfunction() - -function(gstreamer_install_gio_modules) - cmake_parse_arguments(ARG "" "SOURCE_DIR;DEST_DIR;EXTENSION" "" ${ARGN}) - - if(NOT EXISTS "${ARG_SOURCE_DIR}") - message(WARNING "gstreamer_install_gio_modules: SOURCE_DIR does not exist: ${ARG_SOURCE_DIR}") - return() - endif() - - file(GLOB _modules "${ARG_SOURCE_DIR}/*.${ARG_EXTENSION}") - - if(_modules) - install(FILES ${_modules} DESTINATION "${ARG_DEST_DIR}") - endif() -endfunction() - -function(gstreamer_install_plugins) - cmake_parse_arguments(ARG "" "SOURCE_DIR;DEST_DIR;EXTENSION;PREFIX" "" ${ARGN}) - - if(NOT EXISTS "${ARG_SOURCE_DIR}") - message(WARNING "gstreamer_install_plugins: SOURCE_DIR does not exist: ${ARG_SOURCE_DIR}") - return() - endif() - - file(GLOB _all_plugins "${ARG_SOURCE_DIR}/${ARG_PREFIX}*.${ARG_EXTENSION}") - - set(_plugins_to_install "") - foreach(_plugin_path IN LISTS _all_plugins) - get_filename_component(_plugin_name "${_plugin_path}" NAME) - foreach(_allowed IN LISTS GSTREAMER_PLUGINS) - if(_plugin_name MATCHES "^${ARG_PREFIX}${_allowed}([^a-zA-Z0-9]|$)") - list(APPEND _plugins_to_install "${_plugin_path}") - break() - endif() - endforeach() - endforeach() - - if(_plugins_to_install) - install(FILES ${_plugins_to_install} DESTINATION "${ARG_DEST_DIR}") - endif() -endfunction() - -macro(_gst_configure_pkg_config) - cmake_parse_arguments(_GPC "DONT_DEFINE_PREFIX" "PKG_CONFIG_EXE" "LIBDIR" ${ARGN}) - if(_GPC_PKG_CONFIG_EXE) - set(ENV{PKG_CONFIG} "${_GPC_PKG_CONFIG_EXE}") - set(PKG_CONFIG_EXECUTABLE "$ENV{PKG_CONFIG}" CACHE FILEPATH "pkg-config executable" FORCE) - endif() - if(_GPC_LIBDIR) - set(ENV{PKG_CONFIG_PATH} "") - if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") - set(ENV{PKG_CONFIG_LIBDIR} "${_GPC_LIBDIR}") - else() - string(REPLACE ";" ":" _gpc_libdir "${_GPC_LIBDIR}") - set(ENV{PKG_CONFIG_LIBDIR} "${_gpc_libdir}") - unset(_gpc_libdir) - endif() - endif() - if(_GPC_DONT_DEFINE_PREFIX AND NOT "--dont-define-prefix" IN_LIST PKG_CONFIG_ARGN) - list(APPEND PKG_CONFIG_ARGN --dont-define-prefix) - endif() - unset(_GPC_DONT_DEFINE_PREFIX) - unset(_GPC_PKG_CONFIG_EXE) - unset(_GPC_LIBDIR) - unset(_GPC_UNPARSED_ARGUMENTS) - unset(_GPC_KEYWORDS_MISSING_VALUES) -endmacro() - -macro(_gst_set_standard_paths) - cmake_parse_arguments(_GSP "" "INCLUDE_PATH" "" ${ARGN}) - set(GSTREAMER_LIB_PATH "${GStreamer_ROOT_DIR}/lib") - set(GSTREAMER_PLUGIN_PATH "${GSTREAMER_LIB_PATH}/gstreamer-1.0") - if(_GSP_INCLUDE_PATH) - set(GSTREAMER_INCLUDE_PATH "${_GSP_INCLUDE_PATH}") - else() - set(GSTREAMER_INCLUDE_PATH "${GStreamer_ROOT_DIR}/include") - endif() - # Remove any prior --define-variable entries before appending fresh ones - list(FILTER PKG_CONFIG_ARGN EXCLUDE REGEX "^--define-variable=") - list(APPEND PKG_CONFIG_ARGN - --define-variable=prefix=${GStreamer_ROOT_DIR} - --define-variable=libdir=${GSTREAMER_LIB_PATH} - --define-variable=includedir=${GSTREAMER_INCLUDE_PATH} - ) - unset(_GSP_INCLUDE_PATH) - unset(_GSP_UNPARSED_ARGUMENTS) - unset(_GSP_KEYWORDS_MISSING_VALUES) -endmacro() - -macro(_gst_normalize_and_validate_root) - cmake_path(CONVERT "${GStreamer_ROOT_DIR}" TO_CMAKE_PATH_LIST GStreamer_ROOT_DIR NORMALIZE) - if(NOT EXISTS "${GStreamer_ROOT_DIR}") - message(FATAL_ERROR "GStreamer: SDK not found at '${GStreamer_ROOT_DIR}' — " - "check installation or set GStreamer_ROOT_DIR") - endif() -endmacro() - -function(gstreamer_install_libs) - cmake_parse_arguments(ARG "" "SOURCE_DIR;DEST_DIR;EXTENSION" "" ${ARGN}) - - if(NOT EXISTS "${ARG_SOURCE_DIR}") - message(WARNING "gstreamer_install_libs: SOURCE_DIR does not exist: ${ARG_SOURCE_DIR}") - return() - endif() - - set(_blocked_prefixes - /usr/lib /usr/local/lib /opt/homebrew/lib /opt/homebrew/opt - "C:/Windows" "C:/Program Files" "C:/Program Files (x86)" - ) - foreach(_prefix IN LISTS _blocked_prefixes) - cmake_path(IS_PREFIX _prefix "${ARG_SOURCE_DIR}" NORMALIZE _is_system) - if(_is_system) - message(FATAL_ERROR - "gstreamer_install_libs: refusing to copy from system/shared prefix '${ARG_SOURCE_DIR}'.\n" - "This function copies ALL shared libraries unfiltered. Use an auto-downloaded SDK " - "or set GStreamer_ROOT_DIR to an isolated installation.") - endif() - endforeach() - - file(GLOB _all_libs "${ARG_SOURCE_DIR}/*.${ARG_EXTENSION}") - if(_all_libs) - install(FILES ${_all_libs} DESTINATION "${ARG_DEST_DIR}") - endif() -endfunction() - -# Shared list of system libraries that should be linked by name, not resolved via find_library. -# Used by both FindGStreamer.cmake and FindGStreamerMobile.cmake. -if(NOT DEFINED _gst_IGNORED_SYSTEM_LIBRARIES) - set(_gst_IGNORED_SYSTEM_LIBRARIES c c++ unwind m dl atomic) - if(ANDROID) - list(APPEND _gst_IGNORED_SYSTEM_LIBRARIES log GLESv2 EGL OpenSLES android vulkan) - elseif(APPLE) - list(APPEND _gst_IGNORED_SYSTEM_LIBRARIES iconv resolv System) - elseif(WIN32) - list(APPEND _gst_IGNORED_SYSTEM_LIBRARIES - ws2_32 ole32 oleaut32 winmm shlwapi secur32 iphlpapi dnsapi - userenv bcrypt crypt32 advapi32 kernel32 shell32 uuid) - endif() -endif() -if(NOT DEFINED _gst_SRT_REGEX_PATCH) - set(_gst_SRT_REGEX_PATCH "^:lib(.+)\\.(a|so|lib|dylib)$") -endif() - -# Save/restore macros for CMAKE_FIND_LIBRARY_SUFFIXES/PREFIXES. -# Used by FindGStreamer.cmake and FindGStreamerMobile.cmake when resolving static libs. -macro(_gst_save_find_suffixes) - set(_gst_saved_suffixes ${CMAKE_FIND_LIBRARY_SUFFIXES}) - set(_gst_saved_prefixes ${CMAKE_FIND_LIBRARY_PREFIXES}) - set(CMAKE_FIND_LIBRARY_PREFIXES "" "lib") - if(GStreamer_USE_STATIC_LIBS) - set(CMAKE_FIND_LIBRARY_SUFFIXES ".a") - elseif(APPLE) - set(CMAKE_FIND_LIBRARY_SUFFIXES ".a" ".dylib" ".so" ".tbd") - elseif(UNIX) - set(CMAKE_FIND_LIBRARY_SUFFIXES ".a" ".so") - else() - set(CMAKE_FIND_LIBRARY_SUFFIXES ".a" ".lib") - endif() -endmacro() - -macro(_gst_restore_find_suffixes) - set(CMAKE_FIND_LIBRARY_SUFFIXES ${_gst_saved_suffixes}) - set(CMAKE_FIND_LIBRARY_PREFIXES ${_gst_saved_prefixes}) -endmacro() - -# _gst_resolve_and_link_libraries( [HIDE] [WARN_MISSING]) -# -# Resolves a list of library names via find_library and links them to TARGET with the given SCOPE -# (PRIVATE, INTERFACE, or PUBLIC). Handles SRT regex patching and system library passthrough. -# -# HIDE - Use -hidden-l (Apple) or --exclude-libs (Unix) to hide symbols -# WARN_MISSING - Warn and skip missing libraries instead of failing -macro(_gst_resolve_and_link_libraries _grll_TARGET _grll_SCOPE _grll_LIBS_VAR _grll_HINTS_VAR) - cmake_parse_arguments(_grll "HIDE;WARN_MISSING" "" "" ${ARGN}) - - if(_grll_HIDE AND APPLE) - target_link_directories(${_grll_TARGET} ${_grll_SCOPE} ${${_grll_HINTS_VAR}}) - endif() - - _gst_save_find_suffixes() - - foreach(_grll_LIB IN LISTS ${_grll_LIBS_VAR}) - if(_grll_LIB MATCHES "${_gst_SRT_REGEX_PATCH}") - string(REGEX REPLACE "${_gst_SRT_REGEX_PATCH}" "\\1" _grll_LIB "${_grll_LIB}") - endif() - - if("${_grll_LIB}" IN_LIST _gst_IGNORED_SYSTEM_LIBRARIES) - target_link_libraries(${_grll_TARGET} ${_grll_SCOPE} "${_grll_LIB}") - continue() - endif() - - string(MAKE_C_IDENTIFIER "_gst_${_grll_LIB}" _grll_CACHE_VAR) - if(DEFINED ${_grll_CACHE_VAR} AND "${${_grll_CACHE_VAR}}" MATCHES "NOTFOUND$") - unset(${_grll_CACHE_VAR} CACHE) - endif() - if(NOT DEFINED ${_grll_CACHE_VAR} OR "${${_grll_CACHE_VAR}}" MATCHES "NOTFOUND$") - if(_grll_WARN_MISSING) - find_library(${_grll_CACHE_VAR} NAMES ${_grll_LIB} HINTS ${${_grll_HINTS_VAR}} NO_DEFAULT_PATH) - else() - find_library(${_grll_CACHE_VAR} NAMES ${_grll_LIB} HINTS ${${_grll_HINTS_VAR}} - NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH REQUIRED) - endif() - endif() - - if(NOT ${_grll_CACHE_VAR}) - if(_grll_WARN_MISSING) - message(WARNING "GStreamer: Library '${_grll_LIB}' not found in ${${_grll_HINTS_VAR}}, skipping") - endif() - continue() - endif() - - if(_grll_HIDE AND APPLE) - get_filename_component(_grll_NAME_WE "${${_grll_CACHE_VAR}}" NAME_WE) - string(REGEX REPLACE "^lib" "" _grll_NAME_WE "${_grll_NAME_WE}") - target_link_libraries(${_grll_TARGET} ${_grll_SCOPE} "-hidden-l${_grll_NAME_WE}") - elseif(_grll_HIDE AND (UNIX OR ANDROID)) - target_link_libraries(${_grll_TARGET} ${_grll_SCOPE} -Wl,--exclude-libs,${${_grll_CACHE_VAR}}) - else() - target_link_libraries(${_grll_TARGET} ${_grll_SCOPE} "${${_grll_CACHE_VAR}}") - endif() - endforeach() - - _gst_restore_find_suffixes() -endmacro() diff --git a/cmake/install/Install.cmake b/cmake/install/Install.cmake index 0c4bedada26c..af75152f07fd 100644 --- a/cmake/install/Install.cmake +++ b/cmake/install/Install.cmake @@ -86,7 +86,14 @@ endif() # are inside the framework's lib/ directory, but the binary's @rpath resolves to # Contents/Frameworks/ (flat). Add the framework lib path so dyld finds them. # Runs after Qt deploy to survive any rpath rewriting by macdeployqt. -if(MACOS AND GSTREAMER_FRAMEWORK) +set(_qgc_gst_framework_bundle "") +if(MACOS AND TARGET GStreamer::Layout) + get_target_property(_qgc_gst_framework_bundle GStreamer::Layout GSTREAMER_FRAMEWORK_BUNDLE) + if(_qgc_gst_framework_bundle STREQUAL "_qgc_gst_framework_bundle-NOTFOUND") + set(_qgc_gst_framework_bundle "") + endif() +endif() +if(MACOS AND _qgc_gst_framework_bundle) install(CODE " set(_binary \"\${CMAKE_INSTALL_PREFIX}/${CMAKE_PROJECT_NAME}.app/Contents/MacOS/${CMAKE_PROJECT_NAME}\") if(EXISTS \"\${_binary}\") diff --git a/cmake/modules/Download.cmake b/cmake/modules/Download.cmake index 138fd88b384a..ebfedde92bc3 100644 --- a/cmake/modules/Download.cmake +++ b/cmake/modules/Download.cmake @@ -112,9 +112,8 @@ function(qgc_resilient_download) qgc_parse_expected_hash("${ARG_EXPECTED_HASH}" _hash_algo _expected) file(${_hash_algo} "${_tmp}" _actual_hash) if(NOT _actual_hash STREQUAL "${_expected}") - message(WARNING "${ARG_LOG_TAG}: ${_hash_algo} mismatch for ${ARG_FILENAME} from ${_url}") file(REMOVE "${_tmp}") - break() + message(FATAL_ERROR "${ARG_LOG_TAG}: ${_hash_algo} mismatch for ${ARG_FILENAME} from ${_url} (expected ${_expected}, got ${_actual_hash}). Refusing to fall back to other mirrors with a pinned checksum.") endif() endif() file(RENAME "${_tmp}" "${_dest}") diff --git a/cmake/platform/Windows.cmake b/cmake/platform/Windows.cmake index fa689e4571a8..1bd7079a4455 100644 --- a/cmake/platform/Windows.cmake +++ b/cmake/platform/Windows.cmake @@ -17,6 +17,14 @@ target_compile_definitions(${CMAKE_PROJECT_NAME} _CRT_SECURE_NO_WARNINGS # Disable warnings for unsafe C functions ) +if(MSVC) + target_compile_options(${CMAKE_PROJECT_NAME} + PRIVATE + /bigobj + /Zc:preprocessor + ) +endif() + # ---------------------------------------------------------------------------- # Windows Executable Configuration # ---------------------------------------------------------------------------- diff --git a/deploy/ios/iOS-Info.plist b/deploy/ios/iOS-Info.plist index 0d299a639766..91c1b0d39a8e 100644 --- a/deploy/ios/iOS-Info.plist +++ b/deploy/ios/iOS-Info.plist @@ -6,6 +6,12 @@ QGC uses UVC devices for video streaming. NSMicrophoneUsageDescription Qt Multimedia for iOS uses the camera and microphone. + NSLocalNetworkUsageDescription + QGC connects to vehicles and receives UDP/RTSP video over the local network. + NSBonjourServices + + _rtsp._tcp + CFBundleDisplayName QGroundControl CFBundleExecutable diff --git a/deploy/linux/AppRun b/deploy/linux/AppRun index c4bd5277e215..74d61f1a70b4 100755 --- a/deploy/linux/AppRun +++ b/deploy/linux/AppRun @@ -140,6 +140,7 @@ if [ "$XDG_SESSION_TYPE" = "wayland" ]; then fi export GST_REGISTRY_REUSE_PLUGIN_SCANNER="no" +export GST_REGISTRY_FORK="no" GST_PLUGIN_DIR="${APPDIR}/usr/lib/gstreamer-1.0" GST_SCANNER="${APPDIR}/usr/lib/gstreamer1.0/gstreamer-1.0/gst-plugin-scanner" diff --git a/deploy/macos/qgroundcontrol.entitlements b/deploy/macos/qgroundcontrol.entitlements index 4a46d113e6b5..f212158e0aaa 100644 --- a/deploy/macos/qgroundcontrol.entitlements +++ b/deploy/macos/qgroundcontrol.entitlements @@ -6,6 +6,10 @@ com.apple.security.network.client + com.apple.security.network.server + + com.apple.security.device.camera + com.apple.security.cs.disable-library-validation diff --git a/deploy/windows/nullsoft_installer.nsi b/deploy/windows/nullsoft_installer.nsi index 275f3fef489f..324a6e18f211 100644 --- a/deploy/windows/nullsoft_installer.nsi +++ b/deploy/windows/nullsoft_installer.nsi @@ -152,8 +152,6 @@ Section "Create Start Menu Shortcuts" !insertmacro MUI_STARTMENU_WRITE_BEGIN Application CreateShortCut "$SMPROGRAMS\$StartMenuFolder\${APPNAME}.lnk" "$INSTDIR\bin\${EXENAME}.exe" "" "$INSTDIR\bin\${EXENAME}.exe" 0 - CreateShortCut "$SMPROGRAMS\$StartMenuFolder\${APPNAME} (GPU Compatibility Mode).lnk" "$INSTDIR\bin\${EXENAME}.exe" "-desktop" "$INSTDIR\bin\${EXENAME}.exe" 0 - !insertmacro DemoteShortCut "$SMPROGRAMS\$StartMenuFolder\${APPNAME} (GPU Compatibility Mode).lnk" CreateShortCut "$SMPROGRAMS\$StartMenuFolder\${APPNAME} (GPU Safe Mode).lnk" "$INSTDIR\bin\${EXENAME}.exe" "-swrast" "$INSTDIR\bin\${EXENAME}.exe" 0 !insertmacro DemoteShortCut "$SMPROGRAMS\$StartMenuFolder\${APPNAME} (GPU Safe Mode).lnk" !insertmacro MUI_STARTMENU_WRITE_END diff --git a/src/API/QGCCorePlugin.cc b/src/API/QGCCorePlugin.cc index 63147a334902..931f468ebc3a 100644 --- a/src/API/QGCCorePlugin.cc +++ b/src/API/QGCCorePlugin.cc @@ -1,24 +1,20 @@ #include "QGCCorePlugin.h" #include "AppSettings.h" +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) #include "MavlinkSettings.h" +#endif #include "FactMetaData.h" #include "QGCMAVLink.h" -#ifdef QGC_GST_STREAMING -#include "GStreamer.h" -#endif #include "HorizontalFactValueGrid.h" #include "InstrumentValueData.h" #include "JoystickManager.h" -#include "MAVLinkMessageType.h" #include "QGCLoggingCategory.h" #include "QGCOptions.h" #include "QmlComponentInfo.h" #include "QmlObjectListModel.h" -#ifdef QGC_QT_STREAMING -#include "QtMultimediaReceiver.h" -#endif #include "SettingsManager.h" #include "VideoReceiver.h" +#include "VideoBackend.h" #include "SurveyPlanCreator.h" #include "CorridorScanPlanCreator.h" #include "StructureScanPlanCreator.h" @@ -298,36 +294,17 @@ void QGCCorePlugin::createRootWindow(QQmlApplicationEngine *qmlEngine) VideoReceiver *QGCCorePlugin::createVideoReceiver(QObject *parent) { -#ifdef QGC_GST_STREAMING - return GStreamer::createVideoReceiver(parent); -#elif defined(QGC_QT_STREAMING) - return QtMultimediaReceiver::createVideoReceiver(parent); -#else - Q_UNUSED(parent); - return nullptr; -#endif + return VideoBackend::createReceiver(parent); } void *QGCCorePlugin::createVideoSink(QQuickItem *widget, QObject *parent) { -#ifdef QGC_GST_STREAMING - return GStreamer::createVideoSink(widget, parent); -#elif defined(QGC_QT_STREAMING) - return QtMultimediaReceiver::createVideoSink(widget, parent); -#else - Q_UNUSED(widget); Q_UNUSED(parent); - return nullptr; -#endif + return VideoBackend::createSink(widget, parent); } + void QGCCorePlugin::releaseVideoSink(void *sink) { -#ifdef QGC_GST_STREAMING - GStreamer::releaseVideoSink(sink); -#elif defined(QGC_QT_STREAMING) - QtMultimediaReceiver::releaseVideoSink(sink); -#else - Q_UNUSED(sink); -#endif + VideoBackend::releaseSink(sink); } const QVariantList &QGCCorePlugin::toolBarIndicators() diff --git a/src/AppSettings/pages/Video.SettingsUI.json b/src/AppSettings/pages/Video.SettingsUI.json index 045216fee498..f2f5837398e3 100644 --- a/src/AppSettings/pages/Video.SettingsUI.json +++ b/src/AppSettings/pages/Video.SettingsUI.json @@ -6,7 +6,7 @@ "autoStreamConfig": "QGroundControl.videoManager.autoStreamConfigured", "sourceDisabled": "videoSource === QGroundControl.settingsManager.videoSettings.disabledVideoSource", "isStreamSource": "QGroundControl.videoManager.isStreamSource", - "isGST": "QGroundControl.videoManager.gstreamerEnabled" + "rtpLatencyVisible": "!QGroundControl.settingsManager.videoSettings.lowLatencyMode.rawValue" }, "groups": [ { @@ -55,11 +55,19 @@ }, { "setting": "videoSettings.lowLatencyMode", - "showWhen": "!autoStreamConfig && isStreamSource && isGST" + "showWhen": "!autoStreamConfig && isStreamSource && QGroundControl.settingsManager.videoSettings.lowLatencyMode.userVisible" + }, + { + "setting": "videoSettings.rtpJitterLatencyMs", + "showWhen": "!autoStreamConfig && isStreamSource && rtpLatencyVisible && QGroundControl.settingsManager.videoSettings.rtpJitterLatencyMs.userVisible" + }, + { + "setting": "videoSettings.rtspAutoReconnect", + "showWhen": "!autoStreamConfig && isStreamSource" }, { "setting": "videoSettings.forceCpuVideoPath", - "showWhen": "isGST && QGroundControl.settingsManager.videoSettings.forceCpuVideoPath.userVisible" + "showWhen": "QGroundControl.settingsManager.videoSettings.forceCpuVideoPath.userVisible" }, { "setting": "videoSettings.forceVideoDecoder" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 980a661cb91f..f8dd02659378 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -105,6 +105,7 @@ target_link_libraries(${CMAKE_PROJECT_NAME} Qt6::Core Qt6::CorePrivate Qt6::Gui + Qt6::GuiPrivate Qt6::HttpServer Qt6::Network Qt6::StateMachine diff --git a/src/FlyView/FlightDisplayViewVideo.qml b/src/FlyView/FlightDisplayViewVideo.qml index adf8934da09e..6198941e7896 100644 --- a/src/FlyView/FlightDisplayViewVideo.qml +++ b/src/FlyView/FlightDisplayViewVideo.qml @@ -14,9 +14,7 @@ Item { property double _ar: (cameraLoader.visible && cameraLoader.status === Loader.Ready) ? cameraLoader.item.implicitWidth / cameraLoader.item.implicitHeight - : QGroundControl.videoManager.gstreamerEnabled - ? QGroundControl.videoManager.videoSize.width / QGroundControl.videoManager.videoSize.height - : QGroundControl.videoManager.aspectRatio + : QGroundControl.videoManager.aspectRatio property bool _showGrid: QGroundControl.settingsManager.videoSettings.gridLines.rawValue property var _dynamicCameras: globals.activeVehicle ? globals.activeVehicle.cameraManager : null property bool _connected: globals.activeVehicle ? !globals.activeVehicle.communicationLost : false @@ -121,7 +119,7 @@ Item { id: cameraLoader anchors.fill: videoContentArea visible: _showUvcLoader - source: QGroundControl.videoManager.uvcEnabled ? "qrc:/qml/QGroundControl/FlyView/FlightDisplayViewUVC.qml" : "qrc:/qml/QGroundControl/FlyView//FlightDisplayViewDummy.qml" + source: _showUvcLoader ? "qrc:/qml/QGroundControl/FlyView/FlightDisplayViewUVC.qml" : "qrc:/qml/QGroundControl/FlyView/FlightDisplayViewDummy.qml" } Item { @@ -169,7 +167,7 @@ Item { width: height * QGroundControl.videoManager.thermalAspectRatio height: _camera ? (_camera.thermalMode === MavlinkCameraControlInterface.THERMAL_FULL ? parent.height : (_camera.thermalMode === MavlinkCameraControlInterface.THERMAL_PIP ? ScreenTools.defaultFontPixelHeight * 12 : parent.height * _thermalHeightFactor)) : 0 anchors.centerIn: parent - visible: QGroundControl.videoManager.hasThermal && _camera.thermalMode !== MavlinkCameraControlInterface.THERMAL_OFF + visible: QGroundControl.videoManager.hasThermal && _camera && _camera.thermalMode !== MavlinkCameraControlInterface.THERMAL_OFF function pipOrNot() { if(_camera) { if(_camera.thermalMode === MavlinkCameraControlInterface.THERMAL_PIP) { diff --git a/src/FlyView/FlightDisplayViewVideoOutput.qml b/src/FlyView/FlightDisplayViewVideoOutput.qml index 7dea62963eb7..e89e70fe5788 100644 --- a/src/FlyView/FlightDisplayViewVideoOutput.qml +++ b/src/FlyView/FlightDisplayViewVideoOutput.qml @@ -7,7 +7,7 @@ VideoOutput { objectName: "videoContent" // Do NOT set `orientation` here — VideoOutput composes orientation on top of the - // QVideoFrame's own rotation()/mirrored() metadata that GstAppSinkAdapter forwards from + // QVideoFrame's own rotation()/mirrored() metadata that qgcqvideosink forwards from // GstVideoOrientationMeta. Setting it would double-rotate any stream with orientation tags. // videoFit enum: 0=Fit Width, 1=Fit Height, 2=Fill, 3=No Crop. The container diff --git a/src/LogManager/LogManager.cc b/src/LogManager/LogManager.cc index 7373ff60e9c3..1aed8da94931 100644 --- a/src/LogManager/LogManager.cc +++ b/src/LogManager/LogManager.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include "LogFormatter.h" @@ -40,6 +41,7 @@ static QElapsedTimer s_elapsedTimer = []() { QElapsedTimer t; t.start(); return // --------------------------------------------------------------------------- static QtMessageHandler s_defaultHandler = nullptr; +static std::atomic s_echoToStderr{false}; void LogManager::msgHandler(QtMsgType type, const QMessageLogContext& context, const QString& msg) { @@ -48,6 +50,11 @@ void LogManager::msgHandler(QtMsgType type, const QMessageLogContext& context, c if (s_defaultHandler) { s_defaultHandler(type, context, msg); } + if (s_echoToStderr.load(std::memory_order_relaxed)) { + const QByteArray line = qFormatLogMessage(type, context, msg).toLocal8Bit(); + std::fprintf(stderr, "%s\n", line.constData()); + std::fflush(stderr); + } if (!inst) { return; @@ -118,10 +125,12 @@ LogManager::~LogManager() } } -void LogManager::installHandler() +void LogManager::installHandler(bool logOutput) { Q_ASSERT(!s_instance.load(std::memory_order_relaxed)); auto* mgr = new LogManager(); + Q_UNUSED(mgr) + s_echoToStderr.store(logOutput, std::memory_order_release); qSetMessagePattern( QStringLiteral("%{time process}%{if-warning} Warning:%{endif}%{if-critical} Critical:%{endif} %{message} - " diff --git a/src/LogManager/LogManager.h b/src/LogManager/LogManager.h index e47e3d344d9d..d380d6d56672 100644 --- a/src/LogManager/LogManager.h +++ b/src/LogManager/LogManager.h @@ -33,7 +33,7 @@ class LogManager : public QObject static LogManager* instance(); static LogManager* create(QQmlEngine* qmlEngine, QJSEngine* jsEngine); - static void installHandler(); + static void installHandler(bool logOutput); static void applyEnvironmentLogLevel(); void init(); diff --git a/src/QGCApplication.cc b/src/QGCApplication.cc index 7e70445dd051..3d40473ff3b2 100644 --- a/src/QGCApplication.cc +++ b/src/QGCApplication.cc @@ -19,6 +19,7 @@ #include "AudioOutput.h" #include "ColoredSvgImageProvider.h" #include "FollowMe.h" +#include "GraphicsSetup.h" #include "JoystickManager.h" #include "JsonParsing.h" #include "LinkManager.h" @@ -37,9 +38,7 @@ #include "QGCLoggingCategoryManager.h" #include "QGCNetworkHelper.h" #include "SettingsManager.h" -#include "UDPLink.h" #include "Vehicle.h" -#include "VehicleComponent.h" #include "VideoManager.h" #include "qgc_version.h" @@ -265,8 +264,8 @@ bool QGCApplication::_initVideo() QGCCorePlugin::instance(); // CorePlugin must be initialized before VideoManager for Video Cleanup VideoManager* videoManager = VideoManager::instance(); - videoManager->startGStreamerInit(); - const bool initSucceeded = !_simpleBootTest || videoManager->waitForGStreamerInit(); + videoManager->startVideoBackendInit(); + const bool initSucceeded = !_simpleBootTest || videoManager->waitForVideoBackendReady(); _videoManagerInitialized = true; return initSucceeded; } @@ -288,6 +287,10 @@ bool QGCApplication::_initQmlRootWindow() QGCCorePlugin::instance()->createRootWindow(_qmlAppEngine); + // The root QQuickWindow exists now (load() is synchronous) but its scene graph has not been + // initialized yet -- the only safe point to apply RHI graphics config / forced device. + GraphicsSetup::configureMainWindow(mainRootWindow()); + return mainRootWindow() != nullptr; } diff --git a/src/Settings/Video.SettingsGroup.json b/src/Settings/Video.SettingsGroup.json index 23b4c021621b..9a9c0cd4c213 100644 --- a/src/Settings/Video.SettingsGroup.json +++ b/src/Settings/Video.SettingsGroup.json @@ -151,6 +151,27 @@ "label": "Low Latency Mode", "keywords": "low latency" }, + { + "name": "rtpJitterLatencyMs", + "shortDesc": "RTP jitter-buffer playout latency (ms).", + "longDesc": "Latency budget for the RTP jitter buffer (rtspsrc.latency / rtpjitterbuffer.latency). Lower = less glass-to-glass delay; higher = more headroom for packet reordering and retransmission. The default of 80 ms allows RFC 4588 retransmission to recover lost packets before the playout deadline; values <40 ms effectively disable retransmission. Ignored when Low Latency Mode is enabled.", + "type": "uint32", + "min": 0, + "max": 2000, + "units": "ms", + "default": 80, + "label": "RTP jitter latency", + "keywords": "rtp,jitter,latency,rtsp,rtx,retransmission,advanced" + }, + { + "name": "rtspAutoReconnect", + "shortDesc": "Automatically restart the pipeline on watchdog timeout or pipeline error.", + "longDesc": "When enabled, the receiver reconnects with exponential backoff (1s → 30s) after a stream timeout or a fatal pipeline error. Disable to fall back to the legacy behaviour of giving up after one failure.", + "type": "bool", + "default": true, + "label": "Auto-reconnect on stream loss", + "keywords": "rtsp,reconnect,watchdog,recovery,advanced" + }, { "name": "forceVideoDecoder", "shortDesc": "Override automatic video decoder selection to force a specific decoding method.", @@ -190,15 +211,6 @@ "default": false, "label": "Disable pixel-aspect-ratio normalization", "keywords": "pixel aspect ratio,capsfilter,v4l2,workaround,advanced" - }, - { - "name": "frameSmoothingEnabled", - "shortDesc": "Buffer up to 3 decoded frames and pace delivery to the display refresh rate.", - "longDesc": "Off by default — appsink frames are delivered to the renderer immediately. When enabled, the adapter holds a 3-frame ring and a display-rate timer picks the frame closest to the expected presentation time (PTS-anchored, 70 ms tolerance). Smooths out jitter from bursty decoders or variable network at the cost of up to one frame of added latency. Frozen sources keep the last good frame on screen. Takes effect on next stream restart.", - "type": "bool", - "default": false, - "label": "Smooth frame pacing (experimental)", - "keywords": "smoothing,jitter,pacing,latency,obs" } ] } diff --git a/src/Settings/VideoSettings.cc b/src/Settings/VideoSettings.cc index c3b7a8fd5394..e729b477eed8 100644 --- a/src/Settings/VideoSettings.cc +++ b/src/Settings/VideoSettings.cc @@ -13,15 +13,12 @@ static constexpr bool kGstEnabled = true; #else static constexpr bool kGstEnabled = false; #endif -#ifndef QGC_DISABLE_UVC #include "UVCReceiver.h" -#endif DECLARE_SETTINGGROUP(Video, "Video") { // Setup enum values for videoSource settings into meta data QVariantList videoSourceList; -#if defined(QGC_GST_STREAMING) || defined(QGC_QT_STREAMING) videoSourceList.append(videoSourceRTSP); videoSourceList.append(videoSourceUDPH264); videoSourceList.append(videoSourceUDPH265); @@ -31,18 +28,15 @@ DECLARE_SETTINGGROUP(Video, "Video") videoSourceList.append(videoSourceParrotDiscovery); videoSourceList.append(videoSourceYuneecMantisG); - #ifdef QGC_HERELINK_AIRUNIT_VIDEO - videoSourceList.append(videoSourceHerelinkAirUnit); - #else - videoSourceList.append(videoSourceHerelinkHotspot); - #endif +#ifdef QGC_HERELINK_AIRUNIT_VIDEO + videoSourceList.append(videoSourceHerelinkAirUnit); +#else + videoSourceList.append(videoSourceHerelinkHotspot); #endif -#ifndef QGC_DISABLE_UVC QStringList uvcDevices = UVCReceiver::getDeviceNameList(); for (const QString& device : uvcDevices) { videoSourceList.append(device); } -#endif if (videoSourceList.count() == 0) { _noVideo = true; videoSourceList.append(videoSourceNoVideo); @@ -141,6 +135,26 @@ DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, lowLatencyMode) return _lowLatencyModeFact; } +DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, rtpJitterLatencyMs) +{ + if (!_rtpJitterLatencyMsFact) { + _rtpJitterLatencyMsFact = _createSettingsFact(rtpJitterLatencyMsName); + _rtpJitterLatencyMsFact->setUserVisible(kGstEnabled); + connect(_rtpJitterLatencyMsFact, &Fact::valueChanged, this, &VideoSettings::_configChanged); + } + return _rtpJitterLatencyMsFact; +} + +DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, rtspAutoReconnect) +{ + if (!_rtspAutoReconnectFact) { + _rtspAutoReconnectFact = _createSettingsFact(rtspAutoReconnectName); + _rtspAutoReconnectFact->setUserVisible(kGstEnabled); + connect(_rtspAutoReconnectFact, &Fact::valueChanged, this, &VideoSettings::_configChanged); + } + return _rtspAutoReconnectFact; +} + DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, forceCpuVideoPath) { if (!_forceCpuVideoPathFact) { @@ -155,8 +169,8 @@ DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, forceCpuVideoPath) return _forceCpuVideoPathFact; } -// videoConversionElement / disablePixelAspectRatio are read by GStreamer::createVideoSink() -// at bin construction and passed as construct-only properties — no env-var indirection. +// videoConversionElement / disablePixelAspectRatio are read by VideoBackend::createSink() +// into a VideoSinkConfig and passed as construct-only bin properties — no env-var indirection. DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, videoConversionElement) { if (!_videoConversionElementFact) { @@ -175,14 +189,6 @@ DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, disablePixelAspectRatio) return _disablePixelAspectRatioFact; } -DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, frameSmoothingEnabled) -{ - if (!_frameSmoothingEnabledFact) { - _frameSmoothingEnabledFact = _createSettingsFact(frameSmoothingEnabledName); - _frameSmoothingEnabledFact->setUserVisible(kGstEnabled); - } - return _frameSmoothingEnabledFact; -} DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, rtspTimeout) { @@ -265,12 +271,10 @@ bool VideoSettings::streamConfigured(void) qCDebug(VideoSettingsLog) << "Stream configured for Herelink Hotspot"; return true; } -#ifndef QGC_DISABLE_UVC if (UVCReceiver::enabled() && UVCReceiver::deviceExists(vSource)) { qCDebug(VideoSettingsLog) << "Stream configured for UVC"; return true; } -#endif return false; } @@ -311,3 +315,37 @@ void VideoSettings::_setForceVideoDecodeList() } #endif } + +void VideoSettings::pruneUnavailableDecoders() +{ +#ifdef QGC_GST_STREAMING + static const QList hardwareFamilies{ + GStreamer::VideoDecoderOptions::ForceVideoDecoderNVIDIA, + GStreamer::VideoDecoderOptions::ForceVideoDecoderVAAPI, + GStreamer::VideoDecoderOptions::ForceVideoDecoderDirectX3D, + GStreamer::VideoDecoderOptions::ForceVideoDecoderVideoToolbox, + GStreamer::VideoDecoderOptions::ForceVideoDecoderIntel, + GStreamer::VideoDecoderOptions::ForceVideoDecoderVulkan, + }; + + const QList available = GStreamer::availableDecoderFamilies(); + const auto metaIt = _nameToMetaDataMap.constFind(forceVideoDecoderName); + if (metaIt == _nameToMetaDataMap.constEnd() || !metaIt.value()) { + return; + } + FactMetaData* const metaData = metaIt.value(); + for (const auto family : hardwareFamilies) { + // removeEnumInfo() qWarns on an absent value, so skip families not in the enum; values are + // stored as QVariant(int), so match that representation. + const QVariant familyValue = static_cast(family); + if (!available.contains(family) && metaData->enumValues().contains(familyValue)) { + metaData->removeEnumInfo(familyValue); + } + } + + Fact* const fact = forceVideoDecoder(); + if (!metaData->enumValues().contains(fact->rawValue())) { + fact->setRawValue(GStreamer::VideoDecoderOptions::ForceVideoDecoderDefault); + } +#endif +} diff --git a/src/Settings/VideoSettings.h b/src/Settings/VideoSettings.h index 3435de8f2444..6bd21c19b1b5 100644 --- a/src/Settings/VideoSettings.h +++ b/src/Settings/VideoSettings.h @@ -28,11 +28,12 @@ class VideoSettings : public SettingsGroup DEFINE_SETTINGFACT(streamEnabled) DEFINE_SETTINGFACT(disableWhenDisarmed) DEFINE_SETTINGFACT(lowLatencyMode) + DEFINE_SETTINGFACT(rtpJitterLatencyMs) + DEFINE_SETTINGFACT(rtspAutoReconnect) DEFINE_SETTINGFACT(forceVideoDecoder) DEFINE_SETTINGFACT(forceCpuVideoPath) DEFINE_SETTINGFACT(videoConversionElement) DEFINE_SETTINGFACT(disablePixelAspectRatio) - DEFINE_SETTINGFACT(frameSmoothingEnabled) Q_PROPERTY(bool streamConfigured READ streamConfigured NOTIFY streamConfiguredChanged) Q_PROPERTY(QString rtspVideoSource READ rtspVideoSource CONSTANT) @@ -50,6 +51,11 @@ class VideoSettings : public SettingsGroup QString mpegtsVideoSource () { return videoSourceMPEGTS; } QString disabledVideoSource () { return videoDisabled; } + /// Remove hardware forced-decoder options absent from the running GStreamer registry, and + /// reset the active choice to Default if it was pruned. Call after the video backend has + /// initialized (the registry is empty until then). + void pruneUnavailableDecoders(); + static constexpr const char* videoSourceNoVideo = QT_TRANSLATE_NOOP("VideoSettings", "No Video Available"); static constexpr const char* videoDisabled = QT_TRANSLATE_NOOP("VideoSettings", "Video Stream Disabled"); static constexpr const char* videoSourceRTSP = QT_TRANSLATE_NOOP("VideoSettings", "RTSP Video Stream"); diff --git a/src/Utilities/Platform/CMakeLists.txt b/src/Utilities/Platform/CMakeLists.txt index b1a7a75eb682..1062cb8f51fd 100644 --- a/src/Utilities/Platform/CMakeLists.txt +++ b/src/Utilities/Platform/CMakeLists.txt @@ -7,6 +7,8 @@ target_sources(${CMAKE_PROJECT_NAME} PRIVATE Platform.cc Platform.h + GraphicsSetup.cc + GraphicsSetup.h RunGuard.cc RunGuard.h ) diff --git a/src/Utilities/Platform/GraphicsSetup.cc b/src/Utilities/Platform/GraphicsSetup.cc new file mode 100644 index 000000000000..a435aadb6c00 --- /dev/null +++ b/src/Utilities/Platform/GraphicsSetup.cc @@ -0,0 +1,234 @@ +#include "GraphicsSetup.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(GraphicsSetupLog, "API.GraphicsSetup") + +namespace GraphicsSetup { + +namespace { + +constexpr const char* kEnvRhiDebug = "QGC_RHI_DEBUG"; +constexpr const char* kEnvRhiPipelineCache = "QGC_RHI_PIPELINE_CACHE"; +constexpr const char* kEnvHdrOutput = "QGC_HDR_OUTPUT"; +constexpr const char* kEnvForceVideoGpu = "QGC_FORCE_VIDEO_GPU"; + +bool envFlag(const char* name) +{ + return qEnvironmentVariableIsSet(name) && (qEnvironmentVariable(name) != QLatin1String("0")); +} + +bool envEnabledByDefault(const char* name) +{ + return !qEnvironmentVariableIsSet(name) || (qEnvironmentVariable(name) != QLatin1String("0")); +} + +void configurePipelineCache(QQuickGraphicsConfiguration& config) +{ + if (!envEnabledByDefault(kEnvRhiPipelineCache)) { + return; + } + + const QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + if (cacheDir.isEmpty()) { + qCWarning(GraphicsSetupLog) << "RHI pipeline cache disabled: no writable cache location"; + return; + } + + QDir dir; + if (!dir.mkpath(cacheDir)) { + qCWarning(GraphicsSetupLog) << "RHI pipeline cache disabled: failed to create" << cacheDir; + return; + } + + const QString cacheFile = cacheDir + QStringLiteral("/qgc_rhi_pipeline.cache"); + config.setPipelineCacheLoadFile(cacheFile); + config.setPipelineCacheSaveFile(cacheFile); + qCDebug(GraphicsSetupLog) << "RHI pipeline cache:" << cacheFile; +} + +#if defined(Q_OS_WIN) +bool parseHex32(const QString& spec, quint32& value) +{ + QString token = spec.trimmed(); + if (token.startsWith(QLatin1String("0x"), Qt::CaseInsensitive)) { + token.remove(0, 2); + } + + if (token.isEmpty()) { + return false; + } + + bool ok = false; + value = token.toUInt(&ok, 16); + return ok; +} + +qint32 signExtend32(quint32 value) +{ + const qint64 signedValue = + (value <= 0x7FFFFFFFU) ? static_cast(value) : (static_cast(value) - 0x100000000LL); + return static_cast(signedValue); +} + +// Parse "low:high" hex LUID from QGC_FORCE_VIDEO_GPU into a DXGI adapter LUID pair. +bool parseLuid(const QString& spec, quint32& low, qint32& high) +{ + const QStringList parts = spec.split(QLatin1Char(':')); + if (parts.size() != 2) { + return false; + } + + quint32 highRaw = 0; + if (!parseHex32(parts.at(0), low) || !parseHex32(parts.at(1), highRaw)) { + return false; + } + + high = signExtend32(highRaw); + return true; +} + +void configureForcedD3DAdapter(QQuickWindow* window) +{ + if (!qEnvironmentVariableIsSet(kEnvForceVideoGpu)) { + return; + } + + const QString spec = qEnvironmentVariable(kEnvForceVideoGpu); + quint32 luidLow = 0; + qint32 luidHigh = 0; + if (!parseLuid(spec, luidLow, luidHigh)) { + qCWarning(GraphicsSetupLog) << "QGC_FORCE_VIDEO_GPU malformed (expected hex 'low:high'):" << spec; + return; + } + + // setGraphicsDevice() must happen before scene graph initialization. Once the QRhi is created, + // Qt Quick owns the adapter choice for the lifetime of this scene graph. + window->setGraphicsDevice(QQuickGraphicsDevice::fromAdapter(luidLow, luidHigh)); + qCWarning(GraphicsSetupLog).nospace() + << "QGC_FORCE_VIDEO_GPU: forcing adapter LUID low=0x" << QString::number(luidLow, 16) << " high=0x" + << QString::number(static_cast(luidHigh), 16); +} +#else +void warnForcedGpuUnsupported() +{ + if (qEnvironmentVariableIsSet(kEnvForceVideoGpu)) { + qCWarning(GraphicsSetupLog) + << "QGC_FORCE_VIDEO_GPU is Windows/D3D-only; ignoring LUID" << qEnvironmentVariable(kEnvForceVideoGpu); + } +} +#endif + +const char* hdrFormatName(QRhiSwapChain::Format f) +{ + switch (f) { + case QRhiSwapChain::SDR: + return "SDR"; + case QRhiSwapChain::HDRExtendedSrgbLinear: + return "HDRExtendedSrgbLinear"; + case QRhiSwapChain::HDR10: + return "HDR10"; + case QRhiSwapChain::HDRExtendedDisplayP3Linear: + return "HDRExtendedDisplayP3Linear"; + default: + return "Unknown"; + } +} + +// Diagnostic-only HDR probe. This runs on the render thread where window->rhi() is valid. +// The temporary swapchain is never presented; it only exposes QRhi's HDR support query APIs. +void probeHdr(QQuickWindow* window) +{ + QRhi* rhi = window->rhi(); + if (!rhi) { + qCWarning(GraphicsSetupLog) << "HDR probe: no QRhi on render thread"; + return; + } + + std::unique_ptr sc(rhi->newSwapChain()); + if (!sc) { + qCWarning(GraphicsSetupLog) << "HDR probe: backend has no swapchain support"; + return; + } + sc->setWindow(window); + + const QRhiSwapChainHdrInfo info = sc->hdrInfo(); + qCDebug(GraphicsSetupLog).nospace() + << "HDR display info: limitsType=" << static_cast(info.limitsType) + << " luminanceBehavior=" << static_cast(info.luminanceBehavior) << " sdrWhiteLevel=" << info.sdrWhiteLevel; + + for (const QRhiSwapChain::Format f : + {QRhiSwapChain::HDRExtendedSrgbLinear, QRhiSwapChain::HDR10, QRhiSwapChain::HDRExtendedDisplayP3Linear}) { + qCDebug(GraphicsSetupLog) << "HDR format" << hdrFormatName(f) << "supported:" << sc->isFormatSupported(f); + } + + // Qt Quick owns the real window swapchain. Qt 6.10 has no public per-window HDR format setter, + // so this cannot enable HDR output; it only reports what the backend/display could support. + if (envFlag(kEnvHdrOutput)) { + qCWarning(GraphicsSetupLog) + << "QGC_HDR_OUTPUT set but Qt Quick exposes no public swapchain-format setter in this" + << "Qt version; staying on SDR. (Diagnostic only.)"; + } +} + +} // namespace + +void configureMainWindow(QQuickWindow* window) +{ + if (!window) { + qCWarning(GraphicsSetupLog) << "configureMainWindow: null window"; + return; + } + + // Graphics config and forced device MUST precede SG init. If the SG is already live we cannot + // safely apply them, so warn and skip rather than silently no-op. + if (window->isSceneGraphInitialized()) { + qCWarning(GraphicsSetupLog) + << "Scene graph already initialized before configureMainWindow; skipping RHI config"; + } else { + QQuickGraphicsConfiguration config = window->graphicsConfiguration(); + + if (envFlag(kEnvRhiDebug)) { + config.setDebugLayer(true); + config.setDebugMarkers(true); + config.setTimestamps(true); + qCDebug(GraphicsSetupLog) << "RHI debug layer/markers/timestamps enabled"; + } + + configurePipelineCache(config); + window->setGraphicsConfiguration(config); + +#if defined(Q_OS_WIN) + configureForcedD3DAdapter(window); +#else + warnForcedGpuUnsupported(); +#endif + } + + // HDR diagnostics need a live QRhi, so they run on the render thread after scene graph init. + // Normal launches skip the temporary probe swapchain and its log noise. + if (!envFlag(kEnvRhiDebug) && !envFlag(kEnvHdrOutput)) { + return; + } + QQuickWindow* win = window; + if (win->isSceneGraphInitialized()) { + win->scheduleRenderJob(QRunnable::create([win]() { probeHdr(win); }), QQuickWindow::BeforeSynchronizingStage); + win->update(); + } else { + QObject::connect( + win, &QQuickWindow::sceneGraphInitialized, win, [win]() { probeHdr(win); }, Qt::DirectConnection); + } +} + +} // namespace GraphicsSetup diff --git a/src/Utilities/Platform/GraphicsSetup.h b/src/Utilities/Platform/GraphicsSetup.h new file mode 100644 index 000000000000..7c46dabe184d --- /dev/null +++ b/src/Utilities/Platform/GraphicsSetup.h @@ -0,0 +1,25 @@ +#pragma once + +class QQuickWindow; + +/// Low-level RHI / scene-graph configuration for the main QQuickWindow. +/// +/// All of this MUST be applied before the window's scene graph initializes for the first time +/// (before first expose). Call configureMainWindow() synchronously right after the root window +/// object is created and before the event loop resumes — at that point the QQuickWindow exists but +/// the SG is not yet live. The diagnostic HDR probe is deferred to a render job because it needs a +/// valid QRhi, which only exists after sceneGraphInitialized. +/// +/// Everything here is OFF by default and gated behind environment variables (except the low-risk +/// pipeline cache, which is ON unless QGC_RHI_PIPELINE_CACHE=0): +/// QGC_RHI_DEBUG - enable RHI debug layer, debug markers and GPU timestamps +/// QGC_RHI_PIPELINE_CACHE - "0" disables the on-disk pipeline cache (default on) +/// QGC_HDR_OUTPUT - request HDR display output (diagnostic; see note in .cc) +/// QGC_FORCE_VIDEO_GPU - "low:high" LUID hex pair to force a specific D3D adapter (Windows only) +namespace GraphicsSetup { + +/// Apply pre-scene-graph-init configuration (graphics config + optional forced device) and schedule +/// the deferred HDR diagnostic probe. No-op with a warning if the SG is already initialized. +void configureMainWindow(QQuickWindow* window); + +} // namespace GraphicsSetup diff --git a/src/Utilities/Platform/Platform.cc b/src/Utilities/Platform/Platform.cc index 783e690b7b48..90c6fc304bb0 100644 --- a/src/Utilities/Platform/Platform.cc +++ b/src/Utilities/Platform/Platform.cc @@ -3,6 +3,8 @@ #include #include +#include +#include #include "QGCCommandLineParser.h" @@ -192,6 +194,11 @@ std::optional Platform::initialize(int argc, char* argv[], if (!qEnvironmentVariableIsSet("QT_WIN_DEBUG_CONSOLE")) { (void) qputenv("QT_WIN_DEBUG_CONSOLE", "attach"); } + if (qEnvironmentVariable("QSG_RHI_BACKEND").compare(QLatin1String("d3d12"), Qt::CaseInsensitive) == 0) { + // Qt 6.10 does not reliably select D3D12 from QSG_RHI_BACKEND on Windows. Make the test/diagnostic override + // explicit before the scene graph is initialized; the default path remains Qt's D3D11 backend. + QQuickWindow::setGraphicsApi(QSGRendererInterface::Direct3D12); + } setWindowsErrorModes(args.quietWindowsAsserts); #endif @@ -208,15 +215,25 @@ std::optional Platform::initialize(int argc, char* argv[], #endif // --- Qt attributes --- - if (args.useDesktopGL) { - QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); - } - if (args.useSwRast) { + // RHI defaults to D3D11/Metal on Win/macOS; AA_UseSoftwareOpenGL only bites once the scene graph is on GL. + QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); QCoreApplication::setAttribute(Qt::AA_UseSoftwareOpenGL); } +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && \ + (defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) || defined(QGC_HAS_GST_DMABUF_GPU_PATH)) + // GL is the only working desktop-Linux GStreamer zero-copy backend (GLMemory and DMABuf/EGLImage both import into a + // GL RHI; Vulkan import dormant); pin it unless the user set QSG_RHI_BACKEND. No QRhi::probe — needs GuiPrivate (not + // linked here) and GL is always present on Linux. + else if (!qEnvironmentVariableIsSet("QSG_RHI_BACKEND")) { + QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); + } +#endif + // GStreamer's GL/DMABuf zero-copy paths both need QOpenGLContext::globalShareContext(), which this attribute enables. +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) || defined(QGC_HAS_GST_DMABUF_GPU_PATH) QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts); +#endif QCoreApplication::setAttribute(Qt::AA_CompressTabletEvents); return std::nullopt; diff --git a/src/Utilities/QGCCommandLineParser.cc b/src/Utilities/QGCCommandLineParser.cc index f2fec515f7ec..998054c0190f 100644 --- a/src/Utilities/QGCCommandLineParser.cc +++ b/src/Utilities/QGCCommandLineParser.cc @@ -42,7 +42,6 @@ constexpr QLatin1StringView kOptOnscreen = QLatin1StringView("onscreen"); #ifdef Q_OS_WIN // --- Windows-only options --- -constexpr QLatin1StringView kOptDesktop = QLatin1StringView("desktop"); constexpr QLatin1StringView kOptNoWinAssertUI = QLatin1StringView("no-windows-assert-ui"); #endif @@ -220,11 +219,6 @@ CommandLineParseResult parseCommandLine() #ifdef Q_OS_WIN // --- Windows-only options --- - const QCommandLineOption desktopOpt( - QString(kOptDesktop), - QCoreApplication::translate("main", "Force Desktop OpenGL.")); - (void) parser.addOption(desktopOpt); - const QCommandLineOption quietWinAssertOpt( QString(kOptNoWinAssertUI), QCoreApplication::translate("main", "Disable Windows assert dialog boxes.")); @@ -268,11 +262,10 @@ CommandLineParseResult parseCommandLine() #ifndef Q_OS_WIN // Non-Windows platforms don't support Windows-specific options - if (out.unknownOptions.contains(QLatin1String("desktop")) || - out.unknownOptions.contains(QLatin1String("no-windows-assert-ui"))) { + if (out.unknownOptions.contains(QLatin1String("no-windows-assert-ui"))) { out.statusCode = CommandLineParseResult::Status::Error; out.errorString = QCoreApplication::translate("main", - "--desktop/--no-windows-assert-ui are only supported on Windows."); + "--no-windows-assert-ui is only supported on Windows."); qCWarning(QGCCommandLineParserLog) << out.errorString.value(); return out; } @@ -409,10 +402,8 @@ CommandLineParseResult parseCommandLine() // --- Parse graphics options --- #ifdef Q_OS_WIN - out.useDesktopGL = parser.isSet(desktopOpt); out.quietWindowsAsserts = parser.isSet(quietWinAssertOpt); #else - out.useDesktopGL = false; out.quietWindowsAsserts = false; #endif diff --git a/src/Utilities/QGCCommandLineParser.h b/src/Utilities/QGCCommandLineParser.h index 6f58efb0f309..fb3aef181464 100644 --- a/src/Utilities/QGCCommandLineParser.h +++ b/src/Utilities/QGCCommandLineParser.h @@ -53,7 +53,6 @@ struct CommandLineParseResult bool allowMultiple = false; // --- Graphics options --- - bool useDesktopGL = false; ///< Windows only: Force Desktop OpenGL bool useSwRast = false; ///< Windows/macOS: Force software OpenGL bool quietWindowsAsserts = false; ///< Windows only: Disable assert dialogs }; diff --git a/src/VideoManager/VideoManager.cc b/src/VideoManager/VideoManager.cc index a5ed8ee5840c..4b4db87d8885 100644 --- a/src/VideoManager/VideoManager.cc +++ b/src/VideoManager/VideoManager.cc @@ -14,24 +14,17 @@ #include "VehicleLinkManager.h" #include "VideoReceiver.h" #include "VideoSettings.h" -#include "QtMultimediaReceiver.h" #include "UVCReceiver.h" -#ifdef QGC_GST_STREAMING -#include "GStreamerHelpers.h" -#include "GStreamer.h" -#if defined(QGC_HAS_ANY_GPU_PATH) -#include "VideoReceiver/GStreamer/HwBuffers/QGCRhiCapture.h" -#endif -#include -#include -#endif +#include "VideoBackend.h" + +#include #include #include +#include #include #include #include -#include #include #include #include @@ -48,11 +41,6 @@ static constexpr const char *kFileExtension[VideoReceiver::FILE_FORMAT_MAX + 1] Q_APPLICATION_STATIC(VideoManager, _videoManagerInstance); -bool VideoManager::_shouldSkipGStreamerForUnitTests() -{ - return qgcApp() && QGC::runningUnitTests() && !qEnvironmentVariableIsSet("QGC_TEST_ENABLE_GSTREAMER"); -} - VideoManager::VideoManager(QObject *parent) : QObject(parent) , _subtitleWriter(new SubtitleWriter(this)) @@ -62,12 +50,12 @@ VideoManager::VideoManager(QObject *parent) (void) qRegisterMetaType("STATUS"); -#ifdef QGC_GST_STREAMING - _gstreamerDisabledForUnitTests = _shouldSkipGStreamerForUnitTests(); - if (_gstreamerDisabledForUnitTests) { - qCInfo(VideoManagerLog) << "Skipping GStreamer initialization for unit tests"; + if (VideoBackend::needsAsyncInit()) { + _backendDisabledForTests = VideoBackend::disabledForUnitTests(); + if (_backendDisabledForTests) { + qCInfo(VideoManagerLog) << "Skipping video backend initialization for unit tests"; + } } -#endif } VideoManager::~VideoManager() @@ -80,56 +68,59 @@ VideoManager *VideoManager::instance() return _videoManagerInstance(); } -void VideoManager::startGStreamerInit() +void VideoManager::startVideoBackendInit() { -#ifdef QGC_GST_STREAMING - if (_gstreamerDisabledForUnitTests) { - _initState = InitState::GstReady; - qCInfo(VideoManagerLog) << "GStreamer initialization disabled for unit tests"; + if (!VideoBackend::needsAsyncInit()) return; + + if (_backendDisabledForTests) { + _initState.store(InitState::BackendReady); + qCInfo(VideoManagerLog) << "video initialization disabled for unit tests"; return; } - if (_initState != InitState::NotStarted) { - qCWarning(VideoManagerLog) << "GStreamer init already started"; + // CAS-gate NotStarted -> Pending: init() (GUI thread) and waitForVideoBackendReady() (other threads) + // both enter here; without it both launch VideoBackend::initialize() -> double-init SIGABRT. + InitState expected = InitState::NotStarted; + if (!_initState.compare_exchange_strong(expected, InitState::Pending)) { + qCWarning(VideoManagerLog) << "video init already started"; return; } - _initState = InitState::Pending; + const VideoBackend::EnvPrepResult envResult = VideoBackend::prepareEnvironment(); + // Snapshot argv + env result here on the GUI thread; QCoreApplication::arguments() is not thread-safe. + _backendInitFuture = QtConcurrent::run(&VideoBackend::initialize, QCoreApplication::arguments(), envResult); - GStreamer::prepareEnvironment(); - _gstInitFuture = QtConcurrent::run(&GStreamer::initialize); - - _gstInitFuture.then(this, [this](bool success) { - _onGstInitComplete(success); + _backendInitFuture.then(this, [this](bool success) { + _onBackendInitComplete(success); }).onCanceled(this, [this] { - _onGstInitComplete(false); + _onBackendInitComplete(false); }); -#endif } -bool VideoManager::waitForGStreamerInit(int timeoutMs) +bool VideoManager::waitForVideoBackendReady(int timeoutMs) { -#ifdef QGC_GST_STREAMING - if (_gstreamerDisabledForUnitTests) { + if (!VideoBackend::needsAsyncInit()) return true; + + if (_backendDisabledForTests) { return true; } - if (_initState == InitState::NotStarted) { - startGStreamerInit(); + if (_initState.load() == InitState::NotStarted) { + startVideoBackendInit(); } - switch (_initState) { + switch (_initState.load()) { case InitState::Failed: return false; - case InitState::GstReady: + case InitState::BackendReady: case InitState::Running: return true; default: break; } - if (!_gstInitFuture.isValid()) { - qCCritical(VideoManagerLog) << "waitForGStreamerInit: no valid future"; + if (!_backendInitFuture.isValid()) { + qCCritical(VideoManagerLog) << "waitForVideoBackendReady: no valid future"; return false; } @@ -140,26 +131,22 @@ bool VideoManager::waitForGStreamerInit(int timeoutMs) (void) connect(&watcher, &QFutureWatcher::finished, &loop, &QEventLoop::quit); (void) connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); - watcher.setFuture(_gstInitFuture); + watcher.setFuture(_backendInitFuture); if (!watcher.isFinished()) { timer.start(timeoutMs); loop.exec(); } if (!watcher.isFinished()) { - qCCritical(VideoManagerLog) << "Timed out waiting for GStreamer init"; + qCCritical(VideoManagerLog) << "Timed out waiting for video init"; return false; } const bool success = watcher.result(); - if (_initState == InitState::Pending || _initState == InitState::QmlReady) { - _onGstInitComplete(success); + if (_initState.load() == InitState::Pending || _initState.load() == InitState::QmlReady) { + _onBackendInitComplete(success); } - return _initState != InitState::Failed; -#else - Q_UNUSED(timeoutMs); - return true; -#endif + return _initState.load() != InitState::Failed; } void VideoManager::init(QQuickWindow *mainWindow) @@ -175,9 +162,7 @@ void VideoManager::init(QQuickWindow *mainWindow) } _mainWindow = mainWindow; -#if defined(QGC_HAS_ANY_GPU_PATH) - QGCRhiCapture::connectWindow(mainWindow); // populate cached QRhi for GPU bridge handlers -#endif + VideoBackend::onMainWindowReady(mainWindow); (void) connect(_videoSettings->videoSource(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->udpUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); @@ -185,22 +170,25 @@ void VideoManager::init(QQuickWindow *mainWindow) (void) connect(_videoSettings->tcpUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->aspectRatio(), &Fact::rawValueChanged, this, &VideoManager::aspectRatioChanged); (void) connect(_videoSettings->lowLatencyMode(), &Fact::rawValueChanged, this, [this](const QVariant &value) { Q_UNUSED(value); _restartAllVideos(); }); - (void) connect(SettingsManager::instance()->appSettings()->gstDebugLevel(), &Fact::rawValueChanged, this, [](const QVariant &value) { -#ifdef QGC_GST_STREAMING - GStreamer::setDebugLevel(value.toInt()); -#else - Q_UNUSED(value); -#endif + // rtpJitterLatencyMs needs a pipeline restart; route through _videoSourceChanged so _updateSettings + // pushes the new value to each receiver and restarts exactly once (no double restart). + (void) connect(_videoSettings->rtpJitterLatencyMs(), &Fact::rawValueChanged, this, [this](const QVariant &value) { Q_UNUSED(value); _videoSourceChanged(); }); + // autoReconnect is a live setting — push without restart so an in-flight reconnect + // can be cancelled mid-backoff. + (void) connect(_videoSettings->rtspAutoReconnect(), &Fact::rawValueChanged, this, [this](const QVariant &value) { + const bool enabled = value.toBool(); + for (VideoReceiver *receiver : std::as_const(_videoReceivers)) { + receiver->setAutoReconnect(enabled); + } }); + VideoBackend::bindDebugLevelFact(SettingsManager::instance()->appSettings()->gstDebugLevel(), this); (void) connect(MultiVehicleManager::instance(), &MultiVehicleManager::activeVehicleChanged, this, &VideoManager::_setActiveVehicle); (void) connect(this, &VideoManager::autoStreamConfiguredChanged, this, &VideoManager::_videoSourceChanged); -#ifdef QGC_GST_STREAMING - if (_initState == InitState::NotStarted) { - startGStreamerInit(); + if (VideoBackend::needsAsyncInit() && _initState.load() == InitState::NotStarted) { + startVideoBackendInit(); } -#endif _mainWindow->scheduleRenderJob( QRunnable::create([this] { @@ -220,55 +208,52 @@ void VideoManager::_initAfterQmlIsReady() qCDebug(VideoManagerLog) << "_initAfterQmlIsReady"; -#ifdef QGC_GST_STREAMING - switch (_initState) { - case InitState::Pending: - _initState = InitState::QmlReady; - qCDebug(VideoManagerLog) << "QML ready, waiting for GStreamer"; - return; - case InitState::GstReady: - _initState = InitState::Running; - qCDebug(VideoManagerLog) << "QML ready, GStreamer already done — creating receivers"; - break; - case InitState::Failed: - qCWarning(VideoManagerLog) << "QML ready but GStreamer init failed"; - return; - default: - qCWarning(VideoManagerLog) << "_initAfterQmlIsReady: unexpected state" << static_cast(_initState); - return; + if (VideoBackend::needsAsyncInit()) { + switch (_initState.load()) { + case InitState::Pending: + _initState.store(InitState::QmlReady); + qCDebug(VideoManagerLog) << "QML ready, waiting for video"; + return; + case InitState::BackendReady: + _initState.store(InitState::Running); + qCDebug(VideoManagerLog) << "QML ready, video already done — creating receivers"; + break; + case InitState::Failed: + qCWarning(VideoManagerLog) << "QML ready but video init failed"; + return; + default: + qCWarning(VideoManagerLog) << "_initAfterQmlIsReady: unexpected state" << static_cast(_initState.load()); + return; + } } -#endif _createVideoReceivers(); } -void VideoManager::_onGstInitComplete(bool success) +void VideoManager::_onBackendInitComplete(bool success) { if (!success) { - _initState = InitState::Failed; - qCCritical(VideoManagerLog) << "GStreamer initialization failed"; + _initState.store(InitState::Failed); + qCCritical(VideoManagerLog) << "video initialization failed"; return; } -#ifdef QGC_GST_STREAMING - if (_videoSettings) { - const auto decoderOption = static_cast( - _videoSettings->forceVideoDecoder()->rawValue().toInt()); - GStreamer::setCodecPriorities(decoderOption); + if (VideoBackend::needsAsyncInit() && _videoSettings) { + _videoSettings->pruneUnavailableDecoders(); + VideoBackend::applyDecoderPriorities(_videoSettings->forceVideoDecoder()->rawValue().toInt()); } -#endif - switch (_initState) { + switch (_initState.load()) { case InitState::Pending: - _initState = InitState::GstReady; - qCDebug(VideoManagerLog) << "GStreamer ready, waiting for QML"; + _initState.store(InitState::BackendReady); + qCDebug(VideoManagerLog) << "video ready, waiting for QML"; return; case InitState::QmlReady: - _initState = InitState::Running; - qCDebug(VideoManagerLog) << "GStreamer ready, QML already done — creating receivers"; + _initState.store(InitState::Running); + qCDebug(VideoManagerLog) << "video ready, QML already done — creating receivers"; _createVideoReceivers(); return; default: - qCWarning(VideoManagerLog) << "_onGstInitComplete: unexpected state" << static_cast(_initState); + qCWarning(VideoManagerLog) << "_onBackendInitComplete: unexpected state" << static_cast(_initState.load()); return; } } @@ -285,7 +270,19 @@ void VideoManager::_createVideoReceivers() "videoContent", "thermalVideo" }; + + QStringList existing; + existing.reserve(_videoReceivers.size()); + for (const VideoReceiver *receiver : std::as_const(_videoReceivers)) { + existing.append(receiver->name()); + } + for (const QString &streamName : videoStreamList) { + // Skip only names that already initialized; a once-failed receiver was removed from the + // list, so re-entry retries it instead of being blocked by an all-or-nothing guard. + if (existing.contains(streamName)) { + continue; + } VideoReceiver *receiver = QGCCorePlugin::instance()->createVideoReceiver(this); if (!receiver) { continue; @@ -398,6 +395,11 @@ void VideoManager::grabImage(const QString &imageFile) double VideoManager::aspectRatio() const { + // Live decoded resolution wins — set by VideoReceiver::videoSizeChanged once frames flow. + if (!_videoSize.isEmpty()) { + return static_cast(_videoSize.width()) / _videoSize.height(); + } + for (VideoReceiver *receiver : _videoReceivers) { QGCVideoStreamInfo *pInfo = receiver->videoStreamInfo(); if (!receiver->isThermal() && pInfo && !pInfo->isThermal()) { @@ -405,7 +407,6 @@ double VideoManager::aspectRatio() const } } - // FIXME: use _videoReceiver->videoSize() to calculate AR (if AR is not specified in the settings?) return _videoSettings->aspectRatio()->rawValue().toDouble(); } @@ -464,26 +465,7 @@ bool VideoManager::hasVideo() const bool VideoManager::isUvc() const { - return (!_uvcVideoSourceID.isEmpty() && uvcEnabled() && hasVideo()); -} - -bool VideoManager::gstreamerEnabled() -{ -#ifdef QGC_GST_STREAMING - return true; -#else - return false; -#endif -} - -bool VideoManager::uvcEnabled() -{ - return UVCReceiver::enabled(); -} - -bool VideoManager::qtmultimediaEnabled() -{ - return QtMultimediaReceiver::enabled(); + return (!_uvcVideoSourceID.isEmpty() && UVCReceiver::enabled() && hasVideo()); } void VideoManager::setfullScreen(bool on) @@ -561,7 +543,7 @@ bool VideoManager::_updateUVC(VideoReceiver * /*receiver*/) const QString oldUvcVideoSrcID = _uvcVideoSourceID; - if (!uvcEnabled() || !hasVideo() || isStreamSource()) { + if (!UVCReceiver::enabled() || !hasVideo() || isStreamSource()) { _uvcVideoSourceID = QString(); } else { _uvcVideoSourceID = UVCReceiver::getSourceId(); @@ -679,6 +661,18 @@ bool VideoManager::_updateSettings(VideoReceiver *receiver) settingsChanged = true; } + const int rtpLatencyMs = static_cast(qMin(_videoSettings->rtpJitterLatencyMs()->rawValue().toUInt(), static_cast(INT_MAX))); + if (rtpLatencyMs != receiver->rtpJitterLatencyMs()) { + receiver->setRtpJitterLatencyMs(rtpLatencyMs); + settingsChanged = true; + } + + const bool autoReconnect = _videoSettings->rtspAutoReconnect()->rawValue().toBool(); + if (autoReconnect != receiver->autoReconnect()) { + receiver->setAutoReconnect(autoReconnect); + // No settingsChanged: autoReconnect is live, doesn't require pipeline restart. + } + if (receiver->isThermal()) { return settingsChanged; } @@ -836,9 +830,6 @@ void VideoManager::_startReceiver(VideoReceiver *receiver) } const QString source = _videoSettings->videoSource()->rawValue().toString(); - /* The gstreamer rtsp source will switch to tcp if udp is not available after 5 seconds. - So we should allow for some negotiation time for rtsp */ - const uint32_t timeout = ((source == VideoSettings::videoSourceRTSP) ? _videoSettings->rtspTimeout()->rawValue().toUInt() : 3); receiver->start(timeout); @@ -848,59 +839,31 @@ void VideoManager::_initVideoReceiver(VideoReceiver *receiver, QQuickWindow *win { if (_videoReceivers.contains(receiver)) { qCWarning(VideoManagerLog) << "Receiver already initialized"; + return; } + // Register before any setup so re-entry is blocked at every point below; error paths remove it. + _videoReceivers.append(receiver); + QQuickItem *widget = window->findChild(receiver->name()); if (!widget) { qCCritical(VideoManagerLog) << "stream widget not found" << receiver->name(); + _videoReceivers.removeOne(receiver); + receiver->deleteLater(); + return; } receiver->setWidget(widget); void *sink = QGCCorePlugin::instance()->createVideoSink(receiver->widget(), receiver); if (!sink) { qCCritical(VideoManagerLog) << "createVideoSink() failed" << receiver->name(); + _videoReceivers.removeOne(receiver); + receiver->deleteLater(); + return; } receiver->setSink(sink); -#ifdef QGC_GST_STREAMING - if (sink && widget) { - auto *videoOutput = qobject_cast(widget); - if (videoOutput) { - QVideoSink *videoSink = videoOutput->videoSink(); - if (!GStreamer::setupAppSinkAdapter(sink, videoSink, receiver)) { - qCWarning(VideoManagerLog) << "setupAppSinkAdapter failed" << receiver->name(); - } - // Visibility gate: drop frames at the appsink while the host window is hidden - // or minimized. The decoder still runs (cheap with HW accel) but render-thread - // and copy work disappears. Connector handles late window attachment via - // QQuickItem::windowChanged. - auto applyVisibility = [receiver](QWindow *win) { - if (!win) return; - const QWindow::Visibility v = win->visibility(); - const bool active = (v != QWindow::Hidden && v != QWindow::Minimized); - GStreamer::setAppSinkAdaptersActive(receiver, active); - }; - // Track the previous connection so windowChanged can drop it before wiring the - // new window. Without this, an old hidden/minimized window keeps gating the - // live receiver after the video output reparents to a new window. - auto prevConn = std::make_shared(); - auto wireWindow = [receiver, applyVisibility, prevConn](QQuickWindow *qw) { - if (*prevConn) { - QObject::disconnect(*prevConn); - *prevConn = QMetaObject::Connection{}; - } - if (!qw) return; - applyVisibility(qw); - *prevConn = QObject::connect(qw, &QWindow::visibilityChanged, receiver, - [applyVisibility, qw](QWindow::Visibility) { applyVisibility(qw); }); - }; - if (QQuickWindow *qw = videoOutput->window()) wireWindow(qw); - QObject::connect(videoOutput, &QQuickVideoOutput::windowChanged, receiver, wireWindow); - } else { - qCWarning(VideoManagerLog) << "Widget is not a VideoOutput, cannot connect appsink" << receiver->name(); - } - } -#endif + VideoBackend::attachSink(receiver, sink, widget); (void) connect(receiver, &VideoReceiver::onStartComplete, this, [this, receiver](VideoReceiver::STATUS status) { qCDebug(VideoManagerLog) << "Video" << receiver->name() << "Start complete, status:" << status; @@ -915,7 +878,10 @@ void VideoManager::_initVideoReceiver(VideoReceiver *receiver, QQuickWindow *win case VideoReceiver::STATUS_INVALID_STATE: break; default: - _restartVideo(receiver); + // Rate limit restarts on start failure. + QTimer::singleShot(1000, receiver, [this, receiver]() { + _restartVideo(receiver); + }); break; } }); @@ -972,6 +938,7 @@ void VideoManager::_initVideoReceiver(VideoReceiver *receiver, QQuickWindow *win if (!receiver->isThermal()) { _videoSize = size; emit videoSizeChanged(); + emit aspectRatioChanged(); } }); @@ -992,8 +959,6 @@ void VideoManager::_initVideoReceiver(VideoReceiver *receiver, QQuickWindow *win (void) _updateSettings(receiver); - _videoReceivers.append(receiver); - if (hasVideo()) { _startReceiver(receiver); } diff --git a/src/VideoManager/VideoManager.h b/src/VideoManager/VideoManager.h index dae6ad6a5356..ccbc2e9cb706 100644 --- a/src/VideoManager/VideoManager.h +++ b/src/VideoManager/VideoManager.h @@ -1,13 +1,16 @@ #pragma once +#include + #include #include #include #include #include +#ifdef QGC_UNITTEST_BUILD #include -#include +#endif class QQuickWindow; class SubtitleWriter; @@ -22,9 +25,6 @@ class VideoManager : public QObject QML_UNCREATABLE("") Q_MOC_INCLUDE("Vehicle.h") - Q_PROPERTY(bool gstreamerEnabled READ gstreamerEnabled CONSTANT) - Q_PROPERTY(bool qtmultimediaEnabled READ qtmultimediaEnabled CONSTANT) - Q_PROPERTY(bool uvcEnabled READ uvcEnabled CONSTANT) Q_PROPERTY(bool autoStreamConfigured READ autoStreamConfigured NOTIFY autoStreamConfiguredChanged) Q_PROPERTY(bool decoding READ decoding NOTIFY decodingChanged) Q_PROPERTY(bool fullScreen READ fullScreen WRITE setfullScreen NOTIFY fullScreenChanged) @@ -57,8 +57,8 @@ class VideoManager : public QObject Q_INVOKABLE void stopVideo(); void init(QQuickWindow *mainWindow); - void startGStreamerInit(); - bool waitForGStreamerInit(int timeoutMs = 60000); + void startVideoBackendInit(); + bool waitForVideoBackendReady(int timeoutMs = 60000); void cleanup(); bool autoStreamConfigured() const; bool decoding() const { return _decoding; } @@ -77,9 +77,6 @@ class VideoManager : public QObject QString imageFile() const { return _imageFile; } QString uvcVideoSourceID() const { return _uvcVideoSourceID; } void setfullScreen(bool on); - static bool gstreamerEnabled(); - static bool qtmultimediaEnabled(); - static bool uvcEnabled(); signals: void aspectRatioChanged(); @@ -106,15 +103,14 @@ private slots: enum class InitState : uint8_t { NotStarted, Pending, - GstReady, + BackendReady, QmlReady, Running, Failed }; - static bool _shouldSkipGStreamerForUnitTests(); void _initAfterQmlIsReady(); - void _onGstInitComplete(bool success); + void _onBackendInitComplete(bool success); void _createVideoReceivers(); void _initVideoReceiver(VideoReceiver *receiver, QQuickWindow *window); bool _updateAutoStream(VideoReceiver *receiver); @@ -133,12 +129,10 @@ private slots: QQuickWindow *_mainWindow = nullptr; Vehicle *_activeVehicle = nullptr; - InitState _initState = InitState::NotStarted; - QFuture _gstInitFuture; -#if defined(QGC_GST_STREAMING) && defined(Q_OS_ANDROID) -#endif + std::atomic _initState = InitState::NotStarted; + QFuture _backendInitFuture; bool _initialized = false; - bool _gstreamerDisabledForUnitTests = false; + bool _backendDisabledForTests = false; bool _fullScreen = false; QAtomicInteger _decoding = false; diff --git a/src/VideoManager/VideoReceiver/CMakeLists.txt b/src/VideoManager/VideoReceiver/CMakeLists.txt index 8b36831df460..fc5492f2ae4a 100644 --- a/src/VideoManager/VideoReceiver/CMakeLists.txt +++ b/src/VideoManager/VideoReceiver/CMakeLists.txt @@ -3,7 +3,12 @@ # Video streaming backends (GStreamer and Qt Multimedia) # ============================================================================ -target_sources(${CMAKE_PROJECT_NAME} PRIVATE VideoReceiver.h) +target_sources(${CMAKE_PROJECT_NAME} + PRIVATE + VideoReceiver.h + VideoBackend.cc + VideoBackend.h +) target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) @@ -11,4 +16,6 @@ target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_ # Video Backend Implementations # ---------------------------------------------------------------------------- add_subdirectory(GStreamer) +add_subdirectory(Offscreen) add_subdirectory(QtMultimedia) +add_subdirectory(SceneGraph) diff --git a/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt b/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt index 1afc6778a528..637941cda980 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt +++ b/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt @@ -1,300 +1,167 @@ -target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR} - ${CMAKE_CURRENT_SOURCE_DIR}/HwBuffers -) - -if(QGC_ENABLE_GST_VIDEOSTREAMING) - # Linux pulls in gst-allocators (DMABuf zero-copy detection); other - # platforms keep the existing minimal component set. - if(LINUX) - find_package(QGCGStreamer - REQUIRED - # Allocators adds gstreamer-allocators-1.0 to GStreamer::GStreamer — required for the DMABuf header probe in FindQGCGStreamer.cmake. - COMPONENTS Core Base Video App Rtsp Allocators - ) - else() - find_package(QGCGStreamer - REQUIRED - COMPONENTS Core Base Video App Rtsp - ) - endif() - - target_sources(${CMAKE_PROJECT_NAME} - PRIVATE - GStreamer.cc - GStreamer.h - GStreamerHelpers.cc - GStreamerHelpers.h - GStreamerLogging.cc - GStreamerLogging.h - GstAppSinkAdapter.cc - GstAppSinkAdapter.h - GstVideoReceiver.cc - GstVideoReceiver.h - ) - target_link_libraries(${CMAKE_PROJECT_NAME} - PRIVATE - GStreamer::GStreamer - Qt6::Multimedia - Qt6::MultimediaQuickPrivate # found by root CMakeLists.txt find_package - ) - - # iOS GStreamer.cc references gst_ios_{pre,post}_init defined in GStreamer::mobile. - if(IOS AND TARGET GStreamer::mobile) - target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE GStreamer::mobile) - endif() - - add_subdirectory(HwBuffers) - add_subdirectory(gstqgc) -endif() - if(NOT QGC_ENABLE_GST_VIDEOSTREAMING) return() endif() -if(ANDROID OR IOS) - return() +# Linux adds Allocators (gstreamer-allocators-1.0) for the DMABuf header probe in Orchestrator.cmake. +set(_qgc_gst_components Core Base Video App Rtsp) +if(LINUX) + list(APPEND _qgc_gst_components Allocators) endif() +set(QGCGStreamer_FIND_COMPONENTS ${_qgc_gst_components}) +include(GStreamer/Orchestrator) + +# QGCGStreamer: one static lib for the self-contained HwBuffers + gstqgc leaf layer (no app deps). +# PUBLIC usage reqs flow to the app via the link below; facade sources stay app-side (need app headers). +qt_add_library(QGCGStreamer STATIC) + +target_link_libraries(QGCGStreamer + PUBLIC + GStreamer::GStreamer + Qt6::Multimedia + Qt6::MultimediaPrivate + QGCLogging +) -include(GStreamerHelpers) +target_compile_definitions(QGCGStreamer PUBLIC QGC_GST_STREAMING) -# GSTREAMER_LIB_PATH is set by both framework and flat-SDK layouts; GSTREAMER_FRAMEWORK is only set for the framework path. -if(MACOS AND GSTREAMER_LIB_PATH) - set_property(TARGET ${CMAKE_PROJECT_NAME} APPEND PROPERTY - BUILD_RPATH "${GSTREAMER_LIB_PATH}") +# The *ForTest() helpers (singleFdImportEnabledForTest, clearForTest) compile only under this flag. +# PUBLIC so both QGCGStreamer (helper defs) and the app target's GStreamerTest.cc (decls) see it. +if(QGC_BUILD_TESTING) + target_compile_definitions(QGCGStreamer PUBLIC QGC_GST_BUILD_TESTING) endif() -if(LINUX) - gstreamer_install_plugins( - SOURCE_DIR "${GSTREAMER_PLUGIN_PATH}" - DEST_DIR "lib/gstreamer-1.0" - EXTENSION "so" - PREFIX "libgst" - ) - gstreamer_install_gio_modules( - SOURCE_DIR "${GSTREAMER_LIB_PATH}/gio/modules" - DEST_DIR "lib/gio/modules" - EXTENSION "so" - ) - # Helper binary path varies by distro: Debian lib//gstreamer1.0/, - # Fedora libexec/gstreamer-1.0/, Arch lib/gstreamer-1.0/. - set(_gst_helper_search_paths - "${GSTREAMER_LIB_PATH}/gstreamer1.0/gstreamer-1.0" - "${GStreamer_ROOT_DIR}/libexec/gstreamer-1.0" - "${GSTREAMER_PLUGIN_PATH}" - ) - find_program(_GST_PLUGIN_SCANNER gst-plugin-scanner - PATHS ${_gst_helper_search_paths} NO_DEFAULT_PATH) - find_program(_GST_PTP_HELPER gst-ptp-helper - PATHS ${_gst_helper_search_paths} NO_DEFAULT_PATH) - if(_GST_PLUGIN_SCANNER) - install(PROGRAMS "${_GST_PLUGIN_SCANNER}" - DESTINATION "lib/gstreamer1.0/gstreamer-1.0") - else() - message(WARNING "gst-plugin-scanner not found; AppImage video may not work") - endif() - if(_GST_PTP_HELPER) - install(PROGRAMS "${_GST_PTP_HELPER}" - DESTINATION "lib/gstreamer1.0/gstreamer-1.0") - endif() - -elseif(WIN32) - gstreamer_install_libs( - SOURCE_DIR "${GStreamer_ROOT_DIR}/bin" - DEST_DIR "${CMAKE_INSTALL_BINDIR}" - EXTENSION "dll" - ) - gstreamer_install_gio_modules( - SOURCE_DIR "${GSTREAMER_LIB_PATH}/gio/modules" - DEST_DIR "${CMAKE_INSTALL_LIBDIR}/gio/modules" - EXTENSION "dll" - ) - gstreamer_install_plugins( - SOURCE_DIR "${GSTREAMER_PLUGIN_PATH}" - DEST_DIR "${CMAKE_INSTALL_LIBDIR}/gstreamer-1.0" - EXTENSION "dll" - PREFIX "gst" - ) - install( - DIRECTORY "${GStreamer_ROOT_DIR}/libexec/gstreamer-1.0/" - DESTINATION "${CMAKE_INSTALL_LIBEXECDIR}/gstreamer-1.0" - FILE_PERMISSIONS - OWNER_READ OWNER_WRITE OWNER_EXECUTE - GROUP_READ GROUP_EXECUTE - WORLD_READ WORLD_EXECUTE - FILES_MATCHING - PATTERN "*.exe" - ) - -elseif(MACOS) - if(GSTREAMER_FRAMEWORK) - get_filename_component(_gst_framework_real "${GSTREAMER_FRAMEWORK}" REALPATH) - set(_gst_fw_dest "${CMAKE_INSTALL_PREFIX}/${CMAKE_PROJECT_NAME}.app/Contents/Frameworks/GStreamer.framework") - - install(CODE "file(REMOVE_RECURSE \"${_gst_fw_dest}\")") - install( - DIRECTORY "${_gst_framework_real}/Versions/1.0/" - DESTINATION "${_gst_fw_dest}/Versions/1.0" - USE_SOURCE_PERMISSIONS - PATTERN "*.la" EXCLUDE - PATTERN "*.a" EXCLUDE - PATTERN "*/bin" EXCLUDE - PATTERN "*/gst-validate-launcher" EXCLUDE - PATTERN "*/Headers" EXCLUDE - PATTERN "*/include" EXCLUDE - PATTERN "*/pkgconfig" EXCLUDE - PATTERN "*/share/aclocal" EXCLUDE - PATTERN "*/share/bash-completion" EXCLUDE - PATTERN "*/share/gdb" EXCLUDE - PATTERN "*/share/gst-android" EXCLUDE - PATTERN "*/share/gtk-doc" EXCLUDE - PATTERN "*/share/installed-tests" EXCLUDE - PATTERN "*/share/locale" EXCLUDE - PATTERN "*/share/man" EXCLUDE - PATTERN "*gstpython*" EXCLUDE - PATTERN "Commands" EXCLUDE - REGEX ".*/lib/gstreamer-1.0/libgst.*" EXCLUDE - ) +target_include_directories(QGCGStreamer + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/gstqgc + ${CMAKE_CURRENT_SOURCE_DIR}/HwBuffers + ${CMAKE_CURRENT_SOURCE_DIR}/HwBuffers/common + ${CMAKE_CURRENT_SOURCE_DIR}/HwBuffers/dmabuf + ${CMAKE_CURRENT_SOURCE_DIR}/HwBuffers/gl + ${CMAKE_CURRENT_SOURCE_DIR}/HwBuffers/d3d + ${CMAKE_CURRENT_SOURCE_DIR}/HwBuffers/vulkan + ${CMAKE_CURRENT_SOURCE_DIR}/HwBuffers/apple + ${CMAKE_CURRENT_SOURCE_DIR}/HwBuffers/android + ${CMAKE_CURRENT_SOURCE_DIR}/HwBuffers/cuda + PRIVATE + ${CMAKE_BINARY_DIR} +) - install(CODE " - set(_fw \"${_gst_fw_dest}\") - if(NOT EXISTS \"\${_fw}/Versions/1.0/GStreamer\") - message(FATAL_ERROR \"GStreamer framework: Versions/1.0/GStreamer not found — SDK layout may have changed\") - endif() - execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink 1.0 \"\${_fw}/Versions/Current\") - execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink Versions/Current/GStreamer \"\${_fw}/GStreamer\") - if(IS_DIRECTORY \"\${_fw}/Versions/1.0/Resources\") - execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink Versions/Current/Resources \"\${_fw}/Resources\") - endif() - ") +# Qt6::MultimediaQuickPrivate is OPTIONAL at the root find_package; link it when present +# (GStreamer.cc / GStreamerFrameMap.cc need MultimediaQuick) rather than failing configure. +if(TARGET Qt6::MultimediaQuickPrivate) + target_link_libraries(QGCGStreamer PUBLIC Qt6::MultimediaQuickPrivate) +else() + message(WARNING "QGCGStreamer: Qt6::MultimediaQuickPrivate not found - GStreamer video sink integration may not build") +endif() - gstreamer_install_plugins( - SOURCE_DIR "${_gst_framework_real}/Versions/1.0/lib/gstreamer-1.0" - DEST_DIR "${_gst_fw_dest}/Versions/1.0/lib/gstreamer-1.0" - EXTENSION "dylib" - PREFIX "libgst" - ) +add_subdirectory(HwBuffers) +add_subdirectory(gstqgc) + +target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE QGCGStreamer) + +# GStreamer:: facade: app-facing API + source/receiver glue. App-coupled, so it stays on the app +# target and inherits QGCGStreamer's PUBLIC deps/defs/includes via the link above. +target_sources(${CMAKE_PROJECT_NAME} + PRIVATE + GStreamer.cc + GStreamer.h + GStreamerEnvironment.cc + GStreamerEnvironment.h + GStreamerHelpers.cc + GStreamerHelpers.h + GStreamerLogging.cc + GStreamerLogging.h + GstScoped.h + GstSourceFactory.cc + GstSourceFactory.h + GstVideoReceiver.cc + GstVideoReceiver.h + QGCQVideoSinkController.cc + QGCQVideoSinkController.h +) - else() - # Non-framework layout: downloaded SDK, Homebrew, or other flat prefix - set(_mac_fw_dest "${CMAKE_INSTALL_PREFIX}/${CMAKE_PROJECT_NAME}.app/Contents/Frameworks") - set(_mac_lib_dest "${CMAKE_INSTALL_PREFIX}/${CMAKE_PROJECT_NAME}.app/Contents/lib") - set(_mac_libexec_dest "${CMAKE_INSTALL_PREFIX}/${CMAKE_PROJECT_NAME}.app/Contents/libexec/gstreamer-1.0") +# iOS xcframework ships no CA list / no keychain-via-OpenSSL; bundle a Mozilla NSS extract for +# libgioopenssl (fetched at configure time, IOS.cmake). On the app target for MACOSX_PACKAGE_LOCATION. +if(IOS AND GStreamer_IOS_CA_BUNDLE) + target_sources(${CMAKE_PROJECT_NAME} PRIVATE "${GStreamer_IOS_CA_BUNDLE}") + set_source_files_properties("${GStreamer_IOS_CA_BUNDLE}" PROPERTIES + MACOSX_PACKAGE_LOCATION "ssl/certs" + HEADER_FILE_ONLY TRUE + ) +endif() - # Unfiltered copy is only safe for isolated SDK prefixes; Homebrew shares the prefix so we filter. - if(GStreamer_AUTO_DOWNLOADED) - gstreamer_install_libs( - SOURCE_DIR "${GSTREAMER_LIB_PATH}" - DEST_DIR "${_mac_fw_dest}" - EXTENSION "dylib" - ) +# gst_init_static_plugins() shim source (called in GStreamer.cc::_registerPlugins), per platform: +# iOS: provided by GStreamer.xcframework (via GStreamer::mobile); no shim generated. +# Android: shim generated here from the plugin/GIO lists the Android backend put in scope via +# include(GStreamer/Orchestrator); GStreamer::mobile whole-archives the .a's into the app core. +# Desktop static: shim generated from GSTREAMER_PLUGINS below. +if(IOS AND TARGET GStreamer::mobile) + target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE GStreamer::mobile) +elseif(ANDROID AND TARGET GStreamer::mobile) + # Pull the whole-archived plugin .a's + helpers + GIO modules into the app's one core. + target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE GStreamer::mobile) + # The Android SDK gst_init() does NOT register the bundled plugins (verified on-device: without this, + # coreelements/tcp/udp/rtp/... stay unregistered), so the generated gst_init_static_plugins() is required. + configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/gst_static_plugins.c.in" + "${CMAKE_CURRENT_BINARY_DIR}/gst_static_plugins.c" + @ONLY) + target_sources(${CMAKE_PROJECT_NAME} PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}/gst_static_plugins.c") + # This generated C source must not pull the app's C++ precompiled header (the app PCH + # includes C++-only Qt headers); a C-language PCH built from it fails to compile. + set_source_files_properties( + "${CMAKE_CURRENT_BINARY_DIR}/gst_static_plugins.c" + TARGET_DIRECTORY ${CMAKE_PROJECT_NAME} + PROPERTIES SKIP_PRECOMPILE_HEADERS ON) +elseif(GStreamer_USE_STATIC_LIBS) + set(_qgc_found_static "") + set(_qgc_missing_static "") + foreach(_p IN LISTS GSTREAMER_PLUGINS) + if(GST_PLUGIN_${_p}_FOUND) + list(APPEND _qgc_found_static "${_p}") else() - file(GLOB _gst_libs - "${GSTREAMER_LIB_PATH}/libgst*.dylib" - "${GSTREAMER_LIB_PATH}/libglib*.dylib" - "${GSTREAMER_LIB_PATH}/libgobject*.dylib" - "${GSTREAMER_LIB_PATH}/libgmodule*.dylib" - "${GSTREAMER_LIB_PATH}/libgthread*.dylib" - "${GSTREAMER_LIB_PATH}/libgio*.dylib" - "${GSTREAMER_LIB_PATH}/libintl*.dylib" - "${GSTREAMER_LIB_PATH}/liborc*.dylib" - "${GSTREAMER_LIB_PATH}/libffi*.dylib" - "${GSTREAMER_LIB_PATH}/libpcre2*.dylib" - "${GSTREAMER_LIB_PATH}/libgraphene*.dylib" - "${GSTREAMER_LIB_PATH}/libssl*.dylib" - "${GSTREAMER_LIB_PATH}/libcrypto*.dylib" - ) - if(_gst_libs) - install(FILES ${_gst_libs} DESTINATION "${_mac_fw_dest}") - endif() - endif() - gstreamer_install_plugins( - SOURCE_DIR "${GSTREAMER_PLUGIN_PATH}" - DEST_DIR "${_mac_lib_dest}/gstreamer-1.0" - EXTENSION "dylib" - PREFIX "libgst" - ) - gstreamer_install_gio_modules( - SOURCE_DIR "${GSTREAMER_LIB_PATH}/gio/modules" - DEST_DIR "${_mac_lib_dest}/gio/modules" - EXTENSION "dylib" - ) - - # Fix rpaths so dylibs find GStreamer libs in Frameworks/ - foreach(_rpath_dir IN ITEMS - "${_mac_lib_dest}/gstreamer-1.0:@loader_path/../../Frameworks" - "${_mac_lib_dest}/gio/modules:@loader_path/../../../Frameworks" - "${_mac_libexec_dest}:@loader_path/../../Frameworks" - ) - string(REPLACE ":" ";" _parts "${_rpath_dir}") - list(GET _parts 0 _dir) - list(GET _parts 1 _rpath) - install(CODE " - file(GLOB _libs \"${_dir}/*\") - foreach(_lib \${_libs}) - execute_process( - COMMAND install_name_tool -add_rpath ${_rpath} \"\${_lib}\" - ERROR_QUIET - ) - endforeach() - ") - endforeach() - - if(EXISTS "${GStreamer_ROOT_DIR}/libexec/gstreamer-1.0") - install( - DIRECTORY "${GStreamer_ROOT_DIR}/libexec/gstreamer-1.0/" - DESTINATION "${_mac_libexec_dest}" - FILE_PERMISSIONS - OWNER_READ OWNER_WRITE OWNER_EXECUTE - GROUP_READ GROUP_EXECUTE - WORLD_READ WORLD_EXECUTE - FILES_MATCHING - PATTERN "gst-plugin-scanner" - PATTERN "gst-ptp-helper" - ) + list(APPEND _qgc_missing_static "${_p}") endif() + endforeach() + _gst_emit_static_plugin_registration(_qgc_found_static _qgc_decl _qgc_reg) + if(_qgc_missing_static) + gstreamer_filter_alternates(IN_OUT_PLUGINS _qgc_missing_static AVAILABLE ${GSTREAMER_PLUGINS}) + endif() + if(_qgc_missing_static) + message(WARNING "GStreamer (static): requested plugins not found as targets and omitted from static registration: ${_qgc_missing_static}") endif() + set(PLUGINS_DECLARATION "${_qgc_decl}") + set(PLUGINS_REGISTRATION "${_qgc_reg}") + set(G_IO_MODULES_DECLARE "") + set(G_IO_MODULES_LOAD "") + configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/gst_static_plugins.c.in" + "${CMAKE_CURRENT_BINARY_DIR}/gst_static_plugins.c" + @ONLY) + target_sources(${CMAKE_PROJECT_NAME} PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}/gst_static_plugins.c") + set_source_files_properties( + "${CMAKE_CURRENT_BINARY_DIR}/gst_static_plugins.c" + TARGET_DIRECTORY ${CMAKE_PROJECT_NAME} + PROPERTIES SKIP_PRECOMPILE_HEADERS ON) + unset(_qgc_decl) + unset(_qgc_reg) endif() -if(WIN32) - set(_gst_verify_ext "dll") - set(_gst_verify_prefix "gst") - set(_gst_verify_dest "${CMAKE_INSTALL_LIBDIR}/gstreamer-1.0") -elseif(MACOS) - set(_gst_verify_ext "dylib") - set(_gst_verify_prefix "libgst") - if(GSTREAMER_FRAMEWORK) - set(_gst_verify_dest "${CMAKE_PROJECT_NAME}.app/Contents/Frameworks/GStreamer.framework/Versions/1.0/lib/gstreamer-1.0") - else() - set(_gst_verify_dest "${CMAKE_PROJECT_NAME}.app/Contents/lib/gstreamer-1.0") - endif() -elseif(LINUX) - set(_gst_verify_ext "so") - set(_gst_verify_prefix "libgst") - set(_gst_verify_dest "lib/gstreamer-1.0") +# Mobile platforms ship plugins inside the static .a / xcfw — no install steps below. +if(ANDROID OR IOS) + return() endif() -if(_gst_verify_dest) - set(_gst_required_plugins coreelements playback rtsp rtp rtpmanager tcp udp) - set(_gst_required_check "") - foreach(_p IN LISTS _gst_required_plugins) - list(APPEND _gst_required_check "${_gst_verify_prefix}${_p}.${_gst_verify_ext}") - endforeach() +include(GStreamer/Helpers) - install(CODE " - # Honor DESTDIR: CPack stages into \$ENV{DESTDIR}, so this hand-written check - # must prepend it like install(FILES) does, or DEB/RPM/pacman packaging false-fails. - set(_missing_plugins) - foreach(_plugin IN ITEMS ${_gst_required_check}) - if(NOT EXISTS \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${_gst_verify_dest}/\${_plugin}\") - list(APPEND _missing_plugins \"\${_plugin}\") - endif() - endforeach() - if(_missing_plugins) - list(JOIN _missing_plugins \", \" _missing_list) - message(FATAL_ERROR \"GStreamer install verification: missing required plugins in ${_gst_verify_dest}: \${_missing_list} — built bundle cannot run any pipeline\") - else() - message(STATUS \"GStreamer install verification: all required plugins present in ${_gst_verify_dest}\") - endif() - ") +# Build-time rpath so the unrelocated binary finds component dylibs in the SDK +# (framework /lib or flat /lib); install-time rpath is set in cmake/install/Install.cmake. +if(MACOS AND GSTREAMER_LIB_PATH) + set_property(TARGET ${CMAKE_PROJECT_NAME} APPEND PROPERTY + BUILD_RPATH "${GSTREAMER_LIB_PATH}") endif() + +gstreamer_install_platform_sdk(${CMAKE_PROJECT_NAME}) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamer.cc b/src/VideoManager/VideoReceiver/GStreamer/GStreamer.cc index a6d9ce2b407b..83db5709ad77 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamer.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamer.cc @@ -1,790 +1,193 @@ #include "GStreamer.h" -#include "GStreamerHelpers.h" -#include "GStreamerLogging.h" -#include "AppSettings.h" -#include "QGCLoggingCategory.h" -#include "GstVideoReceiver.h" -#include "SettingsManager.h" -#include "VideoSettings.h" -#include "Fact.h" - -#include "GstAppSinkAdapter.h" -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) -# include "HwBuffers/GstGlContextBridge.h" -#endif - -#include -#include #include #include +#include #include +#include #include -#include +#include #include #include +#include +#include +#include +#include +#include #include +#include +#include +#ifdef Q_OS_ANDROID +#include +#include +#endif +#include +#include +#include +#include -#include +#include "Fact.h" +#include "GStreamerEnvironment.h" +#include "GStreamerHelpers.h" +#include "GStreamerLogging.h" +#include "GstScoped.h" +#include "GstVideoReceiver.h" +#include "HwBuffers/common/HwBuffers.h" +#if defined(QGC_HAS_ANY_GPU_PATH) +#include + +#include "HwBuffers/common/QGCRhiCapture.h" +#endif +#include "QGCLoggingCategory.h" +#include "QGCQVideoSinkController.h" +#include "gstqgc/gstqgcqvideosink.h" +#include "gstqgc/gstqgcvideosinkbin.h" #ifdef Q_OS_LINUX #include #endif +#ifdef Q_OS_ANDROID +#include +#include +#endif + #include QGC_LOGGING_CATEGORY(GStreamerLog, "Video.GStreamer.GStreamer") -QGC_LOGGING_CATEGORY(GStreamerDecoderRanksLog, "Video.GStreamer.GStreamer.DecoderRanks") - -#ifdef Q_OS_IOS -extern "C" { -void gst_ios_pre_init(void); -void gst_ios_post_init(void); -} -#endif G_BEGIN_DECLS #ifdef QGC_GST_STATIC_BUILD -GST_PLUGIN_STATIC_DECLARE(app); -GST_PLUGIN_STATIC_DECLARE(coreelements); -GST_PLUGIN_STATIC_DECLARE(isomp4); -GST_PLUGIN_STATIC_DECLARE(libav); -GST_PLUGIN_STATIC_DECLARE(matroska); -GST_PLUGIN_STATIC_DECLARE(mpegtsdemux); -GST_PLUGIN_STATIC_DECLARE(openh264); -GST_PLUGIN_STATIC_DECLARE(playback); -GST_PLUGIN_STATIC_DECLARE(rtp); -GST_PLUGIN_STATIC_DECLARE(rtpmanager); -GST_PLUGIN_STATIC_DECLARE(rtsp); -GST_PLUGIN_STATIC_DECLARE(sdpelem); -GST_PLUGIN_STATIC_DECLARE(tcp); -GST_PLUGIN_STATIC_DECLARE(typefindfunctions); -GST_PLUGIN_STATIC_DECLARE(udp); -// gst 1.22 merged videoconvert+videoscale into videoconvertscale, but custom/embedded -// gst builds may keep the legacy split. FindQGCGStreamer.cmake exports GST_PLUGIN__FOUND -// so we declare against what the linker actually has, not a version assumption. -#ifdef GST_PLUGIN_videoconvertscale_FOUND -GST_PLUGIN_STATIC_DECLARE(videoconvertscale); -#endif -#ifdef GST_PLUGIN_videoconvert_FOUND -GST_PLUGIN_STATIC_DECLARE(videoconvert); -#endif -#ifdef GST_PLUGIN_videoscale_FOUND -GST_PLUGIN_STATIC_DECLARE(videoscale); -#endif -GST_PLUGIN_STATIC_DECLARE(videoparsersbad); -GST_PLUGIN_STATIC_DECLARE(vpx); - -#ifdef GST_PLUGIN_androidmedia_FOUND -GST_PLUGIN_STATIC_DECLARE(androidmedia); -#endif -#ifdef GST_PLUGIN_applemedia_FOUND -GST_PLUGIN_STATIC_DECLARE(applemedia); -#endif -#ifdef GST_PLUGIN_d3d_FOUND -GST_PLUGIN_STATIC_DECLARE(d3d); -#endif -#ifdef GST_PLUGIN_d3d11_FOUND -GST_PLUGIN_STATIC_DECLARE(d3d11); -#endif -#ifdef GST_PLUGIN_d3d12_FOUND -GST_PLUGIN_STATIC_DECLARE(d3d12); -#endif -#ifdef GST_PLUGIN_dav1d_FOUND -GST_PLUGIN_STATIC_DECLARE(dav1d); -#endif -#ifdef GST_PLUGIN_dxva_FOUND -GST_PLUGIN_STATIC_DECLARE(dxva); -#endif -#ifdef GST_PLUGIN_nvcodec_FOUND -GST_PLUGIN_STATIC_DECLARE(nvcodec); -#endif -#ifdef GST_PLUGIN_qsv_FOUND -GST_PLUGIN_STATIC_DECLARE(qsv); -#endif -#ifdef GST_PLUGIN_va_FOUND -GST_PLUGIN_STATIC_DECLARE(va); -#endif -#ifdef GST_PLUGIN_vulkan_FOUND -GST_PLUGIN_STATIC_DECLARE(vulkan); -#endif +// Generated from gst_static_plugins.c.in (Android/IOS.cmake on mobile, desktop static cmake): +// registers every configured plugin, and on mobile loads gioopenssl + the bundled CA bundle. +extern void gst_init_static_plugins(void); #endif GST_PLUGIN_STATIC_DECLARE(qgc); G_END_DECLS -namespace GStreamer -{ +namespace GStreamer { namespace { -static std::atomic s_envPathsValid{true}; -static QMutex s_envPathsMutex; -static QString s_envPathsError; - void _registerPlugins() { + // GST_PLUGIN_STATIC_REGISTER / gst_init_static_plugins() are not idempotent: a second pass + // re-registers GTypes ("cannot register existing type 'GstBaseQTMux'") and aborts, so run once. + static std::once_flag s_pluginsRegistered; + std::call_once(s_pluginsRegistered, [] { #ifdef QGC_GST_STATIC_BUILD - GST_PLUGIN_STATIC_REGISTER(app); - GST_PLUGIN_STATIC_REGISTER(coreelements); - GST_PLUGIN_STATIC_REGISTER(isomp4); - GST_PLUGIN_STATIC_REGISTER(libav); - GST_PLUGIN_STATIC_REGISTER(matroska); - GST_PLUGIN_STATIC_REGISTER(mpegtsdemux); - GST_PLUGIN_STATIC_REGISTER(openh264); - GST_PLUGIN_STATIC_REGISTER(playback); - GST_PLUGIN_STATIC_REGISTER(rtp); - GST_PLUGIN_STATIC_REGISTER(rtpmanager); - GST_PLUGIN_STATIC_REGISTER(rtsp); - GST_PLUGIN_STATIC_REGISTER(sdpelem); - GST_PLUGIN_STATIC_REGISTER(tcp); - GST_PLUGIN_STATIC_REGISTER(typefindfunctions); - GST_PLUGIN_STATIC_REGISTER(udp); -#ifdef GST_PLUGIN_videoconvertscale_FOUND - GST_PLUGIN_STATIC_REGISTER(videoconvertscale); -#endif -#ifdef GST_PLUGIN_videoconvert_FOUND - GST_PLUGIN_STATIC_REGISTER(videoconvert); -#endif -#ifdef GST_PLUGIN_videoscale_FOUND - GST_PLUGIN_STATIC_REGISTER(videoscale); -#endif - GST_PLUGIN_STATIC_REGISTER(videoparsersbad); - GST_PLUGIN_STATIC_REGISTER(vpx); - -#ifdef GST_PLUGIN_androidmedia_FOUND - GST_PLUGIN_STATIC_REGISTER(androidmedia); -#endif -#ifdef GST_PLUGIN_applemedia_FOUND - GST_PLUGIN_STATIC_REGISTER(applemedia); -#endif -#ifdef GST_PLUGIN_d3d_FOUND - GST_PLUGIN_STATIC_REGISTER(d3d); -#endif -#ifdef GST_PLUGIN_d3d11_FOUND - GST_PLUGIN_STATIC_REGISTER(d3d11); -#endif -#ifdef GST_PLUGIN_d3d12_FOUND - GST_PLUGIN_STATIC_REGISTER(d3d12); -#endif -#ifdef GST_PLUGIN_dav1d_FOUND - GST_PLUGIN_STATIC_REGISTER(dav1d); -#endif -#ifdef GST_PLUGIN_dxva_FOUND - GST_PLUGIN_STATIC_REGISTER(dxva); -#endif -#ifdef GST_PLUGIN_nvcodec_FOUND - GST_PLUGIN_STATIC_REGISTER(nvcodec); -#endif -#ifdef GST_PLUGIN_qsv_FOUND - GST_PLUGIN_STATIC_REGISTER(qsv); -#endif -#ifdef GST_PLUGIN_va_FOUND - GST_PLUGIN_STATIC_REGISTER(va); -#endif -#ifdef GST_PLUGIN_vulkan_FOUND - GST_PLUGIN_STATIC_REGISTER(vulkan); -#endif + // Per-plugin registers in the generated shim are registry-guarded, so plugins the Android SDK + // gst_init() already pre-registered aren't re-added here. + gst_init_static_plugins(); #endif - - GST_PLUGIN_STATIC_REGISTER(qgc); -} - -void _resetEnvValidation() -{ - const QMutexLocker locker(&s_envPathsMutex); - s_envPathsError.clear(); - s_envPathsValid.store(true, std::memory_order_release); -} - -// Used by every platform branch except iOS. -[[maybe_unused]] QString _cleanJoin(const QString &base, const QString &relative) -{ - return QDir::cleanPath(QDir(base).filePath(relative)); -} - -[[maybe_unused]] void _setGstEnv(const char *name, const QString &value) -{ - qputenv(name, value.toUtf8()); - qCDebug(GStreamerLog) << " " << name << "=" << value; -} - -#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) - -void _setEnvValidationError(const QString &error) -{ - const QMutexLocker locker(&s_envPathsMutex); - s_envPathsError = error; - s_envPathsValid.store(false, std::memory_order_release); - qCCritical(GStreamerLog) << error; -} - -void _unsetEnv(const char *name) -{ - if (qEnvironmentVariableIsSet(name)) { - qunsetenv(name); - qCDebug(GStreamerLog) << " unset" << name; - } -} - -void _setGstEnvIfExists(const char *name, const QString &path) -{ - if (QFileInfo::exists(path)) { - _setGstEnv(name, path); - } -} - -bool _isExecutableFile(const QString &path) -{ - const QFileInfo fileInfo(path); - return fileInfo.exists() && fileInfo.isFile() && fileInfo.isExecutable(); -} - -QString _firstExistingPath(const QStringList &paths) -{ - for (const QString &path : paths) { - if (QFileInfo::exists(path)) { - return path; - } - } - - return {}; -} - -#if defined(Q_OS_MACOS) -QString _joinExistingPaths(const QStringList &paths) -{ - QStringList existing; - existing.reserve(paths.size()); - - for (const QString &path : paths) { - if (QFileInfo::exists(path) && !existing.contains(path)) { - existing.append(path); - } - } - - return existing.join(QDir::listSeparator()); -} -#endif - -void _clearManagedGstEnvVars() -{ - static constexpr const char *varsToUnset[] = { - "GIO_EXTRA_MODULES", - "GIO_MODULE_DIR", - "GIO_USE_VFS", - "GST_PTP_HELPER_1_0", - "GST_PTP_HELPER", - "GST_PLUGIN_SCANNER_1_0", - "GST_PLUGIN_SCANNER", - "GST_PLUGIN_SYSTEM_PATH_1_0", - "GST_PLUGIN_SYSTEM_PATH", - "GST_PLUGIN_PATH_1_0", - "GST_PLUGIN_PATH", - }; - - for (const char *name : varsToUnset) { - _unsetEnv(name); - } -} - -void _setGstEnvIfExecutable(const char *name, const QString &path) -{ - if (_isExecutableFile(path)) { - _setGstEnv(name, path); - } else { - _unsetEnv(name); - } -} - - -void _sanitizePythonEnvForScanner() -{ - static constexpr const char *varsToUnset[] = { - "PYTHONHOME", - "PYTHONPATH", - "VIRTUAL_ENV", - "CONDA_PREFIX", - "CONDA_DEFAULT_ENV", - "PYTHONUSERBASE", - }; - - for (const char *name : varsToUnset) { - _unsetEnv(name); - } - - _setGstEnv("PYTHONNOUSERSITE", QStringLiteral("1")); -} - -void _applyGstEnvVars(const QString &pluginDir, const QString &gioModDir, - const QString &scannerPath, const QString &ptpPath) -{ - qCDebug(GStreamerLog) << "Applying GStreamer environment:"; - - _sanitizePythonEnvForScanner(); - _clearManagedGstEnvVars(); - _setGstEnv("GST_REGISTRY_REUSE_PLUGIN_SCANNER", QStringLiteral("no")); - _setGstEnv("GST_REGISTRY_FORK", QStringLiteral("no")); - _setGstEnvIfExists("GIO_EXTRA_MODULES", gioModDir); - _setGstEnvIfExecutable("GST_PTP_HELPER_1_0", ptpPath); - _setGstEnvIfExecutable("GST_PTP_HELPER", ptpPath); - _setGstEnvIfExecutable("GST_PLUGIN_SCANNER_1_0", scannerPath); - _setGstEnvIfExecutable("GST_PLUGIN_SCANNER", scannerPath); - _setGstEnv("GST_PLUGIN_SYSTEM_PATH_1_0", pluginDir); - _setGstEnv("GST_PLUGIN_SYSTEM_PATH", pluginDir); - _setGstEnv("GST_PLUGIN_PATH_1_0", pluginDir); - _setGstEnv("GST_PLUGIN_PATH", pluginDir); -} - -#if defined(Q_OS_LINUX) -bool _systemGioIsNew() -{ - // Try the bare soname first — dlopen resolves it via ldconfig/LD_LIBRARY_PATH, - // which works on NixOS, Guix, and other non-FHS distros. - // Fall back to hardcoded paths for environments where the bare name fails. - static constexpr const char *kGioSoPaths[] = { - "libgio-2.0.so.0", - "/usr/lib/x86_64-linux-gnu/libgio-2.0.so.0", - "/usr/lib/aarch64-linux-gnu/libgio-2.0.so.0", - "/usr/lib/arm-linux-gnueabihf/libgio-2.0.so.0", - "/usr/lib64/libgio-2.0.so.0", - "/usr/lib/libgio-2.0.so.0", - }; - - for (const char *path : kGioSoPaths) { - void *handle = dlopen(path, RTLD_LAZY | RTLD_NOLOAD); - if (!handle) { - handle = dlopen(path, RTLD_LAZY); - } - if (!handle) { - continue; - } - const bool found = (dlsym(handle, "g_task_set_static_name") != nullptr); - dlclose(handle); - return found; - } - - return false; -} - -void _applyGioCompatOverride(const QString &gioModDir) -{ - if (gioModDir.isEmpty()) { - return; - } - - // GIO 2.76+ requires bundled modules to be loaded via GIO_MODULE_DIR with - // VFS forced to local, mirroring the AppImage launcher logic. - if (_systemGioIsNew()) { - _unsetEnv("GIO_EXTRA_MODULES"); - _setGstEnv("GIO_MODULE_DIR", gioModDir); - _setGstEnv("GIO_USE_VFS", QStringLiteral("local")); - } -} -#endif - -void _warnIfScannerMissing(const QString &platformLabel, const QString &scannerPath) -{ - if (scannerPath.isEmpty()) { - qCWarning(GStreamerLog) << "GStreamer:" << platformLabel - << "bundled gst-plugin-scanner not found; GStreamer will use in-process scanning"; - } else if (!_isExecutableFile(scannerPath)) { - qCWarning(GStreamerLog) << "GStreamer:" << platformLabel - << "gst-plugin-scanner is not executable:" << scannerPath; - } -} - -#if defined(Q_OS_MACOS) -bool _validateMacBundlePaths(const QString &bundleFrameworkRoot, - const QString &pluginDirs, - const QString &scannerPath) -{ - if (pluginDirs.isEmpty()) { - _setEnvValidationError(QStringLiteral( - "GStreamer: bundled macOS framework found but plugin directory is missing under %1") - .arg(bundleFrameworkRoot)); - return false; - } - - _warnIfScannerMissing(QStringLiteral("macOS framework"), scannerPath); - return true; + GST_PLUGIN_STATIC_REGISTER(qgc); + }); } -#endif -bool _validateBundledDesktopPaths(const QString &platformLabel, - const QString &pluginDirs, - const QString &scannerPath) +// plugin_init can fail silently; confirm the element factory is exposed so failures surface here +// rather than as a misleading "create returned nullptr" later. Common cause: iOS LTO / Android R8 +// stripping the GST_ELEMENT_REGISTER side effect. +bool requireFactory(const char* name, const char* hint) { - if (pluginDirs.isEmpty()) { - _setEnvValidationError(QStringLiteral( - "GStreamer: %1 bundled plugin directory is missing.") - .arg(platformLabel)); + const GstFactoryPtr factory = adoptFactory(gst_element_factory_find(name)); + if (!factory) { + qCCritical(GStreamerLog) << name << "factory not found —" << hint; return false; } - - _warnIfScannerMissing(platformLabel, scannerPath); + qCDebug(GStreamerLog) << name << "factory available"; return true; } -#endif // !Q_OS_ANDROID && !Q_OS_IOS - -void _setGstEnvVars() -{ - _resetEnvValidation(); - - const QString appDir = QCoreApplication::applicationDirPath(); - qCDebug(GStreamerLog) << "App directory:" << appDir; - -#if defined(Q_OS_MACOS) - const QString frameworkDir = _cleanJoin(appDir, "../Frameworks/GStreamer.framework"); - QString rootDir = _firstExistingPath({ - _cleanJoin(frameworkDir, "Versions/1.0"), - _cleanJoin(frameworkDir, "Versions/Current"), - frameworkDir, - }); - if (rootDir.isEmpty()) { - rootDir = _cleanJoin(frameworkDir, "Versions/1.0"); - } - -#if defined(QGC_GST_MACOS_FRAMEWORK) - // Framework builds prefer framework paths over app-relative paths - const QString pluginDirs = _joinExistingPaths({ - _cleanJoin(rootDir, "lib/gstreamer-1.0"), - _cleanJoin(appDir, "../lib/gstreamer-1.0"), - }); - const QString gioMod = _firstExistingPath({ - _cleanJoin(rootDir, "lib/gio/modules"), - _cleanJoin(appDir, "../lib/gio/modules"), - }); -#else - // Non-framework (Homebrew) builds prefer app-relative paths - const QString pluginDirs = _joinExistingPaths({ - _cleanJoin(appDir, "../lib/gstreamer-1.0"), - _cleanJoin(rootDir, "lib/gstreamer-1.0"), - }); - const QString gioMod = _firstExistingPath({ - _cleanJoin(appDir, "../lib/gio/modules"), - _cleanJoin(rootDir, "lib/gio/modules"), - }); -#endif - - const QString scanner = _firstExistingPath({ - _cleanJoin(appDir, "../libexec/gstreamer-1.0/gst-plugin-scanner"), - _cleanJoin(rootDir, "libexec/gstreamer-1.0/gst-plugin-scanner"), - }); - const QString ptp = _firstExistingPath({ - _cleanJoin(appDir, "../libexec/gstreamer-1.0/gst-ptp-helper"), - _cleanJoin(rootDir, "libexec/gstreamer-1.0/gst-ptp-helper"), - }); - const bool hasBundledFramework = QFileInfo::exists(frameworkDir); - - bool validBundlePaths = true; - if (!pluginDirs.isEmpty()) { - validBundlePaths = _validateBundledDesktopPaths(QStringLiteral("macOS"), pluginDirs, scanner); - } - if (hasBundledFramework) { - validBundlePaths = validBundlePaths && _validateMacBundlePaths(rootDir, pluginDirs, scanner); - } - - if (!pluginDirs.isEmpty() && validBundlePaths) { - _applyGstEnvVars(pluginDirs, gioMod, scanner, ptp); - } - -#if defined(QGC_GST_MACOS_FRAMEWORK) - if (hasBundledFramework) { - _setGstEnv("GTK_PATH", rootDir); - } -#endif - -#elif defined(Q_OS_WIN) - const QString libDir = _cleanJoin(appDir, "../lib"); - const QString pluginDir = _cleanJoin(libDir, "gstreamer-1.0"); - const QString gioMod = _cleanJoin(libDir, "gio/modules"); - const QString libexecDir = _cleanJoin(appDir, "../libexec"); - const QString scanner = _cleanJoin(libexecDir, "gstreamer-1.0/gst-plugin-scanner.exe"); - const QString ptp = _cleanJoin(libexecDir, "gstreamer-1.0/gst-ptp-helper.exe"); - - if (QFileInfo::exists(pluginDir) - && _validateBundledDesktopPaths(QStringLiteral("Windows"), pluginDir, scanner)) { - _applyGstEnvVars(pluginDir, gioMod, scanner, ptp); - - // Ensure the app's bin directory is on PATH so that child processes - // (gst-plugin-scanner.exe) can locate GStreamer DLLs installed - // alongside the main executable. - const QByteArray curPath = qgetenv("PATH"); - const QByteArray binDir = QDir::toNativeSeparators(appDir).toUtf8(); - if (!curPath.split(';').contains(binDir)) { - qputenv("PATH", binDir + ";" + curPath); - } - } - -#elif defined(Q_OS_ANDROID) - // Android uses static plugins — no GST_PLUGIN_PATH needed. But fontconfig - // and TLS need env vars pointing to the app's files/cache dirs where - // GStreamer.java copied fonts and certificates. - { - const QString filesDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); - const QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); - - if (!filesDir.isEmpty()) { - _setGstEnv("HOME", filesDir); - _setGstEnv("FONTCONFIG_PATH", _cleanJoin(filesDir, "fontconfig")); - _setGstEnv("CA_CERTIFICATES", _cleanJoin(filesDir, "ssl/certs/ca-certificates.crt")); - _setGstEnv("XDG_DATA_DIRS", filesDir); - _setGstEnv("XDG_CONFIG_DIRS", filesDir); - _setGstEnv("XDG_CONFIG_HOME", filesDir); - _setGstEnv("XDG_DATA_HOME", filesDir); - } - - if (!cacheDir.isEmpty()) { - _setGstEnv("TMP", cacheDir); - _setGstEnv("TEMP", cacheDir); - _setGstEnv("TMPDIR", cacheDir); - _setGstEnv("XDG_CACHE_HOME", cacheDir); - _setGstEnv("XDG_RUNTIME_DIR", cacheDir); - _setGstEnv("GST_REGISTRY", _cleanJoin(cacheDir, "registry.bin")); - } - - _setGstEnv("GST_REGISTRY_REUSE_PLUGIN_SCANNER", QStringLiteral("no")); - } - -#elif defined(Q_OS_LINUX) - // AppRun sets GStreamer env vars before launch (including GIO compatibility - // logic). Only apply fallback paths when no external override is present. - if (!qEnvironmentVariableIsSet("GST_PLUGIN_PATH_1_0") - && !qEnvironmentVariableIsSet("GST_PLUGIN_PATH") - && !qEnvironmentVariableIsSet("GST_PLUGIN_SYSTEM_PATH_1_0") - && !qEnvironmentVariableIsSet("GST_PLUGIN_SYSTEM_PATH")) { - const QString libDir = _cleanJoin(appDir, "../lib"); - const QString libexecDir = _cleanJoin(appDir, "../libexec"); - const QString pluginDir = _cleanJoin(libDir, "gstreamer-1.0"); - const QString gioMod = _cleanJoin(libDir, "gio/modules"); - const QString scanner = _firstExistingPath({ - _cleanJoin(libDir, "gstreamer1.0/gstreamer-1.0/gst-plugin-scanner"), - _cleanJoin(libexecDir, "gstreamer-1.0/gst-plugin-scanner"), - }); - const QString ptp = _firstExistingPath({ - _cleanJoin(libDir, "gstreamer1.0/gstreamer-1.0/gst-ptp-helper"), - _cleanJoin(libexecDir, "gstreamer-1.0/gst-ptp-helper"), - }); - - if (QFileInfo::exists(pluginDir) - && _validateBundledDesktopPaths(QStringLiteral("Linux"), pluginDir, scanner)) { - _applyGstEnvVars(pluginDir, gioMod, scanner, ptp); - _applyGioCompatOverride(gioMod); - } - } -#endif - -} - bool _verifyPlugins() { - GstRegistry *registry = gst_registry_get(); + GstRegistry* registry = gst_registry_get(); if (!registry) { qCCritical(GStreamerLog) << "Failed to get GStreamer registry"; return false; } - GList *plugins = gst_registry_get_plugin_list(registry); - if (plugins) { - qCDebug(GStreamerLog) << "Installed GStreamer plugins:"; - for (GList *node = plugins; node != nullptr; node = node->next) { - GstPlugin *plugin = static_cast(node->data); - if (plugin) { - qCDebug(GStreamerLog) << " " << gst_plugin_get_name(plugin) - << gst_plugin_get_version(plugin); - } - } - gst_plugin_list_free(plugins); - } + qCDebug(GStreamerLog) << "Installed GStreamer plugins:"; + GStreamer::forEachPlugin(registry, [](GstPlugin* plugin) { + qCDebug(GStreamerLog) << " " << gst_plugin_get_name(plugin) << gst_plugin_get_version(plugin); + }); bool result = true; // Mirror the install-time verification list so a stripped registry fails loudly here - // instead of waiting for first stream attempt with a misleading "no source element". - static constexpr const char *requiredPlugins[] = { + // instead of at first stream attempt with a misleading "no source element". + static constexpr std::array kRequiredPlugins = { "qgc", "coreelements", "playback", "rtp", "rtpmanager", "rtsp", "tcp", "udp", }; - for (const char *name : requiredPlugins) { - GstPlugin *plugin = gst_registry_find_plugin(registry, name); + for (const char* name : kRequiredPlugins) { + const GstObjectPtr plugin(GST_OBJECT(gst_registry_find_plugin(registry, name))); if (!plugin) { qCCritical(GStreamerLog) << "Required QGC plugin not found:" << name; result = false; - continue; } - gst_clear_object(&plugin); } if (!result) { - const QByteArray pluginPath = qEnvironmentVariableIsSet("GST_PLUGIN_PATH_1_0") - ? qgetenv("GST_PLUGIN_PATH_1_0") - : qgetenv("GST_PLUGIN_PATH"); - - if (!pluginPath.isEmpty()) { - qCCritical(GStreamerLog) << "Check GST_PLUGIN_PATH=" << pluginPath; - } else { - qCCritical(GStreamerLog) << "GST_PLUGIN_PATH is not set"; - } + // Surface blacklisted plugins so a failure from a corrupt/incompatible plugin file + // shows up here instead of looking like a missing-plugin problem. + GStreamer::forEachPlugin(registry, [](GstPlugin* p) { + const gchar* desc = gst_plugin_get_description(p); + if (!desc || !g_str_has_prefix(desc, "BLACKLIST")) + return; + const gchar* filename = gst_plugin_get_filename(p); + qCWarning(GStreamerLog) << "Blacklisted plugin:" << gst_plugin_get_name(p) + << "file:" << (filename ? filename : "(null)"); + }); - GList *allPlugins = gst_registry_get_plugin_list(registry); - for (GList *node = allPlugins; node != nullptr; node = node->next) { - GstPlugin *p = static_cast(node->data); - if (!p) continue; - const gchar *desc = gst_plugin_get_description(p); - const gchar *filename = gst_plugin_get_filename(p); - if (desc && g_str_has_prefix(desc, "BLACKLIST")) { - qCWarning(GStreamerLog) << "Blacklisted plugin:" << gst_plugin_get_name(p) - << "file:" << (filename ? filename : "(null)"); - } - } - gst_plugin_list_free(allPlugins); - - static constexpr const char *envDiagnostics[] = { - "GST_PLUGIN_PATH", "GST_PLUGIN_PATH_1_0", - "GST_PLUGIN_SYSTEM_PATH", "GST_PLUGIN_SYSTEM_PATH_1_0", - "GST_PLUGIN_SCANNER", "GST_PLUGIN_SCANNER_1_0", - "GST_REGISTRY_REUSE_PLUGIN_SCANNER", - }; - qCCritical(GStreamerLog) << "GStreamer environment diagnostics:"; - for (const char *var : envDiagnostics) { - const QByteArray val = qgetenv(var); - qCCritical(GStreamerLog) << " " << var << "=" << (val.isEmpty() ? "(unset)" : val.constData()); - } + // Path / scanner env vars belong to the environment layer that set them. + Environment::logDiagnostics(); } return result; } -void _logDecoderRanks() -{ - GList *factories = gst_element_factory_list_get_elements( - static_cast(GST_ELEMENT_FACTORY_TYPE_DECODER | GST_ELEMENT_FACTORY_TYPE_MEDIA_VIDEO), - GST_RANK_NONE); - - if (!factories) { - qCDebug(GStreamerDecoderRanksLog) << "No video decoder factories found"; - return; - } - - factories = g_list_sort(factories, [](gconstpointer lhs, gconstpointer rhs) -> gint { - const guint lhsRank = gst_plugin_feature_get_rank(GST_PLUGIN_FEATURE(lhs)); - const guint rhsRank = gst_plugin_feature_get_rank(GST_PLUGIN_FEATURE(rhs)); - if (lhsRank != rhsRank) { - return (lhsRank > rhsRank) ? -1 : 1; - } - return g_strcmp0(gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(lhs)), - gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(rhs))); - }); - - qCDebug(GStreamerDecoderRanksLog) << "Video decoder ranks:"; - for (GList *node = factories; node != nullptr; node = node->next) { - GstElementFactory *factory = GST_ELEMENT_FACTORY(node->data); - GstPluginFeature *feature = GST_PLUGIN_FEATURE(factory); - const gchar *featureName = gst_plugin_feature_get_name(feature); - const guint rank = gst_plugin_feature_get_rank(feature); - const gchar *klass = gst_element_factory_get_klass(factory); - const bool isHw = GStreamer::isHardwareDecoderFactory(factory); - - GstPlugin *plugin = gst_plugin_feature_get_plugin(feature); - const gchar *pluginName = plugin ? gst_plugin_get_name(plugin) : "?"; - - qCDebug(GStreamerDecoderRanksLog).noquote() - << QStringLiteral(" [%1] %2/%3 rank=%4 (%5)") - .arg(isHw ? QStringLiteral("HW") : QStringLiteral("SW"), - QString::fromUtf8(pluginName), - QString::fromUtf8(featureName)) - .arg(rank) - .arg(QString::fromUtf8(klass)); - - if (plugin) { - gst_object_unref(plugin); - } - } - - gst_plugin_feature_list_free(factories); -} - -void _configureDebugLogging() -{ - gst_debug_remove_log_function(gst_debug_log_default); - gst_debug_add_log_function(GStreamer::qtGstLog, nullptr, nullptr); - - if (!qEnvironmentVariableIsEmpty("GST_DEBUG")) { - return; - } - - QSettings settings; - if (settings.contains(AppSettings::gstDebugLevelName)) { - const int level = qBound(0, settings.value(AppSettings::gstDebugLevelName).toInt(), - static_cast(GST_LEVEL_MEMDUMP)); - gst_debug_set_default_threshold(static_cast(level)); - } -} - -} // anonymous namespace - -void setDebugLevel(int level) -{ - if (!gst_is_initialized()) { - return; - } - const int clamped = qBound(0, level, static_cast(GST_LEVEL_MEMDUMP)); - gst_debug_set_default_threshold(static_cast(clamped)); - qCDebug(GStreamerLog) << "GStreamer debug threshold set to" << clamped; -} +} // anonymous namespace -void prepareEnvironment() +Environment::ValidationResult prepareEnvironment() { - _setGstEnvVars(); + return Environment::prepareEnvironment(); } namespace { -bool _initGstRuntime() +bool _initGstRuntime(const QStringList& args, const Environment::ValidationResult& env) { - if (!s_envPathsValid.load(std::memory_order_acquire)) { - const QMutexLocker locker(&s_envPathsMutex); - qCCritical(GStreamerLog) << "Invalid GStreamer environment configuration:" << s_envPathsError; + if (!env.ok) { + qCCritical(GStreamerLog) << "Invalid GStreamer environment configuration:" << env.error; return false; } - // Cache arguments on the stack — QCoreApplication::arguments() is not thread-safe, - // but this runs early during init before concurrent access is possible. - const QStringList args = QCoreApplication::arguments(); + // args is snapshotted on the GUI thread: QCoreApplication::arguments() is not thread-safe + // and initialize() runs on a QtConcurrent pool thread. QByteArrayList argStorage; argStorage.reserve(args.size()); - for (const QString &arg : args) { + for (const QString& arg : args) { argStorage.append(arg.toUtf8()); } QVarLengthArray argv; - for (QByteArray &arg : argStorage) { + for (QByteArray& arg : argStorage) { argv.append(arg.data()); } int argc = argv.size(); - char **argvPtr = argv.data(); - GError *error = nullptr; - -#ifdef Q_OS_IOS - gst_ios_pre_init(); -#endif + char** argvPtr = argv.data(); + GError* error = nullptr; if (!gst_init_check(&argc, &argvPtr, &error)) { - qCCritical(GStreamerLog) << "Failed to initialize GStreamer:" - << (error ? error->message : "unknown error"); + qCCritical(GStreamerLog) << "Failed to initialize GStreamer:" << (error ? error->message : "unknown error"); g_clear_error(&error); return false; } -#ifdef Q_OS_IOS - gst_ios_post_init(); -#endif - return true; } -} // anonymous namespace +} // anonymous namespace bool completeInit() { @@ -793,7 +196,7 @@ bool completeInit() return false; } - _configureDebugLogging(); + GStreamer::configureDebugLogging(); guint major, minor, micro, nano; gst_version(&major, &minor, µ, &nano); @@ -801,28 +204,38 @@ bool completeInit() #ifdef QGC_GST_BUILD_VERSION_MAJOR if (major != QGC_GST_BUILD_VERSION_MAJOR || minor != QGC_GST_BUILD_VERSION_MINOR) { - qCWarning(GStreamerLog) << "GStreamer version mismatch: built against" - << QGC_GST_BUILD_VERSION_MAJOR << "." << QGC_GST_BUILD_VERSION_MINOR - << "but runtime is" << major << "." << minor << "." << micro; + qCWarning(GStreamerLog) << "GStreamer version mismatch: built against" << QGC_GST_BUILD_VERSION_MAJOR << "." + << QGC_GST_BUILD_VERSION_MINOR << "but runtime is" << major << "." << minor << "." + << micro; } #endif _registerPlugins(); +#ifdef Q_OS_IOS + // Prefer applemedia-backed sources on iOS. Must run after _registerPlugins() (registry empty before). + if (GstRegistry* reg = gst_registry_get()) { + GStreamer::changeFeatureRank(reg, "filesrc", GST_RANK_SECONDARY); + GStreamer::changeFeatureRank(reg, "giosrc", GST_RANK_SECONDARY - 1); + } +#endif + if (!_verifyPlugins()) { qCCritical(GStreamerLog) << "Plugin verification failed"; return false; } - _logDecoderRanks(); + GStreamer::logDecoderRanks(); - GstElementFactory *appsinkFactory = gst_element_factory_find("appsink"); - if (!appsinkFactory) { - qCCritical(GStreamerLog) << "appsink factory not found — videoconvert→appsink path unavailable"; + if (!requireFactory("qgcqvideosink", "sink bin will fail to construct")) { + return false; + } + if (!requireFactory("qgcvideosinkbin", + "qgc plugin registered but element exposure failed. Likely link-time symbol " + "stripping (iOS LTO / Android R8) removed the GST_ELEMENT_REGISTER side effect; " + "add gstqgcelements.cc to a -force_load / keep rule.")) { return false; } - qCDebug(GStreamerLog) << "appsink factory available (videoconvert → appsink → QVideoSink)"; - gst_object_unref(appsinkFactory); if (GStreamer::didExternalPluginLoaderFail()) { qCCritical(GStreamerLog) @@ -833,149 +246,271 @@ bool completeInit() return true; } -bool initialize() +bool initialize(const QStringList& arguments, const Environment::ValidationResult& envResult) { GStreamer::resetExternalPluginLoaderFailure(); GStreamer::redirectGLibLogging(); - // Suppress GStreamer's default stderr debug handler before gst_init_check() - // to prevent raw ANSI escape codes from corrupting the terminal on macOS. + // Suppress GStreamer's default stderr handler before gst_init_check() — raw ANSI codes + // corrupt the terminal on macOS. gst_debug_remove_log_function(gst_debug_log_default); - if (!_initGstRuntime()) { + if (!_initGstRuntime(arguments, envResult)) { return false; } return completeInit(); } -// Ownership protocol for the video sink element: -// createVideoSink — returns a floating-ref element (refcount conceptually 1). -// startDecoding — calls gst_object_ref (sinks float, refcount=1). -// _ensureVideoSinkInPipeline — gst_object_ref (+1=2), gst_bin_add (+1=3). -// _shutdownDecodingBranch — gst_bin_remove (-1=2), gst_clear_object (-1=1). -// releaseVideoSink — gst_clear_object (-1=0, freed). -void *createVideoSink(QQuickItem * /*widget*/, QObject * /*parent*/) +// Video sink refcount protocol: createVideoSink returns floating(1); ref on add to the pipeline, +// unref on removal; releaseVideoSink drops the last ref. Keep the ref/unref sites balanced. +void* createVideoSink(const VideoSinkConfig& config) { - GstElement *videoSinkBin = nullptr; // All bin tunables are construct-only — properties drive behavior, no env-var indirection. - VideoSettings *const vs = SettingsManager::instance()->videoSettings(); - const QByteArray conversionElement = vs->videoConversionElement()->rawValue().toString().toUtf8(); - const gboolean disablePar = vs->disablePixelAspectRatio()->rawValue().toBool() ? TRUE : FALSE; + const gboolean disablePar = config.disablePixelAspectRatio ? TRUE : FALSE; + const char* const conversion = config.conversionElement.isEmpty() ? nullptr : config.conversionElement.constData(); + + const GstFactoryPtr factory = adoptFactory(gst_element_factory_find("qgcvideosinkbin")); + if (!factory) { + // completeInit verified this factory at startup; absence here means the registry changed underfoot. + qCCritical(GStreamerLog) << "qgcvideosinkbin factory not found"; + return nullptr; + } + #if defined(QGC_HAS_ANY_GPU_PATH) // gpu-zerocopy is construct-only on the bin; adapter reads it back from the bin so the two halves can't desync. - // Bin defaults to gpu-zerocopy=FALSE — every GPU-capable platform must set it explicitly here or zero-copy stays off. - const bool forceCpu = vs->forceCpuVideoPath()->rawValue().toBool(); - const bool swDecoder = vs->forceVideoDecoder()->rawValue().toInt() - == GStreamer::ForceVideoDecoderSoftware; - const bool gpuZeroCopy = !forceCpu && !swDecoder; - if (GstElementFactory *factory = gst_element_factory_find("qgcvideosinkbin")) { - videoSinkBin = gst_element_factory_create_full(factory, - "gpu-zerocopy", gpuZeroCopy ? TRUE : FALSE, - "conversion-element", - conversionElement.isEmpty() ? nullptr : conversionElement.constData(), - "disable-par", disablePar, - NULL); - gst_object_unref(factory); - } + GstElement* videoSinkBin = + gst_element_factory_create_full(factory.get(), "gpu-zerocopy", config.gpuZeroCopy ? TRUE : FALSE, + "conversion-element", conversion, "disable-par", disablePar, NULL); #else - if (GstElementFactory *factory = gst_element_factory_find("qgcvideosinkbin")) { - videoSinkBin = gst_element_factory_create_full(factory, - "conversion-element", - conversionElement.isEmpty() ? nullptr : conversionElement.constData(), - "disable-par", disablePar, - NULL); - gst_object_unref(factory); - } + GstElement* videoSinkBin = gst_element_factory_create_full(factory.get(), "conversion-element", conversion, + "disable-par", disablePar, NULL); #endif if (!videoSinkBin) { - qCCritical(GStreamerLog) << "gst_element_factory_make('qgcvideosinkbin') failed"; + qCCritical(GStreamerLog) << "qgcvideosinkbin element creation failed"; } return videoSinkBin; } -void releaseVideoSink(void *sink) +void releaseVideoSink(void* sink) { - if (!sink) return; - GstElement *videoSink = GST_ELEMENT(sink); + if (!sink) + return; + GstElement* videoSink = GST_ELEMENT(sink); gst_clear_object(&videoSink); } -VideoReceiver *createVideoReceiver(QObject *parent) +VideoReceiver* createVideoReceiver(QObject* parent) { return new GstVideoReceiver(parent); } -bool setupAppSinkAdapter(void *sinkBin, QVideoSink *videoSink, QObject *adapterParent) +bool setupQVideoSinkElement(void* sinkBin, QVideoSink* videoSink, QObject* controllerParent) { - if (!sinkBin || !videoSink || !adapterParent) { - // adapterParent owns the adapter's QObject lifetime; without it the adapter - // would leak on success since the caller has no handle to destroy it. - qCWarning(GStreamerLog) << "setupAppSinkAdapter: null sinkBin, videoSink, or adapterParent"; + if (!sinkBin || !videoSink || !controllerParent) { + // controllerParent owns the controller's QObject lifetime — else it leaks (caller has no handle). + qCWarning(GStreamerLog) << "setupQVideoSinkElement: null sinkBin, videoSink, or controllerParent"; return false; } - // Idempotent re-setup: tear down any previous adapter parented under this caller - // before creating a new one, so repeated startDecoding cycles don't accumulate - // dangling adapters under the same parent. - const auto existing = adapterParent->findChildren( - QString(), Qt::FindDirectChildrenOnly); - for (GstAppSinkAdapter *old : existing) { - // setActive(false) BEFORE teardown — teardown() nulls the sink under lock and - // the empty-frame push would no-op. Order matters for the ghost-frame fix to land. - old->setActive(false); - old->teardown(); - old->deleteLater(); + // Idempotent re-setup: tear down prior controllers so repeated startDecoding cycles + // don't accumulate dangling ones. + for (auto* c : QGCQVideoSinkController::controllersOf(controllerParent)) { + c->setActive(false); + // Stop the poll timer synchronously: deleteLater is deferred and the timer would otherwise + // keep binding the same element while the new controller is being installed. + c->prepareForRelease(); + c->deleteLater(); } - // Clear the GL bridge's exhausted-retry latch so a pipeline restart that occurs after Qt's - // globalShareContext finally appeared can prime on the next NEED_CONTEXT. No-op when the - // bridge is already primed (keeps cached display/context across restarts to avoid the - // expensive re-discovery dance). -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - GstGlContextBridge::rearm(); -#endif + // Clear the GL bridge's exhausted-retry latch so a restart after Qt's globalShareContext + // appears can prime on the next NEED_CONTEXT. No-op when already primed. + HwBuffers::onPipelineRestart(); - auto *adapter = new GstAppSinkAdapter(adapterParent); - if (!adapter->setup(GST_ELEMENT(sinkBin), videoSink)) { - qCCritical(GStreamerLog) << "GstAppSinkAdapter::setup() failed"; - adapter->deleteLater(); + // Accessor returns a transfer-full ref; the guard releases it once the controller has taken + // its own ref for deferred QObject teardown. + const GstObjectPtr element(GST_OBJECT(gst_qgc_video_sink_bin_get_qvideosink(GST_QGC_VIDEO_SINK_BIN(sinkBin)))); + if (!element) { + qCCritical(GStreamerLog) << "setupQVideoSinkElement: bin has no qgcqvideosink child"; return false; } + GstElement* const elementRaw = GST_ELEMENT(element.get()); - // Hand the display refresh rate to the adapter so it can keep max-time bounded by the - // panel's redraw budget; the adapter combines this with the negotiated stream framerate - // on each caps change. setRefreshRate is a no-op when QScreen is unavailable - // (headless/early boot) — bin's 33 ms default stays in effect. - const qreal refreshHz = QGuiApplication::primaryScreen() - ? QGuiApplication::primaryScreen()->refreshRate() : 0.0; - if (refreshHz >= 1.0) { - adapter->setRefreshRate(refreshHz); +#if defined(QGC_HAS_ANY_GPU_PATH) + // Resolve the GPU context on the GUI thread and push it in, else gpu-zerocopy=TRUE negotiates + // GPU caps but show_frame still memcpys. gpu-zerocopy is the bin's property, not the inner sink's. + const gboolean gpuZeroCopy = gst_qgc_video_sink_bin_get_gpu_zerocopy(GST_ELEMENT(sinkBin)); + if (gpuZeroCopy) { + gst_qgc_q_video_sink_set_hw_context(GST_QGC_Q_VIDEO_SINK(elementRaw), HwBuffers::makeAdapterContext(true)); } +#endif + + auto* controller = new QGCQVideoSinkController(elementRaw, controllerParent); + + // Route the initial install through the controller so its destroyed-sink QPointer guard + // covers setup, not just later swaps. + controller->setVideoSink(QPointer(videoSink)); + controller->setActive(true); - // Opt-in OBS-style smoothing ring (default off). Read here so the streaming thread - // never has to dip into the SettingsManager. Pass refreshHz so the tick paces with - // the panel; the adapter falls back to 60 Hz when refreshHz is 0. - if (SettingsManager::instance()->videoSettings()->frameSmoothingEnabled()->rawValue().toBool()) { - adapter->setSmoothingEnabled(true, refreshHz); + return true; +} + +void attachAppSink(QObject* receiver, void* sink, QQuickItem* widget) +{ + if (!sink || !widget || !receiver) { + return; } - // Connect latencyChanged so the adapter re-queries immediately on RTSP jitter-buffer reconfigures. - if (auto *gstReceiver = qobject_cast(adapterParent)) { - QObject::connect(gstReceiver, &GstVideoReceiver::latencyChanged, - adapter, &GstAppSinkAdapter::requestLatencyRefresh, - Qt::DirectConnection); + + auto* videoOutput = qobject_cast(widget); + if (!videoOutput) { + qCWarning(GStreamerLog) << "Widget is not a VideoOutput, cannot connect qgcqvideosink"; + return; } - return true; + + QVideoSink* videoSink = videoOutput->videoSink(); + if (!setupQVideoSinkElement(sink, videoSink, receiver)) { + qCWarning(GStreamerLog) << "setupQVideoSinkElement failed"; + } + + QGCQVideoSinkController::syncActiveToWindowVisibility(receiver, videoOutput); } -void setAppSinkAdaptersActive(QObject *adapterParent, bool active) +void bindDebugLevelFact(Fact* fact, QObject* context) { - if (!adapterParent) return; - const auto adapters = adapterParent->findChildren( - QString(), Qt::FindDirectChildrenOnly); - for (GstAppSinkAdapter *a : adapters) { - a->setActive(active); + if (!fact || !context) + return; + QObject::connect(fact, &Fact::rawValueChanged, context, + [](const QVariant& value) { setDebugLevel(value.toInt()); }); +} + +static const char* graphicsApiName(QSGRendererInterface::GraphicsApi api) +{ + switch (api) { + case QSGRendererInterface::Software: + return "Software"; + case QSGRendererInterface::OpenGL: + return "OpenGL"; + case QSGRendererInterface::Direct3D11: + return "Direct3D11"; + case QSGRendererInterface::Direct3D12: + return "Direct3D12"; + case QSGRendererInterface::Vulkan: + return "Vulkan"; + case QSGRendererInterface::Metal: + return "Metal"; + default: + return "Unknown"; + } +} + +// Zero-copy buffer family the resolved RHI backend enables, or "CPU" when no import path is compiled for it. +static const char* zeroCopyFamilyForApi(QSGRendererInterface::GraphicsApi api) +{ + switch (api) { + case QSGRendererInterface::OpenGL: +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) || defined(QGC_HAS_GST_DMABUF_GPU_PATH) + return "GLMemory/DMABuf"; +#else + return "CPU"; +#endif + case QSGRendererInterface::Direct3D11: +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + return "D3D11"; +#else + return "CPU"; +#endif + case QSGRendererInterface::Direct3D12: + // GStreamer 1.28 can match adapter LUID but cannot wrap Qt's ID3D12Device; D3D12 zero-copy disabled until + // same-device import is possible. + return "CPU (D3D12 import disabled)"; + case QSGRendererInterface::Metal: +#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) + return "IOSurface/VideoToolbox"; +#else + return "CPU"; +#endif + case QSGRendererInterface::Vulkan: + // Vulkan import is dormant (foreign-VkDevice guard → CPU copy), so it never delivers zero-copy today. + return "CPU (Vulkan import dormant)"; + default: + return "CPU"; + } +} + +void onMainWindowReady(QQuickWindow* window) +{ + HwBuffers::connectMainWindow(window); + // Prefer the resolved backend (cachedRhi) over the configured API once the scene graph is up; QGCRhiCapture exists + // only when a GPU path is compiled, hence the guard. + QSGRendererInterface::GraphicsApi api = QQuickWindow::graphicsApi(); +#if defined(QGC_HAS_ANY_GPU_PATH) + if (QRhi* rhi = QGCRhiCapture::cachedRhi()) { + switch (rhi->backend()) { + case QRhi::OpenGLES2: api = QSGRendererInterface::OpenGL; break; + case QRhi::D3D11: api = QSGRendererInterface::Direct3D11; break; + case QRhi::D3D12: api = QSGRendererInterface::Direct3D12; break; + case QRhi::Metal: api = QSGRendererInterface::Metal; break; + case QRhi::Vulkan: api = QSGRendererInterface::Vulkan; break; + default: break; + } + } +#endif + qCInfo(GStreamerLog) << "Resolved RHI backend:" << graphicsApiName(api) << "→ zero-copy path:" + << zeroCopyFamilyForApi(api); +} + +QList availableDecoderFamilies() +{ + // One walk of the decoder factory list (mirrors prioritizeByHardwareClass) classifies each + // factory by name prefix into a VideoDecoderOptions family. + static constexpr std::array, 5> kFamilyPrefixes = {{ + {ForceVideoDecoderNVIDIA, "nv"}, + {ForceVideoDecoderVAAPI, "va"}, + {ForceVideoDecoderIntel, "qsv"}, + {ForceVideoDecoderVideoToolbox, "vtdec"}, + {ForceVideoDecoderVulkan, "vulkan"}, + }}; + + QList families; + const auto note = [&families](VideoDecoderOptions f) { + if (!families.contains(f)) + families.append(f); + }; + + GList* decoderFactories = gst_element_factory_list_get_elements( + static_cast(GST_ELEMENT_FACTORY_TYPE_DECODER | GST_ELEMENT_FACTORY_TYPE_MEDIA_VIDEO), + GST_RANK_NONE); + for (GList* node = decoderFactories; node != nullptr; node = node->next) { + GstElementFactory* factory = GST_ELEMENT_FACTORY(node->data); + if (!factory) + continue; + const gchar* name = gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(factory)); + if (!name) + continue; + + if (g_str_has_prefix(name, "msdk")) { + note(ForceVideoDecoderIntel); + continue; + } + if (g_str_has_prefix(name, "d3d11") || g_str_has_prefix(name, "d3d12") || g_str_has_prefix(name, "dxva")) { + note(ForceVideoDecoderDirectX3D); + continue; + } + // Legacy gstreamer-vaapi (vaapi*) also matches "va" but is demoted to RANK_NONE and never bumped by + // ForceVideoDecoderVAAPI (modern va* only); skip it so an unusable VAAPI option isn't kept. + if (g_str_has_prefix(name, "vaapi")) { + continue; + } + for (const auto& [family, prefix] : kFamilyPrefixes) { + if (g_str_has_prefix(name, prefix)) { + note(family); + break; + } + } } + gst_plugin_feature_list_free(decoderFactories); + + return families; } -} // namespace GStreamer +} // namespace GStreamer diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamer.h b/src/VideoManager/VideoReceiver/GStreamer/GStreamer.h index a840a23f76cf..9a825b1208d3 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamer.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamer.h @@ -1,13 +1,21 @@ #pragma once +#include +#include + +#include "GStreamerEnvironment.h" + +class Fact; +class QObject; class QQuickItem; +class QQuickWindow; class QVideoSink; class VideoReceiver; -namespace GStreamer -{ +namespace GStreamer { -enum VideoDecoderOptions { +enum VideoDecoderOptions +{ ForceVideoDecoderDefault = 0, ForceVideoDecoderSoftware, ForceVideoDecoderNVIDIA, @@ -19,21 +27,37 @@ enum VideoDecoderOptions { ForceVideoDecoderHardware }; -void prepareEnvironment(); -bool initialize(); +/// Construct-only tunables for the qgcvideosinkbin, resolved from VideoSettings by the +/// VideoBackend layer so this facade stays decoupled from the settings singleton. +struct VideoSinkConfig +{ + QByteArray conversionElement; + bool disablePixelAspectRatio = false; + bool gpuZeroCopy = false; +}; + bool completeInit(); void setDebugLevel(int level); -void *createVideoSink(QQuickItem *widget, QObject *parent = nullptr); -void releaseVideoSink(void *sink); -VideoReceiver *createVideoReceiver(QObject *parent = nullptr); +void* createVideoSink(const VideoSinkConfig& config); +void releaseVideoSink(void* sink); +VideoReceiver* createVideoReceiver(QObject* parent = nullptr); + +/// Wire @p videoSink into the qgcqvideosink element inside @p sinkBin and create a +/// QGCQVideoSinkController child of @p controllerParent. Idempotent: prior controllers +/// under the same parent are torn down first. Returns true on success. +bool setupQVideoSinkElement(void* sinkBin, QVideoSink* videoSink, QObject* controllerParent); -/// Connect the appsink inside @p sinkBin to @p videoSink. Returns true on success. -bool setupAppSinkAdapter(void *sinkBin, QVideoSink *videoSink, QObject *adapterParent); +/// Hardware decoder families currently present in the GStreamer registry, as VideoDecoderOptions +/// values (always omits ForceVideoDecoderDefault/Software). Lets the settings layer validate a +/// persisted ForceVideoDecoder* choice against what the running build can actually provide. +QList availableDecoderFamilies(); -/// Toggle every appsink adapter parented under @p adapterParent. Used to drop frames at -/// the appsink while the host window is hidden/minimized — saves CPU vs. running the -/// full decode→render path against a non-visible sink. Safe to call repeatedly; no-op -/// when no adapters exist. -void setAppSinkAdaptersActive(QObject *adapterParent, bool active); +// Process/runtime lifecycle. Reached only through VideoBackend, which is the layer that +// no-ops these for the QtMultimedia build; this header is included solely under QGC_GST_STREAMING. +Environment::ValidationResult prepareEnvironment(); +bool initialize(const QStringList& arguments, const Environment::ValidationResult& envResult); +void attachAppSink(QObject* receiver, void* sink, QQuickItem* widget); +void bindDebugLevelFact(Fact* fact, QObject* context); +void onMainWindowReady(QQuickWindow* window); -} +} // namespace GStreamer diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamerEnvironment.cc b/src/VideoManager/VideoReceiver/GStreamer/GStreamerEnvironment.cc new file mode 100644 index 000000000000..4608eda89fd3 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamerEnvironment.cc @@ -0,0 +1,583 @@ +#include "GStreamerEnvironment.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "QGCLoggingCategory.h" + +#ifdef Q_OS_LINUX +#include +#endif + +#ifdef Q_OS_ANDROID +#include +#include +#include +#include +#include +#endif + +#include + +QGC_LOGGING_CATEGORY(GStreamerEnvLog, "Video.GStreamer.Environment") + +namespace { + +static bool s_envPathsValid = true; +static QString s_envPathsError; +// Serializes the whole env mutation: qputenv/setenv aren't thread-safe vs gst_init's getenv. +// Also guards s_envPathsValid/s_envPathsError — every access is under this lock (plain types suffice). +static QMutex s_setEnvVarsMutex; + +// Single source of truth for the managed plugin-path/scanner vars: keeps _clearManagedGstEnvVars +// and logDiagnostics from drifting. Each site layers its own extras (GIO/PTP, registry) on top. +constexpr const char* kManagedGstPathVars[] = { + "GST_PLUGIN_PATH", "GST_PLUGIN_PATH_1_0", "GST_PLUGIN_SYSTEM_PATH", "GST_PLUGIN_SYSTEM_PATH_1_0", + "GST_PLUGIN_SCANNER", "GST_PLUGIN_SCANNER_1_0", +}; + +// Caller must hold s_setEnvVarsMutex. +void _resetEnvValidation() +{ + s_envPathsError.clear(); + s_envPathsValid = true; +} + +QString _cleanJoin(const QString& base, const QString& relative) +{ + return QDir::cleanPath(QDir(base).filePath(relative)); +} + +void _setGstEnv(const char* name, const QString& value) +{ + qputenv(name, value.toUtf8()); + qCDebug(GStreamerEnvLog) << " " << name << "=" << value; +} + +// Set @p var to the first existing candidate, but only if unset (explicit user/system value wins). +// @p nativePath uses native separators + 8-bit encoding, required for OpenSSL SSL_CERT_FILE on Windows. +[[maybe_unused]] void _setCaBundleIfUnset(const char* var, const QStringList& candidates, bool nativePath = false) +{ + if (!qEnvironmentVariableIsEmpty(var)) { + return; + } + for (const QString& path : candidates) { + if (QFileInfo::exists(path)) { + if (nativePath) { + const QByteArray encoded = QFile::encodeName(QDir::toNativeSeparators(path)); + qputenv(var, encoded); + qCDebug(GStreamerEnvLog) << " " << var << "=" << encoded; + } else { + _setGstEnv(var, path); + } + return; + } + } +} + +[[maybe_unused]] void _setTmpDirVars(const QString& dir) +{ + _setGstEnv("TMP", dir); + _setGstEnv("TEMP", dir); + _setGstEnv("TMPDIR", dir); +} + +#ifdef Q_OS_ANDROID +// Extract an APK asset to destPath; skip rewrite when sizes already match +// (asset bytes are immutable per APK build, so size match ⇒ same content). +bool _extractApkAsset(const char* assetPath, const QString& destPath) +{ + QJniObject ctx = QNativeInterface::QAndroidApplication::context(); + if (!ctx.isValid()) { + qCWarning(GStreamerEnvLog) << "Cannot resolve Android Context for asset extraction"; + return false; + } + + QJniObject jAssetMgr = ctx.callObjectMethod("getAssets", "()Landroid/content/res/AssetManager;"); + if (!jAssetMgr.isValid()) { + qCWarning(GStreamerEnvLog) << "Context.getAssets() returned null"; + return false; + } + + QJniEnvironment env; + AAssetManager* am = AAssetManager_fromJava(env.jniEnv(), jAssetMgr.object()); + if (!am) { + qCWarning(GStreamerEnvLog) << "AAssetManager_fromJava failed"; + return false; + } + + AAsset* asset = AAssetManager_open(am, assetPath, AASSET_MODE_BUFFER); + if (!asset) { + qCDebug(GStreamerEnvLog) << "APK asset not present:" << assetPath; + return false; + } + const off_t assetLen = AAsset_getLength(asset); + + const QFileInfo destInfo(destPath); + if (destInfo.exists() && destInfo.size() == assetLen) { + AAsset_close(asset); + return true; + } + + QDir().mkpath(destInfo.absolutePath()); + QFile out(destPath); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qCWarning(GStreamerEnvLog) << "Cannot open" << destPath << "for write:" << out.errorString(); + AAsset_close(asset); + return false; + } + + const void* buf = AAsset_getBuffer(asset); + bool ok = false; + if (buf && assetLen > 0) { + ok = (out.write(static_cast(buf), assetLen) == assetLen); + } + out.close(); + AAsset_close(asset); + if (!ok) { + qCWarning(GStreamerEnvLog) << "Failed writing asset" << assetPath << "to" << destPath; + QFile::remove(destPath); + } + return ok; +} +#endif + +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) + +// Caller must hold s_setEnvVarsMutex. +void _setEnvValidationError(const QString& error) +{ + s_envPathsError = error; + s_envPathsValid = false; + qCCritical(GStreamerEnvLog) << error; +} + +void _unsetEnv(const char* name) +{ + if (qEnvironmentVariableIsSet(name)) { + qunsetenv(name); + qCDebug(GStreamerEnvLog) << " unset" << name; + } +} + +void _setGstEnvIfExists(const char* name, const QString& path) +{ + if (QFileInfo::exists(path)) { + _setGstEnv(name, path); + } +} + +bool _isExecutableFile(const QString& path) +{ + const QFileInfo fileInfo(path); + return fileInfo.exists() && fileInfo.isFile() && fileInfo.isExecutable(); +} + +QString _firstExistingPath(const QStringList& paths) +{ + for (const QString& path : paths) { + if (QFileInfo::exists(path)) { + return path; + } + } + + return {}; +} + +#if defined(Q_OS_MACOS) +QString _joinExistingPaths(const QStringList& paths) +{ + QStringList existing; + existing.reserve(paths.size()); + + for (const QString& path : paths) { + if (QFileInfo::exists(path) && !existing.contains(path)) { + existing.append(path); + } + } + + return existing.join(QDir::listSeparator()); +} +#endif + +void _clearManagedGstEnvVars() +{ + static constexpr const char* gioPtpVars[] = { + "GIO_EXTRA_MODULES", "GIO_MODULE_DIR", "GIO_USE_VFS", "GST_PTP_HELPER_1_0", "GST_PTP_HELPER", + }; + + for (const char* name : gioPtpVars) { + _unsetEnv(name); + } + for (const char* name : kManagedGstPathVars) { + _unsetEnv(name); + } +} + +void _setGstEnvIfExecutable(const char* name, const QString& path) +{ + if (_isExecutableFile(path)) { + _setGstEnv(name, path); + } else { + _unsetEnv(name); + } +} + +void _sanitizePythonEnvForScanner() +{ + static constexpr const char* varsToUnset[] = { + "PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV", "CONDA_PREFIX", "CONDA_DEFAULT_ENV", "PYTHONUSERBASE", + }; + + for (const char* name : varsToUnset) { + _unsetEnv(name); + } + + _setGstEnv("PYTHONNOUSERSITE", QStringLiteral("1")); +} + +void _applyGstEnvVars(const QString& pluginDir, const QString& gioModDir, const QString& scannerPath, + const QString& ptpPath) +{ + qCDebug(GStreamerEnvLog) << "Applying GStreamer environment:"; + + _sanitizePythonEnvForScanner(); + _clearManagedGstEnvVars(); + _setGstEnv("GST_REGISTRY_REUSE_PLUGIN_SCANNER", QStringLiteral("no")); + _setGstEnv("GST_REGISTRY_FORK", QStringLiteral("no")); + _setGstEnvIfExists("GIO_EXTRA_MODULES", gioModDir); + _setGstEnvIfExecutable("GST_PTP_HELPER_1_0", ptpPath); + _setGstEnvIfExecutable("GST_PTP_HELPER", ptpPath); + _setGstEnvIfExecutable("GST_PLUGIN_SCANNER_1_0", scannerPath); + _setGstEnvIfExecutable("GST_PLUGIN_SCANNER", scannerPath); + _setGstEnv("GST_PLUGIN_SYSTEM_PATH_1_0", pluginDir); + _setGstEnv("GST_PLUGIN_SYSTEM_PATH", pluginDir); + _setGstEnv("GST_PLUGIN_PATH_1_0", pluginDir); + _setGstEnv("GST_PLUGIN_PATH", pluginDir); +} + +#if defined(Q_OS_LINUX) +bool _systemGioIsNew() +{ + // Bare soname first (ldconfig/LD_LIBRARY_PATH, works on NixOS/Guix non-FHS), then + // hardcoded paths where the bare name fails. + static constexpr const char* kGioSoPaths[] = { + "libgio-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libgio-2.0.so.0", + "/usr/lib/aarch64-linux-gnu/libgio-2.0.so.0", + "/usr/lib/arm-linux-gnueabihf/libgio-2.0.so.0", + "/usr/lib64/libgio-2.0.so.0", + "/usr/lib/libgio-2.0.so.0", + }; + + for (const char* path : kGioSoPaths) { + void* handle = dlopen(path, RTLD_LAZY | RTLD_NOLOAD); + if (!handle) { + handle = dlopen(path, RTLD_LAZY); + } + if (!handle) { + continue; + } + const bool found = (dlsym(handle, "g_task_set_static_name") != nullptr); + dlclose(handle); + return found; + } + + return false; +} + +void _applyGioCompatOverride(const QString& gioModDir) +{ + if (gioModDir.isEmpty()) { + return; + } + + // GIO 2.76+ needs bundled modules via GIO_MODULE_DIR with VFS forced local (AppImage launcher logic). + if (_systemGioIsNew()) { + _unsetEnv("GIO_EXTRA_MODULES"); + _setGstEnv("GIO_MODULE_DIR", gioModDir); + _setGstEnv("GIO_USE_VFS", QStringLiteral("local")); + } +} +#endif + +void _warnIfScannerMissing(const QString& platformLabel, const QString& scannerPath) +{ + if (scannerPath.isEmpty()) { + qCWarning(GStreamerEnvLog) << "GStreamer:" << platformLabel + << "bundled gst-plugin-scanner not found; GStreamer will use in-process scanning"; + } else if (!_isExecutableFile(scannerPath)) { + qCWarning(GStreamerEnvLog) << "GStreamer:" << platformLabel + << "gst-plugin-scanner is not executable:" << scannerPath; + } +} + +#if defined(Q_OS_MACOS) +void _reportMissingMacFrameworkPlugins(const QString& bundleFrameworkRoot) +{ + _setEnvValidationError( + QStringLiteral("GStreamer: bundled macOS framework found but plugin directory is missing under %1") + .arg(bundleFrameworkRoot)); +} +#endif + +bool _validateBundledDesktopPaths(const QString& platformLabel, const QString& pluginDirs, const QString& scannerPath) +{ + if (pluginDirs.isEmpty()) { + _setEnvValidationError(QStringLiteral("GStreamer: %1 bundled plugin directory is missing.").arg(platformLabel)); + return false; + } + + _warnIfScannerMissing(platformLabel, scannerPath); + return true; +} + +#endif // !Q_OS_ANDROID && !Q_OS_IOS + +void _setGstEnvVars() +{ + _resetEnvValidation(); + + const QString appDir = QCoreApplication::applicationDirPath(); + qCDebug(GStreamerEnvLog) << "App directory:" << appDir; + +#if defined(Q_OS_MACOS) + const QString frameworkDir = _cleanJoin(appDir, "../Frameworks/GStreamer.framework"); + QString rootDir = _firstExistingPath({ + _cleanJoin(frameworkDir, "Versions/1.0"), + _cleanJoin(frameworkDir, "Versions/Current"), + frameworkDir, + }); + if (rootDir.isEmpty()) { + rootDir = _cleanJoin(frameworkDir, "Versions/1.0"); + } + +#if defined(QGC_GST_MACOS_FRAMEWORK) + // Framework builds prefer framework paths over app-relative paths + const QString pluginDirs = _joinExistingPaths({ + _cleanJoin(rootDir, "lib/gstreamer-1.0"), + _cleanJoin(appDir, "../lib/gstreamer-1.0"), + }); + const QString gioMod = _firstExistingPath({ + _cleanJoin(rootDir, "lib/gio/modules"), + _cleanJoin(appDir, "../lib/gio/modules"), + }); +#else + // Non-framework (Homebrew) builds prefer app-relative paths + const QString pluginDirs = _joinExistingPaths({ + _cleanJoin(appDir, "../lib/gstreamer-1.0"), + _cleanJoin(rootDir, "lib/gstreamer-1.0"), + }); + const QString gioMod = _firstExistingPath({ + _cleanJoin(appDir, "../lib/gio/modules"), + _cleanJoin(rootDir, "lib/gio/modules"), + }); +#endif + + const QString scanner = _firstExistingPath({ + _cleanJoin(appDir, "../libexec/gstreamer-1.0/gst-plugin-scanner"), + _cleanJoin(rootDir, "libexec/gstreamer-1.0/gst-plugin-scanner"), + }); + const QString ptp = _firstExistingPath({ + _cleanJoin(appDir, "../libexec/gstreamer-1.0/gst-ptp-helper"), + _cleanJoin(rootDir, "libexec/gstreamer-1.0/gst-ptp-helper"), + }); + const bool hasBundledFramework = QFileInfo::exists(frameworkDir); + + bool validBundlePaths = true; + if (pluginDirs.isEmpty()) { + if (hasBundledFramework) { + _reportMissingMacFrameworkPlugins(rootDir); + validBundlePaths = false; + } + } else { + validBundlePaths = _validateBundledDesktopPaths(QStringLiteral("macOS"), pluginDirs, scanner); + } + + if (!pluginDirs.isEmpty() && validBundlePaths) { + _applyGstEnvVars(pluginDirs, gioMod, scanner, ptp); + } + +#if defined(QGC_GST_MACOS_FRAMEWORK) + if (hasBundledFramework) { + _setGstEnv("GTK_PATH", rootDir); + } +#endif + + // libgioopenssl's compiled-in CA path is the Cerbero prefix; repoint OpenSSL at the + // bundled file (framework Versions/1.0/etc vs non-framework Contents/Resources/etc). + _setCaBundleIfUnset("SSL_CERT_FILE", { + _cleanJoin(rootDir, "etc/ssl/certs/ca-certificates.crt"), + _cleanJoin(appDir, "../Resources/etc/ssl/certs/ca-certificates.crt"), + }); + +#elif defined(Q_OS_WIN) + const QString libDir = _cleanJoin(appDir, "../lib"); + const QString pluginDir = _cleanJoin(libDir, "gstreamer-1.0"); + const QString gioMod = _cleanJoin(libDir, "gio/modules"); + const QString libexecDir = _cleanJoin(appDir, "../libexec"); + const QString scanner = _cleanJoin(libexecDir, "gstreamer-1.0/gst-plugin-scanner.exe"); + const QString ptp = _cleanJoin(libexecDir, "gstreamer-1.0/gst-ptp-helper.exe"); + + if (QFileInfo::exists(pluginDir) && _validateBundledDesktopPaths(QStringLiteral("Windows"), pluginDir, scanner)) { + _applyGstEnvVars(pluginDir, gioMod, scanner, ptp); + + // Put the app's bin dir on PATH so child processes (gst-plugin-scanner.exe) can locate + // GStreamer DLLs installed alongside the main executable. + const QByteArray curPath = qgetenv("PATH"); + const QByteArray binDir = QDir::toNativeSeparators(appDir).toUtf8(); + bool alreadyOnPath = false; + for (const QByteArray &entry : curPath.split(';')) { + // Windows paths are case-insensitive; compare accordingly to avoid duplicate prepends. + if (entry.compare(binDir, Qt::CaseInsensitive) == 0) { + alreadyOnPath = true; + break; + } + } + if (!alreadyOnPath) { + qputenv("PATH", binDir + ";" + curPath); + } + + // gioopenssl.dll's compiled-in CA path is relative to the Cerbero SDK root and breaks once + // detached; point OpenSSL at the bundled CA file (installed by gstreamer_install_windows_sdk). + _setCaBundleIfUnset("SSL_CERT_FILE", {_cleanJoin(appDir, "../etc/ssl/certs/ca-certificates.crt")}, + /*nativePath=*/true); + } + +#elif defined(Q_OS_IOS) + // Static plugins — no GST_PLUGIN_PATH; bundle resources read-only, scratch in sandbox. CA + // bundle (MACOSX_PACKAGE_LOCATION) is consumed by qgc_load_gio_modules_and_ca() at static init. + { + const QString resources = appDir; + const QString docs = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + const QString cache = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + const QString tmp = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + + if (!tmp.isEmpty()) { + _setTmpDirVars(tmp); + } + if (!cache.isEmpty()) { + _setGstEnv("XDG_CACHE_HOME", cache); + _setGstEnv("XDG_CONFIG_HOME", cache); + // Writable-cache registry, no fork rescan: sandbox rejects fork, static plugins need no scanner. + _setGstEnv("GST_REGISTRY", _cleanJoin(cache, "registry.bin")); + _setGstEnv("GST_REGISTRY_FORK", QStringLiteral("no")); + } + // Tutorial 5 binds XDG_RUNTIME_DIR to the read-only bundle; libGStreamer never writes there on iOS. + if (!resources.isEmpty()) { + _setGstEnv("XDG_RUNTIME_DIR", resources); + _setGstEnv("XDG_DATA_DIRS", resources); + _setGstEnv("XDG_CONFIG_DIRS", resources); + _setGstEnv("XDG_DATA_HOME", resources); + } + if (!docs.isEmpty()) { + _setGstEnv("HOME", docs); + } + + if (!resources.isEmpty()) { + _setCaBundleIfUnset("CA_CERTIFICATES", {_cleanJoin(resources, "ssl/certs/ca-certificates.crt")}); + } + } + +#elif defined(Q_OS_ANDROID) + // Static plugins — no GST_PLUGIN_PATH; APK assets are read-only, so the CA bundle is extracted + // to filesDir on first launch (we run gst_init from C++, not Java's GStreamer.init). + { + const QString filesDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + const QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + + if (!filesDir.isEmpty()) { + _setGstEnv("HOME", filesDir); + _setGstEnv("FONTCONFIG_PATH", _cleanJoin(filesDir, "fontconfig")); + const QString caBundle = _cleanJoin(filesDir, "ssl/certs/ca-certificates.crt"); + if (_extractApkAsset("ssl/certs/ca-certificates.crt", caBundle) && + qEnvironmentVariableIsEmpty("CA_CERTIFICATES")) { + _setGstEnv("CA_CERTIFICATES", caBundle); + } + _setGstEnv("XDG_DATA_DIRS", filesDir); + _setGstEnv("XDG_CONFIG_DIRS", filesDir); + _setGstEnv("XDG_CONFIG_HOME", filesDir); + _setGstEnv("XDG_DATA_HOME", filesDir); + } + + if (!cacheDir.isEmpty()) { + _setTmpDirVars(cacheDir); + _setGstEnv("XDG_CACHE_HOME", cacheDir); + _setGstEnv("XDG_RUNTIME_DIR", cacheDir); + _setGstEnv("GST_REGISTRY", _cleanJoin(cacheDir, "registry.bin")); + } + + _setGstEnv("GST_REGISTRY_REUSE_PLUGIN_SCANNER", QStringLiteral("no")); + } + +#elif defined(Q_OS_LINUX) + // AppRun sets GStreamer env vars before launch; apply fallbacks only with no external override. + if (!qEnvironmentVariableIsSet("GST_PLUGIN_PATH_1_0") && !qEnvironmentVariableIsSet("GST_PLUGIN_PATH") && + !qEnvironmentVariableIsSet("GST_PLUGIN_SYSTEM_PATH_1_0") && + !qEnvironmentVariableIsSet("GST_PLUGIN_SYSTEM_PATH")) { + const QString libDir = _cleanJoin(appDir, "../lib"); + const QString libexecDir = _cleanJoin(appDir, "../libexec"); + const QString pluginDir = _cleanJoin(libDir, "gstreamer-1.0"); + const QString gioMod = _cleanJoin(libDir, "gio/modules"); + const QString scanner = _firstExistingPath({ + _cleanJoin(libDir, "gstreamer1.0/gstreamer-1.0/gst-plugin-scanner"), + _cleanJoin(libexecDir, "gstreamer-1.0/gst-plugin-scanner"), + }); + const QString ptp = _firstExistingPath({ + _cleanJoin(libDir, "gstreamer1.0/gstreamer-1.0/gst-ptp-helper"), + _cleanJoin(libexecDir, "gstreamer-1.0/gst-ptp-helper"), + }); + + if (QFileInfo::exists(pluginDir) && _validateBundledDesktopPaths(QStringLiteral("Linux"), pluginDir, scanner)) { + _applyGstEnvVars(pluginDir, gioMod, scanner, ptp); + _applyGioCompatOverride(gioMod); + } + } +#endif +} + +} // anonymous namespace + +namespace GStreamer::Environment { + +ValidationResult prepareEnvironment() +{ + // qputenv is not thread-safe against concurrent getenv; this must run on the GUI thread before + // the QtConcurrent init worker spawns. Warn loudly rather than corrupt the environment silently. + if (QCoreApplication::instance() && (QThread::currentThread() != QCoreApplication::instance()->thread())) { + qCWarning(GStreamerEnvLog) << "prepareEnvironment() called off the GUI thread; qputenv is not thread-safe"; + } + const QMutexLocker setEnvLocker(&s_setEnvVarsMutex); + _setGstEnvVars(); + return {s_envPathsValid, s_envPathsError}; +} + +void logDiagnostics() +{ + // Echo GST_PLUGIN_PATH first (most-misconfigured); _1_0-suffixed wins per gstregistry.c lookup order. + const QByteArray pluginPath = + qEnvironmentVariableIsSet("GST_PLUGIN_PATH_1_0") ? qgetenv("GST_PLUGIN_PATH_1_0") : qgetenv("GST_PLUGIN_PATH"); + if (!pluginPath.isEmpty()) { + qCCritical(GStreamerEnvLog) << "Check GST_PLUGIN_PATH=" << pluginPath; + } else { + qCCritical(GStreamerEnvLog) << "GST_PLUGIN_PATH is not set"; + } + + qCCritical(GStreamerEnvLog) << "GStreamer environment diagnostics:"; + const auto dumpVar = [](const char* var) { + const QByteArray val = qgetenv(var); + qCCritical(GStreamerEnvLog) << " " << var << "=" << (val.isEmpty() ? "(unset)" : val.constData()); + }; + for (const char* var : kManagedGstPathVars) { + dumpVar(var); + } + dumpVar("GST_REGISTRY_REUSE_PLUGIN_SCANNER"); +} + +} // namespace GStreamer::Environment diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamerEnvironment.h b/src/VideoManager/VideoReceiver/GStreamer/GStreamerEnvironment.h new file mode 100644 index 000000000000..dd5a12362573 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamerEnvironment.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +/// Process-wide GStreamer environment setup: plugin-path discovery, GIO compat override, +/// env var management, APK asset extraction, scanner sanitization. +namespace GStreamer::Environment { + +/// Outcome of environment preparation: whether the discovered plugin/scanner +/// paths validated, plus a human-readable error when they did not. +struct ValidationResult +{ + bool ok = true; + QString error; +}; + +/// Apply all GStreamer env vars for this process from bundled-paths discovery + platform +/// validation. Idempotent: clears prior managed vars first so a retry starts clean. Returns the +/// validation outcome by value (threaded explicitly to initialize(), not a cross-thread global). +ValidationResult prepareEnvironment(); + +/// Dump the GST_PLUGIN_*/GST_REGISTRY_* vars managed by this layer to the log at critical level. +/// Called from plugin-verification failure paths so a stripped registry shows how the loader was +/// configured. +void logDiagnostics(); + +} // namespace GStreamer::Environment diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.cc b/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.cc index 8e96751d3e93..c930867d8862 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.cc @@ -1,54 +1,136 @@ #include "GStreamerHelpers.h" -#include "QGCLoggingCategory.h" -#include +#include +#include +#include +#include +#include #include +#include #include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#include +#if defined(QGC_HAS_ANY_GPU_PATH) +#include + +#include "HwBuffers/common/QGCRhiCapture.h" +#endif +#endif + +#include "GStreamer.h" +#include "GstScoped.h" +#include "QGCLoggingCategory.h" QGC_LOGGING_CATEGORY(GStreamerHelpersLog, "Video.GStreamer.GStreamerHelpers") -namespace GStreamer -{ +namespace GStreamer { -gboolean -isValidRtspUri(const gchar *uri_str) +bool isValidRtspUri(const gchar* uri_str) { if (!uri_str) { - return FALSE; + return false; } - GstRTSPUrl *url = NULL; - GstRTSPResult res; - if (!gst_uri_is_valid(uri_str)) { - return FALSE; + return false; } - res = gst_rtsp_url_parse(uri_str, &url); - if ((res != GST_RTSP_OK) || (url == NULL)) { + GstRTSPUrl* url = nullptr; + const GstRTSPResult res = gst_rtsp_url_parse(uri_str, &url); + if ((res != GST_RTSP_OK) || !url) { if (url) { gst_rtsp_url_free(url); } - return FALSE; + return false; } - const gboolean hasHost = (url->host && url->host[0] != '\0'); + const bool hasHost = (url->host && url->host[0] != '\0'); gst_rtsp_url_free(url); return hasHost; } -bool isHardwareDecoderFactory(GstElementFactory *factory) +QString writePipelineDot(GstElement* pipeline, const char* tag) +{ + // kMaxDotFiles: cap retained .dot pipeline graphs; below 5 makes retro-debugging hard. + constexpr int kMaxDotFiles = 10; + + if (!pipeline) + return {}; + if (!qgetenv("GST_DEBUG_DUMP_DOT_DIR").isEmpty()) { + return {}; + } + const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + if (cacheRoot.isEmpty()) + return {}; + QDir dir(cacheRoot + QStringLiteral("/qgc-pipeline-dot")); + if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) { + qCWarning(GStreamerHelpersLog) << "Failed to create" << dir.absolutePath(); + return {}; + } + + // Rotate: remove oldest .dot files until under cap. Sort by mtime ascending. + QFileInfoList existing = dir.entryInfoList(QStringList{QStringLiteral("*.dot")}, + QDir::Files, QDir::Time | QDir::Reversed); + while (existing.size() >= kMaxDotFiles) { + QFile::remove(existing.takeFirst().absoluteFilePath()); + } + + gchar* data = gst_debug_bin_to_dot_data(GST_BIN(pipeline), GST_DEBUG_GRAPH_SHOW_ALL); + if (!data) + return {}; + const QString fileName = QStringLiteral("%1-%2.dot") + .arg(QString::fromLatin1(tag), + QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd-HHmmss-zzz"))); + const QString fullPath = dir.absoluteFilePath(fileName); + const QByteArray dotData = QByteArray::fromRawData(data, static_cast(qstrlen(data))); + bool wrote = false; + QFile out(fullPath); + if (out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + wrote = (out.write(dotData) == static_cast(dotData.size())); + if (!out.flush()) { + wrote = false; + } + out.close(); + if (out.error() != QFileDevice::NoError) { + wrote = false; + } + } + g_free(data); + return wrote ? fullPath : QString{}; +} + +void forEachPlugin(GstRegistry* registry, const std::function& visitor) +{ + if (!registry || !visitor) + return; + GList* plugins = gst_registry_get_plugin_list(registry); + for (GList* node = plugins; node != nullptr; node = node->next) { + GstPlugin* plugin = static_cast(node->data); + if (plugin) + visitor(plugin); + } + gst_plugin_list_free(plugins); +} + +bool isHardwareDecoderFactory(GstElementFactory* factory) { if (!factory) { return false; } - const gchar *factoryName = gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(factory)); + const gchar* factoryName = gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(factory)); if (!factoryName) { return false; } - const QString nameLower = QString::fromUtf8(factoryName).toLower(); + const QByteArray nameLower = QByteArray::fromRawData(factoryName, qstrlen(factoryName)).toLower(); // Android MediaCodec: exclude software wrappers, accept remaining as hardware if (nameLower.startsWith("amcviddec-omxgoogle") || nameLower.startsWith("amcviddec-c2android")) { @@ -58,9 +140,10 @@ bool isHardwareDecoderFactory(GstElementFactory *factory) return true; } - const auto containsHardware = [](const gchar *value) { - if (!value) return false; - gchar *lower = g_ascii_strdown(value, -1); + const auto containsHardware = [](const gchar* value) { + if (!value) + return false; + gchar* lower = g_ascii_strdown(value, -1); bool found = (g_strrstr(lower, "hardware") != nullptr); g_free(lower); return found; @@ -74,19 +157,11 @@ bool isHardwareDecoderFactory(GstElementFactory *factory) return true; } - static constexpr QLatin1String kHardwareTags[] = { - QLatin1String("va"), - QLatin1String("nv"), - QLatin1String("qsv"), - QLatin1String("msdk"), - QLatin1String("vulkan"), - QLatin1String("d3d"), - QLatin1String("dxva"), - QLatin1String("vtdec"), - QLatin1String("metal"), + static constexpr const char* kHardwareTags[] = { + "va", "nv", "qsv", "msdk", "vulkan", "d3d", "dxva", "vtdec", "metal", }; - for (const auto &tag : kHardwareTags) { + for (const auto& tag : kHardwareTags) { if (nameLower.contains(tag)) { return true; } @@ -95,26 +170,48 @@ bool isHardwareDecoderFactory(GstElementFactory *factory) return false; } -namespace { - -void changeFeatureRank(GstRegistry *registry, const char *featureName, uint16_t rank) +bool changeFeatureRank(GstRegistry* registry, const char* featureName, uint16_t rank) { if (!registry || !featureName) { - return; + return false; } - GstPluginFeature *feature = gst_registry_lookup_feature(registry, featureName); + const GstFeaturePtr feature = adoptFeature(gst_registry_lookup_feature(registry, featureName)); if (!feature) { - qCDebug(GStreamerHelpersLog) << "Failed to change ranking of feature. Feature does not exist:" << featureName; - return; + return false; } qCDebug(GStreamerHelpersLog) << " Changing feature (" << featureName << ") to use rank:" << rank; - gst_plugin_feature_set_rank(feature, rank); - gst_clear_object(&feature); + gst_plugin_feature_set_rank(feature.get(), rank); + return true; +} + +namespace { + +void applyRanks(GstRegistry* registry, std::span features, uint16_t rank) +{ + for (const char* name : features) { + changeFeatureRank(registry, name, rank); + } } -void lowerDecoderRanksByClass(GstRegistry *registry, bool lowerHardware) +// Decoder-family rank tables, shared by setCodecPriorities and the zero-copy steering below. +constexpr std::array kVaDecoders = {"vaav1dec", "vah264dec", "vah265dec", "vajpegdec", + "vampeg2dec", "vavp8dec", "vavp9dec"}; +constexpr std::array kNvidiaDecoders = {"nvav1dec", "nvh264dec", "nvh265dec", + "nvjpegdec", "nvmpeg2videodec", "nvmpeg4videodec", + "nvmpegvideodec", "nvvp8dec", "nvvp9dec"}; +constexpr std::array kDirectX3DDecoders = { + "d3d11av1dec", "d3d11h264dec", "d3d11h265dec", "d3d11mpeg2dec", "d3d11vp8dec", "d3d11vp9dec", + "d3d12av1dec", "d3d12h264dec", "d3d12h265dec", "d3d12mpeg2dec", "d3d12vp8dec", "d3d12vp9dec", + "dxvaav1decoder", "dxvah264decoder", "dxvah265decoder", "dxvampeg2decoder", "dxvavp8decoder", "dxvavp9decoder"}; +constexpr std::array kVideoToolboxDecoders = {"vtdec_hw", "vtdec"}; +constexpr std::array kIntelDecoders = { + "qsvh264dec", "qsvh265dec", "qsvjpegdec", "qsvvp9dec", "msdkav1dec", "msdkh264dec", + "msdkh265dec", "msdkmjpegdec", "msdkmpeg2dec", "msdkvc1dec", "msdkvp8dec", "msdkvp9dec"}; +constexpr std::array kVulkanDecoders = {"vulkanh264dec", "vulkanh265dec"}; + +void lowerDecoderRanksByClass(GstRegistry* registry, bool lowerHardware) { static constexpr uint16_t NewRank = GST_RANK_NONE; if (!registry) { @@ -122,12 +219,12 @@ void lowerDecoderRanksByClass(GstRegistry *registry, bool lowerHardware) return; } - GList *decoderFactories = gst_element_factory_list_get_elements( + GList* decoderFactories = gst_element_factory_list_get_elements( static_cast(GST_ELEMENT_FACTORY_TYPE_DECODER | GST_ELEMENT_FACTORY_TYPE_MEDIA_VIDEO), - GST_RANK_NONE); + GST_RANK_MARGINAL); - for (GList *node = decoderFactories; node != nullptr; node = node->next) { - GstElementFactory *factory = GST_ELEMENT_FACTORY(node->data); + for (GList* node = decoderFactories; node != nullptr; node = node->next) { + GstElementFactory* factory = GST_ELEMENT_FACTORY(node->data); if (!factory) { continue; } @@ -136,40 +233,41 @@ void lowerDecoderRanksByClass(GstRegistry *registry, bool lowerHardware) continue; } - const gchar *name = gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(factory)); + const gchar* name = gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(factory)); if (!name) { continue; } - qCDebug(GStreamerHelpersLog) << "Lowering" << (lowerHardware ? "hardware" : "software") << "decoder rank:" << name; + qCDebug(GStreamerHelpersLog) << "Lowering" << (lowerHardware ? "hardware" : "software") + << "decoder rank:" << name; gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(factory), NewRank); } gst_plugin_feature_list_free(decoderFactories); } -void prioritizeByHardwareClass(GstRegistry *registry, uint16_t prioritizedRank, bool requireHardware) +void prioritizeByHardwareClass(GstRegistry* registry, uint16_t prioritizedRank, bool requireHardware) { if (!registry) { qCCritical(GStreamerHelpersLog) << "Failed to get gstreamer registry."; return; } - GList *decoderFactories = gst_element_factory_list_get_elements( + GList* decoderFactories = gst_element_factory_list_get_elements( static_cast(GST_ELEMENT_FACTORY_TYPE_DECODER | GST_ELEMENT_FACTORY_TYPE_MEDIA_VIDEO), GST_RANK_NONE); if (!decoderFactories) { qCDebug(GStreamerHelpersLog) << "No decoder factories available while prioritizing" - << (requireHardware ? "hardware" : "software") << "decoders"; + << (requireHardware ? "hardware" : "software") << "decoders"; return; } qCDebug(GStreamerHelpersLog) << "Prioritizing" << (requireHardware ? "hardware" : "software") - << "video decoders with rank:" << prioritizedRank; + << "video decoders with rank:" << prioritizedRank; int matchedFactories = 0; - for (GList *node = decoderFactories; node != nullptr; node = node->next) { - GstElementFactory *factory = GST_ELEMENT_FACTORY(node->data); + for (GList* node = decoderFactories; node != nullptr; node = node->next) { + GstElementFactory* factory = GST_ELEMENT_FACTORY(node->data); if (!factory) { continue; } @@ -178,7 +276,7 @@ void prioritizeByHardwareClass(GstRegistry *registry, uint16_t prioritizedRank, continue; } - const gchar *featureName = gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(factory)); + const gchar* featureName = gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(factory)); if (!featureName) { continue; } @@ -189,7 +287,7 @@ void prioritizeByHardwareClass(GstRegistry *registry, uint16_t prioritizedRank, if (matchedFactories == 0) { qCWarning(GStreamerHelpersLog) << "No" << (requireHardware ? "hardware" : "software") - << "video decoder factories found to reprioritize."; + << "video decoder factories found to reprioritize."; } qCDebug(GStreamerHelpersLog) << "Lowering" << (requireHardware ? "software" : "hardware") << "decoder ranks."; @@ -198,11 +296,91 @@ void prioritizeByHardwareClass(GstRegistry *registry, uint16_t prioritizedRank, gst_plugin_feature_list_free(decoderFactories); } -} // anonymous namespace +#ifdef Q_OS_WIN +// A decoder whose D3D family mismatches the active QRhi backend can't be sampled zero-copy and drops to a CPU copy. +void alignD3DDecoderRanksToRhi(GstRegistry* registry, bool promoteMatchedFamily) +{ + // Prefer the resolved QRhi backend; ranks usually apply before the scene graph resolves, so cachedRhi() is often + // null here and graphicsApi() Unknown — fall back to QSG_RHI_BACKEND (Qt's D3D11 default) in that Windows case. + // QGCRhiCapture/qrhi.h link only when a GPU path is compiled (GuiPrivate), so the resolved-backend lookup is gated; + // a no-GPU Windows build relies on graphicsApi()/QSG_RHI_BACKEND alone. + bool wantD3D12 = false; + bool resolved = false; +#if defined(QGC_HAS_ANY_GPU_PATH) + if (QRhi* rhi = QGCRhiCapture::cachedRhi()) { + wantD3D12 = (rhi->backend() == QRhi::D3D12); + resolved = true; + } +#endif + if (!resolved) { + switch (QQuickWindow::graphicsApi()) { + case QSGRendererInterface::Direct3D12: + wantD3D12 = true; + break; + case QSGRendererInterface::Direct3D11: + wantD3D12 = false; + break; + default: + wantD3D12 = + qEnvironmentVariable("QSG_RHI_BACKEND").compare(QLatin1String("d3d12"), Qt::CaseInsensitive) == 0; + break; + } + } + static constexpr const char* kD3D11Decoders[] = {"d3d11av1dec", "d3d11h264dec", "d3d11h265dec", + "d3d11mpeg2dec", "d3d11vp8dec", "d3d11vp9dec"}; + static constexpr const char* kD3D12Decoders[] = {"d3d12av1dec", "d3d12h264dec", "d3d12h265dec", + "d3d12mpeg2dec", "d3d12vp8dec", "d3d12vp9dec"}; + const char* const* matched = wantD3D12 ? kD3D12Decoders : kD3D11Decoders; + const char* const* mismatched = wantD3D12 ? kD3D11Decoders : kD3D12Decoders; + qCDebug(GStreamerHelpersLog) << "Aligning D3D decoder ranks to" << (wantD3D12 ? "D3D12" : "D3D11") + << "RHI - demoting the mismatched decoder family"; + static_assert(std::size(kD3D11Decoders) == std::size(kD3D12Decoders)); + if (promoteMatchedFamily) { + static constexpr uint16_t ZeroCopyRank = GST_RANK_PRIMARY + 2; + for (size_t i = 0; i < std::size(kD3D11Decoders); ++i) { + changeFeatureRank(registry, matched[i], ZeroCopyRank); + } + } + for (size_t i = 0; i < std::size(kD3D11Decoders); ++i) { + changeFeatureRank(registry, mismatched[i], GST_RANK_NONE); + } +} +#endif // Q_OS_WIN + +#ifdef Q_OS_LINUX +constexpr std::array kLegacyVaapiDecoders = {"vaapiav1dec", "vaapih264dec", "vaapih265dec", + "vaapijpegdec", "vaapimpeg2dec", "vaapivp8dec", + "vaapivp9dec", "vaapidecodebin"}; +constexpr std::array kV4l2StatelessDecoders = { + "v4l2slh264dec", "v4l2slh265dec", "v4l2slvp8dec", "v4l2slvp9dec", "v4l2slav1dec", "v4l2slmpeg2dec"}; + +// Steer autoplug toward zero-copy GPU-memory decoders (DMABuf/DMA_DRM) over system-memory ones. Rank-only and +// additive: absent factories no-op, va/v4l2 self-demote at runtime without a device, software fallback untouched. +void preferZeroCopyDecoders(GstRegistry* registry) +{ + static constexpr uint16_t ZeroCopyRank = GST_RANK_PRIMARY + 2; + + // Legacy gstreamer-vaapi negotiates GstVaapiMemory the qgcqvideosink/HwBuffers paths can't import + // zero-copy; demote it so the modern va plugin (clean DMABuf/DMA_DRM output) wins when both exist. + applyRanks(registry, kLegacyVaapiDecoders, GST_RANK_NONE); + + // Modern va decoders (gst-va, supersedes gstreamer-vaapi) emit DMABuf/DMA_DRM, and V4L2 stateless + // decoders expose DMA_DRM caps on 1.26+; bump both above software so the zero-copy allocator is picked. + applyRanks(registry, kVaDecoders, ZeroCopyRank); + applyRanks(registry, kV4l2StatelessDecoders, ZeroCopyRank); +} +#endif // Q_OS_LINUX + +} // anonymous namespace + +void setCodecPriorities(int rawOption) +{ + setCodecPriorities(static_cast(rawOption)); +} void setCodecPriorities(VideoDecoderOptions option) { - GstRegistry *registry = gst_registry_get(); + GstRegistry* registry = gst_registry_get(); if (!registry) { qCCritical(GStreamerHelpersLog) << "Failed to get gstreamer registry."; @@ -212,51 +390,51 @@ void setCodecPriorities(VideoDecoderOptions option) static constexpr uint16_t PrioritizedRank = GST_RANK_PRIMARY + 1; switch (option) { - case ForceVideoDecoderDefault: - // HW-decoder GPU caps (GLMemory/DMABuf/VAMemory) auto-plug to system memory via gldownload/vapostproc. - break; - case ForceVideoDecoderSoftware: - prioritizeByHardwareClass(registry, PrioritizedRank, false); - break; - case ForceVideoDecoderHardware: - prioritizeByHardwareClass(registry, PrioritizedRank, true); - break; - case ForceVideoDecoderVAAPI: - for (const char *name : {"vaav1dec", "vah264dec", "vah265dec", "vajpegdec", "vampeg2dec", "vavp8dec", "vavp9dec"}) { - changeFeatureRank(registry, name, PrioritizedRank); - } - break; - case ForceVideoDecoderNVIDIA: - for (const char *name : {"nvav1dec", "nvh264dec", "nvh265dec", "nvjpegdec", "nvmpeg2videodec", "nvmpeg4videodec", "nvmpegvideodec", "nvvp8dec", "nvvp9dec"}) { - changeFeatureRank(registry, name, PrioritizedRank); - } - break; - case ForceVideoDecoderDirectX3D: - for (const char *name : {"d3d11av1dec", "d3d11h264dec", "d3d11h265dec", "d3d11mpeg2dec", "d3d11vp8dec", "d3d11vp9dec", - "d3d12av1dec", "d3d12h264dec", "d3d12h265dec", "d3d12mpeg2dec", "d3d12vp8dec", "d3d12vp9dec", - "dxvaav1decoder", "dxvah264decoder", "dxvah265decoder", "dxvampeg2decoder", "dxvavp8decoder", "dxvavp9decoder"}) { - changeFeatureRank(registry, name, PrioritizedRank); - } - break; - case ForceVideoDecoderVideoToolbox: - for (const char *name : {"vtdec_hw", "vtdec"}) { - changeFeatureRank(registry, name, PrioritizedRank); - } - break; - case ForceVideoDecoderIntel: - for (const char *name : {"qsvh264dec", "qsvh265dec", "qsvjpegdec", "qsvvp9dec", "msdkav1dec", "msdkh264dec", "msdkh265dec", "msdkmjpegdec", "msdkmpeg2dec", "msdkvc1dec", "msdkvp8dec", "msdkvp9dec"}) { - changeFeatureRank(registry, name, PrioritizedRank); - } - break; - case ForceVideoDecoderVulkan: - for (const char *name : {"vulkanh264dec", "vulkanh265dec"}) { - changeFeatureRank(registry, name, PrioritizedRank); - } - break; - default: - qCWarning(GStreamerHelpersLog) << "Can't handle decode option:" << option; - break; + case ForceVideoDecoderDefault: + // HW-decoder GPU caps (GLMemory/DMABuf/VAMemory) auto-plug to system memory via gldownload/vapostproc. +#ifdef Q_OS_LINUX + preferZeroCopyDecoders(registry); +#endif + break; + case ForceVideoDecoderSoftware: + prioritizeByHardwareClass(registry, PrioritizedRank, false); + break; + case ForceVideoDecoderHardware: + prioritizeByHardwareClass(registry, PrioritizedRank, true); + break; + case ForceVideoDecoderVAAPI: + applyRanks(registry, kVaDecoders, PrioritizedRank); + break; + case ForceVideoDecoderNVIDIA: + applyRanks(registry, kNvidiaDecoders, PrioritizedRank); + break; + case ForceVideoDecoderDirectX3D: + applyRanks(registry, kDirectX3DDecoders, PrioritizedRank); + break; + case ForceVideoDecoderVideoToolbox: + applyRanks(registry, kVideoToolboxDecoders, PrioritizedRank); + break; + case ForceVideoDecoderIntel: + applyRanks(registry, kIntelDecoders, PrioritizedRank); + break; + case ForceVideoDecoderVulkan: + // Vulkan zero-copy import is dormant: gst-vulkan's own VkDevice fails QGC's device-match guard → CPU copy. + qCWarning(GStreamerHelpersLog) << "Forcing Vulkan video decoders: zero-copy import is dormant " + "(foreign VkDevice → CPU fallback), so decode will not be zero-copy."; + applyRanks(registry, kVulkanDecoders, PrioritizedRank); + break; + default: + qCWarning(GStreamerHelpersLog) << "Can't handle decode option:" << option; + break; } + +#ifdef Q_OS_WIN + // After any option-driven rank changes, force the D3D decoder API to match the active QRhi + // backend so a D3D12 decoder never wins over D3D11 on Qt's default Windows RHI (and vice-versa). + const bool promoteMatchingD3D = (option == ForceVideoDecoderDefault) || (option == ForceVideoDecoderHardware) || + (option == ForceVideoDecoderDirectX3D); + alignD3DDecoderRanksToRhi(registry, promoteMatchingD3D); +#endif } -} // namespace GStreamer +} // namespace GStreamer diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.h b/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.h index cd55babd41c9..568884a98985 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.h @@ -1,15 +1,33 @@ #pragma once +#include +#include +#include #include #include -#include "GStreamer.h" +#include "GStreamer.h" // VideoDecoderOptions -namespace GStreamer -{ - gboolean isValidRtspUri(const gchar *uri_str); +namespace GStreamer { +bool isValidRtspUri(const gchar* uri_str); - bool isHardwareDecoderFactory(GstElementFactory *factory); +/// Dump @p pipeline's graph as a rotating .dot under CacheLocation/qgc-pipeline-dot/ for field reports. +/// Returns empty (no-op) when GST_DEBUG_DUMP_DOT_DIR is set or on I/O failure. +QString writePipelineDot(GstElement* pipeline, const char* tag); - void setCodecPriorities(VideoDecoderOptions option); -} +bool isHardwareDecoderFactory(GstElementFactory* factory); + +/// Look up @p featureName in @p registry and set its autoplug rank to @p rank, releasing the +/// transfer-full feature ref. Returns false when the registry/name is null or the feature is absent. +bool changeFeatureRank(GstRegistry* registry, const char* featureName, uint16_t rank); + +/// Apply decoder rank overrides for @p option to the global GStreamer registry. Defined here +/// alongside the decoder-rank table it drives; re-exported through the GStreamer facade. +void setCodecPriorities(VideoDecoderOptions option); +/// Overload taking the raw forceVideoDecoder setting value; the cast/range-check lives in the impl. +void setCodecPriorities(int rawOption); + +/// Walk every plugin in the GStreamer registry, invoking @p visitor for each; frees the GList +/// internally. The plugin pointer is valid only for the duration of the call (don't ref it). +void forEachPlugin(GstRegistry* registry, const std::function& visitor); +} // namespace GStreamer diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamerLogging.cc b/src/VideoManager/VideoReceiver/GStreamer/GStreamerLogging.cc index cb79d6f79514..b1fc5b2d890f 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamerLogging.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamerLogging.cc @@ -1,31 +1,35 @@ -#include "GStreamer.h" #include "GStreamerLogging.h" -#include "QGCLoggingCategory.h" +#include #include - +#include #include #include +#include "AppSettings.h" +#include "GStreamer.h" +#include "GStreamerHelpers.h" +#include "QGCLoggingCategory.h" + QGC_LOGGING_CATEGORY(GStreamerLoggingLog, "Video.GStreamer.GStreamerLogging") QGC_LOGGING_CATEGORY_ON(GStreamerAPILog, "Video.GStreamer.GStreamerAPI") +QGC_LOGGING_CATEGORY(GStreamerDecoderRanksLog, "Video.GStreamer.DecoderRanks") namespace { -std::atomic_bool g_externalPluginLoaderFailed {false}; +std::atomic_bool g_externalPluginLoaderFailed = false; -void glib_print_handler(const gchar *string) +void glib_print_handler(const gchar* string) { qCInfo(GStreamerLoggingLog) << string; } -void glib_printerr_handler(const gchar *string) +void glib_printerr_handler(const gchar* string) { qCWarning(GStreamerLoggingLog) << string; } -void glib_log_handler(const gchar *log_domain, GLogLevelFlags log_level, - const gchar *message, gpointer user_data) +void glib_log_handler(const gchar* log_domain, GLogLevelFlags log_level, const gchar* message, gpointer user_data) { Q_UNUSED(user_data); const QString domain = log_domain ? QString::fromUtf8(log_domain) : QStringLiteral("GLib"); @@ -41,28 +45,27 @@ void glib_log_handler(const gchar *log_domain, GLogLevelFlags log_level, } switch (log_level & G_LOG_LEVEL_MASK) { - case G_LOG_LEVEL_ERROR: - case G_LOG_LEVEL_CRITICAL: - qCCritical(GStreamerLoggingLog) << domain << msg; - break; - case G_LOG_LEVEL_WARNING: - qCWarning(GStreamerLoggingLog) << domain << msg; - break; - case G_LOG_LEVEL_MESSAGE: - case G_LOG_LEVEL_INFO: - qCInfo(GStreamerLoggingLog) << domain << msg; - break; - case G_LOG_LEVEL_DEBUG: - default: - qCDebug(GStreamerLoggingLog) << domain << msg; - break; + case G_LOG_LEVEL_ERROR: + case G_LOG_LEVEL_CRITICAL: + qCCritical(GStreamerLoggingLog) << domain << msg; + break; + case G_LOG_LEVEL_WARNING: + qCWarning(GStreamerLoggingLog) << domain << msg; + break; + case G_LOG_LEVEL_MESSAGE: + case G_LOG_LEVEL_INFO: + qCInfo(GStreamerLoggingLog) << domain << msg; + break; + case G_LOG_LEVEL_DEBUG: + default: + qCDebug(GStreamerLoggingLog) << domain << msg; + break; } } -} // anonymous namespace +} // anonymous namespace -namespace GStreamer -{ +namespace GStreamer { void resetExternalPluginLoaderFailure() { @@ -81,14 +84,8 @@ void redirectGLibLogging() g_log_set_default_handler(glib_log_handler, nullptr); } -void qtGstLog(GstDebugCategory *category, - GstDebugLevel level, - const gchar *file, - const gchar *function, - gint line, - GObject *object, - GstDebugMessage *message, - gpointer data) +void qtGstLog(GstDebugCategory* category, GstDebugLevel level, const gchar* file, const gchar* function, gint line, + GObject* object, GstDebugMessage* message, gpointer data) { Q_UNUSED(data); @@ -98,34 +95,113 @@ void qtGstLog(GstDebugCategory *category, QMessageLogger log(file, line, function); - struct GFree { void operator()(gchar *p) const { g_free(p); } }; - const std::unique_ptr object_info( - gst_info_strdup_printf("%" GST_PTR_FORMAT, object)); + struct GFree + { + void operator()(gchar* p) const { g_free(p); } + }; + + const std::unique_ptr object_info(gst_info_strdup_printf("%" GST_PTR_FORMAT, object)); switch (level) { - case GST_LEVEL_ERROR: - log.critical(GStreamerAPILog, "%s %s", object_info.get(), gst_debug_message_get(message)); - break; - case GST_LEVEL_WARNING: - log.warning(GStreamerAPILog, "%s %s", object_info.get(), gst_debug_message_get(message)); - break; - case GST_LEVEL_FIXME: - case GST_LEVEL_INFO: - log.info(GStreamerAPILog, "%s %s", object_info.get(), gst_debug_message_get(message)); - break; - case GST_LEVEL_DEBUG: + case GST_LEVEL_ERROR: + log.critical(GStreamerAPILog, "%s %s", object_info.get(), gst_debug_message_get(message)); + break; + case GST_LEVEL_WARNING: + log.warning(GStreamerAPILog, "%s %s", object_info.get(), gst_debug_message_get(message)); + break; + case GST_LEVEL_FIXME: + case GST_LEVEL_INFO: + log.info(GStreamerAPILog, "%s %s", object_info.get(), gst_debug_message_get(message)); + break; + case GST_LEVEL_DEBUG: #ifdef QT_DEBUG - // In release builds LOG/TRACE/MEMDUMP are intentionally dropped to reduce - // noise. Only debug builds route these verbose levels through Qt logging. - case GST_LEVEL_LOG: - case GST_LEVEL_TRACE: - case GST_LEVEL_MEMDUMP: + // In release builds LOG/TRACE/MEMDUMP are intentionally dropped to reduce + // noise. Only debug builds route these verbose levels through Qt logging. + case GST_LEVEL_LOG: + case GST_LEVEL_TRACE: + case GST_LEVEL_MEMDUMP: #endif - log.debug(GStreamerAPILog, "%s %s", object_info.get(), gst_debug_message_get(message)); - break; - default: - break; + log.debug(GStreamerAPILog, "%s %s", object_info.get(), gst_debug_message_get(message)); + break; + default: + break; + } +} + +void configureDebugLogging() +{ + gst_debug_remove_log_function(gst_debug_log_default); + gst_debug_remove_log_function(GStreamer::qtGstLog); + gst_debug_add_log_function(GStreamer::qtGstLog, nullptr, nullptr); + + if (!qEnvironmentVariableIsEmpty("GST_DEBUG")) { + return; } + + QSettings settings; + if (settings.contains(AppSettings::gstDebugLevelName)) { + const int level = + std::clamp(settings.value(AppSettings::gstDebugLevelName).toInt(), 0, static_cast(GST_LEVEL_MEMDUMP)); + gst_debug_set_default_threshold(static_cast(level)); + } +} + +void setDebugLevel(int level) +{ + if (!gst_is_initialized()) { + return; + } + const int clamped = std::clamp(level, 0, static_cast(GST_LEVEL_MEMDUMP)); + gst_debug_set_default_threshold(static_cast(clamped)); + qCDebug(GStreamerLoggingLog) << "GStreamer debug threshold set to" << clamped; +} + +void logDecoderRanks() +{ + GList* factories = gst_element_factory_list_get_elements( + static_cast(GST_ELEMENT_FACTORY_TYPE_DECODER | GST_ELEMENT_FACTORY_TYPE_MEDIA_VIDEO), + GST_RANK_NONE); + + if (!factories) { + qCDebug(GStreamerDecoderRanksLog) << "No video decoder factories found"; + return; + } + + factories = g_list_sort(factories, [](gconstpointer lhs, gconstpointer rhs) -> gint { + const guint lhsRank = gst_plugin_feature_get_rank(GST_PLUGIN_FEATURE(lhs)); + const guint rhsRank = gst_plugin_feature_get_rank(GST_PLUGIN_FEATURE(rhs)); + if (lhsRank != rhsRank) { + return (lhsRank > rhsRank) ? -1 : 1; + } + return g_strcmp0(gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(lhs)), + gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(rhs))); + }); + + qCDebug(GStreamerDecoderRanksLog) << "Video decoder ranks:"; + for (GList* node = factories; node != nullptr; node = node->next) { + GstElementFactory* factory = GST_ELEMENT_FACTORY(node->data); + GstPluginFeature* feature = GST_PLUGIN_FEATURE(factory); + const gchar* featureName = gst_plugin_feature_get_name(feature); + const guint rank = gst_plugin_feature_get_rank(feature); + const gchar* klass = gst_element_factory_get_klass(factory); + const bool isHw = GStreamer::isHardwareDecoderFactory(factory); + + GstPlugin* plugin = gst_plugin_feature_get_plugin(feature); + const gchar* pluginName = plugin ? gst_plugin_get_name(plugin) : "?"; + + qCDebug(GStreamerDecoderRanksLog).noquote() + << QStringLiteral(" [%1] %2/%3 rank=%4 (%5)") + .arg(isHw ? QStringLiteral("HW") : QStringLiteral("SW"), QString::fromUtf8(pluginName), + QString::fromUtf8(featureName)) + .arg(rank) + .arg(QString::fromUtf8(klass)); + + if (plugin) { + gst_object_unref(plugin); + } + } + + gst_plugin_feature_list_free(factories); } -} // namespace GStreamer +} // namespace GStreamer diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamerLogging.h b/src/VideoManager/VideoReceiver/GStreamer/GStreamerLogging.h index 6a6212ede873..3d9e473524c5 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamerLogging.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamerLogging.h @@ -2,18 +2,19 @@ #include -namespace GStreamer -{ - void redirectGLibLogging(); - void resetExternalPluginLoaderFailure(); - bool didExternalPluginLoaderFail(); +namespace GStreamer { +void redirectGLibLogging(); +void resetExternalPluginLoaderFailure(); +bool didExternalPluginLoaderFail(); - void qtGstLog(GstDebugCategory *category, - GstDebugLevel level, - const gchar *file, - const gchar *function, - gint line, - GObject *object, - GstDebugMessage *message, - gpointer data); -} +// Install qtGstLog as gst's log function and apply the persisted gstDebugLevel +// setting (unless GST_DEBUG is already set in the environment). +void configureDebugLogging(); + +// Diagnostic dump of installed video-decoder factories, sorted by rank, with +// HW/SW classification. No-op once the registry is empty. +void logDecoderRanks(); + +void qtGstLog(GstDebugCategory* category, GstDebugLevel level, const gchar* file, const gchar* function, gint line, + GObject* object, GstDebugMessage* message, gpointer data); +} // namespace GStreamer diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstAppSinkAdapter.cc b/src/VideoManager/VideoReceiver/GStreamer/GstAppSinkAdapter.cc deleted file mode 100644 index a67972ff227c..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/GstAppSinkAdapter.cc +++ /dev/null @@ -1,1339 +0,0 @@ -#include "GstAppSinkAdapter.h" -#include "HwBuffers/GstHwVideoBufferFactory.h" -#include "QGCLoggingCategory.h" -#include "gstqgc/gstqgcvideosinkbin.h" - -#include -#include - -#include -#include -#include -#include - -#include -#include -#include -#include -// Umbrella — provides GstVideoOrientationMethod, gst_video_orientation_from_tag, and (when -// the build's gst-video supports it) the per-buffer GstVideoOrientationMeta accessor. Both -// pieces work without QGC_HAS_GST_VIDEO_ORIENTATION_META except the buffer-meta lookup. -#include -#if GST_CHECK_VERSION(1, 24, 0) -# include -#endif - -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) -#include "HwBuffers/GstDmaBufVideoBuffer.h" -#include -#include -#include -#endif -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) -#include "HwBuffers/GstGlVideoBuffer.h" -#endif -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) -#include "HwBuffers/GstD3D11VideoBuffer.h" -#endif -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) -#include "HwBuffers/GstD3D12VideoBuffer.h" -#endif -#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) -#include "HwBuffers/GstIOSurfaceVideoBuffer.h" -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) -#include "HwBuffers/GstAHardwareBufferVideoBuffer.h" -#include -#endif - -QGC_LOGGING_CATEGORY(GstAppSinkAdapterLog, "Video.GStreamer.GstAppSinkAdapter") - -QVideoFrameFormat::ColorSpace toQtColorSpace(GstVideoColorMatrix matrix) -{ - switch (matrix) { - case GST_VIDEO_COLOR_MATRIX_BT601: return QVideoFrameFormat::ColorSpace_BT601; - case GST_VIDEO_COLOR_MATRIX_BT709: return QVideoFrameFormat::ColorSpace_BT709; - case GST_VIDEO_COLOR_MATRIX_BT2020: return QVideoFrameFormat::ColorSpace_BT2020; - case GST_VIDEO_COLOR_MATRIX_SMPTE240M: return QVideoFrameFormat::ColorSpace_BT709; // closest Qt equivalent - case GST_VIDEO_COLOR_MATRIX_FCC: return QVideoFrameFormat::ColorSpace_BT601; - default: return QVideoFrameFormat::ColorSpace_Undefined; - } -} - -QVideoFrameFormat::ColorTransfer toQtColorTransfer(GstVideoTransferFunction transfer) -{ - // Mapping mirrors qt6/qtmultimedia/.../qgst.cpp QGstCaps::formatAndVideoInfo() — keep - // in sync if Qt changes its mapping (last cross-check: Qt 6.10.3). - switch (transfer) { - case GST_VIDEO_TRANSFER_BT601: return QVideoFrameFormat::ColorTransfer_BT601; - case GST_VIDEO_TRANSFER_BT2020_10: - case GST_VIDEO_TRANSFER_BT2020_12: - case GST_VIDEO_TRANSFER_BT709: return QVideoFrameFormat::ColorTransfer_BT709; - case GST_VIDEO_TRANSFER_GAMMA20: return QVideoFrameFormat::ColorTransfer_BT709; // best fit per Qt - case GST_VIDEO_TRANSFER_SMPTE240M: return QVideoFrameFormat::ColorTransfer_BT709; // near-identical to BT.709 per Qt qgst.cpp:424 - case GST_VIDEO_TRANSFER_GAMMA22: - case GST_VIDEO_TRANSFER_SRGB: - case GST_VIDEO_TRANSFER_ADOBERGB: return QVideoFrameFormat::ColorTransfer_Gamma22; - case GST_VIDEO_TRANSFER_GAMMA18: return QVideoFrameFormat::ColorTransfer_Gamma22; // closest Qt equivalent - case GST_VIDEO_TRANSFER_GAMMA28: return QVideoFrameFormat::ColorTransfer_Gamma28; - case GST_VIDEO_TRANSFER_GAMMA10: return QVideoFrameFormat::ColorTransfer_Linear; - case GST_VIDEO_TRANSFER_SMPTE2084: return QVideoFrameFormat::ColorTransfer_ST2084; - case GST_VIDEO_TRANSFER_ARIB_STD_B67: return QVideoFrameFormat::ColorTransfer_STD_B67; - // GST_VIDEO_TRANSFER_LOG100 / LOG316 have no Qt equivalent — leave as Unknown - default: return QVideoFrameFormat::ColorTransfer_Unknown; - } -} - -QVideoFrameFormat::PixelFormat toQtPixelFormat(GstVideoFormat fmt) -{ - switch (fmt) { - case GST_VIDEO_FORMAT_BGRA: return QVideoFrameFormat::Format_BGRA8888; - case GST_VIDEO_FORMAT_RGBA: return QVideoFrameFormat::Format_RGBA8888; - case GST_VIDEO_FORMAT_BGRx: return QVideoFrameFormat::Format_BGRX8888; - case GST_VIDEO_FORMAT_RGBx: return QVideoFrameFormat::Format_RGBX8888; - // Qt6 has no 24-bit packed format; drop rather than corrupt stride arithmetic. - case GST_VIDEO_FORMAT_BGR: - case GST_VIDEO_FORMAT_RGB: return QVideoFrameFormat::Format_Invalid; - case GST_VIDEO_FORMAT_ARGB: return QVideoFrameFormat::Format_ARGB8888; - case GST_VIDEO_FORMAT_xRGB: return QVideoFrameFormat::Format_XRGB8888; - case GST_VIDEO_FORMAT_NV12: return QVideoFrameFormat::Format_NV12; - case GST_VIDEO_FORMAT_NV21: return QVideoFrameFormat::Format_NV21; - case GST_VIDEO_FORMAT_I420: return QVideoFrameFormat::Format_YUV420P; - case GST_VIDEO_FORMAT_Y42B: return QVideoFrameFormat::Format_YUV422P; - case GST_VIDEO_FORMAT_YV12: return QVideoFrameFormat::Format_YV12; - case GST_VIDEO_FORMAT_I420_10LE: return QVideoFrameFormat::Format_YUV420P10; - case GST_VIDEO_FORMAT_P010_10LE: return QVideoFrameFormat::Format_P010; - case GST_VIDEO_FORMAT_P016_LE: return QVideoFrameFormat::Format_P016; - case GST_VIDEO_FORMAT_AYUV: return QVideoFrameFormat::Format_AYUV; - case GST_VIDEO_FORMAT_YUY2: return QVideoFrameFormat::Format_YUYV; - case GST_VIDEO_FORMAT_UYVY: return QVideoFrameFormat::Format_UYVY; - case GST_VIDEO_FORMAT_GRAY8: return QVideoFrameFormat::Format_Y8; - case GST_VIDEO_FORMAT_GRAY16_LE: return QVideoFrameFormat::Format_Y16; - default: return QVideoFrameFormat::Format_Invalid; - } -} - -namespace { - -QVideoFrameFormat::ColorRange toQtColorRange(GstVideoColorRange range) -{ - switch (range) { - case GST_VIDEO_COLOR_RANGE_0_255: return QVideoFrameFormat::ColorRange_Full; - case GST_VIDEO_COLOR_RANGE_16_235: return QVideoFrameFormat::ColorRange_Video; - default: return QVideoFrameFormat::ColorRange_Unknown; - } -} - -} // namespace - -// Operates on the GstVideoOrientationMethod enum, which is in 's -// always-present subset — independent of QGC_HAS_GST_VIDEO_ORIENTATION_META. -void applyOrientationToFrame(QVideoFrame &frame, GstVideoOrientationMethod method) -{ - switch (method) { - case GST_VIDEO_ORIENTATION_IDENTITY: - frame.setRotation(QtVideo::Rotation::None); - frame.setMirrored(false); - break; - case GST_VIDEO_ORIENTATION_90R: - frame.setRotation(QtVideo::Rotation::Clockwise90); - frame.setMirrored(false); - break; - case GST_VIDEO_ORIENTATION_180: - frame.setRotation(QtVideo::Rotation::Clockwise180); - frame.setMirrored(false); - break; - case GST_VIDEO_ORIENTATION_90L: - frame.setRotation(QtVideo::Rotation::Clockwise270); - frame.setMirrored(false); - break; - case GST_VIDEO_ORIENTATION_HORIZ: - frame.setRotation(QtVideo::Rotation::None); - frame.setMirrored(true); - break; - case GST_VIDEO_ORIENTATION_VERT: - frame.setRotation(QtVideo::Rotation::Clockwise180); - frame.setMirrored(true); - break; - case GST_VIDEO_ORIENTATION_UL_LR: - frame.setRotation(QtVideo::Rotation::Clockwise90); - frame.setMirrored(true); - break; - case GST_VIDEO_ORIENTATION_UR_LL: - frame.setRotation(QtVideo::Rotation::Clockwise270); - frame.setMirrored(true); - break; - default: - static bool s_warnedUnhandled = false; - if (!s_warnedUnhandled) { - s_warnedUnhandled = true; - qCWarning(GstAppSinkAdapterLog) << "Unhandled GstVideoOrientationMethod" << method << "— treating as identity"; - } - frame.setRotation(QtVideo::Rotation::None); - frame.setMirrored(false); - break; - } -} - -namespace { - -void applyColorimetry(QVideoFrameFormat &format, const GstVideoInfo &info, GstCaps *caps) -{ - const GstVideoColorimetry &colorimetry = GST_VIDEO_INFO_COLORIMETRY(&info); - QVideoFrameFormat::ColorSpace colorSpace = toQtColorSpace(colorimetry.matrix); - // Live RTSP sources often omit colorimetry caps; resolution heuristic matches mpv. - if (colorSpace == QVideoFrameFormat::ColorSpace_Undefined) { - const int height = GST_VIDEO_INFO_HEIGHT(&info); - if (height > 0) { - colorSpace = (height <= 720) ? QVideoFrameFormat::ColorSpace_BT601 - : QVideoFrameFormat::ColorSpace_BT709; - } - } - format.setColorSpace(colorSpace); - format.setColorTransfer(toQtColorTransfer(colorimetry.transfer)); - QVideoFrameFormat::ColorRange range = toQtColorRange(colorimetry.range); - // H.264/H.265 omit VUI range but encode limited per spec — Unknown skips Qt's limited→full offset. - if (range == QVideoFrameFormat::ColorRange_Unknown - && colorimetry.matrix != GST_VIDEO_COLOR_MATRIX_RGB) { - range = QVideoFrameFormat::ColorRange_Video; - } - format.setColorRange(range); - - // Prefer MaxCLL (tighter tone-mapping target) over mastering-display max-luminance. - GstVideoContentLightLevel cll; - bool clipApplied = false; - if (caps && gst_video_content_light_level_from_caps(&cll, caps) - && cll.max_content_light_level > 0) { - format.setMaxLuminance(static_cast(cll.max_content_light_level)); - clipApplied = true; - } - if (!clipApplied) { - GstVideoMasteringDisplayInfo masteringInfo; - if (caps && gst_video_mastering_display_info_from_caps(&masteringInfo, caps)) { - // GstVideoMasteringDisplayColorVolume max_luma is in 0.0001 cd/m². - const double maxLuminance = static_cast(masteringInfo.max_display_mastering_luminance) / 10000.0; - if (maxLuminance > 0.0) { - format.setMaxLuminance(static_cast(maxLuminance)); - } - } - } -} - -// Definition lives outside the anonymous namespace; declaration in GstAppSinkAdapter.h. - -void applyOrientationAndTiming(QVideoFrame &frame, [[maybe_unused]] GstBuffer *buffer, - int streamOrientation) -{ - // Per-buffer meta wins (per-frame override) — only available when the build's gst-video - // exports gst_buffer_get_video_orientation_meta. The stream-level fallback works on every - // gst-video install (gst_video_orientation_from_tag is in the umbrella header). -#ifdef QGC_HAS_GST_VIDEO_ORIENTATION_META - if (GstVideoOrientationMeta *meta = gst_buffer_get_video_orientation_meta(buffer)) { - applyOrientationToFrame(frame, meta->orientation); - } else -#endif - if (streamOrientation != static_cast(GST_VIDEO_ORIENTATION_IDENTITY)) { - applyOrientationToFrame(frame, static_cast(streamOrientation)); - } - if (GST_BUFFER_PTS_IS_VALID(buffer)) { - // GstClockTime is ns; QVideoFrame timestamps are µs. - frame.setStartTime(GST_BUFFER_PTS(buffer) / GST_USECOND); - if (GST_BUFFER_DURATION_IS_VALID(buffer)) { - frame.setEndTime((GST_BUFFER_PTS(buffer) + GST_BUFFER_DURATION(buffer)) / GST_USECOND); - } - } -} - -void pushFrameQueued(QPointer sink, QVideoFrame &&frame) -{ - // Take QPointer by value: callers extract under _stateMutex and pass through, so we never construct a QPointer from a possibly-dangling raw pointer (UB) — the QPointer's own atomic guard tracks destruction across the snapshot→deliver window. - if (!sink) return; - // AutoConnection: direct call when already on the sink's thread, queued otherwise — mirrors Qt's qgstreamervideosink.cpp pattern. - QMetaObject::invokeMethod(sink.data(), [sink, f = std::move(frame)]() { - if (sink) sink->setVideoFrame(f); - }, Qt::AutoConnection); -} - -} // namespace - -// QQuickVideoOutput computes its sample rect as viewport/frameSize (qquickvideooutput.cpp:498); -// matches Qt's own gstreamer renderer (qgstvideorenderersink.cpp:230). externalTextureMatrix -// is only consulted for Format_SamplerExternalOES, so it can't be used for crop on standard formats. -QVideoFrameFormat applyCropMeta(QVideoFrameFormat format, GstBuffer *buffer) -{ - if (GstVideoCropMeta *crop = gst_buffer_get_video_crop_meta(buffer)) { - format.setViewport(QRect(crop->x, crop->y, crop->width, crop->height)); - } - return format; -} - -void GstAppSinkAdapter::_logFrameStats() const -{ - const quint64 cpu = _cpuFrames.load(std::memory_order_relaxed); - quint64 totalThisCall = cpu; - QString s = QStringLiteral("CPU:%1").arg(cpu); -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - const quint64 dma = _gpuFrames.load(std::memory_order_relaxed); - s += QStringLiteral(" DMABuf:%1/%2").arg(dma).arg(GstDmaBufVideoBuffer::peekMapFailureCount()); - totalThisCall += dma; -#endif -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - const quint64 gl = _glFrames.load(std::memory_order_relaxed); - s += QStringLiteral(" GL:%1/%2").arg(gl).arg(GstGlVideoBuffer::peekMapFailureCount()); - totalThisCall += gl; -#endif -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) - const quint64 d3d = _d3d11Frames.load(std::memory_order_relaxed); - s += QStringLiteral(" D3D11:%1/%2").arg(d3d).arg(GstD3D11VideoBuffer::peekMapFailureCount()); - totalThisCall += d3d; -#endif -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) - const quint64 d3d12 = _d3d12Frames.load(std::memory_order_relaxed); - s += QStringLiteral(" D3D12:%1/%2").arg(d3d12).arg(GstD3D12VideoBuffer::peekMapFailureCount()); - totalThisCall += d3d12; -#endif -#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) - const quint64 ios = _iosurfaceFrames.load(std::memory_order_relaxed); - s += QStringLiteral(" IOSurface:%1/%2").arg(ios).arg(GstIOSurfaceVideoBuffer::peekMapFailureCount()); - totalThisCall += ios; -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - const quint64 ahwb = _ahwbFrames.load(std::memory_order_relaxed); - s += QStringLiteral(" AHWBuf:%1/%2").arg(ahwb).arg(GstAHardwareBufferVideoBuffer::peekMapFailureCount()); - totalThisCall += ahwb; -#endif - - QString tail; - { - QMutexLocker locker(&_stateMutex); - if (!_cachedAllocatorName.isEmpty()) { - const int w = GST_VIDEO_INFO_WIDTH(&_cachedInfo); - const int h = GST_VIDEO_INFO_HEIGHT(&_cachedInfo); - const char *fmt = gst_video_format_to_string(GST_VIDEO_INFO_FORMAT(&_cachedInfo)); - tail = QStringLiteral(" (alloc=%1 %2x%3 %4") - .arg(_cachedAllocatorName).arg(w).arg(h).arg(QLatin1String(fmt)); - } - } - - const qint64 nowNs = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count(); - const qint64 prevNs = _lastStatsAtNs.exchange(nowNs, std::memory_order_relaxed); - if (prevNs != 0 && nowNs > prevNs && !tail.isEmpty()) { - // Approximate: total counters are cumulative, so derive delta against the previous _logFrameStats call. - // Skip the first call (prevNs == 0) since no window exists yet. - const double seconds = static_cast(nowNs - prevNs) / 1e9; - const quint64 prevTotal = _lastStatsTotal.exchange(totalThisCall, std::memory_order_relaxed); - if (totalThisCall > prevTotal && seconds > 0.0) { - const double fps = static_cast(totalThisCall - prevTotal) / seconds; - tail += QStringLiteral(" ~%1fps").arg(fps, 0, 'f', 1); - } - tail += QLatin1Char(')'); - } else if (!tail.isEmpty()) { - _lastStatsTotal.store(totalThisCall, std::memory_order_relaxed); - tail += QLatin1Char(')'); - } - - qCDebug(GstAppSinkAdapterLog).noquote() << "Frame stats —" << s << tail; -} - -GstAppSinkAdapter::GstAppSinkAdapter(QObject *parent) - : QObject(parent) -{ -} - -// Pipeline must be NULL before destroying the adapter; teardown() does not block in-flight callbacks. -GstAppSinkAdapter::~GstAppSinkAdapter() -{ - teardown(); -} - -bool GstAppSinkAdapter::setup(GstElement *sinkBin, QVideoSink *videoSink) -{ - if (!sinkBin || !videoSink) { - qCWarning(GstAppSinkAdapterLog) << "setup() called with null arguments"; - return false; - } - - teardown(); - - if (!GST_IS_QGC_VIDEO_SINK_BIN(sinkBin)) { - qCWarning(GstAppSinkAdapterLog) << "sinkBin is not a GstQgcVideoSinkBin"; - return false; - } - - _appsink = gst_qgc_video_sink_bin_get_appsink(GST_QGC_VIDEO_SINK_BIN(sinkBin)); - if (!_appsink) { - qCWarning(GstAppSinkAdapterLog) << "qgcvideosinkbin has no appsink (not constructed?)"; - return false; - } - - { - QMutexLocker locker(&_stateMutex); - _videoSink = videoSink; - } - -#if defined(QGC_HAS_ANY_GPU_PATH) - // Bin owns the path: it set its `gpu-zerocopy` GObject prop at construct time. Reading it back here keeps adapter telemetry in lockstep with whichever pipeline the bin actually built — no fact-vs-bin desync. - { - gboolean binZeroCopy = FALSE; - g_object_get(sinkBin, "gpu-zerocopy", &binZeroCopy, NULL); - _gpuPathEnabled = binZeroCopy ? true : false; - } -#endif -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - if (_gpuPathEnabled) { - // Construction-time hint only — render-time the buffer's mapTextures() prefers eglGetCurrentDisplay() over this so xcb_egl mismatches between Qt's actual EGLDisplay and eglGetDisplay(EGL_DEFAULT_DISPLAY) don't cause silent black-frame imports. - _eglDisplay = EGL_NO_DISPLAY; - const QString platform = QGuiApplication::platformName(); - if (platform == QLatin1String("wayland") || platform == QLatin1String("wayland-egl")) { - if (auto *ni = QGuiApplication::platformNativeInterface()) { - _eglDisplay = static_cast(ni->nativeResourceForIntegration("egldisplay")); - } - } - if (_eglDisplay == EGL_NO_DISPLAY) { - _eglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY); - } - if (_eglDisplay == EGL_NO_DISPLAY) { - qCWarning(GstAppSinkAdapterLog) << "GPU zero-copy requested but EGLDisplay unavailable on platform" - << platform << "— DMABuf path disabled"; - } else { - qCInfo(GstAppSinkAdapterLog) << "DMABuf zero-copy path available on" << platform - << "— actual path chosen at caps negotiation"; - } - } -#endif -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) - if (_gpuPathEnabled) { - qCInfo(GstAppSinkAdapterLog) << "D3D11 zero-copy path available — actual path chosen at caps negotiation"; - } -#endif -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) - if (_gpuPathEnabled) { - qCInfo(GstAppSinkAdapterLog) << "D3D12 zero-copy path available — actual path chosen at caps negotiation"; - } -#endif -#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) - if (_gpuPathEnabled) { - qCInfo(GstAppSinkAdapterLog) << "IOSurface zero-copy path available — actual path chosen at caps negotiation"; - } -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - if (_gpuPathEnabled) { - _ahwbEglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY); - if (_ahwbEglDisplay == EGL_NO_DISPLAY) { - qCWarning(GstAppSinkAdapterLog) << "AHardwareBuffer path: EGLDisplay unavailable"; - } else { - qCInfo(GstAppSinkAdapterLog) << "AHardwareBuffer zero-copy path available" - << "— actual path chosen at caps negotiation"; - } - } -#endif - - GstAppSinkCallbacks callbacks{}; - callbacks.new_sample = onNewSample; - // teardown() clears callbacks before unref'ing _appsink — destroy_notify=NULL is safe. - gst_app_sink_set_callbacks(GST_APP_SINK(_appsink), &callbacks, this, nullptr); - - // Install a sink-pad probe so we can observe every buffer that *reaches* the appsink, - // even ones the appsink later drops via max-buffers=1/drop=TRUE. Difference vs delivered - // counters = appsink-level drop pressure (separate from decoder QoS drops). - _appsinkInputFrames.store(0, std::memory_order_relaxed); - _flushing.store(false, std::memory_order_relaxed); - if (GstPad *sinkPad = gst_element_get_static_pad(_appsink, "sink")) { - // BUFFER + EVENT_DOWNSTREAM + EVENT_FLUSH in one probe. BUFFER feeds the input counter. - // EVENT_DOWNSTREAM catches serialized events (FLUSH_STOP arrives serialized after data). - // EVENT_FLUSH is REQUIRED to catch FLUSH_START — that event is non-serialized and bypasses - // the streaming thread, so EVENT_DOWNSTREAM alone would miss it (per gstpad.h:484-487). - _appsinkProbeId = gst_pad_add_probe(sinkPad, - GstPadProbeType(GST_PAD_PROBE_TYPE_BUFFER - | GST_PAD_PROBE_TYPE_EVENT_DOWNSTREAM - | GST_PAD_PROBE_TYPE_EVENT_FLUSH), - &GstAppSinkAdapter::appsinkBufferProbe, this, nullptr); - if (_appsinkProbeId == 0) { - qCWarning(GstAppSinkAdapterLog) << "gst_pad_add_probe(BUFFER) returned 0 — appsink drop counter disabled"; - gst_object_unref(sinkPad); - } else { - // Hold the ref: removal in teardown() targets the pad regardless of _appsink lifetime. - _appsinkProbePad = sinkPad; - } - } else { - qCWarning(GstAppSinkAdapterLog) << "Could not obtain appsink sink pad — drop counter disabled"; - } - - _telemetryEmitTimer.setInterval(1000); - _telemetryEmitTimer.setSingleShot(false); - // setup() is idempotent — disconnect first so re-setup doesn't stack lambda emitters. - QObject::disconnect(&_telemetryEmitTimer, &QTimer::timeout, this, nullptr); - QObject::connect(&_telemetryEmitTimer, &QTimer::timeout, this, [this]() { - quint64 total = _cpuFrames.load(std::memory_order_relaxed); -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - total += _gpuFrames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - total += _glFrames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) - total += _d3d11Frames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) - total += _d3d12Frames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) - total += _iosurfaceFrames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - total += _ahwbFrames.load(std::memory_order_relaxed); -#endif - if (total != _lastEmittedFrameTotal) { - _lastEmittedFrameTotal = total; - emit frameCountsChanged(); - } - }); - // QTimer::start must run on its owning thread (GUI); setup() may be on the streaming thread. - QMetaObject::invokeMethod(&_telemetryEmitTimer, qOverload<>(&QTimer::start), Qt::QueuedConnection); - - qCDebug(GstAppSinkAdapterLog) << "Installed appsink callbacks"; - return true; -} - -void GstAppSinkAdapter::setRefreshRate(qreal hz) -{ - if (hz < 1.0) { - return; - } - const quint64 periodNs = static_cast(GST_SECOND / hz); - _refreshPeriodNs.store(periodNs, std::memory_order_release); - - // Apply baseline now so first-frame budget isn't capped by the bin's 33 ms default. -#if defined(QGC_GST_BUILD_VERSION_MAJOR) && \ - (QGC_GST_BUILD_VERSION_MAJOR > 1 || \ - (QGC_GST_BUILD_VERSION_MAJOR == 1 && QGC_GST_BUILD_VERSION_MINOR >= 24)) - QMutexLocker locker(&_stateMutex); - if (_appsink) { - gst_app_sink_set_max_time(GST_APP_SINK(_appsink), static_cast(periodNs)); - } -#endif -} - -void GstAppSinkAdapter::setSmoothingEnabled(bool enabled, qreal refreshHz) -{ - if (enabled == _smoothingEnabled.load(std::memory_order_acquire)) { - return; - } - if (enabled) { - // refreshHz may be 0 (headless) or NaN (broken QScreen) — clamp. - const int hz = (refreshHz >= 1.0 && refreshHz <= 240.0) ? int(qRound(refreshHz)) : 60; - const int periodMs = qMax(1, int(qRound(1000.0 / hz))); - // Start clock before publishing enabled=true so streaming thread sees a started clock. - _smoothingClock.start(); - connect(&_smoothingTickTimer, &QTimer::timeout, - this, &GstAppSinkAdapter::_onSmoothingTick, Qt::UniqueConnection); - _smoothingTickTimer.setInterval(periodMs); - _smoothingTickTimer.setTimerType(Qt::PreciseTimer); - _smoothingEnabled.store(true, std::memory_order_release); - QMetaObject::invokeMethod(&_smoothingTickTimer, qOverload<>(&QTimer::start), - Qt::QueuedConnection); - qCInfo(GstAppSinkAdapterLog) << "Smoothing ring on: tick=" << periodMs - << "ms threshold=" << (kSmoothingThresholdNs / 1000000) << "ms capacity=" - << kSmoothingRingCapacity; - } else { - _smoothingEnabled.store(false, std::memory_order_release); - QMetaObject::invokeMethod(&_smoothingTickTimer, &QTimer::stop, Qt::QueuedConnection); - QMutexLocker lock(&_smoothingMutex); - _smoothingRing.clear(); - _smoothingFirstPtsNs = -1; - } -} - -void GstAppSinkAdapter::_deliverFrame(QPointer sink, QVideoFrame &&frame, int64_t ptsNs) -{ - if (!_smoothingEnabled.load(std::memory_order_acquire)) { - pushFrameQueued(sink, std::move(frame)); - return; - } - const qint64 nowNs = _smoothingClock.nsecsElapsed(); - QMutexLocker lock(&_smoothingMutex); - if (_smoothingRing.size() >= kSmoothingRingCapacity) { - _smoothingRing.removeFirst(); - const quint64 c = _smoothingDroppedFrames.fetch_add(1, std::memory_order_relaxed) + 1; - if ((c & 0x3F) == 1) { - qCDebug(GstAppSinkAdapterLog) << "Smoothing ring overflow; dropped oldest (total=" << c << ")"; - } - } - _smoothingRing.append({std::move(frame), - ptsNs >= 0 ? ptsNs : static_cast(nowNs), - nowNs}); -} - -void GstAppSinkAdapter::_onSmoothingTick() -{ - QPointer sinkSnapshot; - { - QMutexLocker locker(&_stateMutex); - sinkSnapshot = _videoSink; - } - if (!sinkSnapshot) { - return; - } - - QVideoFrame chosen; - { - QMutexLocker lock(&_smoothingMutex); - if (_smoothingRing.isEmpty()) { - return; // hold last good frame on freeze - } - if (_smoothingFirstPtsNs < 0) { - _smoothingFirstPtsNs = _smoothingRing.first().ptsNs; - _smoothingFirstClockNs = _smoothingRing.first().enqueuedNs; - } - const qint64 nowNs = _smoothingClock.nsecsElapsed(); - const int64_t targetPts = _smoothingFirstPtsNs + (nowNs - _smoothingFirstClockNs); - int bestIdx = 0; - int64_t bestDelta = std::llabs(_smoothingRing[0].ptsNs - targetPts); - for (int i = 1; i < _smoothingRing.size(); ++i) { - const int64_t d = std::llabs(_smoothingRing[i].ptsNs - targetPts); - if (d < bestDelta) { - bestDelta = d; - bestIdx = i; - } - } - if (bestDelta > kSmoothingThresholdNs) { - // Re-anchor on next tick; ring contents become candidates for the new anchor. - _smoothingFirstPtsNs = -1; - return; - } - chosen = _smoothingRing[bestIdx].frame; - // Drop chosen + older so the same frame can't be picked again. - for (int i = bestIdx; i >= 0; --i) { - _smoothingRing.removeAt(i); - } - } - pushFrameQueued(sinkSnapshot, std::move(chosen)); -} - -void GstAppSinkAdapter::teardown() -{ - // teardown() may fire from the streaming thread; queue stop to the timer's owner. - QMetaObject::invokeMethod(&_telemetryEmitTimer, &QTimer::stop, Qt::QueuedConnection); - // acq_rel exchange flips enabled then drains the ring so late _deliverFrame() can't refill. - if (_smoothingEnabled.exchange(false, std::memory_order_acq_rel)) { - QMetaObject::invokeMethod(&_smoothingTickTimer, &QTimer::stop, Qt::QueuedConnection); - QMutexLocker lock(&_smoothingMutex); - _smoothingRing.clear(); - _smoothingFirstPtsNs = -1; - } - - { - QString stats = QStringLiteral("CPU:%1").arg(_cpuFrames.load(std::memory_order_relaxed)); - quint64 totalFrames = _cpuFrames.load(std::memory_order_relaxed); -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - const quint64 dmaFailures = GstDmaBufVideoBuffer::takeMapFailureCount(); - stats += QStringLiteral(" DMABuf:%1 DMABuf-failures:%2").arg(_gpuFrames.load(std::memory_order_relaxed)).arg(dmaFailures); - totalFrames += _gpuFrames.load(std::memory_order_relaxed) + dmaFailures; - _gpuFrames.store(0, std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - const quint64 glFailures = GstGlVideoBuffer::takeMapFailureCount(); - const quint64 glReuse = GstGlVideoBuffer::takeTextureReuseHits(); - quint64 glGpuWaits = 0; - const quint64 glCpuWaits = GstGlVideoBuffer::takeSyncWaitCounts(glGpuWaits); - stats += QStringLiteral(" GL:%1 GL-failures:%2 GL-reuse:%3 GL-wait[gpu/cpu]:%4/%5") - .arg(_glFrames.load(std::memory_order_relaxed)) - .arg(glFailures).arg(glReuse).arg(glGpuWaits).arg(glCpuWaits); - totalFrames += _glFrames.load(std::memory_order_relaxed) + glFailures; - _glFrames.store(0, std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) - const quint64 d3dFailures = GstD3D11VideoBuffer::takeMapFailureCount(); - stats += QStringLiteral(" D3D11:%1 D3D11-failures:%2").arg(_d3d11Frames.load(std::memory_order_relaxed)).arg(d3dFailures); - totalFrames += _d3d11Frames.load(std::memory_order_relaxed) + d3dFailures; - _d3d11Frames.store(0, std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) - const quint64 d3d12Failures = GstD3D12VideoBuffer::takeMapFailureCount(); - stats += QStringLiteral(" D3D12:%1 D3D12-failures:%2").arg(_d3d12Frames.load(std::memory_order_relaxed)).arg(d3d12Failures); - totalFrames += _d3d12Frames.load(std::memory_order_relaxed) + d3d12Failures; - _d3d12Frames.store(0, std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) - const quint64 iosFailures = GstIOSurfaceVideoBuffer::takeMapFailureCount(); - stats += QStringLiteral(" IOSurface:%1 IOSurface-failures:%2").arg(_iosurfaceFrames.load(std::memory_order_relaxed)).arg(iosFailures); - totalFrames += _iosurfaceFrames.load(std::memory_order_relaxed) + iosFailures; - _iosurfaceFrames.store(0, std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - const quint64 ahwbFailures = GstAHardwareBufferVideoBuffer::takeMapFailureCount(); - stats += QStringLiteral(" AHWBuf:%1 AHWBuf-failures:%2").arg(_ahwbFrames.load(std::memory_order_relaxed)).arg(ahwbFailures); - totalFrames += _ahwbFrames.load(std::memory_order_relaxed) + ahwbFailures; - _ahwbFrames.store(0, std::memory_order_relaxed); -#endif - if (totalFrames > 0) { - qCInfo(GstAppSinkAdapterLog).noquote() << "Adapter teardown —" << stats; - } - } - _cpuFrames.store(0, std::memory_order_relaxed); - _lastEmittedFrameTotal = 0; - - if (_appsink) { - // userData=nullptr ensures any racing new_sample callback receives nullptr and bails early; - // set_callbacks takes appsink's internal mutex before swapping the callbacks struct. - GstAppSinkCallbacks empty{}; - gst_app_sink_set_callbacks(GST_APP_SINK(_appsink), &empty, nullptr, nullptr); - // Drain any buffer queued before the callbacks swap took effect. - while (GstSample *s = gst_app_sink_try_pull_sample(GST_APP_SINK(_appsink), 0)) { - gst_sample_unref(s); - } - } - // Drop the probe before releasing the pad ref. gst_pad_remove_probe is a no-op on id=0. - if (_appsinkProbeId != 0 && _appsinkProbePad) { - gst_pad_remove_probe(_appsinkProbePad, _appsinkProbeId); - } - _appsinkProbeId = 0; - gst_clear_object(&_appsinkProbePad); - _appsinkInputFrames.store(0, std::memory_order_relaxed); - gst_clear_object(&_appsink); - - { - QMutexLocker locker(&_stateMutex); - _videoSink = nullptr; - // Drop the held caps ref; next setup() call repopulates on first sample. - if (_cachedCapsKey) { - gst_caps_unref(_cachedCapsKey); - _cachedCapsKey = nullptr; - } - _cachedFormat = QVideoFrameFormat(); - _cachedPixelFormat = QVideoFrameFormat::Format_Invalid; - } - -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - _eglDisplay = EGL_NO_DISPLAY; -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - _ahwbEglDisplay = EGL_NO_DISPLAY; -#endif -#if defined(QGC_HAS_ANY_GPU_PATH) - _gpuPathEnabled = false; -#endif - _active.store(true, std::memory_order_release); - _qosSampleCount = 0; - _qosAvgRate = 1.0; - _qosLastPts = GST_CLOCK_TIME_NONE; - _qosLastArrivalNs = 0; - _pipelineMinLatencyNs = 0; - _latencyValid = false; - _latencyRefreshPending.store(false, std::memory_order_relaxed); -} - -void GstAppSinkAdapter::setActive(bool active) -{ - const bool was = _active.exchange(active, std::memory_order_acq_rel); - if (was == active) return; - if (active) return; - // false transition: clear the sink with one empty frame so the previous stream's last - // image doesn't ghost. snapshot the QVideoSink under lock — same dangling-pointer - // contract as onNewSample (sink may be destroyed on its owner thread). - QPointer sinkSnapshot; - { - QMutexLocker locker(&_stateMutex); - sinkSnapshot = _videoSink; - } - if (sinkSnapshot) { - pushFrameQueued(sinkSnapshot, QVideoFrame{}); - } -} - -GstFlowReturn GstAppSinkAdapter::onNewSample(GstAppSink *appsink, gpointer userData) -{ - auto *self = static_cast(userData); - // nullptr after teardown() swaps in an empty callbacks struct with userData=nullptr. - if (!self) return GST_FLOW_OK; - - // Drop the sample if a flush is in progress: a new_sample callback can race ahead of - // FLUSH_START between the upstream serializer and the appsink's queue. Returning - // GST_FLOW_FLUSHING tells appsink to discard without state-change side effects. - if (self->_flushing.load(std::memory_order_acquire)) { - if (GstSample *drop = gst_app_sink_try_pull_sample(appsink, 0)) { - gst_sample_unref(drop); - } - return GST_FLOW_FLUSHING; - } - - // Inactive sink: pull-and-discard so appsink's queue doesn't back-pressure upstream. - if (!self->_active.load(std::memory_order_acquire)) { - if (GstSample *drop = gst_app_sink_try_pull_sample(appsink, 0)) { - gst_sample_unref(drop); - } - return GST_FLOW_OK; - } - - GstSample *sample = gst_app_sink_pull_sample(appsink); - if (!sample) { - return GST_FLOW_ERROR; - } - - GstBuffer *buffer = gst_sample_get_buffer(sample); - GstCaps *caps = gst_sample_get_caps(sample); - if (!buffer || !caps) { - gst_sample_unref(sample); - return GST_FLOW_ERROR; - } - - // Copy the QPointer member directly so the snapshot stays sin-aware across the queued-delivery window — extracting a raw pointer here would dangle if the QVideoSink is destroyed on its owner thread before pushFrameQueued constructs its QPointer. - QPointer sinkSnapshot; - { - QMutexLocker locker(&self->_stateMutex); - sinkSnapshot = self->_videoSink; - } - if (!sinkSnapshot) { - gst_sample_unref(sample); - return GST_FLOW_OK; - } - - // Lock pattern: check key → on miss parse outside lock → relock to store. - GstVideoInfo localInfo{}; - QVideoFrameFormat localFormat; - int localPixelFormat = 0; - - { - QMutexLocker locker(&self->_stateMutex); - if (caps == self->_cachedCapsKey) { - localInfo = self->_cachedInfo; - localFormat = self->_cachedFormat; - localPixelFormat = self->_cachedPixelFormat; - } else { - localPixelFormat = -1; // sentinel: cache miss, parse below - } - } - - if (localPixelFormat == -1) { - GstVideoInfo parsedInfo{}; - // GStreamer 1.24+ va plugin advertises DMABuf as format=DMA_DRM with drm-format=; gst_video_info_from_caps doesn't understand DMA_DRM and would fail here, so go through the DMA-DRM-aware variant first. -#if GST_CHECK_VERSION(1, 24, 0) - if (gst_video_is_dma_drm_caps(caps)) { - GstVideoInfoDmaDrm drmInfo; - gst_video_info_dma_drm_init(&drmInfo); - if (!gst_video_info_dma_drm_from_caps(&drmInfo, caps) - || !gst_video_info_dma_drm_to_video_info(&drmInfo, &parsedInfo)) { - qCWarning(GstAppSinkAdapterLog) << "Failed to parse DMA-DRM video info from caps"; - gst_sample_unref(sample); - return GST_FLOW_ERROR; - } - } else -#endif - if (!gst_video_info_from_caps(&parsedInfo, caps)) { - qCWarning(GstAppSinkAdapterLog) << "Failed to parse video info from caps"; - gst_sample_unref(sample); - return GST_FLOW_ERROR; - } - const QVideoFrameFormat::PixelFormat pixelFormat = toQtPixelFormat( - GST_VIDEO_INFO_FORMAT(&parsedInfo)); - if (pixelFormat == QVideoFrameFormat::Format_Invalid) { - const GstVideoFormat fmt = GST_VIDEO_INFO_FORMAT(&parsedInfo); - if (self->_lastWarnedFormat.exchange(fmt, std::memory_order_relaxed) != fmt) { - qCWarning(GstAppSinkAdapterLog) << "Unsupported video format" - << gst_video_format_to_string(fmt); - } - gst_sample_unref(sample); - return GST_FLOW_ERROR; - } - const int w = GST_VIDEO_INFO_WIDTH(&parsedInfo); - const int h = GST_VIDEO_INFO_HEIGHT(&parsedInfo); - if (w <= 0 || h <= 0) { - gst_sample_unref(sample); - return GST_FLOW_ERROR; - } - QVideoFrameFormat parsedFormat(QSize(w, h), pixelFormat); - applyColorimetry(parsedFormat, parsedInfo, caps); - const int fpsN = GST_VIDEO_INFO_FPS_N(&parsedInfo); - const int fpsD = GST_VIDEO_INFO_FPS_D(&parsedInfo); - if (fpsN > 0 && fpsD > 0) { - parsedFormat.setStreamFrameRate(static_cast(fpsN) / static_cast(fpsD)); - // max(refreshPeriod, framePeriod): refresh is floor (don't drop early), frame is ceiling. -#if defined(QGC_GST_BUILD_VERSION_MAJOR) && \ - (QGC_GST_BUILD_VERSION_MAJOR > 1 || \ - (QGC_GST_BUILD_VERSION_MAJOR == 1 && QGC_GST_BUILD_VERSION_MINOR >= 24)) - const quint64 framePeriodNs = static_cast(GST_SECOND) * static_cast(fpsD) - / static_cast(fpsN); - const quint64 refreshNs = self->_refreshPeriodNs.load(std::memory_order_acquire); - const GstClockTime maxTime = static_cast(qMax(framePeriodNs, refreshNs)); - gst_app_sink_set_max_time(appsink, maxTime); -#endif - } - - QString allocName; - { - GstMemory *mem0 = gst_buffer_peek_memory(buffer, 0); - allocName = QString::fromUtf8((mem0 && mem0->allocator) ? mem0->allocator->mem_type : "(none)"); - qCInfo(GstAppSinkAdapterLog).noquote() - << "Caps changed — allocator:" << allocName - << "format:" << gst_video_format_to_string(GST_VIDEO_INFO_FORMAT(&parsedInfo)) - << GST_VIDEO_INFO_WIDTH(&parsedInfo) << "x" << GST_VIDEO_INFO_HEIGHT(&parsedInfo); - } - - // QVideoFrameFormat has no setPixelAspectRatio; GPU branch can't normalize non-1/1 PAR. - const int parN = GST_VIDEO_INFO_PAR_N(&parsedInfo); - const int parD = GST_VIDEO_INFO_PAR_D(&parsedInfo); - if (parN > 0 && parD > 0 && parN != parD) { -#if defined(QGC_HAS_ANY_GPU_PATH) - if (self->_gpuPathEnabled) { - qCWarning(GstAppSinkAdapterLog).noquote() - << "Source has non-square PAR" << parN << "/" << parD - << "— GPU zero-copy renders distorted (no Qt API to compensate)." - << "Disable GPU zero-copy on this source for correct geometry."; - } -#endif - } - - gst_caps_ref(caps); - { - QMutexLocker locker(&self->_stateMutex); - if (self->_cachedCapsKey) { - gst_caps_unref(self->_cachedCapsKey); - } - self->_cachedCapsKey = caps; - self->_cachedInfo = parsedInfo; - self->_cachedFormat = parsedFormat; - self->_cachedPixelFormat = pixelFormat; - self->_cachedAllocatorName = allocName; - } - - localInfo = parsedInfo; - localFormat = parsedFormat; - localPixelFormat = pixelFormat; - } else { - // v4l2h264dec can silently upgrade allocator (sysmem→DMABuf) without re-negotiation. - if (GstMemory *mem0 = gst_buffer_peek_memory(buffer, 0)) { - const QString memType = QString::fromUtf8( - (mem0->allocator) ? mem0->allocator->mem_type : "(none)"); - QMutexLocker locker(&self->_stateMutex); - if (memType != self->_cachedAllocatorName) { - qCInfo(GstAppSinkAdapterLog).noquote() - << "Allocator changed mid-stream:" << self->_cachedAllocatorName - << "→" << memType; - self->_cachedAllocatorName = memType; - } - } - } - - const GstVideoInfo &videoInfo = localInfo; - - // PTS monotonicity guard: a regressed timestamp wedges QVideoOutput's internal advance. - if (GST_BUFFER_PTS_IS_VALID(buffer)) { - if (GST_BUFFER_FLAG_IS_SET(buffer, GST_BUFFER_FLAG_DISCONT)) { - self->_lastDeliveredPtsNs.store(-1, std::memory_order_release); - } - const int64_t pts = static_cast(GST_BUFFER_PTS(buffer)); - const int64_t lastPts = self->_lastDeliveredPtsNs.load(std::memory_order_acquire); - if (lastPts >= 0 && pts < lastPts) { - const int fpsN = GST_VIDEO_INFO_FPS_N(&videoInfo); - const int fpsD = GST_VIDEO_INFO_FPS_D(&videoInfo); - const int64_t framePeriodNs = (fpsN > 0 && fpsD > 0) - ? static_cast(GST_SECOND) * fpsD / fpsN - : 33000000; - if (lastPts - pts > framePeriodNs) { - static std::atomic s_ptsRegressionDrops{0}; - const quint64 c = s_ptsRegressionDrops.fetch_add(1, std::memory_order_relaxed) + 1; - if ((c & 0x3F) == 1) { - qCWarning(GstAppSinkAdapterLog) - << "PTS regression — dropping buffer (pts=" << pts - << "last=" << lastPts << "delta=" << (lastPts - pts) << "ns; total drops=" << c << ")"; - } - gst_sample_unref(sample); - return GST_FLOW_OK; - } - } - self->_lastDeliveredPtsNs.store(pts, std::memory_order_release); - } - -#if defined(QGC_HAS_ANY_GPU_PATH) - { - HwVideoBufferPath matchedPath = HwVideoBufferPath::None; - auto hwBuf = makeHwVideoBuffer( - sample, videoInfo, applyCropMeta(localFormat, buffer), - self->_gpuPathEnabled, -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - self->_eglDisplay, -#else - nullptr, -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - self->_ahwbEglDisplay, -#else - nullptr, -#endif - matchedPath); - if (hwBuf) { - QVideoFrame gpuFrame(std::move(hwBuf)); - applyOrientationAndTiming(gpuFrame, buffer, - self->_streamOrientation.load(std::memory_order_acquire)); - gst_sample_unref(sample); - switch (matchedPath) { -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - case HwVideoBufferPath::DmaBuf: { - const quint64 c = self->_gpuFrames.fetch_add(1, std::memory_order_relaxed) + 1; - if ((c & 0xFF) == 0) self->_logFrameStats(); - break; - } -#endif -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - case HwVideoBufferPath::GlMemory: { - const quint64 c = self->_glFrames.fetch_add(1, std::memory_order_relaxed) + 1; - if ((c & 0xFF) == 0) self->_logFrameStats(); - break; - } -#endif -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) - case HwVideoBufferPath::D3D11: { - const quint64 c = self->_d3d11Frames.fetch_add(1, std::memory_order_relaxed) + 1; - if ((c & 0xFF) == 0) self->_logFrameStats(); - break; - } -#endif -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) - case HwVideoBufferPath::D3D12: { - const quint64 c = self->_d3d12Frames.fetch_add(1, std::memory_order_relaxed) + 1; - if ((c & 0xFF) == 0) self->_logFrameStats(); - break; - } -#endif -#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) - case HwVideoBufferPath::IOSurface: { - const quint64 c = self->_iosurfaceFrames.fetch_add(1, std::memory_order_relaxed) + 1; - if ((c & 0xFF) == 0) self->_logFrameStats(); - break; - } -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - case HwVideoBufferPath::AHardwareBuffer: { - const quint64 c = self->_ahwbFrames.fetch_add(1, std::memory_order_relaxed) + 1; - if ((c & 0xFF) == 0) self->_logFrameStats(); - break; - } -#endif - default: break; - } - const int64_t ptsNs = GST_BUFFER_PTS_IS_VALID(buffer) - ? static_cast(GST_BUFFER_PTS(buffer)) : -1; - self->_deliverFrame(sinkSnapshot, std::move(gpuFrame), ptsNs); - self->_pushQosUpstream(appsink, buffer); - return GST_FLOW_OK; - } - } -#endif - - GstVideoFrame gstFrame; - // gst_video_frame_map honors GstVideoMeta strides; bypass would require manual offset handling. - if (!gst_video_frame_map(&gstFrame, const_cast(&videoInfo), buffer, GST_MAP_READ)) { - // PTS is the most useful clue for diagnosing hardware-fence / timeout failures. - static std::atomic s_failCount{0}; - const quint64 count = s_failCount.fetch_add(1, std::memory_order_relaxed) + 1; - if ((count & 0x3F) == 1) { - qCWarning(GstAppSinkAdapterLog) << "gst_video_frame_map failed; pts=" - << (GST_BUFFER_PTS_IS_VALID(buffer) ? GST_BUFFER_PTS(buffer) : 0) - << "consecutive=" << count; - } - gst_sample_unref(sample); - return GST_FLOW_ERROR; - } - - QVideoFrame videoFrame(applyCropMeta(localFormat, buffer)); - - if (!videoFrame.map(QVideoFrame::WriteOnly)) { - qCWarning(GstAppSinkAdapterLog) << "Failed to map QVideoFrame for writing"; - gst_video_frame_unmap(&gstFrame); - gst_sample_unref(sample); - return GST_FLOW_ERROR; - } - - const int planes = GST_VIDEO_INFO_N_PLANES(&videoInfo); - for (int p = 0; p < planes; ++p) { - const int dstStride = videoFrame.bytesPerLine(p); - const int compWidth = GST_VIDEO_FRAME_COMP_WIDTH(&gstFrame, p); - const int compPstride = GST_VIDEO_FRAME_COMP_PSTRIDE(&gstFrame, p); - const int srcStride = GST_VIDEO_FRAME_PLANE_STRIDE(&gstFrame, p); - const int planeHeight = GST_VIDEO_FRAME_COMP_HEIGHT(&gstFrame, p); - const int activeRowBytes = compWidth * compPstride; - const uchar *src = static_cast(GST_VIDEO_FRAME_PLANE_DATA(&gstFrame, p)); - uchar *dst = videoFrame.bits(p); - if (!dst) { - continue; - } - if (activeRowBytes > dstStride) { - // Qt allocated less than the active pixel width — should be impossible. - static bool s_warnedStrideOverflow = false; - if (!s_warnedStrideOverflow) { - s_warnedStrideOverflow = true; - qCWarning(GstAppSinkAdapterLog) - << "Plane" << p << ": activeRowBytes" << activeRowBytes - << "> dstStride" << dstStride << "— skipping frame"; - } - videoFrame.unmap(); - gst_video_frame_unmap(&gstFrame); - gst_sample_unref(sample); - return GST_FLOW_ERROR; - } - if (srcStride == dstStride && activeRowBytes == srcStride) { - memcpy(dst, src, static_cast(planeHeight) * srcStride); - } else { - for (int y = 0; y < planeHeight; ++y) { - memcpy(dst + y * dstStride, src + y * srcStride, activeRowBytes); - } - } - } - - videoFrame.unmap(); - gst_video_frame_unmap(&gstFrame); - - applyOrientationAndTiming(videoFrame, buffer, - self->_streamOrientation.load(std::memory_order_acquire)); - const quint64 c = self->_cpuFrames.fetch_add(1, std::memory_order_relaxed) + 1; - if ((c & 0xFF) == 0) self->_logFrameStats(); - const int64_t ptsNs = GST_BUFFER_PTS_IS_VALID(buffer) - ? static_cast(GST_BUFFER_PTS(buffer)) : -1; - gst_sample_unref(sample); - self->_deliverFrame(sinkSnapshot, std::move(videoFrame), ptsNs); - self->_pushQosUpstream(appsink, buffer); - return GST_FLOW_OK; -} - -void GstAppSinkAdapter::_refreshLatency() -{ - if (!_appsink) return; - GstQuery *q = gst_query_new_latency(); - if (!q) return; - // Query on the appsink element propagates upstream through the live pipeline graph. - if (gst_element_query(_appsink, q)) { - gboolean live = FALSE; - GstClockTime minLat = 0, maxLat = 0; - gst_query_parse_latency(q, &live, &minLat, &maxLat); - _pipelineMinLatencyNs = (minLat != GST_CLOCK_TIME_NONE) ? minLat : 0; - _latencyValid = true; - qCDebug(GstAppSinkAdapterLog) << "Pipeline latency refreshed: live=" << live - << "min=" << _pipelineMinLatencyNs << "ns max=" << maxLat << "ns"; - } - // On failure, _latencyValid stays false; _pushQosUpstream retries every frame. - gst_query_unref(q); -} - -void GstAppSinkAdapter::_pushQosUpstream(GstAppSink * /*appsink*/, GstBuffer *buffer) -{ - if (!_qosUpstreamEnabled) return; - if (!GST_BUFFER_PTS_IS_VALID(buffer)) return; - - // Retry every frame until valid; then every N frames or when bus thread pokes the flag. - if (!_latencyValid || _latencyRefreshPending.exchange(false, std::memory_order_relaxed) - || (_qosSampleCount > 0 && (_qosSampleCount % kLatencyRefreshInterval) == 0)) { - _refreshLatency(); - } - - const GstClockTime pts = GST_BUFFER_PTS(buffer); - - // Measure wall-clock arrival interval using steady_clock; PTS spacing gives expected interval. - const GstClockTime nowNs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - - ++_qosSampleCount; - - if (_qosLastPts == GST_CLOCK_TIME_NONE || _qosLastArrivalNs == 0 - || pts <= _qosLastPts) { - _qosLastPts = pts; - _qosLastArrivalNs = nowNs; - return; - } - - const GstClockTime ptsDelta = pts - _qosLastPts; - const GstClockTime arrivalDelta = nowNs - _qosLastArrivalNs; - - _qosLastPts = pts; - _qosLastArrivalNs = nowNs; - - if (ptsDelta == 0) return; - - // rate = arrivalDelta / ptsDelta; >1 means we're consuming slower than the stream. - const double rate = static_cast(arrivalDelta) / static_cast(ptsDelta); - - // EWMA: positive deviations averaged over window=16, negative over window=4 — - // mirrors gstbasesink.c UPDATE_RUNNING_AVG_P/N so slow-path feedback stabilises. - if (rate > _qosAvgRate) { - _qosAvgRate = (rate + 15.0 * _qosAvgRate) / 16.0; // UPDATE_RUNNING_AVG_P - } else { - _qosAvgRate = (rate + 3.0 * _qosAvgRate) / 4.0; // UPDATE_RUNNING_AVG_N - } - - // Skip warmup samples and only emit every kQosInterval frames to avoid event spam. - if (_qosSampleCount < kQosWarmup) return; - if ((_qosSampleCount % kQosInterval) != 0) return; - - // High-latency pipelines (e.g. RTSP jitter buffer) look "slow" by _pipelineMinLatencyNs; - // if absolute lateness is within the pipeline's own minimum latency, treat as steady-state. - if (_pipelineMinLatencyNs > 0) { - // GST_CLOCK_DIFF avoids UB on guint64 subtraction; returns signed GstClockTimeDiff. - const GstClockTimeDiff absLateness = GST_CLOCK_DIFF(ptsDelta, arrivalDelta); - if (absLateness < static_cast(_pipelineMinLatencyNs)) { - return; - } - } - - // proportion is clamped to [0.0, 16.0] per gstbasesink convention. - const gdouble proportion = qBound(0.0, _qosAvgRate, 16.0); - - // diff=0: we don't sync to the pipeline clock so pipeline-clock lateness is unavailable. - const GstClockTimeDiff diff = 0; - - GstQOSType type; - if (proportion >= 16.0) { - type = GST_QOS_TYPE_THROTTLE; // catastrophically behind - } else if (proportion > 1.0) { - type = GST_QOS_TYPE_UNDERFLOW; // we're behind; upstream should slow its production rate - } else { - type = GST_QOS_TYPE_OVERFLOW; // we have headroom; upstream is producing too fast - } - - GstEvent *event = gst_event_new_qos(type, proportion, diff, pts); - if (event) { - if (!_appsinkProbePad) { - gst_event_unref(event); - return; - } - // _appsinkProbePad is ref-held at setup(); reuse avoids a per-frame get/unref pair. - const bool pushed = gst_pad_push_event(_appsinkProbePad, event); - if (!pushed) { - static bool s_qosPushFailed = false; - if (!s_qosPushFailed) { - s_qosPushFailed = true; - qCDebug(GstAppSinkAdapterLog) << "QoS upstream push failed (silenced after first)"; - } - } - } -} - -quint64 GstAppSinkAdapter::gpuFrameCount() const noexcept -{ - QMutexLocker locker(&_stateMutex); // snapshot read; torn-write-safe on supported arches - quint64 count = 0; -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - count += _gpuFrames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - count += _glFrames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) - count += _d3d11Frames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) - count += _d3d12Frames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) - count += _iosurfaceFrames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - count += _ahwbFrames.load(std::memory_order_relaxed); -#endif - return count; -} - -quint64 GstAppSinkAdapter::cpuFrameCount() const noexcept -{ - QMutexLocker locker(&_stateMutex); // snapshot read; torn-write-safe on supported arches - return _cpuFrames.load(std::memory_order_relaxed); -} - -quint64 GstAppSinkAdapter::gpuFallbackCount() const noexcept -{ - QMutexLocker locker(&_stateMutex); // snapshot read; torn-write-safe on supported arches -#if defined(QGC_HAS_ANY_GPU_PATH) - return _gpuPathEnabled ? _cpuFrames.load(std::memory_order_relaxed) : quint64(0); -#else - return 0; -#endif -} - -quint64 GstAppSinkAdapter::_deliveredFrames() const noexcept -{ - quint64 count = _cpuFrames.load(std::memory_order_relaxed); -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - count += _gpuFrames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - count += _glFrames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) - count += _d3d11Frames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) - count += _d3d12Frames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) - count += _iosurfaceFrames.load(std::memory_order_relaxed); -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - count += _ahwbFrames.load(std::memory_order_relaxed); -#endif - return count; -} - -quint64 GstAppSinkAdapter::appsinkInputFrames() const noexcept -{ - return _appsinkInputFrames.load(std::memory_order_relaxed); -} - -quint64 GstAppSinkAdapter::appsinkDroppedFrames() const noexcept -{ - const quint64 in = _appsinkInputFrames.load(std::memory_order_relaxed); - const quint64 out = _deliveredFrames(); - // The probe runs synchronously on the streaming thread before the buffer is enqueued; - // delivered counters lag by at most the in-flight callback. Underflow is therefore possible - // for a single-buffer race window — clamp to zero so QML never sees a wraparound value. - return in > out ? in - out : 0; -} - -GstPadProbeReturn GstAppSinkAdapter::appsinkBufferProbe(GstPad *, GstPadProbeInfo *info, gpointer userData) -{ - auto *self = static_cast(userData); - if (!self) return GST_PAD_PROBE_OK; - const auto type = GST_PAD_PROBE_INFO_TYPE(info); - // FLUSH_START arrives with only EVENT_FLUSH set (non-serialized); FLUSH_STOP is serialized - // and arrives with EVENT_DOWNSTREAM set. Accept either to catch both halves of the flush. - if (type & (GST_PAD_PROBE_TYPE_EVENT_DOWNSTREAM | GST_PAD_PROBE_TYPE_EVENT_FLUSH)) { - // FLUSH_START is non-serialized: arrives even when the streaming thread is mid-callback. - // Set the flag before letting the event through so a parallel new_sample sees it. - if (GstEvent *event = GST_PAD_PROBE_INFO_EVENT(info)) { - switch (GST_EVENT_TYPE(event)) { - case GST_EVENT_FLUSH_START: - self->_flushing.store(true, std::memory_order_release); - break; - case GST_EVENT_FLUSH_STOP: - self->_flushing.store(false, std::memory_order_release); - // New source may not re-emit orientation tag; reset to identity. - self->_streamOrientation.store(static_cast(GST_VIDEO_ORIENTATION_IDENTITY), - std::memory_order_release); - self->_lastDeliveredPtsNs.store(-1, std::memory_order_release); - // Smoothing-ring PTS belongs to the old timeline. - if (self->_smoothingEnabled.load(std::memory_order_acquire)) { - QMutexLocker lock(&self->_smoothingMutex); - self->_smoothingRing.clear(); - self->_smoothingFirstPtsNs = -1; - } - break; - case GST_EVENT_TAG: { - GstTagList *taglist = nullptr; - gst_event_parse_tag(event, &taglist); - GstVideoOrientationMethod method = GST_VIDEO_ORIENTATION_IDENTITY; - if (taglist && gst_video_orientation_from_tag(taglist, &method)) { - self->_streamOrientation.store(static_cast(method), - std::memory_order_release); - } - break; - } - default: - break; - } - } - return GST_PAD_PROBE_OK; - } - if (type & GST_PAD_PROBE_TYPE_BUFFER) { - self->_appsinkInputFrames.fetch_add(1, std::memory_order_relaxed); - } - return GST_PAD_PROBE_OK; -} diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstAppSinkAdapter.h b/src/VideoManager/VideoReceiver/GStreamer/GstAppSinkAdapter.h deleted file mode 100644 index aa8238056cfa..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/GstAppSinkAdapter.h +++ /dev/null @@ -1,233 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include -#include -#include - -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) || defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) -#include -#endif - -class QVideoSink; - -// File-scope helpers (kept out of the anonymous namespace so unit tests can call them directly). -QVideoFrameFormat::ColorSpace toQtColorSpace (GstVideoColorMatrix matrix); -QVideoFrameFormat::ColorTransfer toQtColorTransfer(GstVideoTransferFunction transfer); -QVideoFrameFormat::PixelFormat toQtPixelFormat (GstVideoFormat fmt); -QVideoFrameFormat applyCropMeta (QVideoFrameFormat format, GstBuffer *buffer); -// Umbrella header — pulls in GstVideoOrientationMethod (always present) plus the -// per-buffer meta accessor (gated below by QGC_HAS_GST_VIDEO_ORIENTATION_META). -// The standalone isn't shipped in every gst-video -// install; the umbrella is the portable spelling. -#include -void applyOrientationToFrame(QVideoFrame &frame, GstVideoOrientationMethod method); - -/// \brief Bridges a GStreamer appsink to a Qt QVideoSink. -/// -/// Each decoded frame arriving at the appsink is copied into a QVideoFrame -/// and pushed to the QVideoSink, which renders through Qt's native RHI -/// backend (Metal on macOS, Vulkan/D3D elsewhere). -/// -class GstAppSinkAdapter : public QObject -{ - Q_OBJECT - - Q_PROPERTY(quint64 gpuFrameCount READ gpuFrameCount NOTIFY frameCountsChanged) - Q_PROPERTY(quint64 cpuFrameCount READ cpuFrameCount NOTIFY frameCountsChanged) - Q_PROPERTY(quint64 gpuFallbackCount READ gpuFallbackCount NOTIFY frameCountsChanged) - /// Frames that reached the appsink sink pad (counted via pad probe). - Q_PROPERTY(quint64 appsinkInputFrames READ appsinkInputFrames NOTIFY frameCountsChanged) - /// Frames the appsink dropped because of `max-buffers=1, drop=true` queue overflow. - /// Distinct from decoder-level QoS drops surfaced by GstVideoReceiver. - Q_PROPERTY(quint64 appsinkDroppedFrames READ appsinkDroppedFrames NOTIFY frameCountsChanged) - -public: - explicit GstAppSinkAdapter(QObject *parent = nullptr); - ~GstAppSinkAdapter() override; - - /// Connect to the named appsink inside @p sinkBin and push frames to @p videoSink. - /// Returns true on success. - bool setup(GstElement *sinkBin, QVideoSink *videoSink); - - /// Disconnect the callback (safe to call multiple times). - void teardown(); - - /// Signal the adapter to refresh pipeline latency on the next streaming-thread tick. - void requestLatencyRefresh() noexcept { _latencyRefreshPending.store(true, std::memory_order_relaxed); } - - /// Gate frame delivery without tearing the pipeline down. When set to false the next - /// frame seen is a single empty QVideoFrame (clears the sink), and subsequent samples - /// are dropped at the appsink callback until re-enabled. Mirrors Qt's - /// QVideoSink::isActive gate (qgstvideorenderersink.cpp:345-355). Use when switching - /// streams to avoid the previous stream's last frame ghosting on the QML output. - void setActive(bool active); - - /// Display refresh-rate baseline for appsink max-time (max(1/refreshHz, 1/streamFps) - /// applied at each caps change). Call once from the GUI thread after setup(); pass 0 - /// to leave the bin's 33 ms default. - void setRefreshRate(qreal hz); - - /// Opt-in OBS-style smoothing: 3-deep ring, display-rate timer picks the frame - /// nearest the PTS-anchored expected presentation time. Adds up to one frame of - /// latency. refreshHz <= 0 falls back to 60 Hz tick. - void setSmoothingEnabled(bool enabled, qreal refreshHz); - - quint64 gpuFrameCount() const noexcept; - quint64 cpuFrameCount() const noexcept; - quint64 gpuFallbackCount() const noexcept; - quint64 appsinkInputFrames() const noexcept; - quint64 appsinkDroppedFrames() const noexcept; - -signals: - void frameCountsChanged(); - -private: - static GstFlowReturn onNewSample(GstAppSink *appsink, gpointer userData); - /// Sink-pad probe on the appsink that increments _appsinkInputFrames per buffer. - /// Combined with delivered counters this exposes appsink drop pressure separately - /// from decoder QoS drops. - static GstPadProbeReturn appsinkBufferProbe(GstPad *pad, GstPadProbeInfo *info, gpointer userData); - - void _logFrameStats() const; - /// Sum of every "delivered" counter (cpu + every enabled GPU path). - quint64 _deliveredFrames() const noexcept; - - /// Push a GST_EVENT_QOS upstream from the streaming thread to throttle the decoder. - void _pushQosUpstream(GstAppSink *appsink, GstBuffer *buffer); - /// Re-query pipeline latency via the appsink element; called periodically on the streaming thread. - void _refreshLatency(); - - /// Push immediately (smoothing off) or enqueue into the ring (smoothing on). - /// ptsNs = -1 when buffer PTS is unset. - void _deliverFrame(QPointer sink, QVideoFrame &&frame, int64_t ptsNs); - - /// GUI-thread tick — picks the ring entry nearest the anchored target PTS. - void _onSmoothingTick(); - - QTimer _telemetryEmitTimer; - - // Never held while pushing a frame to QVideoSink (avoids priority inversion with the render thread). - mutable QMutex _stateMutex; - - QPointer _videoSink; - GstElement *_appsink = nullptr; - // Ref-held: the probe is installed on the appsink's sink pad; we keep the pad alive so - // teardown can target it for removal even if _appsink ownership changes. - GstPad *_appsinkProbePad = nullptr; - gulong _appsinkProbeId = 0; - std::atomic _appsinkInputFrames{0}; - // Set by GST_EVENT_FLUSH_START on the appsink sink pad, cleared by FLUSH_STOP. - // Drops new_sample callbacks while flushing so a buffer queued before a pipeline reset - // can't surface as a stale frame after FLUSH_STOP. Read on the streaming thread, written - // on the upstream serializer thread — both ordered by GStreamer's event semantics. - std::atomic _flushing{false}; - // setActive(false) drops samples at onNewSample so a torn-down stream's last frame - // can't ghost when the user switches sources. Default true for backwards compatibility. - std::atomic _active{true}; - // Stream-level orientation from upstream GST_TAG_IMAGE_ORIENTATION. Per-buffer - // GstVideoOrientationMeta still wins when present (gated by QGC_HAS_GST_VIDEO_ORIENTATION_META); - // this fallback works on any gst-video install since gst_video_orientation_from_tag is - // in the umbrella header. - std::atomic _streamOrientation{static_cast(GST_VIDEO_ORIENTATION_IDENTITY)}; - - // 0 = use bin's 33 ms default. Streaming thread reads on caps change. - std::atomic _refreshPeriodNs{0}; - - // Smoothing ring: producer (streaming) writes under _smoothingMutex, timer (owner thread) reads. - // _smoothingClock is started before _smoothingEnabled flips so producer always sees started clock. - static constexpr int kSmoothingRingCapacity = 3; - static constexpr int64_t kSmoothingThresholdNs = 70 * 1000000; - struct SmoothingEntry { - QVideoFrame frame; - int64_t ptsNs; // stream PTS (or fallback wall-time when PTS missing) - qint64 enqueuedNs; // QElapsedTimer::nsecsElapsed at enqueue - }; - std::atomic _smoothingEnabled{false}; - mutable QMutex _smoothingMutex; - QList _smoothingRing; - QTimer _smoothingTickTimer; - QElapsedTimer _smoothingClock; - int64_t _smoothingFirstPtsNs = -1; - qint64 _smoothingFirstClockNs = 0; - std::atomic _smoothingDroppedFrames{0}; - // -1 = no prior; reset by DISCONT and FLUSH_STOP. Without this a seek wedges QVideoOutput. - std::atomic _lastDeliveredPtsNs{-1}; - - // Held ref prevents GstCaps address reuse that would produce a false identity-cache hit with stale colorimetry. - GstCaps *_cachedCapsKey = nullptr; - GstVideoInfo _cachedInfo{}; - QVideoFrameFormat _cachedFormat; - int _cachedPixelFormat = 0; // QVideoFrameFormat::Format_Invalid sentinel - QString _cachedAllocatorName; // negotiated allocator from first sample of each caps change - - // Steady-clock ns checkpoint + matching total-frames snapshot for fps-over-window in _logFrameStats. - // Both mutated from the streaming thread; readers (currently none) get a relaxed view. - mutable std::atomic _lastStatsAtNs{0}; - mutable std::atomic _lastStatsTotal{0}; - - // Tracks the last total-frames count observed by the emit timer so we can suppress - // no-change frameCountsChanged() emissions (idle adapters waste ~60 emits/min otherwise). - quint64 _lastEmittedFrameTotal = 0; - - // Format warning throttle: only warn on first occurrence of each unsupported format. - std::atomic _lastWarnedFormat{GST_VIDEO_FORMAT_UNKNOWN}; - - // Counters are written from the GStreamer streaming thread and read from the GUI thread - // (Q_PROPERTY getters + telemetry timer). Atomics keep the read/write race TSan-clean. - std::atomic _cpuFrames{0}; - -#if defined(QGC_HAS_ANY_GPU_PATH) - bool _gpuPathEnabled = false; -#endif -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - // Set at setup() to a Wayland/X11 EGL display when zero-copy is enabled and - // resolvable. EGL_NO_DISPLAY disables the GPU path for this adapter instance. - EGLDisplay _eglDisplay = EGL_NO_DISPLAY; - std::atomic _gpuFrames{0}; // DMABuf zero-copy frames delivered -#endif -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - std::atomic _glFrames{0}; // GLMemory zero-copy frames delivered -#endif -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) - std::atomic _d3d11Frames{0}; // D3D11Memory zero-copy frames delivered -#endif -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) - std::atomic _d3d12Frames{0}; // D3D12Memory zero-copy frames delivered -#endif -#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) - std::atomic _iosurfaceFrames{0}; // IOSurface/CVPixelBuffer zero-copy frames delivered -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - EGLDisplay _ahwbEglDisplay = EGL_NO_DISPLAY; - std::atomic _ahwbFrames{0}; // AHardwareBuffer zero-copy frames delivered -#endif - - // QoS upstream feedback — EWMA mirrors gstbasesink.c DO_RUNNING_AVG window constants. - bool _qosUpstreamEnabled = true; - // Number of samples delivered so far; skip first kQosWarmup before sending events. - quint64 _qosSampleCount = 0; - // EWMA of actual interval / expected interval; >1.0 means we're consuming slower than source. - double _qosAvgRate = 1.0; - GstClockTime _qosLastPts = GST_CLOCK_TIME_NONE; - GstClockTime _qosLastArrivalNs = 0; - static constexpr quint64 kQosWarmup = 10; - static constexpr int kQosInterval = 8; // emit every N-th frame to avoid event spam - // Latency fields — all written and read exclusively on the streaming thread; - // teardown() resets them only after the pipeline is NULL (no concurrent streaming). - GstClockTime _pipelineMinLatencyNs = 0; - bool _latencyValid = false; // false until first successful GST_QUERY_LATENCY - std::atomic _latencyRefreshPending{false}; // set by requestLatencyRefresh() from any thread - static constexpr quint64 kLatencyRefreshInterval = 256; // re-query every N frames -}; diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstScoped.h b/src/VideoManager/VideoReceiver/GStreamer/GstScoped.h new file mode 100644 index 000000000000..1015167bf00f --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/GstScoped.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +/// Scoped owners for transfer-full GStreamer returns so refs can't leak on early return. +/// Use the adopt* helpers at the transfer-full call boundary; .get() yields a non-owning view. +namespace GStreamer { + +struct GstObjectDeleter +{ + // gst_object_unref takes gpointer; the wrapper keeps the pointer types clean at call sites. + void operator()(gpointer obj) const noexcept { gst_object_unref(obj); } +}; + +struct GstQueryDeleter +{ + void operator()(GstQuery* query) const noexcept { gst_query_unref(query); } +}; + +using GstObjectPtr = std::unique_ptr; +using GstFactoryPtr = std::unique_ptr; +using GstFeaturePtr = std::unique_ptr; +using GstQueryPtr = std::unique_ptr; + +inline GstFactoryPtr adoptFactory(GstElementFactory* factory) noexcept +{ + return GstFactoryPtr(factory); +} + +inline GstFeaturePtr adoptFeature(GstPluginFeature* feature) noexcept +{ + return GstFeaturePtr(feature); +} + +inline GstQueryPtr adoptQuery(GstQuery* query) noexcept +{ + return GstQueryPtr(query); +} + +} // namespace GStreamer diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc new file mode 100644 index 000000000000..588b1e44e702 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -0,0 +1,638 @@ +#include "GstSourceFactory.h" + +#include +#include +#include +#include + +#include "GStreamerHelpers.h" +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(GstSourceFactoryLog, "Video.GStreamer.GstSourceFactory") + +namespace { + +constexpr guint64 kRtspTcpTimeoutUs = G_GUINT64_CONSTANT(5000000); +constexpr int kRtspRetry = 3; +constexpr int kUdpBufferSizeBytes = 8 * 1024 * 1024; + +// GStreamer < 1.28 needs an autoplug-query caps filter to keep parsebin on byte-stream output. +#if !GST_CHECK_VERSION(1, 28, 0) +gboolean filterParserCaps([[maybe_unused]] GstElement* bin, [[maybe_unused]] GstPad* pad, + [[maybe_unused]] GstElement* element, GstQuery* query, [[maybe_unused]] gpointer data) +{ + if (GST_QUERY_TYPE(query) != GST_QUERY_CAPS) { + return FALSE; + } + + GstCaps* srcCaps; + gst_query_parse_caps(query, &srcCaps); + if (!srcCaps || gst_caps_is_any(srcCaps)) { + return FALSE; + } + + GstCaps* sinkCaps = nullptr; + GstCaps* filter = nullptr; + GstStructure* structure = gst_caps_get_structure(srcCaps, 0); + if (gst_structure_has_name(structure, "video/x-h265")) { + filter = gst_caps_from_string("video/x-h265"); + if (gst_caps_can_intersect(srcCaps, filter)) { + sinkCaps = gst_caps_from_string("video/x-h265,stream-format=hvc1"); + } + gst_clear_caps(&filter); + } else if (gst_structure_has_name(structure, "video/x-h264")) { + filter = gst_caps_from_string("video/x-h264"); + if (gst_caps_can_intersect(srcCaps, filter)) { + sinkCaps = gst_caps_from_string("video/x-h264,stream-format=avc"); + } + gst_clear_caps(&filter); + } + + if (sinkCaps) { + gst_query_set_caps_result(query, sinkCaps); + gst_clear_caps(&sinkCaps); + return TRUE; + } + + return FALSE; +} +#endif + +void wrapWithGhostPad(GstElement* element, GstPad* pad, [[maybe_unused]] gpointer data) +{ + gchar* name = gst_pad_get_name(pad); + if (!name) { + qCCritical(GstSourceFactoryLog) << "gst_pad_get_name() failed"; + return; + } + + GstPad* ghostpad = gst_ghost_pad_new(name, pad); + if (!ghostpad) { + qCCritical(GstSourceFactoryLog) << "gst_ghost_pad_new() failed"; + g_clear_pointer(&name, g_free); + return; + } + + g_clear_pointer(&name, g_free); + + (void) gst_pad_set_active(ghostpad, TRUE); + + // gst_object_get_parent takes a ref (GST_ELEMENT_PARENT does not) — guards against a + // concurrent bin teardown finalizing the parent before gst_element_add_pad. + GstObject* parent = gst_object_get_parent(GST_OBJECT(element)); + if (parent) { + if (!gst_element_add_pad(GST_ELEMENT(parent), ghostpad)) { + qCCritical(GstSourceFactoryLog) << "gst_element_add_pad() failed"; + // add_pad only sinks the ghost pad's floating ref on success; release it on failure. + gst_object_unref(ghostpad); + } + gst_object_unref(parent); + } else { + qCWarning(GstSourceFactoryLog) << "wrapWithGhostPad: element has no parent — bin already torn down?"; + gst_object_unref(ghostpad); + } +} + +gboolean padProbe([[maybe_unused]] GstElement* element, GstPad* pad, gpointer user_data) +{ + int* probeRes = static_cast(user_data); + *probeRes |= 1; + + GstCaps* filter = gst_caps_from_string("application/x-rtp"); + if (filter) { + GstCaps* caps = gst_pad_query_caps(pad, nullptr); + if (caps) { + if (!gst_caps_is_any(caps) && gst_caps_can_intersect(caps, filter)) { + *probeRes |= 2; + } + + gst_clear_caps(&caps); + } + + gst_clear_caps(&filter); + } + + return TRUE; +} + +bool addStaticGhostPad(GstElement* element) +{ + GstPad* srcPad = gst_element_get_static_pad(element, "src"); + if (!srcPad) { + qCCritical(GstSourceFactoryLog) << "gst_element_get_static_pad('src') failed"; + return false; + } + + wrapWithGhostPad(element, srcPad, nullptr); + gst_object_unref(srcPad); + return true; +} + +bool validPort(int port) +{ + return port > 0 && port <= 65535; +} + +} // namespace + +namespace GStreamer::SourceFactory { + +namespace { + +// Shared by the static and dynamic (pad-added) link paths so both apply the identical jitter-buffer +// policy. rtx-delay/rtx-max-retries are fixed (not auto) so RF-link recovery is predictable. +void configureJitterBuffer(GstElement* buffer, const Config& config, guint latencyMs) +{ + g_object_set(buffer, "latency", latencyMs, "do-lost", TRUE, "do-retransmission", + config.doRetransmission ? TRUE : FALSE, "drop-on-latency", + config.jitterBuffer == JitterBuffer::DropOnLatency ? TRUE : FALSE, nullptr); + if (config.doRetransmission) { + // Fixed 25 ms rtx-delay + single retry: bounded recovery latency over a lossy RF link, + // instead of the auto(-1) heuristic that can stack retries and balloon playout latency. + g_object_set(buffer, "rtx-delay", 25, "rtx-max-retries", 1, nullptr); + } +} + +// Heap-owned context for the dynamic (pad-added) link path; freed when @binParser is finalized. +struct DynamicLinkContext +{ + GstElement* binParser; + Config config; + guint latencyMs; + bool allowJitterBuffer; // false for RTSP (rtspsrc has its own internal jitterbuffer) +}; + +void linkPad(GstElement* element, GstPad* pad, gpointer data) +{ + const auto* ctx = static_cast(data); + + // tsdemux fires pad-added for audio/data pads too; only link video src pads so non-video + // pads don't trigger a spurious CRITICAL cascade on the expected link failure. + if (GST_PAD_DIRECTION(pad) != GST_PAD_SRC) { + return; + } + + bool isVideo = false; + bool isRtp = false; + GstCaps* caps = gst_pad_get_current_caps(pad); + if (!caps) { + caps = gst_pad_query_caps(pad, nullptr); + } + if (caps) { + const guint n = gst_caps_get_size(caps); + for (guint i = 0; i < n; ++i) { + const GstStructure* st = gst_caps_get_structure(caps, i); + const gchar* sname = gst_structure_get_name(st); + if (!sname) { + continue; + } + if (g_str_has_prefix(sname, "video/")) { + isVideo = true; + } else if (g_str_equal(sname, "application/x-rtp")) { + isRtp = true; + // RTP carries video; the depayloader/parsebin downstream classifies the payload. + isVideo = true; + } + } + gst_clear_caps(&caps); + } + if (!isVideo) { + return; + } + + // Link only the first matching video pad. A second pad-added (duplicate RTP pad, or a later + // tsdemux elementary stream) must not stack a second jitterbuffer or re-link an occupied sink. + if (GstPad* parserSink = gst_element_get_static_pad(ctx->binParser, "sink")) { + const gboolean alreadyLinked = gst_pad_is_linked(parserSink); + gst_object_unref(parserSink); + if (alreadyLinked) { + return; + } + } + + // Insert a jitterbuffer ahead of the parser for RTP pads on non-RTSP dynamic sources, matching the + // static path's policy. RTSP is excluded: rtspsrc owns an internal jitterbuffer (no double-buffering). + GstElement* downstream = ctx->binParser; + GstElement* dynamicBuffer = nullptr; + const auto removeDynamicBuffer = [&dynamicBuffer] { + if (!dynamicBuffer) { + return; + } + + GstObject* parent = gst_object_get_parent(GST_OBJECT(dynamicBuffer)); + if (parent) { + gst_bin_remove(GST_BIN(parent), dynamicBuffer); + gst_object_unref(parent); + } + dynamicBuffer = nullptr; + }; + + if (isRtp && ctx->allowJitterBuffer && (ctx->config.jitterBuffer != JitterBuffer::None)) { + GstElement* bin = GST_ELEMENT(gst_object_get_parent(GST_OBJECT(ctx->binParser))); + if (bin) { + GstElement* buffer = gst_element_factory_make("rtpjitterbuffer", nullptr); + if (buffer && gst_bin_add(GST_BIN(bin), buffer)) { + configureJitterBuffer(buffer, ctx->config, ctx->latencyMs); + if (gst_element_sync_state_with_parent(buffer) && gst_element_link(buffer, ctx->binParser)) { + downstream = buffer; + dynamicBuffer = buffer; + } else { + qCWarning(GstSourceFactoryLog) + << "dynamic rtpjitterbuffer link/sync failed; linking pad straight to parser"; + gst_bin_remove(GST_BIN(bin), buffer); + buffer = nullptr; + } + } else { + qCWarning(GstSourceFactoryLog) << "dynamic rtpjitterbuffer create/add failed; skipping jitter buffer"; + if (buffer) { + gst_object_unref(buffer); + } + } + gst_object_unref(bin); + } + } + + gchar* name = gst_pad_get_name(pad); + if (!name) { + qCWarning(GstSourceFactoryLog) << "gst_pad_get_name() failed"; + removeDynamicBuffer(); + return; + } + + if (!gst_element_link_pads(element, name, downstream, "sink")) { + qCWarning(GstSourceFactoryLog) << "gst_element_link_pads() failed for" << name; + removeDynamicBuffer(); + } + + g_clear_pointer(&name, g_free); +} + +// Each build* helper makes and configures the source element for one scheme family, logs the +// specific failure, and returns the owning element (or nullptr). create() owns it from here on. + +GstElement* buildRtspSource(const QString& uri, const QUrl& sourceUrl, const Config& config, guint latencyMs) +{ + if (!GStreamer::isValidRtspUri(uri.toUtf8().constData())) { + qCCritical(GstSourceFactoryLog) << "Invalid RTSP URI:" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + return nullptr; + } + + GstElement* source = gst_element_factory_make("rtspsrc", "source"); + if (!source) { + qCCritical(GstSourceFactoryLog) << "gst_element_factory_make('rtspsrc') failed"; + return nullptr; + } + + QUrl cleanUrl(sourceUrl); + cleanUrl.setUserInfo(QString()); + const QByteArray cleanLocation = cleanUrl.toEncoded(); + + // protocols mask enables TCP-interleaved fallback when UDP is blocked; without it + // firewalled networks hang until tcp-timeout instead of negotiating TCP. + constexpr GstRTSPLowerTrans kRtspProtocols = + static_cast(GST_RTSP_LOWER_TRANS_UDP | GST_RTSP_LOWER_TRANS_TCP); + + // do-retransmission forwards to rtspsrc's internal rtpjitterbuffer (added 1.6); + // drop-on-latency=TRUE unless jitterBuffer==Buffered (opt out of bounded playout). + const gboolean dropOnLatency = (config.jitterBuffer == JitterBuffer::Buffered) ? FALSE : TRUE; + g_object_set(source, "location", cleanLocation.constData(), "latency", latencyMs, "do-rtcp", TRUE, + "do-retransmission", config.doRetransmission ? TRUE : FALSE, "tcp-timeout", kRtspTcpTimeoutUs, + "udp-reconnect", TRUE, "drop-on-latency", dropOnLatency, "retry", kRtspRetry, "protocols", + kRtspProtocols, nullptr); + + const QString rtspUser = sourceUrl.userName(QUrl::FullyDecoded); + const QString rtspPassword = sourceUrl.password(QUrl::FullyDecoded); + if (!rtspUser.isEmpty()) { + g_object_set(source, "user-id", rtspUser.toUtf8().constData(), "user-pw", + rtspPassword.toUtf8().constData(), nullptr); + } + return source; +} + +GstElement* buildTcpSource(const QUrl& sourceUrl) +{ + const int port = sourceUrl.port(); + if (!validPort(port)) { + qCCritical(GstSourceFactoryLog) << "Invalid TCP port" << port << "in" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + return nullptr; + } + const QString host = sourceUrl.host(); + if (host.isEmpty()) { + qCCritical(GstSourceFactoryLog) << "Missing host in TCP URI" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + return nullptr; + } + + GstElement* source = gst_element_factory_make("tcpclientsrc", "source"); + if (!source) { + qCCritical(GstSourceFactoryLog) << "gst_element_factory_make('tcpclientsrc') failed"; + return nullptr; + } + + g_object_set(source, "host", host.toUtf8().constData(), "port", static_cast(port), nullptr); + return source; +} + +GstElement* buildUdpSource(const QUrl& sourceUrl, bool isUdpH264, bool isUdpH265) +{ + const int port = sourceUrl.port(); + if (!validPort(port)) { + qCCritical(GstSourceFactoryLog) << "Invalid UDP port" << port << "in" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + return nullptr; + } + + GstElement* source = gst_element_factory_make("udpsrc", "source"); + if (!source) { + qCCritical(GstSourceFactoryLog) << "gst_element_factory_make('udpsrc') failed"; + return nullptr; + } + + // Normalize udp265:///mpegts:// to udp:// for udpsrc, preserving query params. + QUrl udpUrl(sourceUrl); + udpUrl.setScheme(QStringLiteral("udp")); + udpUrl.setUserInfo(QString()); + const QByteArray udpUri = udpUrl.toEncoded(); + + // SO_RCVBUF above net.core.rmem_max needs CAP_NET_ADMIN; without it gstudpsrc fails with EPERM + // (scary warning) then clamps. Request the kernel-permitted max instead (Linux/Android only). + int udpBufferSize = kUdpBufferSizeBytes; +#ifdef Q_OS_LINUX + // rmem_max is a boot-time sysctl; parse once and cache (0 = unreadable, leave request unclamped). + static const int s_rmemMax = [] { + QFile rmemMaxFile(QStringLiteral("/proc/sys/net/core/rmem_max")); + if (rmemMaxFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + bool ok = false; + const int sysMax = rmemMaxFile.readAll().trimmed().toInt(&ok); + if (ok && (sysMax > 0)) { + return sysMax; + } + } + return 0; + }(); + if ((s_rmemMax > 0) && (s_rmemMax < udpBufferSize)) { + qCDebug(GstSourceFactoryLog) << "Clamping UDP buffer-size from" << udpBufferSize << "to net.core.rmem_max" + << s_rmemMax; + udpBufferSize = s_rmemMax; + } +#endif + g_object_set(source, "uri", udpUri.constData(), "buffer-size", udpBufferSize, nullptr); + + GstCaps* caps = nullptr; + if (isUdpH264) { + caps = gst_caps_from_string( + "application/x-rtp, media=(string)video, clock-rate=(int)90000, encoding-name=(string)H264"); + } else if (isUdpH265) { + caps = gst_caps_from_string( + "application/x-rtp, media=(string)video, clock-rate=(int)90000, encoding-name=(string)H265"); + } + + if ((isUdpH264 || isUdpH265) && !caps) { + qCCritical(GstSourceFactoryLog) << "gst_caps_from_string() failed"; + gst_object_unref(source); + return nullptr; + } + + if (caps) { + g_object_set(source, "caps", caps, nullptr); + gst_clear_caps(&caps); + } + return source; +} + +// Wire upstream → (optional rtpjitterbuffer) → binParser, topology chosen by RTP probe (MPEG-TS +// links via pad-added). Created elements join @p bin; returns false (logged) on failure. +bool linkSourceToParser(GstElement* bin, GstElement* upstream, GstElement* binParser, const Config& config, + guint latencyMs, bool isMpegTs, bool isRtsp) +{ + // MPEG-TS exposes elementary streams via tsdemux dynamic src pads (none at NULL state) and + // isn't raw RTP, so skip the RTP probe and link via pad-added when the video pad appears. + int probeRes = 0; + if (!isMpegTs) { + (void) gst_element_foreach_src_pad(upstream, padProbe, &probeRes); + } + + if (probeRes & 1) { + if ((probeRes & 2) && config.jitterBuffer != JitterBuffer::None) { + GstElement* buffer = gst_element_factory_make("rtpjitterbuffer", nullptr); + if (!buffer) { + qCCritical(GstSourceFactoryLog) << "gst_element_factory_make('rtpjitterbuffer') failed"; + return false; + } + + configureJitterBuffer(buffer, config, latencyMs); + + if (!gst_bin_add(GST_BIN(bin), buffer)) { + qCCritical(GstSourceFactoryLog) << "gst_bin_add(rtpjitterbuffer) failed"; + gst_object_unref(buffer); + return false; + } + // bin owns buffer now; a link failure below is cleaned up via the caller's bin teardown. + if (!gst_element_link_many(upstream, buffer, binParser, nullptr)) { + qCCritical(GstSourceFactoryLog) << "gst_element_link_many(source, rtpjitterbuffer, parser) failed"; + return false; + } + } else { + if (!gst_element_link(upstream, binParser)) { + qCCritical(GstSourceFactoryLog) << "gst_element_link(source, parser) failed"; + return false; + } + } + } else { + // linkPad applies the jitter-buffer policy itself when an application/x-rtp pad appears on a + // non-RTSP source. Context lifetime is tied to binParser (freed on its finalize). + auto* ctx = new DynamicLinkContext{binParser, config, latencyMs, !isRtsp}; + g_object_set_data_full(G_OBJECT(binParser), "qgc-dynamic-link-ctx", ctx, + [](gpointer p) { delete static_cast(p); }); + (void) g_signal_connect(upstream, "pad-added", G_CALLBACK(linkPad), ctx); + } + + // pad-added fires on the streaming thread after create() returns the NULL-state bin, so it + // can't race construction; wrapWithGhostPad ref-checks the parent against teardown. + (void) g_signal_connect(binParser, "pad-added", G_CALLBACK(wrapWithGhostPad), nullptr); + return true; +} + +bool linkUdpRtpToDepayAndParser(GstElement* bin, GstElement* source, GstElement* depay, GstElement* parser, + const Config& config, guint latencyMs) +{ + if (config.jitterBuffer == JitterBuffer::None) { + if (!gst_element_link_many(source, depay, parser, nullptr)) { + qCCritical(GstSourceFactoryLog) << "gst_element_link_many(source, depay, parser) failed"; + return false; + } + return true; + } + + GstElement* buffer = gst_element_factory_make("rtpjitterbuffer", nullptr); + if (!buffer) { + qCCritical(GstSourceFactoryLog) << "gst_element_factory_make('rtpjitterbuffer') failed"; + return false; + } + + configureJitterBuffer(buffer, config, latencyMs); + + if (!gst_bin_add(GST_BIN(bin), buffer)) { + qCCritical(GstSourceFactoryLog) << "gst_bin_add(rtpjitterbuffer) failed"; + gst_object_unref(buffer); + return false; + } + + if (!gst_element_link_many(source, buffer, depay, parser, nullptr)) { + qCCritical(GstSourceFactoryLog) << "gst_element_link_many(source, rtpjitterbuffer, depay, parser) failed"; + return false; + } + + return true; +} + +} // namespace + +GstElement* create(const QString& uri, const Config& config) +{ + if (uri.isEmpty()) { + qCCritical(GstSourceFactoryLog) << "Failed because URI is not specified"; + return nullptr; + } + + const guint latencyMs = (config.latencyMs < 0) ? 0u : static_cast(config.latencyMs); + + const QUrl sourceUrl(uri); + const QString scheme = sourceUrl.scheme().toLower(); + + const bool isRtsp = scheme.startsWith(QLatin1String("rtsp")); + const bool isUdpH264 = (scheme == QLatin1String("udp")); + const bool isUdpH265 = (scheme == QLatin1String("udp265")); + const bool isUdpMPEGTS = (scheme == QLatin1String("mpegts")); + const bool isTcpMPEGTS = (scheme == QLatin1String("tcp")); + + if (!isRtsp && !isUdpH264 && !isUdpH265 && !isUdpMPEGTS && !isTcpMPEGTS) { + qCWarning(GstSourceFactoryLog) << "Unsupported URI scheme:" << scheme << "in" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + return nullptr; + } + + // Owning locals until gst_bin_add*, then nulled (non-owning alias used downstream) so the + // unconditional gst_clear_object cleanup at the bottom stays safe. + GstElement* source = nullptr; + GstElement* parser = nullptr; + GstElement* rtpDepay = nullptr; + GstElement* tsdemux = nullptr; + GstElement* bin = nullptr; + GstElement* srcbin = nullptr; + + do { + if (isRtsp) { + source = buildRtspSource(uri, sourceUrl, config, latencyMs); + } else if (isTcpMPEGTS) { + source = buildTcpSource(sourceUrl); + } else { // isUdpH264 || isUdpH265 || isUdpMPEGTS + source = buildUdpSource(sourceUrl, isUdpH264, isUdpH265); + } + if (!source) { + break; // helper logged the specific failure + } + + bin = gst_bin_new("sourcebin"); + if (!bin) { + qCCritical(GstSourceFactoryLog) << "gst_bin_new('sourcebin') failed"; + break; + } + + parser = gst_element_factory_make(isUdpH265 ? "h265parse" : "parsebin", "parser"); + if (!parser) { + qCCritical(GstSourceFactoryLog) << "gst_element_factory_make(" + << (isUdpH265 ? "'h265parse'" : "'parsebin'") << ") failed"; + break; + } + + if (isUdpH265) { + // UDP H.265 has no SDP/RTSP control plane. Make depayloading and parser config explicit + // so hardware decoders, especially Android MediaCodec, see repeated VPS/SPS/PPS at IDR + // boundaries instead of depending on parsebin's defaults after startup or reconnect. + rtpDepay = gst_element_factory_make("rtph265depay", nullptr); + if (!rtpDepay) { + qCCritical(GstSourceFactoryLog) << "gst_element_factory_make('rtph265depay') failed"; + break; + } + g_object_set(parser, "config-interval", -1, nullptr); + } + + // GStreamer <1.28 misnegotiates parser->decoder caps; force avc/hvc1. 1.28+ fixes it + // and the forced caps break byte-stream HW decoders (Qualcomm AMC, D3D12). +#if !GST_CHECK_VERSION(1, 28, 0) + if (!isUdpH265) { + (void) g_signal_connect(parser, "autoplug-query", G_CALLBACK(filterParserCaps), nullptr); + } +#endif + + // Add individually so ownership is unambiguous on failure: gst_bin_add sinks the ref only + // on success, so the local stays owning (and gets unref'd by the cleanup block) when it fails. + if (!gst_bin_add(GST_BIN(bin), source)) { + qCCritical(GstSourceFactoryLog) << "gst_bin_add(source) failed"; + break; + } + GstElement* upstream = source; + source = nullptr; + + if (rtpDepay && !gst_bin_add(GST_BIN(bin), rtpDepay)) { + qCCritical(GstSourceFactoryLog) << "gst_bin_add(rtph265depay) failed"; + break; + } + GstElement* depay = rtpDepay; + rtpDepay = nullptr; + + if (!gst_bin_add(GST_BIN(bin), parser)) { + qCCritical(GstSourceFactoryLog) << "gst_bin_add(parser) failed"; + break; + } + GstElement* binParser = parser; + parser = nullptr; + + // Android can't determine MPEG2-TS via parsebin, so create tsdemux explicitly. + if (isTcpMPEGTS || isUdpMPEGTS) { + tsdemux = gst_element_factory_make("tsdemux", nullptr); + if (!tsdemux) { + qCCritical(GstSourceFactoryLog) << "gst_element_factory_make('tsdemux') failed"; + break; + } + + if (!gst_bin_add(GST_BIN(bin), tsdemux)) { + qCCritical(GstSourceFactoryLog) << "gst_bin_add(tsdemux) failed"; + break; + } + GstElement* demux = tsdemux; + tsdemux = nullptr; + + if (!gst_element_link(upstream, demux)) { + qCCritical(GstSourceFactoryLog) << "gst_element_link(source, tsdemux) failed"; + break; + } + + upstream = demux; + } + + if (isUdpH265) { + if (!linkUdpRtpToDepayAndParser(bin, upstream, depay, binParser, config, latencyMs)) { + break; // helper logged the specific failure + } + if (!addStaticGhostPad(binParser)) { + break; + } + } else { + if (!linkSourceToParser(bin, upstream, binParser, config, latencyMs, isTcpMPEGTS || isUdpMPEGTS, isRtsp)) { + break; // helper logged the specific failure + } + } + + srcbin = bin; + bin = nullptr; + } while (0); + + gst_clear_object(&bin); + gst_clear_object(&parser); + gst_clear_object(&rtpDepay); + gst_clear_object(&tsdemux); + gst_clear_object(&source); + + return srcbin; +} + +} // namespace GStreamer::SourceFactory diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h new file mode 100644 index 000000000000..00c12ed127b5 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace GStreamer::SourceFactory { + +/// RTP jitter-buffer policy for sources that produce `application/x-rtp` caps. +/// Ignored for non-RTP sources (e.g. raw MPEG-TS over TCP). +enum class JitterBuffer +{ + None, ///< No `rtpjitterbuffer` element; lowest latency, no reordering. + DropOnLatency, ///< `rtpjitterbuffer` with `drop-on-latency=TRUE`. + Buffered, ///< `rtpjitterbuffer` with `drop-on-latency=FALSE`. +}; + +/// Source-bin construction parameters. Defaults match the drone-GCS profile: 80 ms playout +/// latency with RFC 4588 retransmission, leaving ~3× the 20 ms rtx-delay for packet recovery. +/// Callers wanting sub-frame latency should use JitterBuffer::None. +struct Config +{ + JitterBuffer jitterBuffer = JitterBuffer::DropOnLatency; + int latencyMs = 80; + bool doRetransmission = true; +}; + +/// Build a source bin (`source` [+ `tsdemux`] [+ `rtpjitterbuffer`] + `parsebin`) +/// for `uri`. Supported schemes: rtsp/rtspt, tcp:// (MPEG-TS), udp:// (H.264 RTP), +/// udp265:// (H.265 RTP), mpegts:// (MPEG-TS over UDP). +/// +/// Ghost pads on the returned bin are wired lazily; for `rtspsrc`/`tsdemux`/`parsebin` +/// they appear only after upstream produces pads, so callers must connect any +/// downstream `pad-added` handlers before transitioning to PLAYING. +/// +/// Returns the source bin or nullptr on failure. +GstElement* create(const QString& uri, const Config& config); + +} // namespace GStreamer::SourceFactory diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc index 8410409a23a9..b19dc24f48f8 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc @@ -10,16 +10,15 @@ #include "GstVideoReceiver.h" -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) -#include "HwBuffers/GstD3D11ContextBridge.h" -#endif -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) -#include "HwBuffers/GstD3D12ContextBridge.h" -#endif +#include "HwBuffers/common/HwBuffers.h" + #include "GStreamerHelpers.h" +#include "GstSourceFactory.h" #include "QGCLoggingCategory.h" +#include "QGCQVideoSinkController.h" #include +#include #include #include @@ -28,15 +27,35 @@ QGC_LOGGING_CATEGORY(GstVideoReceiverLog, "Video.GStreamer.GstVideoReceiver") -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) || defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH) -#include "HwBuffers/GstContextBridgeRegistry.h" namespace { -GstBusSyncReply _contextSyncDispatch(GstBus * /*bus*/, GstMessage *message, gpointer /*data*/) +// kEosTimeoutNs: bus wait budget for EOS/ERROR during stop(); 3 s covers slow hw decoders. +constexpr GstClockTime kEosTimeoutNs = 3 * GST_SECOND; + +// Refs the element's first src pad into *userData and stops iterating. Resync is handled +// internally by gst_element_foreach_src_pad (unlike a bare gst_iterator_next loop). +gboolean grabFirstSrcPad(GstElement * /*element*/, GstPad *pad, gpointer userData) +{ + *static_cast(userData) = GST_PAD(gst_object_ref(pad)); + return FALSE; +} + +bool isRecoverableH265PaciError(GstMessage *msg, const GError *error, const gchar *debug) { - return GstContextBridgeRegistry::dispatchBridges(message); + if (!msg || !error || (error->domain != GST_STREAM_ERROR) || (error->code != GST_STREAM_ERROR_FORMAT) || + !debug || !g_strrstr(debug, "NAL unit type 50 not supported yet")) { + return false; + } + + GstObject *src = GST_MESSAGE_SRC(msg); + if (!src || !GST_IS_ELEMENT(src)) { + return false; + } + + GstElementFactory *factory = gst_element_get_factory(GST_ELEMENT(src)); + return factory && (g_strcmp0(GST_OBJECT_NAME(factory), "rtph265depay") == 0); } + } // namespace -#endif GstVideoReceiver::GstVideoReceiver(QObject *parent) : VideoReceiver(parent) @@ -65,13 +84,13 @@ void GstVideoReceiver::start(uint32_t timeout) if (_pipeline) { qCDebug(GstVideoReceiverLog) << "Already running!" << _uri; - _dispatchSignal([this]() { emit onStartComplete(STATUS_INVALID_STATE); }); + emit onStartComplete(STATUS_INVALID_STATE); return; } if (_uri.isEmpty()) { qCDebug(GstVideoReceiverLog) << "Failed because URI is not specified"; - _dispatchSignal([this]() { emit onStartComplete(STATUS_INVALID_URL); }); + emit onStartComplete(STATUS_INVALID_URL); return; } @@ -80,6 +99,16 @@ void GstVideoReceiver::start(uint32_t timeout) qCDebug(GstVideoReceiverLog) << "Starting" << _uri << ", lowLatency" << lowLatency() << ", timeout" << _timeout; + // GST_DEBUG_BIN_TO_DOT_FILE is a no-op unless GST_DEBUG_DUMP_DOT_DIR is set; surface that + // once per process so field debugging doesn't require re-reading the source. + [[maybe_unused]] static const bool dotDirHinted = []() { + if (qgetenv("GST_DEBUG_DUMP_DOT_DIR").isEmpty()) { + qCInfo(GstVideoReceiverLog).noquote() + << "Pipeline dot-graph dumps are disabled. Set GST_DEBUG_DUMP_DOT_DIR=/path/to/dir to enable."; + } + return true; + }(); + _endOfStream = false; bool running = false; @@ -117,6 +146,16 @@ void GstVideoReceiver::start(uint32_t timeout) break; } + // leaky=downstream (2) + tiny depth: the live-display branch must drop the oldest + // buffer on backpressure, not stall the streaming thread. Recording branch (below) + // keeps default non-leaky semantics so every frame reaches the muxer. + g_object_set(decoderQueue, + "leaky", 2, + "max-size-buffers", 2, + "max-size-bytes", 0, + "max-size-time", G_GUINT64_CONSTANT(0), + nullptr); + _decoderValve = gst_element_factory_make("valve", nullptr); if (!_decoderValve) { qCCritical(GstVideoReceiverLog) << "gst_element_factory_make('valve') failed"; @@ -153,9 +192,19 @@ void GstVideoReceiver::start(uint32_t timeout) "message-forward", TRUE, nullptr); - _source = _makeSource(_uri); + GStreamer::SourceFactory::Config sourceConfig; + sourceConfig.jitterBuffer = (_buffer < 0) + ? GStreamer::SourceFactory::JitterBuffer::None + : (_buffer == 0 + ? GStreamer::SourceFactory::JitterBuffer::DropOnLatency + : GStreamer::SourceFactory::JitterBuffer::Buffered); + sourceConfig.latencyMs = _rtpJitterLatencyMs; + // do-retransmission needs ≥40 ms latency headroom over the default 20 ms rtx-delay; + // forcibly disable for sub-frame latency configurations to avoid retransmit storms. + sourceConfig.doRetransmission = (_rtpJitterLatencyMs >= 40) && (sourceConfig.jitterBuffer != GStreamer::SourceFactory::JitterBuffer::None); + _source = GStreamer::SourceFactory::create(_uri, sourceConfig); if (!_source) { - qCCritical(GstVideoReceiverLog) << "_makeSource() failed"; + qCCritical(GstVideoReceiverLog) << "SourceFactory::create() failed"; break; } @@ -164,22 +213,7 @@ void GstVideoReceiver::start(uint32_t timeout) pipelineUp = true; GstPad *srcPad = nullptr; - GstIterator *it = gst_element_iterate_src_pads(_source); - GValue vpad = G_VALUE_INIT; - switch (gst_iterator_next(it, &vpad)) { - case GST_ITERATOR_OK: - srcPad = GST_PAD(g_value_get_object(&vpad)); - (void) gst_object_ref(srcPad); - (void) g_value_reset(&vpad); - break; - case GST_ITERATOR_RESYNC: - gst_iterator_resync(it); - break; - default: - break; - } - g_value_unset(&vpad); - gst_iterator_free(it); + (void) gst_element_foreach_src_pad(_source, grabFirstSrcPad, &srcPad); if (srcPad) { _onNewSourcePad(srcPad); @@ -202,14 +236,10 @@ void GstVideoReceiver::start(uint32_t timeout) if (bus) { gst_bus_enable_sync_message_emission(bus); (void) g_signal_connect(bus, "sync-message", G_CALLBACK(_onBusMessage), this); -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) || defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH) - // Single sync dispatcher chains every compiled context bridge so - // they don't clobber each other via gst_bus_set_sync_handler. Must - // run before GST_STATE_PLAYING — upstream queries context during - // PAUSED→PLAYING. Each bridge cheap-rejects messages it doesn't - // serve, so total cost on irrelevant messages is a strcmp. - gst_bus_set_sync_handler(bus, _contextSyncDispatch, nullptr, nullptr); -#endif + // HwBuffers facade chains every compiled context bridge so they don't clobber each + // other via gst_bus_set_sync_handler. Must run before GST_STATE_PLAYING — upstream + // queries context during PAUSED→PLAYING. No-op when no bridge-using GPU path is compiled. + gst_bus_set_sync_handler(bus, HwBuffers::onBusSyncMessage, nullptr, nullptr); gst_clear_object(&bus); } @@ -235,17 +265,15 @@ void GstVideoReceiver::start(uint32_t timeout) gst_clear_object(&_source); } - // Rate limit restarts on failure. This sleep is OK because we're in the video worker thread. - QThread::sleep(1); - _dispatchSignal([this]() { emit onStartComplete(STATUS_FAIL); }); + emit onStartComplete(STATUS_FAIL); } else { GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-started"); qCDebug(GstVideoReceiverLog) << "Started" << _uri; - // _watchdogTimer lives on `this` (GUI thread); _dispatchSignal runs synchronously on the + // _watchdogTimer lives on `this` (GUI thread); the emit runs synchronously on the // worker thread, so the timer start has to be queued separately or QObject warns. QMetaObject::invokeMethod(this, [this]() { _watchdogTimer.start(1000); }, Qt::QueuedConnection); - _dispatchSignal([this]() { emit onStartComplete(STATUS_OK); }); + emit onStartComplete(STATUS_OK); } } @@ -263,6 +291,10 @@ void GstVideoReceiver::stop() qCDebug(GstVideoReceiverLog) << "Stopping" << _uri; + // Bump the epoch synchronously (atomic — no GUI thread needed) so any in-flight reconnect lambda + // is superseded before this stop() returns; cross-callsite QueuedConnection FIFO is not guaranteed. + _reconnectEpoch.fetch_add(1, std::memory_order_relaxed); + // Only _watchdogTimer.stop() must run on the GUI thread (the timer lives on `this`). QMetaObject::invokeMethod(this, [this]() { _watchdogTimer.stop(); }, Qt::QueuedConnection); if (_teeProbeId != 0) { @@ -288,22 +320,51 @@ void GstVideoReceiver::stop() if (!recordingValveClosed) { (void) gst_element_send_event(_pipeline, gst_event_new_eos()); - GstMessage *msg = gst_bus_timed_pop_filtered(bus, GST_CLOCK_TIME_NONE, (GstMessageType)(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); - if (msg) { + // Wait for splitmuxsink to actually finalize its current fragment. async-finalize + // pushes muxer teardown off the streaming thread; the splitmuxsink-fragment-closed + // element message is posted (via message-forward=TRUE) exactly when the muxer's + // state has gone NULL. EOS is the fallback for older builds / unexpected paths; + // ERROR breaks out so we don't burn the full budget on a known failure. Track + // elapsed time so unrelated ELEMENT messages don't abort the wait early. + const GstClockTime deadline = kEosTimeoutNs; + const qint64 startMs = QDateTime::currentMSecsSinceEpoch(); + bool finalized = false; + for (;;) { + const qint64 elapsedNs = (QDateTime::currentMSecsSinceEpoch() - startMs) + * qint64(GST_MSECOND); + if (elapsedNs >= qint64(deadline)) break; + const GstClockTime remaining = GstClockTime(qint64(deadline) - elapsedNs); + GstMessage *msg = gst_bus_timed_pop_filtered(bus, remaining, + (GstMessageType)(GST_MESSAGE_EOS | GST_MESSAGE_ERROR | GST_MESSAGE_ELEMENT)); + if (!msg) break; switch (GST_MESSAGE_TYPE(msg)) { + case GST_MESSAGE_ELEMENT: { + const GstStructure *s = gst_message_get_structure(msg); + if (s && gst_structure_has_name(s, "splitmuxsink-fragment-closed")) { + qCDebug(GstVideoReceiverLog) << "splitmuxsink fragment finalized"; + finalized = true; + } + break; + } case GST_MESSAGE_EOS: - qCDebug(GstVideoReceiverLog) << "End of stream received!"; + qCDebug(GstVideoReceiverLog) << "End of stream received (fallback path)"; + finalized = true; break; case GST_MESSAGE_ERROR: qCCritical(GstVideoReceiverLog) << "Error stopping pipeline!"; + finalized = true; break; default: break; } - gst_clear_message(&msg); - } else { - qCCritical(GstVideoReceiverLog) << "gst_bus_timed_pop_filtered() failed"; + if (finalized) break; + } + if (!finalized) { + qCWarning(GstVideoReceiverLog) << "splitmuxsink finalize signal not received within" + << (kEosTimeoutNs / GST_MSECOND) + << "ms — forcing pipeline NULL (recording may be truncated; " + << "faststart + reserved-moov-update-period keep the file playable)"; } } @@ -326,8 +387,13 @@ void GstVideoReceiver::stop() GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-stopped"); - gst_clear_object(&_pipeline); - _pipeline = nullptr; + // Lock before nulling so an in-flight _onBusMessage on the streaming thread cannot read + // a half-destroyed _pipeline. _acquirePipelineRef takes its own ref under the same lock. + { + QMutexLocker lock(&_pipelineMutex); + gst_clear_object(&_pipeline); + _pipeline = nullptr; + } _recorderValve = nullptr; _decoderValve = nullptr; @@ -339,7 +405,7 @@ void GstVideoReceiver::stop() if (_streaming) { _streaming = false; qCDebug(GstVideoReceiverLog) << "Streaming stopped" << _uri; - _dispatchSignal([this]() { emit streamingChanged(_streaming); }); + emit streamingChanged(_streaming); } else { qCDebug(GstVideoReceiverLog) << "Streaming did not start" << _uri; } @@ -347,7 +413,12 @@ void GstVideoReceiver::stop() qCDebug(GstVideoReceiverLog) << "Stopped" << _uri; - _dispatchSignal([this]() { emit onStopComplete(STATUS_OK); }); + if (const HwBuffers::PathStats hwStats = HwBuffers::formatPathStats(true); hwStats.totalDelivered > 0) { + qCInfo(GstVideoReceiverLog).noquote() + << "HW path stats" << _uri << hwStats.line + HwBuffers::takeExtraPathStats(); + } + + emit onStopComplete(STATUS_OK); } void GstVideoReceiver::startDecoding(void *sink) @@ -366,7 +437,7 @@ void GstVideoReceiver::startDecoding(void *sink) if (!_widget) { qCDebug(GstVideoReceiverLog) << "Video Widget is NULL" << _uri; - _dispatchSignal([this]() { emit onStartDecodingComplete(STATUS_FAIL); }); + emit onStartDecodingComplete(STATUS_FAIL); return; } @@ -376,7 +447,7 @@ void GstVideoReceiver::startDecoding(void *sink) if (_videoSink || _decoding) { qCDebug(GstVideoReceiverLog) << "Already decoding!" << _uri; - _dispatchSignal([this]() { emit onStartDecodingComplete(STATUS_INVALID_STATE); }); + emit onStartDecodingComplete(STATUS_INVALID_STATE); return; } @@ -384,7 +455,7 @@ void GstVideoReceiver::startDecoding(void *sink) GstPad *pad = gst_element_get_static_pad(videoSink, "sink"); if (!pad) { qCCritical(GstVideoReceiverLog) << "Unable to find sink pad of video sink" << _uri; - _dispatchSignal([this]() { emit onStartDecodingComplete(STATUS_FAIL); }); + emit onStartDecodingComplete(STATUS_FAIL); return; } @@ -400,7 +471,7 @@ void GstVideoReceiver::startDecoding(void *sink) _removingDecoder = false; if (!_streaming) { - _dispatchSignal([this]() { emit onStartDecodingComplete(STATUS_OK); }); + emit onStartDecodingComplete(STATUS_OK); return; } @@ -409,7 +480,7 @@ void GstVideoReceiver::startDecoding(void *sink) if (!_addDecoder(_decoderValve)) { qCCritical(GstVideoReceiverLog) << "_addDecoder() failed" << _uri; _shutdownDecodingBranch(); - _dispatchSignal([this]() { emit onStartDecodingComplete(STATUS_FAIL); }); + emit onStartDecodingComplete(STATUS_FAIL); return; } @@ -419,7 +490,7 @@ void GstVideoReceiver::startDecoding(void *sink) qCDebug(GstVideoReceiverLog) << "Decoding started" << _uri; - _dispatchSignal([this]() { emit onStartDecodingComplete(STATUS_OK); }); + emit onStartDecodingComplete(STATUS_OK); } void GstVideoReceiver::stopDecoding() @@ -437,7 +508,7 @@ void GstVideoReceiver::stopDecoding() // leaves the decoder/sink branch live. if (!_pipeline || !_videoSink) { qCDebug(GstVideoReceiverLog) << "Not decoding!" << _uri; - _dispatchSignal([this]() { emit onStopDecodingComplete(STATUS_INVALID_STATE); }); + emit onStopDecodingComplete(STATUS_INVALID_STATE); return; } @@ -451,7 +522,7 @@ void GstVideoReceiver::stopDecoding() // FIXME: it is much better to emit onStopDecodingComplete() after decoding is really stopped // (which happens later due to async design) but as for now it is also not so bad... - _dispatchSignal([this, ret](){ emit onStopDecodingComplete(ret ? STATUS_OK : STATUS_FAIL); }); + emit onStopDecodingComplete(ret ? STATUS_OK : STATUS_FAIL); } void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT format) @@ -466,13 +537,13 @@ void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT form if (!_pipeline) { qCDebug(GstVideoReceiverLog) << "Streaming is not active!" << _uri; - _dispatchSignal([this](){ emit onStartRecordingComplete(STATUS_INVALID_STATE); }); + emit onStartRecordingComplete(STATUS_INVALID_STATE); return; } if (_recording) { qCDebug(GstVideoReceiverLog) << "Already recording!" << _uri; - _dispatchSignal([this]() { emit onStartRecordingComplete(STATUS_INVALID_STATE); }); + emit onStartRecordingComplete(STATUS_INVALID_STATE); return; } @@ -481,7 +552,7 @@ void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT form _fileSink = _makeFileSink(videoFile, format); if (!_fileSink) { qCCritical(GstVideoReceiverLog) << "_makeFileSink() failed" << _uri; - _dispatchSignal([this]() { emit onStartRecordingComplete(STATUS_FAIL); }); + emit onStartRecordingComplete(STATUS_FAIL); return; } @@ -493,7 +564,7 @@ void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT form if (!gst_element_link(_recorderValve, _fileSink)) { qCCritical(GstVideoReceiverLog) << "Failed to link valve and file sink" << _uri; - _dispatchSignal([this]() { emit onStartRecordingComplete(STATUS_FAIL); }); + emit onStartRecordingComplete(STATUS_FAIL); return; } @@ -507,7 +578,7 @@ void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT form GstPad *probepad = gst_element_get_static_pad(_recorderValve, "src"); if (!probepad) { qCCritical(GstVideoReceiverLog) << "gst_element_get_static_pad() failed" << _uri; - _dispatchSignal([this]() { emit onStartRecordingComplete(STATUS_FAIL); }); + emit onStartRecordingComplete(STATUS_FAIL); return; } @@ -521,10 +592,8 @@ void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT form _recordingOutput = videoFile; _recording = true; qCDebug(GstVideoReceiverLog) << "Recording started" << _uri; - _dispatchSignal([this]() { - emit onStartRecordingComplete(STATUS_OK); - emit recordingChanged(_recording); - }); + emit onStartRecordingComplete(STATUS_OK); + emit recordingChanged(_recording); } void GstVideoReceiver::stopRecording() @@ -538,7 +607,7 @@ void GstVideoReceiver::stopRecording() if (!_pipeline || !_recording) { qCDebug(GstVideoReceiverLog) << "Not recording!" << _uri; - _dispatchSignal([this]() { emit onStopRecordingComplete(STATUS_INVALID_STATE); }); + emit onStopRecordingComplete(STATUS_INVALID_STATE); return; } @@ -550,7 +619,7 @@ void GstVideoReceiver::stopRecording() if (!_unlinkBranch(_recorderValve)) { _removingRecorder = false; - _dispatchSignal([this]() { emit onStopRecordingComplete(STATUS_FAIL); }); + emit onStopRecordingComplete(STATUS_FAIL); return; } @@ -570,7 +639,7 @@ void GstVideoReceiver::takeScreenshot(const QString &imageFile) qCDebug(GstVideoReceiverLog) << "taking screenshot" << _uri; // FIXME: record screenshot here - _dispatchSignal([this]() { emit onTakeScreenshotComplete(STATUS_NOT_IMPLEMENTED); }); + emit onTakeScreenshotComplete(STATUS_NOT_IMPLEMENTED); } void GstVideoReceiver::_watchdog() @@ -585,12 +654,20 @@ void GstVideoReceiver::_watchdog() _lastSourceFrameTime = now; } + if (++_statsTickCounter >= 10) { + _statsTickCounter = 0; + if (const HwBuffers::PathStats hwStats = HwBuffers::formatPathStats(false); hwStats.totalDelivered > 0) { + qCDebug(GstVideoReceiverLog).noquote() << "HW path live" << _uri << hwStats.line; + } + } + qint64 elapsed = now - _lastSourceFrameTime; if (elapsed > _timeout) { qCDebug(GstVideoReceiverLog) << "Stream timeout, no frames for" << elapsed << _uri; GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-watchdog-timeout"); - _dispatchSignal([this]() { emit timeout(); }); - stop(); + emit timeout(); + _scheduleReconnect("source watchdog"); + return; } if (_decoding && !_removingDecoder) { @@ -602,13 +679,75 @@ void GstVideoReceiver::_watchdog() if (elapsed > (_timeout * 2)) { qCDebug(GstVideoReceiverLog) << "Video decoder timeout, no frames for" << elapsed << _uri; GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-watchdog-timeout"); - _dispatchSignal([this]() { emit timeout(); }); - stop(); + emit timeout(); + _scheduleReconnect("decoder watchdog"); } } }); } +void GstVideoReceiver::_scheduleReconnect(const char *reason) +{ + // Always tear down — even when autoReconnect is off we still want a clean stop. + // stop() bumps _reconnectEpoch, so any prior singleShot lambda becomes a no-op. + stop(); + + if (!_autoReconnect) { + qCDebug(GstVideoReceiverLog) << "Auto-reconnect disabled — not retrying after" << reason; + return; + } + + if (_uri.isEmpty()) { + return; + } + + // Schedule on the GUI thread (QTimer::singleShot requires its receiver's thread). Worker + // is the only caller today, but route through invokeMethod so a future direct GUI-thread + // call (e.g. user-initiated retry) stays correct. + QMetaObject::invokeMethod(this, [this, reason]() { + const int next = qMin(_reconnectAttempts.load(std::memory_order_relaxed) + 1, 30); + _reconnectAttempts.store(next, std::memory_order_relaxed); + // 1s → 2s → 4s → 8s → 16s, capped at 30s. Capping bounds worst-case "vehicle in flight, + // RF down for 5 min" recovery; lower than typical RTSP server keepalive (60s). + const int delaySec = qMin(1 << qMin(next - 1, 5), 30); + const quint64 epoch = _reconnectEpoch.load(std::memory_order_relaxed); + const int attempts = next; + qCInfo(GstVideoReceiverLog) << "Scheduling reconnect #" << attempts + << "in" << delaySec << "s after" << reason << _uri; + // _uri/_timeout are written by start() on the worker thread and read here on the GUI thread; + // the epoch gate makes this safe — a superseding start() always runs stop() (epoch bump) first. + QTimer::singleShot(delaySec * 1000, this, [this, epoch, attempts]() { + if (epoch != _reconnectEpoch.load(std::memory_order_relaxed)) return; // superseded by stop() + // _pipeline is mutated by the worker under _pipelineMutex; a bare deref here (GUI + // thread) races teardown, so probe liveness through the mutex-guarded accessor. + GstElement *livePipeline = _acquirePipelineRef(); + const bool pipelineUp = (livePipeline != nullptr); + if (livePipeline) gst_object_unref(livePipeline); + if (_uri.isEmpty() || pipelineUp) return; // pipeline already came back + qCInfo(GstVideoReceiverLog) << "Reconnecting (attempt" << attempts << ")" << _uri; + start(_timeout ? _timeout : 8); + }); + }, Qt::QueuedConnection); +} + +void GstVideoReceiver::dumpPipelineGraph(const QString &tag) +{ + _worker->dispatch([this, tag]() { + GstElement *pipelineRef = _acquirePipelineRef(); + if (!pipelineRef) { + qCDebug(GstVideoReceiverLog) << "dumpPipelineGraph: pipeline not running"; + return; + } + const QByteArray tagUtf8 = tag.toUtf8(); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(pipelineRef), GST_DEBUG_GRAPH_SHOW_ALL, tagUtf8.constData()); + const QString dotPath = GStreamer::writePipelineDot(pipelineRef, tagUtf8.constData()); + if (!dotPath.isEmpty()) { + qCInfo(GstVideoReceiverLog) << "Pipeline graph saved to" << dotPath; + } + gst_object_unref(pipelineRef); + }); +} + void GstVideoReceiver::_handleEOS() { if (!_pipeline) { @@ -627,261 +766,6 @@ void GstVideoReceiver::_handleEOS() }*/ } -#if !defined(QGC_GST_BUILD_VERSION_MAJOR) || (QGC_GST_BUILD_VERSION_MAJOR == 1 && QGC_GST_BUILD_VERSION_MINOR < 28) -gboolean GstVideoReceiver::_filterParserCaps(GstElement *bin, GstPad *pad, GstElement *element, GstQuery *query, gpointer data) -{ - Q_UNUSED(bin); Q_UNUSED(pad); Q_UNUSED(element); Q_UNUSED(data) - - if (GST_QUERY_TYPE(query) != GST_QUERY_CAPS) { - return FALSE; - } - - GstCaps *srcCaps; - gst_query_parse_caps(query, &srcCaps); - if (!srcCaps || gst_caps_is_any(srcCaps)) { - return FALSE; - } - - GstCaps *sinkCaps = nullptr; - GstCaps *filter = nullptr; - GstStructure *structure = gst_caps_get_structure(srcCaps, 0); - if (gst_structure_has_name(structure, "video/x-h265")) { - filter = gst_caps_from_string("video/x-h265"); - if (gst_caps_can_intersect(srcCaps, filter)) { - sinkCaps = gst_caps_from_string("video/x-h265,stream-format=hvc1"); - } - gst_clear_caps(&filter); - } else if (gst_structure_has_name(structure, "video/x-h264")) { - filter = gst_caps_from_string("video/x-h264"); - if (gst_caps_can_intersect(srcCaps, filter)) { - sinkCaps = gst_caps_from_string("video/x-h264,stream-format=avc"); - } - gst_clear_caps(&filter); - } - - if (sinkCaps) { - gst_query_set_caps_result(query, sinkCaps); - gst_clear_caps(&sinkCaps); - return TRUE; - } - - return FALSE; -} -#endif - -GstElement *GstVideoReceiver::_makeSource(const QString &input) -{ - if (input.isEmpty()) { - qCCritical(GstVideoReceiverLog) << "Failed because URI is not specified"; - return nullptr; - } - - const QUrl sourceUrl(input); - - const bool isRtsp = sourceUrl.scheme().startsWith("rtsp", Qt::CaseInsensitive); - const bool isUdp264 = input.contains("udp://", Qt::CaseInsensitive); - const bool isUdp265 = input.contains("udp265://", Qt::CaseInsensitive); - const bool isUdpMPEGTS = input.contains("mpegts://", Qt::CaseInsensitive); - const bool isTcpMPEGTS = input.contains("tcp://", Qt::CaseInsensitive); - - GstElement *source = nullptr; - GstElement *buffer = nullptr; - GstElement *tsdemux = nullptr; - GstElement *parser = nullptr; - GstElement *bin = nullptr; - GstElement *srcbin = nullptr; - - do { - if (isRtsp) { - if (!GStreamer::isValidRtspUri(input.toUtf8().constData())) { - qCCritical(GstVideoReceiverLog) << "Invalid RTSP URI:" << input; - break; - } - - source = gst_element_factory_make("rtspsrc", "source"); - if (!source) { - qCCritical(GstVideoReceiverLog) << "gst_element_factory_make('rtspsrc') failed"; - break; - } - - const QString rtspUserInfo = sourceUrl.userInfo(); - QString rtspUser, rtspPassword; - if (!rtspUserInfo.isEmpty()) { - const int colonIdx = rtspUserInfo.indexOf(QLatin1Char(':')); - if (colonIdx >= 0) { - rtspUser = rtspUserInfo.left(colonIdx); - rtspPassword = rtspUserInfo.mid(colonIdx + 1); - } else { - rtspUser = rtspUserInfo; - } - } - QUrl cleanUrl(sourceUrl); - cleanUrl.setUserInfo(QString()); - const QByteArray cleanLocation = cleanUrl.toString().toUtf8(); - - g_object_set(source, - "location", cleanLocation.constData(), - "latency", 25, - "do-rtcp", TRUE, - "tcp-timeout", G_GUINT64_CONSTANT(5000000), - "udp-reconnect", TRUE, - "drop-on-latency", TRUE, - "retry", 3, - nullptr); - - if (!rtspUser.isEmpty()) { - g_object_set(source, - "user-id", rtspUser.toUtf8().constData(), - "user-pw", rtspPassword.toUtf8().constData(), - nullptr); - } - } else if (isTcpMPEGTS) { - source = gst_element_factory_make("tcpclientsrc", "source"); - if (!source) { - qCCritical(GstVideoReceiverLog) << "gst_element_factory_make('tcpclientsrc') failed"; - break; - } - - const QString host = sourceUrl.host(); - const quint16 port = sourceUrl.port(); - g_object_set(source, - "host", host.toUtf8().constData(), - "port", port, - nullptr); - } else if (isUdp264 || isUdp265 || isUdpMPEGTS) { - source = gst_element_factory_make("udpsrc", "source"); - if (!source) { - qCCritical(GstVideoReceiverLog) << "gst_element_factory_make('udpsrc') failed"; - break; - } - - const QString uri = QStringLiteral("udp://%1:%2").arg(sourceUrl.host(), QString::number(sourceUrl.port())); - g_object_set(source, - "uri", uri.toUtf8().constData(), - "buffer-size", 8 * 1024 * 1024, - nullptr); - - GstCaps *caps = nullptr; - if (isUdp264) { - caps = gst_caps_from_string("application/x-rtp, media=(string)video, clock-rate=(int)90000, encoding-name=(string)H264"); - if (!caps) { - qCCritical(GstVideoReceiverLog) << "gst_caps_from_string() failed"; - break; - } - } else if (isUdp265) { - caps = gst_caps_from_string("application/x-rtp, media=(string)video, clock-rate=(int)90000, encoding-name=(string)H265"); - if (!caps) { - qCCritical(GstVideoReceiverLog) << "gst_caps_from_string() failed"; - break; - } - } - - if (caps) { - g_object_set(source, - "caps", caps, - nullptr); - gst_clear_caps(&caps); - } - } else { - qCDebug(GstVideoReceiverLog) << "URI is not recognized"; - } - - if (!source) { - qCCritical(GstVideoReceiverLog) << "gst_element_factory_make() for data source failed"; - break; - } - - bin = gst_bin_new("sourcebin"); - if (!bin) { - qCCritical(GstVideoReceiverLog) << "gst_bin_new('sourcebin') failed"; - break; - } - - parser = gst_element_factory_make("parsebin", "parser"); - if (!parser) { - qCCritical(GstVideoReceiverLog) << "gst_element_factory_make('parsebin') failed"; - break; - } - - // GStreamer < 1.28: decodebin3 didn't negotiate stream-format caps properly - // between parser and decoder, so we forced hvc1/avc. GStreamer 1.28+ fixes - // this natively, and the forced caps break hardware decoders that need - // byte-stream format (e.g. Qualcomm AMC on Android, D3D12 on Windows). -#if !defined(QGC_GST_BUILD_VERSION_MAJOR) || (QGC_GST_BUILD_VERSION_MAJOR == 1 && QGC_GST_BUILD_VERSION_MINOR < 28) - (void) g_signal_connect(parser, "autoplug-query", G_CALLBACK(_filterParserCaps), nullptr); -#endif - - gst_bin_add_many(GST_BIN(bin), source, parser, nullptr); - - // FIXME: AV: Android does not determine MPEG2-TS via parsebin - have to explicitly state which demux to use - // FIXME: AV: tsdemux handling is a bit ugly - let's try to find elegant solution for that later - if (isTcpMPEGTS || isUdpMPEGTS) { - tsdemux = gst_element_factory_make("tsdemux", nullptr); - if (!tsdemux) { - qCCritical(GstVideoReceiverLog) << "gst_element_factory_make('tsdemux') failed"; - break; - } - - (void) gst_bin_add(GST_BIN(bin), tsdemux); - - if (!gst_element_link(source, tsdemux)) { - qCCritical(GstVideoReceiverLog) << "gst_element_link() failed"; - break; - } - - source = tsdemux; - tsdemux = nullptr; - } - - int probeRes = 0; - (void) gst_element_foreach_src_pad(source, _padProbe, &probeRes); - - if (probeRes & 1) { - if ((probeRes & 2) && (_buffer >= 0)) { - buffer = gst_element_factory_make("rtpjitterbuffer", nullptr); - if (!buffer) { - qCCritical(GstVideoReceiverLog) << "gst_element_factory_make('rtpjitterbuffer') failed"; - break; - } - - g_object_set(buffer, - "do-lost", TRUE, - "drop-on-latency", _buffer == 0 ? TRUE : FALSE, - nullptr); - - (void) gst_bin_add(GST_BIN(bin), buffer); - - if (!gst_element_link_many(source, buffer, parser, nullptr)) { - qCCritical(GstVideoReceiverLog) << "gst_element_link() failed"; - break; - } - } else { - if (!gst_element_link(source, parser)) { - qCCritical(GstVideoReceiverLog) << "gst_element_link() failed"; - break; - } - } - } else { - (void) g_signal_connect(source, "pad-added", G_CALLBACK(_linkPad), parser); - } - - (void) g_signal_connect(parser, "pad-added", G_CALLBACK(_wrapWithGhostPad), nullptr); - - source = tsdemux = buffer = parser = nullptr; - - srcbin = bin; - bin = nullptr; - } while(0); - - gst_clear_object(&bin); - gst_clear_object(&parser); - gst_clear_object(&tsdemux); - gst_clear_object(&buffer); - gst_clear_object(&source); - - return srcbin; -} - GstElement *GstVideoReceiver::_makeDecoder() { GstElement *decoder = gst_element_factory_make("decodebin3", nullptr); @@ -894,10 +778,10 @@ GstElement *GstVideoReceiver::_makeDecoder() GstElement *GstVideoReceiver::_makeFileSink(const QString &videoFile, FILE_FORMAT format) { GstElement *fileSink = nullptr; - GstElement *mux = nullptr; - GstElement *sink = nullptr; + GstElement *splitmux = nullptr; GstElement *bin = nullptr; - bool releaseElements = true; + GstPad *videopad = nullptr; + GstPad *ghostpad = nullptr; do { if (!isValidFileFormat(format)) { @@ -905,72 +789,83 @@ GstElement *GstVideoReceiver::_makeFileSink(const QString &videoFile, FILE_FORMA break; } - mux = gst_element_factory_make(_kFileMux[format], nullptr); - if (!mux) { - qCCritical(GstVideoReceiverLog) << "gst_element_factory_make('" << _kFileMux[format] << "') failed"; - break; - } - - // mp4mux/qtmux: write moov atom up-front + reserve space for index updates so a crash - // mid-recording leaves a playable file. matroskamux is naturally streamable; skip. - if (format == FILE_FORMAT_MP4 || format == FILE_FORMAT_MOV) { - g_object_set(mux, - "faststart", TRUE, - "reserved-moov-update-period", G_GUINT64_CONSTANT(1000000000), - nullptr); - } - - sink = gst_element_factory_make("filesink", nullptr); - if (!sink) { - qCCritical(GstVideoReceiverLog) << "gst_element_factory_make('filesink') failed"; + // splitmuxsink owns its own muxer + filesink internally, handles request-pad + // lifetime, and finalizes asynchronously so EOS no longer wedges the worker + // thread (replaces the manual qtmux/matroskamux+filesink combo + "stuck muxer" + // bounded-wait in stop()). max-size-time=0 keeps single-file behaviour. + splitmux = gst_element_factory_make("splitmuxsink", nullptr); + if (!splitmux) { + qCCritical(GstVideoReceiverLog) << "gst_element_factory_make('splitmuxsink') failed"; break; } - g_object_set(sink, + g_object_set(splitmux, "location", qPrintable(videoFile), + "muxer-factory", _kFileMux[format], + "max-size-time", G_GUINT64_CONSTANT(0), + "max-size-bytes", G_GUINT64_CONSTANT(0), + "async-finalize", TRUE, + // Surface "splitmuxsink-fragment-closed" element messages on the pipeline bus so + // stop() can wait on the precise per-fragment finalize signal instead of EOS + // (gstsplitmuxsink.c:send_fragment_opened_closed_msg posts this per fragment, + // including the final fragment torn down on EOS). + "message-forward", TRUE, nullptr); + // Crash-safe MP4/MOV: faststart writes moov up-front; reserved-moov-update-period + // refreshes the moov on a 1 s cadence so an abrupt kill still leaves a playable file. + // matroskamux is naturally streamable; skip the GstStructure dance. + if (format == FILE_FORMAT_MP4 || format == FILE_FORMAT_MOV) { + GstStructure *muxerProps = gst_structure_new("properties", + "faststart", G_TYPE_BOOLEAN, TRUE, + "reserved-moov-update-period", G_TYPE_UINT64, G_GUINT64_CONSTANT(1000000000), + nullptr); + g_object_set(splitmux, "muxer-properties", muxerProps, nullptr); + gst_structure_free(muxerProps); + } + bin = gst_bin_new("sinkbin"); if (!bin) { qCCritical(GstVideoReceiverLog) << "gst_bin_new('sinkbin') failed"; break; } - GstPadTemplate *padTemplate = gst_element_class_get_pad_template(GST_ELEMENT_GET_CLASS(mux), "video_%u"); - if (!padTemplate) { - qCCritical(GstVideoReceiverLog) << "gst_element_class_get_pad_template(mux) failed"; + // splitmuxsink's video sink pad is a request pad — request once during construction + // and ghost it as "sink" so the existing recorderValve→fileSink link works unchanged. + // request_pad_simple (1.20+) does the pad-template lookup internally. + videopad = gst_element_request_pad_simple(splitmux, "video"); + if (!videopad) { + qCCritical(GstVideoReceiverLog) << "gst_element_request_pad_simple(splitmuxsink, video) failed"; break; } - // FIXME: pad handling is potentially leaking (and other similar places too!) - GstPad *pad = gst_element_request_pad(mux, padTemplate, nullptr, nullptr); - if (!pad) { - qCCritical(GstVideoReceiverLog) << "gst_element_request_pad(mux) failed"; + if (!gst_bin_add(GST_BIN(bin), splitmux)) { + qCCritical(GstVideoReceiverLog) << "gst_bin_add(splitmuxsink) failed"; break; } + splitmux = nullptr; // bin now owns it - gst_bin_add_many(GST_BIN(bin), mux, sink, nullptr); - - releaseElements = false; - - GstPad *ghostpad = gst_ghost_pad_new("sink", pad); - (void) gst_element_add_pad(bin, ghostpad); - gst_clear_object(&pad); + ghostpad = gst_ghost_pad_new("sink", videopad); + if (!ghostpad) { + qCCritical(GstVideoReceiverLog) << "gst_ghost_pad_new() failed"; + break; + } - if (!gst_element_link(mux, sink)) { - qCCritical(GstVideoReceiverLog) << "gst_element_link() failed"; + if (!gst_element_add_pad(bin, ghostpad)) { + qCCritical(GstVideoReceiverLog) << "gst_element_add_pad(ghost) failed"; break; } + ghostpad = nullptr; // bin now owns it fileSink = bin; bin = nullptr; } while(0); - if (releaseElements) { - gst_clear_object(&sink); - gst_clear_object(&mux); - } - + gst_clear_object(&ghostpad); + // No release_request_pad: on success splitmux is already NULL (owned by bin), and on failure the + // bin/splitmux unref below finalizes splitmuxsink, which reclaims its "video" request pad itself. + gst_clear_object(&videopad); + gst_clear_object(&splitmux); gst_clear_object(&bin); return fileSink; } @@ -986,7 +881,7 @@ void GstVideoReceiver::_onNewSourcePad(GstPad *pad) if (!_streaming) { _streaming = true; qCDebug(GstVideoReceiverLog) << "Streaming started" << _uri; - _dispatchSignal([this]() { emit streamingChanged(_streaming); }); + emit streamingChanged(_streaming); } _eosProbeId = gst_pad_add_probe(pad, GST_PAD_PROBE_TYPE_EVENT_DOWNSTREAM, _eosProbe, this, nullptr); @@ -1043,9 +938,16 @@ void GstVideoReceiver::_logDecodebin3SelectedCodec(GstElement *decodebin3) qCDebug(GstVideoReceiverLog) << "Decodebin3 selected codec:rank -" << pluginName << "/" << featureName << "-" << decoderKlass << (isHardwareDecoder ? "(HW)" : "(SW)") << ":" << rank; const QString newName = QString::fromUtf8(featureName); - if (newName != _decoderName) { - _decoderName = newName; - _dispatchSignal([this]() { emit decoderStatsChanged(); }); + bool nameChanged = false; + { + QMutexLocker locker(&_decoderNameMutex); + if (newName != _decoderName) { + _decoderName = newName; + nameChanged = true; + } + } + if (nameChanged) { + emit decoderStatsChanged(); } // Disable QoS on the internal decoder to prevent cascading @@ -1102,22 +1004,7 @@ bool GstVideoReceiver::_addDecoder(GstElement *src) } GstPad *srcPad = nullptr; - GstIterator *it = gst_element_iterate_src_pads(_decoder); - GValue vpad = G_VALUE_INIT; - switch (gst_iterator_next(it, &vpad)) { - case GST_ITERATOR_OK: - srcPad = GST_PAD(g_value_get_object(&vpad)); - (void) gst_object_ref(srcPad); - (void) g_value_reset(&vpad); - break; - case GST_ITERATOR_RESYNC: - gst_iterator_resync(it); - break; - default: - break; - } - g_value_unset(&vpad); - gst_iterator_free(it); + (void) gst_element_foreach_src_pad(_decoder, grabFirstSrcPad, &srcPad); if (srcPad) { _onNewDecoderPad(srcPad); @@ -1180,9 +1067,6 @@ bool GstVideoReceiver::_addVideoSink(GstPad *pad) (void) gst_element_sync_state_with_parent(_videoSink); - // sync=FALSE + max-lateness=-1: HW decoders with startup latency must not drop early frames. - g_object_set(_videoSink, "sync", FALSE, "max-lateness", G_GINT64_CONSTANT(-1), nullptr); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-with-videosink"); // Determine video size. Errors here are non-fatal. @@ -1235,7 +1119,7 @@ bool GstVideoReceiver::_addVideoSink(GstPad *pad) gst_clear_caps(&valveSrcPadCaps); gst_clear_object(&valveSrcPad); } while (false); - _dispatchSignal([this, videoSize]() { emit videoSizeChanged(videoSize); }); + emit videoSizeChanged(videoSize); gst_clear_caps(&caps); return true; @@ -1244,6 +1128,21 @@ bool GstVideoReceiver::_addVideoSink(GstPad *pad) void GstVideoReceiver::_noteTeeFrame() { _lastSourceFrameTime = QDateTime::currentSecsSinceEpoch(); + // Successful frame arrival: drop the reconnect backoff so the next failure starts at 1 s, + // not minutes-into-the-curve. This probe runs on the streaming thread while the backoff + // increment runs on the GUI thread; post the reset there too so all mutation of + // _reconnectAttempts is single-threaded and the increment can't clobber the reset. + if (_reconnectAttempts.load(std::memory_order_relaxed) != 0) { + QMetaObject::invokeMethod( + this, [this]() { _reconnectAttempts.store(0, std::memory_order_relaxed); }, Qt::QueuedConnection); + } + const quint64 sourceFrames = _sourceFrameCount.fetch_add(1, std::memory_order_relaxed) + 1; + if (sourceFrames == 1) { + qCInfo(GstVideoReceiverLog).noquote() << "Source receiving frames (tee):" << _uri; + } else if ((sourceFrames % 300) == 0) { + qCDebug(GstVideoReceiverLog).noquote() + << "Source flow: teeFrames=" << sourceFrames << "decoding=" << _decoding << _uri; + } } void GstVideoReceiver::_noteVideoSinkFrame() @@ -1252,7 +1151,7 @@ void GstVideoReceiver::_noteVideoSinkFrame() if (!_decoding) { _decoding = true; qCDebug(GstVideoReceiverLog) << "Decoding started"; - _dispatchSignal([this]() { emit decodingChanged(_decoding); }); + emit decodingChanged(_decoding); } } @@ -1348,7 +1247,7 @@ void GstVideoReceiver::_shutdownDecodingBranch() if (_decoding) { _decoding = false; qCDebug(GstVideoReceiverLog) << "Decoding stopped"; - _dispatchSignal([this]() { emit decodingChanged(_decoding); }); + emit decodingChanged(_decoding); } GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-decoding-stopped"); @@ -1375,12 +1274,12 @@ void GstVideoReceiver::_shutdownRecordingBranch() if (_recording) { _recording = false; qCDebug(GstVideoReceiverLog) << "Recording stopped"; - _dispatchSignal([this]() { emit recordingChanged(_recording); }); + emit recordingChanged(_recording); } if (_recordingStopRequested) { _recordingStopRequested = false; - _dispatchSignal([this]() { emit onStopRecordingComplete(STATUS_OK); }); + emit onStopRecordingComplete(STATUS_OK); } GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-recording-stopped"); @@ -1391,11 +1290,11 @@ bool GstVideoReceiver::_needDispatch() return _worker->needDispatch(); } -void GstVideoReceiver::_dispatchSignal(Task emitter) +GstElement *GstVideoReceiver::_acquirePipelineRef() const { - _signalDepth += 1; - emitter(); - _signalDepth -= 1; + QMutexLocker lock(&_pipelineMutex); + if (!_pipeline) return nullptr; + return GST_ELEMENT(gst_object_ref(_pipeline)); } gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gpointer data) @@ -1407,11 +1306,19 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp GstVideoReceiver *pThis = static_cast(data); + // Fan out to cross-cutting observers (GPU device reset on ERROR, future telemetry + // / crash-dump hooks). ERROR messages are dispatched after recoverable stream errors + // are filtered so a bad RTP packet does not look like a GPU/device failure. + if (GST_MESSAGE_TYPE(msg) != GST_MESSAGE_ERROR) { + HwBuffers::dispatchBusMessage(msg); + } + switch (GST_MESSAGE_TYPE(msg)) { case GST_MESSAGE_ERROR: { - gchar *debug; - GError *error; + gchar *debug = nullptr; + GError *error = nullptr; gst_message_parse_error(msg, &error, &debug); + const bool recoverableH265PaciError = isRecoverableH265PaciError(msg, error, debug); if (debug) { qCDebug(GstVideoReceiverLog) << "GStreamer debug:" << debug; @@ -1419,28 +1326,37 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp } if (error) { - qCCritical(GstVideoReceiverLog) << "GStreamer error:" << error->message; + if (recoverableH265PaciError) { + qCWarning(GstVideoReceiverLog) + << "Ignoring unsupported H.265 RTP PACI packet from rtph265depay:" << error->message; + } else { + qCCritical(GstVideoReceiverLog) << "GStreamer error:" << error->message; + } g_clear_error(&error); } - if (pThis->_pipeline) { - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(pThis->_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-error"); + if (recoverableH265PaciError) { + break; } -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) || defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH) - // Drop bridge-cached devices defensively. D3D11/D3D12 errors are most often device-loss - // (DXGI_ERROR_DEVICE_REMOVED on driver reset / TDR / GPU detach); GST_MESSAGE_ERROR - // doesn't carry a structured device-lost code, so reset on any error rather than - // letting the next pipeline restart re-use a potentially-dead cached device. Cost on - // false positives is one device re-discovery on next prime — already paid on cold start. - // Render-thread mapTextures calls currentDevice() with transfer-full ownership now, - // so an in-flight mapTextures keeps its own ref alive across this reset. - GstContextBridgeRegistry::resetAllBridges(); -#endif + HwBuffers::dispatchBusMessage(msg); + if (GstElement *pipelineRef = pThis->_acquirePipelineRef()) { + // Native dump path (no-op without GST_DEBUG_DUMP_DOT_DIR) plus an unconditional + // CacheLocation fallback so field-bug-report bundles include pipeline topology. + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(pipelineRef), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-error"); + const QString dotPath = GStreamer::writePipelineDot(pipelineRef, "pipeline-error"); + if (!dotPath.isEmpty()) { + qCInfo(GstVideoReceiverLog) << "Pipeline graph saved to" << dotPath; + } + gst_object_unref(pipelineRef); + } + + // GPU-side ERROR handling (cached-device drop) runs in HwBuffers::dispatchBusMessage above. + // _scheduleReconnect calls stop() then queues a backoff retry if autoReconnect is on. pThis->_worker->dispatch([pThis]() { qCDebug(GstVideoReceiverLog) << "Stopping because of error"; - pThis->stop(); + pThis->_scheduleReconnect("pipeline error"); }); break; } @@ -1497,16 +1413,34 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp gint quality = 0; gst_message_parse_qos_values(msg, &jitter, &proportion, &quality); - pThis->_processedFrames = processed; - pThis->_droppedFrames = dropped; - pThis->_currentJitterNs = jitter; - pThis->_qosProportion = proportion; - pThis->_qosQuality = quality; - pThis->_dispatchSignal([pThis]() { emit pThis->decoderStatsChanged(); }); + pThis->_processedFrames.store(processed, std::memory_order_relaxed); + pThis->_droppedFrames.store(dropped, std::memory_order_relaxed); + pThis->_currentJitterNs.store(jitter, std::memory_order_relaxed); + pThis->_qosProportion.store(proportion, std::memory_order_relaxed); + pThis->_qosQuality.store(quality, std::memory_order_relaxed); + emit pThis->decoderStatsChanged(); break; } case GST_MESSAGE_ELEMENT: { const GstStructure *structure = gst_message_get_structure(msg); + if (structure && gst_structure_has_name(structure, "qgc-caps-info")) { + gint w = 0, h = 0; + const gchar *fmt = gst_structure_get_string(structure, "format"); + gst_structure_get_int(structure, "width", &w); + gst_structure_get_int(structure, "height", &h); + const QString format = QString::fromUtf8(fmt ? fmt : ""); + const QSize resolution(w, h); + // src compared by address only on the GUI thread; never dereferenced (may be gone by then). + void *src = GST_MESSAGE_SRC(msg); + QMetaObject::invokeMethod(pThis, [pThis, format, resolution, src]() { + for (auto *c : QGCQVideoSinkController::controllersOf(pThis)) { + if (static_cast(c->element()) == src) { + c->updateNegotiation(format, resolution); + } + } + }, Qt::QueuedConnection); + break; + } if (!gst_structure_has_name(structure, "GstBinForwarded")) { break; } @@ -1528,7 +1462,11 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp break; } case GST_MESSAGE_STATE_CHANGED: { - if (GST_MESSAGE_SRC(msg) != GST_OBJECT(pThis->_pipeline)) { + GstElement *pipelineRef = pThis->_acquirePipelineRef(); + if (!pipelineRef) break; + const bool fromPipeline = (GST_MESSAGE_SRC(msg) == GST_OBJECT(pipelineRef)); + if (!fromPipeline) { + gst_object_unref(pipelineRef); break; } GstState oldState = GST_STATE_NULL, newState = GST_STATE_NULL; @@ -1536,26 +1474,35 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp if (newState == GST_STATE_PLAYING && oldState != GST_STATE_PLAYING) { GstClockTime min = 0, max = 0; GstQuery *q = gst_query_new_latency(); - if (gst_element_query(pThis->_pipeline, q)) { + if (gst_element_query(pipelineRef, q)) { gboolean live = FALSE; gst_query_parse_latency(q, &live, &min, &max); } gst_query_unref(q); + const QString decName = pThis->decoderName(); qCDebug(GstVideoReceiverLog).noquote() << "Pipeline PLAYING:" << pThis->_uri - << "decoder:" << (pThis->_decoderName.isEmpty() ? QStringLiteral("(pending)") : pThis->_decoderName) + << "decoder:" << (decName.isEmpty() ? QStringLiteral("(pending)") : decName) << "min-latency:" << (min / 1000000) << "ms" << "max-latency:" << (max / 1000000) << "ms"; } + gst_object_unref(pipelineRef); break; } case GST_MESSAGE_LATENCY: pThis->_worker->dispatch([pThis]() { - if (pThis->_pipeline) { - (void) gst_bin_recalculate_latency(GST_BIN(pThis->_pipeline)); + GstElement* pipeline = pThis->_acquirePipelineRef(); + if (pipeline) { + (void) gst_bin_recalculate_latency(GST_BIN(pipeline)); + gst_object_unref(pipeline); } }); - pThis->_dispatchSignal([pThis]() { emit pThis->latencyChanged(); }); + // Re-prime sink-side latency tracking after the pipeline recalculation (e.g. RTSP + // jitter-buffer reconfigure). Controllers live on the GUI thread; hop there to query. + QMetaObject::invokeMethod(pThis, [pThis]() { + for (auto* c : QGCQVideoSinkController::controllersOf(pThis)) + c->refreshLatency(); + }, Qt::QueuedConnection); break; default: break; @@ -1577,71 +1524,6 @@ void GstVideoReceiver::_onNewPad(GstElement *element, GstPad *pad, gpointer data } } -void GstVideoReceiver::_wrapWithGhostPad(GstElement *element, GstPad *pad, gpointer data) -{ - Q_UNUSED(data) - - gchar *name = gst_pad_get_name(pad); - if (!name) { - qCCritical(GstVideoReceiverLog) << "gst_pad_get_name() failed"; - return; - } - - GstPad *ghostpad = gst_ghost_pad_new(name, pad); - if (!ghostpad) { - qCCritical(GstVideoReceiverLog) << "gst_ghost_pad_new() failed"; - g_clear_pointer(&name, g_free); - return; - } - - g_clear_pointer(&name, g_free); - - (void) gst_pad_set_active(ghostpad, TRUE); - - if (!gst_element_add_pad(GST_ELEMENT_PARENT(element), ghostpad)) { - qCCritical(GstVideoReceiverLog) << "gst_element_add_pad() failed"; - } -} - -void GstVideoReceiver::_linkPad(GstElement *element, GstPad *pad, gpointer data) -{ - gchar *name = gst_pad_get_name(pad); - if (!name) { - qCCritical(GstVideoReceiverLog) << "gst_pad_get_name() failed"; - return; - } - - if (!gst_element_link_pads(element, name, GST_ELEMENT(data), "sink")) { - qCCritical(GstVideoReceiverLog) << "gst_element_link_pads() failed"; - } - - g_clear_pointer(&name, g_free); -} - -gboolean GstVideoReceiver::_padProbe(GstElement *element, GstPad *pad, gpointer user_data) -{ - Q_UNUSED(element) - - int *probeRes = static_cast(user_data); - *probeRes |= 1; - - GstCaps *filter = gst_caps_from_string("application/x-rtp"); - if (filter) { - GstCaps *caps = gst_pad_query_caps(pad, nullptr); - if (caps) { - if (!gst_caps_is_any(caps) && gst_caps_can_intersect(caps, filter)) { - *probeRes |= 2; - } - - gst_clear_caps(&caps); - } - - gst_clear_caps(&filter); - } - - return TRUE; -} - GstPadProbeReturn GstVideoReceiver::_teeProbe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data) { Q_UNUSED(pad); Q_UNUSED(info) @@ -1730,7 +1612,7 @@ GstPadProbeReturn GstVideoReceiver::_keyframeWatch(GstPad *pad, GstPadProbeInfo qCDebug(GstVideoReceiverLog) << "Got keyframe, stop dropping buffers"; GstVideoReceiver *pThis = static_cast(user_data); - pThis->_dispatchSignal([pThis]() { emit pThis->recordingStarted(pThis->recordingOutput()); }); + emit pThis->recordingStarted(pThis->recordingOutput()); return GST_PAD_PROBE_REMOVE; } diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h index ccf6b8fe3169..709ceadf4326 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -54,12 +56,12 @@ class GstVideoReceiver : public VideoReceiver explicit GstVideoReceiver(QObject *parent = nullptr); ~GstVideoReceiver(); - QString decoderName() const { return _decoderName; } - quint64 processedFrames() const { return _processedFrames; } - quint64 droppedFrames() const { return _droppedFrames; } - qint64 currentJitterNs() const { return _currentJitterNs; } - double qosProportion() const { return _qosProportion; } - int qosQuality() const { return _qosQuality; } + QString decoderName() const { QMutexLocker locker(&_decoderNameMutex); return _decoderName; } + quint64 processedFrames() const { return _processedFrames.load(std::memory_order_relaxed); } + quint64 droppedFrames() const { return _droppedFrames.load(std::memory_order_relaxed); } + qint64 currentJitterNs() const { return _currentJitterNs.load(std::memory_order_relaxed); } + double qosProportion() const { return _qosProportion.load(std::memory_order_relaxed); } + int qosQuality() const { return _qosQuality.load(std::memory_order_relaxed); } public slots: void start(uint32_t timeout) override; @@ -70,16 +72,19 @@ public slots: void stopRecording() override; void takeScreenshot(const QString &imageFile) override; + /// Dump the current pipeline graph to GST_DEBUG_DUMP_DOT_DIR (if set) plus + /// CacheLocation/qgc-pipeline-dot for field-bug-report bundles. No-op when + /// the pipeline isn't running. Callable from QML for a debug menu. + Q_INVOKABLE void dumpPipelineGraph(const QString &tag = QStringLiteral("manual")); + signals: void decoderStatsChanged(); - void latencyChanged(); private slots: void _watchdog(); void _handleEOS(); private: - GstElement *_makeSource(const QString &input); GstElement *_makeDecoder(); GstElement *_makeFileSink(const QString &videoFile, FILE_FORMAT format); @@ -99,16 +104,19 @@ private slots: void _logDecodebin3SelectedCodec(GstElement *decodebin3); bool _needDispatch(); - void _dispatchSignal(Task emitter); + + /// Stop the pipeline and queue a delayed restart with exponential backoff. + /// `reason` is logged so reconnect storms are diagnosable. No-op when + /// autoReconnect() is disabled. + void _scheduleReconnect(const char *reason); + + /// Returns a strong ref to _pipeline (caller must gst_object_unref) or nullptr if torn down. + /// Bus sync-message callbacks run on the streaming thread concurrent with stop() on the + /// worker thread, so direct dereference of _pipeline races with gst_clear_object(&_pipeline). + GstElement *_acquirePipelineRef() const; static gboolean _onBusMessage(GstBus *bus, GstMessage *message, gpointer user_data); static void _onNewPad(GstElement *element, GstPad *pad, gpointer data); - static void _wrapWithGhostPad(GstElement *element, GstPad *pad, gpointer data); - static void _linkPad(GstElement *element, GstPad *pad, gpointer data); - static gboolean _padProbe(GstElement *element, GstPad *pad, gpointer user_data); -#if !defined(QGC_GST_BUILD_VERSION_MAJOR) || (QGC_GST_BUILD_VERSION_MAJOR == 1 && QGC_GST_BUILD_VERSION_MINOR < 28) - static gboolean _filterParserCaps(GstElement *bin, GstPad *pad, GstElement *element, GstQuery *query, gpointer data); -#endif static GstPadProbeReturn _teeProbe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data); static GstPadProbeReturn _videoSinkProbe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data); static GstPadProbeReturn _eosProbe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data); @@ -118,11 +126,16 @@ private slots: GstElement *_decoderValve = nullptr; GstElement *_fileSink = nullptr; GstElement *_pipeline = nullptr; + mutable QMutex _pipelineMutex; // serializes _pipeline mutation (worker) vs read in _onBusMessage (streaming thread) GstElement *_recorderValve = nullptr; GstElement *_source = nullptr; GstElement *_tee = nullptr; GstElement *_videoSink = nullptr; GstVideoWorker *_worker = nullptr; + std::atomic _reconnectAttempts = 0; ///< Written on the streaming thread (_noteTeeFrame) and GUI thread (reconnect lambda); atomic. + std::atomic _reconnectEpoch = 0; ///< Bumped on every stop() — pending singleShot lambdas check this before firing, replacing an explicit cancel/pending-flag pair. + std::atomic _sourceFrameCount = + 0; ///< Tee-probe frame tally (streaming thread); drives the source-side flow heartbeat log. gulong _teeProbeId = 0; gulong _videoSinkProbeId = 0; gulong _eosProbeId = 0; @@ -130,12 +143,13 @@ private slots: gulong _keyframeWatchId = 0; bool _recordingStopRequested = false; + mutable QMutex _decoderNameMutex; // QString refcount isn't thread-safe across reader/writer threads QString _decoderName; - quint64 _processedFrames = 0; - quint64 _droppedFrames = 0; - qint64 _currentJitterNs = 0; - double _qosProportion = 1.0; - int _qosQuality = 1000000; + std::atomic _processedFrames{0}; // written on streaming thread (QOS), read on GUI + std::atomic _droppedFrames{0}; + std::atomic _currentJitterNs{0}; + std::atomic _qosProportion{1.0}; + std::atomic _qosQuality{1000000}; static constexpr const char *_kFileMux[FILE_FORMAT_MAX + 1] = { "matroskamux", diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/CMakeLists.txt b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/CMakeLists.txt index 2399eda59df1..761ab9912a25 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/CMakeLists.txt +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/CMakeLists.txt @@ -1,312 +1,358 @@ -# HwBuffers: platform-specific zero-copy GPU video buffer implementations. -# Each block gates on runtime feature tests and emits its own status message. -# QGC_HAS_ANY_GPU_PATH is set as a compile definition when any path is enabled, -# removing the umbrella macro from GstAppSinkAdapter.h. +# HwBuffers: platform zero-copy GPU video buffer paths. Each block gates on runtime feature tests; +# _qgc_register_gpu_path centralizes sources/libs/defines/logging onto QGCGStreamer (created in the +# parent CMakeLists) — gstqgc shares that target, so defs reach it with no cross-target ferrying. -include(CheckCXXSourceCompiles) +include(GStreamer/Probe) -set(_any_gpu_path FALSE) +# Tracked as a GLOBAL property (not a scoped var) so _qgc_register_gpu_path can flip it from any +# call depth without a PARENT_SCOPE relay — registrars in helper functions stay correct. +set_property(GLOBAL PROPERTY QGC_GST_ANY_GPU_PATH FALSE) -# Base class + factory + bridge registry pulled in once any GPU path is enabled below -# (deferred to the _any_gpu_path block at end of file). They depend on Qt6::MultimediaPrivate -# (private/qhwvideobuffer_p.h) which is only linked in that block, so compiling them -# unconditionally would break a hypothetical no-GPU-paths build. +# Private-Qt-free core (facade, telemetry, CPU pool): compiled unconditionally so a CPU-only +# build (no GPU path qualified) still links the base sources. Qt-private bits stay gated below. +target_sources(QGCGStreamer + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/common/HwBuffers.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/HwBuffers.h + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstHwPathTelemetry.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstHwPathTelemetry.h + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstHwVideoBufferFactory.h + ${CMAKE_CURRENT_SOURCE_DIR}/common/CpuVideoFramePool.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/CpuVideoFramePool.h + ${CMAKE_CURRENT_SOURCE_DIR}/dmabuf/GstDmaDrmCaps.cc + ${CMAKE_CURRENT_SOURCE_DIR}/dmabuf/GstDmaDrmCaps.h +) -find_package(Qt6 QUIET COMPONENTS MultimediaPrivate GuiPrivate) +# Base class + factory + bridge registry depend on Qt6::MultimediaPrivate and are added in the +# _any_gpu_path block at end of file, so a hypothetical no-GPU-paths build stays green. + +# Qt6::MultimediaPrivate / Qt6::GuiPrivate ship with the root's REQUIRED Multimedia / +# Gui finds, so no re-query is needed; the flag just gates the private-Qt branches below. +if(TARGET Qt6::MultimediaPrivate AND TARGET Qt6::GuiPrivate) + set(_qgc_qt_private TRUE) +else() + set(_qgc_qt_private FALSE) +endif() + +# _qgc_register_gpu_path(NAME SOURCES [LIBS ] DEFINE LOG ) +# Adds sources/libs to QGCGStreamer, sets DEFINE=1, sets the QGC_GST_ANY_GPU_PATH global, logs status. +function(_qgc_register_gpu_path) + cmake_parse_arguments(ARG "" "NAME;DEFINE;LOG" "SOURCES;LIBS" ${ARGN}) + foreach(_req IN ITEMS NAME DEFINE LOG SOURCES) + if(NOT ARG_${_req}) + message(FATAL_ERROR "_qgc_register_gpu_path: ${_req} is required") + endif() + endforeach() + target_sources(QGCGStreamer PRIVATE ${ARG_SOURCES}) + if(ARG_LIBS) + target_link_libraries(QGCGStreamer PRIVATE ${ARG_LIBS}) + endif() + # PUBLIC: these flags gate HwVideoBufferContext fields/EGL includes in GstHwVideoBufferFactory.h, + # which the app facade also includes — PRIVATE gave it a mismatched struct layout (dropped EGLDisplay). + target_compile_definitions(QGCGStreamer PUBLIC ${ARG_DEFINE}=1) + set_property(GLOBAL PROPERTY QGC_GST_ANY_GPU_PATH TRUE) + message(STATUS "QGCGStreamer: ${ARG_LOG}") +endfunction() # ---- Linux DMABuf zero-copy (VA-API / Mesa) ---- if(LINUX AND QGC_GST_HAS_DMABUF) find_package(OpenGL QUIET COMPONENTS EGL) - if(TARGET Qt6::MultimediaPrivate AND TARGET Qt6::GuiPrivate AND TARGET OpenGL::EGL) - target_sources(${CMAKE_PROJECT_NAME} - PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/GstDmaBufVideoBuffer.cc - ${CMAKE_CURRENT_SOURCE_DIR}/GstDmaBufVideoBuffer.h - ) - target_link_libraries(${CMAKE_PROJECT_NAME} - PRIVATE - OpenGL::EGL - ) - target_compile_definitions(${CMAKE_PROJECT_NAME} - PRIVATE QGC_HAS_GST_DMABUF_GPU_PATH=1 - ) - set(_any_gpu_path TRUE) - message(STATUS "QGCGStreamer: DMABuf zero-copy GPU path enabled") + if(_qgc_qt_private AND TARGET OpenGL::EGL) + _qgc_register_gpu_path(NAME DMABuf + SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/dmabuf/GstDmaBufVideoBuffer.cc + ${CMAKE_CURRENT_SOURCE_DIR}/dmabuf/GstDmaBufVideoBuffer.h + ${CMAKE_CURRENT_SOURCE_DIR}/dmabuf/GstDmaFourcc.cc + ${CMAKE_CURRENT_SOURCE_DIR}/dmabuf/GstDmaFourcc.h + ${CMAKE_CURRENT_SOURCE_DIR}/dmabuf/GstDmaBufVulkanImport.cc + ${CMAKE_CURRENT_SOURCE_DIR}/dmabuf/GstDmaBufVulkanImport.h + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstHwImportCache.h + LIBS OpenGL::EGL + DEFINE QGC_HAS_GST_DMABUF_GPU_PATH + LOG "DMABuf zero-copy GPU path enabled") + set(_qgc_dmabuf_path_enabled TRUE) else() message(STATUS "QGCGStreamer: DMABuf detected but Qt private modules or EGL missing — GPU path disabled") endif() endif() -# ---- GLMemory zero-copy (Linux + macOS + Android via gst-gl) ---- -if(LINUX OR MACOS OR ANDROID) - set(CMAKE_REQUIRED_LIBRARIES_BACKUP "${CMAKE_REQUIRED_LIBRARIES}") - # On Android the primary gst target is GStreamerMobile; check_cxx_source_compiles - # only reads INTERFACE include dirs, so target the right one. - if(ANDROID AND TARGET GStreamerMobile) - set(CMAKE_REQUIRED_LIBRARIES GStreamerMobile) +# ---- NVMM/CUDA zero-copy (NVIDIA/Jetson) ---- +# Exports CUDA memory to a DMABuf fd and reuses the DMABuf EGLImage path; needs the DMABuf path (export target) +# plus gst-plugins-bad CUDA. Runtime needs NVIDIA HW + exportable (cuMemCreate/MMAP) memory. +if(LINUX AND _qgc_dmabuf_path_enabled) + # gst/cuda/gstcuda.h transitively pulls ; resolve a CUDA include dir (full Toolkit, or gst-plugins-bad's + # stub headers for compile-only builds without the SDK) before probing, else the header test fails to compile. + set(_qgc_cuda_inc "") + # find_path cache var: a hit survives a CUDA toolkit swap until the cache is cleared. + find_path(QGC_CUDA_INCLUDE_DIR NAMES cuda.h + HINTS ${CUDAToolkit_INCLUDE_DIRS} $ENV{CUDA_PATH}/include /usr/local/cuda/include ${QGC_GST_CUDA_STUB_DIR}) + if(QGC_CUDA_INCLUDE_DIR) + set(_qgc_cuda_inc "${QGC_CUDA_INCLUDE_DIR}") + endif() + set(_gst_cuda_found FALSE) + if(_qgc_cuda_inc AND PkgConfig_FOUND) + pkg_check_modules(PC_GStreamer_Cuda QUIET gstreamer-cuda-1.0) + if(PC_GStreamer_Cuda_FOUND) + qgc_check_gst_header(VAR QGC_GST_HAS_CUDA HEADER gst/cuda/gstcuda.h SYMBOL gst_is_cuda_memory + INCLUDES ${_qgc_cuda_inc} ${PC_GStreamer_Cuda_INCLUDE_DIRS}) + if(QGC_GST_HAS_CUDA) + set(_gst_cuda_found TRUE) + endif() + endif() + endif() + if(_gst_cuda_found) + target_include_directories(QGCGStreamer PRIVATE ${_qgc_cuda_inc} ${PC_GStreamer_Cuda_INCLUDE_DIRS}) + target_link_directories(QGCGStreamer PRIVATE ${PC_GStreamer_Cuda_LIBRARY_DIRS}) + target_compile_definitions(QGCGStreamer PRIVATE GST_USE_UNSTABLE_API) # gst-bad CUDA is unstable-API by design + _qgc_register_gpu_path(NAME CUDA + SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/cuda/GstCudaVideoBuffer.cc + ${CMAKE_CURRENT_SOURCE_DIR}/cuda/GstCudaVideoBuffer.h + LIBS ${PC_GStreamer_Cuda_LIBRARIES} + DEFINE QGC_HAS_GST_CUDA_GPU_PATH + LOG "CUDA/NVMM zero-copy GPU path enabled (DMABuf export)") else() - set(CMAKE_REQUIRED_LIBRARIES GStreamer::GStreamer) + message(STATUS "QGCGStreamer: gst-cuda (gst/cuda/gstcuda.h + cuda.h + gstreamer-cuda-1.0) not found — CUDA GPU path disabled") endif() - check_cxx_source_compiles(" - #include - int main() { (void)gst_is_gl_memory; return 0; } - " QGC_GST_HAS_GLMEMORY) - set(CMAKE_REQUIRED_LIBRARIES "${CMAKE_REQUIRED_LIBRARIES_BACKUP}") - if(QGC_GST_HAS_GLMEMORY AND TARGET Qt6::MultimediaPrivate AND TARGET Qt6::GuiPrivate) - target_sources(${CMAKE_PROJECT_NAME} - PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/GstGlVideoBuffer.cc - ${CMAKE_CURRENT_SOURCE_DIR}/GstGlVideoBuffer.h - ${CMAKE_CURRENT_SOURCE_DIR}/GstGlContextBridge.cc - ${CMAKE_CURRENT_SOURCE_DIR}/GstGlContextBridge.h - ) - target_compile_definitions(${CMAKE_PROJECT_NAME} - PRIVATE QGC_HAS_GST_GLMEMORY_GPU_PATH=1 - ) - set(_any_gpu_path TRUE) - message(STATUS "QGCGStreamer: GLMemory zero-copy GPU path enabled") - # Linux bin routing deferred to after the DMABuf block (DMABuf preempts glupload). +endif() + +# ---- GLMemory zero-copy (Linux + macOS + Android, plus Windows ANGLE/EGL fallback via gst-gl) ---- +# WIN32: only reachable when Qt uses the ANGLE/EGL GL backend (RHI defaults to D3D11/D3D12, which stay the primary +# Windows paths above); GstGlContextBridge wraps the EGL context only — no WGL. Review-only, no Windows compiler here. +if(LINUX OR MACOS OR ANDROID OR WIN32) + qgc_check_gst_header(VAR QGC_GST_HAS_GLMEMORY HEADER gst/gl/gstglmemory.h SYMBOL gst_is_gl_memory) + if(QGC_GST_HAS_GLMEMORY AND _qgc_qt_private) + _qgc_register_gpu_path(NAME GLMemory + SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/gl/GstGlVideoBuffer.cc + ${CMAKE_CURRENT_SOURCE_DIR}/gl/GstGlVideoBuffer.h + ${CMAKE_CURRENT_SOURCE_DIR}/gl/GstGlContextBridge.cc + ${CMAKE_CURRENT_SOURCE_DIR}/gl/GstGlContextBridge.h + DEFINE QGC_HAS_GST_GLMEMORY_GPU_PATH + LOG "GLMemory zero-copy GPU path enabled") + set(_qgc_glmemory_path_enabled TRUE) + # Bin routing is resolved after DMABuf probing so Linux can prefer direct DMABuf when both paths exist. endif() endif() -# ---- Linux bin GPU routing: DMABuf direct vs glupload fallback ---- -# Direct DMABuf when available; runtime modifier probe + CPU fallback live in -# GstDmaBufVideoBuffer. Pre-1.24 va plugins lack modifier metadata — buffer treats as LINEAR -# and CPU fallback catches tiled hardware. -if(LINUX AND QGC_GST_HAS_DMABUF AND TARGET OpenGL::EGL) - target_compile_definitions(${CMAKE_PROJECT_NAME} - PRIVATE QGC_GST_BIN_USE_DMABUF=1 +# Android needs glupload for the GstGLContext handshake amcvideodec uses for Surface output — else androidmedia falls +# back to ByteBuffer/CPU. On Linux, prefer QGC's direct DMABuf importer when it is compiled; glupload remains a +# fallback for GLMemory-only builds. +# PUBLIC so the app facade and test TU #if-select the same bin structure across the link boundary. +if(ANDROID AND _qgc_glmemory_path_enabled) + target_compile_definitions(QGCGStreamer + PUBLIC QGC_GST_BIN_USE_GLUPLOAD=1 + ) + message(STATUS "QGCGStreamer: bin will route GPU path through glupload (gst-gl EGLImage import)") +elseif(_qgc_dmabuf_path_enabled) + target_compile_definitions(QGCGStreamer + PUBLIC QGC_GST_BIN_USE_DMABUF=1 ) - message(STATUS "QGCGStreamer: bin will direct-import DMABuf (no glupload)") -elseif(LINUX AND QGC_GST_HAS_GLMEMORY) - target_compile_definitions(${CMAKE_PROJECT_NAME} - PRIVATE QGC_GST_BIN_USE_GLUPLOAD=1 + message(STATUS "QGCGStreamer: bin will direct-import DMABuf") +elseif(LINUX AND _qgc_glmemory_path_enabled) + target_compile_definitions(QGCGStreamer + PUBLIC QGC_GST_BIN_USE_GLUPLOAD=1 ) - message(STATUS "QGCGStreamer: bin will route GPU path through glupload (Linux fallback)") + message(STATUS "QGCGStreamer: bin will route GPU path through glupload (GLMemory fallback)") endif() # ---- Apple IOSurface zero-copy (VideoToolbox vtdec on macOS, AVF on iOS) ---- if(MACOS OR IOS) - if(TARGET Qt6::MultimediaPrivate AND TARGET Qt6::GuiPrivate) - target_sources(${CMAKE_PROJECT_NAME} - PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/GstIOSurfaceVideoBuffer.mm - ${CMAKE_CURRENT_SOURCE_DIR}/GstIOSurfaceVideoBuffer.h - ) - # TARGET_DIRECTORY required: source-file properties are directory-scoped, and the QGC target's PCH is registered in the root CMakeLists.txt — without TARGET_DIRECTORY the SKIP would be visible only inside this subdirectory and not honored when the file is compiled against the target. + if(_qgc_qt_private) + # TARGET_DIRECTORY: source-file props are dir-scoped; QGCGStreamer is created in the parent + # CMakeLists, so name it explicitly here for SKIP_PRECOMPILE_HEADERS to bind to that target. set_source_files_properties( - ${CMAKE_CURRENT_SOURCE_DIR}/GstIOSurfaceVideoBuffer.mm - TARGET_DIRECTORY ${CMAKE_PROJECT_NAME} + ${CMAKE_CURRENT_SOURCE_DIR}/apple/GstIOSurfaceVideoBuffer.mm + TARGET_DIRECTORY QGCGStreamer PROPERTIES SKIP_PRECOMPILE_HEADERS ON ) - target_link_libraries(${CMAKE_PROJECT_NAME} - PRIVATE - "-framework CoreVideo" - "-framework IOSurface" - "-framework Metal" - ) - target_compile_definitions(${CMAKE_PROJECT_NAME} - PRIVATE QGC_HAS_GST_IOSURFACE_GPU_PATH=1 - ) - set(_any_gpu_path TRUE) if(IOS) - message(STATUS "QGCGStreamer: IOSurface zero-copy GPU path enabled (iOS)") + set(_iosurface_log "IOSurface zero-copy GPU path enabled (iOS)") else() - message(STATUS "QGCGStreamer: IOSurface zero-copy GPU path enabled (macOS)") + set(_iosurface_log "IOSurface zero-copy GPU path enabled (macOS)") endif() + _qgc_register_gpu_path(NAME IOSurface + SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/apple/GstIOSurfaceVideoBuffer.mm + ${CMAKE_CURRENT_SOURCE_DIR}/apple/GstIOSurfaceVideoBuffer.h + LIBS + "-framework CoreVideo" + "-framework IOSurface" + "-framework Metal" + DEFINE QGC_HAS_GST_IOSURFACE_GPU_PATH + LOG "${_iosurface_log}") else() message(STATUS "QGCGStreamer: IOSurface path requires Qt MultimediaPrivate+GuiPrivate — GPU path disabled") endif() endif() -# ---- Windows D3D11 zero-copy (d3d11h264dec / d3d11vp9dec) ---- +# ---- Windows D3D11 / D3D12 zero-copy (gst-plugins-bad d3d{11,12}h264dec / vp9dec) ---- +# `_qgc_probe_gst_d3d_path` (cmake/GStreamer/Probe.cmake) does header+lib resolution; sources are +# registered here so their d3d/ paths resolve against this directory's CMAKE_CURRENT_SOURCE_DIR. if(WIN32) - set(CMAKE_REQUIRED_LIBRARIES_BACKUP "${CMAKE_REQUIRED_LIBRARIES}") - set(CMAKE_REQUIRED_LIBRARIES GStreamer::GStreamer) - check_cxx_source_compiles(" - #include - int main() { (void)gst_is_d3d11_memory; return 0; } - " QGC_GST_HAS_D3D11) - set(CMAKE_REQUIRED_LIBRARIES "${CMAKE_REQUIRED_LIBRARIES_BACKUP}") - if(QGC_GST_HAS_D3D11 AND TARGET Qt6::MultimediaPrivate AND TARGET Qt6::GuiPrivate) - # The headers compile, but the actual gst_d3d11_* exports live in gstd3d11-1.0.lib (gst-plugins-bad shared helper). - # Pull it via pkg-config so the resolved import lib is added to the link line — without this the MSVC ARM64 - # link fails with __imp_gst_d3d11_device_new_wrapped et al unresolved. - find_package(PkgConfig QUIET) - set(_gst_d3d11_pc_found FALSE) - if(PkgConfig_FOUND) - pkg_check_modules(PC_GStreamer_D3D11 QUIET gstreamer-d3d11-1.0) - if(PC_GStreamer_D3D11_FOUND) - set(_gst_d3d11_pc_found TRUE) - endif() - endif() - if(NOT _gst_d3d11_pc_found) - # Fallback: pkg-config may not ship gstreamer-d3d11-1.0.pc on every Windows SDK build, - # but the import lib is usually present on the link search path. - find_library(GST_D3D11_LIB NAMES gstd3d11-1.0 gstd3d11) - if(GST_D3D11_LIB) - set(PC_GStreamer_D3D11_LIBRARIES "${GST_D3D11_LIB}") - set(_gst_d3d11_pc_found TRUE) - endif() - endif() - if(NOT _gst_d3d11_pc_found) - message(STATUS "QGCGStreamer: gstreamer-d3d11-1.0 pkg-config not found and gstd3d11-1.0 not on link path — D3D11 GPU path disabled") - else() - target_sources(${CMAKE_PROJECT_NAME} - PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/GstD3D11VideoBuffer.cc - ${CMAKE_CURRENT_SOURCE_DIR}/GstD3D11VideoBuffer.h - ${CMAKE_CURRENT_SOURCE_DIR}/GstD3D11ContextBridge.cc - ${CMAKE_CURRENT_SOURCE_DIR}/GstD3D11ContextBridge.h - ${CMAKE_CURRENT_SOURCE_DIR}/GstD3DVideoBufferCommon.h - ) - target_include_directories(${CMAKE_PROJECT_NAME} - PRIVATE ${PC_GStreamer_D3D11_INCLUDE_DIRS} - ) - target_link_directories(${CMAKE_PROJECT_NAME} - PRIVATE ${PC_GStreamer_D3D11_LIBRARY_DIRS} - ) - target_link_libraries(${CMAKE_PROJECT_NAME} - PRIVATE - d3d11 - ${PC_GStreamer_D3D11_LIBRARIES} - ) - target_compile_definitions(${CMAKE_PROJECT_NAME} - PRIVATE QGC_HAS_GST_D3D11_GPU_PATH=1 - ) - set(_any_gpu_path TRUE) - message(STATUS "QGCGStreamer: D3D11 zero-copy GPU path enabled (libs=${PC_GStreamer_D3D11_LIBRARIES})") + # PkgConfig is already found upstream via Orchestrator -> PkgConfigTargets/FindGStreamer. + foreach(_ver IN ITEMS 11 12) + _qgc_probe_gst_d3d_path(${_ver} _d3d${_ver}) + if(_d3d${_ver}_FOUND) + target_include_directories(QGCGStreamer PRIVATE ${_d3d${_ver}_INCLUDE_DIRS}) + target_link_directories(QGCGStreamer PRIVATE ${_d3d${_ver}_LIBRARY_DIRS}) + target_compile_definitions(QGCGStreamer PRIVATE GST_USE_UNSTABLE_API) + _qgc_register_gpu_path(NAME D3D${_ver} + SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/d3d/GstD3D${_ver}VideoBuffer.cc + ${CMAKE_CURRENT_SOURCE_DIR}/d3d/GstD3D${_ver}VideoBuffer.h + ${CMAKE_CURRENT_SOURCE_DIR}/d3d/GstD3D${_ver}ContextBridge.cc + ${CMAKE_CURRENT_SOURCE_DIR}/d3d/GstD3D${_ver}ContextBridge.h + ${CMAKE_CURRENT_SOURCE_DIR}/d3d/GstD3DVideoBufferCommon.h + LIBS ${_d3d${_ver}_LIBS} + DEFINE QGC_HAS_GST_D3D${_ver}_GPU_PATH + LOG "D3D${_ver} zero-copy GPU path enabled (libs=${_d3d${_ver}_PC_LIBRARIES})") endif() - endif() -endif() - -# ---- Windows D3D12 zero-copy (d3d12h264dec / d3d12vp9dec) ---- -if(WIN32) - set(CMAKE_REQUIRED_LIBRARIES_BACKUP "${CMAKE_REQUIRED_LIBRARIES}") - set(CMAKE_REQUIRED_LIBRARIES GStreamer::GStreamer) - check_cxx_source_compiles(" - #include - int main() { (void)gst_is_d3d12_memory; return 0; } - " QGC_GST_HAS_D3D12) - set(CMAKE_REQUIRED_LIBRARIES "${CMAKE_REQUIRED_LIBRARIES_BACKUP}") - if(QGC_GST_HAS_D3D12 AND TARGET Qt6::MultimediaPrivate AND TARGET Qt6::GuiPrivate) - find_package(PkgConfig QUIET) - set(_gst_d3d12_pc_found FALSE) - if(PkgConfig_FOUND) - pkg_check_modules(PC_GStreamer_D3D12 QUIET gstreamer-d3d12-1.0) - if(PC_GStreamer_D3D12_FOUND) - set(_gst_d3d12_pc_found TRUE) - endif() - endif() - if(NOT _gst_d3d12_pc_found) - find_library(GST_D3D12_LIB NAMES gstd3d12-1.0 gstd3d12) - if(GST_D3D12_LIB) - set(PC_GStreamer_D3D12_LIBRARIES "${GST_D3D12_LIB}") - set(_gst_d3d12_pc_found TRUE) - endif() - endif() - if(NOT _gst_d3d12_pc_found) - message(STATUS "QGCGStreamer: gstreamer-d3d12-1.0 pkg-config not found and gstd3d12-1.0 not on link path — D3D12 GPU path disabled") - else() - target_sources(${CMAKE_PROJECT_NAME} - PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/GstD3D12VideoBuffer.cc - ${CMAKE_CURRENT_SOURCE_DIR}/GstD3D12VideoBuffer.h - ${CMAKE_CURRENT_SOURCE_DIR}/GstD3D12ContextBridge.cc - ${CMAKE_CURRENT_SOURCE_DIR}/GstD3D12ContextBridge.h - ${CMAKE_CURRENT_SOURCE_DIR}/GstD3DVideoBufferCommon.h - ) - target_include_directories(${CMAKE_PROJECT_NAME} - PRIVATE ${PC_GStreamer_D3D12_INCLUDE_DIRS} - ) - target_link_directories(${CMAKE_PROJECT_NAME} - PRIVATE ${PC_GStreamer_D3D12_LIBRARY_DIRS} - ) - target_link_libraries(${CMAKE_PROJECT_NAME} - PRIVATE - d3d12 - ${PC_GStreamer_D3D12_LIBRARIES} - ) - target_compile_definitions(${CMAKE_PROJECT_NAME} - PRIVATE QGC_HAS_GST_D3D12_GPU_PATH=1 - ) - set(_any_gpu_path TRUE) - message(STATUS "QGCGStreamer: D3D12 zero-copy GPU path enabled (libs=${PC_GStreamer_D3D12_LIBRARIES})") - endif() - endif() + endforeach() endif() # Shared bridge helpers compiled once when at least one D3D path is on. -if(WIN32 AND (QGC_GST_HAS_D3D11 OR QGC_GST_HAS_D3D12) AND TARGET Qt6::MultimediaPrivate AND TARGET Qt6::GuiPrivate) - target_sources(${CMAKE_PROJECT_NAME} +if(WIN32 AND (QGC_GST_HAS_D3D11 OR QGC_GST_HAS_D3D12) AND _qgc_qt_private) + target_sources(QGCGStreamer PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/GstD3DContextBridgeCommon.cc - ${CMAKE_CURRENT_SOURCE_DIR}/GstD3DContextBridgeCommon.h + ${CMAKE_CURRENT_SOURCE_DIR}/d3d/GstD3DContextBridgeCommon.cc + ${CMAKE_CURRENT_SOURCE_DIR}/d3d/GstD3DContextBridgeCommon.h ) endif() # ---- Android AHardwareBuffer zero-copy (gst-plugins-bad >= 1.28) ---- if(ANDROID) - set(CMAKE_REQUIRED_LIBRARIES_BACKUP "${CMAKE_REQUIRED_LIBRARIES}") - if(TARGET GStreamerMobile) - set(CMAKE_REQUIRED_LIBRARIES GStreamerMobile) - else() - set(CMAKE_REQUIRED_LIBRARIES GStreamer::GStreamer) - endif() - check_cxx_source_compiles(" - #include - int main() { (void)gst_is_ahardware_buffer_memory; return 0; } - " QGC_GST_HAS_AHARDWAREBUFFER) - set(CMAKE_REQUIRED_LIBRARIES "${CMAKE_REQUIRED_LIBRARIES_BACKUP}") + qgc_check_gst_header(VAR QGC_GST_HAS_AHARDWAREBUFFER + HEADER gst/android/gstandroid.h SYMBOL gst_is_ahardware_buffer_memory) if(NOT QGC_GST_HAS_AHARDWAREBUFFER) message(STATUS "QGCGStreamer: AHardwareBuffer header not in SDK (need gst-plugins-bad >= 1.28) — falling back to GLMemory path on Android") endif() - if(QGC_GST_HAS_AHARDWAREBUFFER AND TARGET Qt6::MultimediaPrivate AND TARGET Qt6::GuiPrivate) + if(QGC_GST_HAS_AHARDWAREBUFFER AND _qgc_qt_private) find_package(OpenGL QUIET COMPONENTS EGL) if(TARGET OpenGL::EGL) - target_sources(${CMAKE_PROJECT_NAME} - PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/GstAHardwareBufferVideoBuffer.cc - ${CMAKE_CURRENT_SOURCE_DIR}/GstAHardwareBufferVideoBuffer.h - ) - target_link_libraries(${CMAKE_PROJECT_NAME} - PRIVATE - OpenGL::EGL - ) - target_compile_definitions(${CMAKE_PROJECT_NAME} - PRIVATE QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH=1 - ) - set(_any_gpu_path TRUE) - message(STATUS "QGCGStreamer: AHardwareBuffer zero-copy GPU path enabled") + _qgc_register_gpu_path(NAME AHardwareBuffer + SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/android/GstAHardwareBufferVideoBuffer.cc + ${CMAKE_CURRENT_SOURCE_DIR}/android/GstAHardwareBufferVideoBuffer.h + LIBS OpenGL::EGL + DEFINE QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH + LOG "AHardwareBuffer zero-copy GPU path enabled") else() message(STATUS "QGCGStreamer: AHardwareBuffer detected but EGL missing — GPU path disabled") endif() endif() endif() +# ---- Vulkan zero-copy (gst-vulkan) — forward-looking, all desktop/mobile platforms ---- +# Runtime-dormant: the per-frame device-match guard sends foreign-VkDevice frames to CPU until same-VkDevice +# sharing lands. Platform-agnostic (QRhi + gst-vulkan, no X11/EGL); non-Linux builds are review-only here. +if(LINUX OR WIN32 OR MACOS OR ANDROID) + qgc_check_gst_header(VAR QGC_GST_HAS_VULKAN HEADER gst/vulkan/vulkan.h SYMBOL gst_is_vulkan_image_memory) + # The impl TUs gate their bodies on `... && QT_CONFIG(vulkan)` (they need qrhi Vulkan + vulkan.h), + # but headers/callsites gate on the define alone — so the define must imply Qt Vulkan support, else + # callsites reference symbols the impls #if'd out. macOS Qt ships no Vulkan yet GStreamer.framework + # carries libgstvulkan-1.0, which is exactly the case that linked broken. + if(NOT QT_FEATURE_vulkan) + message(STATUS "QGCGStreamer: Qt built without Vulkan support (QT_FEATURE_vulkan off) — Vulkan GPU path disabled") + elseif(QGC_GST_HAS_VULKAN AND _qgc_qt_private) + # PkgConfig already found upstream via Orchestrator (see WIN32 block above). + set(_gst_vulkan_found FALSE) + if(PkgConfig_FOUND) + pkg_check_modules(PC_GStreamer_Vulkan QUIET gstreamer-vulkan-1.0) + if(PC_GStreamer_Vulkan_FOUND) + # macOS GStreamer.framework ships the .pc + headers but not libgstvulkan-1.0; verify the + # library actually resolves on the link path (as the D3D probe does) before enabling, or + # the build dies at link with "ld: library 'gstvulkan-1.0' not found". + if(DEFINED QGC_GST_VULKAN_LIB AND + (QGC_GST_VULKAN_LIB MATCHES "NOTFOUND$" OR NOT EXISTS "${QGC_GST_VULKAN_LIB}")) + unset(QGC_GST_VULKAN_LIB CACHE) + endif() + find_library(QGC_GST_VULKAN_LIB NAMES gstvulkan-1.0 gstvulkan + HINTS ${PC_GStreamer_Vulkan_LIBRARY_DIRS} "${GSTREAMER_LIB_PATH}") + if(QGC_GST_VULKAN_LIB) + # Link the absolute resolved path (as the D3D probe does), not the bare + # pkg-config name: on macOS the framework's gstreamer-vulkan-1.0.pc libdir + # doesn't match where libgstvulkan-1.0 actually lives, so find_library locates + # it via GSTREAMER_LIB_PATH while `-lgstvulkan-1.0` with the .pc's -L fails at + # link ("ld: library 'gstvulkan-1.0' not found"). + set(PC_GStreamer_Vulkan_LIBRARIES "${QGC_GST_VULKAN_LIB}") + set(_gst_vulkan_found TRUE) + else() + message(STATUS "QGCGStreamer: gstreamer-vulkan-1.0 pkg-config found but libgstvulkan-1.0 not on link path — Vulkan GPU path disabled") + endif() + endif() + endif() + # A static libgstvulkan (Android mobile archive; static-libs desktop) drags gst-vulkan's own + # loader calls into our link unresolved — the shared .so self-satisfies them, the .a does not. + # Link the loader explicitly or Android arm64 dies: ld.lld "undefined symbol: vkDestroyPipeline". + set(_qgc_vulkan_loader_libs "") + if(_gst_vulkan_found AND (GStreamer_USE_STATIC_LIBS OR ANDROID)) + # find_library, not find_package(Vulkan): we need only the loader, not SDK headers/glslc, + # and it resolves libvulkan in the NDK sysroot. NAMES: vulkan (Android/Linux), vulkan-1 (MSVC). + if(DEFINED QGC_GST_VULKAN_LOADER_LIB AND + (QGC_GST_VULKAN_LOADER_LIB MATCHES "NOTFOUND$" OR NOT EXISTS "${QGC_GST_VULKAN_LOADER_LIB}")) + unset(QGC_GST_VULKAN_LOADER_LIB CACHE) + endif() + find_library(QGC_GST_VULKAN_LOADER_LIB NAMES vulkan vulkan-1) + if(QGC_GST_VULKAN_LOADER_LIB) + set(_qgc_vulkan_loader_libs "${QGC_GST_VULKAN_LOADER_LIB}") + else() + set(_gst_vulkan_found FALSE) + set(_qgc_vulkan_loader_missing TRUE) + message(STATUS "QGCGStreamer: gst-vulkan found but the Vulkan loader (libvulkan) is not on the link path for a static link — Vulkan GPU path disabled") + endif() + endif() + if(_gst_vulkan_found) + target_include_directories(QGCGStreamer PRIVATE ${PC_GStreamer_Vulkan_INCLUDE_DIRS}) + target_link_directories(QGCGStreamer PRIVATE ${PC_GStreamer_Vulkan_LIBRARY_DIRS}) + _qgc_register_gpu_path(NAME Vulkan + SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/vulkan/GstVulkanVideoBuffer.cc + ${CMAKE_CURRENT_SOURCE_DIR}/vulkan/GstVulkanVideoBuffer.h + ${CMAKE_CURRENT_SOURCE_DIR}/vulkan/GstVulkanContextBridge.cc + ${CMAKE_CURRENT_SOURCE_DIR}/vulkan/GstVulkanContextBridge.h + LIBS ${PC_GStreamer_Vulkan_LIBRARIES} ${_qgc_vulkan_loader_libs} + DEFINE QGC_HAS_GST_VULKAN_GPU_PATH + LOG "Vulkan zero-copy GPU path enabled") + elseif(NOT _qgc_vulkan_loader_missing) + message(STATUS "QGCGStreamer: gstreamer-vulkan-1.0 pkg-config not found — Vulkan GPU path disabled") + endif() + endif() +endif() + # QGC_HAS_ANY_GPU_PATH: set when at least one platform zero-copy path is enabled. +get_property(_any_gpu_path GLOBAL PROPERTY QGC_GST_ANY_GPU_PATH) if(_any_gpu_path) - target_compile_definitions(${CMAKE_PROJECT_NAME} - PRIVATE QGC_HAS_ANY_GPU_PATH=1 + # PUBLIC: the app facade (GStreamer.cc) gates gpu-zerocopy bin construction on this macro but + # compiles into the app target — PRIVATE hid it, so createVideoSink() always built the CPU-only bin. + target_compile_definitions(QGCGStreamer + PUBLIC QGC_HAS_ANY_GPU_PATH=1 ) - if(TARGET Qt6::MultimediaPrivate AND TARGET Qt6::GuiPrivate) - target_link_libraries(${CMAKE_PROJECT_NAME} - PRIVATE - Qt6::MultimediaPrivate - Qt6::GuiPrivate - ) - # Base class + factory + bridge registry + RHI capture: all depend on Qt6::MultimediaPrivate - # (private/qhwvideobuffer_p.h, qrhi.h). Co-located with the link to keep the no-GPU build green. - target_sources(${CMAKE_PROJECT_NAME} + if(_qgc_qt_private) + # Qt6::MultimediaPrivate is already PUBLIC on QGCGStreamer (the facade reaches it through the + # HwBuffers headers it includes); only Qt6::GuiPrivate (qrhi.h) is GPU-path-exclusive here. + target_link_libraries(QGCGStreamer PRIVATE Qt6::GuiPrivate) + # GPU-only TUs (base class, factory, bridge registry, RHI capture) depend on qrhi.h; co-located + # with the link so the no-GPU build stays green. + target_sources(QGCGStreamer PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/GstHwVideoBuffer.cc - ${CMAKE_CURRENT_SOURCE_DIR}/GstHwVideoBuffer.h - ${CMAKE_CURRENT_SOURCE_DIR}/GstContextBridgeRegistry.cc - ${CMAKE_CURRENT_SOURCE_DIR}/GstContextBridgeRegistry.h - ${CMAKE_CURRENT_SOURCE_DIR}/GstHwVideoBufferFactory.cc - ${CMAKE_CURRENT_SOURCE_DIR}/GstHwVideoBufferFactory.h - ${CMAKE_CURRENT_SOURCE_DIR}/QGCRhiCapture.cc - ${CMAKE_CURRENT_SOURCE_DIR}/QGCRhiCapture.h + ${CMAKE_CURRENT_SOURCE_DIR}/dmabuf/GstEglHelpers.cc + ${CMAKE_CURRENT_SOURCE_DIR}/dmabuf/GstEglHelpers.h + ${CMAKE_CURRENT_SOURCE_DIR}/gl/GstGlFrameTextures.h + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstHwFrameTexturesBase.h + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstHwImportPreflight.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstHwImportPreflight.h + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstBridgePrimeRetry.h + ${CMAKE_CURRENT_SOURCE_DIR}/vulkan/GstVulkanFrameTextures.h + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstHwVideoBuffer.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstHwVideoBuffer.h + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstContextBridgeCommon.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstContextBridgeCommon.h + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstContextBridgeRegistry.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstContextBridgeRegistry.h + ${CMAKE_CURRENT_SOURCE_DIR}/common/GstHwVideoBufferFactory.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/QGCRhiCapture.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/QGCRhiCapture.h ) endif() endif() diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstAHardwareBufferVideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstAHardwareBufferVideoBuffer.cc deleted file mode 100644 index 53928051d203..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstAHardwareBufferVideoBuffer.cc +++ /dev/null @@ -1,270 +0,0 @@ -#include "GstAHardwareBufferVideoBuffer.h" - -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - -#include "QGCLoggingCategory.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include -#include -#include - -#include - -#include -#include -#include - -QGC_LOGGING_CATEGORY(GstAHWBufLog, "Video.GStreamer.HwBuffers.GstAHWBuf") - -namespace { - -constexpr int kMaxPlanes = 4; - -std::atomic s_mapFailureCount{0}; -std::atomic s_loggedBadBackend{false}; - -// EGL_ANDROID_image_native_buffer presence cache — one query per EGLDisplay. -QMutex s_extCacheMutex; -QHash s_extCache; - -bool queryHasNativeBufferExt(EGLDisplay display) -{ - QMutexLocker lock(&s_extCacheMutex); - auto it = s_extCache.find(display); - if (it != s_extCache.end()) { - return it.value(); - } - const char *exts = eglQueryString(display, EGL_EXTENSIONS); - const bool supported = exts != nullptr - && strstr(exts, "EGL_ANDROID_image_native_buffer") != nullptr; - s_extCache.insert(display, supported); - return supported; -} - -QVideoFrameTexturesUPtr fail() -{ - s_mapFailureCount.fetch_add(1, std::memory_order_relaxed); - return {}; -} - -class FrameTextures final : public QVideoFrameTextures -{ -public: - // AHardwareBuffer is always single-plane external-OES; pixelFormat must be Format_SamplerExternalOES. - FrameTextures(QRhi *rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, GLuint name) - : _rhi(rhi) - , _name(name) - { - const auto *desc = QVideoTextureHelper::textureDescription(pixelFormat); - if (!desc) { - qCWarning(GstAHWBufLog) << "no QVideoTextureHelper description for format" << pixelFormat; - return; - } - const QSize planeSize = desc->rhiPlaneSize(size, 0, rhi); - // ExternalOES: bind GL_TEXTURE_EXTERNAL_OES + emit SamplerExternalOES — without it OES_EGL_image_external samples black on GLES2. - _texture.reset(rhi->newTexture( - desc->rhiTextureFormat(0, rhi, QVideoTextureHelper::TextureDescription::FallbackPolicy::Disable), - planeSize, 1, QRhiTexture::ExternalOES)); - if (_texture && !_texture->createFrom({_name, 0})) { - qCWarning(GstAHWBufLog) << "QRhiTexture::createFrom failed for AHardwareBuffer plane 0"; - _texture.reset(); - } - } - - ~FrameTextures() override - { - releaseGLTextures(); - } - - void onFrameEndInvoked() override - { - releaseGLTextures(); - } - - QRhiTexture *texture(uint plane) const override - { - return plane == 0 ? _texture.get() : nullptr; - } - -private: - void releaseGLTextures() - { - if (_released || !_rhi) return; - _released = true; - _rhi->makeThreadLocalNativeContextCurrent(); - if (auto *ctx = QOpenGLContext::currentContext()) { - ctx->functions()->glDeleteTextures(1, &_name); - } - } - - QRhi *_rhi = nullptr; - GLuint _name = 0; - bool _released = false; - std::unique_ptr _texture; -}; - -} // namespace - -GstAHardwareBufferVideoBuffer::GstAHardwareBufferVideoBuffer(GstSample *sample, - const GstVideoInfo &videoInfo, - const QVideoFrameFormat &format, - EGLDisplay eglDisplay) - : GstHwVideoBuffer(QVideoFrame::RhiTextureHandle, sample, videoInfo, format) - , _eglDisplay(eglDisplay) -{ -} - -GstAHardwareBufferVideoBuffer::~GstAHardwareBufferVideoBuffer() = default; - -QAbstractVideoBuffer::MapData GstAHardwareBufferVideoBuffer::map(QVideoFrame::MapMode /*mode*/) -{ - return {}; -} - -bool GstAHardwareBufferVideoBuffer::validatePlaneHandles() const -{ - if (!_sample) return false; - GstBuffer *buffer = gst_sample_get_buffer(_sample); - if (!buffer) return false; - const int memCount = qMin(int(gst_buffer_n_memory(buffer)), kMaxPlanes); - if (memCount <= 0) return false; - for (int i = 0; i < memCount; ++i) { - GstMemory *mem = gst_buffer_peek_memory(buffer, i); - if (!mem || !gst_is_ahardware_buffer_memory(mem)) return false; - if (!gst_ahardware_buffer_memory_get_buffer(GST_AHARDWARE_BUFFER_MEMORY_CAST(mem))) { - return false; - } - } - return true; -} - -QVideoFrameTexturesUPtr GstAHardwareBufferVideoBuffer::mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr & /*old*/) -{ - Q_ASSERT(rhi.thread()->isCurrentThread()); // Qt's contract: mapTextures runs on the QRhi (render) thread. - // TODO(android-test): needs on-device fixture; no Android CI rig for the test harness here. - - if (!_sample || _eglDisplay == EGL_NO_DISPLAY) { - return fail(); - } - if (rhi.backend() != QRhi::OpenGLES2) { - if (!s_loggedBadBackend.exchange(true, std::memory_order_relaxed)) { - qCWarning(GstAHWBufLog) << "QRhi backend is not OpenGLES2; AHardwareBuffer path unsupported"; - } - return fail(); - } - - // Bind Qt's GL context on the render thread before any EGL/GL call — without this glBindTexture / glEGLImageTargetTexture2DOES silently no-op into a foreign or null context. - rhi.makeThreadLocalNativeContextCurrent(); - - // Prefer Qt's actual EGLDisplay over the constructor-passed handle; eglGetDisplay(EGL_DEFAULT_DISPLAY) at construction time can resolve to a different EGLDisplay than the one Qt's context is bound to, which makes EGLImage import succeed but sampling return black. - EGLDisplay eglDpy = eglGetCurrentDisplay(); - if (eglDpy == EGL_NO_DISPLAY) { - eglDpy = _eglDisplay; - } - - GstBuffer *buffer = gst_sample_get_buffer(_sample); - if (!buffer) return fail(); - - GstMemory *mem0 = gst_buffer_peek_memory(buffer, 0); - if (!mem0 || !gst_is_ahardware_buffer_memory(mem0)) { - return fail(); - } - - if (!queryHasNativeBufferExt(eglDpy)) { - static bool s_warned = false; - if (!s_warned) { - s_warned = true; - qCWarning(GstAHWBufLog) << "EGL_ANDROID_image_native_buffer unavailable; AHardwareBuffer path disabled"; - } - return fail(); - } - - static const auto eglGetNativeClientBufferANDROID_ = - reinterpret_cast( - eglGetProcAddress("eglGetNativeClientBufferANDROID")); - static const auto eglCreateImageKHR_ = - reinterpret_cast( - eglGetProcAddress("eglCreateImageKHR")); - static const auto eglDestroyImageKHR_ = - reinterpret_cast( - eglGetProcAddress("eglDestroyImageKHR")); - static const auto glEGLImageTargetTexture2DOES_ = - reinterpret_cast( - eglGetProcAddress("glEGLImageTargetTexture2DOES")); - - if (!eglGetNativeClientBufferANDROID_ || !eglCreateImageKHR_ - || !eglDestroyImageKHR_ || !glEGLImageTargetTexture2DOES_) { - qCWarning(GstAHWBufLog) << "Required EGL/GL proc addresses unavailable"; - return fail(); - } - - const auto *nativeHandles = static_cast(rhi.nativeHandles()); - if (!nativeHandles || !nativeHandles->context) { - qCWarning(GstAHWBufLog) << "QRhi exposes no GL context"; - return fail(); - } - - AHardwareBuffer *ahwb = gst_ahardware_buffer_memory_get_buffer( - GST_AHARDWARE_BUFFER_MEMORY_CAST(mem0)); - if (!ahwb) { - qCWarning(GstAHWBufLog) << "gst_ahardware_buffer_memory_get_buffer returned null"; - return fail(); - } - - EGLClientBuffer clientBuffer = eglGetNativeClientBufferANDROID_(ahwb); - if (!clientBuffer) { - qCWarning(GstAHWBufLog) << "eglGetNativeClientBufferANDROID returned null"; - return fail(); - } - - const EGLint attribs[] = { EGL_NONE }; - EGLImageKHR image = eglCreateImageKHR_(eglDpy, EGL_NO_CONTEXT, - EGL_NATIVE_BUFFER_ANDROID, - clientBuffer, attribs); - if (image == EGL_NO_IMAGE_KHR) { - qCWarning(GstAHWBufLog) << "eglCreateImageKHR failed, err=" << Qt::hex << eglGetError(); - return fail(); - } - - // AHardwareBuffer is single-plane; GL_TEXTURE_EXTERNAL_OES requires Format_SamplerExternalOES. - GLuint name = 0; - QOpenGLFunctions functions(nativeHandles->context); - functions.glGenTextures(1, &name); - functions.glBindTexture(GL_TEXTURE_EXTERNAL_OES, name); - glEGLImageTargetTexture2DOES_(GL_TEXTURE_EXTERNAL_OES, image); - eglDestroyImageKHR_(eglDpy, image); - - auto textures = std::make_unique(&rhi, _format.frameSize(), - QVideoFrameFormat::Format_SamplerExternalOES, name); - if (!textures->texture(0)) { - qCWarning(GstAHWBufLog) << "createFrom failed for plane 0 (SamplerExternalOES)"; - functions.glDeleteTextures(1, &name); - return fail(); - } - return textures; -} - -quint64 GstAHardwareBufferVideoBuffer::takeMapFailureCount() -{ - return s_mapFailureCount.exchange(0, std::memory_order_relaxed); -} - -quint64 GstAHardwareBufferVideoBuffer::peekMapFailureCount() -{ - return s_mapFailureCount.load(std::memory_order_relaxed); -} - -#endif // QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstAHardwareBufferVideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstAHardwareBufferVideoBuffer.h deleted file mode 100644 index c05b5bdca6aa..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstAHardwareBufferVideoBuffer.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include - -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - -#include "GstHwVideoBuffer.h" - -#include - -class QRhi; - -/// \brief Zero-copy QVideoFrame backing for GStreamer AHardwareBuffer samples (Android). -/// -/// Imports via eglGetNativeClientBufferANDROID + eglCreateImageKHR into GL textures. -/// Requires EGL_ANDROID_image_native_buffer. -/// -class GstAHardwareBufferVideoBuffer final : public GstHwVideoBuffer -{ -public: - GstAHardwareBufferVideoBuffer(GstSample *sample, - const GstVideoInfo &videoInfo, - const QVideoFrameFormat &format, - EGLDisplay eglDisplay); - ~GstAHardwareBufferVideoBuffer() override; - - MapData map(QVideoFrame::MapMode mode) override; - QVideoFrameTexturesUPtr mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr &oldTextures) override; - bool validatePlaneHandles() const override; - - static quint64 takeMapFailureCount(); - static quint64 peekMapFailureCount(); - -private: - EGLDisplay _eglDisplay = EGL_NO_DISPLAY; -}; - -#endif // QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstContextBridgeRegistry.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstContextBridgeRegistry.cc deleted file mode 100644 index bfbdf7eae3fd..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstContextBridgeRegistry.cc +++ /dev/null @@ -1,100 +0,0 @@ -#include "GstContextBridgeRegistry.h" -#include "QGCLoggingCategory.h" - -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) || defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH) - -#include -#include -#include - -QGC_LOGGING_CATEGORY(GstContextBridgeRegistryLog, "Video.GStreamer.HwBuffers.GstContextBridgeRegistry") - -namespace GstContextBridgeRegistry { - -namespace { - -constexpr int kMaxHandlers = 8; -// Atomic entries close the write-after-read hazard: dispatch loads each slot with acquire -// ordering, paired with the release store in registerBridgeHandler. -std::array, kMaxHandlers> s_handlers{}; -std::atomic s_count{0}; -std::array, kMaxHandlers> s_resets{}; -std::atomic s_resetCount{0}; -std::mutex s_registerMutex; - -} // namespace - -void registerBridgeHandler(BridgeHandler handler) -{ - if (!handler) return; - std::lock_guard lock(s_registerMutex); - const int slot = s_count.load(std::memory_order_relaxed); - if (slot >= kMaxHandlers) { - qCWarning(GstContextBridgeRegistryLog) - << "Bridge handler limit (" << kMaxHandlers << ") exceeded — dropping registration"; - return; - } - // Store handler before publishing the new count so dispatchBridges can't observe a count - // that includes a slot whose handler write isn't yet visible. - s_handlers[slot].store(handler, std::memory_order_release); - s_count.store(slot + 1, std::memory_order_release); -} - -// Handlers run in registration (link) order; first GST_BUS_DROP wins. Bridges must mutually -// exclude on context-type so order doesn't shadow another bridge's intended handoff. -GstBusSyncReply dispatchBridges(GstMessage *message) -{ - const int count = s_count.load(std::memory_order_acquire); - for (int i = 0; i < count && i < kMaxHandlers; ++i) { - BridgeHandler h = s_handlers[i].load(std::memory_order_acquire); - if (h && h(message) == GST_BUS_DROP) { - return GST_BUS_DROP; - } - } - return GST_BUS_PASS; -} - -void registerResetCallback(ResetCallback callback) -{ - if (!callback) return; - std::lock_guard lock(s_registerMutex); - const int slot = s_resetCount.load(std::memory_order_relaxed); - if (slot >= kMaxHandlers) { - qCWarning(GstContextBridgeRegistryLog) - << "Reset callback limit (" << kMaxHandlers << ") exceeded — dropping registration"; - return; - } - s_resets[slot].store(callback, std::memory_order_release); - s_resetCount.store(slot + 1, std::memory_order_release); -} - -void resetAllBridges() -{ - const int count = s_resetCount.load(std::memory_order_acquire); - for (int i = 0; i < count && i < kMaxHandlers; ++i) { - ResetCallback cb = s_resets[i].load(std::memory_order_acquire); - if (cb) cb(); - } -} - -#ifdef QT_TESTLIB_LIB -void clearForTest() -{ - // Drop cached device/context state in every bridge first; otherwise a test that primes - // a bridge then re-registers via registerBridgeHandler would observe stale s_primed=true. - // Done before mutex acquisition because each bridge takes its own lock. - resetAllBridges(); - std::lock_guard lock(s_registerMutex); - // Zero count before the slot stores so a concurrent dispatchBridges can't read a slot - // we've already nulled while count is still high. Pairs with the count-after-slot store - // ordering in registerBridgeHandler. - s_count.store(0, std::memory_order_release); - s_resetCount.store(0, std::memory_order_release); - for (auto &slot : s_handlers) slot.store(nullptr, std::memory_order_release); - for (auto &slot : s_resets) slot.store(nullptr, std::memory_order_release); -} -#endif - -} // namespace GstContextBridgeRegistry - -#endif // QGC_HAS_GST_GLMEMORY_GPU_PATH || QGC_HAS_GST_D3D11_GPU_PATH || QGC_HAS_GST_D3D12_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstContextBridgeRegistry.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstContextBridgeRegistry.h deleted file mode 100644 index bd71c38dce5d..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstContextBridgeRegistry.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include - -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) || defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH) - -#include - -/// Static registry that fans out GstBus sync messages to every compiled context bridge. -/// -/// Each bridge registers a handler at file scope. _contextSyncDispatch in -/// GstVideoReceiver reduces to a single dispatchBridges() call. -namespace GstContextBridgeRegistry { - -using BridgeHandler = GstBusSyncReply(*)(GstMessage *); -using ResetCallback = void(*)(); - -/// Register a bridge handler. Called from static initializers before any pipeline starts. -void registerBridgeHandler(BridgeHandler handler); - -/// Register a per-bridge reset callback that drops cached devices/contexts. -/// Invoked on QQuickWindow scene-graph teardown to keep wrapped GPU handles -/// from outliving the underlying QRhi. -void registerResetCallback(ResetCallback callback); - -/// Dispatch message to all registered handlers; returns GST_BUS_DROP on first consumer. -GstBusSyncReply dispatchBridges(GstMessage *message); - -/// Invoke every registered reset callback. Safe to call from any thread; each bridge's -/// reset() takes its own mutex. -void resetAllBridges(); - -#ifdef QT_TESTLIB_LIB -/// Reset handler list so unit tests can register controlled handlers in isolation. -/// Also calls resetAllBridges() so cached state from prior tests doesn't leak across. -void clearForTest(); -#endif - -} // namespace GstContextBridgeRegistry - -#endif // QGC_HAS_GST_GLMEMORY_GPU_PATH || QGC_HAS_GST_D3D11_GPU_PATH || QGC_HAS_GST_D3D12_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D11ContextBridge.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D11ContextBridge.cc deleted file mode 100644 index 795143eb3d11..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D11ContextBridge.cc +++ /dev/null @@ -1,117 +0,0 @@ -#include "GstD3D11ContextBridge.h" -#include "GstContextBridgeRegistry.h" -#include "GstD3DContextBridgeCommon.h" - -#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) - -#include "QGCLoggingCategory.h" - -#include - -#include -#include - -#include - -QGC_LOGGING_CATEGORY(GstD3D11BridgeLog, "Video.GStreamer.HwBuffers.GstD3D11Bridge") - -namespace GstD3D11ContextBridge { -namespace { - -GstD3DContextBridgeCommon::BridgeState s_state; -GstD3D11Device *s_device = nullptr; - -bool primeLocked() -{ - if (s_state.primed) return true; - - QRhi *rhi = GstD3DContextBridgeCommon::checkRhiBackend( - s_state, GstD3D11BridgeLog(), int(QRhi::D3D11), "D3D11"); - if (!rhi) return false; - - auto *handles = static_cast(rhi->nativeHandles()); - if (!handles || !handles->dev) { - qCWarning(GstD3D11BridgeLog) << "QRhiD3D11NativeHandles missing ID3D11Device*"; - return false; - } - - // gst_d3d11_device_new_wrapped (renamed from gst_d3d11_device_wrap in 1.28): shared device keeps textures sampleable by QRhi without keyed-mutex transfer. - s_device = gst_d3d11_device_new_wrapped(static_cast(handles->dev)); - if (!s_device) { - qCWarning(GstD3D11BridgeLog) << "gst_d3d11_device_new_wrapped failed"; - return false; - } - s_state.primed = true; - qCInfo(GstD3D11BridgeLog) << "D3D11 bridge primed: shared device =" << s_device; - // Sign-extend qint32 HighPart so it matches LARGE_INTEGER::QuadPart bit-for-bit. - const gint64 expectedLuid = (static_cast(handles->adapterLuidHigh) << 32) - | (static_cast(handles->adapterLuidLow) & 0xFFFFFFFFLL); - GstD3DContextBridgeCommon::logAdapterMatch(rhi, expectedLuid, s_device, - GstD3D11BridgeLog(), "D3D11"); - return true; -} - -} // namespace - -bool prime() -{ - QMutexLocker lock(&s_state.mutex); - return primeLocked(); -} - -GstD3D11Device *currentDevice() -{ - QMutexLocker lock(&s_state.mutex); - if (!s_device) return nullptr; - return GST_D3D11_DEVICE_CAST(gst_object_ref(s_device)); -} - -GstBusSyncReply handleSyncMessage(GstMessage *message) -{ - GstElement *element = GstD3DContextBridgeCommon::matchNeedContext( - message, GST_D3D11_DEVICE_HANDLE_CONTEXT_TYPE); - if (!element) { - return GST_BUS_PASS; - } - - QMutexLocker lock(&s_state.mutex); - if (!primeLocked() || !s_device) { - return GST_BUS_PASS; - } - - // gst_d3d11_context_new wraps s_device in a GstContext; gst_object_ref's s_device internally. - GstContext *ctx = gst_d3d11_context_new(s_device); - if (!ctx) { - qCWarning(GstD3D11BridgeLog) << "gst_d3d11_context_new failed for element" - << GST_ELEMENT_NAME(element); - return GST_BUS_PASS; - } - gst_element_set_context(element, ctx); - gst_context_unref(ctx); - gst_message_unref(message); - GstD3DContextBridgeCommon::logHandoff(s_state, GstD3D11BridgeLog(), element, "D3D11"); - return GST_BUS_DROP; -} - -void reset() -{ - QMutexLocker lock(&s_state.mutex); - gst_clear_object(&s_device); - s_state.primed = false; - s_state.warnedWrongBackend = false; - qCDebug(GstD3D11BridgeLog) << "D3D11 bridge reset"; -} - -namespace { -struct D3D11BridgeRegistrar { - D3D11BridgeRegistrar() { - GstContextBridgeRegistry::registerBridgeHandler(&GstD3D11ContextBridge::handleSyncMessage); - GstContextBridgeRegistry::registerResetCallback(&GstD3D11ContextBridge::reset); - } -}; -static D3D11BridgeRegistrar s_d3d11BridgeRegistrar; -} // anonymous namespace - -} // namespace GstD3D11ContextBridge - -#endif // Q_OS_WIN && QGC_HAS_GST_D3D11_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D11ContextBridge.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D11ContextBridge.h deleted file mode 100644 index f1f897c4bba8..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D11ContextBridge.h +++ /dev/null @@ -1,48 +0,0 @@ -#pragma once - -#include - -#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) - -#include - -// Forward-declare the opaque GObject type so the currentDevice() accessor below parses -// for callers that haven't pulled in . Bridge implementation and -// any caller that dereferences the pointer must include the full header. -typedef struct _GstD3D11Device GstD3D11Device; - -/// Process-wide shared GstD3D11Device bridging Qt's D3D11 RHI device with -/// GStreamer's d3d11 elements (`d3d11h264dec`, `d3d11vp9dec`, `d3d11convert`). -/// -/// d3d11 elements ask the pipeline for `GST_D3D11_DEVICE_HANDLE_CONTEXT_TYPE` -/// ("gst.d3d11.device.handle") via GST_MESSAGE_NEED_CONTEXT. If we respond with -/// a GstD3D11Device wrapping the same `ID3D11Device*` QRhi uses, the decoder -/// allocates textures on a device QRhi can sample from — true zero-copy. -/// -/// Without this bridge, d3d11 elements create an internal device isolated from -/// QRhi; textures from one device are unusable on the other. -namespace GstD3D11ContextBridge { - -/// Idempotent. Returns true when a shared GstD3D11Device has been built by -/// wrapping the live `ID3D11Device*` exposed via QRhi's native handles. -/// Returns false (and logs once) if QRhi isn't D3D11, isn't yet initialized, -/// or the wrap call fails — caller should retry on a later NEED_CONTEXT. -bool prime(); - -/// Inspect a NEED_CONTEXT message; if it's for `gst.d3d11.device.handle`, -/// respond with the shared GstD3D11Device and consume the message. Returns -/// GST_BUS_DROP when consumed, GST_BUS_PASS otherwise. Cheap when the message -/// isn't relevant. Thread-safe. -GstBusSyncReply handleSyncMessage(GstMessage *message); - -/// Drop the cached GstD3D11Device so the next prime() rebuilds against the -/// current QRhi device. Call from receiver teardown. -void reset(); - -/// Transfer-full ref to the cached shared device (caller unrefs), or nullptr if not primed. -/// Caller validates GstD3D11Memory device matches; transfer-full keeps it alive across reset(). -GstD3D11Device *currentDevice(); - -} // namespace GstD3D11ContextBridge - -#endif // Q_OS_WIN && QGC_HAS_GST_D3D11_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D11VideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D11VideoBuffer.cc deleted file mode 100644 index dddb3e3a5447..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D11VideoBuffer.cc +++ /dev/null @@ -1,204 +0,0 @@ -#include "GstD3D11VideoBuffer.h" - -#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) - -#include "GstD3D11ContextBridge.h" -#include "GstD3DContextBridgeCommon.h" -#include "GstD3DVideoBufferCommon.h" -#include "QGCLoggingCategory.h" - -#include - -#include - -#include - -QGC_LOGGING_CATEGORY(GstD3D11Log, "Video.GStreamer.HwBuffers.GstD3D11Buf") - -namespace { - -using GstD3DVideoBufferCommon::kMaxPlanes; -using GstD3DVideoBufferCommon::MapDiagnostics; -using GstD3DVideoBufferCommon::fail; -using D3D11FrameTextures = GstD3DVideoBufferCommon::FrameTextures; - -MapDiagnostics s_diag; - -/// Copies a single subresource slice out of a multi-slice ID3D11Texture2D into -/// a fresh ID3D11Texture2D so QRhi (which has no subresource selector) can -/// sample it. Returns the staging texture (caller owns the ref) or nullptr. -ID3D11Texture2D *copySliceToStaging(ID3D11Texture2D *tex, guint subIdx, int planeIdx, - const D3D11_TEXTURE2D_DESC &srcDesc, - GstD3D11Memory *d3dmem) -{ - ID3D11Device *d3dDev = gst_d3d11_device_get_device_handle(d3dmem->device); - ID3D11DeviceContext *d3dCtx = gst_d3d11_device_get_device_context_handle(d3dmem->device); - D3D11_TEXTURE2D_DESC dstDesc = srcDesc; - dstDesc.ArraySize = 1; - dstDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE; - dstDesc.MiscFlags = 0; - dstDesc.MipLevels = 1; - ID3D11Texture2D *stagingTex = nullptr; - if (FAILED(d3dDev->CreateTexture2D(&dstDesc, nullptr, &stagingTex))) { - QGC_D3D_WARN_ONCE(GstD3D11Log, s_diag.loggedTextureCreateFail, - "mapTextures: CreateTexture2D for slice copy failed (plane=" << planeIdx - << "subresource=" << subIdx << ")"); - return nullptr; - } - d3dCtx->CopySubresourceRegion(stagingTex, 0, 0, 0, 0, tex, subIdx, nullptr); - // Flush so QRhi sees the staged copy in the immediate context queue before binding it. - d3dCtx->Flush(); - return stagingTex; -} - -} // namespace - -GstD3D11VideoBuffer::GstD3D11VideoBuffer(GstSample *sample, - const GstVideoInfo &videoInfo, - const QVideoFrameFormat &format) - : GstHwVideoBuffer(QVideoFrame::RhiTextureHandle, sample, videoInfo, format) -{ -} - -GstD3D11VideoBuffer::~GstD3D11VideoBuffer() = default; - -QAbstractVideoBuffer::MapData GstD3D11VideoBuffer::map(QVideoFrame::MapMode /*mode*/) -{ - return {}; -} - -bool GstD3D11VideoBuffer::validatePlaneHandles() const -{ - if (!_sample) return false; - GstBuffer *buffer = gst_sample_get_buffer(_sample); - if (!buffer) return false; - const int memCount = qMin(int(gst_buffer_n_memory(buffer)), kMaxPlanes); - if (memCount <= 0) return false; - for (int i = 0; i < memCount; ++i) { - GstMemory *mem = gst_buffer_peek_memory(buffer, i); - if (!mem || !gst_is_d3d11_memory(mem)) return false; - // Cheap field read; confirms the wrapper actually backs an ID3D11Texture2D. - if (!gst_d3d11_memory_get_resource_handle(GST_D3D11_MEMORY_CAST(mem))) { - return false; - } - } - return true; -} - -QVideoFrameTexturesUPtr GstD3D11VideoBuffer::mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr & /*old*/) -{ - Q_ASSERT(rhi.thread()->isCurrentThread()); // Qt's contract: mapTextures runs on the QRhi (render) thread. - // *** UNTESTED on Windows hardware. CI compiles this path; runtime validation TBD. *** - // Shared-device wiring is provided by GstD3D11ContextBridge — when primed, gst-d3d11 - // decoders allocate textures on QRhi's ID3D11Device, so the handles below are - // directly QRhi-sampleable. Without the bridge, textures are on an isolated device - // and createFrom() will succeed but rendering will produce garbage / crashes. - if (!_sample) { - QGC_D3D_WARN_ONCE(GstD3D11Log, s_diag.loggedNullSample, "mapTextures: GstSample is null"); - return fail(s_diag); - } - if (rhi.backend() != QRhi::D3D11) { - QGC_D3D_WARN_ONCE(GstD3D11Log, s_diag.loggedBadBackend, - "mapTextures: QRhi backend is" << rhi.backendName() << "(D3D11 required)"); - return fail(s_diag); - } - - GstBuffer *buffer = gst_sample_get_buffer(_sample); - if (!buffer) { - QGC_D3D_WARN_ONCE(GstD3D11Log, s_diag.loggedNullBuffer, "mapTextures: GstSample has no buffer"); - return fail(s_diag); - } - - const int memCount = qMin(int(gst_buffer_n_memory(buffer)), kMaxPlanes); - std::array texs{}; - QVarLengthArray refdTexs; - for (int i = 0; i < memCount; ++i) { - GstMemory *mem = gst_buffer_peek_memory(buffer, i); - if (!mem || !gst_is_d3d11_memory(mem)) { - QGC_D3D_WARN_ONCE(GstD3D11Log, s_diag.loggedNonD3DMemory, - "mapTextures: plane" << i << "memory is not GstD3D11Memory (allocator=" - << (mem && mem->allocator ? mem->allocator->mem_type : "null") - << ")"); - for (auto *t : refdTexs) t->Release(); - return fail(s_diag); - } - // Per-buffer device guard: gst-d3d11 elements may run on an isolated device when our - // NEED_CONTEXT response was preempted by another bridge. Sampling a foreign-device - // ID3D11Texture2D from QRhi corrupts silently (texture handle is valid on the wrong - // device). Check once per first plane; same buffer ⇒ same device for all planes. - if (i == 0) { - // currentDevice() is transfer-full — unref both branches to avoid UAF after reset(). - GstD3D11Device *bridgeDev = GstD3D11ContextBridge::currentDevice(); - GstD3D11Device *bufDev = GST_D3D11_MEMORY_CAST(mem)->device; - if (bridgeDev && bufDev != bridgeDev) { - const gint64 bridgeLuid = GstD3DContextBridgeCommon::readAdapterLuid(bridgeDev); - const gint64 bufLuid = GstD3DContextBridgeCommon::readAdapterLuid(bufDev); - gst_object_unref(bridgeDev); - QGC_D3D_WARN_ONCE(GstD3D11Log, s_diag.loggedDeviceMismatch, - "mapTextures: GstD3D11Memory on foreign device (bridge LUID=" - << bridgeLuid << "buffer LUID=" << bufLuid - << "); bridge missed NEED_CONTEXT race — rejecting frame"); - return fail(s_diag); - } - if (bridgeDev) gst_object_unref(bridgeDev); - } - ID3D11Texture2D *tex = reinterpret_cast( - gst_d3d11_memory_get_resource_handle(GST_D3D11_MEMORY_CAST(mem))); - if (!tex) { - QGC_D3D_WARN_ONCE(GstD3D11Log, s_diag.loggedNullResource, - "mapTextures: gst_d3d11_memory_get_resource_handle returned null for plane" << i); - for (auto *t : refdTexs) t->Release(); - return fail(s_diag); - } - // QRhi::createFrom has no subresource parameter — copy the slice when needed. - const guint subIdx = gst_d3d11_memory_get_subresource_index(GST_D3D11_MEMORY_CAST(mem)); - D3D11_TEXTURE2D_DESC srcDesc{}; - tex->GetDesc(&srcDesc); - if (subIdx > 0 || srcDesc.ArraySize > 1) { - ID3D11Texture2D *stagingTex = copySliceToStaging(tex, subIdx, i, srcDesc, - GST_D3D11_MEMORY_CAST(mem)); - if (!stagingTex) { - for (auto *t : refdTexs) t->Release(); - return fail(s_diag); - } - refdTexs.append(stagingTex); - texs[i] = stagingTex; - } else { - tex->AddRef(); - refdTexs.append(tex); - texs[i] = tex; - } - } - - auto textures = std::make_unique(&rhi, _format.frameSize(), - _format.pixelFormat(), texs, memCount); - // Per-plane: NV12 chroma can fail while luma succeeds. Returning a partial bundle - // would render with missing planes and no failure-counter increment. - for (int i = 0; i < memCount; ++i) { - if (!textures->texture(static_cast(i))) { - QGC_D3D_WARN_ONCE(GstD3D11Log, s_diag.loggedTextureCreateFail, - "mapTextures: QRhiTexture::createFrom failed plane=" << i - << " (size=" << _format.frameSize() - << "format=" << int(_format.pixelFormat()) << "planes=" << memCount << ")"); - return fail(s_diag); - } - } - - if (!s_diag.loggedFirstSuccess.exchange(true, std::memory_order_relaxed)) { - qCInfo(GstD3D11Log) << "First D3D11 zero-copy mapTextures success: size=" << _format.frameSize() - << "format=" << int(_format.pixelFormat()) << "planes=" << memCount; - } - return textures; -} - -quint64 GstD3D11VideoBuffer::takeMapFailureCount() -{ - return GstD3DVideoBufferCommon::takeMapFailureCount(s_diag); -} - -quint64 GstD3D11VideoBuffer::peekMapFailureCount() -{ - return GstD3DVideoBufferCommon::peekMapFailureCount(s_diag); -} - -#endif // Q_OS_WIN && QGC_HAS_GST_D3D11_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D11VideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D11VideoBuffer.h deleted file mode 100644 index 8bcea2f22edb..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D11VideoBuffer.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include - -#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) - -#include "GstHwVideoBuffer.h" - -class QRhi; - -/// \brief Wraps a D3D11Memory-backed GstSample as a QHwVideoBuffer; samples natively on QRhi::D3D11. -/// -class GstD3D11VideoBuffer final : public GstHwVideoBuffer -{ -public: - GstD3D11VideoBuffer(GstSample *sample, - const GstVideoInfo &videoInfo, - const QVideoFrameFormat &format); - ~GstD3D11VideoBuffer() override; - - MapData map(QVideoFrame::MapMode mode) override; - QVideoFrameTexturesUPtr mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr &oldTextures) override; - bool validatePlaneHandles() const override; - - static quint64 takeMapFailureCount(); - static quint64 peekMapFailureCount(); -}; - -#endif // Q_OS_WIN && QGC_HAS_GST_D3D11_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D12ContextBridge.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D12ContextBridge.cc deleted file mode 100644 index 1170c7619881..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D12ContextBridge.cc +++ /dev/null @@ -1,122 +0,0 @@ -#include "GstD3D12ContextBridge.h" -#include "GstContextBridgeRegistry.h" -#include "GstD3DContextBridgeCommon.h" - -#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) - -#include "QGCLoggingCategory.h" - -#include - -#include -#include - -#include - -QGC_LOGGING_CATEGORY(GstD3D12BridgeLog, "Video.GStreamer.HwBuffers.GstD3D12Bridge") - -namespace GstD3D12ContextBridge { -namespace { - -GstD3DContextBridgeCommon::BridgeState s_state; -GstD3D12Device *s_device = nullptr; - -bool primeLocked() -{ - if (s_state.primed) return true; - - QRhi *rhi = GstD3DContextBridgeCommon::checkRhiBackend( - s_state, GstD3D12BridgeLog(), int(QRhi::D3D12), "D3D12"); - if (!rhi) return false; - - auto *handles = static_cast(rhi->nativeHandles()); - if (!handles || !handles->dev) { - qCWarning(GstD3D12BridgeLog) << "QRhiD3D12NativeHandles missing ID3D12Device*"; - return false; - } - - // Compose the adapter LUID from the two halves Qt exposes, then let gst-d3d12 - // create (or retrieve from cache) a GstD3D12Device on that adapter. - // HighPart is qint32 (signed, mirrors LARGE_INTEGER::HighPart=LONG); sign-extend - // before the shift so negative HighPart matches LARGE_INTEGER::QuadPart bit-for-bit. - const gint64 luid = (static_cast(handles->adapterLuidHigh) << 32) - | static_cast(handles->adapterLuidLow); - - s_device = gst_d3d12_device_new_for_adapter_luid(luid); - if (!s_device) { - qCWarning(GstD3D12BridgeLog) << "gst_d3d12_device_new_for_adapter_luid failed (luid=" << luid << ")"; - return false; - } - s_state.primed = true; - qCInfo(GstD3D12BridgeLog) << "D3D12 bridge primed: shared device =" << s_device - << "luid=" << luid; - GstD3DContextBridgeCommon::logAdapterMatch(rhi, luid, s_device, - GstD3D12BridgeLog(), "D3D12"); - return true; -} - -} // namespace - -bool prime() -{ - QMutexLocker lock(&s_state.mutex); - return primeLocked(); -} - -GstD3D12Device *currentDevice() -{ - QMutexLocker lock(&s_state.mutex); - if (!s_device) return nullptr; - return GST_D3D12_DEVICE_CAST(gst_object_ref(s_device)); -} - -GstBusSyncReply handleSyncMessage(GstMessage *message) -{ - GstElement *element = GstD3DContextBridgeCommon::matchNeedContext( - message, GST_D3D12_DEVICE_HANDLE_CONTEXT_TYPE); - if (!element) { - return GST_BUS_PASS; - } - - QMutexLocker lock(&s_state.mutex); - if (!primeLocked() || !s_device) { - return GST_BUS_PASS; - } - - // gst_d3d12_context_new internally gst_object_ref's s_device; caller retains ownership. - GstContext *ctx = gst_d3d12_context_new(s_device); - if (!ctx) { - qCWarning(GstD3D12BridgeLog) << "gst_d3d12_context_new failed for element" - << GST_ELEMENT_NAME(element); - return GST_BUS_PASS; - } - gst_element_set_context(element, ctx); - gst_context_unref(ctx); - gst_message_unref(message); - - GstD3DContextBridgeCommon::logHandoff(s_state, GstD3D12BridgeLog(), element, "D3D12"); - return GST_BUS_DROP; -} - -void reset() -{ - QMutexLocker lock(&s_state.mutex); - gst_clear_object(&s_device); - s_state.primed = false; - s_state.warnedWrongBackend = false; - qCDebug(GstD3D12BridgeLog) << "D3D12 bridge reset"; -} - -namespace { -struct D3D12BridgeRegistrar { - D3D12BridgeRegistrar() { - GstContextBridgeRegistry::registerBridgeHandler(&GstD3D12ContextBridge::handleSyncMessage); - GstContextBridgeRegistry::registerResetCallback(&GstD3D12ContextBridge::reset); - } -}; -static D3D12BridgeRegistrar s_d3d12BridgeRegistrar; -} // namespace - -} // namespace GstD3D12ContextBridge - -#endif // Q_OS_WIN && QGC_HAS_GST_D3D12_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D12ContextBridge.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D12ContextBridge.h deleted file mode 100644 index c911db9bb3ab..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D12ContextBridge.h +++ /dev/null @@ -1,49 +0,0 @@ -#pragma once - -#include - -#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) - -#include - -// Forward-declare the opaque GObject type so the currentDevice() accessor below parses -// for callers that haven't pulled in . Bridge implementation and -// any caller that dereferences the pointer must include the full header. -typedef struct _GstD3D12Device GstD3D12Device; - -/// Process-wide shared GstD3D12Device bridging Qt's D3D12 RHI adapter with -/// GStreamer's d3d12 elements (`d3d12h264dec`, `d3d12vp9dec`, `d3d12convert`). -/// -/// d3d12 elements ask the pipeline for `GST_D3D12_DEVICE_HANDLE_CONTEXT_TYPE` -/// ("gst.d3d12.device.handle") via GST_MESSAGE_NEED_CONTEXT. If we respond with -/// a GstD3D12Device created for the same adapter LUID as QRhi uses, the decoder -/// allocates resources on a compatible adapter — true zero-copy. -/// -/// Without this bridge, d3d12 elements create an internal device isolated from -/// QRhi; resources from one device are unusable on the other. -namespace GstD3D12ContextBridge { - -/// Idempotent. Returns true when a shared GstD3D12Device has been built for -/// the same adapter LUID as QRhi's D3D12 device (composed from the high/low -/// halves QRhi exposes via its native handles). Returns false (and logs once) -/// if QRhi isn't D3D12, isn't yet initialized, or the device-from-LUID call -/// fails — caller should retry on a later NEED_CONTEXT. -bool prime(); - -/// Inspect a NEED_CONTEXT message; if it's for `gst.d3d12.device.handle`, -/// respond with the shared GstD3D12Device and consume the message. Returns -/// GST_BUS_DROP when consumed, GST_BUS_PASS otherwise. Cheap when the message -/// isn't relevant. Thread-safe. -GstBusSyncReply handleSyncMessage(GstMessage *message); - -/// Drop the cached GstD3D12Device so the next prime() rebuilds against the -/// current QRhi device. Call from receiver teardown. -void reset(); - -/// Transfer-full ref to the cached shared device (caller unrefs), or nullptr if not primed. -/// Caller validates GstD3D12Memory device matches; transfer-full keeps it alive across reset(). -GstD3D12Device *currentDevice(); - -} // namespace GstD3D12ContextBridge - -#endif // Q_OS_WIN && QGC_HAS_GST_D3D12_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D12VideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D12VideoBuffer.cc deleted file mode 100644 index 9f45a45b45fe..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D12VideoBuffer.cc +++ /dev/null @@ -1,282 +0,0 @@ -#include "GstD3D12VideoBuffer.h" - -#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) - -#include "GstD3D12ContextBridge.h" -#include "GstD3DContextBridgeCommon.h" -#include "GstD3DVideoBufferCommon.h" -#include "QGCLoggingCategory.h" - -#include - -#include - -#include - -QGC_LOGGING_CATEGORY(GstD3D12Log, "Video.GStreamer.HwBuffers.GstD3D12Buf") - -namespace { - -using GstD3DVideoBufferCommon::kMaxPlanes; -using GstD3DVideoBufferCommon::MapDiagnostics; -using GstD3DVideoBufferCommon::fail; -using D3D12FrameTextures = GstD3DVideoBufferCommon::FrameTextures; - -MapDiagnostics s_diag; - -/// Copies one subresource slice into a fresh resource via the dedicated COPY queue -/// (DIRECT fallback) so the transfer doesn't stall Qt's DIRECT queue. Caller owns the ref. -ID3D12Resource *copySliceToStaging(ID3D12Resource *resource, guint subIdx, int planeIdx, - const D3D12_RESOURCE_DESC &srcDesc, - GstD3D12Memory *d3dmem) -{ - ID3D12Device *d3dDev = gst_d3d12_device_get_device_handle(d3dmem->device); - D3D12_COMMAND_LIST_TYPE queueType = D3D12_COMMAND_LIST_TYPE_COPY; - GstD3D12CmdQueue *cmdQueue = gst_d3d12_device_get_cmd_queue(d3dmem->device, queueType); - if (!cmdQueue) { - queueType = D3D12_COMMAND_LIST_TYPE_DIRECT; - cmdQueue = gst_d3d12_device_get_cmd_queue(d3dmem->device, queueType); - } - ID3D12CommandQueue *rawQueue = cmdQueue ? gst_d3d12_cmd_queue_get_handle(cmdQueue) : nullptr; - if (!d3dDev || !rawQueue) { - QGC_D3D_WARN_ONCE(GstD3D12Log, s_diag.loggedTextureCreateFail, - "mapTextures: could not obtain D3D12 device/queue for slice copy (plane=" - << planeIdx << ")"); - return nullptr; - } - - D3D12_RESOURCE_DESC dstDesc = srcDesc; - dstDesc.DepthOrArraySize = 1; - dstDesc.MipLevels = 1; - - D3D12_HEAP_PROPERTIES heapProps{}; - heapProps.Type = D3D12_HEAP_TYPE_DEFAULT; - - ID3D12Resource *stagingResource = nullptr; - if (FAILED(d3dDev->CreateCommittedResource( - &heapProps, D3D12_HEAP_FLAG_NONE, &dstDesc, - D3D12_RESOURCE_STATE_COMMON, nullptr, IID_PPV_ARGS(&stagingResource)))) { - QGC_D3D_WARN_ONCE(GstD3D12Log, s_diag.loggedTextureCreateFail, - "mapTextures: CreateCommittedResource for slice copy failed (plane=" << planeIdx - << " subresource=" << subIdx << ")"); - return nullptr; - } - - // COPY queue accepts COMMON↔COPY_SOURCE↔COPY_DEST barriers — transitions below stay legal. - ID3D12CommandAllocator *cmdAlloc = nullptr; - ID3D12GraphicsCommandList *cmdList = nullptr; - bool copyOk = false; - if (SUCCEEDED(d3dDev->CreateCommandAllocator(queueType, IID_PPV_ARGS(&cmdAlloc))) && - SUCCEEDED(d3dDev->CreateCommandList(0, queueType, - cmdAlloc, nullptr, IID_PPV_ARGS(&cmdList)))) { - D3D12_TEXTURE_COPY_LOCATION srcLoc{}; - srcLoc.pResource = resource; - srcLoc.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX; - srcLoc.SubresourceIndex = subIdx; - - D3D12_TEXTURE_COPY_LOCATION dstLoc{}; - dstLoc.pResource = stagingResource; - dstLoc.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX; - dstLoc.SubresourceIndex = 0; - - // Decoder leaves the resource in a non-COPY_SOURCE state (typically COMMON - // after gst_d3d12_memory_sync, or VIDEO_DECODE_WRITE pre-sync). Issue an - // explicit transition; without it the debug layer fires and some drivers TDR. - D3D12_RESOURCE_BARRIER toCopySrc{}; - toCopySrc.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; - toCopySrc.Transition.pResource = resource; - toCopySrc.Transition.Subresource = subIdx; - toCopySrc.Transition.StateBefore = D3D12_RESOURCE_STATE_COMMON; - toCopySrc.Transition.StateAfter = D3D12_RESOURCE_STATE_COPY_SOURCE; - cmdList->ResourceBarrier(1, &toCopySrc); - - cmdList->CopyTextureRegion(&dstLoc, 0, 0, 0, &srcLoc, nullptr); - - D3D12_RESOURCE_BARRIER toCommon = toCopySrc; - toCommon.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_SOURCE; - toCommon.Transition.StateAfter = D3D12_RESOURCE_STATE_COMMON; - cmdList->ResourceBarrier(1, &toCommon); - - cmdList->Close(); - - ID3D12CommandList *lists[] = { cmdList }; - rawQueue->ExecuteCommandLists(1, lists); - - ID3D12Fence *fence = nullptr; - if (SUCCEEDED(d3dDev->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&fence)))) { - rawQueue->Signal(fence, 1); - HANDLE event = CreateEvent(nullptr, FALSE, FALSE, nullptr); - if (event) { - fence->SetEventOnCompletion(1, event); - WaitForSingleObject(event, INFINITE); - CloseHandle(event); - } - fence->Release(); - } - copyOk = true; - } - if (cmdList) cmdList->Release(); - if (cmdAlloc) cmdAlloc->Release(); - - if (!copyOk) { - stagingResource->Release(); - return nullptr; - } - return stagingResource; -} - -} // namespace - -GstD3D12VideoBuffer::GstD3D12VideoBuffer(GstSample *sample, - const GstVideoInfo &videoInfo, - const QVideoFrameFormat &format) - : GstHwVideoBuffer(QVideoFrame::RhiTextureHandle, sample, videoInfo, format) -{ -} - -GstD3D12VideoBuffer::~GstD3D12VideoBuffer() = default; - -QAbstractVideoBuffer::MapData GstD3D12VideoBuffer::map(QVideoFrame::MapMode /*mode*/) -{ - return {}; -} - -bool GstD3D12VideoBuffer::validatePlaneHandles() const -{ - if (!_sample) return false; - GstBuffer *buffer = gst_sample_get_buffer(_sample); - if (!buffer) return false; - const int memCount = qMin(int(gst_buffer_n_memory(buffer)), kMaxPlanes); - if (memCount <= 0) return false; - for (int i = 0; i < memCount; ++i) { - GstMemory *mem = gst_buffer_peek_memory(buffer, i); - if (!mem || !gst_is_d3d12_memory(mem)) return false; - if (!gst_d3d12_memory_get_resource_handle(GST_D3D12_MEMORY_CAST(mem))) { - return false; - } - } - return true; -} - -QVideoFrameTexturesUPtr GstD3D12VideoBuffer::mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr & /*old*/) -{ - Q_ASSERT(rhi.thread()->isCurrentThread()); // Qt's contract: mapTextures runs on the QRhi (render) thread. - // Shared-device wiring is provided by GstD3D12ContextBridge — when primed, gst-d3d12 - // decoders allocate resources on QRhi's adapter, so the handles below are - // directly QRhi-importable. Without the bridge, resources are on an isolated device - // and createFrom() will succeed but rendering will produce garbage / crashes. - if (!_sample) { - QGC_D3D_WARN_ONCE(GstD3D12Log, s_diag.loggedNullSample, "mapTextures: GstSample is null"); - return fail(s_diag); - } - if (rhi.backend() != QRhi::D3D12) { - QGC_D3D_WARN_ONCE(GstD3D12Log, s_diag.loggedBadBackend, - "mapTextures: QRhi backend is" << rhi.backendName() << "(D3D12 required)"); - return fail(s_diag); - } - - GstBuffer *buffer = gst_sample_get_buffer(_sample); - if (!buffer) { - QGC_D3D_WARN_ONCE(GstD3D12Log, s_diag.loggedNullBuffer, "mapTextures: GstSample has no buffer"); - return fail(s_diag); - } - - const int memCount = qMin(int(gst_buffer_n_memory(buffer)), kMaxPlanes); - std::array resources{}; - QVarLengthArray refdResources; - for (int i = 0; i < memCount; ++i) { - GstMemory *mem = gst_buffer_peek_memory(buffer, i); - if (!mem || !gst_is_d3d12_memory(mem)) { - QGC_D3D_WARN_ONCE(GstD3D12Log, s_diag.loggedNonD3DMemory, - "mapTextures: plane" << i << "memory is not GstD3D12Memory (allocator=" - << (mem && mem->allocator ? mem->allocator->mem_type : "null") - << ")"); - for (auto *r : refdResources) r->Release(); - return fail(s_diag); - } - GstD3D12Memory *d3dmem = GST_D3D12_MEMORY_CAST(mem); - // Per-buffer device guard: gst-d3d12 may run on an isolated device when our - // NEED_CONTEXT response was preempted. Importing a foreign-device ID3D12Resource - // into QRhi either fails or silently corrupts. Check once on the first plane. - if (i == 0) { - // currentDevice() is transfer-full — unref both branches to avoid UAF after reset(). - GstD3D12Device *bridgeDev = GstD3D12ContextBridge::currentDevice(); - if (bridgeDev && d3dmem->device != bridgeDev) { - const gint64 bridgeLuid = GstD3DContextBridgeCommon::readAdapterLuid(bridgeDev); - const gint64 bufLuid = GstD3DContextBridgeCommon::readAdapterLuid(d3dmem->device); - gst_object_unref(bridgeDev); - QGC_D3D_WARN_ONCE(GstD3D12Log, s_diag.loggedDeviceMismatch, - "mapTextures: GstD3D12Memory on foreign device (bridge LUID=" - << bridgeLuid << "buffer LUID=" << bufLuid - << "); bridge missed NEED_CONTEXT race — rejecting frame"); - return fail(s_diag); - } - if (bridgeDev) gst_object_unref(bridgeDev); - } - // Block on the decoder's fence before reading; otherwise QRhi may sample mid-write - // (decoder still has a CL writing this resource). gst-d3d12 stores the fence on the - // memory when the producing element calls set_fence; sync() flushes it. - if (!gst_d3d12_memory_sync(d3dmem)) { - QGC_D3D_WARN_ONCE(GstD3D12Log, s_diag.loggedNullResource, - "mapTextures: gst_d3d12_memory_sync failed for plane" << i); - for (auto *r : refdResources) r->Release(); - return fail(s_diag); - } - ID3D12Resource *resource = gst_d3d12_memory_get_resource_handle(d3dmem); - if (!resource) { - QGC_D3D_WARN_ONCE(GstD3D12Log, s_diag.loggedNullResource, - "mapTextures: gst_d3d12_memory_get_resource_handle returned null for plane" << i); - for (auto *r : refdResources) r->Release(); - return fail(s_diag); - } - // QRhi::createFrom has no subresource parameter — copy the slice when needed. - guint subIdx = 0; - gst_d3d12_memory_get_subresource_index(d3dmem, guint(i), &subIdx); - D3D12_RESOURCE_DESC srcDesc = resource->GetDesc(); - if (subIdx > 0 || srcDesc.DepthOrArraySize > 1) { - ID3D12Resource *stagingResource = copySliceToStaging(resource, subIdx, i, srcDesc, d3dmem); - if (!stagingResource) { - for (auto *r : refdResources) r->Release(); - return fail(s_diag); - } - refdResources.append(stagingResource); - resources[i] = stagingResource; - } else { - resource->AddRef(); - refdResources.append(resource); - resources[i] = resource; - } - } - - auto textures = std::make_unique(&rhi, _format.frameSize(), - _format.pixelFormat(), resources, memCount); - // Per-plane: NV12 chroma can fail while luma succeeds. Returning a partial bundle - // would render with missing planes and no failure-counter increment. - for (int i = 0; i < memCount; ++i) { - if (!textures->texture(static_cast(i))) { - QGC_D3D_WARN_ONCE(GstD3D12Log, s_diag.loggedTextureCreateFail, - "mapTextures: QRhiTexture::createFrom failed plane=" << i - << " (size=" << _format.frameSize() - << " format=" << int(_format.pixelFormat()) << " planes=" << memCount << ")"); - return fail(s_diag); - } - } - - if (!s_diag.loggedFirstSuccess.exchange(true, std::memory_order_relaxed)) { - qCInfo(GstD3D12Log) << "First D3D12 zero-copy mapTextures success: size=" << _format.frameSize() - << "format=" << int(_format.pixelFormat()) << "planes=" << memCount; - } - return textures; -} - -quint64 GstD3D12VideoBuffer::takeMapFailureCount() -{ - return GstD3DVideoBufferCommon::takeMapFailureCount(s_diag); -} - -quint64 GstD3D12VideoBuffer::peekMapFailureCount() -{ - return GstD3DVideoBufferCommon::peekMapFailureCount(s_diag); -} - -#endif // Q_OS_WIN && QGC_HAS_GST_D3D12_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D12VideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D12VideoBuffer.h deleted file mode 100644 index de00c3e9901f..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3D12VideoBuffer.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include - -#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) - -#include "GstHwVideoBuffer.h" - -class QRhi; - -/// \brief Wraps a D3D12Memory-backed GstSample as a QHwVideoBuffer; samples natively on QRhi::D3D12. -/// -class GstD3D12VideoBuffer final : public GstHwVideoBuffer -{ -public: - GstD3D12VideoBuffer(GstSample *sample, - const GstVideoInfo &videoInfo, - const QVideoFrameFormat &format); - ~GstD3D12VideoBuffer() override; - - MapData map(QVideoFrame::MapMode mode) override; - QVideoFrameTexturesUPtr mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr &oldTextures) override; - bool validatePlaneHandles() const override; - - static quint64 takeMapFailureCount(); - static quint64 peekMapFailureCount(); -}; - -#endif // Q_OS_WIN && QGC_HAS_GST_D3D12_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3DContextBridgeCommon.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3DContextBridgeCommon.cc deleted file mode 100644 index 1b5113780229..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3DContextBridgeCommon.cc +++ /dev/null @@ -1,95 +0,0 @@ -#include "GstD3DContextBridgeCommon.h" - -#if defined(Q_OS_WIN) && (defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH)) - -#include "QGCRhiCapture.h" - -#include -#include - -namespace GstD3DContextBridgeCommon { - -// Caller must hold state.mutex — warnedWrongBackend is a plain bool, not atomic. -// Also called from the bus-sync thread; backend()/backendName() are read-only enum/string -// accessors on QRhi and don't touch GPU state, but QRhi is documented single-thread so this -// is "safe by inspection" rather than by API contract — keep the calls limited to these. -QRhi *checkRhiBackend(BridgeState &state, - const QLoggingCategory &cat, - int expectedBackend, - const char *backendName) -{ - QRhi *rhi = QGCRhiCapture::cachedRhi(); - if (!rhi) { - qCDebug(cat) << "QRhi not yet available; will retry on next NEED_CONTEXT"; - return nullptr; - } - if (static_cast(rhi->backend()) != expectedBackend) { - if (!state.warnedWrongBackend) { - qCInfo(cat) << "QRhi backend is" << rhi->backendName() - << "(not" << backendName << "); bridge inactive"; - state.warnedWrongBackend = true; - } - return nullptr; - } - return rhi; -} - -GstElement *matchNeedContext(GstMessage *message, const char *expectedContextType) -{ - if (GST_MESSAGE_TYPE(message) != GST_MESSAGE_NEED_CONTEXT) { - return nullptr; - } - const gchar *contextType = nullptr; - if (!gst_message_parse_context_type(message, &contextType) || !contextType) { - return nullptr; - } - if (g_strcmp0(contextType, expectedContextType) != 0) { - return nullptr; - } - return GST_ELEMENT(GST_MESSAGE_SRC(message)); -} - -void logHandoff(BridgeState &state, - const QLoggingCategory &cat, - GstElement *element, - const char *apiName) -{ - if (!state.loggedFirstHandoff.exchange(true, std::memory_order_relaxed)) { - qCInfo(cat) << "First" << apiName << "device handoff to element" - << GST_ELEMENT_NAME(element); - } else { - qCDebug(cat) << "Provided" << apiName << "device context to" << GST_ELEMENT_NAME(element); - } -} - -gint64 readAdapterLuid(gpointer device) -{ - if (!device || !G_IS_OBJECT(device)) return 0; - gint64 luid = 0; - g_object_get(G_OBJECT(device), "adapter-luid", &luid, nullptr); - return luid; -} - -void logAdapterMatch(QRhi *rhi, gint64 expectedLuid, gpointer gstDevice, - const QLoggingCategory &cat, const char *apiName) -{ - const gint64 actualLuid = readAdapterLuid(gstDevice); - if (actualLuid != expectedLuid) { - qCWarning(cat).noquote() - << apiName << "bridge: gst device LUID mismatch — QRhi LUID=" - << expectedLuid << "but wrapped device LUID=" << actualLuid - << "(zero-copy will appear corrupt; check NEED_CONTEXT race)"; - return; - } - if (!rhi) return; - const QRhiDriverInfo info = rhi->driverInfo(); - qCInfo(cat).noquote() - << apiName << "bridge adapter:" << info.deviceName - << QString::asprintf("(vendorId=0x%04X deviceId=0x%04X type=%d luid=%lld)", - unsigned(info.vendorId), unsigned(info.deviceId), - int(info.deviceType), static_cast(expectedLuid)); -} - -} // namespace GstD3DContextBridgeCommon - -#endif // Q_OS_WIN && (QGC_HAS_GST_D3D11_GPU_PATH || QGC_HAS_GST_D3D12_GPU_PATH) diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3DContextBridgeCommon.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3DContextBridgeCommon.h deleted file mode 100644 index 3ed3c5af1038..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3DContextBridgeCommon.h +++ /dev/null @@ -1,68 +0,0 @@ -#pragma once - -#include - -#if defined(Q_OS_WIN) && (defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH)) - -#include -#include - -#include - -#include - -class QRhi; - -/// Common bookkeeping shared by GstD3D11ContextBridge and GstD3D12ContextBridge. -/// -/// The two bridges differ in how they construct the shared GstD3DXDevice -/// (`gst_d3d11_device_wrap` vs `gst_d3d12_device_new_for_adapter_luid`) and in -/// how they hand a context back to the requesting element -/// (`gst_d3d11_handle_set_context` vs `gst_d3d12_context_new` + `set_context`). -/// Everything else — the prime/reset state machine, the QRhi-up-and-correct- -/// backend gate, the NEED_CONTEXT message dispatch, and the first-handoff log -/// — is shared via this header. -namespace GstD3DContextBridgeCommon { - -/// Per-bridge state. Both bridges hold one static instance. -struct BridgeState { - QMutex mutex; - bool primed = false; - bool warnedWrongBackend = false; - std::atomic loggedFirstHandoff{false}; -}; - -/// Returns the live QRhi if (a) it exists and (b) it matches @p expectedBackend. -/// @p backendName is purely for logging ("D3D11" / "D3D12"). Returns nullptr if -/// QRhi isn't ready yet (caller should retry on next NEED_CONTEXT) or if the -/// backend is wrong (caller logs once via @p state.warnedWrongBackend). -QRhi *checkRhiBackend(BridgeState &state, - const QLoggingCategory &cat, - int expectedBackend, - const char *backendName); - -/// Inspects @p message; if it's a NEED_CONTEXT for @p expectedContextType, -/// returns the source element. Otherwise returns nullptr (caller passes the -/// message through). Cheap when the message isn't relevant. -GstElement *matchNeedContext(GstMessage *message, const char *expectedContextType); - -/// Logs the first successful element handoff at qCInfo level; subsequent -/// handoffs go to qCDebug. -void logHandoff(BridgeState &state, - const QLoggingCategory &cat, - GstElement *element, - const char *apiName); - -/// Reads the `adapter-luid` GObject property from a GstD3D11Device or GstD3D12Device. -/// Returns 0 on null input or if the property read fails (both APIs expose this property -/// since their initial gst-plugins-bad release). -gint64 readAdapterLuid(gpointer device); - -/// One-shot LUID compare at prime time. Mismatch = gst wrapped a different physical adapter -/// than QRhi; zero-copy will corrupt at sample time. -void logAdapterMatch(QRhi *rhi, gint64 expectedLuid, gpointer gstDevice, - const QLoggingCategory &cat, const char *apiName); - -} // namespace GstD3DContextBridgeCommon - -#endif // Q_OS_WIN && (QGC_HAS_GST_D3D11_GPU_PATH || QGC_HAS_GST_D3D12_GPU_PATH) diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3DVideoBufferCommon.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3DVideoBufferCommon.h deleted file mode 100644 index 375e72867132..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstD3DVideoBufferCommon.h +++ /dev/null @@ -1,120 +0,0 @@ -#pragma once - -#include - -#if defined(Q_OS_WIN) && (defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH)) - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -/// Shared scaffolding for the D3D11 / D3D12 zero-copy QHwVideoBuffer wrappers. -/// -/// Both wrappers walk the same shape: pull native textures out of a GstSample, -/// hand them to QRhi via `QRhiTexture::createFrom`, release the refs in the -/// QVideoFrameTextures dtor. They differ only in the native handle type -/// (`ID3D11Texture2D*` vs `ID3D12Resource*`) and in the slice-copy machinery -/// the loop in `mapTextures()` uses — those slice-copy helpers stay in the -/// per-API .cc files because the D3D11 immediate-context model and the D3D12 -/// command-list+fence model are not unifiable. -namespace GstD3DVideoBufferCommon { - -constexpr int kMaxPlanes = 4; - -/// Per-translation-unit failure counters and one-shot warning flags. Each .cc -/// keeps its own static instance so the diagnostics stay separated per API. -struct MapDiagnostics { - std::atomic mapFailureCount{0}; - std::atomic loggedFirstSuccess{false}; - // One-shot log flags per failure reason — keeps CI logs informative without - // spamming at framerate. Each path warns the first time it trips, then bumps - // the counter silently; teardown emits the running total. - std::atomic loggedNullSample{false}; - std::atomic loggedBadBackend{false}; - std::atomic loggedNullBuffer{false}; - std::atomic loggedNonD3DMemory{false}; - std::atomic loggedNullResource{false}; - std::atomic loggedTextureCreateFail{false}; - // Tripped when GstD3DXMemory carries a device that doesn't match the bridge's shared - // device — sampling from another device's textures corrupts or crashes silently, so the - // wrapper rejects the frame instead. Indicates a NEED_CONTEXT race the bridge lost. - std::atomic loggedDeviceMismatch{false}; -}; - -inline QVideoFrameTexturesUPtr fail(MapDiagnostics &d) -{ - d.mapFailureCount.fetch_add(1, std::memory_order_relaxed); - return {}; -} - -inline quint64 takeMapFailureCount(MapDiagnostics &d) -{ - return d.mapFailureCount.exchange(0, std::memory_order_relaxed); -} - -inline quint64 peekMapFailureCount(MapDiagnostics &d) -{ - return d.mapFailureCount.load(std::memory_order_relaxed); -} - -/// Wraps an array of D3D native handles (ID3D11Texture2D* or ID3D12Resource*) -/// as QRhi-importable textures. Releases the handles in the destructor — the -/// caller must `AddRef()` (or own a fresh ref) before constructing. -template -class FrameTextures final : public QVideoFrameTextures -{ -public: - FrameTextures(QRhi *rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, - std::array handles, int count) - : _count(count) - , _handles(handles) - { - const auto *desc = QVideoTextureHelper::textureDescription(pixelFormat); - if (!desc) return; - for (int i = 0; i < _count; ++i) { - const QSize planeSize = desc->rhiPlaneSize(size, i, rhi); - _textures[i].reset(rhi->newTexture(desc->rhiTextureFormat(i, rhi), planeSize, 1, {})); - if (_textures[i] && !_textures[i]->createFrom({reinterpret_cast(handles[i]), 0})) { - _textures[i].reset(); - } - } - } - - ~FrameTextures() override - { - for (int i = 0; i < _count; ++i) { - if (_handles[i]) _handles[i]->Release(); - } - } - - QRhiTexture *texture(uint plane) const override - { - return (int(plane) < _count) ? _textures[plane].get() : nullptr; - } - -private: - int _count = 0; - std::array _handles; - std::unique_ptr _textures[kMaxPlanes]; -}; - -} // namespace GstD3DVideoBufferCommon - -/// Logs an arbitrary qCWarning the first time the flag flips; subsequent -/// trips bump the per-diagnostic counter silently. -#define QGC_D3D_WARN_ONCE(LOGCAT, FLAG, ...) \ - do { \ - if (!(FLAG).exchange(true, std::memory_order_relaxed)) { \ - qCWarning(LOGCAT) << __VA_ARGS__; \ - } \ - } while (0) - -#endif // Q_OS_WIN && (QGC_HAS_GST_D3D11_GPU_PATH || QGC_HAS_GST_D3D12_GPU_PATH) diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstDmaBufVideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstDmaBufVideoBuffer.cc deleted file mode 100644 index efc988cc47e3..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstDmaBufVideoBuffer.cc +++ /dev/null @@ -1,532 +0,0 @@ -#include "GstDmaBufVideoBuffer.h" -#include "GstHwVideoBuffer.h" - -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - -#include "QGCLoggingCategory.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#if defined(QGC_GST_BUILD_VERSION_MAJOR) && \ - (QGC_GST_BUILD_VERSION_MAJOR > 1 || \ - (QGC_GST_BUILD_VERSION_MAJOR == 1 && QGC_GST_BUILD_VERSION_MINOR >= 24)) -#include -#endif - -#include -#include -#include - -#include -#include -#include - -QGC_LOGGING_CATEGORY(GstDmaBufLog, "Video.GStreamer.HwBuffers.GstDmaBuf") - -namespace { - -constexpr int kMaxPlanes = 4; - -// Process-wide failure tally; read+reset by GstDmaBufVideoBuffer::takeMapFailureCount. -std::atomic s_mapFailureCount{0}; -std::atomic s_loggedBadBackend{false}; - -QMutex s_modExtMutex; -QHash s_modExtCache; - -bool queryHasModifiersExt(EGLDisplay display) -{ - QMutexLocker lock(&s_modExtMutex); - auto it = s_modExtCache.find(display); - if (it != s_modExtCache.end()) { - return it.value(); - } - // eglQueryString(display, EGL_EXTENSIONS) returns NULL on un-initialized displays. - // eglInitialize is idempotent, so this is a safe defensive call when we got the display - // from eglGetDisplay(EGL_DEFAULT_DISPLAY) and Qt happens to have init'd a *different* one. - EGLint major = 0, minor = 0; - eglInitialize(display, &major, &minor); - const char *exts = eglQueryString(display, EGL_EXTENSIONS); - const bool supported = exts != nullptr - && std::strstr(exts, "EGL_EXT_image_dma_buf_import_modifiers") != nullptr; - qCDebug(GstDmaBufLog) << "EGL display" << display - << "modifiers ext supported:" << supported - << " (driver:" << eglQueryString(display, EGL_VENDOR) << ")"; - s_modExtCache.insert(display, supported); - return supported; -} - -// DRM fourcc for a given GstVideoInfo plane. Modeled on Qt's -// fourccFromVideoInfo() in qgstvideobuffer.cpp (LGPL-3). -int drmFourccFor(const GstVideoInfo *info, int plane) -{ - const GstVideoFormat fmt = GST_VIDEO_INFO_FORMAT(info); -#if G_BYTE_ORDER == G_LITTLE_ENDIAN - constexpr int rgbaFourcc = DRM_FORMAT_ABGR8888; - constexpr int rgFourcc = DRM_FORMAT_GR88; -#else - constexpr int rgbaFourcc = DRM_FORMAT_RGBA8888; - constexpr int rgFourcc = DRM_FORMAT_RG88; -#endif - - switch (fmt) { - case GST_VIDEO_FORMAT_RGBA: - case GST_VIDEO_FORMAT_RGBx: - case GST_VIDEO_FORMAT_BGRA: - case GST_VIDEO_FORMAT_BGRx: - case GST_VIDEO_FORMAT_ARGB: - case GST_VIDEO_FORMAT_xRGB: - case GST_VIDEO_FORMAT_ABGR: - case GST_VIDEO_FORMAT_xBGR: - case GST_VIDEO_FORMAT_AYUV: - return rgbaFourcc; - case GST_VIDEO_FORMAT_GRAY8: - return DRM_FORMAT_R8; - case GST_VIDEO_FORMAT_YUY2: - case GST_VIDEO_FORMAT_UYVY: - case GST_VIDEO_FORMAT_GRAY16_LE: - return rgFourcc; - case GST_VIDEO_FORMAT_NV12: - case GST_VIDEO_FORMAT_NV21: - return plane == 0 ? DRM_FORMAT_R8 : rgFourcc; - case GST_VIDEO_FORMAT_I420: - case GST_VIDEO_FORMAT_YV12: - case GST_VIDEO_FORMAT_Y42B: - case GST_VIDEO_FORMAT_Y444: - return DRM_FORMAT_R8; - case GST_VIDEO_FORMAT_P010_10LE: - return plane == 0 ? DRM_FORMAT_R16 : DRM_FORMAT_GR1616; - default: - return -1; - } -} - -// DRM fourcc for formats that can be imported as a single EGLImage (all planes, one fd). -int drmFourccForSingleFd(const GstVideoInfo *info) -{ - switch (GST_VIDEO_INFO_FORMAT(info)) { - case GST_VIDEO_FORMAT_NV12: return DRM_FORMAT_NV12; - case GST_VIDEO_FORMAT_NV21: return DRM_FORMAT_NV21; - case GST_VIDEO_FORMAT_P010_10LE: return DRM_FORMAT_P010; - case GST_VIDEO_FORMAT_I420: return DRM_FORMAT_YUV420; - case GST_VIDEO_FORMAT_YV12: return DRM_FORMAT_YVU420; - // packed single-memory formats: planeCount==1, memCount==1 - case GST_VIDEO_FORMAT_YUY2: return DRM_FORMAT_YUYV; - case GST_VIDEO_FORMAT_UYVY: return DRM_FORMAT_UYVY; -#ifdef DRM_FORMAT_YVYU - case GST_VIDEO_FORMAT_YVYU: return DRM_FORMAT_YVYU; -#endif -#ifdef DRM_FORMAT_VYUY - case GST_VIDEO_FORMAT_VYUY: return DRM_FORMAT_VYUY; -#endif - // AYUV excluded: EGL importers don't support DRM_FORMAT_AYUV; per-plane RGBA path handles it. -#ifdef DRM_FORMAT_Y210 - case GST_VIDEO_FORMAT_Y210: return DRM_FORMAT_Y210; -#endif -#ifdef DRM_FORMAT_Y410 - case GST_VIDEO_FORMAT_Y410: return DRM_FORMAT_Y410; -#endif - default: return -1; - } -} - -// Per-plane EGL attrib keys — spec defines distinct enums per plane index. -static constexpr EGLint kPlFd[4] = { - EGL_DMA_BUF_PLANE0_FD_EXT, EGL_DMA_BUF_PLANE1_FD_EXT, - EGL_DMA_BUF_PLANE2_FD_EXT, EGL_DMA_BUF_PLANE3_FD_EXT, -}; -static constexpr EGLint kPlOffset[4] = { - EGL_DMA_BUF_PLANE0_OFFSET_EXT, EGL_DMA_BUF_PLANE1_OFFSET_EXT, - EGL_DMA_BUF_PLANE2_OFFSET_EXT, EGL_DMA_BUF_PLANE3_OFFSET_EXT, -}; -static constexpr EGLint kPlPitch[4] = { - EGL_DMA_BUF_PLANE0_PITCH_EXT, EGL_DMA_BUF_PLANE1_PITCH_EXT, - EGL_DMA_BUF_PLANE2_PITCH_EXT, EGL_DMA_BUF_PLANE3_PITCH_EXT, -}; -static constexpr EGLint kPlModLo[4] = { - EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE1_MODIFIER_LO_EXT, - EGL_DMA_BUF_PLANE2_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE3_MODIFIER_LO_EXT, -}; -static constexpr EGLint kPlModHi[4] = { - EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE1_MODIFIER_HI_EXT, - EGL_DMA_BUF_PLANE2_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE3_MODIFIER_HI_EXT, -}; - -class FrameTextures final : public QVideoFrameTextures -{ -public: - FrameTextures(QRhi *rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, - std::array names, int count) - : _rhi(rhi) - , _names(names) - , _count(count) - { - const auto *desc = QVideoTextureHelper::textureDescription(pixelFormat); - if (!desc) { - qCWarning(GstDmaBufLog) << "no QVideoTextureHelper description for format" << pixelFormat; - return; - } - for (int i = 0; i < _count; ++i) { - const QSize planeSize = desc->rhiPlaneSize(size, i, rhi); - _textures[i].reset(rhi->newTexture( - desc->rhiTextureFormat(i, rhi, QVideoTextureHelper::TextureDescription::FallbackPolicy::Disable), - planeSize, 1, {})); - if (_textures[i] && !_textures[i]->createFrom({_names[i], 0})) { - qCWarning(GstDmaBufLog) << "QRhiTexture::createFrom failed for plane" << i; - _textures[i].reset(); - } - } - } - - ~FrameTextures() override - { - releaseGLTextures(); // safety net if onFrameEndInvoked() was never called - } - - void onFrameEndInvoked() override - { - releaseGLTextures(); - } - - QRhiTexture *texture(uint plane) const override - { - return (int(plane) < _count) ? _textures[plane].get() : nullptr; - } - -private: - void releaseGLTextures() - { - if (_released || !_rhi || _count == 0) return; - _released = true; - // makeThreadLocalNativeContextCurrent avoids crash when Qt drops frame off the render thread. - _rhi->makeThreadLocalNativeContextCurrent(); - if (auto *ctx = QOpenGLContext::currentContext()) { - ctx->functions()->glDeleteTextures(_count, _names.data()); - } - } - - QRhi *_rhi = nullptr; - std::array _names{}; - int _count = 0; - bool _released = false; - std::unique_ptr _textures[kMaxPlanes]; -}; - -} // namespace - -GstDmaBufVideoBuffer::GstDmaBufVideoBuffer(GstSample *sample, - const GstVideoInfo &videoInfo, - const QVideoFrameFormat &format, - EGLDisplay eglDisplay) - : GstHwVideoBuffer(QVideoFrame::RhiTextureHandle, sample, videoInfo, format) - , _eglDisplay(eglDisplay) -{ -} - -GstDmaBufVideoBuffer::~GstDmaBufVideoBuffer() = default; - -QAbstractVideoBuffer::MapData GstDmaBufVideoBuffer::map(QVideoFrame::MapMode /*mode*/) -{ - // GPU-only buffer; CPU map intentionally unsupported. Qt will draw black if it - // ever calls map() — that means mapTextures() failed, which is logged below. - return {}; -} - -bool GstDmaBufVideoBuffer::validatePlaneHandles() const -{ - if (!_sample) return false; - GstBuffer *buffer = gst_sample_get_buffer(_sample); - if (!buffer) return false; - const int memCount = qMin(int(gst_buffer_n_memory(buffer)), kMaxPlanes); - if (memCount <= 0) return false; - for (int i = 0; i < memCount; ++i) { - GstMemory *mem = gst_buffer_peek_memory(buffer, i); - if (!mem || !gst_is_dmabuf_memory(mem)) return false; - if (gst_dmabuf_memory_get_fd(mem) < 0) return false; - } - // Early non-LINEAR rejection — without this, Qt warns "Cannot map ReadOnly" and drops the frame. -#if defined(QGC_GST_BUILD_VERSION_MAJOR) && \ - (QGC_GST_BUILD_VERSION_MAJOR > 1 || \ - (QGC_GST_BUILD_VERSION_MAJOR == 1 && QGC_GST_BUILD_VERSION_MINOR >= 24)) - if (GstCaps *caps = gst_sample_get_caps(_sample); caps && gst_video_is_dma_drm_caps(caps)) { - GstVideoInfoDmaDrm drmInfo{}; - gst_video_info_dma_drm_init(&drmInfo); - if (gst_video_info_dma_drm_from_caps(&drmInfo, caps) && drmInfo.drm_modifier != 0) { - return false; - } - } -#endif - return true; -} - -QVideoFrameTexturesUPtr GstDmaBufVideoBuffer::mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr & /*old*/) -{ - Q_ASSERT(rhi.thread()->isCurrentThread()); // Qt's contract: mapTextures runs on the QRhi (render) thread. - - auto fail = []() -> QVideoFrameTexturesUPtr { - s_mapFailureCount.fetch_add(1, std::memory_order_relaxed); - return {}; - }; - - if (!_sample) { - return fail(); - } - - // Parse modifier before the sync mmap — mmap assumes LINEAR; tiled strides fault libgstvideo. - guint64 drmModifier = 0; -#if defined(QGC_GST_BUILD_VERSION_MAJOR) && \ - (QGC_GST_BUILD_VERSION_MAJOR > 1 || \ - (QGC_GST_BUILD_VERSION_MAJOR == 1 && QGC_GST_BUILD_VERSION_MINOR >= 24)) - if (GstCaps *caps = gst_sample_get_caps(_sample); caps && gst_video_is_dma_drm_caps(caps)) { - GstVideoInfoDmaDrm drmInfo{}; - gst_video_info_dma_drm_init(&drmInfo); - if (gst_video_info_dma_drm_from_caps(&drmInfo, caps)) { - drmModifier = drmInfo.drm_modifier; - } - } -#endif - - // dmabuf mmap as a sync barrier — Intel iHD legacy LINEAR exporter has no implicit fence - // and vaSyncSurface() is a no-op there. Tiled paths carry fences and aren't safe to mmap. - if (drmModifier == 0 /* DRM_FORMAT_MOD_LINEAR / unknown */) { - if (GstBuffer *syncBuf = gst_sample_get_buffer(_sample)) { - GstVideoFrame syncFrame; - if (gst_video_frame_map(&syncFrame, &_videoInfo, syncBuf, GST_MAP_READ)) { - gst_video_frame_unmap(&syncFrame); - } - } - } - - // Bind Qt's GL context on the render thread BEFORE any EGL/GL call. Without this, - // glBindTexture / glEGLImageTargetTexture2DOES silently no-op into a foreign or - // null context — symptom: import succeeds, texture samples empty (green). - rhi.makeThreadLocalNativeContextCurrent(); - - // Use Qt's actual EGLDisplay, not the adapter's eglGetDisplay(EGL_DEFAULT_DISPLAY) — - // those can resolve to different EGLDisplay objects under xcb_egl, and EGLImages - // imported in display A cannot be sampled by a context bound to display B. - EGLDisplay eglDpy = eglGetCurrentDisplay(); - if (eglDpy == EGL_NO_DISPLAY) { - eglDpy = _eglDisplay; - } - - if (eglDpy == EGL_NO_DISPLAY) { - return fail(); - } - if (rhi.backend() != QRhi::OpenGLES2) { - if (!s_loggedBadBackend.exchange(true, std::memory_order_relaxed)) { - qCWarning(GstDmaBufLog) << "QRhi backend is not OpenGLES2; zero-copy DMABuf path unsupported"; - } - return fail(); - } - - GstBuffer *buffer = gst_sample_get_buffer(_sample); - if (!buffer || !gst_is_dmabuf_memory(gst_buffer_peek_memory(buffer, 0))) { - return fail(); - } - const bool hasModifiersExt = queryHasModifiersExt(eglDpy); - if (drmModifier != 0) { - // Tiled+CCS (e.g. I915_FORMAT_MOD_Y_TILED_CCS) needs multi-plane EXTERNAL_OES import; - // per-plane GL_TEXTURE_2D crashes the driver in glEGLImageTargetTexture2DOES. - static std::atomic warned{false}; - if (!warned.exchange(true, std::memory_order_relaxed)) { - qCWarning(GstDmaBufLog) << "DMABuf modifier" << Qt::hex << drmModifier - << "is non-LINEAR; per-plane EGLImage import is unsafe" - " on tiled/CCS layouts — falling back to CPU" - " (modifiersExt=" << hasModifiersExt << ")"; - } - return fail(); - } - - const auto *nativeHandles = static_cast(rhi.nativeHandles()); - if (!nativeHandles || !nativeHandles->context) { - qCWarning(GstDmaBufLog) << "QRhi exposes no GL context"; - return fail(); - } - - static const auto eglImageTargetTexture2D = - reinterpret_cast( - eglGetProcAddress("glEGLImageTargetTexture2DOES")); - if (!eglImageTargetTexture2D) { - qCWarning(GstDmaBufLog) << "glEGLImageTargetTexture2DOES unavailable"; - return fail(); - } - - // gst_video_frame_map(GST_MAP_READ) on DMABuf triggers a CPU mmap defeating zero-copy; read offsets from GstVideoMeta directly. - GstVideoMeta *vmeta = gst_buffer_get_video_meta(buffer); - const int planeCount = qBound(1, int(GST_VIDEO_INFO_N_PLANES(&_videoInfo)), kMaxPlanes); - const int memCount = int(gst_buffer_n_memory(buffer)); - // memCount==1: either multi-plane shared fd (NV12) or packed single-plane (YUY2/UYVY/…). - const int cachedSingleFourcc = (memCount == 1) ? drmFourccForSingleFd(&_videoInfo) : -1; - const bool singleFdPacking = (cachedSingleFourcc != -1); - if (memCount != planeCount && !singleFdPacking) { - return fail(); - } - - // No texture reuse: a previous attempt to cache QRhiTextures across frames - // (keyed first on fd values, then on GstMemory pointers) caused periodic - // green frames in real RTSP H.264 streams. The optimization is worth ~µs per - // frame and not worth the lifetime hazards. Always rebuild. - std::array names{}; - QOpenGLFunctions functions(nativeHandles->context); - functions.glGenTextures(planeCount, names.data()); - - // Pass modifier attribs only when extension is available; otherwise the implementation - // assumes LINEAR, which matches our earlier rejection of non-LINEAR without the ext. - const EGLAttrib modLo = static_cast(drmModifier & 0xFFFFFFFFu); - const EGLAttrib modHi = static_cast((drmModifier >> 32) & 0xFFFFFFFFu); - - bool ok = true; - - // Single-fd fast path: one EGLImage covering all planes, one eglCreateImage call. - // Mesa VA-API NV12 default ships memCount==1; iHD rejects split-plane R8/GR88 imports. - if (singleFdPacking) { - if (planeCount > kMaxPlanes) { - qCWarning(GstDmaBufLog) << "single-fd format has" << planeCount - << "planes (>" << kMaxPlanes << "); falling back to per-plane"; - goto per_plane_path; - } - const int singleFourcc = cachedSingleFourcc; - if (singleFourcc == -1) { - goto per_plane_path; - } - const int fd0 = gst_dmabuf_memory_get_fd(gst_buffer_peek_memory(buffer, 0)); - // maxAttr: WIDTH/HEIGHT/FOURCC + 3 attribs/plane * kMaxPlanes + 2 modifier attribs/plane * kMaxPlanes + EGL_NONE - EGLAttrib attribs[3 + kMaxPlanes * 5 + 1]; - int n = 0; -#define ATTRIB_PUSH(k, v) do { Q_ASSERT(n < int(std::size(attribs)) - 1); attribs[n++] = (k); attribs[n++] = (v); } while (0) - ATTRIB_PUSH(EGL_WIDTH, GST_VIDEO_INFO_WIDTH(&_videoInfo)); - ATTRIB_PUSH(EGL_HEIGHT, GST_VIDEO_INFO_HEIGHT(&_videoInfo)); - ATTRIB_PUSH(EGL_LINUX_DRM_FOURCC_EXT, singleFourcc); - for (int p = 0; p < planeCount; ++p) { - const auto off = vmeta ? vmeta->offset[p] : GST_VIDEO_INFO_PLANE_OFFSET(&_videoInfo, p); - const auto pitch = vmeta ? vmeta->stride[p] : GST_VIDEO_INFO_PLANE_STRIDE(&_videoInfo, p); - ATTRIB_PUSH(kPlFd[p], fd0); - ATTRIB_PUSH(kPlOffset[p], static_cast(off)); - ATTRIB_PUSH(kPlPitch[p], static_cast(pitch)); - if (hasModifiersExt) { // interleaved per spec: modifier attribs follow FD/OFFSET/PITCH for same plane - ATTRIB_PUSH(kPlModLo[p], modLo); - ATTRIB_PUSH(kPlModHi[p], modHi); - } - } -#undef ATTRIB_PUSH - attribs[n++] = EGL_NONE; - EGLImage singleImage = eglCreateImage(eglDpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, - nullptr, attribs); - if (singleImage != EGL_NO_IMAGE_KHR) { - for (int p = 0; p < planeCount; ++p) { - functions.glBindTexture(GL_TEXTURE_2D, names[p]); - eglImageTargetTexture2D(GL_TEXTURE_2D, singleImage); - } - eglDestroyImage(eglDpy, singleImage); - goto textures_built; - } -#if defined(DRM_FORMAT_Y210) || defined(DRM_FORMAT_Y410) - // Y210/Y410 have no per-plane fallback; warn once so the user knows frames will be black. - { - static std::atomic s_warnedY210{false}; - static std::atomic s_warnedY410{false}; -#if defined(DRM_FORMAT_Y210) - if (singleFourcc == DRM_FORMAT_Y210 && !s_warnedY210.exchange(true, std::memory_order_relaxed)) - qCWarning(GstDmaBufLog) << "EGL DMABuf import of Y210 unsupported on this driver; frames will be black"; -#endif -#if defined(DRM_FORMAT_Y410) - if (singleFourcc == DRM_FORMAT_Y410 && !s_warnedY410.exchange(true, std::memory_order_relaxed)) - qCWarning(GstDmaBufLog) << "EGL DMABuf import of Y410 unsupported on this driver; frames will be black"; -#endif - } -#endif - qCWarning(GstDmaBufLog) << "single-fd eglCreateImage failed (" << Qt::hex - << eglGetError() << "); falling back to per-plane"; - } - -per_plane_path: - for (int i = 0; i < planeCount && ok; ++i) { - const int memIdx = singleFdPacking ? 0 : i; - const int fd = gst_dmabuf_memory_get_fd(gst_buffer_peek_memory(buffer, memIdx)); - const auto offset = vmeta ? vmeta->offset[i] : GST_VIDEO_INFO_PLANE_OFFSET(&_videoInfo, i); - const auto stride = vmeta ? vmeta->stride[i] : GST_VIDEO_INFO_PLANE_STRIDE(&_videoInfo, i); - Q_ASSERT(stride > 0); - Q_ASSERT(static_cast(offset) <= static_cast(std::numeric_limits::max())); - const int planeWidth = GST_VIDEO_INFO_COMP_WIDTH(&_videoInfo, i); - const int planeHeight = GST_VIDEO_INFO_COMP_HEIGHT(&_videoInfo, i); - const int fourcc = drmFourccFor(&_videoInfo, i); - if (fourcc == -1) { - qCWarning(GstDmaBufLog) << "no DRM fourcc for format" - << gst_video_format_to_string(GST_VIDEO_INFO_FORMAT(&_videoInfo)); - ok = false; - break; - } - - // EGL attribute list — sized for the maximum (with modifier ext) and zero-terminated. - EGLAttrib attribs[18]; - int n = 0; -#define ATTRIB_PUSH(k, v) do { Q_ASSERT(n < int(std::size(attribs)) - 1); attribs[n++] = (k); attribs[n++] = (v); } while (0) - ATTRIB_PUSH(EGL_WIDTH, planeWidth); - ATTRIB_PUSH(EGL_HEIGHT, planeHeight); - ATTRIB_PUSH(EGL_LINUX_DRM_FOURCC_EXT, fourcc); - ATTRIB_PUSH(kPlFd[0], fd); - ATTRIB_PUSH(kPlOffset[0], static_cast(offset)); - ATTRIB_PUSH(kPlPitch[0], static_cast(stride)); - if (hasModifiersExt) { - ATTRIB_PUSH(kPlModLo[i], modLo); // plane-specific key per EGL_EXT_image_dma_buf_import_modifiers spec - ATTRIB_PUSH(kPlModHi[i], modHi); - } -#undef ATTRIB_PUSH - attribs[n++] = EGL_NONE; - EGLImage image = eglCreateImage(eglDpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, - nullptr, attribs); - if (image == EGL_NO_IMAGE_KHR) { - qCWarning(GstDmaBufLog) << "eglCreateImage failed plane" << i << "err" - << Qt::hex << eglGetError(); - ok = false; - break; - } - functions.glBindTexture(GL_TEXTURE_2D, names[i]); - eglImageTargetTexture2D(GL_TEXTURE_2D, image); - eglDestroyImage(eglDpy, image); - } - -textures_built: - - if (!ok) { - functions.glDeleteTextures(planeCount, names.data()); - return fail(); - } - - auto frameTextures = std::make_unique(&rhi, _format.frameSize(), - _format.pixelFormat(), names, planeCount); - // FrameTextures ctor null-resets any plane that QRhiTexture::createFrom() rejects; - // returning a partially-empty textures bundle would render black without - // incrementing the failure counter (Qt sees a "successful" frame). Bail loudly instead. - for (int i = 0; i < planeCount; ++i) { - if (!frameTextures->texture(i)) { - qCWarning(GstDmaBufLog) << "FrameTextures plane" << i << "createFrom failed — dropping frame"; - // ~FrameTextures will glDeleteTextures via releaseGLTextures() — names are owned now. - return fail(); - } - } - return frameTextures; -} - -quint64 GstDmaBufVideoBuffer::takeMapFailureCount() -{ - return s_mapFailureCount.exchange(0, std::memory_order_relaxed); -} - -quint64 GstDmaBufVideoBuffer::peekMapFailureCount() -{ - return s_mapFailureCount.load(std::memory_order_relaxed); -} - -#endif // QGC_HAS_GST_DMABUF_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstDmaBufVideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstDmaBufVideoBuffer.h deleted file mode 100644 index 3214c5cdfdeb..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstDmaBufVideoBuffer.h +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once - -#include - -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - -#include "GstHwVideoBuffer.h" - -#include -#include - -class QRhi; - -/// \brief Zero-copy QVideoFrame backing for GStreamer DMABuf samples. -/// -/// Imports per-plane DMABuf fds into EGLImages on demand in mapTextures(), -/// which Qt invokes on the render thread with a current GL context. -/// -class GstDmaBufVideoBuffer final : public GstHwVideoBuffer -{ -public: - GstDmaBufVideoBuffer(GstSample *sample, - const GstVideoInfo &videoInfo, - const QVideoFrameFormat &format, - EGLDisplay eglDisplay); - - MapData map(QVideoFrame::MapMode mode) override; - bool isDmaBuf() const override { return true; } - - ~GstDmaBufVideoBuffer() override; - - QVideoFrameTexturesUPtr mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr &oldTextures) override; - bool validatePlaneHandles() const override; - - static quint64 takeMapFailureCount(); - static quint64 peekMapFailureCount(); - -private: - EGLDisplay _eglDisplay = EGL_NO_DISPLAY; -}; - -#endif // QGC_HAS_GST_DMABUF_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstGlContextBridge.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstGlContextBridge.cc deleted file mode 100644 index 10e3ebcc4755..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstGlContextBridge.cc +++ /dev/null @@ -1,331 +0,0 @@ -#include "GstGlContextBridge.h" -#include "GstContextBridgeRegistry.h" - -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - -#include "QGCLoggingCategory.h" - -#include -#include -#include -#include -#include -#include - -#include -#if defined(__linux__) -# include -# include -// Qt 6 on xcb uses GLX by default — fall back to GLX context wrapping when EGL isn't available. -# if __has_include() && __has_include() -# include -# include -# include -# define QGC_GST_BRIDGE_HAS_GLX 1 -# endif -// Wayland: downstream elements that probe GST_IS_GL_DISPLAY_WAYLAND need the wl_display tagged on the GstGLDisplay; without it pool/queue negotiation falls back to a generic EGL display and silently misses Wayland-specific zero-copy paths. -# if __has_include() -# include -# define QGC_GST_BRIDGE_HAS_WAYLAND 1 -# endif -#endif - -QGC_LOGGING_CATEGORY(GstGlBridgeLog, "Video.GStreamer.HwBuffers.GstGlBridge") - -namespace GstGlContextBridge { -namespace { - -QMutex s_mutex; -GstGLDisplay *s_display = nullptr; -GstGLContext *s_context = nullptr; -bool s_primed = false; -bool s_primeAttempted = false; -// Bounds globalShareContext retry spam when Qt GL is never initialized. -int s_primeNullShareCount = 0; -constexpr int kMaxPrimeNullShareRetries = 16; - -#if defined(__linux__) -EGLDisplay qtEglDisplay() -{ - if (auto *ni = QGuiApplication::platformNativeInterface()) { - if (auto *d = ni->nativeResourceForIntegration("egldisplay")) { - return static_cast(d); - } - } - return eglGetDisplay(EGL_DEFAULT_DISPLAY); -} - -EGLContext qtEglContext(QOpenGLContext *qtCtx) -{ - if (!qtCtx) return EGL_NO_CONTEXT; - if (auto *egl = qtCtx->nativeInterface()) { - return egl->nativeContext(); - } - return EGL_NO_CONTEXT; -} -#endif - -bool primeLocked() -{ - if (s_primed) return true; - if (s_primeAttempted) return false; - s_primeAttempted = true; - - QOpenGLContext *qtCtx = QOpenGLContext::globalShareContext(); - if (!qtCtx) { - ++s_primeNullShareCount; - if (s_primeNullShareCount <= kMaxPrimeNullShareRetries) { - qCInfo(GstGlBridgeLog) << "globalShareContext() is null — Qt GL not initialized yet" - << "(attempt" << s_primeNullShareCount << "/" << kMaxPrimeNullShareRetries << ")"; - s_primeAttempted = false; // allow retry until limit is reached - } else { - if (s_primeNullShareCount == kMaxPrimeNullShareRetries + 1) { - qCWarning(GstGlBridgeLog) << "globalShareContext() still null after" - << kMaxPrimeNullShareRetries - << "retries; GL bridge giving up"; - } - // s_primeAttempted stays true — no more retries until reset() - } - return false; - } - -#if defined(__linux__) - // Try EGL first (Wayland, eglfs, xcb_egl); fall back to GLX (xcb default on Qt 6 desktop). - EGLContext eglCtx = qtEglContext(qtCtx); - EGLDisplay eglDisp = (eglCtx != EGL_NO_CONTEXT) ? qtEglDisplay() : EGL_NO_DISPLAY; - // Allow retry on every "real" failure below — these are likely permanent (misconfigured - // gst build, missing native interface) but if a window-recreate fixes things the next - // NEED_CONTEXT should get another chance. reset() also re-enables retry by clearing - // s_primeAttempted directly. - auto bail = [](const char *) -> bool { s_primeAttempted = false; return false; }; - if (eglCtx != EGL_NO_CONTEXT && eglDisp != EGL_NO_DISPLAY) { -# if defined(QGC_GST_BRIDGE_HAS_WAYLAND) - // On Wayland, primary display must be GstGLDisplayWayland (carries wl_display); the EGL - // display is then derived and marked foreign so unref doesn't tear down the EGL handle Qt owns. - const QString platformName = QGuiApplication::platformName(); - if (platformName == QLatin1String("wayland") || platformName == QLatin1String("wayland-egl")) { - struct wl_display *wlDisp = nullptr; - if (auto *ni = QGuiApplication::platformNativeInterface()) { - wlDisp = static_cast(ni->nativeResourceForIntegration("wl_display")); - } - if (wlDisp) { - GstGLDisplayWayland *displayWl = gst_gl_display_wayland_new_with_display(wlDisp); - if (displayWl) { - s_display = GST_GL_DISPLAY(displayWl); -# if GST_CHECK_VERSION(1, 26, 0) - // Pre-derive the EGL view so downstream NEED_CONTEXT for gst.gl.display.egl is satisfied - // by a display that maps back to the same wl_display. set_foreign(TRUE) is mandatory: - // Qt owns the EGLDisplay; without it gst would call eglTerminate on Qt's display. - if (GstGLDisplayEGL *derived = gst_gl_display_egl_from_gl_display(s_display)) { - gst_gl_display_egl_set_foreign(derived, TRUE); - gst_object_unref(derived); - } -# endif - } - } - } -# endif - if (!s_display) { - GstGLDisplayEGL *displayEgl = gst_gl_display_egl_new_with_egl_display(eglDisp); - if (!displayEgl) { - qCWarning(GstGlBridgeLog) << "gst_gl_display_egl_new_with_egl_display failed"; - return bail("displayEgl"); - } - s_display = GST_GL_DISPLAY(displayEgl); - } - - s_context = gst_gl_context_new_wrapped(s_display, - reinterpret_cast(eglCtx), - GST_GL_PLATFORM_EGL, - static_cast(GST_GL_API_GLES2 | GST_GL_API_OPENGL)); - if (!s_context) { - qCWarning(GstGlBridgeLog) << "gst_gl_context_new_wrapped (EGL) failed"; - gst_clear_object(&s_display); - return bail("ctxEgl"); - } -# if defined(QGC_GST_BRIDGE_HAS_WAYLAND) - const bool isWayland = GST_IS_GL_DISPLAY_WAYLAND(s_display); - qCInfo(GstGlBridgeLog) << (isWayland ? "GL bridge primed (Wayland+EGL)" : "GL bridge primed (EGL)"); -# else - qCInfo(GstGlBridgeLog) << "GL bridge primed (EGL)"; -# endif - } else { -# if defined(QGC_GST_BRIDGE_HAS_GLX) - // Qt's QGLXContext exposes the X11 Display* + GLXContext we need to wrap. - auto *glx = qtCtx->nativeInterface(); - if (!glx) { - qCWarning(GstGlBridgeLog) << "Qt GL context exposes neither EGL nor GLX; GL bridge disabled"; - return bail("noGlx"); - } - Display *xdisp = nullptr; - if (auto *ni = QGuiApplication::platformNativeInterface()) { - xdisp = static_cast(ni->nativeResourceForIntegration("display")); - } - if (!xdisp) { - qCWarning(GstGlBridgeLog) << "X11 Display unresolvable; GL bridge disabled"; - return bail("xdisp"); - } - GstGLDisplayX11 *displayX11 = gst_gl_display_x11_new_with_display(xdisp); - if (!displayX11) { - qCWarning(GstGlBridgeLog) << "gst_gl_display_x11_new_with_display failed"; - return bail("displayX11"); - } - s_display = GST_GL_DISPLAY(displayX11); - s_context = gst_gl_context_new_wrapped(s_display, - reinterpret_cast(glx->nativeContext()), - GST_GL_PLATFORM_GLX, - static_cast(GST_GL_API_OPENGL)); - if (!s_context) { - qCWarning(GstGlBridgeLog) << "gst_gl_context_new_wrapped (GLX) failed"; - gst_clear_object(&s_display); - return bail("ctxGlx"); - } - qCInfo(GstGlBridgeLog) << "GL bridge primed (GLX)"; -# else - qCWarning(GstGlBridgeLog) << "Qt EGLContext unresolvable and GLX bridge not built; GL bridge disabled"; - return bail("noEglNoGlx"); -# endif - } - - s_primed = true; - // gst_gl_context_fill_info() must run on the context's own thread, but a freshly wrapped context has no active thread until first activation — calling thread_add now trips an assertion. GStreamer fills info lazily on first use, so don't pre-fill. - qCDebug(GstGlBridgeLog) << "GL bridge primed: display=" << s_display - << "context=" << s_context; - return true; -#else - // EGL-only by design. Windows uses GstD3D11ContextBridge; macOS/iOS use the - // CVPixelBuffer path. WGL/CGL wrappers add no value over those. - qCInfo(GstGlBridgeLog) << "GL bridge inactive on this platform (non-EGL)"; - return false; -#endif -} - -} // namespace - -bool prime() -{ - QMutexLocker lock(&s_mutex); - return primeLocked(); -} - -GstBusSyncReply handleSyncMessage(GstMessage *message) -{ - if (GST_MESSAGE_TYPE(message) != GST_MESSAGE_NEED_CONTEXT) { - return GST_BUS_PASS; - } - - const gchar *contextType = nullptr; - if (!gst_message_parse_context_type(message, &contextType) || !contextType) { - return GST_BUS_PASS; - } - const bool isGlDisplay = (g_strcmp0(contextType, GST_GL_DISPLAY_CONTEXT_TYPE) == 0); - const bool isGlApp = (g_strcmp0(contextType, "gst.gl.app_context") == 0); - if (!isGlDisplay && !isGlApp) { - return GST_BUS_PASS; - } - - QMutexLocker lock(&s_mutex); - if (!primeLocked()) { - return GST_BUS_PASS; - } - - GstElement *element = GST_ELEMENT(GST_MESSAGE_SRC(message)); - if (!element) { - return GST_BUS_PASS; - } - - if (isGlDisplay && s_display) { - GstContext *gctx = gst_context_new(GST_GL_DISPLAY_CONTEXT_TYPE, TRUE); - gst_context_set_gl_display(gctx, s_display); - gst_element_set_context(element, gctx); - gst_context_unref(gctx); - qCDebug(GstGlBridgeLog) << "Provided GL display context to" << GST_ELEMENT_NAME(element); - } else if (isGlApp && s_context) { - GstContext *gctx = gst_context_new("gst.gl.app_context", TRUE); - GstStructure *s = gst_context_writable_structure(gctx); - gst_structure_set(s, "context", GST_TYPE_GL_CONTEXT, s_context, NULL); - gst_element_set_context(element, gctx); - gst_context_unref(gctx); - qCDebug(GstGlBridgeLog) << "Provided GL app context to" << GST_ELEMENT_NAME(element); - } else { - return GST_BUS_PASS; - } - - gst_message_unref(message); - return GST_BUS_DROP; -} - -bool answerContextQuery(GstQuery *query) -{ - if (!query || GST_QUERY_TYPE(query) != GST_QUERY_CONTEXT) { - return false; - } - const gchar *contextType = nullptr; - if (!gst_query_parse_context_type(query, &contextType) || !contextType) { - return false; - } - const bool isGlDisplay = (g_strcmp0(contextType, GST_GL_DISPLAY_CONTEXT_TYPE) == 0); - const bool isGlApp = (g_strcmp0(contextType, "gst.gl.app_context") == 0); - if (!isGlDisplay && !isGlApp) { - return false; - } - - QMutexLocker lock(&s_mutex); - if (!primeLocked()) { - return false; - } - - if (isGlDisplay && s_display) { - GstContext *gctx = gst_context_new(GST_GL_DISPLAY_CONTEXT_TYPE, TRUE); - gst_context_set_gl_display(gctx, s_display); - gst_query_set_context(query, gctx); - gst_context_unref(gctx); - return true; - } - if (isGlApp && s_context) { - GstContext *gctx = gst_context_new("gst.gl.app_context", TRUE); - GstStructure *s = gst_context_writable_structure(gctx); - gst_structure_set(s, "context", GST_TYPE_GL_CONTEXT, s_context, NULL); - gst_query_set_context(query, gctx); - gst_context_unref(gctx); - return true; - } - return false; -} - -void reset() -{ - QMutexLocker lock(&s_mutex); - gst_clear_object(&s_context); - gst_clear_object(&s_display); - s_primed = false; - s_primeAttempted = false; - s_primeNullShareCount = 0; - qCDebug(GstGlBridgeLog) << "GL bridge reset"; -} - -void rearm() -{ - QMutexLocker lock(&s_mutex); - if (s_primed) return; - if (s_primeAttempted && s_primeNullShareCount > kMaxPrimeNullShareRetries) { - s_primeAttempted = false; - s_primeNullShareCount = 0; - qCInfo(GstGlBridgeLog) << "GL bridge rearm: clearing exhausted retry latch"; - } -} - - -namespace { -struct GlBridgeRegistrar { - GlBridgeRegistrar() { - GstContextBridgeRegistry::registerBridgeHandler(&GstGlContextBridge::handleSyncMessage); - GstContextBridgeRegistry::registerResetCallback(&GstGlContextBridge::reset); - } -}; -static GlBridgeRegistrar s_glBridgeRegistrar; -} // anonymous namespace - -} // namespace GstGlContextBridge - -#endif // QGC_HAS_GST_GLMEMORY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstGlContextBridge.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstGlContextBridge.h deleted file mode 100644 index 3d25748da490..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstGlContextBridge.h +++ /dev/null @@ -1,48 +0,0 @@ -#pragma once - -#include - -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - -#include - -/// Process-wide shared GstGL display/context bridging Qt's GL with GStreamer's -/// gst-gl elements (glupload, glcolorconvert, gldownload, the *gl decoders). -/// -/// gst-gl elements ask the pipeline for GST_GL_DISPLAY_CONTEXT_TYPE and -/// "gst.gl.app_context" via GST_MESSAGE_NEED_CONTEXT. If we respond with our -/// shared display+context, the decoder allocates textures in a context Qt's -/// QRhi (GL backend) can sample from — true zero-copy. -/// -/// Without this bridge, gst-gl creates an internal context isolated from Qt; -/// any GL textures it produces are unreachable from QRhi's render thread. -namespace GstGlContextBridge { - -/// Idempotent. Tries to build a shared display+context from Qt's -/// QOpenGLContext::globalShareContext (must already exist — typically true once -/// the first QQuickWindow has been shown). Returns true on success. -bool prime(); - -/// Inspect a NEED_CONTEXT message; if it's for the gst-gl context types this -/// bridge serves, respond with the shared display/context and consume the -/// message. Returns GST_BUS_DROP when consumed, GST_BUS_PASS otherwise. Cheap -/// when the message isn't relevant. Thread-safe. -GstBusSyncReply handleSyncMessage(GstMessage *message); - -/// Answer a downstream GST_QUERY_CONTEXT for gst.gl.GLDisplay or gst.gl.app_context -/// using the primed display/context. Returns true iff the query was filled — caller -/// should signal GST_PAD_PROBE_HANDLED to short-circuit upstream propagation. -bool answerContextQuery(GstQuery *query); - -/// Drop the cached display/context so the next prime() rebuilds against the -/// current Qt GL state. Call from receiver teardown — between sessions Qt may -/// destroy and recreate its GL context, leaving the cached gst-gl wrappers -/// stale. -void reset(); - -/// Clear exhausted-retry latch so a later NEED_CONTEXT can prime; no-op if already primed. -void rearm(); - -} // namespace GstGlContextBridge - -#endif // QGC_HAS_GST_GLMEMORY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstGlVideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstGlVideoBuffer.cc deleted file mode 100644 index 7854e8a89dea..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstGlVideoBuffer.cc +++ /dev/null @@ -1,245 +0,0 @@ -#include "GstGlVideoBuffer.h" -#include "GstHwVideoBuffer.h" - -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - -#include "QGCLoggingCategory.h" - -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -// Qt's portable GL types (GLuint et al.) — works on Linux/macOS/Android without GLES2 SDK headers. -#include - -#include -#include - -QGC_LOGGING_CATEGORY(GstGlBufLog, "Video.GStreamer.HwBuffers.GstGlBuf") - -namespace { - -constexpr int kMaxPlanes = 4; - -std::atomic s_mapFailureCount{0}; - -QVideoFrameTexturesUPtr fail() -{ - s_mapFailureCount.fetch_add(1, std::memory_order_relaxed); - return {}; -} - -class FrameTextures final : public QVideoFrameTextures -{ -public: - FrameTextures(QRhi *rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, - std::array names, int count) - : _rhi(rhi), _size(size), _pixelFormat(pixelFormat), _names(names), _count(count) - { - const auto *desc = QVideoTextureHelper::textureDescription(pixelFormat); - if (!desc) { - return; - } - for (int i = 0; i < _count; ++i) { - // GL_NONE (0) would be silently accepted by createFrom and sample as black — - // gst-gl can hand us 0 if a plane wasn't uploaded yet. - if (names[i] == 0) { - qCWarning(GstGlBufLog) << "FrameTextures: GL texture id is 0 for plane" << i; - continue; - } - const QSize planeSize = desc->rhiPlaneSize(size, i, rhi); - _textures[i].reset(rhi->newTexture(desc->rhiTextureFormat(i, rhi), planeSize, 1, {})); - if (_textures[i] && !_textures[i]->createFrom({names[i], 0})) { - _textures[i].reset(); - } - } - } - - QRhiTexture *texture(uint plane) const override - { - return (int(plane) < _count) ? _textures[plane].get() : nullptr; - } - - // Reuse-eligibility: same rhi+size+format and identical, non-zero per-plane GL texture ids. - // gst-gl rotates ids within a fixed pool; the QRhiTexture is just a thin view, so when the - // pool re-binds id N to new frame contents the wrapper transparently samples the new data. - bool matches(QRhi *rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, - const std::array &names, int count) const - { - if (_rhi != rhi || _size != size || _pixelFormat != pixelFormat || _count != count) { - return false; - } - for (int i = 0; i < _count; ++i) { - if (_names[i] == 0 || _names[i] != names[i] || !_textures[i]) { - return false; - } - } - return true; - } - -private: - QRhi *_rhi = nullptr; - QSize _size; - QVideoFrameFormat::PixelFormat _pixelFormat = QVideoFrameFormat::Format_Invalid; - std::array _names{}; - int _count = 0; - std::unique_ptr _textures[kMaxPlanes]; -}; - -std::atomic s_textureReuseHits{0}; -std::atomic s_gpuWaitCount{0}; -std::atomic s_cpuWaitCount{0}; - -std::atomic s_loggedNullSample{false}; -std::atomic s_loggedBadBackend{false}; - -#define GL_WARN_ONCE(flag, ...) \ - do { if (!(flag).exchange(true, std::memory_order_relaxed)) qCWarning(GstGlBufLog) << __VA_ARGS__; } while (0) - -} // namespace - -GstGlVideoBuffer::GstGlVideoBuffer(GstSample *sample, - const GstVideoInfo &videoInfo, - const QVideoFrameFormat &format) - : GstHwVideoBuffer(QVideoFrame::RhiTextureHandle, sample, videoInfo, format) -{ -} - -GstGlVideoBuffer::~GstGlVideoBuffer() = default; - -QAbstractVideoBuffer::MapData GstGlVideoBuffer::map(QVideoFrame::MapMode /*mode*/) -{ - return {}; -} - -bool GstGlVideoBuffer::validatePlaneHandles() const -{ - // Allocator-only — GLuint check needs GST_MAP_GL, too expensive on streaming thread. - if (!_sample) return false; - GstBuffer *buffer = gst_sample_get_buffer(_sample); - if (!buffer) return false; - const int memCount = qMin(int(gst_buffer_n_memory(buffer)), kMaxPlanes); - if (memCount <= 0) return false; - for (int i = 0; i < memCount; ++i) { - GstMemory *mem = gst_buffer_peek_memory(buffer, i); - if (!mem || !gst_is_gl_memory(mem)) return false; - } - return true; -} - -QVideoFrameTexturesUPtr GstGlVideoBuffer::mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr &old) -{ - Q_ASSERT(rhi.thread()->isCurrentThread()); // Qt's contract: mapTextures runs on the QRhi (render) thread. - if (!_sample) { - GL_WARN_ONCE(s_loggedNullSample, "mapTextures: GstSample is null"); - return fail(); - } - // QRhi::OpenGLES2 covers both desktop GL and GLES — Qt collapses both into - // the same backend enum value. There is no separate QRhi::OpenGL. - if (rhi.backend() != QRhi::OpenGLES2) { - GL_WARN_ONCE(s_loggedBadBackend, "QRhi backend is" << rhi.backendName() - << "(GL backend required); GLMemory path disabled"); - return fail(); - } - - // QRhi::createFrom calls glBindTexture/glTexParameteri internally — defensive bracket against custom RHI integrations where QSGRenderThread doesn't have Qt's GL context bound on entry. - rhi.makeThreadLocalNativeContextCurrent(); - - GstBuffer *buffer = gst_sample_get_buffer(_sample); - if (!buffer) return fail(); - - GstMemory *mem0 = gst_buffer_peek_memory(buffer, 0); - if (!mem0 || !gst_is_gl_memory(mem0)) { - return fail(); - } - - GstVideoFrame frame{}; - // GST_MAP_GL (a gst-gl extension flag) OR'd with GST_MAP_READ triggers GstGLMemory upload. - if (!gst_video_frame_map(&frame, &_videoInfo, buffer, - static_cast(GST_MAP_READ | GST_MAP_GL))) { - qCWarning(GstGlBufLog) << "gst_video_frame_map(GST_MAP_READ | GST_MAP_GL) failed"; - return fail(); - } - - // wait_cpu (CPU-blocking) is the only durably correct fence here. The cheaper GPU-side - // wait() — even when gst_gl_context_can_share(producer, primed) returns true — produces - // periodic torn / saturated-green NV12 frames on Linux Mesa: wait() only records ordering - // in the caller's GL command stream, and Mesa's per-thread queues don't always flush in - // time for QRhi to sample (UV plane reads partially-written memory). wait_cpu blocks - // until the producer's fence signals on the CPU side, which is what QRhi actually needs. - GstGLContext *glCtx = GST_GL_BASE_MEMORY_CAST(mem0)->context; - if (GstGLSyncMeta *syncMeta = gst_buffer_get_gl_sync_meta(buffer); syncMeta && glCtx) { - gst_gl_sync_meta_wait_cpu(syncMeta, glCtx); - s_cpuWaitCount.fetch_add(1, std::memory_order_relaxed); - } - - const int planeCount = qBound(1, int(GST_VIDEO_FRAME_N_PLANES(&frame)), kMaxPlanes); - std::array names{}; - for (int i = 0; i < planeCount; ++i) { - // GST_MAP_GL maps return a *pointer to* the GLuint texture id, not the id itself. - const GLuint *texIdPtr = static_cast(GST_VIDEO_FRAME_PLANE_DATA(&frame, i)); - names[i] = texIdPtr ? *texIdPtr : 0; - } - - gst_video_frame_unmap(&frame); - - // Reuse Qt's previous textures wrapper if the pool gave us identical GL ids on the same RHI. - // dynamic_cast (Qt builds with RTTI) safely no-ops when `old` came from a different video buffer - // type (its anon-namespace FrameTextures is a different translation-unit type). - if (old) { - if (auto *prev = dynamic_cast(old.get()); - prev && prev->matches(&rhi, _format.frameSize(), _format.pixelFormat(), names, planeCount)) { - s_textureReuseHits.fetch_add(1, std::memory_order_relaxed); - return std::move(old); - } - } - - auto textures = std::make_unique(&rhi, _format.frameSize(), - _format.pixelFormat(), names, planeCount); - // FrameTextures null-resets any plane that QRhiTexture::createFrom rejects; for NV12/I420 - // the chroma plane can fail while luma succeeds, producing a "successful" frame with - // missing chroma if we only check plane 0. Verify all planes (matches DMABuf path). - for (int i = 0; i < planeCount; ++i) { - if (!textures->texture(static_cast(i))) { - qCWarning(GstGlBufLog) << "createFrom failed for plane" << i - << "format=" << _format.pixelFormat(); - return fail(); - } - } - return textures; -} - -quint64 GstGlVideoBuffer::peekTextureReuseHits() -{ - return s_textureReuseHits.load(std::memory_order_relaxed); -} - -quint64 GstGlVideoBuffer::takeTextureReuseHits() -{ - return s_textureReuseHits.exchange(0, std::memory_order_relaxed); -} - -quint64 GstGlVideoBuffer::takeSyncWaitCounts(quint64 &gpuWaits) -{ - gpuWaits = s_gpuWaitCount.exchange(0, std::memory_order_relaxed); - return s_cpuWaitCount.exchange(0, std::memory_order_relaxed); -} - -quint64 GstGlVideoBuffer::takeMapFailureCount() -{ - return s_mapFailureCount.exchange(0, std::memory_order_relaxed); -} - -quint64 GstGlVideoBuffer::peekMapFailureCount() -{ - return s_mapFailureCount.load(std::memory_order_relaxed); -} - -#endif // QGC_HAS_GST_GLMEMORY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstGlVideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstGlVideoBuffer.h deleted file mode 100644 index 93dc7738f8b6..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstGlVideoBuffer.h +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once - -#include - -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - -#include "GstHwVideoBuffer.h" - -class QRhi; - -/// \brief Zero-copy QVideoFrame backing for GStreamer GLMemory samples. -/// -/// Wraps GstGLMemory texture ids in QRhiTextures. Requires the QRhi GL context -/// to share with the GstGLContext — see GstGlContextBridge for the wiring. -/// -class GstGlVideoBuffer final : public GstHwVideoBuffer -{ -public: - /// @p sample is ref'd; the buffer keeps it alive until destruction. - GstGlVideoBuffer(GstSample *sample, - const GstVideoInfo &videoInfo, - const QVideoFrameFormat &format); - ~GstGlVideoBuffer() override; - - MapData map(QVideoFrame::MapMode mode) override; - QVideoFrameTexturesUPtr mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr &oldTextures) override; - bool validatePlaneHandles() const override; - - /// Process-wide failure tally (read+reset / read-only). Mirrors GstDmaBufVideoBuffer - /// so the adapter can log both paths uniformly on teardown. - static quint64 takeMapFailureCount(); - static quint64 peekMapFailureCount(); - - /// Per-process count of mapTextures calls that returned the previous QRhiTexture wrapper - /// instead of allocating a new one (gst-gl pool returned the same plane id). - static quint64 takeTextureReuseHits(); - static quint64 peekTextureReuseHits(); - - /// Read-and-reset the per-process sync-wait split. Returns CPU-blocking-wait count; - /// writes the GPU-side-wait count into @p gpuWaits. Lets the adapter expose how often - /// the cheap GPU fence path was taken vs the conservative CPU block. - static quint64 takeSyncWaitCounts(quint64 &gpuWaits); -}; - -#endif // QGC_HAS_GST_GLMEMORY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstHwVideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstHwVideoBuffer.cc deleted file mode 100644 index 1a985ae72184..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstHwVideoBuffer.cc +++ /dev/null @@ -1,20 +0,0 @@ -#include "GstHwVideoBuffer.h" - -GstHwVideoBuffer::GstHwVideoBuffer(QVideoFrame::HandleType handleType, - GstSample *sample, - const GstVideoInfo &videoInfo, - QVideoFrameFormat format) - : QHwVideoBuffer(handleType, nullptr) - , _sample(sample ? gst_sample_ref(sample) : nullptr) - , _videoInfo(videoInfo) - , _format(std::move(format)) -{ - // Crop is applied by the renderer via QVideoFrameFormat::viewport(); see GstAppSinkAdapter::applyCropMeta. -} - -GstHwVideoBuffer::~GstHwVideoBuffer() -{ - if (_sample) { - gst_sample_unref(_sample); - } -} diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstHwVideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstHwVideoBuffer.h deleted file mode 100644 index 3be6cc4f308b..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstHwVideoBuffer.h +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include -#include - -/// \brief Common base for GStreamer-backed QHwVideoBuffer subclasses. -/// -/// Owns a ref on GstSample for its lifetime; holds a GstVideoInfo and -/// QVideoFrameFormat so subclasses don't duplicate those three members. -/// -class GstHwVideoBuffer : public QHwVideoBuffer -{ -public: - GstHwVideoBuffer(QVideoFrame::HandleType handleType, - GstSample *sample, - const GstVideoInfo &videoInfo, - QVideoFrameFormat format); - ~GstHwVideoBuffer() override; - - QVideoFrameFormat format() const override { return _format; } - - /// Streaming-thread sanity check on per-plane handles. Failure routes to CPU memcpy. - /// GPU texture validity is checked later in mapTextures. - virtual bool validatePlaneHandles() const { return true; } - -protected: - GstSample *_sample = nullptr; - GstVideoInfo _videoInfo{}; - QVideoFrameFormat _format; -}; diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstHwVideoBufferFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstHwVideoBufferFactory.cc deleted file mode 100644 index 2540861f862f..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstHwVideoBufferFactory.cc +++ /dev/null @@ -1,165 +0,0 @@ -#include "GstHwVideoBufferFactory.h" -#include "QGCLoggingCategory.h" - -#include -#include -#include -#include -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) -# include -#endif - -#include - -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) -# include "GstDmaBufVideoBuffer.h" -# include -#endif -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) -# include "GstGlVideoBuffer.h" -# include -#endif -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) -# include "GstD3D11VideoBuffer.h" -# include -#endif -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) -# include "GstD3D12VideoBuffer.h" -# include -#endif -#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) -# include "GstIOSurfaceVideoBuffer.h" -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) -# include "GstAHardwareBufferVideoBuffer.h" -# include -#endif - -QGC_LOGGING_CATEGORY(GstHwBufFactoryLog, "Video.GStreamer.HwBuffers.Factory") - -std::unique_ptr makeHwVideoBuffer( - GstSample *sample, - [[maybe_unused]] const GstVideoInfo &info, - [[maybe_unused]] QVideoFrameFormat format, - bool gpuEnabled, -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - EGLDisplay eglDisplay, -#else - void * /*eglDisplay*/, -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - EGLDisplay ahbEglDisplay, -#else - void * /*ahbEglDisplay*/, -#endif - HwVideoBufferPath &matchedPath) -{ - matchedPath = HwVideoBufferPath::None; - if (!gpuEnabled || !sample) { - return nullptr; - } - - GstBuffer *buffer = gst_sample_get_buffer(sample); - if (!buffer) { - return nullptr; - } - - // Plane 0's allocator type is the dispatch key; gst-video buffers from a single decoder use one allocator across all planes. - GstMemory *mem0 = gst_buffer_peek_memory(buffer, 0); - if (!mem0) { - return nullptr; - } - - // Validate before commit — failure resets matchedPath so per-path counters don't double-count. - auto buildOrFallback = [&matchedPath](auto &&buf) -> std::unique_ptr { - if (!buf || !buf->validatePlaneHandles()) { - static std::atomic s_validateFails{0}; - const quint64 c = s_validateFails.fetch_add(1, std::memory_order_relaxed) + 1; - if ((c & 0x3F) == 1) { - qCWarning(GstHwBufFactoryLog) - << "validatePlaneHandles failed — CPU memcpy fallback (total:" << c << ")"; - } - matchedPath = HwVideoBufferPath::None; - return nullptr; - } - return std::move(buf); - }; - -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - if (eglDisplay != EGL_NO_DISPLAY && gst_is_dmabuf_memory(mem0)) { - matchedPath = HwVideoBufferPath::DmaBuf; - // Pi/eglfs quirk: the EGL impl on RPi (Broadcom/Mesa V3D) does implicit YUV→RGB conversion - // when sampling YUYV/UYVY DMABuf via EGLImage, so the pixel data is already RGBA on the GPU - // texture. Declaring the format as RGBA8888 picks Qt's RGB sampler shader (otherwise Qt - // applies a YUV→RGB matrix in software, double-converting). Mirror's Qt's own backend - // (qtmultimedia/.../qgstvideorenderersink.cpp:render — eglfs UYVY/YUYV branch). - static const bool isEglfsQPA = - QGuiApplication::platformName() == QLatin1String("eglfs"); - if (isEglfsQPA - && (format.pixelFormat() == QVideoFrameFormat::Format_UYVY - || format.pixelFormat() == QVideoFrameFormat::Format_YUYV)) { - QVideoFrameFormat spoofed(format.frameSize(), QVideoFrameFormat::Format_RGBA8888); - spoofed.setStreamFrameRate(format.streamFrameRate()); - spoofed.setColorRange(format.colorRange()); - spoofed.setColorSpace(format.colorSpace()); - spoofed.setColorTransfer(format.colorTransfer()); - spoofed.setViewport(format.viewport()); - format = std::move(spoofed); - } - return buildOrFallback(std::make_unique(sample, info, format, eglDisplay)); - } -#endif - -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - if (gst_is_gl_memory(mem0)) { - matchedPath = HwVideoBufferPath::GlMemory; - return buildOrFallback(std::make_unique(sample, info, format)); - } -#endif - -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) - if (gst_is_d3d11_memory(mem0)) { - matchedPath = HwVideoBufferPath::D3D11; - return buildOrFallback(std::make_unique(sample, info, format)); - } -#endif - -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) - if (gst_is_d3d12_memory(mem0)) { - matchedPath = HwVideoBufferPath::D3D12; - return buildOrFallback(std::make_unique(sample, info, format)); - } -#endif - -#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) - // String-compare the allocator name; gst-applemedia exports no public gst_is_apple_core_video_memory() predicate. - if (mem0->allocator && g_strcmp0(mem0->allocator->mem_type, "AppleCoreVideoMemory") == 0) { - matchedPath = HwVideoBufferPath::IOSurface; - return buildOrFallback(std::make_unique(sample, info, format)); - } -#endif - -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - if (ahbEglDisplay != EGL_NO_DISPLAY && gst_is_ahardware_buffer_memory(mem0)) { - matchedPath = HwVideoBufferPath::AHardwareBuffer; - // GL_TEXTURE_EXTERNAL_OES requires the SamplerExternalOES pixel format for Qt's shader path. - format.setPixelFormat(QVideoFrameFormat::Format_SamplerExternalOES); - return buildOrFallback(std::make_unique(sample, info, format, ahbEglDisplay)); - } -#endif - - { - static QSet s_seen; - static QMutex s_mtx; - const QString memType = mem0->allocator - ? QString::fromUtf8(mem0->allocator->mem_type) - : QStringLiteral(""); - QMutexLocker lock(&s_mtx); - if (!s_seen.contains(memType)) { - s_seen.insert(memType); - qCDebug(GstHwBufFactoryLog) << "no zero-copy path for memory type" - << memType << "— falling back to CPU memcpy"; - } - } - return nullptr; -} diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstHwVideoBufferFactory.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstHwVideoBufferFactory.h deleted file mode 100644 index 16474c294492..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstHwVideoBufferFactory.h +++ /dev/null @@ -1,49 +0,0 @@ -#pragma once - -#include -#include - -#include -#include - -#include - -class QHwVideoBuffer; - -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) || defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) -# include -#endif - -/// Identifies which GPU path was chosen; used by the adapter to increment the right counter. -enum class HwVideoBufferPath { - None, - DmaBuf, - GlMemory, - D3D11, - D3D12, - IOSurface, - AHardwareBuffer, -}; - -/// Factory that selects the appropriate zero-copy QHwVideoBuffer for a GstSample. -/// -/// Returns nullptr if no compiled GPU path matches the buffer's memory type, -/// in which case the caller should fall back to the CPU memcpy path. -/// @p gpuEnabled must be true for any GPU path to be attempted. -std::unique_ptr makeHwVideoBuffer( - GstSample *sample, - const GstVideoInfo &info, - QVideoFrameFormat format, - bool gpuEnabled, -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - EGLDisplay eglDisplay, -#else - void *eglDisplay, -#endif -#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - EGLDisplay ahbEglDisplay, -#else - void *ahbEglDisplay, -#endif - HwVideoBufferPath &matchedPath -); diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstIOSurfaceVideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstIOSurfaceVideoBuffer.h deleted file mode 100644 index 4b48aae5ab86..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstIOSurfaceVideoBuffer.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include - -#if (defined(Q_OS_MACOS) || defined(Q_OS_IOS)) && defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) - -#include "GstHwVideoBuffer.h" - -class QRhi; - -/// \brief Wraps a GstAppleCoreVideoMemory/IOSurface-backed sample as a QHwVideoBuffer; samples natively on QRhi::Metal. -/// -class GstIOSurfaceVideoBuffer final : public GstHwVideoBuffer -{ -public: - GstIOSurfaceVideoBuffer(GstSample *sample, - const GstVideoInfo &videoInfo, - const QVideoFrameFormat &format); - ~GstIOSurfaceVideoBuffer() override; - - MapData map(QVideoFrame::MapMode mode) override; - QVideoFrameTexturesUPtr mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr &oldTextures) override; - bool validatePlaneHandles() const override; - - static quint64 takeMapFailureCount(); - static quint64 peekMapFailureCount(); -}; - -#endif // (Q_OS_MACOS || Q_OS_IOS) && QGC_HAS_GST_IOSURFACE_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/QGCRhiCapture.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/QGCRhiCapture.cc deleted file mode 100644 index bf42f8a2fc51..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/QGCRhiCapture.cc +++ /dev/null @@ -1,72 +0,0 @@ -#include "QGCRhiCapture.h" - -#include "QGCApplication.h" - -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) || defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH) -#include "GstContextBridgeRegistry.h" -#endif - -#include -#include - -#include -#include - -namespace QGCRhiCapture { - -namespace { -std::atomic s_cachedRhi{nullptr}; -QPointer s_connectedWindow; -// Stored per connection so a window swap (e.g. popout video) can disconnect the prior window's -// lambdas before they fire and clobber the new window's cached RHI. -std::array s_connections; -} // namespace - -QRhi *qrhi() -{ - QGCApplication *app = qgcApp(); - if (!app) return nullptr; - QQuickWindow *win = app->mainRootWindow(); - if (!win) return nullptr; - return win->rhi(); -} - -QRhi *cachedRhi() noexcept -{ - return s_cachedRhi.load(std::memory_order_acquire); -} - -void connectWindow(QQuickWindow *window) -{ - if (!window) return; - if (s_connectedWindow == window) return; // idempotent — avoid duplicate connections - - // Detach prior window's signals before binding the new one — otherwise the old window's - // destroyed/invalidated lambdas would later wipe cachedRhi() and reset bridges that the - // new window is actively using. - for (auto &conn : s_connections) { - QObject::disconnect(conn); - conn = QMetaObject::Connection(); - } - - s_connectedWindow = window; - s_connections[0] = QObject::connect(window, &QQuickWindow::sceneGraphInitialized, window, [window]() { - s_cachedRhi.store(window->rhi(), std::memory_order_release); - }, Qt::DirectConnection); - s_connections[1] = QObject::connect(window, &QQuickWindow::sceneGraphInvalidated, window, []() { - s_cachedRhi.store(nullptr, std::memory_order_release); -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) || defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH) - // Drop bridge-cached GPU devices that wrap the now-defunct QRhi-owned device. - GstContextBridgeRegistry::resetAllBridges(); -#endif - }, Qt::DirectConnection); - // Clear cache when window is destroyed so a stale QRhi* is never returned. - s_connections[2] = QObject::connect(window, &QQuickWindow::destroyed, window, [](QObject *) { - s_cachedRhi.store(nullptr, std::memory_order_release); -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) || defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH) - GstContextBridgeRegistry::resetAllBridges(); -#endif - }, Qt::DirectConnection); -} - -} // namespace QGCRhiCapture diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/QGCRhiCapture.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/QGCRhiCapture.h deleted file mode 100644 index 3305b14a2a10..000000000000 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/QGCRhiCapture.h +++ /dev/null @@ -1,34 +0,0 @@ -#pragma once - -#include -#include - -#include - -class QRhi; -class QQuickWindow; - -/// Resolves the live `QRhi*` driving QGC's main scene graph so platform-specific -/// GPU-handoff code (D3D11 device sharing, Metal device matching) can target the -/// same device QRhi is rendering on. -/// -/// Qt does not expose a global RHI accessor — `QQuickWindow::rhi()` is the only -/// public API and requires a window with an initialized scene graph. This helper -/// walks `QGCApplication::mainRootWindow()` lazily on each call. -namespace QGCRhiCapture { - -/// Returns the QRhi for QGC's main QQuickWindow once its scene graph is up. -/// Safe to call from the GUI thread or the render thread (QQuickWindow::rhi() -/// is documented safe on both). Returns nullptr if the window doesn't exist -/// yet, isn't initialized, or QGC is running headless. -QRhi *qrhi(); - -/// Thread-safe snapshot populated from sceneGraphInitialized and cleared on -/// sceneGraphInvalidated. Safe to read from any thread via acquire ordering. -QRhi *cachedRhi() noexcept; - -/// Call once from the GUI thread after the main QQuickWindow is available. -/// Connects sceneGraphInitialized / sceneGraphInvalidated to maintain cachedRhi(). -void connectWindow(QQuickWindow *window); - -} // namespace QGCRhiCapture diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/android/GstAHardwareBufferVideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/android/GstAHardwareBufferVideoBuffer.cc new file mode 100644 index 000000000000..c5d0ee9bdea5 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/android/GstAHardwareBufferVideoBuffer.cc @@ -0,0 +1,465 @@ +#include "GstAHardwareBufferVideoBuffer.h" + +#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "GstContextBridgeRegistry.h" +#include "GstEglHelpers.h" +#include "GstHwFrameTexturesBase.h" +#include "GstHwImportPreflight.h" +#include "GstHwPathTelemetry.h" +#include "GstHwVideoBuffer.h" +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(GstAHWBufLog, "Video.GStreamer.HwBuffers.GstAHWBuf") + +namespace { + +using GstHw::kMaxPlanes; +constexpr int kImageCacheCapacity = 4; + +std::atomic s_loggedBadBackend{false}; + +constexpr const char* kNativeBufferExt = "EGL_ANDROID_image_native_buffer"; + +std::atomic s_loggedNoFenceSync{false}; + +// LRU cache (AHardwareBuffer*, EGLDisplay) -> (EGLImage, GL texture): MediaCodec recycles the same AHB so the import +// stays valid, saving ~50-200 us/frame on Mali/Adreno. +struct AhbCacheEntry +{ + QRhi* rhi = nullptr; + AHardwareBuffer* ahb = nullptr; + EGLDisplay display = EGL_NO_DISPLAY; + EGLImageKHR image = EGL_NO_IMAGE_KHR; + GLuint textureName = 0; + quint64 lastUsedTick = 0; + int inUse = 0; // live FrameTextures wrappers referencing this name; never evict while > 0 +}; + +QMutex s_imageCacheMutex; +std::array s_imageCache{}; +quint64 s_imageCacheTick = 0; + +// Set by resetImageCache() from any thread; teardown deferred to the next mapTextures() since glDeleteTextures +// off-thread leaks and deleting a live-wrapped name is a UAF. +std::atomic s_imageCacheResetPending{false}; + +// Render-thread-only; callers must hold s_imageCacheMutex AND have Qt's GL context current. +void destroyCacheEntryLocked(AhbCacheEntry& e, PFNEGLDESTROYIMAGEKHRPROC destroyImage, QOpenGLFunctions* gl) +{ + if (e.image != EGL_NO_IMAGE_KHR && e.display != EGL_NO_DISPLAY && destroyImage) { + destroyImage(e.display, e.image); + } + if (e.textureName != 0 && gl) { + gl->glDeleteTextures(1, &e.textureName); + } + if (e.ahb) { + AHardwareBuffer_release(e.ahb); + } + e = AhbCacheEntry{}; +} + +GLuint cachedTextureNameLocked(QRhi* rhi, AHardwareBuffer* ahb, EGLDisplay eglDpy) +{ + for (std::size_t i = 0; i < s_imageCache.size(); ++i) { + auto& e = s_imageCache[i]; + if (e.rhi == rhi && e.ahb == ahb && e.display == eglDpy && e.textureName != 0) { + e.lastUsedTick = ++s_imageCacheTick; + return e.textureName; + } + } + return 0; +} + +// Insert into cache (evicts LRU when full), taking ownership of image/textureName; caller holds s_imageCacheMutex with +// GL current. False (no ownership taken) when every entry is pinned in-use. +bool insertCacheEntryLocked(QRhi* rhi, AHardwareBuffer* ahb, EGLDisplay eglDpy, EGLImageKHR image, GLuint textureName, + PFNEGLDESTROYIMAGEKHRPROC destroyImage, QOpenGLFunctions* gl) +{ + AhbCacheEntry* target = nullptr; + for (std::size_t i = 0; i < s_imageCache.size(); ++i) { + auto& e = s_imageCache[i]; + if (e.textureName == 0) { + target = &e; + break; + } + } + if (!target) { + // Evict the LRU entry that no live frame still references; in-use names would UAF on render. + for (std::size_t i = 0; i < s_imageCache.size(); ++i) { + auto& e = s_imageCache[i]; + if (e.inUse == 0 && (!target || e.lastUsedTick < target->lastUsedTick)) + target = &e; + } + if (!target) { + qCWarning(GstAHWBufLog) << "AHB texture cache full and all entries in use; skipping cache insert"; + return false; + } + destroyCacheEntryLocked(*target, destroyImage, gl); + } + AHardwareBuffer_acquire(ahb); + target->rhi = rhi; + target->ahb = ahb; + target->display = eglDpy; + target->image = image; + target->textureName = textureName; + target->lastUsedTick = ++s_imageCacheTick; + return true; +} + +void clearImageCacheLocked(PFNEGLDESTROYIMAGEKHRPROC destroyImage, QOpenGLFunctions* gl) +{ + for (std::size_t i = 0; i < s_imageCache.size(); ++i) { + auto& entry = s_imageCache[i]; + if (entry.inUse > 0) { + // Live FrameTextures still holds this name; destroying now would UAF — leak the texture/image/AHB instead. + qCWarning(GstAHWBufLog) << "AHB texture cache reset with entry still in use; leaking texture" + << entry.textureName; + entry = AhbCacheEntry{}; + continue; + } + destroyCacheEntryLocked(entry, destroyImage, gl); + } + s_imageCacheTick = 0; +} + +// Pin a cache entry by texture name so LRU eviction can't free a name a live frame holds; caller holds +// s_imageCacheMutex. Unmatched names (e.g. after a reset) are no-ops. +void acquireCacheEntryLocked(GLuint name) +{ + if (name == 0) + return; + for (std::size_t i = 0; i < s_imageCache.size(); ++i) { + auto& e = s_imageCache[i]; + if (e.textureName == name) { + ++e.inUse; + return; + } + } +} + +void releaseCacheEntryLocked(GLuint name) +{ + if (name == 0) + return; + for (std::size_t i = 0; i < s_imageCache.size(); ++i) { + auto& e = s_imageCache[i]; + if (e.textureName == name && e.inUse > 0) { + --e.inUse; + return; + } + } +} + +class FrameTextures final : public GstHwFrameTexturesBase +{ +public: + // Always single-plane external-OES; the GL texture name is owned by the process-wide AhbCache, not this object + // (shared across frames on AHB recycle). + FrameTextures(QRhi* rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, GLuint name) + : _rhi(rhi), _size(size), _pixelFormat(pixelFormat), _name(name) + { + _count = 1; + const auto* desc = QVideoTextureHelper::textureDescription(pixelFormat); + if (!desc) { + qCWarning(GstAHWBufLog) << "no QVideoTextureHelper description for format" << pixelFormat; + return; + } + const QSize planeSize = desc->rhiPlaneSize(size, 0, rhi); + // ExternalOES: bind GL_TEXTURE_EXTERNAL_OES + emit SamplerExternalOES, else OES_EGL_image_external samples + // black on GLES2. + _textures[0].reset(rhi->newTexture( + desc->rhiTextureFormat(0, rhi, QVideoTextureHelper::TextureDescription::FallbackPolicy::Disable), planeSize, + 1, QRhiTexture::ExternalOES)); + if (_textures[0] && !_textures[0]->createFrom({name, 0})) { + qCWarning(GstAHWBufLog) << "QRhiTexture::createFrom failed for AHardwareBuffer plane 0"; + _textures[0].reset(); + } + QMutexLocker lock(&s_imageCacheMutex); + acquireCacheEntryLocked(_name); + } + + ~FrameTextures() override + { + QMutexLocker lock(&s_imageCacheMutex); + releaseCacheEntryLocked(_name); + } + + HwVideoBufferPath sourcePath() const override { return HwVideoBufferPath::AHardwareBuffer; } + + // Reuse eligibility: same rhi+size+format and identical GL texture name (AhbCache returns the same name on AHB + // recycle). + bool matches(QRhi* rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, GLuint name) const noexcept + { + return _rhi == rhi && _size == size && _pixelFormat == pixelFormat && _name == name && _name != 0 && + _textures[0]; + } + +private: + QRhi* _rhi = nullptr; + QSize _size; + QVideoFrameFormat::PixelFormat _pixelFormat = QVideoFrameFormat::Format_Invalid; + GLuint _name = 0; +}; + +// MediaCodec writes the AHB asynchronously; without a producer-side wait the EGLImage import can sample a half-written, +// recycled buffer (tearing). Only the buffer's GstGLSyncMeta is a real producer fence — it carries the decoder's +// GPU-completion point. A consumer-side EGL fence (glFlush + eglCreateSync in the GL context) cannot observe +// MediaCodec's async write, so it would synchronise nothing while masking the hazard; we deliberately do not use one. +// When no sync meta is present, warn once and proceed (no bogus barrier). +void waitProducerComplete(GstBuffer* buffer) +{ + if (GstGLSyncMeta* syncMeta = buffer ? gst_buffer_get_gl_sync_meta(buffer) : nullptr) { + if (GstGLContext* glObj = syncMeta->context) { + gst_gl_sync_meta_wait(syncMeta, glObj); + return; + } + } + + QGC_HW_WARN_ONCE(GstAHWBufLog, s_loggedNoFenceSync, + "AHardwareBuffer: no GstGLSyncMeta producer fence; frames may tear on recycle (no consumer-side " + "barrier can substitute)"); +} + +} // namespace + +GstAHardwareBufferVideoBuffer::GstAHardwareBufferVideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, + const QVideoFrameFormat& format, EGLDisplay eglDisplay) + : GstHwVideoBuffer(QVideoFrame::RhiTextureHandle, sample, videoInfo, format), _eglDisplay(eglDisplay) +{} + +// The AHB is imported directly as an EGLImage (no SurfaceTexture::updateTexImage()), so no per-buffer +// getTransformMatrix() exists here; external-OES sampling of a directly-imported buffer is top-left origin while GL +// texcoords are bottom-left, so apply the static Y-flip Qt's SurfaceTexture path also applies (flipV, row-major). +QMatrix4x4 GstAHardwareBufferVideoBuffer::externalTextureMatrix() const +{ + // GStreamer exposes no per-buffer decoder transform at this layer, so we apply the static external-OES Y-flip + // (top-left vs GL bottom-left origin). If a device ever needs crop-inset/rotation, source it from GstVideoCropMeta. + static const QMatrix4x4 flipV(1.0f, 0.0f, 0.0f, 0.0f, + 0.0f, -1.0f, 0.0f, 1.0f, + 0.0f, 0.0f, 1.0f, 0.0f, + 0.0f, 0.0f, 0.0f, 1.0f); + return flipV; +} + +bool GstAHardwareBufferVideoBuffer::validatePlaneHandles() const +{ + // Qt 6.10-6.12 has the right ExternalOES rendering path, but upstream GStreamer still does not expose this + // AHardwareBuffer memory API. If a future GStreamer/Qt release grows an official API, replace these vendor hooks. + return validatePlanes([](GstMemory* mem) { + if (!mem || !gst_is_ahardware_buffer_memory(mem)) + return false; + return gst_ahardware_buffer_memory_get_buffer(GST_AHARDWARE_BUFFER_MEMORY_CAST(mem)) != nullptr; + }); +} + +// Teardown deferred to the render thread at a frame boundary — glDeleteTextures off-thread is unsafe. +void GstAHardwareBufferVideoBuffer::resetImageCache() noexcept +{ + s_imageCacheResetPending.store(true, std::memory_order_release); +} + +namespace { +struct AhbCacheResetRegistrar +{ + AhbCacheResetRegistrar() { GstContextBridgeRegistry::registerCacheReset(&GstAHardwareBufferVideoBuffer::resetImageCache); } +}; +const AhbCacheResetRegistrar s_ahbCacheResetRegistrar; +} // namespace + +QVideoFrameTexturesUPtr GstAHardwareBufferVideoBuffer::mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& old) +{ + // Qt's contract: mapTextures runs on the QRhi (render) thread. Bail rather than crash if ever called off-thread. + if (!rhi.thread()->isCurrentThread()) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + + if (!_sample) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + if (rhi.backend() != QRhi::OpenGLES2) { + if (!s_loggedBadBackend.exchange(true, std::memory_order_relaxed)) { + qCWarning(GstAHWBufLog) << "QRhi backend is not OpenGLES2; AHardwareBuffer path unsupported"; + } + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + + // Bind Qt's GL context before any EGL/GL call, else the calls silently no-op into a foreign/null context. + rhi.makeThreadLocalNativeContextCurrent(); + + const auto* nativeHandles = static_cast(rhi.nativeHandles()); + if (!nativeHandles || !nativeHandles->context) { + qCWarning(GstAHWBufLog) << "QRhi exposes no GL context"; + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + + // Prefer QEGLContext::display() — sampling on a foreign display silently returns black. + EGLDisplay eglDpy = GstEglHelpers::resolveEglDisplay(nativeHandles->context); + if (eglDpy == EGL_NO_DISPLAY) + eglDpy = _eglDisplay; + if (eglDpy == EGL_NO_DISPLAY) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + + GstBuffer* buffer = gst_sample_get_buffer(_sample); + if (!buffer) + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + + GstMemory* mem0 = gst_buffer_peek_memory(buffer, 0); + if (!mem0 || !gst_is_ahardware_buffer_memory(mem0)) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + + if (!GstEglHelpers::displaySupportsExtension(eglDpy, kNativeBufferExt)) { + static std::atomic s_warnedNativeBufferExt{false}; + if (!s_warnedNativeBufferExt.exchange(true, std::memory_order_relaxed)) { + qCWarning(GstAHWBufLog) << "EGL_ANDROID_image_native_buffer unavailable; AHardwareBuffer path disabled"; + } + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + + static const auto eglGetNativeClientBufferANDROID_ = + reinterpret_cast(eglGetProcAddress("eglGetNativeClientBufferANDROID")); + static const auto eglCreateImageKHR_ = + reinterpret_cast(eglGetProcAddress("eglCreateImageKHR")); + static const auto eglDestroyImageKHR_ = + reinterpret_cast(eglGetProcAddress("eglDestroyImageKHR")); + static const auto glEGLImageTargetTexture2DOES_ = + reinterpret_cast(eglGetProcAddress("glEGLImageTargetTexture2DOES")); + + if (!eglGetNativeClientBufferANDROID_ || !eglCreateImageKHR_ || !eglDestroyImageKHR_ || + !glEGLImageTargetTexture2DOES_) { + qCWarning(GstAHWBufLog) << "Required EGL/GL proc addresses unavailable"; + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + + AHardwareBuffer* ahwb = gst_ahardware_buffer_memory_get_buffer(GST_AHARDWARE_BUFFER_MEMORY_CAST(mem0)); + if (!ahwb) { + qCWarning(GstAHWBufLog) << "gst_ahardware_buffer_memory_get_buffer returned null"; + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + + // Block until the producer's GPU write completes before importing/sampling the recycled AHB. + waitProducerComplete(buffer); + + QOpenGLFunctions functions(nativeHandles->context); + + // Clearing forces a lookup miss so the prior `old` textures aren't reused over freed GL names. + { + QMutexLocker lock(&s_imageCacheMutex); + if (s_imageCacheResetPending.exchange(false, std::memory_order_acq_rel)) { + clearImageCacheLocked(eglDestroyImageKHR_, &functions); + } + } + + GLuint name = 0; + { + QMutexLocker lock(&s_imageCacheMutex); + name = cachedTextureNameLocked(&rhi, ahwb, eglDpy); + if (name != 0) { + acquireCacheEntryLocked(name); // provisional pin: hold the entry across the unlocked gap below + GstHwPathTelemetry::recordImageCacheHit(HwVideoBufferPath::AHardwareBuffer); + } + } + + if (name == 0) { + GstHwPathTelemetry::recordImageCacheMiss(HwVideoBufferPath::AHardwareBuffer); + + EGLClientBuffer clientBuffer = eglGetNativeClientBufferANDROID_(ahwb); + if (!clientBuffer) { + qCWarning(GstAHWBufLog) << "eglGetNativeClientBufferANDROID returned null"; + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + + const EGLint attribs[] = {EGL_NONE}; + EGLImageKHR image = + eglCreateImageKHR_(eglDpy, EGL_NO_CONTEXT, EGL_NATIVE_BUFFER_ANDROID, clientBuffer, attribs); + if (image == EGL_NO_IMAGE_KHR) { + qCWarning(GstAHWBufLog) << "eglCreateImageKHR failed, err=" << Qt::hex << eglGetError(); + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + + functions.glGenTextures(1, &name); + functions.glBindTexture(GL_TEXTURE_EXTERNAL_OES, name); + glEGLImageTargetTexture2DOES_(GL_TEXTURE_EXTERNAL_OES, image); + if (Q_UNLIKELY(GstAHWBufLog().isDebugEnabled())) { + if (const GLenum glErr = functions.glGetError(); glErr != GL_NO_ERROR) { + qCDebug(GstAHWBufLog) << "glEGLImageTargetTexture2DOES glError=" << Qt::hex << glErr + << "eglError=" << eglGetError(); + } + } + + QMutexLocker lock(&s_imageCacheMutex); + // Real race: s_imageCache is process-wide and each window's render thread can import the same recycled ahb, so + // re-check and reuse theirs if present. + if (GLuint existing = cachedTextureNameLocked(&rhi, ahwb, eglDpy); existing != 0) { + eglDestroyImageKHR_(eglDpy, image); + functions.glDeleteTextures(1, &name); + name = existing; + } else if (!insertCacheEntryLocked(&rhi, ahwb, eglDpy, image, name, eglDestroyImageKHR_, &functions)) { + // Cache full and every entry pinned: insert took no ownership, so free what we made here. + eglDestroyImageKHR_(eglDpy, image); + functions.glDeleteTextures(1, &name); + name = 0; + } + acquireCacheEntryLocked(name); // provisional pin (no-op if name == 0) + } + + if (name == 0) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + + // Provisional pin keeps LRU eviction from freeing the name before FrameTextures takes its own pin. + const auto pinGuard = qScopeGuard([&] { + const QMutexLocker lock(&s_imageCacheMutex); + releaseCacheEntryLocked(name); + }); + + if (auto* prev = GstHwFrameTexturesBase::reusableBundle(old, HwVideoBufferPath::AHardwareBuffer)) { + if (prev->matches(&rhi, _format.frameSize(), QVideoFrameFormat::Format_SamplerExternalOES, name)) { + GstHwPathTelemetry::recordTextureReuse(HwVideoBufferPath::AHardwareBuffer); + prev->setSourceSample(takeSample()); + QVideoFrameTexturesUPtr reused = std::move(old); + return reused; + } + } + + // Pre-flight the external-OES RHI format/size before createFrom() so an unsupported import demotes to CPU on a query. + if (!GstHwImportPreflight::preflightOrRecord(&rhi, HwVideoBufferPath::AHardwareBuffer, + QVideoFrameFormat::Format_SamplerExternalOES, _format.frameSize(), + QRhiTexture::ExternalOES)) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + + auto textures = + std::make_unique(&rhi, _format.frameSize(), QVideoFrameFormat::Format_SamplerExternalOES, name); + if (!textures->texture(0)) { + qCWarning(GstAHWBufLog) << "createFrom failed for plane 0 (SamplerExternalOES)"; + return GstHwPathTelemetry::fail(HwVideoBufferPath::AHardwareBuffer); + } + textures->setSourceSample(takeSample()); + return textures; +} + +#endif // QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/android/GstAHardwareBufferVideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/android/GstAHardwareBufferVideoBuffer.h new file mode 100644 index 000000000000..2bc556a10858 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/android/GstAHardwareBufferVideoBuffer.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + +#include +#include + +#include "GstHwVideoBuffer.h" + +class QRhi; + +/// \brief Zero-copy QVideoFrame backing for GStreamer AHardwareBuffer samples (Android); imports via +/// eglGetNativeClientBufferANDROID + eglCreateImageKHR (needs EGL_ANDROID_image_native_buffer). +class GstAHardwareBufferVideoBuffer final : public GstHwVideoBuffer +{ +public: + GstAHardwareBufferVideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, const QVideoFrameFormat& format, + EGLDisplay eglDisplay); + + QVideoFrameTexturesUPtr mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& oldTextures) override; + bool validatePlaneHandles() const override; + + /// External-OES texcoord transform consumed by qvideotexturehelper for Format_SamplerExternalOES. + QMatrix4x4 externalTextureMatrix() const override; + + const char* storageTag() const override { return "AHardwareBuffer"; } + + static void resetImageCache() noexcept; + +private: + EGLDisplay _eglDisplay = EGL_NO_DISPLAY; +}; + +#endif // QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/apple/GstIOSurfaceVideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/apple/GstIOSurfaceVideoBuffer.h new file mode 100644 index 000000000000..ed7eece5823b --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/apple/GstIOSurfaceVideoBuffer.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +#if (defined(Q_OS_MACOS) || defined(Q_OS_IOS)) && defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) + +#include "GstHwVideoBuffer.h" + +class QRhi; + +/// \brief Wraps a GstAppleCoreVideoMemory/IOSurface-backed sample as a QHwVideoBuffer; samples natively on QRhi::Metal. +class GstIOSurfaceVideoBuffer final : public GstHwVideoBuffer +{ +public: + GstIOSurfaceVideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, const QVideoFrameFormat& format); + + QVideoFrameTexturesUPtr mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& oldTextures) override; + bool validatePlaneHandles() const override; + + const char* storageTag() const override { return "IOSurface"; } + + static void resetTextureCache() noexcept; +}; + +#endif // (Q_OS_MACOS || Q_OS_IOS) && QGC_HAS_GST_IOSURFACE_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstIOSurfaceVideoBuffer.mm b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/apple/GstIOSurfaceVideoBuffer.mm similarity index 53% rename from src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstIOSurfaceVideoBuffer.mm rename to src/VideoManager/VideoReceiver/GStreamer/HwBuffers/apple/GstIOSurfaceVideoBuffer.mm index 9b050a516434..983fbfce3e60 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/GstIOSurfaceVideoBuffer.mm +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/apple/GstIOSurfaceVideoBuffer.mm @@ -1,5 +1,8 @@ #include "GstIOSurfaceVideoBuffer.h" +#include "GstContextBridgeRegistry.h" #include "GstHwVideoBuffer.h" +#include "GstHwFrameTexturesBase.h" +#include "GstHwPathTelemetry.h" #if (defined(Q_OS_MACOS) || defined(Q_OS_IOS)) && defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) @@ -13,6 +16,10 @@ #include #include +#if defined(__has_include) && __has_include() +#define QGC_HAS_PUBLIC_CORE_VIDEO_META 1 +#include +#endif #include #include @@ -24,27 +31,27 @@ #include #include #include +#include QGC_LOGGING_CATEGORY(GstIOSurfaceLog, "Video.GStreamer.HwBuffers.GstIOSurface") namespace { -constexpr int kMaxPlanes = 4; - -std::atomic s_mapFailureCount{0}; -std::atomic s_loggedFirstSuccess{false}; -// One-shot per failure cause; teardown emits the running counter. -std::atomic s_loggedNullSample{false}; -std::atomic s_loggedBadBackend{false}; -std::atomic s_loggedNullBuffer{false}; -std::atomic s_loggedNoMemory{false}; -std::atomic s_loggedPixelBufferExtractFail{false}; -std::atomic s_loggedNoIOSurface{false}; -std::atomic s_loggedNoMtlDevice{false}; -std::atomic s_loggedUnsupportedFormat{false}; -std::atomic s_loggedTextureCreateFail{false}; -std::atomic s_loggedRhiCreateFromFail{false}; -std::atomic s_loggedCacheCreateFail{false}; +using GstHw::kMaxPlanes; + +// One-shot per failure cause; teardown emits the running counter. Shared causes come from +// GstHw::MapDiagnostics; the remainder are IOSurface/Metal-specific. +struct IOSurfaceDiagnostics : GstHw::MapDiagnostics +{ + std::atomic loggedNoMemory{false}; + std::atomic loggedPixelBufferExtractFail{false}; + std::atomic loggedNoIOSurface{false}; + std::atomic loggedNoMtlDevice{false}; + std::atomic loggedUnsupportedFormat{false}; + std::atomic loggedRhiCreateFromFail{false}; + std::atomic loggedCacheCreateFail{false}; +}; +IOSurfaceDiagnostics s_diag; // Process-wide CVMetalTextureCache, keyed by MTLDevice*. Apple's documented bridge between // CVPixelBuffer/IOSurface and Metal — reuses MTLTexture objects when the decoder recycles @@ -73,7 +80,7 @@ CVMetalTextureCacheRef ensureCacheLocked(id device) const CVReturn r = CVMetalTextureCacheCreate( kCFAllocatorDefault, nullptr, device, nullptr, &cache); if (r != kCVReturnSuccess || !cache) { - if (!s_loggedCacheCreateFail.exchange(true, std::memory_order_relaxed)) { + if (!s_diag.loggedCacheCreateFail.exchange(true, std::memory_order_relaxed)) { qCWarning(GstIOSurfaceLog) << "CVMetalTextureCacheCreate failed (CVReturn=" << int(r) << ")"; } return nullptr; @@ -83,16 +90,7 @@ CVMetalTextureCacheRef ensureCacheLocked(id device) return cache; } -QVideoFrameTexturesUPtr fail() -{ - s_mapFailureCount.fetch_add(1, std::memory_order_relaxed); - return {}; -} -#define WARN_ONCE(flag, ...) \ - do { if (!(flag).exchange(true, std::memory_order_relaxed)) { \ - qCWarning(GstIOSurfaceLog) << __VA_ARGS__; \ - } } while (0) MTLPixelFormat metalPixelFormatForPlane(QVideoFrameFormat::PixelFormat fmt, int plane) { @@ -115,19 +113,22 @@ MTLPixelFormat metalPixelFormatForPlane(QVideoFrameFormat::PixelFormat fmt, int } } -class FrameTextures final : public QVideoFrameTextures +class FrameTextures final : public GstHwFrameTexturesBase { public: FrameTextures(QRhi *rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, std::array, kMaxPlanes> mtex, std::array cvtex, int count) - : _count(count) + : _rhi(rhi) + , _size(size) + , _pixelFormat(pixelFormat) , _mtex(mtex) , _cvtex(cvtex) { + _count = count; const auto *desc = QVideoTextureHelper::textureDescription(pixelFormat); if (!desc) { - WARN_ONCE(s_loggedTextureCreateFail, + QGC_HW_WARN_ONCE(GstIOSurfaceLog, s_diag.loggedTextureCreateFail, "FrameTextures: QVideoTextureHelper has no description for format" << int(pixelFormat)); return; } @@ -136,7 +137,7 @@ MTLPixelFormat metalPixelFormatForPlane(QVideoFrameFormat::PixelFormat fmt, int _textures[i].reset(rhi->newTexture(desc->rhiTextureFormat(i, rhi), planeSize, 1, {})); const quint64 handle = reinterpret_cast(static_cast(mtex[i])); if (_textures[i] && !_textures[i]->createFrom({handle, 0})) { - WARN_ONCE(s_loggedRhiCreateFromFail, + QGC_HW_WARN_ONCE(GstIOSurfaceLog, s_diag.loggedRhiCreateFromFail, "FrameTextures: QRhiTexture::createFrom failed for plane" << i << "(planeSize=" << planeSize << ")"); _textures[i].reset(); @@ -146,26 +147,63 @@ MTLPixelFormat metalPixelFormatForPlane(QVideoFrameFormat::PixelFormat fmt, int ~FrameTextures() override { - // Release CVMetalTextureRefs first — each holds the underlying MTLTexture and the - // CVPixelBuffer's IOSurface refcount; releasing returns them to the cache for reuse. + // CVMetalTextureRefs hold the underlying MTLTexture and the CVPixelBuffer's IOSurface + // refcount; releasing returns them to the cache for reuse. Base dtor clears _srcSample. for (int i = 0; i < _count; ++i) { if (_cvtex[i]) CFRelease(_cvtex[i]); _mtex[i] = nil; } } - QRhiTexture *texture(uint plane) const override + void onFrameEndInvoked() override { - return (int(plane) < _count) ? _textures[plane].get() : nullptr; + // Release CVMetalTextureRefs eagerly to return IOSurface pool slots; defer to dtor risks pool pressure. + for (int i = 0; i < _count; ++i) { + if (_cvtex[i]) { CFRelease(_cvtex[i]); _cvtex[i] = nullptr; } + } + GstHwFrameTexturesBase::onFrameEndInvoked(); + } + + HwVideoBufferPath sourcePath() const override { return HwVideoBufferPath::IOSurface; } + + // Reuse eligibility: identical rhi+size+format and identical, non-nil MTLTexture handles + // per plane. VideoToolbox recycles CVPixelBuffer/IOSurface across frames; the + // CVMetalTextureCache returns the same MTLTexture when the underlying IOSurface repeats, + // so the QRhiTexture wrapper can be reused with new sample data. + bool matches(QRhi *rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, + const std::array, kMaxPlanes> &mtex, int count) const + { + if (_rhi != rhi || _size != size || _pixelFormat != pixelFormat || _count != count) { + return false; + } + for (int i = 0; i < _count; ++i) { + if (_mtex[i] == nil || _mtex[i] != mtex[i] || !_textures[i]) { + return false; + } + } + return true; + } + + // Replace the per-plane CVMetalTextureRef holders with fresh ones (transferring the + // IOSurface ref). The underlying MTLTexture handles are unchanged so wrappers stay valid. + void adoptCvRefs(std::array &cvtex) + { + for (int i = 0; i < _count; ++i) { + if (_cvtex[i]) CFRelease(_cvtex[i]); + _cvtex[i] = cvtex[i]; + cvtex[i] = nullptr; + } } private: - int _count = 0; + QRhi *_rhi = nullptr; + QSize _size; + QVideoFrameFormat::PixelFormat _pixelFormat = QVideoFrameFormat::Format_Invalid; std::array, kMaxPlanes> _mtex; std::array _cvtex; - std::unique_ptr _textures[kMaxPlanes]; }; + } // namespace GstIOSurfaceVideoBuffer::GstIOSurfaceVideoBuffer(GstSample *sample, @@ -175,86 +213,95 @@ MTLPixelFormat metalPixelFormatForPlane(QVideoFrameFormat::PixelFormat fmt, int { } -GstIOSurfaceVideoBuffer::~GstIOSurfaceVideoBuffer() = default; - -QAbstractVideoBuffer::MapData GstIOSurfaceVideoBuffer::map(QVideoFrame::MapMode /*mode*/) +bool GstIOSurfaceVideoBuffer::validatePlaneHandles() const { - return {}; + return validatePlanes([](GstMemory *mem) { + if (!mem || !mem->allocator) return false; + return g_strcmp0(mem->allocator->mem_type, "AppleCoreVideoMemory") == 0; + }); } -bool GstIOSurfaceVideoBuffer::validatePlaneHandles() const +void GstIOSurfaceVideoBuffer::resetTextureCache() noexcept { - if (!_sample) return false; - GstBuffer *buffer = gst_sample_get_buffer(_sample); - if (!buffer) return false; - const int memCount = qMin(int(gst_buffer_n_memory(buffer)), kMaxPlanes); - if (memCount <= 0) return false; - for (int i = 0; i < memCount; ++i) { - GstMemory *mem = gst_buffer_peek_memory(buffer, i); - if (!mem || !mem->allocator) return false; - if (g_strcmp0(mem->allocator->mem_type, "AppleCoreVideoMemory") != 0) return false; + QMutexLocker lock(&s_cacheMutex); + if (s_cache.cache) { + CFRelease(s_cache.cache); } - return true; + s_cache = MetalTextureCache{}; } -QVideoFrameTexturesUPtr GstIOSurfaceVideoBuffer::mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr & /*old*/) +namespace { +struct IOSurfaceCacheResetRegistrar { - Q_ASSERT(rhi.thread()->isCurrentThread()); // Qt's contract: mapTextures runs on the QRhi (render) thread. + IOSurfaceCacheResetRegistrar() { GstContextBridgeRegistry::registerCacheReset(&GstIOSurfaceVideoBuffer::resetTextureCache); } +}; +const IOSurfaceCacheResetRegistrar s_ioSurfaceCacheResetRegistrar; +} // namespace + +QVideoFrameTexturesUPtr GstIOSurfaceVideoBuffer::mapTextures(QRhi &rhi, QVideoFrameTexturesUPtr &old) +{ + // Qt's contract: mapTextures runs on the QRhi (render) thread. Bail rather than crash if ever called off-thread. + if (!rhi.thread()->isCurrentThread()) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::IOSurface); + } // *** UNTESTED on macOS hardware. CI compiles this path; runtime validation TBD. *** // Pulls a CVPixelBufferRef from the GstBuffer (gst-vt path), grabs its // IOSurfaceRef, imports each plane via [MTLDevice newTextureWithDescriptor: // iosurface:plane:], and wraps each MTLTexture in a QRhiTexture. // IOSurface is device-agnostic so no shared-MTLDevice bridge is required. - if (!_sample) { - WARN_ONCE(s_loggedNullSample, "mapTextures: GstSample is null"); - return fail(); - } - if (rhi.backend() != QRhi::Metal) { - WARN_ONCE(s_loggedBadBackend, - "mapTextures: QRhi backend is" << rhi.backendName() << "(Metal required)"); - return fail(); + GstBuffer *buffer = nullptr; + if (!checkMapPreconditions(rhi, static_cast(QRhi::Metal), GstIOSurfaceLog(), s_diag, buffer)) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::IOSurface); } - GstBuffer *buffer = gst_sample_get_buffer(_sample); - if (!buffer) { - WARN_ONCE(s_loggedNullBuffer, "mapTextures: GstSample has no buffer"); - return fail(); + // Prefer gst-applemedia's public GstCoreVideoMeta when its header is installed (included at file top); otherwise + // fall back to the hand-mirrored layout (gst-plugins-bad <= 1.28 ships no public applemedia header), verified + // against upstream coremediabuffer.h: { GstMeta meta; GstBuffer *buf; CVBufferRef cvbuf; CVPixelBufferRef pixbuf; }. +#if defined(QGC_HAS_PUBLIC_CORE_VIDEO_META) + using _QgcCoreVideoMeta = GstCoreVideoMeta; +#else + struct _QgcCoreVideoMeta { GstMeta meta; GstBuffer *buf; CVBufferRef cvbuf; CVPixelBufferRef pixbuf; }; + // Punning relies on cvbuf/pixbuf sitting right after the GstMeta+GstBuffer* prefix; a layout drift here would read + // garbage CFTypeRefs, so fail the build loudly instead. + static_assert(sizeof(_QgcCoreVideoMeta) == sizeof(GstMeta) + sizeof(GstBuffer *) + 2 * sizeof(CVBufferRef), + "GstCoreVideoMeta layout drift — re-check gst-plugins-bad coremediabuffer.h"); + static_assert(offsetof(_QgcCoreVideoMeta, pixbuf) > offsetof(_QgcCoreVideoMeta, cvbuf), + "GstCoreVideoMeta pixbuf must follow cvbuf"); +#endif + static std::atomic s_coreVideoMetaApiType{0}; + GType coreVideoMetaApiType = s_coreVideoMetaApiType.load(std::memory_order_acquire); + if (G_UNLIKELY(coreVideoMetaApiType == 0)) { + coreVideoMetaApiType = g_type_from_name("GstCoreVideoMetaAPI"); + s_coreVideoMetaApiType.store(coreVideoMetaApiType, std::memory_order_release); } - - // gst/applemedia/applemedia.h is not installed in gst-plugins-bad 1.28; access the - // CVPixelBufferRef through GstCoreVideoMeta which vtdec attaches to every output buffer. - struct _QgcCoreVideoMeta { GstMeta meta; CVBufferRef cvbuf; CVPixelBufferRef pixbuf; }; - static GType s_coreVideoMetaApiType = 0; - if (G_UNLIKELY(s_coreVideoMetaApiType == 0)) - s_coreVideoMetaApiType = g_type_from_name("GstCoreVideoMetaAPI"); auto *cvMeta = reinterpret_cast<_QgcCoreVideoMeta *>( - s_coreVideoMetaApiType ? gst_buffer_get_meta(buffer, s_coreVideoMetaApiType) : nullptr); + coreVideoMetaApiType ? gst_buffer_get_meta(buffer, coreVideoMetaApiType) : nullptr); if (!cvMeta) { - WARN_ONCE(s_loggedNoMemory, "mapTextures: GstCoreVideoMeta not found on buffer"); - return fail(); + QGC_HW_WARN_ONCE(GstIOSurfaceLog, s_diag.loggedNoMemory, "mapTextures: GstCoreVideoMeta not found on buffer"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::IOSurface); } CVPixelBufferRef pixelBuffer = cvMeta->pixbuf; if (!pixelBuffer) { - WARN_ONCE(s_loggedPixelBufferExtractFail, + QGC_HW_WARN_ONCE(GstIOSurfaceLog, s_diag.loggedPixelBufferExtractFail, "mapTextures: GstCoreVideoMeta.pixbuf is null"); - return fail(); + return GstHwPathTelemetry::fail(HwVideoBufferPath::IOSurface); } CFRetain(pixelBuffer); IOSurfaceRef ioSurface = CVPixelBufferGetIOSurface(pixelBuffer); if (!ioSurface) { CFRelease(pixelBuffer); - WARN_ONCE(s_loggedNoIOSurface, "mapTextures: CVPixelBufferGetIOSurface returned null " + QGC_HW_WARN_ONCE(GstIOSurfaceLog, s_diag.loggedNoIOSurface, "mapTextures: CVPixelBufferGetIOSurface returned null " "(non-IOSurface-backed CVPixelBuffer?)"); - return fail(); + return GstHwPathTelemetry::fail(HwVideoBufferPath::IOSurface); } const auto *nh = static_cast(rhi.nativeHandles()); if (!nh || !nh->dev) { CFRelease(pixelBuffer); - WARN_ONCE(s_loggedNoMtlDevice, "mapTextures: QRhiMetalNativeHandles missing MTLDevice"); - return fail(); + QGC_HW_WARN_ONCE(GstIOSurfaceLog, s_diag.loggedNoMtlDevice, "mapTextures: QRhiMetalNativeHandles missing MTLDevice"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::IOSurface); } id device = (__bridge id)nh->dev; @@ -264,15 +311,12 @@ MTLPixelFormat metalPixelFormatForPlane(QVideoFrameFormat::PixelFormat fmt, int return n == 0 ? 1 : int(n); // non-planar IOSurfaces report 0 planes }(); if (gstPlaneCount != ioSurfacePlaneCount) { - static bool s_warnedPlaneMismatch = false; - if (!s_warnedPlaneMismatch) { - s_warnedPlaneMismatch = true; - qCWarning(GstIOSurfaceLog) << "GstVideoInfo plane count" << gstPlaneCount - << "differs from IOSurface plane count" << ioSurfacePlaneCount - << "— using minimum"; - } + static std::atomic s_warnedPlaneMismatch{false}; + QGC_HW_WARN_ONCE(GstIOSurfaceLog, s_warnedPlaneMismatch, "GstVideoInfo plane count" << gstPlaneCount + << "differs from IOSurface plane count" << ioSurfacePlaneCount + << "— using minimum"); } - const int planeCount = std::min({gstPlaneCount, ioSurfacePlaneCount, kMaxPlanes}); + const int planeCount = (std::min)({gstPlaneCount, ioSurfacePlaneCount, kMaxPlanes}); CVMetalTextureCacheRef cache = nullptr; { QMutexLocker lock(&s_cacheMutex); @@ -283,7 +327,7 @@ MTLPixelFormat metalPixelFormatForPlane(QVideoFrameFormat::PixelFormat fmt, int } if (!cache) { CFRelease(pixelBuffer); - return fail(); + return GstHwPathTelemetry::fail(HwVideoBufferPath::IOSurface); } std::array, kMaxPlanes> mtex{}; @@ -294,12 +338,12 @@ MTLPixelFormat metalPixelFormatForPlane(QVideoFrameFormat::PixelFormat fmt, int mtex[j] = nil; } CFRelease(pixelBuffer); - return fail(); + return GstHwPathTelemetry::fail(HwVideoBufferPath::IOSurface); }; for (int i = 0; i < planeCount; ++i) { const MTLPixelFormat fmt = metalPixelFormatForPlane(_format.pixelFormat(), i); if (fmt == MTLPixelFormatInvalid) { - WARN_ONCE(s_loggedUnsupportedFormat, + QGC_HW_WARN_ONCE(GstIOSurfaceLog, s_diag.loggedUnsupportedFormat, "mapTextures: unsupported pixel format" << int(_format.pixelFormat()) << "for plane" << i); return cleanupAndFail(i); @@ -313,7 +357,7 @@ MTLPixelFormat metalPixelFormatForPlane(QVideoFrameFormat::PixelFormat fmt, int kCFAllocatorDefault, cache, pixelBuffer, nullptr, fmt, w, h, NSUInteger(i), &ct); if (r != kCVReturnSuccess || !ct) { - WARN_ONCE(s_loggedTextureCreateFail, + QGC_HW_WARN_ONCE(GstIOSurfaceLog, s_diag.loggedTextureCreateFail, "mapTextures: CVMetalTextureCacheCreateTextureFromImage failed for plane" << i << "(format=" << int(fmt) << "size=" << QSize(w, h) << "CVReturn=" << int(r) << ")"); @@ -322,7 +366,7 @@ MTLPixelFormat metalPixelFormatForPlane(QVideoFrameFormat::PixelFormat fmt, int id t = CVMetalTextureGetTexture(ct); if (!t) { CFRelease(ct); - WARN_ONCE(s_loggedTextureCreateFail, + QGC_HW_WARN_ONCE(GstIOSurfaceLog, s_diag.loggedTextureCreateFail, "mapTextures: CVMetalTextureGetTexture returned nil for plane" << i); return cleanupAndFail(i); } @@ -332,6 +376,16 @@ MTLPixelFormat metalPixelFormatForPlane(QVideoFrameFormat::PixelFormat fmt, int CFRelease(pixelBuffer); + if (auto *prev = GstHwFrameTexturesBase::reusableBundle(old, HwVideoBufferPath::IOSurface)) { + if (prev->matches(&rhi, _format.frameSize(), _format.pixelFormat(), mtex, planeCount)) { + GstHwPathTelemetry::recordTextureReuse(HwVideoBufferPath::IOSurface); + prev->adoptCvRefs(cvtex); + prev->setSourceSample(takeSample()); + QVideoFrameTexturesUPtr reused = std::move(old); + return reused; + } + } + auto textures = std::make_unique(&rhi, _format.frameSize(), _format.pixelFormat(), mtex, cvtex, planeCount); // Per-plane: NV12 chroma can fail while luma succeeds. Returning a partial bundle @@ -340,26 +394,16 @@ MTLPixelFormat metalPixelFormatForPlane(QVideoFrameFormat::PixelFormat fmt, int if (!textures->texture(static_cast(i))) { qCWarning(GstIOSurfaceLog) << "createFrom failed for plane" << i << "format=" << int(_format.pixelFormat()); - return fail(); + return GstHwPathTelemetry::fail(HwVideoBufferPath::IOSurface); } } - if (!s_loggedFirstSuccess.exchange(true, std::memory_order_relaxed)) { - qCInfo(GstIOSurfaceLog) << "First IOSurface zero-copy mapTextures success: size=" - << _format.frameSize() << "format=" << int(_format.pixelFormat()) - << "planes=" << planeCount; - } + logFirstSuccess(s_diag.loggedFirstSuccess, GstIOSurfaceLog(), "IOSurface", _format.frameSize(), + _format.pixelFormat(), planeCount); + textures->setSourceSample(takeSample()); return textures; } -quint64 GstIOSurfaceVideoBuffer::takeMapFailureCount() -{ - return s_mapFailureCount.exchange(0, std::memory_order_relaxed); -} -quint64 GstIOSurfaceVideoBuffer::peekMapFailureCount() -{ - return s_mapFailureCount.load(std::memory_order_relaxed); -} #endif // (Q_OS_MACOS || Q_OS_IOS) && QGC_HAS_GST_IOSURFACE_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/CpuVideoFramePool.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/CpuVideoFramePool.cc new file mode 100644 index 000000000000..93bd183364e2 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/CpuVideoFramePool.cc @@ -0,0 +1,330 @@ +#include "CpuVideoFramePool.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "QGCLoggingCategory.h" +#if defined(QGC_HAS_ANY_GPU_PATH) +#include + +#include "QGCRhiCapture.h" +#endif + +QGC_LOGGING_CATEGORY(CpuVideoFramePoolLog, "Video.GStreamer.HwBuffers.CpuPool") + +namespace { + +class PooledCpuVideoBuffer final : public QAbstractVideoBuffer +{ +public: + PooledCpuVideoBuffer(QVideoFrameFormat format, CpuVideoFramePool::PlaneLayout layout, QByteArray backing) + : _format(std::move(format)), _layout(layout), _backing(std::move(backing)) + {} + + ~PooledCpuVideoBuffer() override + { + if (!_backing.isEmpty()) { + CpuVideoFramePool::instance().releaseBacking(_format.frameSize(), _format.pixelFormat(), + std::move(_backing)); + } + } + + MapData map(QVideoFrame::MapMode mode) override + { + if (mode == QVideoFrame::NotMapped) { + return {}; + } + MapData data; + data.planeCount = _layout.planeCount; + uchar* base = reinterpret_cast(_backing.data()); + for (int i = 0; i < _layout.planeCount; ++i) { + data.data[i] = base + _layout.planeOffset[i]; + data.bytesPerLine[i] = _layout.bytesPerLine[i]; + data.dataSize[i] = static_cast(_layout.bytesPerLine[i]) * _layout.height[i]; + } + return data; + } + + QVideoFrameFormat format() const override { return _format; } + +private: + QVideoFrameFormat _format; + CpuVideoFramePool::PlaneLayout _layout; + QByteArray _backing; +}; + +// Refs the source GstBuffer and maps its system memory through to Qt with no copy. The decoder buffer stays pinned for +// the frame's lifetime — the same tradeoff the GPU paths already make by anchoring the sample. +class MapThroughGstVideoBuffer final : public QAbstractVideoBuffer +{ +public: + // Outstanding zero-copy buffers each pin a decoder buffer; cap how many can be in flight before + // wrapZeroCopy falls back to a copy so a compositor stall can't drain the decoder's HW pool. Backend-correctness + // refinement: derive from the RHI's FramesInFlight (the prior constant 6 already covered typical 2-3 depths). + static int maxLive() noexcept + { + static const int s_max = []() -> int { + constexpr int kFloor = 6; +#if defined(QGC_HAS_ANY_GPU_PATH) + if (QRhi* rhi = QGCRhiCapture::cachedRhi()) { + const int fif = rhi->resourceLimit(QRhi::FramesInFlight); + if (fif > 0) { + return std::clamp(fif * 2, kFloor, 16); + } + } +#endif + return kFloor; + }(); + return s_max; + } + static std::atomic& liveCount() + { + static std::atomic s_live{0}; + return s_live; + } + + MapThroughGstVideoBuffer(GstBuffer* buffer, const GstVideoInfo& info, QVideoFrameFormat format) + : _buffer(gst_buffer_ref(buffer)), _info(info), _format(std::move(format)) + { + liveCount().fetch_add(1, std::memory_order_relaxed); + } + + ~MapThroughGstVideoBuffer() override + { + if (_mapped) { + gst_video_frame_unmap(&_frame); + } + gst_buffer_unref(_buffer); + liveCount().fetch_sub(1, std::memory_order_relaxed); + } + + MapData map(QVideoFrame::MapMode mode) override + { + if (mode == QVideoFrame::NotMapped) { + return {}; + } + if (mode != QVideoFrame::ReadOnly) { + return {}; + } + if (!_mapped) { + if (!gst_video_frame_map(&_frame, &_info, _buffer, GST_MAP_READ)) { + return {}; + } + _mapped = true; + } + MapData data; + data.planeCount = GST_VIDEO_FRAME_N_PLANES(&_frame); + for (int i = 0; i < data.planeCount; ++i) { + data.data[i] = static_cast(GST_VIDEO_FRAME_PLANE_DATA(&_frame, i)); + data.bytesPerLine[i] = GST_VIDEO_FRAME_PLANE_STRIDE(&_frame, i); + data.dataSize[i] = static_cast(GST_VIDEO_FRAME_PLANE_STRIDE(&_frame, i)) * + GST_VIDEO_FRAME_COMP_HEIGHT(&_frame, i); + } + return data; + } + + void unmap() override + { + if (_mapped) { + gst_video_frame_unmap(&_frame); + _mapped = false; + } + } + + QVideoFrameFormat format() const override { return _format; } + +private: + GstBuffer* _buffer; + GstVideoInfo _info; + QVideoFrameFormat _format; + GstVideoFrame _frame = {}; + bool _mapped = false; +}; + +} // namespace + +CpuVideoFramePool& CpuVideoFramePool::instance() +{ + static CpuVideoFramePool s_pool; + return s_pool; +} + +CpuVideoFramePool::PlaneLayout CpuVideoFramePool::computeLayout(const GstVideoInfo& info) +{ + PlaneLayout L = {}; + const int n = GST_VIDEO_INFO_N_PLANES(&info); + if (n <= 0 || n > 4) { + return L; + } + L.planeCount = n; + for (int i = 0; i < n; ++i) { + L.bytesPerLine[i] = GST_VIDEO_INFO_PLANE_STRIDE(&info, i); + L.planeOffset[i] = static_cast(GST_VIDEO_INFO_PLANE_OFFSET(&info, i)); + L.height[i] = static_cast(GST_VIDEO_INFO_COMP_HEIGHT(&info, i)); + } + L.byteSize = static_cast(GST_VIDEO_INFO_SIZE(&info)); + return L; +} + +QByteArray CpuVideoFramePool::acquireBacking(const PlaneLayout& layout, QSize size, + QVideoFrameFormat::PixelFormat format) +{ + QMutexLocker lock(&_mutex); + for (std::size_t i = 0; i < _slots.size(); ++i) { + auto& slot = _slots[i]; + if (slot.size == size && slot.format == format) { + // (size,format) implies a fixed byteSize; discard any stale mis-sized backing and keep scanning rather + // than stranding it, which would force a permanent miss loop. + while (slot.availableCount > 0) { + QByteArray b = std::move(slot.available[--slot.availableCount]); + if (b.size() == layout.byteSize) { + _hits.fetch_add(1, std::memory_order_relaxed); + return b; + } + } + break; + } + } + _misses.fetch_add(1, std::memory_order_relaxed); + // Qt::Uninitialized skips the value-init memset; caller overwrites every plane. + return QByteArray(layout.byteSize, Qt::Uninitialized); +} + +void CpuVideoFramePool::releaseBacking(QSize size, QVideoFrameFormat::PixelFormat format, QByteArray&& backing) +{ + QMutexLocker lock(&_mutex); + for (std::size_t i = 0; i < _slots.size(); ++i) { + auto& slot = _slots[i]; + if (slot.size == size && slot.format == format) { + if (slot.availableCount < kMaxPerSlot) { + slot.available[slot.availableCount++] = std::move(backing); + } + return; + } + } + // No slot for this (size,format): claim an empty slot or round-robin evict to stay bounded. + Slot* target = nullptr; + for (std::size_t i = 0; i < _slots.size(); ++i) { + auto& slot = _slots[i]; + if (slot.format == QVideoFrameFormat::Format_Invalid) { + target = &slot; + break; + } + } + if (!target) { + target = &_slots[_evictCursor]; + _evictCursor = (_evictCursor + 1) % kMaxSlots; + *target = Slot{}; + } + target->size = size; + target->format = format; + target->available[0] = std::move(backing); + target->availableCount = 1; +} + +QVideoFrame CpuVideoFramePool::acquireFrame(const QVideoFrameFormat& format, const GstVideoInfo& info) +{ + const PlaneLayout layout = computeLayout(info); + if (layout.planeCount == 0 || layout.byteSize <= 0) { + return {}; + } + QByteArray backing = acquireBacking(layout, format.frameSize(), format.pixelFormat()); + auto buffer = std::make_unique(format, layout, std::move(backing)); + return QVideoFrame(std::move(buffer)); +} + +std::unique_ptr CpuVideoFramePool::wrapZeroCopy(GstBuffer* buffer, const GstVideoInfo& info, + const QVideoFrameFormat& format) +{ + if (!buffer || !format.isValid()) { + return nullptr; + } + if (MapThroughGstVideoBuffer::liveCount().load(std::memory_order_relaxed) >= MapThroughGstVideoBuffer::maxLive()) { + return nullptr; + } + auto buf = std::make_unique(buffer, info, format); + // Probe mappability: system memory always maps, GPU memory that slipped past the factory does not (caller copies). + // Keep the mapping rather than unmapping — the sink's subsequent map() reuses it instead of re-running the map. + const auto probe = buf->map(QVideoFrame::ReadOnly); + if (probe.planeCount <= 0) { + return nullptr; + } + return buf; +} + +QVideoFrame CpuVideoFramePool::copyFromBuffer(GstBuffer* buffer, const GstVideoInfo& videoInfo, + const QVideoFrameFormat& format) +{ + if (!buffer || !format.isValid()) { + return {}; + } + + GstVideoFrame gstFrame; + if (!gst_video_frame_map(&gstFrame, &videoInfo, buffer, GST_MAP_READ)) { + static std::atomic s_failCount{0}; + const quint64 count = s_failCount.fetch_add(1, std::memory_order_relaxed) + 1; + if ((count & 0x3F) == 1) { + qCWarning(CpuVideoFramePoolLog) << "copyFromBuffer: gst_video_frame_map failed (count=" << count << ")"; + } + return {}; + } + + QVideoFrame videoFrame = instance().acquireFrame(format, videoInfo); + if (!videoFrame.isValid() || !videoFrame.map(QVideoFrame::WriteOnly)) { + gst_video_frame_unmap(&gstFrame); + return {}; + } + + const int srcPlanes = GST_VIDEO_INFO_N_PLANES(&videoInfo); + if (srcPlanes != videoFrame.planeCount()) { + static std::atomic s_warnedPlaneMismatch{false}; + if (!s_warnedPlaneMismatch.exchange(true, std::memory_order_relaxed)) { + qCWarning(CpuVideoFramePoolLog) << "copyFromBuffer: plane-count mismatch src" << srcPlanes << "dst" + << videoFrame.planeCount() << "— dropping frame"; + } + videoFrame.unmap(); + gst_video_frame_unmap(&gstFrame); + return {}; + } + + for (int p = 0; p < srcPlanes; ++p) { + const int dstStride = videoFrame.bytesPerLine(p); + const int srcStride = GST_VIDEO_FRAME_PLANE_STRIDE(&gstFrame, p); + const int planeHeight = GST_VIDEO_FRAME_COMP_HEIGHT(&gstFrame, p); + const int activeRowBytes = + GST_VIDEO_FRAME_COMP_WIDTH(&gstFrame, p) * GST_VIDEO_FRAME_COMP_PSTRIDE(&gstFrame, p); + // Reject before copy: a dst stride too narrow for a source row would tear the plane. + if (activeRowBytes > dstStride) { + static std::atomic s_warnedStrideOverflow{false}; + if (!s_warnedStrideOverflow.exchange(true, std::memory_order_relaxed)) { + qCWarning(CpuVideoFramePoolLog) << "copyFromBuffer: plane" << p << "activeRowBytes" << activeRowBytes + << "> dstStride" << dstStride << "— dropping frame"; + } + videoFrame.unmap(); + gst_video_frame_unmap(&gstFrame); + return {}; + } + const uchar* src = static_cast(GST_VIDEO_FRAME_PLANE_DATA(&gstFrame, p)); + uchar* dst = videoFrame.bits(p); + if (!dst) { + continue; + } + if (srcStride == dstStride && activeRowBytes == srcStride) { + std::memcpy(dst, src, static_cast(planeHeight) * srcStride); + } else { + for (int y = 0; y < planeHeight; ++y) { + std::memcpy(dst + y * dstStride, src + y * srcStride, static_cast(activeRowBytes)); + } + } + } + + videoFrame.unmap(); + gst_video_frame_unmap(&gstFrame); + return videoFrame; +} diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/CpuVideoFramePool.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/CpuVideoFramePool.h new file mode 100644 index 000000000000..d930fba41db0 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/CpuVideoFramePool.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QAbstractVideoBuffer; + +/// \brief Recycles CPU-backed QVideoFrame storage keyed by (size, pixelFormat) to avoid per-frame malloc churn. +class CpuVideoFramePool +{ +public: + static CpuVideoFramePool& instance(); + + /// Returns a pool-backed frame sized to @p info, or freshly allocates on a pool miss; invalid if unsupported. + QVideoFrame acquireFrame(const QVideoFrameFormat& format, const GstVideoInfo& info); + + /// Zero-copy CPU frame: wraps @p buffer and maps its system memory through to Qt. nullptr if not CPU-mappable + /// (caller copies instead). Pins the decoder buffer for the frame's lifetime. + static std::unique_ptr wrapZeroCopy(GstBuffer* buffer, const GstVideoInfo& info, + const QVideoFrameFormat& format); + + /// Telemetry: total acquireFrame() calls satisfied from the pool vs. fresh alloc. + quint64 hitCount() const noexcept { return _hits.load(std::memory_order_relaxed); } + + quint64 missCount() const noexcept { return _misses.load(std::memory_order_relaxed); } + + struct PlaneLayout + { + int planeCount = 0; + int bytesPerLine[4] = {}; + int height[4] = {}; + qsizetype planeOffset[4] = {}; + qsizetype byteSize = 0; + }; + + /// Destination plane layout mirroring @p info (strides/offsets from the decoder). planeCount==0 means unsupported. + static PlaneLayout computeLayout(const GstVideoInfo& info); + + /// Copy @p buffer's planes into a pool-allocated QVideoFrame; invalid on stride overflow or unsupported format. + static QVideoFrame copyFromBuffer(GstBuffer* buffer, const GstVideoInfo& videoInfo, + const QVideoFrameFormat& format); + + /// Return a backing array to the pool; called by PooledCpuVideoBuffer's destructor only. + void releaseBacking(QSize size, QVideoFrameFormat::PixelFormat format, QByteArray&& backing); + +private: + CpuVideoFramePool() = default; + + QByteArray acquireBacking(const PlaneLayout& layout, QSize size, QVideoFrameFormat::PixelFormat format); + + static constexpr int kMaxPerSlot = 4; + // Fixed-size slot array; round-robin eviction caps memory when resolution keeps changing. + static constexpr int kMaxSlots = 8; + + struct Slot + { + QSize size; + QVideoFrameFormat::PixelFormat format = QVideoFrameFormat::Format_Invalid; + std::array available; + int availableCount = 0; + }; + + QMutex _mutex; + std::array _slots; + int _evictCursor = 0; + std::atomic _hits{0}; + std::atomic _misses{0}; +}; diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstBridgePrimeRetry.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstBridgePrimeRetry.h new file mode 100644 index 000000000000..f407d38b6e3f --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstBridgePrimeRetry.h @@ -0,0 +1,86 @@ +#pragma once + +#include + +#if defined(QGC_HAS_ANY_GPU_PATH) + +/// Shared prime-retry latch for the GL/Vulkan context bridges. Both wrap a QRhi-derived native handle that may be null +/// until Qt's RHI initializes, so they retry priming a bounded number of times before latching off. Only the +/// boolean/counter bookkeeping is shared here; each bridge keeps its own bail/teardown logic (the GstObject sets they +/// clear differ). +namespace GstBridgePrimeRetry { + +struct PrimeRetryState +{ + bool primed = false; + bool primeAttempted = false; + int nullCount = 0; + int maxRetries = 16; +}; + +enum class Decision +{ + AlreadyPrimed, ///< prime() already succeeded; caller returns true. + ShouldRetry, ///< not yet primed and retries remain; caller proceeds to prime. + GiveUp ///< latched off (already attempted, or retry budget exhausted); caller returns false. +}; + +/// Call at the top of primeLocked(). On ShouldRetry the state is marked attempted so the caller proceeds; the caller +/// is responsible for clearing primeAttempted on a recoverable bail (so a later attempt can retry). +inline Decision primeRetryGuard(PrimeRetryState& s) +{ + if (s.primed) { + return Decision::AlreadyPrimed; + } + if (s.primeAttempted) { + return Decision::GiveUp; + } + s.primeAttempted = true; + return Decision::ShouldRetry; +} + +/// Record a null native-handle attempt. Returns true while retries remain (caller should clear primeAttempted to allow +/// a later retry); false once the budget is exhausted (caller leaves primeAttempted latched). The transition (return +/// false on the first over-budget call) lets callers log a one-shot give-up message. +inline bool rearmRetry(PrimeRetryState& s) +{ + ++s.nullCount; + if (s.nullCount <= s.maxRetries) { + s.primeAttempted = false; + return true; + } + return false; +} + +/// True exactly once, on the attempt that first exceeds the retry budget — for a single give-up log line. +inline bool justGaveUp(const PrimeRetryState& s) +{ + return s.nullCount == s.maxRetries + 1; +} + +/// Clear the latch so the next prime() retries from scratch. Used by reset(). +inline void resetRetry(PrimeRetryState& s) +{ + s.primed = false; + s.primeAttempted = false; + s.nullCount = 0; +} + +/// Pipeline-restart rearm: if priming latched off after exhausting retries, clear the latch for a fresh attempt. +/// Returns true if it cleared an exhausted latch (caller may log). +inline bool rearmAfterExhaustion(PrimeRetryState& s) +{ + if (s.primed) { + return false; + } + if (s.primeAttempted && s.nullCount > s.maxRetries) { + s.primeAttempted = false; + s.nullCount = 0; + return true; + } + return false; +} + +} // namespace GstBridgePrimeRetry + +#endif // QGC_HAS_ANY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstContextBridgeCommon.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstContextBridgeCommon.cc new file mode 100644 index 000000000000..e5401eb2a679 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstContextBridgeCommon.cc @@ -0,0 +1,122 @@ +#include "GstContextBridgeCommon.h" + +#if defined(QGC_HAS_ANY_GPU_PATH) + +#include +#include +#include + +#include "GstContextBridgeRegistry.h" + +namespace GstContextBridge { + +const char* matchContextType(const BridgeVTable& vt, const char* type) +{ + if (!type) { + return nullptr; + } + for (int i = 0; i < vt.contextTypeCount; ++i) { + if (g_strcmp0(type, vt.contextTypes[i]) == 0) { + return vt.contextTypes[i]; + } + } + return nullptr; +} + +namespace { + +// Shared snapshot path for both NEED_CONTEXT and GST_QUERY_CONTEXT: prime + ref the object under the bridge mutex, then +// build the GstContext with the lock released. The built context holds its own ref on the object, so our snapshot ref +// is dropped here. Returns the context, or null when not primed / object unavailable / build failed. +GstContext* snapshotContext(const BridgeVTable& vt, void* user, const char* matched) +{ + GstObject* object = nullptr; + { + QMutexLocker lock(&vt.mutex(user)); + if (!vt.primeLocked(user)) { + return nullptr; + } + object = vt.refObject(user, matched); + } + if (!object) { + return nullptr; + } + GstContext* ctx = vt.buildContext(user, matched, object); + gst_object_unref(object); + return ctx; +} + +} // namespace + +GstBusSyncReply handleSyncMessage(const BridgeVTable& vt, void* user, GstMessage* message) +{ + if (GST_MESSAGE_TYPE(message) != GST_MESSAGE_NEED_CONTEXT) { + return GST_BUS_PASS; + } + const gchar* contextType = nullptr; + if (!gst_message_parse_context_type(message, &contextType) || !contextType) { + return GST_BUS_PASS; + } + const char* matched = matchContextType(vt, contextType); + if (!matched) { + return GST_BUS_PASS; + } + GstElement* element = GST_ELEMENT(GST_MESSAGE_SRC(message)); + if (!element) { + return GST_BUS_PASS; + } + + GstContext* ctx = snapshotContext(vt, user, matched); + if (!ctx) { + return GST_BUS_PASS; + } + gst_element_set_context(element, ctx); + gst_context_unref(ctx); + gst_message_unref(message); + + if (vt.onHandoff) { + vt.onHandoff(user, element, matched); + } else { + qCDebug(vt.cat(user)) << "Provided" << vt.apiName << matched << "context to" << GST_ELEMENT_NAME(element); + } + return GST_BUS_DROP; +} + +bool answerContextQuery(const BridgeVTable& vt, void* user, GstQuery* query) +{ + if (!query || GST_QUERY_TYPE(query) != GST_QUERY_CONTEXT) { + return false; + } + const gchar* contextType = nullptr; + if (!gst_query_parse_context_type(query, &contextType) || !contextType) { + return false; + } + const char* matched = matchContextType(vt, contextType); + if (!matched) { + return false; + } + + GstContext* ctx = snapshotContext(vt, user, matched); + if (!ctx) { + return false; + } + gst_query_set_context(query, ctx); + gst_context_unref(ctx); + return true; +} + +void registerBridge(const QLoggingCategory& cat, const char* apiName, GstBusSyncReply (*handler)(GstMessage*), + void (*reset)()) +{ + // Register both unconditionally so resetAllBridges() always reaches the bridge's reset(); registry warns on + // overflow. + const auto h = GstContextBridgeRegistry::registerBridgeHandler(handler); + const auto r = GstContextBridgeRegistry::registerResetCallback(reset); + if ((h == GstContextBridgeRegistry::kInvalidHandle) || (r == GstContextBridgeRegistry::kInvalidHandle)) { + qCWarning(cat) << apiName << "bridge registration incomplete (registry full); GPU path inactive"; + } +} + +} // namespace GstContextBridge + +#endif // QGC_HAS_ANY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstContextBridgeCommon.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstContextBridgeCommon.h new file mode 100644 index 000000000000..be9d19b60ecb --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstContextBridgeCommon.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +#if defined(QGC_HAS_ANY_GPU_PATH) + +#include +#include + +QT_FORWARD_DECLARE_CLASS(QLoggingCategory) + +/// Shared NEED_CONTEXT / GST_QUERY_CONTEXT skeleton for every QGC↔GStreamer context bridge (GL, Vulkan, D3D11, D3D12). +/// Centralizes the one delicate invariant all bridges share: snapshot the cached GstObject under the bridge mutex, then +/// release the mutex BEFORE gst_element_set_context()/gst_query_set_context() — those re-enter the handler via nested +/// context queries and self-deadlock if the mutex is still held. Each bridge supplies its prime/ref/build specifics via +/// BridgeVTable; one context-type maps to exactly one GstObject across all bridges. +namespace GstContextBridge { + +/// Per-bridge hooks for the shared skeleton. `user` is an opaque pointer threaded back to every callback (the D3D +/// bridges pass per-instance state; the single-instance GL/Vulkan bridges pass nullptr and use file statics). +struct BridgeVTable +{ + const char* apiName; ///< "GL"/"Vulkan"/"D3D11"/"D3D12" — log label. + const char* const* contextTypes; ///< gst context-type strings this bridge answers. + int contextTypeCount; ///< entries in contextTypes. + const QLoggingCategory& (*cat)(void* user); ///< bridge logging category. + QMutex& (*mutex)(void* user); ///< bridge state mutex (guards prime/ref). + bool (*primeLocked)(void* user); ///< build/validate cached objects; caller holds mutex. + GstObject* (*refObject)(void* user, const char* contextType); ///< transfer-full ref to the object backing + ///< contextType, or null; caller holds mutex. + GstContext* (*buildContext)(void* user, const char* contextType, + GstObject* object); ///< wrap object in a + ///< GstContext (gst refs internally); no lock held. + void (*onHandoff)(void* user, GstElement* element, const char* contextType); ///< optional post-set log hook; + ///< null → default qCDebug. +}; + +/// Matched canonical context-type string (from vt.contextTypes) for @p type, or nullptr if this bridge ignores it. +const char* matchContextType(const BridgeVTable& vt, const char* type); + +/// Answer a NEED_CONTEXT sync message; GST_BUS_DROP when consumed (message unref'd), else GST_BUS_PASS. +GstBusSyncReply handleSyncMessage(const BridgeVTable& vt, void* user, GstMessage* message); + +/// Answer a GST_QUERY_CONTEXT (sink-bin query path); true when consumed. +bool answerContextQuery(const BridgeVTable& vt, void* user, GstQuery* query); + +/// Register a bridge's sync handler + reset callback with GstContextBridgeRegistry (the two calls every bridge +/// repeats). +void registerBridge(const QLoggingCategory& cat, const char* apiName, GstBusSyncReply (*handler)(GstMessage*), + void (*reset)()); + +} // namespace GstContextBridge + +#endif // QGC_HAS_ANY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstContextBridgeRegistry.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstContextBridgeRegistry.cc new file mode 100644 index 000000000000..bfdfd5500042 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstContextBridgeRegistry.cc @@ -0,0 +1,180 @@ +#include "GstContextBridgeRegistry.h" + +#include "QGCLoggingCategory.h" + +#if defined(QGC_HAS_ANY_GPU_PATH) + +#include +#include +#include + +QGC_LOGGING_CATEGORY(GstContextBridgeRegistryLog, "Video.GStreamer.HwBuffers.GstContextBridgeRegistry") + +namespace GstContextBridgeRegistry { + +namespace { + +// Arrays are write-once at static-init (happen-before any GstBus thread), so dispatch reads are lock-free; mutex only +// serializes concurrent registrations. unregister is NOT safe with concurrent dispatch. +// One slot per compiled GPU path that registers a bridge; deriving the cap from the path macros (not a fixed count) +// makes a silent drop impossible. +constexpr int kMaxBridges = 0 +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + + 1 +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + + 1 +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + + 1 +#endif +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + + 1 +#endif + ; +// Cache-reset participants: the import-cache-owning paths (NOT the bridge set). Sized from its own +// macro list so adding a cache path without a slot is a compile-time array overflow, never a silent drop. +constexpr int kMaxCacheResets = 0 +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + + 1 +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + + 1 +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + + 1 +#endif +#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) + + 1 +#endif +#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + + 1 +#endif + ; +QMutex s_mutex; +std::array s_handlers{}; +std::atomic s_handlerCount{0}; +std::array s_resets{}; +std::atomic s_resetCount{0}; +std::array 0 ? kMaxCacheResets : 1)> s_cacheResets{}; +std::atomic s_cacheResetCount{0}; + +} // namespace + +RegistrationHandle registerBridgeHandler(BridgeHandler handler) +{ + if (handler == nullptr) { + return kInvalidHandle; + } + QMutexLocker lock(&s_mutex); + const int count = s_handlerCount.load(std::memory_order_relaxed); + for (int i = 0; i < count; ++i) { + if (s_handlers[i] == handler) + return static_cast(i); + } + if (count >= kMaxBridges) { + qCWarning(GstContextBridgeRegistryLog) << "bridge handler registry full (kMaxBridges=" << kMaxBridges + << "); handler will not receive GstBus messages"; + return kInvalidHandle; + } + s_handlers[count] = handler; + s_handlerCount.store(count + 1, std::memory_order_release); + return static_cast(count); +} + +RegistrationHandle registerResetCallback(ResetCallback callback) +{ + if (callback == nullptr) { + return kInvalidHandle; + } + QMutexLocker lock(&s_mutex); + const int count = s_resetCount.load(std::memory_order_relaxed); + for (int i = 0; i < count; ++i) { + if (s_resets[i] == callback) + return static_cast(i); + } + if (count >= kMaxBridges) { + qCWarning(GstContextBridgeRegistryLog) << "reset callback registry full (kMaxBridges=" << kMaxBridges + << "); callback will not run on QRhi teardown"; + return kInvalidHandle; + } + s_resets[count] = callback; + s_resetCount.store(count + 1, std::memory_order_release); + return static_cast(count); +} + +RegistrationHandle registerCacheReset(ResetCallback callback) +{ + if (callback == nullptr) { + return kInvalidHandle; + } + QMutexLocker lock(&s_mutex); + const int count = s_cacheResetCount.load(std::memory_order_relaxed); + for (int i = 0; i < count; ++i) { + if (s_cacheResets[i] == callback) + return static_cast(i); + } + if (count >= kMaxCacheResets) { + qCWarning(GstContextBridgeRegistryLog) << "cache-reset registry full (kMaxCacheResets=" << kMaxCacheResets + << "); callback will not run on GPU device-loss"; + return kInvalidHandle; + } + s_cacheResets[count] = callback; + s_cacheResetCount.store(count + 1, std::memory_order_release); + return static_cast(count); +} + +// First GST_BUS_DROP wins; bridges must differ on context-type so no bridge shadows another. +GstBusSyncReply dispatchBridges(GstMessage* message) +{ + // Registry lock must not be held across a bridge callout — each handler takes its own per-bridge mutex. + const int count = s_handlerCount.load(std::memory_order_acquire); + for (int i = 0; i < count; ++i) { + const BridgeHandler h = s_handlers[i]; + if (h && (h(message) == GST_BUS_DROP)) + return GST_BUS_DROP; + } + return GST_BUS_PASS; +} + +void resetAllBridges() +{ + const int count = s_resetCount.load(std::memory_order_acquire); + for (int i = 0; i < count; ++i) { + const ResetCallback cb = s_resets[i]; + if (cb) + cb(); + } +} + +void resetAllCaches() +{ + // No registry lock across the callout: each cache reset takes its own per-backend mutex (or is a + // lock-free atomic-store deferred flag). Array is write-once at static-init (happens-before any + // GstBus thread), so iteration is lock-free. + const int count = s_cacheResetCount.load(std::memory_order_acquire); + for (int i = 0; i < count; ++i) { + const ResetCallback cb = s_cacheResets[i]; + if (cb) + cb(); + } +} + +#ifdef QGC_GST_BUILD_TESTING +void clearForTest() +{ + resetAllBridges(); + resetAllCaches(); + QMutexLocker lock(&s_mutex); + s_handlers.fill(nullptr); + s_resets.fill(nullptr); + s_cacheResets.fill(nullptr); + s_handlerCount.store(0, std::memory_order_release); + s_resetCount.store(0, std::memory_order_release); + s_cacheResetCount.store(0, std::memory_order_release); +} +#endif + +} // namespace GstContextBridgeRegistry + +#endif // QGC_HAS_ANY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstContextBridgeRegistry.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstContextBridgeRegistry.h new file mode 100644 index 000000000000..735634c8d59d --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstContextBridgeRegistry.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +#if defined(QGC_HAS_ANY_GPU_PATH) + +#include + +/// Static registry that fans out GstBus sync messages to every compiled context bridge; each bridge registers at file +/// scope. +namespace GstContextBridgeRegistry { + +using BridgeHandler = GstBusSyncReply (*)(GstMessage*); +using ResetCallback = void (*)(); + +/// Opaque slot handle returned by register*; pass to unregister* to clear that slot. Strong-typed so int indices can't +/// be silently passed where a handle is expected. +enum class RegistrationHandle : int +{ + Invalid = -1 +}; +inline constexpr RegistrationHandle kInvalidHandle = RegistrationHandle::Invalid; + +/// Register a bridge handler (call from static initializers); duplicate handler pointers are deduped. +RegistrationHandle registerBridgeHandler(BridgeHandler handler); + +/// Register a reset callback invoked on QRhi teardown to drop cached GPU handles; duplicates are deduped. +RegistrationHandle registerResetCallback(ResetCallback callback); + +/// Register a per-backend import-cache reset (DMABuf/D3D/IOSurface/AHB), invoked on GPU device-loss. +/// Separate array from the bridge resets so its capacity covers the non-bridge cache paths too. +RegistrationHandle registerCacheReset(ResetCallback callback); + +/// Dispatch message to all registered handlers; returns GST_BUS_DROP on first consumer. +GstBusSyncReply dispatchBridges(GstMessage* message); + +/// Invoke every registered reset callback; each bridge's reset() takes its own mutex. +void resetAllBridges(); + +/// Invoke every registered cache-reset callback; each backend's reset takes its own cache mutex. +void resetAllCaches(); + +#ifdef QGC_GST_BUILD_TESTING +/// Reset all registrations and call resetAllBridges() so tests start from a clean state. +void clearForTest(); +#endif + +} // namespace GstContextBridgeRegistry + +#endif // QGC_HAS_ANY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwFrameTexturesBase.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwFrameTexturesBase.h new file mode 100644 index 000000000000..9b2db6c3e3e5 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwFrameTexturesBase.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include + +#include "GstHwVideoBuffer.h" +#include "GstHwVideoBufferFactory.h" + +class QRhiTexture; + +/// Common base for per-platform `FrameTextures : QVideoFrameTextures` from `*VideoBuffer::mapTextures()`. +class GstHwFrameTexturesBase : public QVideoFrameTextures +{ +public: + ~GstHwFrameTexturesBase() override { g_clear_pointer(&_srcSample, gst_sample_unref); } + + void onFrameEndInvoked() override { g_clear_pointer(&_srcSample, gst_sample_unref); } + + QRhiTexture* texture(uint plane) const override { return int(plane) < _count ? _textures[plane].get() : nullptr; } + + /// GPU path that produced this bundle; used after a type-safe downcast to decide path-local reuse. + virtual HwVideoBufferPath sourcePath() const { return HwVideoBufferPath::None; } + + /// Transfers a ref into the bundle. Caller must have a fresh ref. + void setSourceSample(GstSample* s) noexcept + { + g_clear_pointer(&_srcSample, gst_sample_unref); + _srcSample = s; + } + + /// Reuse probe: @p old downcasts to @p FT iff it is one of our bundles and came from path @p p. + /// Qt 6.10's texture pool can pass CPU-fallback or Qt-owned texture bundles here, so the base cast must be checked + /// before consulting sourcePath(). + /// Caller still runs FT::matches() — the equality predicate differs per path. + template + static FT* reusableBundle(QVideoFrameTexturesUPtr& old, HwVideoBufferPath p) + { + auto* base = old ? dynamic_cast(old.get()) : nullptr; + return (base && base->sourcePath() == p) ? dynamic_cast(base) : nullptr; + } + +protected: + int _count = 0; + std::unique_ptr _textures[GstHw::kMaxPlanes]; + GstSample* _srcSample = nullptr; +}; diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwImportCache.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwImportCache.h new file mode 100644 index 000000000000..54621e44742c --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwImportCache.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include + +namespace GstHw { + +/// Bounded LRU cache for GPU import resources (EGLImages, QRhiTexture wrappers). Keyed on a hashable Key; Resource is +/// destroyed via the caller-supplied deleter on eviction, erase and clear. Not internally synchronized — the caller +/// owns whatever locking the resource lifetime requires (DMABuf holds a mutex; the GL path is render-thread-confined). +template > +class GstHwImportCache +{ +public: + using Deleter = std::function; + + GstHwImportCache(std::size_t capacity, Deleter deleter) : _capacity(capacity), _deleter(std::move(deleter)) {} + + ~GstHwImportCache() { clear(); } + + GstHwImportCache(const GstHwImportCache&) = delete; + GstHwImportCache& operator=(const GstHwImportCache&) = delete; + + /// MRU-promoting lookup; returns the stored Resource pointer or nullptr on miss. The pointer is invalidated by the + /// next insert()/eraseIf()/clear() on this cache (a different-key insert can evict it) — never hold it across one. + Resource* find(const Key& key) + { + auto it = _map.find(key); + if (it == _map.end()) { + return nullptr; + } + _order.splice(_order.begin(), _order, it->second.lru); + return &it->second.resource; + } + + /// Insert (evicting the LRU victim first when at capacity). Replaces any existing entry for @p key, deleting its + /// old resource. Caller must ensure no live alias to a replaced/evicted resource survives. + void insert(const Key& key, Resource resource) + { + if (auto existing = _map.find(key); existing != _map.end()) { + _deleter(existing->first, existing->second.resource); + existing->second.resource = std::move(resource); + _order.splice(_order.begin(), _order, existing->second.lru); + return; + } + if (_map.size() >= _capacity && !_order.empty()) { + const Key victimKey = _order.back(); + if (auto victim = _map.find(victimKey); victim != _map.end()) { + _deleter(victim->first, victim->second.resource); + _map.erase(victim); + } + _order.pop_back(); + } + _order.push_front(key); + _map.emplace(key, Entry{std::move(resource), _order.begin()}); + } + + /// Erase every entry whose key or resource satisfies @p pred, deleting each via the configured deleter. + template + void eraseIf(Pred pred) + { + for (auto it = _map.begin(); it != _map.end();) { + if (pred(it->first, it->second.resource)) { + _deleter(it->first, it->second.resource); + _order.erase(it->second.lru); + it = _map.erase(it); + } else { + ++it; + } + } + } + + void clear() + { + for (auto it = _map.begin(); it != _map.end(); ++it) { + _deleter(it->first, it->second.resource); + } + _map.clear(); + _order.clear(); + } + + bool empty() const noexcept { return _map.empty(); } + + std::size_t size() const noexcept { return _map.size(); } + +private: + struct Entry + { + Resource resource; + typename std::list::iterator lru; + }; + + std::size_t _capacity; + Deleter _deleter; + std::unordered_map _map; + std::list _order; +}; + +} // namespace GstHw diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwImportPreflight.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwImportPreflight.cc new file mode 100644 index 000000000000..ac99cd695641 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwImportPreflight.cc @@ -0,0 +1,63 @@ +#include "GstHwImportPreflight.h" + +#if defined(QGC_HAS_ANY_GPU_PATH) + +#include + +#include "GstHwPathTelemetry.h" + +namespace GstHwImportPreflight { + +bool canImportTexture(QRhi* rhi, QRhiTexture::Format fmt, const QSize& size, QRhiTexture::Flags flags) noexcept +{ + if (!rhi) { + return true; + } + if (fmt == QRhiTexture::UnknownFormat) { + return false; + } + if (!rhi->isTextureFormatSupported(fmt, flags)) { + return false; + } + const int maxDim = rhi->resourceLimit(QRhi::TextureSizeMax); + if (maxDim > 0 && (size.width() > maxDim || size.height() > maxDim)) { + return false; + } + return true; +} + +bool canImportPlanes(QRhi* rhi, QVideoFrameFormat::PixelFormat pixelFormat, const QSize& size, + QRhiTexture::Flags flags) noexcept +{ + if (!rhi) { + return true; + } + const auto* desc = QVideoTextureHelper::textureDescription(pixelFormat); + if (!desc) { + return false; + } + // Disable the helper's own format fallback chain so the pre-flight tests the exact format createFrom() will use. + using FallbackPolicy = QVideoTextureHelper::TextureDescription::FallbackPolicy; + for (int plane = 0; plane < desc->nplanes; ++plane) { + const QRhiTexture::Format fmt = desc->rhiTextureFormat(plane, rhi, FallbackPolicy::Disable); + const QSize planeSize = desc->rhiPlaneSize(size, plane, rhi); + if (!canImportTexture(rhi, fmt, planeSize, flags)) { + return false; + } + } + return true; +} + +bool preflightOrRecord(QRhi* rhi, HwVideoBufferPath path, QVideoFrameFormat::PixelFormat pixelFormat, + const QSize& size, QRhiTexture::Flags flags) noexcept +{ + if (canImportPlanes(rhi, pixelFormat, size, flags)) { + return true; + } + GstHwPathTelemetry::recordFallbackReason(path, GstHwPathTelemetry::HwFallbackReason::ImportUnsupported); + return false; +} + +} // namespace GstHwImportPreflight + +#endif // QGC_HAS_ANY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwImportPreflight.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwImportPreflight.h new file mode 100644 index 000000000000..57cde559a0d4 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwImportPreflight.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +#if defined(QGC_HAS_ANY_GPU_PATH) + +#include +#include +#include + +#include "GstHwVideoBufferFactory.h" // HwVideoBufferPath + +/// Read-only RHI capability pre-flight: rejects a GPU import before createFrom() so a frame that the active QRhi +/// cannot back demotes to CPU on a cheap query instead of after a driver error. All queries are light (backends +/// cache resource limits); safe on the streaming/bus-sync thread as long as @p rhi is the live render QRhi. +namespace GstHwImportPreflight { + +/// True iff @p rhi can create a texture of @p fmt + @p flags at @p size (format supported AND within TextureSizeMax). +/// Null @p rhi returns true (no snapshot to gate on — let the createFrom attempt decide). +bool canImportTexture(QRhi* rhi, QRhiTexture::Format fmt, const QSize& size, QRhiTexture::Flags flags = {}) noexcept; + +/// Multiplane variant: derives each plane's QRhiTexture::Format + plane size from @p pixelFormat via +/// QVideoTextureHelper and pre-flights every plane. Used by NV12 (R8/RG8) / P010 (R16/RG16) imports. +bool canImportPlanes(QRhi* rhi, QVideoFrameFormat::PixelFormat pixelFormat, const QSize& size, + QRhiTexture::Flags flags = {}) noexcept; + +/// Pre-flight + telemetry: runs canImportPlanes and, on rejection, records an ImportUnsupported fallback for +/// @p path so the demotion shows up in the per-(path,reason) breakdown like other CPU fallbacks. Returns the +/// canImportPlanes result. +bool preflightOrRecord(QRhi* rhi, HwVideoBufferPath path, QVideoFrameFormat::PixelFormat pixelFormat, + const QSize& size, QRhiTexture::Flags flags = {}) noexcept; + +} // namespace GstHwImportPreflight + +#endif // QGC_HAS_ANY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwPathTelemetry.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwPathTelemetry.cc new file mode 100644 index 000000000000..77bab63df807 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwPathTelemetry.cc @@ -0,0 +1,195 @@ +#include "GstHwPathTelemetry.h" + +#include +#include + +namespace GstHwPathTelemetry { + +namespace { + +constexpr size_t kPathCount = size_t(HwVideoBufferPath::Vulkan) + 1; + +struct PathCounters +{ + std::atomic mapFailures{0}; + std::atomic textureReuseHits{0}; + std::atomic syncCpuWaits{0}; + std::atomic syncGpuWaits{0}; + std::atomic imageCacheHits{0}; + std::atomic imageCacheMisses{0}; + std::atomic delivered{0}; + std::atomic mapDurationUsEwma{0}; + std::atomic fenceTimeouts{0}; + std::atomic mmapBarrierHits{0}; + std::atomic explicitFenceWaits{0}; + std::atomic streamDemotions{0}; +}; + +std::array s_counters{}; + +constexpr size_t kReasonCount = size_t(HwFallbackReason::_Count); +std::array, kReasonCount>, kPathCount> s_fallbackReasons{}; + +inline PathCounters& slot(HwVideoBufferPath path) noexcept +{ + return s_counters[size_t(path)]; +} + +} // namespace + +void recordMapFailure(HwVideoBufferPath path) noexcept +{ + slot(path).mapFailures.fetch_add(1, std::memory_order_relaxed); +} + +quint64 takeMapFailureCount(HwVideoBufferPath path) noexcept +{ + return slot(path).mapFailures.exchange(0, std::memory_order_relaxed); +} + +quint64 peekMapFailureCount(HwVideoBufferPath path) noexcept +{ + return slot(path).mapFailures.load(std::memory_order_relaxed); +} + +void recordTextureReuse(HwVideoBufferPath path) noexcept +{ + slot(path).textureReuseHits.fetch_add(1, std::memory_order_relaxed); +} + +quint64 takeTextureReuseHits(HwVideoBufferPath path) noexcept +{ + return slot(path).textureReuseHits.exchange(0, std::memory_order_relaxed); +} + +void recordSyncWait(HwVideoBufferPath path, bool gpuSide) noexcept +{ + auto& target = gpuSide ? slot(path).syncGpuWaits : slot(path).syncCpuWaits; + target.fetch_add(1, std::memory_order_relaxed); +} + +quint64 takeSyncWaitCounts(HwVideoBufferPath path, quint64& gpuWaits) noexcept +{ + gpuWaits = slot(path).syncGpuWaits.exchange(0, std::memory_order_relaxed); + return slot(path).syncCpuWaits.exchange(0, std::memory_order_relaxed); +} + +void recordImageCacheHit(HwVideoBufferPath path) noexcept +{ + slot(path).imageCacheHits.fetch_add(1, std::memory_order_relaxed); +} + +void recordImageCacheMiss(HwVideoBufferPath path) noexcept +{ + slot(path).imageCacheMisses.fetch_add(1, std::memory_order_relaxed); +} + +#if defined(QGC_HAS_ANY_GPU_PATH) +QVideoFrameTexturesUPtr fail(HwVideoBufferPath path) noexcept +{ + recordMapFailure(path); + return {}; +} +#endif + +void recordDelivered(HwVideoBufferPath path) noexcept +{ + slot(path).delivered.fetch_add(1, std::memory_order_relaxed); +} + +quint64 peekDeliveredCount(HwVideoBufferPath path) noexcept +{ + return slot(path).delivered.load(std::memory_order_relaxed); +} + +quint64 takeDeliveredCount(HwVideoBufferPath path) noexcept +{ + return slot(path).delivered.exchange(0, std::memory_order_relaxed); +} + +void recordMapDuration(HwVideoBufferPath path, qint64 nsecs) noexcept +{ + if (nsecs < 0) { + return; + } + // EWMA in microseconds (alpha = 1/8); seed on the first sample so the average tracks immediately. + const quint64 sampleUs = static_cast(nsecs) / 1000; + auto& ewma = slot(path).mapDurationUsEwma; + quint64 prev = ewma.load(std::memory_order_relaxed); + quint64 next; + do { + next = (prev == 0) ? sampleUs : prev - (prev >> 3) + (sampleUs >> 3); + } while (!ewma.compare_exchange_weak(prev, next, std::memory_order_relaxed)); +} + +quint64 peekMapDurationUsEwma(HwVideoBufferPath path) noexcept +{ + return slot(path).mapDurationUsEwma.load(std::memory_order_relaxed); +} + +void recordFenceTimeout(HwVideoBufferPath path) noexcept +{ + slot(path).fenceTimeouts.fetch_add(1, std::memory_order_relaxed); +} + +quint64 peekFenceTimeouts(HwVideoBufferPath path) noexcept +{ + return slot(path).fenceTimeouts.load(std::memory_order_relaxed); +} + +quint64 takeFenceTimeouts(HwVideoBufferPath path) noexcept +{ + return slot(path).fenceTimeouts.exchange(0, std::memory_order_relaxed); +} + +void recordMmapBarrierHit(HwVideoBufferPath path) noexcept +{ + slot(path).mmapBarrierHits.fetch_add(1, std::memory_order_relaxed); +} + +quint64 peekMmapBarrierHits(HwVideoBufferPath path) noexcept +{ + return slot(path).mmapBarrierHits.load(std::memory_order_relaxed); +} + +quint64 takeMmapBarrierHits(HwVideoBufferPath path) noexcept +{ + return slot(path).mmapBarrierHits.exchange(0, std::memory_order_relaxed); +} + +void recordExplicitFenceWait(HwVideoBufferPath path) noexcept +{ + slot(path).explicitFenceWaits.fetch_add(1, std::memory_order_relaxed); +} + +quint64 takeExplicitFenceWaits(HwVideoBufferPath path) noexcept +{ + return slot(path).explicitFenceWaits.exchange(0, std::memory_order_relaxed); +} + +void recordFallbackReason(HwVideoBufferPath attemptedPath, HwFallbackReason reason) noexcept +{ + s_fallbackReasons[size_t(attemptedPath)][size_t(reason)].fetch_add(1, std::memory_order_relaxed); +} + +quint64 peekFallbackReason(HwVideoBufferPath attemptedPath, HwFallbackReason reason) noexcept +{ + return s_fallbackReasons[size_t(attemptedPath)][size_t(reason)].load(std::memory_order_relaxed); +} + +quint64 takeFallbackReason(HwVideoBufferPath attemptedPath, HwFallbackReason reason) noexcept +{ + return s_fallbackReasons[size_t(attemptedPath)][size_t(reason)].exchange(0, std::memory_order_relaxed); +} + +void recordStreamDemotion(HwVideoBufferPath negotiated) noexcept +{ + slot(negotiated).streamDemotions.fetch_add(1, std::memory_order_relaxed); +} + +quint64 takeStreamDemotions(HwVideoBufferPath negotiated) noexcept +{ + return slot(negotiated).streamDemotions.exchange(0, std::memory_order_relaxed); +} + +} // namespace GstHwPathTelemetry diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwPathTelemetry.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwPathTelemetry.h new file mode 100644 index 000000000000..76ffe0424e36 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwPathTelemetry.h @@ -0,0 +1,102 @@ +#pragma once + +#include +#include + +#include "GstHwVideoBufferFactory.h" // HwVideoBufferPath enum + +#if defined(QGC_HAS_ANY_GPU_PATH) +#include +#endif + +/// Per-path telemetry counters (map failures, reuse hits, sync waits) keyed by `HwVideoBufferPath`; atomics, +/// thread-safe. +namespace GstHwPathTelemetry { + +/// Specific cause a HW path was rejected for a sample; surfaced as a per-(path,reason) breakdown. +enum class HwFallbackReason +{ + None, + NoExt, + ModifierRejected, + EglBadMatch, + FenceTimeout, + ValidateFailed, + UnknownMemType, + NullSample, + MapFailed, + VulkanNoSync, + ImportUnsupported, + _Count, +}; + +/// mapTextures() returned an invalid bundle (GPU import failed). +void recordMapFailure(HwVideoBufferPath path) noexcept; +quint64 takeMapFailureCount(HwVideoBufferPath path) noexcept; +quint64 peekMapFailureCount(HwVideoBufferPath path) noexcept; + +/// Prior frame's QRhiTexture wrappers reused (decoder pool returned same native handle). +void recordTextureReuse(HwVideoBufferPath path) noexcept; +quint64 takeTextureReuseHits(HwVideoBufferPath path) noexcept; + +/// GL fence sync wait; split CPU-blocking vs GPU-side. +void recordSyncWait(HwVideoBufferPath path, bool gpuSide) noexcept; +/// Reads-and-resets CPU waits; writes GPU waits into @p gpuWaits. +quint64 takeSyncWaitCounts(HwVideoBufferPath path, quint64& gpuWaits) noexcept; + +/// AHardwareBuffer EGLImage LRU hit/miss accounting. +void recordImageCacheHit(HwVideoBufferPath path) noexcept; +void recordImageCacheMiss(HwVideoBufferPath path) noexcept; + +#if defined(QGC_HAS_ANY_GPU_PATH) +/// Single-shot helper for the common "increment-and-return-empty" pattern. +QVideoFrameTexturesUPtr fail(HwVideoBufferPath path) noexcept; +#endif + +/// Frames successfully delivered via this path. +void recordDelivered(HwVideoBufferPath path) noexcept; +quint64 peekDeliveredCount(HwVideoBufferPath path) noexcept; +quint64 takeDeliveredCount(HwVideoBufferPath path) noexcept; + +/// Per-path mapTextures() wall-time, fed into an EWMA; peek returns the smoothed value in microseconds. +void recordMapDuration(HwVideoBufferPath path, qint64 nsecs) noexcept; +quint64 peekMapDurationUsEwma(HwVideoBufferPath path) noexcept; + +/// RAII timer: records mapTextures() wall time into the path's EWMA on scope exit. +class ScopedMapTimer +{ +public: + explicit ScopedMapTimer(HwVideoBufferPath path) noexcept : _path(path) { _timer.start(); } + ~ScopedMapTimer() noexcept { recordMapDuration(_path, _timer.nsecsElapsed()); } + ScopedMapTimer(const ScopedMapTimer&) = delete; + ScopedMapTimer& operator=(const ScopedMapTimer&) = delete; + +private: + HwVideoBufferPath _path; + QElapsedTimer _timer; +}; + +/// DMABuf EGL fence wait timed out (GPU stall) and fell through to the mmap barrier. +void recordFenceTimeout(HwVideoBufferPath path) noexcept; +quint64 peekFenceTimeouts(HwVideoBufferPath path) noexcept; +quint64 takeFenceTimeouts(HwVideoBufferPath path) noexcept; + +/// DMABuf mmap CPU-side completion barrier taken (no usable fence ext). +void recordMmapBarrierHit(HwVideoBufferPath path) noexcept; +quint64 peekMmapBarrierHits(HwVideoBufferPath path) noexcept; +quint64 takeMmapBarrierHits(HwVideoBufferPath path) noexcept; + +/// DMABuf imported the producer's dma-buf/native fence and did a GPU-side wait (skipped the mmap barrier). +void recordExplicitFenceWait(HwVideoBufferPath path) noexcept; +quint64 takeExplicitFenceWaits(HwVideoBufferPath path) noexcept; + +/// Per-(path,reason) fallback accounting; lets a bug report show *why* a path demoted to CPU. +void recordFallbackReason(HwVideoBufferPath attemptedPath, HwFallbackReason reason) noexcept; +quint64 peekFallbackReason(HwVideoBufferPath attemptedPath, HwFallbackReason reason) noexcept; +quint64 takeFallbackReason(HwVideoBufferPath attemptedPath, HwFallbackReason reason) noexcept; + +/// One-shot-per-epoch event: a stream that negotiated a HW path demoted to CPU. Distinct from per-frame CPU counts. +void recordStreamDemotion(HwVideoBufferPath negotiated) noexcept; +quint64 takeStreamDemotions(HwVideoBufferPath negotiated) noexcept; + +} // namespace GstHwPathTelemetry diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwVideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwVideoBuffer.cc new file mode 100644 index 000000000000..e8b13184b182 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwVideoBuffer.cc @@ -0,0 +1,57 @@ +#include "GstHwVideoBuffer.h" + +#include + +GstHwVideoBuffer::GstHwVideoBuffer(QVideoFrame::HandleType handleType, GstSample* sample, const GstVideoInfo& videoInfo, + QVideoFrameFormat format) + : QHwVideoBuffer(handleType, nullptr), + _sample(sample ? gst_sample_ref(sample) : nullptr), + _videoInfo(videoInfo), + _format(std::move(format)) +{ + // Crop is applied by the renderer via QVideoFrameFormat::viewport(); see GStreamerFrameMap::applyCropMeta. +} + +GstHwVideoBuffer::~GstHwVideoBuffer() +{ + if (_sample) { + gst_sample_unref(_sample); + } +} + +bool GstHwVideoBuffer::checkMapPreconditions(const QRhi& rhi, int expectedBackend, const QLoggingCategory& cat, + GstHw::MapDiagnostics& diag, GstBuffer*& outBuffer) const +{ + if (!_sample) { + if (!diag.loggedNullSample.exchange(true, std::memory_order_relaxed)) + qCWarning(cat) << "mapTextures: GstSample is null"; + return false; + } + if (!rhi.thread()->isCurrentThread()) { + if (!diag.loggedWrongThread.exchange(true, std::memory_order_relaxed)) + qCWarning(cat) << "mapTextures: called outside QRhi render thread"; + return false; + } + if (static_cast(rhi.backend()) != expectedBackend) { + if (!diag.loggedBadBackend.exchange(true, std::memory_order_relaxed)) + qCWarning(cat) << "mapTextures: QRhi backend is" << rhi.backendName() << "(backend id" << expectedBackend + << "required)"; + return false; + } + outBuffer = gst_sample_get_buffer(_sample); + if (!outBuffer) { + if (!diag.loggedNullBuffer.exchange(true, std::memory_order_relaxed)) + qCWarning(cat) << "mapTextures: GstSample has no buffer"; + return false; + } + return true; +} + +void GstHwVideoBuffer::logFirstSuccess(std::atomic& flag, const QLoggingCategory& cat, const char* tag, + QSize frameSize, QVideoFrameFormat::PixelFormat pixelFormat, int planes) +{ + if (!flag.exchange(true, std::memory_order_relaxed)) { + qCInfo(cat) << "First" << tag << "zero-copy mapTextures success: size=" << frameSize + << "format=" << int(pixelFormat) << "planes=" << planes; + } +} diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwVideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwVideoBuffer.h new file mode 100644 index 000000000000..61babe2f11f1 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwVideoBuffer.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace GstHw { + +/// Matches GST_VIDEO_MAX_PLANES (gst-video pins it at 4); single source of truth for every per-platform buffer. +constexpr int kMaxPlanes = 4; + +/// One-shot warning flags per failure cause; paths with extra causes (D3D, IOSurface) derive and add members. +struct MapDiagnostics +{ + std::atomic loggedFirstSuccess{false}; + std::atomic loggedNullSample{false}; + std::atomic loggedWrongThread{false}; + std::atomic loggedBadBackend{false}; + std::atomic loggedNullBuffer{false}; + std::atomic loggedTextureCreateFail{false}; +}; + +} // namespace GstHw + +/// Logs once via qCWarning(LOGCAT) the first time @p FLAG flips true; subsequent trips are silent. +#define QGC_HW_WARN_ONCE(LOGCAT, FLAG, ...) \ + do { \ + if (!(FLAG).exchange(true, std::memory_order_relaxed)) { \ + qCWarning(LOGCAT) << __VA_ARGS__; \ + } \ + } while (0) + +/// \brief Common base for GStreamer-backed QHwVideoBuffer subclasses. +class GstHwVideoBuffer : public QHwVideoBuffer +{ +public: + GstHwVideoBuffer(QVideoFrame::HandleType handleType, GstSample* sample, const GstVideoInfo& videoInfo, + QVideoFrameFormat format); + ~GstHwVideoBuffer() override; + + QVideoFrameFormat format() const override { return _format; } + + MapData map(QVideoFrame::MapMode /*mode*/) override { return {}; } + + /// Streaming-thread sanity check on per-plane handles; failure routes to CPU memcpy. + virtual bool validatePlaneHandles() const { return true; } + + /// Human-readable GPU path identifier (e.g. "DMABuf"); string literal, safe from any thread. + virtual const char* storageTag() const { return "Unknown"; } + + /// Transfers GstSample ownership out for early pool-slot release in mapTextures. + GstSample* takeSample() noexcept + { + GstSample* s = _sample; + _sample = nullptr; + return s; + } + + /// One-shot "first zero-copy success" info line; silent after @p flag first flips. + static void logFirstSuccess(std::atomic& flag, const QLoggingCategory& cat, const char* tag, QSize frameSize, + QVideoFrameFormat::PixelFormat pixelFormat, int planes); + +protected: + /// Validate each plane satisfies @p planePred; predicate runs once per plane on the streaming thread. + template + bool validatePlanes(PlanePred&& planePred) const + { + if (!_sample) + return false; + GstBuffer* buffer = gst_sample_get_buffer(_sample); + if (!buffer) + return false; + const int memCount = (std::min)(int(gst_buffer_n_memory(buffer)), GstHw::kMaxPlanes); + if (memCount <= 0) + return false; + for (int i = 0; i < memCount; ++i) { + GstMemory* mem = gst_buffer_peek_memory(buffer, i); + if (!planePred(mem)) + return false; + } + return true; + } + + /// Shared mapTextures preamble; warns once per cause via @p diag flags, returns false on first failure. + bool checkMapPreconditions(const QRhi& rhi, int expectedBackend, const QLoggingCategory& cat, + GstHw::MapDiagnostics& diag, GstBuffer*& outBuffer) const; + + GstSample* _sample = nullptr; + GstVideoInfo _videoInfo = {}; + QVideoFrameFormat _format; +}; diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwVideoBufferFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwVideoBufferFactory.cc new file mode 100644 index 000000000000..3f715a1f137a --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwVideoBufferFactory.cc @@ -0,0 +1,327 @@ +#include "GstHwVideoBufferFactory.h" + +#include +#include +#include +#include +#include + +#include "GstHwPathTelemetry.h" +#include "QGCLoggingCategory.h" + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) +#include + +#include "GstDmaBufVideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_CUDA_GPU_PATH) +#include + +#include "GstCudaVideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) +#include + +#include "GstGlVideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) +#include + +#include "GstD3D11VideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) +#include + +#include "GstD3D12VideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) +#include "GstIOSurfaceVideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) +#include +// `slots` member in gst-vulkan video headers vs Qt's `slots` keyword macro — see GstVulkanVideoBuffer.cc. +#pragma push_macro("slots") +#undef slots +#include +#pragma pop_macro("slots") + +#include "GstVulkanVideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) +#include + +#include "GstAHardwareBufferVideoBuffer.h" +#endif + +QGC_LOGGING_CATEGORY(GstHwBufFactoryLog, "Video.GStreamer.HwBuffers.Factory") + + +namespace { + +const char* pathName(HwVideoBufferPath path) noexcept +{ + switch (path) { + case HwVideoBufferPath::DmaBuf: return "DMABuf"; + case HwVideoBufferPath::GlMemory: return "GL"; + case HwVideoBufferPath::D3D11: return "D3D11"; + case HwVideoBufferPath::D3D12: return "D3D12"; + case HwVideoBufferPath::IOSurface: return "IOSurface"; + case HwVideoBufferPath::AHardwareBuffer: return "AHWBuf"; + case HwVideoBufferPath::Vulkan: return "Vulkan"; + case HwVideoBufferPath::None: break; + } + return "None"; +} + +// Construct the QHwVideoBuffer for an already-resolved path (fast path: skips the gst_is_*_memory ladder). Returns +// nullptr for paths that need extra context the cache can't carry, forcing a full re-resolve. +std::unique_ptr constructForPath(HwVideoBufferPath path, GstSample* sample, + [[maybe_unused]] const GstVideoInfo& info, + [[maybe_unused]] QVideoFrameFormat format, + [[maybe_unused]] const HwVideoBufferContext& context) +{ + switch (path) { +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + case HwVideoBufferPath::DmaBuf: + if (context.dmaBufEglDisplay == EGL_NO_DISPLAY) { + return nullptr; + } + return std::make_unique(sample, info, format, context.dmaBufEglDisplay); +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + case HwVideoBufferPath::GlMemory: + return std::make_unique(sample, info, format); +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + case HwVideoBufferPath::D3D11: + return std::make_unique(sample, info, format); +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + case HwVideoBufferPath::D3D12: + return std::make_unique(sample, info, format); +#endif +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + case HwVideoBufferPath::Vulkan: + return std::make_unique(sample, info, format); +#endif +#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) + case HwVideoBufferPath::IOSurface: + return std::make_unique(sample, info, format); +#endif +#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + case HwVideoBufferPath::AHardwareBuffer: + if (context.ahbEglDisplay == EGL_NO_DISPLAY) { + return nullptr; + } + format.setPixelFormat(QVideoFrameFormat::Format_SamplerExternalOES); + return std::make_unique(sample, info, format, context.ahbEglDisplay); +#endif + default: + return nullptr; + } +} + +bool memoryMatchesPath(HwVideoBufferPath path, GstMemory* mem, [[maybe_unused]] const HwVideoBufferContext& context) +{ + if (!mem) { + return false; + } + + switch (path) { +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + case HwVideoBufferPath::DmaBuf: + return context.dmaBufEglDisplay != EGL_NO_DISPLAY && gst_is_dmabuf_memory(mem); +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + case HwVideoBufferPath::GlMemory: + return gst_is_gl_memory(mem); +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + case HwVideoBufferPath::D3D11: + return gst_is_d3d11_memory(mem); +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + case HwVideoBufferPath::D3D12: + return gst_is_d3d12_memory(mem); +#endif +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + case HwVideoBufferPath::Vulkan: + return gst_is_vulkan_image_memory(mem); +#endif +#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) + case HwVideoBufferPath::IOSurface: + return mem->allocator && g_strcmp0(mem->allocator->mem_type, "AppleCoreVideoMemory") == 0; +#endif +#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + case HwVideoBufferPath::AHardwareBuffer: + return context.ahbEglDisplay != EGL_NO_DISPLAY && gst_is_ahardware_buffer_memory(mem); +#endif + case HwVideoBufferPath::None: + break; + default: + break; + } + + return false; +} + +} // namespace + +std::unique_ptr makeHwVideoBuffer(GstSample* sample, [[maybe_unused]] const GstVideoInfo& info, + [[maybe_unused]] QVideoFrameFormat format, + const HwVideoBufferContext& context, HwVideoBufferPath& matchedPath, + HwResolvedPathCache* cache) +{ + matchedPath = HwVideoBufferPath::None; + if (!context.gpuEnabled || !sample) { + return nullptr; + } + + GstBuffer* buffer = gst_sample_get_buffer(sample); + if (!buffer) { + return nullptr; + } + + // Plane 0's allocator is the dispatch key; gst-video buffers from a single decoder use one allocator type across + // all planes. + GstMemory* mem0 = gst_buffer_peek_memory(buffer, 0); + if (!mem0) { + return nullptr; + } + + // Fast path: a path was resolved+validated earlier this epoch — construct directly, skip the predicate ladder and + // the per-sample validatePlaneHandles(). The allocator check stays per-buffer because decoders can change memory + // type under stable caps; stale cache entries fall through to a full re-resolve. + if (cache && cache->validated && cache->path != HwVideoBufferPath::None) { + if (memoryMatchesPath(cache->path, mem0, context)) { + if (auto buf = constructForPath(cache->path, sample, info, format, context)) { + matchedPath = cache->path; + return buf; + } + } + cache->validated = false; + } + + // Validate before commit — failure resets matchedPath so per-path counters don't double-count. + auto buildOrFallback = [&matchedPath, &info, cache](auto&& buf) -> std::unique_ptr { + if (!buf || !buf->validatePlaneHandles()) { + GstHwPathTelemetry::recordFallbackReason(matchedPath, GstHwPathTelemetry::HwFallbackReason::ValidateFailed); + static std::atomic s_validateFails{0}; + const quint64 c = s_validateFails.fetch_add(1, std::memory_order_relaxed) + 1; + if ((c & 0x3F) == 1) { + qCWarning(GstHwBufFactoryLog) + << "validatePlaneHandles failed — CPU memcpy fallback (total:" << c << ")"; + } + matchedPath = HwVideoBufferPath::None; + return nullptr; + } + if (cache && !cache->validated) { + qCDebug(GstHwBufFactoryLog).noquote() + << "zero-copy path selected:" << pathName(matchedPath) + << "format=" << gst_video_format_to_string(GST_VIDEO_INFO_FORMAT(&info)) + << GST_VIDEO_INFO_WIDTH(&info) << "x" << GST_VIDEO_INFO_HEIGHT(&info); + } + if (cache) { + cache->path = matchedPath; + cache->validated = true; + } + return buf; + }; + +#if defined(QGC_HAS_GST_CUDA_GPU_PATH) + // NVMM/CUDA memory exports to a DMABuf fd (gst_cuda_memory_export / Jetson NvBufSurfaceMapEglImage) and then reuses + // the DMABuf EGLImage path, avoiding a separate CUDA-GL interop. Scaffold: requires NVIDIA hardware to validate. + if (gst_is_cuda_memory(mem0)) { + // One-shot capability latch: desktop dGPU drivers can reject the export every frame, so probe once and skip + // the per-frame retry thereafter (CPU fallback). + static std::atomic s_cudaExportUnsupported{false}; + if (s_cudaExportUnsupported.load(std::memory_order_relaxed)) { + return nullptr; + } + matchedPath = HwVideoBufferPath::DmaBuf; + if (auto buf = buildOrFallback(GstCudaVideoBuffer::exportToDmaBuf(sample, info, format, context))) { + return buf; + } + if (!s_cudaExportUnsupported.exchange(true, std::memory_order_relaxed)) { + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::DmaBuf, + GstHwPathTelemetry::HwFallbackReason::NoExt); + qCWarning(GstHwBufFactoryLog) << "CUDA->DMABuf export unsupported on this driver — CPU fallback for the" + << "remainder of the process"; + } + matchedPath = HwVideoBufferPath::None; + return nullptr; + } +#endif + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + if (context.dmaBufEglDisplay != EGL_NO_DISPLAY && gst_is_dmabuf_memory(mem0)) { + matchedPath = HwVideoBufferPath::DmaBuf; + return buildOrFallback(std::make_unique(sample, info, format, context.dmaBufEglDisplay)); + } +#endif + +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + if (gst_is_gl_memory(mem0)) { + matchedPath = HwVideoBufferPath::GlMemory; + return buildOrFallback(std::make_unique(sample, info, format)); + } +#endif + +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + if (gst_is_d3d11_memory(mem0)) { + matchedPath = HwVideoBufferPath::D3D11; + return buildOrFallback(std::make_unique(sample, info, format)); + } +#endif + +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + if (gst_is_d3d12_memory(mem0)) { + matchedPath = HwVideoBufferPath::D3D12; + return buildOrFallback(std::make_unique(sample, info, format)); + } +#endif + +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + if (gst_is_vulkan_image_memory(mem0)) { + matchedPath = HwVideoBufferPath::Vulkan; + return buildOrFallback(std::make_unique(sample, info, format)); + } +#endif + +#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) + // String-compare the allocator name; gst-applemedia exports no public gst_is_apple_core_video_memory() predicate. + if (mem0->allocator && g_strcmp0(mem0->allocator->mem_type, "AppleCoreVideoMemory") == 0) { + matchedPath = HwVideoBufferPath::IOSurface; + return buildOrFallback(std::make_unique(sample, info, format)); + } +#endif + +#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + if (context.ahbEglDisplay != EGL_NO_DISPLAY && gst_is_ahardware_buffer_memory(mem0)) { + matchedPath = HwVideoBufferPath::AHardwareBuffer; + // GL_TEXTURE_EXTERNAL_OES requires the SamplerExternalOES pixel format for Qt's shader path. + format.setPixelFormat(QVideoFrameFormat::Format_SamplerExternalOES); + return buildOrFallback( + std::make_unique(sample, info, format, context.ahbEglDisplay)); + } +#endif + + { + static QSet s_seen; + static QMutex s_mtx; + const QString memType = + mem0->allocator ? QString::fromUtf8(mem0->allocator->mem_type) : QStringLiteral(""); + QMutexLocker lock(&s_mtx); + if (!s_seen.contains(memType)) { + s_seen.insert(memType); + qCDebug(GstHwBufFactoryLog) << "no zero-copy path for memory type" << memType + << "— falling back to CPU memcpy"; + } + } + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::None, + GstHwPathTelemetry::HwFallbackReason::UnknownMemType); + if (cache) { + cache->resolved = true; + } + return nullptr; +} diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwVideoBufferFactory.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwVideoBufferFactory.h new file mode 100644 index 000000000000..fd072859460d --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/GstHwVideoBufferFactory.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include +#include + +class QHwVideoBuffer; + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) || defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) +#include +#endif + +/// Identifies which GPU path was chosen; used by the adapter to increment the right counter. +enum class HwVideoBufferPath +{ + None, + DmaBuf, + GlMemory, + D3D11, + D3D12, + IOSurface, + AHardwareBuffer, + Vulkan, +}; + +/// Per-stream resolved-path cache, keyed to a caps epoch. Lets makeHwVideoBuffer() skip the predicate ladder and the +/// per-sample validatePlaneHandles() once a path has been resolved+validated for the current caps. Reset on set_caps. +struct HwResolvedPathCache +{ + HwVideoBufferPath path = HwVideoBufferPath::None; + bool validated = false; // path resolved AND validatePlaneHandles() passed for this epoch + bool resolved = false; // selection ran at least once this epoch (HW or CPU decided) + bool demotionRecorded = false; // one-shot guard for recordStreamDemotion() per epoch +}; + +/// Platform context for the factory; encapsulates EGL handles so callers don't need path-specific ifdefs. +struct HwVideoBufferContext +{ + bool gpuEnabled = false; +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + EGLDisplay dmaBufEglDisplay = EGL_NO_DISPLAY; +#endif +#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + EGLDisplay ahbEglDisplay = EGL_NO_DISPLAY; +#endif +}; + +/// Selects the zero-copy QHwVideoBuffer for a GstSample; returns nullptr when no GPU path matches (CPU memcpy +/// fallback). +std::unique_ptr makeHwVideoBuffer(GstSample* sample, const GstVideoInfo& info, QVideoFrameFormat format, + const HwVideoBufferContext& context, HwVideoBufferPath& matchedPath, + HwResolvedPathCache* cache = nullptr); diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/HwBuffers.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/HwBuffers.cc new file mode 100644 index 000000000000..358bda5a1631 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/HwBuffers.cc @@ -0,0 +1,391 @@ +#include "HwBuffers.h" + +#if defined(QGC_HAS_ANY_GPU_PATH) +#include "GstContextBridgeRegistry.h" +#endif +#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) +#include "GstAHardwareBufferVideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) +#include "GstDmaBufVideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) +#include "GstD3D11ContextBridge.h" +#include "GstD3D11VideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) +#include "GstD3D12ContextBridge.h" +#include "GstD3D12VideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) +#include "GstGlContextBridge.h" +#endif +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) +#include "GstVulkanContextBridge.h" +#endif +#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) +#include "GstIOSurfaceVideoBuffer.h" +#endif +#if defined(QGC_HAS_ANY_GPU_PATH) +#include "QGCRhiCapture.h" +#endif + +#if defined(QGC_HAS_ANY_GPU_PATH) +#include "QGCLoggingCategory.h" +QGC_LOGGING_CATEGORY(HwBuffersFacadeLog, "Video.GStreamer.HwBuffers.Facade") +#endif +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) || defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) +#include +#include +#include + +#include "GstEglHelpers.h" +#endif + +#include +#include +#include + +#include "QGCLoggingCategory.h" + +namespace HwBuffers { + +QGC_LOGGING_CATEGORY(HwBuffersConfigLog, "Video.GStreamer.HwBuffers.Config") + +#if defined(QGC_HAS_ANY_GPU_PATH) +namespace { +// Compile-time table of enabled GPU paths; iterated by resetCachedGpuResources/formatPathStats to avoid repeating the +// ifdef ladder. +struct GpuPathEntry +{ + HwVideoBufferPath path; + const char* label; +}; + +constexpr std::array + kEnabledPaths = {{ +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + {HwVideoBufferPath::DmaBuf, "DMABuf"}, +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + {HwVideoBufferPath::GlMemory, "GL"}, +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + {HwVideoBufferPath::D3D11, "D3D11"}, +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + {HwVideoBufferPath::D3D12, "D3D12"}, +#endif +#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) + {HwVideoBufferPath::IOSurface, "IOSurface"}, +#endif +#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + {HwVideoBufferPath::AHardwareBuffer, "AHWBuf"}, +#endif +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + {HwVideoBufferPath::Vulkan, "Vulkan"}, +#endif + }}; +} // namespace +#endif + +void dispatchBusMessage(GstMessage* msg) noexcept +{ + if (!msg || GST_MESSAGE_TYPE(msg) != GST_MESSAGE_ERROR) + return; + + // Only a real device loss should drop every GPU cache; ordinary stream errors must not. There is no structured + // device-lost event, so classify by the D3D/DXGI device-removed/reset text surfaced in the error/debug strings. + GError* err = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(msg, &err, &debug); + + const auto mentionsDeviceLoss = [](const gchar* text) { + return text && (g_strstr_len(text, -1, "DEVICE_REMOVED") || g_strstr_len(text, -1, "DEVICE_RESET") || + g_strstr_len(text, -1, "device removed") || g_strstr_len(text, -1, "device reset")); + }; + const bool deviceLost = mentionsDeviceLoss(err ? err->message : nullptr) || mentionsDeviceLoss(debug); + + if (err) + g_error_free(err); + g_free(debug); + + if (deviceLost) + resetCachedGpuResources(); +} + +void initializeOnce() noexcept +{ + // Bridges self-register at static-init; function kept as a stable call site for future lazy init. +} + +const HwBufferEnvConfig& hwBufferEnvConfig() noexcept +{ + static const HwBufferEnvConfig cfg = []() noexcept { + const auto truthy = [](const char* name, bool dflt) noexcept { + const QByteArray v = qgetenv(name).trimmed().toLower(); + if (v.isEmpty()) { + return dflt; + } + return v != "0" && v != "false" && v != "off" && v != "no"; + }; + HwBufferEnvConfig c; + c.dmaBufCache = truthy("QGC_GST_DMABUF_CACHE", false); + c.dmaBufSingleEglImage = truthy("QGC_GST_DMABUF_SINGLE_EGLIMAGE", true); + c.dmaBufNoMmapFence = truthy("QGC_GST_DMABUF_NO_MMAP_FENCE", false); + c.offerDmaDrmLinear = truthy("QGC_GST_OFFER_DMA_DRM_LINEAR", false); + qCInfo(HwBuffersConfigLog).nospace() + << "HwBuffer env config: QGC_GST_DMABUF_CACHE=" << c.dmaBufCache + << " QGC_GST_DMABUF_SINGLE_EGLIMAGE=" << c.dmaBufSingleEglImage + << " QGC_GST_DMABUF_NO_MMAP_FENCE=" << c.dmaBufNoMmapFence + << " QGC_GST_OFFER_DMA_DRM_LINEAR=" << c.offerDmaDrmLinear; + return c; + }(); + return cfg; +} + +GstBusSyncReply onBusSyncMessage(GstBus* /*bus*/, GstMessage* msg, gpointer /*userData*/) noexcept +{ +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) || defined(QGC_HAS_GST_D3D11_GPU_PATH) || \ + defined(QGC_HAS_GST_D3D12_GPU_PATH) || defined(QGC_HAS_GST_VULKAN_GPU_PATH) + return GstContextBridgeRegistry::dispatchBridges(msg); +#else + Q_UNUSED(msg); + return GST_BUS_PASS; +#endif +} + +void onPipelineRestart() noexcept +{ + resetCachedGpuResources(); + // GL is the only path needing a pipeline-restart rearm (re-prime the shared GstGLContext). +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + GstGlContextBridge::rearm(); +#endif +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + GstVulkanContextBridge::rearm(); +#endif +} + +void resetCachedGpuResources() noexcept +{ +#if defined(QGC_HAS_ANY_GPU_PATH) + GstContextBridgeRegistry::resetAllBridges(); + GstContextBridgeRegistry::resetAllCaches(); +#endif +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) || defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + GstEglHelpers::resetExtensionCache(); +#endif +} + +void connectMainWindow(QQuickWindow* window) noexcept +{ +#if defined(QGC_HAS_ANY_GPU_PATH) + QGCRhiCapture::connectWindow(window); +#else + Q_UNUSED(window); +#endif +} + +#if defined(QGC_HAS_ANY_GPU_PATH) +HwVideoBufferContext makeAdapterContext(bool gpuEnabled) noexcept +{ + HwVideoBufferContext ctx; + ctx.gpuEnabled = gpuEnabled; + if (!gpuEnabled) { + return ctx; + } + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + // Construction-time hint only; render-time mapTextures() prefers eglGetCurrentDisplay() to avoid xcb_egl EGLDisplay + // mismatches. + EGLDisplay dpy = EGL_NO_DISPLAY; + const QString platform = QGuiApplication::platformName(); + if (platform == QLatin1String("wayland") || platform == QLatin1String("wayland-egl")) { + if (auto* ni = QGuiApplication::platformNativeInterface()) { + dpy = static_cast(ni->nativeResourceForIntegration("egldisplay")); + } + } + if (dpy == EGL_NO_DISPLAY) { + dpy = eglGetDisplay(EGL_DEFAULT_DISPLAY); + } + if (dpy == EGL_NO_DISPLAY) { + qCWarning(HwBuffersFacadeLog) << "GPU zero-copy requested but EGLDisplay unavailable on platform" << platform + << "— DMABuf path disabled"; + } else { + qCInfo(HwBuffersFacadeLog) << "DMABuf zero-copy path available on" << platform + << "— actual path chosen at caps negotiation"; + } + ctx.dmaBufEglDisplay = dpy; +#endif + +#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + ctx.ahbEglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (ctx.ahbEglDisplay == EGL_NO_DISPLAY) { + qCWarning(HwBuffersFacadeLog) << "AHardwareBuffer path: EGLDisplay unavailable"; + } else { + qCInfo(HwBuffersFacadeLog) << "AHardwareBuffer zero-copy path available" + << "— actual path chosen at caps negotiation"; + } +#endif + +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + qCInfo(HwBuffersFacadeLog) << "D3D11 zero-copy path available — actual path chosen at caps negotiation"; +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + qCInfo(HwBuffersFacadeLog) << "D3D12 zero-copy path available — actual path chosen at caps negotiation"; +#endif +#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) + qCInfo(HwBuffersFacadeLog) << "IOSurface zero-copy path available — actual path chosen at caps negotiation"; +#endif +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + qCInfo(HwBuffersFacadeLog) << "Vulkan zero-copy path available (active only when QRhi uses the Vulkan backend)"; +#endif + + return ctx; +} +#endif // QGC_HAS_ANY_GPU_PATH + +bool answerSinkBinContextQuery(GstQuery* query) noexcept +{ + bool handled = false; +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + handled = GstGlContextBridge::answerContextQuery(query); +#endif +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + if (!handled) { + handled = GstVulkanContextBridge::answerContextQuery(query); + } +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + if (!handled) { + handled = GstD3D11ContextBridge::answerContextQuery(query); + } +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + if (!handled) { + handled = GstD3D12ContextBridge::answerContextQuery(query); + } +#endif + Q_UNUSED(query); + return handled; +} + +PathStats formatPathStats(bool reset) noexcept +{ + PathStats out; +#if defined(QGC_HAS_ANY_GPU_PATH) + for (const auto& entry : kEnabledPaths) { + const quint64 delivered = reset ? GstHwPathTelemetry::takeDeliveredCount(entry.path) + : GstHwPathTelemetry::peekDeliveredCount(entry.path); + const quint64 failures = reset ? GstHwPathTelemetry::takeMapFailureCount(entry.path) + : GstHwPathTelemetry::peekMapFailureCount(entry.path); + // Teardown (reset) uses expanded label so operators can copy the failures field into bug reports. + if (reset) { + out.line += + QStringLiteral(" %1:%2 %1-failures:%3").arg(QLatin1String(entry.label)).arg(delivered).arg(failures); + } else { + out.line += QStringLiteral(" %1:%2/%3").arg(QLatin1String(entry.label)).arg(delivered).arg(failures); + } + out.totalDelivered += delivered; + } +#else + Q_UNUSED(reset); +#endif + return out; +} + +QString takeExtraPathStats() noexcept +{ + QString out; +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + const quint64 glReuse = GstHwPathTelemetry::takeTextureReuseHits(HwVideoBufferPath::GlMemory); + quint64 glGpuWaits = 0; + const quint64 glCpuWaits = GstHwPathTelemetry::takeSyncWaitCounts(HwVideoBufferPath::GlMemory, glGpuWaits); + out += QStringLiteral(" GL-reuse:%1 GL-wait[gpu/cpu]:%2/%3").arg(glReuse).arg(glGpuWaits).arg(glCpuWaits); +#endif +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + quint64 vkGpuWaits = 0; + const quint64 vkCpuWaits = GstHwPathTelemetry::takeSyncWaitCounts(HwVideoBufferPath::Vulkan, vkGpuWaits); + out += QStringLiteral(" Vulkan-wait[gpu/cpu]:%1/%2").arg(vkGpuWaits).arg(vkCpuWaits); +#endif +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + out += QStringLiteral(" DMABuf-fence-timeouts:%1 DMABuf-mmap-barriers:%2 DMABuf-explicit-fence-waits:%3") + .arg(GstHwPathTelemetry::takeFenceTimeouts(HwVideoBufferPath::DmaBuf)) + .arg(GstHwPathTelemetry::takeMmapBarrierHits(HwVideoBufferPath::DmaBuf)) + .arg(GstHwPathTelemetry::takeExplicitFenceWaits(HwVideoBufferPath::DmaBuf)); +#endif +#if defined(QGC_HAS_ANY_GPU_PATH) + static constexpr std::pair kReasons[] = { + {GstHwPathTelemetry::HwFallbackReason::NoExt, "no-ext"}, + {GstHwPathTelemetry::HwFallbackReason::ModifierRejected, "modifier"}, + {GstHwPathTelemetry::HwFallbackReason::EglBadMatch, "egl-bad-match"}, + {GstHwPathTelemetry::HwFallbackReason::FenceTimeout, "fence-timeout"}, + {GstHwPathTelemetry::HwFallbackReason::ValidateFailed, "validate"}, + {GstHwPathTelemetry::HwFallbackReason::UnknownMemType, "unknown-mem"}, + {GstHwPathTelemetry::HwFallbackReason::NullSample, "null-sample"}, + {GstHwPathTelemetry::HwFallbackReason::MapFailed, "map-failed"}, + {GstHwPathTelemetry::HwFallbackReason::VulkanNoSync, "vulkan-no-sync"}, + {GstHwPathTelemetry::HwFallbackReason::ImportUnsupported, "import-unsupported"}, + }; + for (const auto& entry : kEnabledPaths) { + const quint64 ewmaUs = GstHwPathTelemetry::peekMapDurationUsEwma(entry.path); + if (ewmaUs > 0) { + out += QStringLiteral(" %1-map-us:%2").arg(QLatin1String(entry.label)).arg(ewmaUs); + } + const quint64 demotions = GstHwPathTelemetry::takeStreamDemotions(entry.path); + if (demotions > 0) { + out += QStringLiteral(" %1-demotions:%2").arg(QLatin1String(entry.label)).arg(demotions); + } + QString reasonBreakdown; + for (const auto& [reason, label] : kReasons) { + const quint64 n = GstHwPathTelemetry::takeFallbackReason(entry.path, reason); + if (n > 0) { + reasonBreakdown += QStringLiteral("%1=%2,").arg(QLatin1String(label)).arg(n); + } + } + if (!reasonBreakdown.isEmpty()) { + reasonBreakdown.chop(1); + out += QStringLiteral(" %1-fallback[%2]").arg(QLatin1String(entry.label)).arg(reasonBreakdown); + } + } + // None-path accumulates UnknownMemType (no allocator matched any compiled path). + { + const quint64 unknownMem = + GstHwPathTelemetry::takeFallbackReason(HwVideoBufferPath::None, + GstHwPathTelemetry::HwFallbackReason::UnknownMemType); + if (unknownMem > 0) { + out += QStringLiteral(" None-fallback[unknown-mem=%1]").arg(unknownMem); + } + const quint64 cpuDemotions = GstHwPathTelemetry::takeStreamDemotions(HwVideoBufferPath::None); + if (cpuDemotions > 0) { + out += QStringLiteral(" None-demotions:%1").arg(cpuDemotions); + } + } +#endif + return out; +} + +} // namespace HwBuffers diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/HwBuffers.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/HwBuffers.h new file mode 100644 index 000000000000..610ec0b5790e --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/HwBuffers.h @@ -0,0 +1,72 @@ +#pragma once + +/// Umbrella header / public facade for the HwBuffers subsystem; per-platform bridges stay internal. + +#include +#include +#include + +class QQuickWindow; + +// HwVideoBufferContext/Path and telemetry are unconditional (CPU path uses them); GstHwVideoBuffer needs +// MultimediaPrivate so it's GPU-only. +#include "GstHwPathTelemetry.h" +#include "GstHwVideoBufferFactory.h" // HwVideoBufferContext, HwVideoBufferPath, makeHwVideoBuffer +#if defined(QGC_HAS_ANY_GPU_PATH) +#include "GstHwVideoBuffer.h" +#endif + +namespace HwBuffers { + +/// One-time process init; single call site for future lazy bridge registration. +void initializeOnce() noexcept; + +/// Process-wide QGC_GST_* runtime toggles, parsed once and logged at first access so operators can paste the +/// effective config into bug reports. Semantics match the historical per-call-site g_getenv reads. +struct HwBufferEnvConfig +{ + bool dmaBufCache = false; // QGC_GST_DMABUF_CACHE (default off) + bool dmaBufSingleEglImage = true; // QGC_GST_DMABUF_SINGLE_EGLIMAGE (default on) + bool dmaBufNoMmapFence = false; // QGC_GST_DMABUF_NO_MMAP_FENCE (default off) + bool offerDmaDrmLinear = false; // QGC_GST_OFFER_DMA_DRM_LINEAR (default off) +}; + +/// Lazily parses + logs the toggle config on first call; thread-safe via static-init guarantees. +const HwBufferEnvConfig& hwBufferEnvConfig() noexcept; + +/// Bus sync handler (GstBusSyncHandler) installed on every pipeline; no-op when no GPU path compiled. +GstBusSyncReply onBusSyncMessage(GstBus* bus, GstMessage* msg, gpointer userData) noexcept; + +/// Receiver-side bus hook; drops cached GPU devices on GST_MESSAGE_ERROR. No-op when no GPU paths compiled. +void dispatchBusMessage(GstMessage* msg) noexcept; + +/// Pipeline-restart hook; re-arms one-shot priming latches so a restart can prime on the next NEED_CONTEXT. +void onPipelineRestart() noexcept; + +/// Drop process-wide native GPU handles tied to the current Qt scene-graph device/context. +void resetCachedGpuResources() noexcept; + +/// Wire the main QQuickWindow into the RHI-capture path so snapshots follow its QRhi; no-op without GPU. +void connectMainWindow(QQuickWindow* window) noexcept; + +#if defined(QGC_HAS_ANY_GPU_PATH) +/// Populate the per-pipeline HwVideoBufferContext (EGL displays, gpu-enabled flag) the factory needs. +HwVideoBufferContext makeAdapterContext(bool gpuEnabled) noexcept; +#endif + +/// Synchronously answer GST_QUERY_CONTEXT (gst.gl.GLDisplay/app_context); false -> let bus NEED_CONTEXT run. +bool answerSinkBinContextQuery(GstQuery* query) noexcept; + +/// Formatted per-path counters + delivered total; reset=true reads-and-clears (teardown), false peeks. +struct PathStats +{ + QString line; + quint64 totalDelivered = 0; +}; + +PathStats formatPathStats(bool reset) noexcept; + +/// Path-specific extras after formatPathStats (GL reuse/sync waits); reads-and-clears, teardown only. +QString takeExtraPathStats() noexcept; + +} // namespace HwBuffers diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/QGCRhiCapture.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/QGCRhiCapture.cc new file mode 100644 index 000000000000..580bad6e9b03 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/QGCRhiCapture.cc @@ -0,0 +1,149 @@ +#include "QGCRhiCapture.h" + +#if defined(QGC_HAS_ANY_GPU_PATH) +#include "HwBuffers.h" +#endif + +#include +#include +#include +#if defined(Q_OS_WIN) && (defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH)) +#include +#endif + +#include +#include + +namespace QGCRhiCapture { + +namespace { +std::atomic s_cachedRhi{nullptr}; +// The connected window (or null after it is destroyed); read only by connectWindow's idempotency +// guard. Atomic because the destroyed lambda nulls it on the window's thread. +std::atomic s_connectedWindow{nullptr}; +DeviceSnapshot s_snapshot; +// Per-connection so a window swap (e.g. popout video) can disconnect the prior window's lambdas before they clobber the +// new window's cached RHI. +std::array s_connections; + +void clearSnapshot() +{ + s_snapshot.backend.store(-1, std::memory_order_release); + s_snapshot.d3d11Device.store(nullptr, std::memory_order_release); + s_snapshot.d3d12Device.store(nullptr, std::memory_order_release); + s_snapshot.adapterLuid.store(0, std::memory_order_release); +} + +void populateSnapshot(QRhi* rhi) +{ + if (!rhi) { + clearSnapshot(); + return; + } + const int backend = static_cast(rhi->backend()); + void* d3d11 = nullptr; + void* d3d12 = nullptr; + qint64 luid = 0; + +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) + if (rhi->backend() == QRhi::D3D11) { + if (auto* h = static_cast(rhi->nativeHandles())) { + d3d11 = h->dev; + // Sign-extend qint32 HighPart so it matches LARGE_INTEGER::QuadPart bit-for-bit. + luid = (static_cast(h->adapterLuidHigh) << 32) | + (static_cast(h->adapterLuidLow) & 0xFFFFFFFFLL); + } + } +#endif +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) + if (rhi->backend() == QRhi::D3D12) { + if (auto* h = static_cast(rhi->nativeHandles())) { + d3d12 = h->dev; + luid = (static_cast(h->adapterLuidHigh) << 32) | + (static_cast(h->adapterLuidLow) & 0xFFFFFFFFLL); + } + } +#endif + + // Publish device pointers before the backend tag so a consumer's acquire-load on backend != -1 is guaranteed to see + // populated handles. + s_snapshot.d3d11Device.store(d3d11, std::memory_order_release); + s_snapshot.d3d12Device.store(d3d12, std::memory_order_release); + s_snapshot.adapterLuid.store(luid, std::memory_order_release); + s_snapshot.backend.store(backend, std::memory_order_release); +} +} // namespace + +QRhi* cachedRhi() noexcept +{ + return s_cachedRhi.load(std::memory_order_acquire); +} + +DeviceSnapshot& deviceSnapshot() noexcept +{ + return s_snapshot; +} + +void connectWindow(QQuickWindow* window) +{ + if (!window) + return; + if (s_connectedWindow.load(std::memory_order_acquire) == window) + return; // idempotent — avoid duplicate connections + + // Detach the prior window's signals first, else its destroyed/invalidated lambdas later wipe cachedRhi() and reset + // bridges the new window is using. + for (std::size_t i = 0; i < s_connections.size(); ++i) { + auto& conn = s_connections[i]; + QObject::disconnect(conn); + conn = QMetaObject::Connection(); + } + + s_connectedWindow.store(window, std::memory_order_release); + // sceneGraphInitialized fires on the render thread where rhi() is valid — snapshot native device pointers here so + // bus-sync callbacks never deref QRhi* cross-thread. + s_connections[0] = QObject::connect( + window, &QQuickWindow::sceneGraphInitialized, window, + [window]() { + QRhi* rhi = window->rhi(); + s_cachedRhi.store(rhi, std::memory_order_release); + populateSnapshot(rhi); + }, + Qt::DirectConnection); + s_connections[1] = QObject::connect( + window, &QQuickWindow::sceneGraphInvalidated, window, + []() { + s_cachedRhi.store(nullptr, std::memory_order_release); + clearSnapshot(); +#if defined(QGC_HAS_ANY_GPU_PATH) + // Drop native GPU handles that wrap the now-defunct QRhi-owned device/context. + HwBuffers::resetCachedGpuResources(); +#endif + }, + Qt::DirectConnection); + // Clear cache when window is destroyed so a stale QRhi* is never returned. + s_connections[2] = QObject::connect( + window, &QQuickWindow::destroyed, window, + [](QObject*) { + s_connectedWindow.store(nullptr, std::memory_order_release); + s_cachedRhi.store(nullptr, std::memory_order_release); + clearSnapshot(); +#if defined(QGC_HAS_ANY_GPU_PATH) + HwBuffers::resetCachedGpuResources(); +#endif + }, + Qt::DirectConnection); + + // SG already initialized (video init runs after first render) — publish on the render thread, where rhi() is valid. + if (window->isSceneGraphInitialized()) { + window->scheduleRenderJob(QRunnable::create([window]() { + QRhi* rhi = window->rhi(); + s_cachedRhi.store(rhi, std::memory_order_release); + populateSnapshot(rhi); + }), + QQuickWindow::BeforeSynchronizingStage); + window->update(); + } +} + +} // namespace QGCRhiCapture diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/QGCRhiCapture.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/QGCRhiCapture.h new file mode 100644 index 000000000000..6804d6dc7a45 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/common/QGCRhiCapture.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +class QRhi; +class QQuickWindow; + +/// Resolves and caches the `QRhi*` driving QGC's main scene graph; Qt has no global RHI accessor. +namespace QGCRhiCapture { + +/// Cached QRhi* maintained by sceneGraph signals; safe from any thread via acquire ordering. +QRhi* cachedRhi() noexcept; + +/// Atomic snapshot of native device handles; populated on render thread (where nativeHandles() is safe), safe from any +/// thread. +struct DeviceSnapshot +{ + std::atomic backend{-1}; ///< QRhi::Implementation cast to int (-1 = unset) + std::atomic d3d11Device{nullptr}; ///< ID3D11Device* (Windows only) + std::atomic d3d12Device{nullptr}; ///< ID3D12Device* (Windows only) + std::atomic adapterLuid{0}; ///< Composed (high<<32)|low LUID, 0 if unknown +}; + +/// Returns the global snapshot. Atomic fields make individual reads thread-safe. +DeviceSnapshot& deviceSnapshot() noexcept; + +/// Call once from the GUI thread once the main QQuickWindow exists; wires sceneGraph signals to maintain cachedRhi() +/// and the device snapshot. +void connectWindow(QQuickWindow* window); + +} // namespace QGCRhiCapture diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/cuda/GstCudaVideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/cuda/GstCudaVideoBuffer.cc new file mode 100644 index 000000000000..22548ea3dac7 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/cuda/GstCudaVideoBuffer.cc @@ -0,0 +1,106 @@ +#include "GstCudaVideoBuffer.h" + +#if defined(QGC_HAS_GST_CUDA_GPU_PATH) + +#include +#include +#include +#include +#include + +#include "GstDmaBufVideoBuffer.h" + +namespace GstCudaVideoBuffer { +namespace { + +// gst_cuda_memory_export only succeeds for cuMemCreate/cuMemMap-backed allocations (ALLOC_MMAP); the common +// cuMemAlloc/pitch decoder default cannot be shared and returns FALSE -> nullptr -> factory CPU latch. +int exportCudaMemoryToFd(GstMemory* mem) +{ + if (!gst_is_cuda_memory(mem)) { + return -1; + } + auto* cudaMem = reinterpret_cast(mem); + + // Linux: os_handle is an int* receiving a POSIX file descriptor (CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR). + // gst_cuda_memory_export hands us an owned fd; we transfer it to the dmabuf allocator below. + int fd = -1; + if (!gst_cuda_memory_export(cudaMem, &fd) || fd < 0) { + return -1; + } + return fd; +} + +} // namespace + +std::unique_ptr exportToDmaBuf(GstSample* sample, const GstVideoInfo& info, QVideoFrameFormat format, + const HwVideoBufferContext& context) +{ + if (context.dmaBufEglDisplay == EGL_NO_DISPLAY) { + return nullptr; // No EGLImage display to import the exported fd; fall back to CPU. + } + + GstBuffer* buffer = gst_sample_get_buffer(sample); + if (!buffer) { + return nullptr; + } + + // Single contiguous CUDA allocation per frame (NV12/P010 planes share one device buffer); multi-memory CUDA + // layouts are out of scope, so only memory 0 is exported. + GstMemory* mem0 = gst_buffer_peek_memory(buffer, 0); + const int fd = exportCudaMemoryToFd(mem0); + if (fd < 0) { + return nullptr; + } + + GstAllocator* allocator = gst_dmabuf_allocator_new(); + if (!allocator) { + close(fd); + return nullptr; + } + + // Allocator takes ownership of fd; on success it is closed when dmaMem (and the GstBuffer wrapping it) is unreffed. + GstMemory* dmaMem = gst_dmabuf_allocator_alloc(allocator, fd, gst_memory_get_sizes(mem0, nullptr, nullptr)); + if (!dmaMem) { + close(fd); + gst_object_unref(allocator); + return nullptr; + } + + GstBuffer* dmaBuffer = gst_buffer_new(); + if (!dmaBuffer) { + gst_memory_unref(dmaMem); + gst_object_unref(allocator); + return nullptr; + } + gst_buffer_append_memory(dmaBuffer, dmaMem); + + // Carry the source GstVideoMeta so GstDmaBufVideoBuffer reads correct per-plane offsets/strides without mmapping. + if (GstVideoMeta* srcMeta = gst_buffer_get_video_meta(buffer)) { + gst_buffer_add_video_meta_full(dmaBuffer, GST_VIDEO_FRAME_FLAG_NONE, srcMeta->format, srcMeta->width, + srcMeta->height, srcMeta->n_planes, srcMeta->offset, srcMeta->stride); + } else { + gst_buffer_add_video_meta(dmaBuffer, GST_VIDEO_FRAME_FLAG_NONE, GST_VIDEO_INFO_FORMAT(&info), + GST_VIDEO_INFO_WIDTH(&info), GST_VIDEO_INFO_HEIGHT(&info)); + } + + // Reuse the source caps: the exported fd is LINEAR device memory, so the original (non-DRM) caps drive a + // modifier-0 import in GstDmaBufVideoBuffer. + GstCaps* caps = gst_sample_get_caps(sample); + GstSample* dmaSample = gst_sample_new(dmaBuffer, caps, nullptr, nullptr); + gst_buffer_unref(dmaBuffer); + if (!dmaSample) { + gst_object_unref(allocator); + return nullptr; + } + + // GstHwVideoBuffer refs the sample; our local refs (sample + allocator) drop here. + auto buf = std::make_unique(dmaSample, info, format, context.dmaBufEglDisplay); + gst_sample_unref(dmaSample); + gst_object_unref(allocator); + return buf; +} + +} // namespace GstCudaVideoBuffer + +#endif // QGC_HAS_GST_CUDA_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/cuda/GstCudaVideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/cuda/GstCudaVideoBuffer.h new file mode 100644 index 000000000000..216814c58888 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/cuda/GstCudaVideoBuffer.h @@ -0,0 +1,28 @@ +#pragma once + +// SCAFFOLD — requires NVIDIA hardware to validate; gated behind QGC_HAS_GST_CUDA_GPU_PATH (never defined by CMake yet). +// CUDA/NVMM buffers are routed by exporting the device memory to a DMABuf fd and reusing the DMABuf EGLImage path +// rather than CUDA-GL interop. Enabling this requires CMake gating for gst/cuda/gstcuda.h (gst-plugins-bad CUDA) and a +// Jetson/desktop-NVIDIA test pass. + +#if defined(QGC_HAS_GST_CUDA_GPU_PATH) + +#include +#include +#include + +#include "GstHwVideoBufferFactory.h" + +class GstHwVideoBuffer; + +namespace GstCudaVideoBuffer { + +/// Export @p sample's CUDA/NVMM memory to a DMABuf-backed GstHwVideoBuffer (gst_cuda_memory_export on desktop, +/// NvBufSurfaceMapEglImage on Jetson). Returns nullptr to trigger the factory's CPU fallback when export is +/// unsupported on the running driver. +std::unique_ptr exportToDmaBuf(GstSample* sample, const GstVideoInfo& info, QVideoFrameFormat format, + const HwVideoBufferContext& context); + +} // namespace GstCudaVideoBuffer + +#endif // QGC_HAS_GST_CUDA_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D11ContextBridge.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D11ContextBridge.cc new file mode 100644 index 000000000000..ae5eed6690af --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D11ContextBridge.cc @@ -0,0 +1,104 @@ +#include "GstD3D11ContextBridge.h" + +#include "GstD3DContextBridgeCommon.h" + +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) + +#include +#include // ID3D11Multithread +#include +#include // QRhi::Implementation enum only; no runtime use across threads + +#include "QGCLoggingCategory.h" +#include "QGCRhiCapture.h" + +QGC_LOGGING_CATEGORY(GstD3D11BridgeLog, "Video.GStreamer.HwBuffers.GstD3D11Bridge") + +namespace GstD3D11ContextBridge { +namespace { + +GstD3DContextBridgeCommon::BridgeState s_state; + +GstObject* createDevice(const QLoggingCategory& cat) +{ + // Snapshot populated on the render thread (sceneGraphInitialized); only atomic loads here. + auto* dev = static_cast(QGCRhiCapture::deviceSnapshot().d3d11Device.load(std::memory_order_acquire)); + if (!dev) { + qCWarning(cat) << "QRhi D3D11 snapshot missing ID3D11Device*"; + return nullptr; + } + + // QRhi creates this device with devFlags=0 (qrhid3d11.cpp) — no multithread protection. GStreamer's streaming + // thread and the QRhi render thread share its immediate context, so guard it explicitly. Idempotent: a no-op if + // GStreamer or a future QRhi already enabled it. + ID3D11DeviceContext* immediate = nullptr; + dev->GetImmediateContext(&immediate); + if (immediate) { + ID3D11Multithread* multithread = nullptr; + if (SUCCEEDED(immediate->QueryInterface(__uuidof(ID3D11Multithread), reinterpret_cast(&multithread))) && + multithread) { + multithread->SetMultithreadProtected(TRUE); + multithread->Release(); + } + immediate->Release(); + } + + // gst_d3d11_device_new_wrapped: shared device keeps textures QRhi-sampleable without keyed-mutex transfer. + GstD3D11Device* device = gst_d3d11_device_new_wrapped(dev); + if (!device) { + qCWarning(cat) << "gst_d3d11_device_new_wrapped failed"; + return nullptr; + } + + const gint64 expectedLuid = QGCRhiCapture::deviceSnapshot().adapterLuid.load(std::memory_order_acquire); + GstD3DContextBridgeCommon::logAdapterMatch(expectedLuid, device, cat, "D3D11"); + return GST_OBJECT(device); +} + +GstContext* makeContext(GstObject* device) +{ + // gst_d3d11_context_new gst_object_ref's the device internally; caller retains ownership. + return gst_d3d11_context_new(GST_D3D11_DEVICE_CAST(device)); +} + +const GstD3DContextBridgeCommon::BridgeOps s_ops = { + "D3D11", GST_D3D11_DEVICE_HANDLE_CONTEXT_TYPE, &GstD3D11BridgeLog, int(QRhi::D3D11), &createDevice, &makeContext, +}; + +struct D3D11BridgeRegistrar +{ + D3D11BridgeRegistrar() { GstD3DContextBridgeCommon::registerBridge(GstD3D11BridgeLog(), &handleSyncMessage, &reset); } +}; + +static D3D11BridgeRegistrar s_d3d11BridgeRegistrar; + +} // namespace + +bool prime() +{ + return GstD3DContextBridgeCommon::prime(s_state, s_ops); +} + +GstD3D11Device* currentDevice() +{ + return GST_D3D11_DEVICE_CAST(GstD3DContextBridgeCommon::currentDevice(s_state)); +} + +GstBusSyncReply handleSyncMessage(GstMessage* message) +{ + return GstD3DContextBridgeCommon::handleSyncMessage(s_state, s_ops, message); +} + +bool answerContextQuery(GstQuery* query) +{ + return GstD3DContextBridgeCommon::answerContextQuery(s_state, s_ops, query); +} + +void reset() +{ + GstD3DContextBridgeCommon::reset(s_state, s_ops); +} + +} // namespace GstD3D11ContextBridge + +#endif // Q_OS_WIN && QGC_HAS_GST_D3D11_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D11ContextBridge.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D11ContextBridge.h new file mode 100644 index 000000000000..e61a1b9b5111 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D11ContextBridge.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) + +#include + +// Forward-declare the opaque type so currentDevice() parses without ; dereferencing callers +// include the full header. +typedef struct _GstD3D11Device GstD3D11Device; + +/// Process-wide shared GstD3D11Device answering NEED_CONTEXT (gst.d3d11.device.handle) with QRhi's ID3D11Device so +/// decoders are zero-copy. +namespace GstD3D11ContextBridge { + +/// Idempotent; wraps QRhi's live ID3D11Device into the shared GstD3D11Device. False (logs once) if QRhi isn't +/// D3D11/ready — retry on a later NEED_CONTEXT. +bool prime(); + +/// NEED_CONTEXT for gst.d3d11.device.handle -> respond with the shared device; GST_BUS_DROP when consumed, else +/// GST_BUS_PASS. Thread-safe. +GstBusSyncReply handleSyncMessage(GstMessage* message); + +/// Answer a sink-bin GST_QUERY_CONTEXT (gst.d3d11.device.handle) with the shared device. True when consumed. +bool answerContextQuery(GstQuery* query); + +/// Drop the cached GstD3D11Device so the next prime() rebuilds; call from receiver teardown. +void reset(); + +/// Transfer-full ref to the cached device (caller unrefs), nullptr if not primed; survives reset(). +GstD3D11Device* currentDevice(); + +} // namespace GstD3D11ContextBridge + +#endif // Q_OS_WIN && QGC_HAS_GST_D3D11_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D11VideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D11VideoBuffer.cc new file mode 100644 index 000000000000..6b84e14ec7da --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D11VideoBuffer.cc @@ -0,0 +1,249 @@ +#include "GstD3D11VideoBuffer.h" + +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) + +#include +#include +#include +#include +#include +#include +#include + +#include "GstContextBridgeRegistry.h" +#include "GstD3D11ContextBridge.h" +#include "GstD3DContextBridgeCommon.h" +#include "GstD3DVideoBufferCommon.h" +#include "GstHwPathTelemetry.h" +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(GstD3D11Log, "Video.GStreamer.HwBuffers.GstD3D11Buf") + +namespace { + +using GstD3DVideoBufferCommon::kMaxPlanes; +using GstD3DVideoBufferCommon::MapDiagnostics; +using D3D11FrameTextures = GstD3DVideoBufferCommon::FrameTextures; +using GstD3DVideoBufferCommon::StagingKey; + +MapDiagnostics s_diag; + +/// Caches one staging ID3D11Texture2D per (size, format, plane); pool keeps one ref, acquire() returns an extra +/// AddRef'd ref. Bounded to cap memory under resolution churn. +class StagingTexturePool +{ +public: + static StagingTexturePool& instance() + { + static StagingTexturePool pool; + return pool; + } + + ~StagingTexturePool() { clear(); } + + /// Returns an AddRef'd staging texture for @p key (caller owns the ref), creating it on a pool miss. nullptr on + /// CreateTexture2D failure. + ID3D11Texture2D* acquire(ID3D11Device* dev, const StagingKey& key, const D3D11_TEXTURE2D_DESC& dstDesc, + int planeIdx, guint subIdx) + { + QMutexLocker lock(&_mutex); + auto it = _entries.find(key); + if (it != _entries.end() && it->second) { + GstHwPathTelemetry::recordImageCacheHit(HwVideoBufferPath::D3D11); + it->second->AddRef(); + return it->second; + } + ID3D11Texture2D* tex = nullptr; + if (FAILED(dev->CreateTexture2D(&dstDesc, nullptr, &tex))) { + QGC_HW_WARN_ONCE(GstD3D11Log, s_diag.loggedTextureCreateFail, + "mapTextures: CreateTexture2D for slice copy failed (plane=" << planeIdx << "subresource=" + << subIdx << ")"); + return nullptr; + } + if (int(_entries.size()) >= kMaxEntries) { + _entries.begin()->second->Release(); + _entries.erase(_entries.begin()); + } + tex->AddRef(); + _entries[key] = tex; + GstHwPathTelemetry::recordImageCacheMiss(HwVideoBufferPath::D3D11); + return tex; + } + + void clear() + { + QMutexLocker lock(&_mutex); + for (const auto& kv : _entries) { + if (kv.second) + kv.second->Release(); + } + _entries.clear(); + } + +private: + static constexpr int kMaxEntries = 8; + QMutex _mutex; + std::map _entries; +}; + +/// Copy one subresource slice into a pooled ID3D11Texture2D for QRhi (which has no subresource selector); returns the +/// staging texture (caller owns the ref) or nullptr. Does NOT flush — the caller flushes once after all planes. +ID3D11Texture2D* copySliceToStaging(ID3D11Texture2D* tex, guint subIdx, int planeIdx, + const D3D11_TEXTURE2D_DESC& srcDesc, GstD3D11Memory* d3dmem) +{ + ID3D11Device* d3dDev = gst_d3d11_device_get_device_handle(d3dmem->device); + ID3D11DeviceContext* d3dCtx = gst_d3d11_device_get_device_context_handle(d3dmem->device); + D3D11_TEXTURE2D_DESC dstDesc = srcDesc; + dstDesc.ArraySize = 1; + dstDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE; + dstDesc.MiscFlags = 0; + dstDesc.MipLevels = 1; + const StagingKey key{srcDesc.Width, srcDesc.Height, UINT(srcDesc.Format), planeIdx}; + ID3D11Texture2D* stagingTex = StagingTexturePool::instance().acquire(d3dDev, key, dstDesc, planeIdx, subIdx); + if (!stagingTex) { + return nullptr; + } + // The immediate ID3D11DeviceContext is not free-threaded; gst-d3d11 device-lock contract requires this guard. + gst_d3d11_device_lock(d3dmem->device); + d3dCtx->CopySubresourceRegion(stagingTex, 0, 0, 0, 0, tex, subIdx, nullptr); + gst_d3d11_device_unlock(d3dmem->device); + return stagingTex; +} + +} // namespace + +void GstD3D11VideoBuffer::resetCachedState() noexcept +{ + StagingTexturePool::instance().clear(); + s_diag.reset(); +} + +namespace { +struct D3D11CacheResetRegistrar +{ + D3D11CacheResetRegistrar() { GstContextBridgeRegistry::registerCacheReset(&GstD3D11VideoBuffer::resetCachedState); } +}; + +const D3D11CacheResetRegistrar s_d3d11CacheResetRegistrar; +} // namespace + +GstD3D11VideoBuffer::GstD3D11VideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, + const QVideoFrameFormat& format) + : GstHwVideoBuffer(QVideoFrame::RhiTextureHandle, sample, videoInfo, format) +{ + // Device guard + slice copy + flush on the streaming thread; mapTextures (render thread) only imports resolved + // textures. + resolvePlaneResources(); +} + +GstD3D11VideoBuffer::~GstD3D11VideoBuffer() +{ + for (int i = 0; i < _resolvedCount; ++i) { + if (_textures[i]) + _textures[i]->Release(); + } +} + +bool GstD3D11VideoBuffer::validatePlaneHandles() const +{ + return validatePlanes([](GstMemory* mem) { + if (!mem || !gst_is_d3d11_memory(mem)) + return false; + // Cheap field read; confirms the wrapper actually backs an ID3D11Texture2D. + return gst_d3d11_memory_get_resource_handle(GST_D3D11_MEMORY_CAST(mem)) != nullptr; + }); +} + +void GstD3D11VideoBuffer::resolvePlaneResources() +{ + GstBuffer* buffer = _sample ? gst_sample_get_buffer(_sample) : nullptr; + if (!buffer) + return; + + const int memCount = (std::min)(int(gst_buffer_n_memory(buffer)), kMaxPlanes); + GstD3DVideoBufferCommon::PlaneResourceGuard guard; + GstD3D11Device* copyDevice = nullptr; + + for (int i = 0; i < memCount; ++i) { + GstMemory* mem = gst_buffer_peek_memory(buffer, i); + if (!mem || !gst_is_d3d11_memory(mem)) { + QGC_HW_WARN_ONCE(GstD3D11Log, s_diag.loggedNonD3DMemory, + "resolve: plane" << i << "memory is not GstD3D11Memory (allocator=" + << (mem && mem->allocator ? mem->allocator->mem_type : "null") << ")"); + return; + } + // Device guard on plane 0: gst-d3d11 may land on an isolated device when NEED_CONTEXT was preempted; sampling a + // foreign-device texture from QRhi corrupts silently. + if (i == 0) { + // currentDevice() is transfer-full — unref both branches to avoid UAF after reset(). + GstD3D11Device* bridgeDev = GstD3D11ContextBridge::currentDevice(); + GstD3D11Device* bufDev = GST_D3D11_MEMORY_CAST(mem)->device; + if (bridgeDev && bufDev != bridgeDev) { + const gint64 bridgeLuid = GstD3DContextBridgeCommon::readAdapterLuid(bridgeDev); + const gint64 bufLuid = GstD3DContextBridgeCommon::readAdapterLuid(bufDev); + gst_object_unref(bridgeDev); + QGC_HW_WARN_ONCE(GstD3D11Log, s_diag.loggedDeviceMismatch, + "resolve: GstD3D11Memory on foreign device (bridge LUID=" + << bridgeLuid << "buffer LUID=" << bufLuid + << "); bridge missed NEED_CONTEXT race — rejecting frame"); + return; + } + if (bridgeDev) + gst_object_unref(bridgeDev); + } + ID3D11Texture2D* tex = + reinterpret_cast(gst_d3d11_memory_get_resource_handle(GST_D3D11_MEMORY_CAST(mem))); + if (!tex) { + QGC_HW_WARN_ONCE(GstD3D11Log, s_diag.loggedNullResource, + "resolve: gst_d3d11_memory_get_resource_handle returned null for plane" << i); + return; + } + // QRhi::createFrom has no subresource selector — copy array slices here on the streaming thread. + const guint subIdx = gst_d3d11_memory_get_subresource_index(GST_D3D11_MEMORY_CAST(mem)); + D3D11_TEXTURE2D_DESC srcDesc = {}; + tex->GetDesc(&srcDesc); + if (subIdx > 0 || srcDesc.ArraySize > 1) { + ID3D11Texture2D* stagingTex = copySliceToStaging(tex, subIdx, i, srcDesc, GST_D3D11_MEMORY_CAST(mem)); + if (!stagingTex) { + return; + } + copyDevice = GST_D3D11_MEMORY_CAST(mem)->device; + guard.handles[i] = stagingTex; + } else { + tex->AddRef(); + guard.handles[i] = tex; + } + guard.owned = i + 1; + } + + // Single flush for every staged slice instead of one per plane: QRhi must see the copies in the immediate queue. + if (copyDevice) { + ID3D11DeviceContext* d3dCtx = gst_d3d11_device_get_device_context_handle(copyDevice); + gst_d3d11_device_lock(copyDevice); + d3dCtx->Flush(); + gst_d3d11_device_unlock(copyDevice); + } + + _textures = guard.handles; + _resolvedCount = memCount; + _resolved = true; + guard.commit(); +} + +QVideoFrameTexturesUPtr GstD3D11VideoBuffer::mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& old) +{ + // GstD3D11ContextBridge must be primed; without a shared device createFrom() silently renders garbage. + GstBuffer* buffer = nullptr; + if (!checkMapPreconditions(rhi, static_cast(QRhi::D3D11), GstD3D11Log(), s_diag, buffer)) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::D3D11); + } + if (!_resolved) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::D3D11); + } + + return GstD3DVideoBufferCommon::mapResolvedTextures( + *this, rhi, old, HwVideoBufferPath::D3D11, _textures, _resolvedCount, _format.frameSize(), + _format.pixelFormat(), s_diag.loggedFirstSuccess, s_diag.loggedTextureCreateFail, GstD3D11Log, "D3D11"); +} + +#endif // Q_OS_WIN && QGC_HAS_GST_D3D11_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D11VideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D11VideoBuffer.h new file mode 100644 index 000000000000..2d576e132f71 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D11VideoBuffer.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) + +#include + +#include "GstHwVideoBuffer.h" + +class QRhi; +struct ID3D11Texture2D; + +/// Wraps a D3D11Memory-backed GstSample as a QHwVideoBuffer; device guard and slice copy run at construction on the +/// streaming thread so mapTextures only imports resolved textures. +class GstD3D11VideoBuffer final : public GstHwVideoBuffer +{ +public: + GstD3D11VideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, const QVideoFrameFormat& format); + ~GstD3D11VideoBuffer() override; + + QVideoFrameTexturesUPtr mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& oldTextures) override; + bool validatePlaneHandles() const override; + + const char* storageTag() const override { return "D3D11"; } + + /// Release pooled staging textures on device-loss/TDR; wired into the facade reset path. + static void resetCachedState() noexcept; + +private: + /// Streaming-thread resolve: device guard and slice copy; sets _resolved false on any failure. + void resolvePlaneResources(); + + std::array _textures{}; + int _resolvedCount = 0; + bool _resolved = false; +}; + +#endif // Q_OS_WIN && QGC_HAS_GST_D3D11_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D12ContextBridge.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D12ContextBridge.cc new file mode 100644 index 000000000000..760ff660ec91 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D12ContextBridge.cc @@ -0,0 +1,82 @@ +#include "GstD3D12ContextBridge.h" + +#include "GstD3DContextBridgeCommon.h" + +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) + +#include +#include // QRhi::Implementation enum only; native handles come from the snapshot + +#include "QGCLoggingCategory.h" +#include "QGCRhiCapture.h" + +QGC_LOGGING_CATEGORY(GstD3D12BridgeLog, "Video.GStreamer.HwBuffers.GstD3D12Bridge") + +namespace GstD3D12ContextBridge { +namespace { + +GstD3DContextBridgeCommon::BridgeState s_state; + +GstObject* createDevice(const QLoggingCategory& cat) +{ + // LUID halves were sign-extended into a 64-bit value matching LARGE_INTEGER::QuadPart when the snapshot was + // composed. + const gint64 luid = QGCRhiCapture::deviceSnapshot().adapterLuid.load(std::memory_order_acquire); + + GstD3D12Device* device = gst_d3d12_device_new_for_adapter_luid(luid); + if (!device) { + qCWarning(cat) << "gst_d3d12_device_new_for_adapter_luid failed (luid=" << luid << ")"; + return nullptr; + } + + GstD3DContextBridgeCommon::logAdapterMatch(luid, device, cat, "D3D12"); + return GST_OBJECT(device); +} + +GstContext* makeContext(GstObject* device) +{ + // gst_d3d12_context_new gst_object_ref's the device internally; caller retains ownership. + return gst_d3d12_context_new(GST_D3D12_DEVICE_CAST(device)); +} + +const GstD3DContextBridgeCommon::BridgeOps s_ops = { + "D3D12", GST_D3D12_DEVICE_HANDLE_CONTEXT_TYPE, &GstD3D12BridgeLog, int(QRhi::D3D12), &createDevice, &makeContext, +}; + +struct D3D12BridgeRegistrar +{ + D3D12BridgeRegistrar() { GstD3DContextBridgeCommon::registerBridge(GstD3D12BridgeLog(), &handleSyncMessage, &reset); } +}; + +static D3D12BridgeRegistrar s_d3d12BridgeRegistrar; + +} // namespace + +bool prime() +{ + return GstD3DContextBridgeCommon::prime(s_state, s_ops); +} + +GstD3D12Device* currentDevice() +{ + return GST_D3D12_DEVICE_CAST(GstD3DContextBridgeCommon::currentDevice(s_state)); +} + +GstBusSyncReply handleSyncMessage(GstMessage* message) +{ + return GstD3DContextBridgeCommon::handleSyncMessage(s_state, s_ops, message); +} + +bool answerContextQuery(GstQuery* query) +{ + return GstD3DContextBridgeCommon::answerContextQuery(s_state, s_ops, query); +} + +void reset() +{ + GstD3DContextBridgeCommon::reset(s_state, s_ops); +} + +} // namespace GstD3D12ContextBridge + +#endif // Q_OS_WIN && QGC_HAS_GST_D3D12_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D12ContextBridge.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D12ContextBridge.h new file mode 100644 index 000000000000..2796723e5a7d --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D12ContextBridge.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) + +#include + +// Forward-declare the opaque type so currentDevice() parses without ; dereferencing callers +// include the full header. +typedef struct _GstD3D12Device GstD3D12Device; + +/// Process-wide shared GstD3D12Device answering NEED_CONTEXT (gst.d3d12.device.handle) for QRhi's adapter LUID so +/// decoders are zero-copy. +namespace GstD3D12ContextBridge { + +/// Idempotent; builds the shared GstD3D12Device for QRhi's adapter LUID. False (logs once) if QRhi isn't D3D12/ready — +/// retry on a later NEED_CONTEXT. +bool prime(); + +/// NEED_CONTEXT for gst.d3d12.device.handle -> respond with the shared device; GST_BUS_DROP when consumed, else +/// GST_BUS_PASS. Thread-safe. +GstBusSyncReply handleSyncMessage(GstMessage* message); + +/// Answer a sink-bin GST_QUERY_CONTEXT (gst.d3d12.device.handle) with the shared device. True when consumed. +bool answerContextQuery(GstQuery* query); + +/// Drop the cached GstD3D12Device so the next prime() rebuilds; call from receiver teardown. +void reset(); + +/// Transfer-full ref to the cached device (caller unrefs), nullptr if not primed; survives reset(). +GstD3D12Device* currentDevice(); + +} // namespace GstD3D12ContextBridge + +#endif // Q_OS_WIN && QGC_HAS_GST_D3D12_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D12VideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D12VideoBuffer.cc new file mode 100644 index 000000000000..702e3adcd90e --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D12VideoBuffer.cc @@ -0,0 +1,387 @@ +#include "GstD3D12VideoBuffer.h" + +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) + +#include +#include +#include +#include +#include +#include +#include + +#include "GstContextBridgeRegistry.h" +#include "GstD3D12ContextBridge.h" +#include "GstD3DContextBridgeCommon.h" +#include "GstD3DVideoBufferCommon.h" +#include "GstHwPathTelemetry.h" +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(GstD3D12Log, "Video.GStreamer.HwBuffers.GstD3D12Buf") + +namespace { + +using GstD3DVideoBufferCommon::kMaxPlanes; +using GstD3DVideoBufferCommon::MapDiagnostics; +using D3D12FrameTextures = GstD3DVideoBufferCommon::FrameTextures; +using GstD3DVideoBufferCommon::StagingKey; + +MapDiagnostics s_diag; + +/// Per-key pooled COPY/DIRECT-queue objects: staging resource plus the allocator/list that record the slice copy. +/// Per-frame allocator Reset() is safe because resolvePlaneResources() fence-waits before returning, so the prior +/// submit has completed. +struct StagingEntry +{ + ID3D12Resource* resource = nullptr; + ID3D12CommandAllocator* allocator = nullptr; + ID3D12GraphicsCommandList* list = nullptr; +}; + +/// Caches StagingEntry per (size, format, plane); pool owns one ref on each held COM object, acquire() returns the +/// resource AddRef'd. +class StagingResourcePool +{ +public: + static StagingResourcePool& instance() + { + static StagingResourcePool pool; + return pool; + } + + ~StagingResourcePool() { clear(); } + + /// Returns the pooled entry for @p key (creating COM objects on a miss). The returned resource is owned by the + /// pool — the caller must AddRef before transferring ownership. Returns {} (null resource) on creation failure. + StagingEntry acquire(ID3D12Device* dev, D3D12_COMMAND_LIST_TYPE queueType, const StagingKey& key, + const D3D12_RESOURCE_DESC& dstDesc, const D3D12_HEAP_PROPERTIES& heapProps, int planeIdx, + guint subIdx) + { + QMutexLocker lock(&_mutex); + auto it = _entries.find(key); + if (it != _entries.end() && it->second.resource && it->second.allocator && it->second.list) { + GstHwPathTelemetry::recordImageCacheHit(HwVideoBufferPath::D3D12); + return it->second; + } + + StagingEntry e; + if (FAILED(dev->CreateCommittedResource(&heapProps, D3D12_HEAP_FLAG_NONE, &dstDesc, D3D12_RESOURCE_STATE_COMMON, + nullptr, IID_PPV_ARGS(&e.resource)))) { + QGC_HW_WARN_ONCE(GstD3D12Log, s_diag.loggedTextureCreateFail, + "mapTextures: CreateCommittedResource for slice copy failed (plane=" + << planeIdx << " subresource=" << subIdx << ")"); + return {}; + } + if (FAILED(dev->CreateCommandAllocator(queueType, IID_PPV_ARGS(&e.allocator))) || + FAILED(dev->CreateCommandList(0, queueType, e.allocator, nullptr, IID_PPV_ARGS(&e.list)))) { + QGC_HW_WARN_ONCE(GstD3D12Log, s_diag.loggedTextureCreateFail, + "mapTextures: command allocator/list create failed (plane=" << planeIdx << ")"); + if (e.list) + e.list->Release(); + if (e.allocator) + e.allocator->Release(); + e.resource->Release(); + return {}; + } + // Freshly created lists are open; close so the per-frame Reset() path is uniform. + e.list->Close(); + + if (int(_entries.size()) >= kMaxEntries) { + releaseEntry(_entries.begin()->second); + _entries.erase(_entries.begin()); + } + _entries[key] = e; + GstHwPathTelemetry::recordImageCacheMiss(HwVideoBufferPath::D3D12); + return e; + } + + void clear() + { + QMutexLocker lock(&_mutex); + for (const auto& kv : _entries) { + releaseEntryResources(kv.second); + } + _entries.clear(); + } + +private: + static void releaseEntry(StagingEntry& e) + { + releaseEntryResources(e); + e = {}; + } + + static void releaseEntryResources(const StagingEntry& e) + { + if (e.list) + e.list->Release(); + if (e.allocator) + e.allocator->Release(); + if (e.resource) + e.resource->Release(); + } + + static constexpr int kMaxEntries = 8; + QMutex _mutex; + std::map _entries; +}; + +/// Copy one subresource slice into a pooled COPY-queue (DIRECT fallback) resource and submit the copy WITHOUT waiting; +/// the caller does a single fence wait after all planes. On success returns the staging resource AddRef'd (caller owns +/// the ref) and advances @p maxFenceValue to the highest submitted fence value. nullptr on failure. +ID3D12Resource* copySliceToStaging(ID3D12Resource* resource, guint subIdx, int planeIdx, + const D3D12_RESOURCE_DESC& srcDesc, ID3D12Device* d3dDev, GstD3D12CmdQueue* cmdQueue, + D3D12_COMMAND_LIST_TYPE queueType, guint64& maxFenceValue) +{ + D3D12_RESOURCE_DESC dstDesc = srcDesc; + dstDesc.DepthOrArraySize = 1; + dstDesc.MipLevels = 1; + + D3D12_HEAP_PROPERTIES heapProps = {}; + heapProps.Type = D3D12_HEAP_TYPE_DEFAULT; + + const StagingKey key{srcDesc.Width, srcDesc.Height, UINT(srcDesc.Format), planeIdx}; + StagingEntry entry = + StagingResourcePool::instance().acquire(d3dDev, queueType, key, dstDesc, heapProps, planeIdx, subIdx); + if (!entry.resource || !entry.allocator || !entry.list) { + return nullptr; + } + + if (FAILED(entry.allocator->Reset()) || FAILED(entry.list->Reset(entry.allocator, nullptr))) { + QGC_HW_WARN_ONCE(GstD3D12Log, s_diag.loggedTextureCreateFail, + "mapTextures: command allocator/list reset failed (plane=" << planeIdx << ")"); + return nullptr; + } + ID3D12GraphicsCommandList* cmdList = entry.list; + + D3D12_TEXTURE_COPY_LOCATION srcLoc = {}; + srcLoc.pResource = resource; + srcLoc.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX; + srcLoc.SubresourceIndex = subIdx; + + D3D12_TEXTURE_COPY_LOCATION dstLoc = {}; + dstLoc.pResource = entry.resource; + dstLoc.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX; + dstLoc.SubresourceIndex = 0; + + // Decoder output is parked in COMMON by gst_d3d12_memory_sync; gst-d3d12 exposes no per-resource state getter, so + // COMMON is the only valid before-state. + D3D12_RESOURCE_BARRIER toCopySrc = {}; + toCopySrc.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; + toCopySrc.Transition.pResource = resource; + toCopySrc.Transition.Subresource = subIdx; + toCopySrc.Transition.StateBefore = D3D12_RESOURCE_STATE_COMMON; + toCopySrc.Transition.StateAfter = D3D12_RESOURCE_STATE_COPY_SOURCE; + cmdList->ResourceBarrier(1, &toCopySrc); + + cmdList->CopyTextureRegion(&dstLoc, 0, 0, 0, &srcLoc, nullptr); + + D3D12_RESOURCE_BARRIER toCommon = toCopySrc; + toCommon.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_SOURCE; + toCommon.Transition.StateAfter = D3D12_RESOURCE_STATE_COMMON; + cmdList->ResourceBarrier(1, &toCommon); + + cmdList->Close(); + + ID3D12CommandList* lists[] = {cmdList}; + guint64 fenceValue = 0; + // Wrapper execute takes gst-d3d12's queue lock, serializing the copy against decoder-thread submits. + if (FAILED(gst_d3d12_cmd_queue_execute_command_lists(cmdQueue, 1, lists, &fenceValue))) { + // The caller skips the fence wait on failure, so the pooled allocator's GPU work may still be in flight; drop + // the pool so the next frame rebuilds clean command objects instead of resetting a busy allocator. + StagingResourcePool::instance().clear(); + return nullptr; + } + if (fenceValue > maxFenceValue) { + maxFenceValue = fenceValue; + } + entry.resource->AddRef(); // hand an owning ref to the caller (pool keeps its own). + return entry.resource; +} + +} // namespace + +void GstD3D12VideoBuffer::resetCachedState() noexcept +{ + StagingResourcePool::instance().clear(); + s_diag.reset(); +} + +namespace { +struct D3D12CacheResetRegistrar +{ + D3D12CacheResetRegistrar() { GstContextBridgeRegistry::registerCacheReset(&GstD3D12VideoBuffer::resetCachedState); } +}; + +const D3D12CacheResetRegistrar s_d3d12CacheResetRegistrar; +} // namespace + +GstD3D12VideoBuffer::GstD3D12VideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, + const QVideoFrameFormat& format) + : GstHwVideoBuffer(QVideoFrame::RhiTextureHandle, sample, videoInfo, format) +{ + // Resolve on the streaming thread, never in mapTextures (render thread); blocking here is harmless since the + // decoder is async. + resolvePlaneResources(); +} + +GstD3D12VideoBuffer::~GstD3D12VideoBuffer() +{ + for (int i = 0; i < _resolvedCount; ++i) { + if (_resources[i]) + _resources[i]->Release(); + } +} + +bool GstD3D12VideoBuffer::validatePlaneHandles() const +{ + return validatePlanes([](GstMemory* mem) { + if (!mem || !gst_is_d3d12_memory(mem)) + return false; + return gst_d3d12_memory_get_resource_handle(GST_D3D12_MEMORY_CAST(mem)) != nullptr; + }); +} + +void GstD3D12VideoBuffer::resolvePlaneResources() +{ + GstBuffer* buffer = _sample ? gst_sample_get_buffer(_sample) : nullptr; + if (!buffer) + return; + + const int memCount = (std::min)(int(gst_buffer_n_memory(buffer)), kMaxPlanes); + GstD3DVideoBufferCommon::PlaneResourceGuard guard; + + // Shared copy state: the queue is resolved lazily on the first staged plane (constant per device) and all planes + // submit to it, then a single fence wait covers every submit. The guard unrefs the held queue ref on any early + // return path. + struct CmdQueueRef + { + GstD3D12CmdQueue* q = nullptr; + + ~CmdQueueRef() + { + if (q) + gst_object_unref(q); + } + } queueRef; + + D3D12_COMMAND_LIST_TYPE queueType = D3D12_COMMAND_LIST_TYPE_COPY; + guint64 maxFenceValue = 0; + bool anyCopy = false; + + for (int i = 0; i < memCount; ++i) { + GstMemory* mem = gst_buffer_peek_memory(buffer, i); + if (!mem || !gst_is_d3d12_memory(mem)) { + QGC_HW_WARN_ONCE(GstD3D12Log, s_diag.loggedNonD3DMemory, + "resolve: plane" << i << "memory is not GstD3D12Memory (allocator=" + << (mem && mem->allocator ? mem->allocator->mem_type : "null") << ")"); + return; + } + GstD3D12Memory* d3dmem = GST_D3D12_MEMORY_CAST(mem); + // Device guard (plane 0): gst-d3d12 may land on an isolated device when NEED_CONTEXT was preempted, and + // importing a foreign-device resource into QRhi corrupts. + if (i == 0) { + // currentDevice() is transfer-full — unref both branches to avoid UAF after reset(). + GstD3D12Device* bridgeDev = GstD3D12ContextBridge::currentDevice(); + if (bridgeDev && d3dmem->device != bridgeDev) { + const gint64 bridgeLuid = GstD3DContextBridgeCommon::readAdapterLuid(bridgeDev); + const gint64 bufLuid = GstD3DContextBridgeCommon::readAdapterLuid(d3dmem->device); + gst_object_unref(bridgeDev); + QGC_HW_WARN_ONCE(GstD3D12Log, s_diag.loggedDeviceMismatch, + "resolve: GstD3D12Memory on foreign device (bridge LUID=" + << bridgeLuid << "buffer LUID=" << bufLuid + << "); bridge missed NEED_CONTEXT race — rejecting frame"); + return; + } + if (bridgeDev) + gst_object_unref(bridgeDev); + } + // Block on the decoder's fence before reading, else QRhi samples mid-write while the decoder is still writing + // this resource. + if (!gst_d3d12_memory_sync(d3dmem)) { + QGC_HW_WARN_ONCE(GstD3D12Log, s_diag.loggedNullResource, + "resolve: gst_d3d12_memory_sync failed for plane" << i); + return; + } + ID3D12Resource* resource = gst_d3d12_memory_get_resource_handle(d3dmem); + if (!resource) { + QGC_HW_WARN_ONCE(GstD3D12Log, s_diag.loggedNullResource, + "resolve: gst_d3d12_memory_get_resource_handle returned null for plane" << i); + return; + } + // QRhi::createFrom has no subresource parameter — copy the slice (with its blocking fence wait) here, not in + // mapTextures. + guint subIdx = 0; + gst_d3d12_memory_get_subresource_index(d3dmem, guint(i), &subIdx); + D3D12_RESOURCE_DESC srcDesc = resource->GetDesc(); + if (subIdx > 0 || srcDesc.DepthOrArraySize > 1) { + ID3D12Device* d3dDev = gst_d3d12_device_get_device_handle(d3dmem->device); + if (!queueRef.q) { + queueType = D3D12_COMMAND_LIST_TYPE_COPY; + GstD3D12CmdQueue* q = gst_d3d12_device_get_cmd_queue(d3dmem->device, queueType); + if (!q) { + queueType = D3D12_COMMAND_LIST_TYPE_DIRECT; + q = gst_d3d12_device_get_cmd_queue(d3dmem->device, queueType); + } + if (q) { + // Hold a ref for the whole loop so it serializes against gst-d3d12's queue lock like the + // decoder-thread submits do. + gst_object_ref(q); + queueRef.q = q; + } + } + if (!d3dDev || !queueRef.q) { + QGC_HW_WARN_ONCE(GstD3D12Log, s_diag.loggedTextureCreateFail, + "mapTextures: could not obtain D3D12 device/queue for slice copy (plane=" << i << ")"); + return; + } + ID3D12Resource* stagingResource = + copySliceToStaging(resource, subIdx, i, srcDesc, d3dDev, queueRef.q, queueType, maxFenceValue); + if (!stagingResource) { + return; + } + anyCopy = true; + guard.handles[i] = stagingResource; + } else { + resource->AddRef(); + guard.handles[i] = resource; + } + guard.owned = i + 1; + } + + // One fence wait on the highest value covers every plane (same queue, in order) and guarantees the pooled + // allocators are idle before the next frame resets them. + if (anyCopy && queueRef.q) { + GstHwPathTelemetry::recordSyncWait(HwVideoBufferPath::D3D12, /*gpuSide=*/false); + if (FAILED(gst_d3d12_cmd_queue_fence_wait(queueRef.q, maxFenceValue))) { + QGC_HW_WARN_ONCE(GstD3D12Log, s_diag.loggedTextureCreateFail, + "resolve: gst_d3d12_cmd_queue_fence_wait failed"); + return; + } + } + + _resources = guard.handles; + _resolvedCount = memCount; + _resolved = true; + guard.commit(); +} + +QVideoFrameTexturesUPtr GstD3D12VideoBuffer::mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& old) +{ + // GstD3D12ContextBridge wires a shared adapter; without it createFrom() succeeds but resources sit on an isolated + // device and rendering corrupts. + GstBuffer* buffer = nullptr; + if (!checkMapPreconditions(rhi, static_cast(QRhi::D3D12), GstD3D12Log(), s_diag, buffer)) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::D3D12); + } + if (!_resolved) { + // Failure cause was warned in resolvePlaneResources() on the streaming thread. + return GstHwPathTelemetry::fail(HwVideoBufferPath::D3D12); + } + + return GstD3DVideoBufferCommon::mapResolvedTextures( + *this, rhi, old, HwVideoBufferPath::D3D12, _resources, _resolvedCount, _format.frameSize(), + _format.pixelFormat(), s_diag.loggedFirstSuccess, s_diag.loggedTextureCreateFail, GstD3D12Log, "D3D12"); +} + +#endif // Q_OS_WIN && QGC_HAS_GST_D3D12_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D12VideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D12VideoBuffer.h new file mode 100644 index 000000000000..5e2a3495dbfa --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3D12VideoBuffer.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) + +#include + +#include "GstHwVideoBuffer.h" + +class QRhi; +struct ID3D12Resource; + +/// \brief Wraps a D3D12Memory-backed GstSample as a QHwVideoBuffer; samples natively on QRhi::D3D12. +/// Decoder sync + slice copy (GPU-fence blocking) run at construction on the streaming thread, never in mapTextures +/// (QSGRenderThread). +class GstD3D12VideoBuffer final : public GstHwVideoBuffer +{ +public: + GstD3D12VideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, const QVideoFrameFormat& format); + ~GstD3D12VideoBuffer() override; + + QVideoFrameTexturesUPtr mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& oldTextures) override; + bool validatePlaneHandles() const override; + + const char* storageTag() const override { return "D3D12"; } + + /// Release pooled staging resources on device-loss/TDR; wired into the facade reset path. + static void resetCachedState() noexcept; + +private: + /// Streaming-thread resolve: per-plane decoder sync, device guard, slice copy; fills _resources, leaves _resolved + /// false on failure (mapTextures then uses CPU fallback). + void resolvePlaneResources(); + + std::array _resources{}; + int _resolvedCount = 0; + bool _resolved = false; +}; + +#endif // Q_OS_WIN && QGC_HAS_GST_D3D12_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3DContextBridgeCommon.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3DContextBridgeCommon.cc new file mode 100644 index 000000000000..d7245e669b0f --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3DContextBridgeCommon.cc @@ -0,0 +1,190 @@ +#include "GstD3DContextBridgeCommon.h" + +#if defined(Q_OS_WIN) && (defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH)) + +#include +#include + +#include "GstContextBridgeCommon.h" +#include "GstContextBridgeRegistry.h" +#include "QGCRhiCapture.h" + +namespace GstD3DContextBridgeCommon { +namespace { + +bool primeLocked(BridgeState& state, const BridgeOps& ops) +{ + if (state.primed) { + return true; + } + if (!checkSnapshotBackend(state, ops.cat(), ops.qrhiBackend, ops.apiName)) { + return false; + } + GstObject* device = ops.createDevice(ops.cat()); + if (!device) { + return false; // createDevice already logged the cause. + } + state.device = device; + state.primed = true; + qCInfo(ops.cat()) << ops.apiName << "bridge primed: shared device =" << device; + return true; +} + +} // namespace + +// Reads QGCRhiCapture::deviceSnapshot() via atomic loads — safe from the bus-sync thread without touching QRhi +// cross-thread. +bool checkSnapshotBackend(BridgeState& state, const QLoggingCategory& cat, int expectedBackend, const char* backendName) +{ + const int backend = QGCRhiCapture::deviceSnapshot().backend.load(std::memory_order_acquire); + if (backend < 0) { + qCDebug(cat) << "QRhi snapshot not yet populated; will retry on next NEED_CONTEXT"; + return false; + } + if (backend != expectedBackend) { + if (!state.warnedWrongBackend) { + qCInfo(cat) << "QRhi backend tag is" << backend << "(not" << backendName << "); bridge inactive"; + state.warnedWrongBackend = true; + } + return false; + } + return true; +} + +void logHandoff(BridgeState& state, const QLoggingCategory& cat, GstElement* element, const char* apiName) +{ + if (!state.loggedFirstHandoff.exchange(true, std::memory_order_relaxed)) { + qCInfo(cat) << "First" << apiName << "device handoff to element" << GST_ELEMENT_NAME(element); + } else { + qCDebug(cat) << "Provided" << apiName << "device context to" << GST_ELEMENT_NAME(element); + } +} + +gint64 readAdapterLuid(gpointer device) +{ + if (!device || !G_IS_OBJECT(device)) + return 0; + gint64 luid = 0; + g_object_get(G_OBJECT(device), "adapter-luid", &luid, nullptr); + return luid; +} + +void logAdapterMatch(gint64 expectedLuid, gpointer gstDevice, const QLoggingCategory& cat, const char* apiName) +{ + const gint64 actualLuid = readAdapterLuid(gstDevice); + if (actualLuid != expectedLuid) { + qCWarning(cat).noquote() << apiName << "bridge: gst device LUID mismatch — QRhi LUID=" << expectedLuid + << "but wrapped device LUID=" << actualLuid + << "(zero-copy will appear corrupt; check NEED_CONTEXT race)"; + return; + } + qCInfo(cat).noquote() << apiName << "bridge adapter LUID=" + << QString::asprintf("0x%llx", static_cast(expectedLuid)); +} + +void registerBridge(const QLoggingCategory& cat, GstBusSyncReply (*handler)(GstMessage*), void (*reset)()) +{ + GstContextBridge::registerBridge(cat, "D3D", handler, reset); +} + +bool prime(BridgeState& state, const BridgeOps& ops) +{ + QMutexLocker lock(&state.mutex); + return primeLocked(state, ops); +} + +GstObject* currentDevice(BridgeState& state) +{ + QMutexLocker lock(&state.mutex); + if (!state.device) { + return nullptr; + } + return GST_OBJECT(gst_object_ref(state.device)); +} + +namespace { + +// Adapts a D3D BridgeState+BridgeOps pair onto the path-agnostic GstContextBridge skeleton. Both D3D bridges share this +// TU, so the per-instance state is threaded through `user` rather than file statics. One context-type → one device. +struct D3DUser +{ + BridgeState* state; + const BridgeOps* ops; +}; + +const QLoggingCategory& vtCat(void* user) +{ + return static_cast(user)->ops->cat(); +} + +QMutex& vtMutex(void* user) +{ + return static_cast(user)->state->mutex; +} + +bool vtPrime(void* user) +{ + auto* d = static_cast(user); + return primeLocked(*d->state, *d->ops); +} + +GstObject* vtRefObject(void* user, const char* /*contextType*/) +{ + auto* d = static_cast(user); + return d->state->device ? GST_OBJECT(gst_object_ref(d->state->device)) : nullptr; +} + +GstContext* vtBuildContext(void* user, const char* /*contextType*/, GstObject* object) +{ + auto* d = static_cast(user); + GstContext* ctx = d->ops->makeContext(object); + if (!ctx) { + qCWarning(d->ops->cat()) << d->ops->apiName << "context_new failed"; + } + return ctx; +} + +void vtOnHandoff(void* user, GstElement* element, const char* /*contextType*/) +{ + auto* d = static_cast(user); + logHandoff(*d->state, d->ops->cat(), element, d->ops->apiName); +} + +GstContextBridge::BridgeVTable makeVTable(const BridgeOps& ops, const char** typeStorage) +{ + typeStorage[0] = ops.contextType; + return GstContextBridge::BridgeVTable{ + ops.apiName, typeStorage, 1, &vtCat, &vtMutex, &vtPrime, &vtRefObject, &vtBuildContext, &vtOnHandoff, + }; +} + +} // namespace + +GstBusSyncReply handleSyncMessage(BridgeState& state, const BridgeOps& ops, GstMessage* message) +{ + D3DUser user = {&state, &ops}; + const char* types[1]; + const GstContextBridge::BridgeVTable vt = makeVTable(ops, types); + return GstContextBridge::handleSyncMessage(vt, &user, message); +} + +bool answerContextQuery(BridgeState& state, const BridgeOps& ops, GstQuery* query) +{ + D3DUser user = {&state, &ops}; + const char* types[1]; + const GstContextBridge::BridgeVTable vt = makeVTable(ops, types); + return GstContextBridge::answerContextQuery(vt, &user, query); +} + +void reset(BridgeState& state, const BridgeOps& ops) +{ + QMutexLocker lock(&state.mutex); + gst_clear_object(&state.device); + state.primed = false; + state.warnedWrongBackend = false; + qCDebug(ops.cat()) << ops.apiName << "bridge reset"; +} + +} // namespace GstD3DContextBridgeCommon + +#endif // Q_OS_WIN && (QGC_HAS_GST_D3D11_GPU_PATH || QGC_HAS_GST_D3D12_GPU_PATH) diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3DContextBridgeCommon.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3DContextBridgeCommon.h new file mode 100644 index 000000000000..9d4fd2d4f201 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3DContextBridgeCommon.h @@ -0,0 +1,72 @@ +#pragma once + +#include + +#if defined(Q_OS_WIN) && (defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH)) + +#include +#include +#include +#include + +/// Bookkeeping shared by GstD3D11/D3D12ContextBridge (prime/reset state machine, backend gate, NEED_CONTEXT dispatch); +/// device construction differs per bridge. +namespace GstD3DContextBridgeCommon { + +/// Per-bridge state. Both bridges hold one static instance. +struct BridgeState +{ + QMutex mutex; + bool primed = false; + bool warnedWrongBackend = false; + std::atomic loggedFirstHandoff{false}; + GstObject* device = nullptr; ///< owned wrapped device (GstD3D11Device/GstD3D12Device); reset() clears it. +}; + +/// Per-API hooks for the shared prime/handoff/reset skeleton — the only parts that differ between the D3D11 and D3D12 +/// bridges. +struct BridgeOps +{ + const char* apiName; ///< "D3D11" / "D3D12" — log + diagnostics label. + const char* contextType; ///< GST_D3D1x_DEVICE_HANDLE_CONTEXT_TYPE. + const QLoggingCategory& (*cat)(); ///< bridge's logging category accessor. + int qrhiBackend; ///< int(QRhi::D3D11) / int(QRhi::D3D12). + GstObject* (*createDevice)(const QLoggingCategory&); ///< build the wrapped device; nullptr (logged) on failure. + GstContext* (*makeContext)(GstObject* device); ///< wrap the device in a GstContext (refs internally). +}; + +/// True if QGCRhiCapture's snapshot matches @p expectedBackend. Caller MUST hold state.mutex — touches non-atomic +/// warnedWrongBackend. +bool checkSnapshotBackend(BridgeState& state, const QLoggingCategory& cat, int expectedBackend, + const char* backendName); + +/// Logs the first element handoff at qCInfo; subsequent at qCDebug. +void logHandoff(BridgeState& state, const QLoggingCategory& cat, GstElement* element, const char* apiName); + +/// Reads `adapter-luid` from a GstD3D11Device/GstD3D12Device; 0 on null/read failure. +gint64 readAdapterLuid(gpointer device); + +/// One-shot LUID compare at prime; mismatch = gst wrapped a different adapter than QRhi (corrupts). +void logAdapterMatch(gint64 expectedLuid, gpointer gstDevice, const QLoggingCategory& cat, const char* apiName); + +/// Register a D3D bridge's sync handler + reset callback (the two calls every D3D bridge repeats verbatim). +void registerBridge(const QLoggingCategory& cat, GstBusSyncReply (*handler)(GstMessage*), void (*reset)()); + +/// Idempotent prime: gate on backend, build the device via @p ops, cache it. Thread-safe. +bool prime(BridgeState& state, const BridgeOps& ops); + +/// Transfer-full ref to the cached device (caller unrefs) as a GstObject*, nullptr if not primed. +GstObject* currentDevice(BridgeState& state); + +/// Answer a NEED_CONTEXT for @p ops.contextType with the shared device. GST_BUS_DROP when consumed. +GstBusSyncReply handleSyncMessage(BridgeState& state, const BridgeOps& ops, GstMessage* message); + +/// Answer a GST_QUERY_CONTEXT for @p ops.contextType with the shared device (sink-bin query path). True when consumed. +bool answerContextQuery(BridgeState& state, const BridgeOps& ops, GstQuery* query); + +/// Drop the cached device so the next prime() rebuilds it. +void reset(BridgeState& state, const BridgeOps& ops); + +} // namespace GstD3DContextBridgeCommon + +#endif // Q_OS_WIN && (QGC_HAS_GST_D3D11_GPU_PATH || QGC_HAS_GST_D3D12_GPU_PATH) diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3DVideoBufferCommon.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3DVideoBufferCommon.h new file mode 100644 index 000000000000..bbcde9949adf --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/d3d/GstD3DVideoBufferCommon.h @@ -0,0 +1,242 @@ +#pragma once + +#include + +#if defined(Q_OS_WIN) && (defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH)) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "GstHwFrameTexturesBase.h" +#include "GstHwImportPreflight.h" +#include "GstHwPathTelemetry.h" +#include "GstHwVideoBuffer.h" + +/// Shared scaffolding for D3D11 / D3D12 zero-copy QHwVideoBuffer wrappers. +namespace GstD3DVideoBufferCommon { + +using GstHw::kMaxPlanes; + +inline bool canAliasSingleResourceAcrossPlanes(QVideoFrameFormat::PixelFormat pixelFormat) noexcept +{ + switch (pixelFormat) { + case QVideoFrameFormat::Format_NV12: + case QVideoFrameFormat::Format_NV21: + case QVideoFrameFormat::Format_P010: + case QVideoFrameFormat::Format_P016: + return true; + default: + return false; + } +} + +/// D3D-specific failure causes added to the shared GstHw::MapDiagnostics; each .cc keeps its own instance so the +/// per-cause log throttle stays separated per API. +struct MapDiagnostics : GstHw::MapDiagnostics +{ + std::atomic loggedNonD3DMemory{false}; + std::atomic loggedNullResource{false}; + // Tripped when GstD3DXMemory carries a device that doesn't match the bridge's shared device — a NEED_CONTEXT race + // the bridge lost. + std::atomic loggedDeviceMismatch{false}; + + /// Re-arm every log-once flag so device-loss recovery surfaces warnings again. + void reset() noexcept + { + loggedFirstSuccess.store(false, std::memory_order_release); + loggedNullSample.store(false, std::memory_order_release); + loggedBadBackend.store(false, std::memory_order_release); + loggedNullBuffer.store(false, std::memory_order_release); + loggedTextureCreateFail.store(false, std::memory_order_release); + loggedNonD3DMemory.store(false, std::memory_order_release); + loggedNullResource.store(false, std::memory_order_release); + loggedDeviceMismatch.store(false, std::memory_order_release); + } +}; + +/// Pool key: a staging resource is reusable only for an identical (size, format, plane) layout. width is UINT64 to hold +/// D3D12_RESOURCE_DESC::Width; D3D11's UINT widens losslessly. +struct StagingKey +{ + quint64 width = 0; + quint32 height = 0; + quint32 format = 0; // DXGI_FORMAT + int plane = 0; + + bool operator<(const StagingKey& o) const + { + return std::tie(width, height, format, plane) < std::tie(o.width, o.height, o.format, o.plane); + } +}; + +/// Scope guard for the native handles a resolvePlaneResources() loop builds up: releases every owned handle on scope +/// exit unless commit() transfers ownership on the success path. +template +struct PlaneResourceGuard +{ + std::array handles{}; + int owned = 0; + + ~PlaneResourceGuard() + { + for (int j = 0; j < owned; ++j) { + if (handles[j]) + handles[j]->Release(); + } + } + + /// Disarm the guard after handles are copied into the owning member, so the destructor leaves the transferred refs + /// alone. + void commit() noexcept { owned = 0; } +}; + +/// Wraps an array of D3D native handles (ID3D11Texture2D*/ID3D12Resource*) as QRhi-importable textures; releases them +/// in the dtor, so the caller must own a fresh ref before constructing. +template +class FrameTextures final : public GstHwFrameTexturesBase +{ +public: + FrameTextures(HwVideoBufferPath path, QRhi* rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, + std::array handles, int count) + : _handles(handles), _path(path), _rhi(rhi), _size(size), _pixelFormat(pixelFormat) + { + _count = count; + const auto* desc = QVideoTextureHelper::textureDescription(pixelFormat); + if (!desc) + return; + for (int i = 0; i < _count; ++i) { + const QSize planeSize = desc->rhiPlaneSize(size, i, rhi); + _textures[i].reset(rhi->newTexture(desc->rhiTextureFormat(i, rhi), planeSize, 1, {})); + if (_textures[i] && !_textures[i]->createFrom({reinterpret_cast(handles[i]), 0})) { + _textures[i].reset(); + } + } + } + + ~FrameTextures() override + { + for (int i = 0; i < _count; ++i) { + if (_handles[i]) + _handles[i]->Release(); + } + } + + HwVideoBufferPath sourcePath() const override { return _path; } + + /// Reuse-eligible when the pooled staging handles are identical: the pool rotates a stable set of resources, so the + /// existing QRhiTexture views transparently sample the newly-copied frame. + bool matches(QRhi* rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, + const std::array& handles, int count) const + { + if (_rhi != rhi || _size != size || _pixelFormat != pixelFormat || _count != count) { + return false; + } + for (int i = 0; i < _count; ++i) { + if (!_handles[i] || _handles[i] != handles[i] || !_textures[i]) { + return false; + } + } + return true; + } + +private: + std::array _handles; + HwVideoBufferPath _path = HwVideoBufferPath::None; + QRhi* _rhi = nullptr; + QSize _size; + QVideoFrameFormat::PixelFormat _pixelFormat = QVideoFrameFormat::Format_Invalid; +}; + +/// Shared D3D11/D3D12 render-thread tail run after the streaming-thread resolve: reuse the prior bundle when the pooled +/// staging handles match, else AddRef the handles, build a fresh FrameTextures, verify every plane, and log first +/// success. The caller guarantees `_resolved` and passes its resolved handle array + count. `HandleT` is deduced from +/// the array (ID3D11Texture2D / ID3D12Resource); `catFn` is the logging-category accessor (e.g. GstD3D11Log) so the +/// qCWarning-based once-logging macro keeps working. Monomorphized per backend; no virtual dispatch added. +template +QVideoFrameTexturesUPtr mapResolvedTextures(GstHwVideoBuffer& self, QRhi& rhi, QVideoFrameTexturesUPtr& old, + HwVideoBufferPath path, + const std::array& resolvedHandles, int resolvedCount, + QSize frameSize, QVideoFrameFormat::PixelFormat pixelFormat, + std::atomic& loggedFirstSuccess, + std::atomic& loggedTextureCreateFail, CatFn catFn, const char* tag) +{ + using BundleT = FrameTextures; + + const auto* desc = QVideoTextureHelper::textureDescription(pixelFormat); + if (!desc || desc->nplanes <= 0) { + return GstHwPathTelemetry::fail(path); + } + + const int textureCount = (std::min)(desc->nplanes, kMaxPlanes); + if (resolvedCount <= 0 || resolvedCount > kMaxPlanes) { + return GstHwPathTelemetry::fail(path); + } + + std::array handles{}; + for (int i = 0; i < (std::min)(resolvedCount, textureCount); ++i) { + handles[i] = resolvedHandles[i]; + } + + if (resolvedCount < textureCount) { + if (resolvedCount != 1 || !handles[0] || !canAliasSingleResourceAcrossPlanes(pixelFormat)) { + return GstHwPathTelemetry::fail(path); + } + // D3D NV12/P010-style decoder output is one native texture with multiple shader-resource planes. Qt's video + // renderer still asks QVideoFrameTextures for one texture per pixel-format plane (R8 luma, RG8 chroma). + for (int i = resolvedCount; i < textureCount; ++i) { + handles[i] = handles[0]; + } + } + + // Pooled staging handles keep stable pointers across frames, so the prior bundle's QRhiTexture views can be reused + // when the handles match. + if (auto* prev = GstHwFrameTexturesBase::reusableBundle(old, path)) { + if (prev->matches(&rhi, frameSize, pixelFormat, handles, textureCount)) { + GstHwPathTelemetry::recordTextureReuse(path); + prev->setSourceSample(self.takeSample()); + QVideoFrameTexturesUPtr reused = std::move(old); + return reused; + } + } + + // Pre-flight RHI format/size support before createFrom() so an unsupported import demotes to CPU on a query. + if (!GstHwImportPreflight::preflightOrRecord(&rhi, path, pixelFormat, frameSize)) { + return GstHwPathTelemetry::fail(path); + } + + // FrameTextures takes ownership of AddRef'd refs; the buffer dtor releases the originals independently. + for (int i = 0; i < textureCount; ++i) { + if (handles[i]) + handles[i]->AddRef(); + } + + auto textures = std::make_unique(path, &rhi, frameSize, pixelFormat, handles, textureCount); + // Check all planes: NV12 chroma can fail while luma succeeds, and a partial bundle renders with missing planes. + for (int i = 0; i < textureCount; ++i) { + if (!textures->texture(static_cast(i))) { + QGC_HW_WARN_ONCE(catFn, loggedTextureCreateFail, + "mapTextures: QRhiTexture::createFrom failed plane=" + << i << " (size=" << frameSize << " format=" << int(pixelFormat) + << " planes=" << textureCount << ")"); + return GstHwPathTelemetry::fail(path); + } + } + + GstHwVideoBuffer::logFirstSuccess(loggedFirstSuccess, catFn(), tag, frameSize, pixelFormat, textureCount); + textures->setSourceSample(self.takeSample()); + return textures; +} + +} // namespace GstD3DVideoBufferCommon + +#endif // Q_OS_WIN && (QGC_HAS_GST_D3D11_GPU_PATH || QGC_HAS_GST_D3D12_GPU_PATH) diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaBufVideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaBufVideoBuffer.cc new file mode 100644 index 000000000000..9ed4e4d16323 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaBufVideoBuffer.cc @@ -0,0 +1,938 @@ +#include "GstDmaBufVideoBuffer.h" + +#include "GstContextBridgeRegistry.h" +#include "GstDmaBufVulkanImport.h" +#include "GstDmaFourcc.h" +#include "GstEglHelpers.h" +#include "GstGlFrameTextures.h" +#include "GstHwImportCache.h" +#include "GstHwImportPreflight.h" +#include "GstHwPathTelemetry.h" +#include "GstHwVideoBuffer.h" +#include "GstVulkanFrameTextures.h" +#include "HwBuffers.h" + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "QGCLoggingCategory.h" +#if GST_CHECK_VERSION(1, 24, 0) +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Producer dma-buf fence export (#6): optional. The header exists on Ubuntu 22.04 but predates the +// EXPORT_SYNC_FILE uAPI (kernel ~6.0), so gate on the ioctl token, not just header presence. +#if __has_include() +#include +#include +#ifdef DMA_BUF_IOCTL_EXPORT_SYNC_FILE +#define QGC_HAS_DMABUF_EXPORT_SYNC 1 +#endif +#endif + +#ifndef EGL_ANDROID_native_fence_sync +#define EGL_SYNC_NATIVE_FENCE_ANDROID 0x3144 +#define EGL_SYNC_NATIVE_FENCE_FD_ANDROID 0x3145 +#define EGL_NO_NATIVE_FENCE_FD_ANDROID -1 +#endif + +QGC_LOGGING_CATEGORY(GstDmaBufLog, "Video.GStreamer.HwBuffers.GstDmaBuf") + +namespace { + +using GstHw::kMaxPlanes; + +std::atomic s_loggedBadBackend{false}; + +// EGL_BAD_MATCH from eglCreateImage = broken EGL/VA-API combo (QTBUG-112312); short-circuits mapTextures on first hit. +std::atomic s_permanentlyDisabled{false}; + +void maybePermanentlyDisable(EGLDisplay eglDpy, int plane, const char* pathTag) +{ + if (!s_permanentlyDisabled.exchange(true, std::memory_order_relaxed)) { + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::DmaBuf, + GstHwPathTelemetry::HwFallbackReason::EglBadMatch); + qCWarning(GstDmaBufLog) << "eglCreateImage returned EGL_BAD_MATCH on" << pathTag << "plane" << plane + << "— disabling DMABuf zero-copy for this process" + << "(EGL_VENDOR=" << eglQueryString(eglDpy, EGL_VENDOR) + << "EGL_VERSION=" << eglQueryString(eglDpy, EGL_VERSION) << ")"; + } +} + +// EGLDisplay is process-stable; cache the value (not a context pointer) to avoid dangling-key hazard. +std::atomic s_cachedDisplay{EGL_NO_DISPLAY}; +std::atomic s_loggedSingleFdDisabled{false}; +std::atomic s_loggedFenceTimeout{false}; + +constexpr const char* kModifiersExt = "EGL_EXT_image_dma_buf_import_modifiers"; + +constexpr const char* kFenceSyncExt = "EGL_KHR_fence_sync"; + +constexpr const char* kNativeFenceExt = "EGL_ANDROID_native_fence_sync"; + +// EGLImage cache (DEFAULT OFF, QGC_GST_DMABUF_CACHE=1): fd/GstMemory ABA means a recycled pool buffer can serve a +// stale image, so we key on (fd, modifier, w, h, fourcc) and weak-ref the GstMemory to evict before the fd is reused. +bool imageCacheEnabled() noexcept +{ + return HwBuffers::hwBufferEnvConfig().dmaBufCache; +} + +bool texStorageImportAllowed(bool contextIsOpenGles, bool hasTexStorageExt, guint64 drmModifier) noexcept +{ + return drmModifier == 0 && !contextIsOpenGles && hasTexStorageExt; +} + +bool directGlImportAllowed(bool hasModifiersExt, guint64 drmModifier) noexcept +{ + Q_UNUSED(hasModifiersExt); + return drmModifier == 0; +} + +struct DmaImageKey +{ + int fd = -1; + guint64 modifier = 0; + int width = 0; + int height = 0; + int fourcc = 0; + + bool operator==(const DmaImageKey& o) const noexcept + { + return fd == o.fd && modifier == o.modifier && width == o.width && height == o.height && fourcc == o.fourcc; + } +}; + +struct DmaImageKeyHash +{ + std::size_t operator()(const DmaImageKey& k) const noexcept + { + std::size_t h = std::hash{}(k.fd); + h ^= std::hash{}(k.modifier) + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); + h ^= std::hash{}(k.width) + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); + h ^= std::hash{}(k.height) + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); + h ^= std::hash{}(k.fourcc) + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); + return h; + } +}; + +struct DmaCachedImage +{ + EGLDisplay display = EGL_NO_DISPLAY; + EGLImage image = EGL_NO_IMAGE_KHR; + GstMemory* mem = nullptr; // weak-ref source; never dereferenced after notify +}; + +constexpr int kImageCacheCapacity = 8; + +QMutex s_dmaCacheMutex; +// EGLImages orphaned by an off-thread weak-notify; destroyed on the render thread (drainOrphanedImagesLocked). +std::vector> s_orphanedImages; + +void onSourceMemoryFinalized(gpointer userData, GstMiniObject* finalizedMem); + +// Render-thread; caller holds s_dmaCacheMutex with Qt's GL context current. +void drainOrphanedImagesLocked() +{ + for (const auto& [display, image] : s_orphanedImages) { + if (image != EGL_NO_IMAGE_KHR && display != EGL_NO_DISPLAY) { + eglDestroyImage(display, image); + } + } + s_orphanedImages.clear(); +} + +// Eviction/erase/clear hook. Render thread (GL context current): destroy the EGLImage and drop the GstMemory weak-ref. +// Off-thread weak-notify (no GL context): defer the image to s_orphanedImages and leave the weak-ref alone — the notify +// itself consumes it. +void destroyCachedDmaImage(const DmaImageKey&, DmaCachedImage& entry) +{ + const bool onRenderThread = QOpenGLContext::currentContext() != nullptr; + if (entry.image != EGL_NO_IMAGE_KHR && entry.display != EGL_NO_DISPLAY) { + if (onRenderThread) { + eglDestroyImage(entry.display, entry.image); + } else { + s_orphanedImages.emplace_back(entry.display, entry.image); + } + } + if (entry.mem && onRenderThread) { + gst_mini_object_weak_unref(GST_MINI_OBJECT(entry.mem), onSourceMemoryFinalized, nullptr); + } +} + +GstHw::GstHwImportCache s_dmaImageCache{kImageCacheCapacity, + destroyCachedDmaImage}; + +// Weak-notify on the pool/streaming thread: no GL context, so the stale EGLImage is queued for render-thread +// destruction (drainOrphanedImagesLocked) rather than destroyed here. +void onSourceMemoryFinalized(gpointer userData, GstMiniObject* finalizedMem) +{ + const QMutexLocker lock(&s_dmaCacheMutex); + s_dmaImageCache.eraseIf([&](const DmaImageKey&, const DmaCachedImage& e) { + return e.mem == reinterpret_cast(finalizedMem); + }); + Q_UNUSED(userData); +} + +// Render-thread; caller holds s_dmaCacheMutex with Qt's GL context current. +void clearDmaImageCacheLocked(EGLDisplay eglDpy) +{ + s_dmaImageCache.clear(); + drainOrphanedImagesLocked(); + Q_UNUSED(eglDpy); +} + +EGLImage cachedDmaImageLocked(const DmaImageKey& key, EGLDisplay eglDpy) +{ + if (DmaCachedImage* entry = s_dmaImageCache.find(key); + entry && entry->display == eglDpy && entry->image != EGL_NO_IMAGE_KHR) { + return entry->image; + } + return EGL_NO_IMAGE_KHR; +} + +void insertDmaImageLocked(const DmaImageKey& key, EGLDisplay eglDpy, EGLImage image, GstMemory* mem) +{ + if (mem) { + gst_mini_object_weak_ref(GST_MINI_OBJECT(mem), onSourceMemoryFinalized, nullptr); + } + s_dmaImageCache.insert(key, DmaCachedImage{eglDpy, image, mem}); +} + +std::atomic s_dmaCacheResetPending{false}; + +bool singleFdImportEnabled() noexcept +{ + return HwBuffers::hwBufferEnvConfig().dmaBufSingleEglImage; +} + +// Per-plane EGL attrib keys — spec defines distinct enums per plane index. +static constexpr EGLint kPlFd[4] = { + EGL_DMA_BUF_PLANE0_FD_EXT, + EGL_DMA_BUF_PLANE1_FD_EXT, + EGL_DMA_BUF_PLANE2_FD_EXT, + EGL_DMA_BUF_PLANE3_FD_EXT, +}; +static constexpr EGLint kPlOffset[4] = { + EGL_DMA_BUF_PLANE0_OFFSET_EXT, + EGL_DMA_BUF_PLANE1_OFFSET_EXT, + EGL_DMA_BUF_PLANE2_OFFSET_EXT, + EGL_DMA_BUF_PLANE3_OFFSET_EXT, +}; +static constexpr EGLint kPlPitch[4] = { + EGL_DMA_BUF_PLANE0_PITCH_EXT, + EGL_DMA_BUF_PLANE1_PITCH_EXT, + EGL_DMA_BUF_PLANE2_PITCH_EXT, + EGL_DMA_BUF_PLANE3_PITCH_EXT, +}; +static constexpr EGLint kPlModLo[4] = { + EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT, + EGL_DMA_BUF_PLANE1_MODIFIER_LO_EXT, + EGL_DMA_BUF_PLANE2_MODIFIER_LO_EXT, + EGL_DMA_BUF_PLANE3_MODIFIER_LO_EXT, +}; +static constexpr EGLint kPlModHi[4] = { + EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT, + EGL_DMA_BUF_PLANE1_MODIFIER_HI_EXT, + EGL_DMA_BUF_PLANE2_MODIFIER_HI_EXT, + EGL_DMA_BUF_PLANE3_MODIFIER_HI_EXT, +}; + +class FrameTextures final : public GstGlFrameTextures +{ +public: + FrameTextures(QRhi* rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, + std::array names, int count) + : GstGlFrameTextures(rhi, size, pixelFormat, names, count, FallbackPolicy::Disable) + {} + + ~FrameTextures() override + { + releaseGLTextures(); // safety net if onFrameEndInvoked() was never called + } + + void onFrameEndInvoked() override + { + releaseGLTextures(); + GstHwFrameTexturesBase::onFrameEndInvoked(); + } + +private: + void releaseGLTextures() + { + if (_released || !_rhi || _count == 0) + return; + _released = true; + // Bind the QRhi GL context so currentContext() returns it (mirrors ~QGstQVideoFrameTextures), avoiding the + // backend-conditional NativeHandles cast. + _rhi->makeThreadLocalNativeContextCurrent(); + if (QOpenGLContext* ctx = QOpenGLContext::currentContext()) { + ctx->functions()->glDeleteTextures(int(_count), _names.data()); + } + } + + bool _released = false; +}; + +// RPi eglfs quirk: V3D EGL implicitly converts YUYV/UYVY DMABuf to RGBA, so declare RGBA8888 to pick Qt's RGB shader +// and avoid a double YUV->RGB (mirrors Qt's eglfs branch). +QVideoFrameFormat applyEglfsFormatQuirk(const QVideoFrameFormat& format) +{ + static const bool isEglfsQPA = QGuiApplication::platformName() == QLatin1String("eglfs"); + if (!isEglfsQPA) + return format; + const auto fmt = format.pixelFormat(); + if (fmt != QVideoFrameFormat::Format_UYVY && fmt != QVideoFrameFormat::Format_YUYV) { + return format; + } + QVideoFrameFormat spoofed(format.frameSize(), QVideoFrameFormat::Format_RGBA8888); + spoofed.setStreamFrameRate(format.streamFrameRate()); + spoofed.setColorRange(format.colorRange()); + spoofed.setColorSpace(format.colorSpace()); + spoofed.setColorTransfer(format.colorTransfer()); + spoofed.setViewport(format.viewport()); + return spoofed; +} + +std::atomic s_loggedExplicitFence{false}; + +// #6 best-effort GPU-side wait on the producer's fence: export a sync_file fd from the dma-buf, import it as an +// EGL_SYNC_NATIVE_FENCE_ANDROID sync and eglWaitSyncKHR (server wait). Returns true only on a confirmed GPU-side wait +// that makes the mmap CPU barrier redundant; any failure leaves the caller's existing barrier path untouched. +bool tryExplicitFenceWait(EGLDisplay eglDpy, int dmaFd) +{ +#if defined(QGC_HAS_DMABUF_EXPORT_SYNC) + if (eglDpy == EGL_NO_DISPLAY || dmaFd < 0) { + return false; + } + if (!GstEglHelpers::displaySupportsExtension(eglDpy, kNativeFenceExt)) { + return false; + } + static const auto createSyncKHR = reinterpret_cast(eglGetProcAddress("eglCreateSyncKHR")); + static const auto waitSyncKHR = reinterpret_cast(eglGetProcAddress("eglWaitSyncKHR")); + static const auto destroySyncKHR = + reinterpret_cast(eglGetProcAddress("eglDestroySyncKHR")); + if (!createSyncKHR || !waitSyncKHR || !destroySyncKHR) { + return false; + } + + dma_buf_export_sync_file req = {}; + req.flags = DMA_BUF_SYNC_READ; // wait only on writers (the decoder), not on prior readers + req.fd = -1; + if (ioctl(dmaFd, DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &req) != 0 || req.fd < 0) { + return false; + } + + // EGL takes ownership of req.fd on a successful create (closes it via eglDestroySync); close it ourselves only if + // create fails. + const EGLint attribs[] = {EGL_SYNC_NATIVE_FENCE_FD_ANDROID, req.fd, EGL_NONE}; + EGLSyncKHR sync = createSyncKHR(eglDpy, EGL_SYNC_NATIVE_FENCE_ANDROID, attribs); + if (sync == EGL_NO_SYNC_KHR) { + ::close(req.fd); + return false; + } + const EGLint waited = waitSyncKHR(eglDpy, sync, 0); + destroySyncKHR(eglDpy, sync); + if (waited != EGL_TRUE) { + return false; + } + QGC_HW_WARN_ONCE(GstDmaBufLog, s_loggedExplicitFence, + "DMABuf: using producer dma-buf fence for GPU-side sync (mmap barrier skipped)"); + GstHwPathTelemetry::recordExplicitFenceWait(HwVideoBufferPath::DmaBuf); + GstHwPathTelemetry::recordSyncWait(HwVideoBufferPath::DmaBuf, /*gpuSide=*/true); + return true; +#else + Q_UNUSED(eglDpy); + Q_UNUSED(dmaFd); + return false; +#endif +} + +} // namespace + +GstDmaBufVideoBuffer::GstDmaBufVideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, + const QVideoFrameFormat& format, EGLDisplay eglDisplay) + : GstHwVideoBuffer(QVideoFrame::RhiTextureHandle, sample, videoInfo, applyEglfsFormatQuirk(format)), + _eglDisplay(eglDisplay) +{ +#if GST_CHECK_VERSION(1, 24, 0) + if (GstCaps* caps = gst_sample_get_caps(sample); caps && gst_video_is_dma_drm_caps(caps)) { + GstVideoInfoDmaDrm drmInfo = {}; + gst_video_info_dma_drm_init(&drmInfo); + // DRM_FORMAT_MOD_INVALID would yield EGL_BAD_PARAMETER on strict drivers (AMD RADV); leave _drmModifier at 0 + // (LINEAR). + if (gst_video_info_dma_drm_from_caps(&drmInfo, caps) && drmInfo.drm_modifier != DRM_FORMAT_MOD_INVALID) { + _drmModifier = drmInfo.drm_modifier; + } + } +#endif +} + +void GstDmaBufVideoBuffer::resetCachedState() noexcept +{ + s_cachedDisplay.store(EGL_NO_DISPLAY, std::memory_order_release); + s_permanentlyDisabled.store(false, std::memory_order_release); + s_loggedSingleFdDisabled.store(false, std::memory_order_release); +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + GstDmaBufVulkan::resetLoggedState(); +#endif + s_loggedBadBackend.store(false, std::memory_order_release); + s_loggedFenceTimeout.store(false, std::memory_order_release); + // Defer EGLImage teardown to the next render-thread mapTextures(); eglDestroyImage off the GL thread is unsafe. + s_dmaCacheResetPending.store(true, std::memory_order_release); +} + +namespace { +struct DmaBufCacheResetRegistrar +{ + DmaBufCacheResetRegistrar() + { + GstContextBridgeRegistry::registerCacheReset(&GstDmaBufVideoBuffer::resetCachedState); + } +}; + +const DmaBufCacheResetRegistrar s_dmaBufCacheResetRegistrar; +} // namespace + +#ifdef QGC_GST_BUILD_TESTING +bool GstDmaBufVideoBuffer::singleFdImportEnabledForTest() noexcept +{ + // Read live: production singleFdImportEnabled() reads the parse-once env cache, which can't observe + // per-case env mutations under test. Mirrors HwBuffers truthy() with default=on. + const QByteArray v = qgetenv("QGC_GST_DMABUF_SINGLE_EGLIMAGE").trimmed().toLower(); + if (v.isEmpty()) { + return true; + } + return v != "0" && v != "false" && v != "off" && v != "no"; +} + +bool GstDmaBufVideoBuffer::texStorageImportAllowedForTest(bool contextIsOpenGles, bool hasTexStorageExt, + guint64 drmModifier) noexcept +{ + return texStorageImportAllowed(contextIsOpenGles, hasTexStorageExt, drmModifier); +} + +bool GstDmaBufVideoBuffer::directGlImportAllowedForTest(bool hasModifiersExt, guint64 drmModifier) noexcept +{ + return directGlImportAllowed(hasModifiersExt, drmModifier); +} +#endif + +bool GstDmaBufVideoBuffer::validatePlaneHandles() const +{ + const bool planesOk = validatePlanes([](GstMemory* mem) { + if (!mem || !gst_is_dmabuf_memory(mem)) + return false; + return gst_dmabuf_memory_get_fd(mem) >= 0; + }); + if (!planesOk) + return false; + // Reject tiled (non-LINEAR) DMABuf without EGL_EXT_image_dma_buf_import_modifiers — mapTextures() needs the + // modifier attribs to import tiled layouts. +#if GST_CHECK_VERSION(1, 24, 0) + // Without the modifiers ext, mmap of tiled strides produces garbage and faults libgstvideo. + if (_drmModifier != 0 && + (_eglDisplay == EGL_NO_DISPLAY || !GstEglHelpers::displaySupportsExtension(_eglDisplay, kModifiersExt))) { + return false; + } +#endif + return true; +} + +QVideoFrameTexturesUPtr GstDmaBufVideoBuffer::mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& /*old*/) +{ + const GstHwPathTelemetry::ScopedMapTimer mapTimer(HwVideoBufferPath::DmaBuf); + // Qt's contract: mapTextures runs on the QRhi (render) thread. Bail rather than crash if ever called off-thread. + if (!rhi.thread()->isCurrentThread()) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + if (!_sample) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + // Bail once a prior frame hit EGL_BAD_MATCH (broken EGL/VA-API, QTBUG-112312); retrying stalls the pipeline. + if (s_permanentlyDisabled.load(std::memory_order_relaxed)) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + // Modifier cached from caps in the ctor (0 = LINEAR / non-DRM / normalized INVALID). + const guint64 drmModifier = _drmModifier; + + // Sync barrier for Intel iHD legacy LINEAR (no implicit fence). Prefer an EGL fence (#5) over a full-frame mmap; + // the fence path needs the GL context current, so it runs below. QGC_GST_DMABUF_NO_MMAP_FENCE=1 skips both where + // PRIME 2 fence FDs make the barrier redundant. + const bool skipLinearFence = HwBuffers::hwBufferEnvConfig().dmaBufNoMmapFence; + const bool needLinearFence = (drmModifier == 0 /* DRM_FORMAT_MOD_LINEAR / unknown */ && !skipLinearFence); + +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + if (rhi.backend() == QRhi::Vulkan) { + return importVulkan(rhi); + } +#endif + if (rhi.backend() != QRhi::OpenGLES2) { + QGC_HW_WARN_ONCE(GstDmaBufLog, s_loggedBadBackend, + "QRhi backend is not OpenGLES2/Vulkan; zero-copy DMABuf path unsupported"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + // Bind Qt's GL context before any EGL/GL call, else the calls no-op into a foreign/null context (import succeeds + // but samples green). + rhi.makeThreadLocalNativeContextCurrent(); + + // Mirror Qt 6.10 qgstvideobuffer.cpp:349 — surface latent GL/EGL state from prior frames. + // Qt 6.12 moved its DMABuf gate from eglfs-only to qGstEglCanMapDmaBuf() and added modifier checks; keep this + // richer QGC path in sync when raising the Qt-private-source baseline. + if (Q_UNLIKELY(GstDmaBufLog().isDebugEnabled())) { + const GLenum glErr = glGetError(); + const EGLint eglErr = eglGetError(); + if (glErr != GL_NO_ERROR || eglErr != EGL_SUCCESS) { + qCDebug(GstDmaBufLog) << "mapTextures entry, latent glError=" << Qt::hex << glErr << "eglError=" << eglErr; + } + } + + const auto* nativeHandles = static_cast(rhi.nativeHandles()); + if (!nativeHandles || !nativeHandles->context) { + qCWarning(GstDmaBufLog) << "QRhi exposes no GL context"; + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + // Prefer QEGLContext::display(): EGLImages from one display can't be sampled by a context on another, and + // eglGetCurrentDisplay/eglGetDisplay disagree under xcb_egl. + EGLDisplay eglDpy = s_cachedDisplay.load(std::memory_order_acquire); + if (eglDpy == EGL_NO_DISPLAY) { + eglDpy = GstEglHelpers::resolveEglDisplay(nativeHandles->context); + if (eglDpy == EGL_NO_DISPLAY) + eglDpy = _eglDisplay; + if (eglDpy == EGL_NO_DISPLAY) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + // First writer wins; racing renders that resolve the same display make this idempotent. + EGLDisplay expected = EGL_NO_DISPLAY; + s_cachedDisplay.compare_exchange_strong(expected, eglDpy, std::memory_order_release); + } + + // Render thread, GL context current: reap EGLImages orphaned by off-thread weak-notifies, then any pending reset. + if (imageCacheEnabled()) { + const bool resetPending = s_dmaCacheResetPending.exchange(false, std::memory_order_acq_rel); + const QMutexLocker lock(&s_dmaCacheMutex); + if (!s_orphanedImages.empty()) { + drainOrphanedImagesLocked(); + } + if (resetPending) { + clearDmaImageCacheLocked(eglDpy); + } + } + + // #5 LINEAR sync barrier: prefer EGL_KHR_fence_sync (insert+client-wait, a GPU-completion wait) over mmapping the + // whole frame. mmap remains the fallback when the fence ext is absent (a CPU-side read barrier). + if (needLinearFence) { + // #6: prefer importing the producer's dma-buf fence for a pure GPU-side wait; only if that fails do we fall + // back to the #5 EGL-fence-then-mmap barrier below (never removed — just skipped on a confirmed GPU wait). + int producerFd = -1; + if (GstBuffer* fenceBuf = gst_sample_get_buffer(_sample)) { + if (GstMemory* m0 = gst_buffer_peek_memory(fenceBuf, 0); m0 && gst_is_dmabuf_memory(m0)) { + producerFd = gst_dmabuf_memory_get_fd(m0); + } + } + const bool producerFenced = tryExplicitFenceWait(eglDpy, producerFd); + if (!producerFenced) { + static std::pair fenceExtProbe{EGL_NO_DISPLAY, false}; + if (fenceExtProbe.first != eglDpy) { + fenceExtProbe = {eglDpy, GstEglHelpers::displaySupportsExtension(eglDpy, kFenceSyncExt)}; + } + const bool hasFenceExt = fenceExtProbe.second; + static const auto createSyncKHR = + reinterpret_cast(eglGetProcAddress("eglCreateSyncKHR")); + static const auto clientWaitSyncKHR = + reinterpret_cast(eglGetProcAddress("eglClientWaitSyncKHR")); + static const auto destroySyncKHR = + reinterpret_cast(eglGetProcAddress("eglDestroySyncKHR")); + bool fenced = false; + if (hasFenceExt && createSyncKHR && clientWaitSyncKHR && destroySyncKHR) { + if (EGLSyncKHR sync = createSyncKHR(eglDpy, EGL_SYNC_FENCE_KHR, nullptr); sync != EGL_NO_SYNC_KHR) { + // Bounded wait: this runs on Qt's render thread, so a GPU hang must not block forever — time out + // and fall through to the mmap barrier instead. + const EGLint waitResult = clientWaitSyncKHR(eglDpy, sync, EGL_SYNC_FLUSH_COMMANDS_BIT_KHR, + static_cast(100000000)); + destroySyncKHR(eglDpy, sync); + GstHwPathTelemetry::recordSyncWait(HwVideoBufferPath::DmaBuf, /*gpuSide=*/true); + if (waitResult == EGL_CONDITION_SATISFIED_KHR) { + fenced = true; + } else { + GstHwPathTelemetry::recordFenceTimeout(HwVideoBufferPath::DmaBuf); + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::DmaBuf, + GstHwPathTelemetry::HwFallbackReason::FenceTimeout); + QGC_HW_WARN_ONCE( + GstDmaBufLog, s_loggedFenceTimeout, + "EGL fence wait did not satisfy within 100 ms (GPU stall?) — using mmap barrier"); + } + } + } + if (!fenced) { + // Fallback: mmap the frame purely as a CPU-side completion barrier (defeats zero-copy for one map + // cycle). + if (GstBuffer* syncBuf = gst_sample_get_buffer(_sample)) { + GstVideoFrame syncFrame; + if (gst_video_frame_map(&syncFrame, &_videoInfo, syncBuf, GST_MAP_READ)) { + gst_video_frame_unmap(&syncFrame); + GstHwPathTelemetry::recordSyncWait(HwVideoBufferPath::DmaBuf, /*gpuSide=*/false); + GstHwPathTelemetry::recordMmapBarrierHit(HwVideoBufferPath::DmaBuf); + } + } + } + } + } + + GstBuffer* buffer = gst_sample_get_buffer(_sample); + if (!buffer || !gst_is_dmabuf_memory(gst_buffer_peek_memory(buffer, 0))) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + const bool hasModifiersExt = GstEglHelpers::displaySupportsExtension(eglDpy, kModifiersExt); + if (!directGlImportAllowed(hasModifiersExt, drmModifier)) { + // Reaching here means caps/filtering changed between the validate and render threads or an upstream element + // pushed a modifier layout we do not advertise. Fall back before either GL import path can bind the EGLImage. + static std::atomic warnedModifierRejected{false}; + if (!warnedModifierRejected.exchange(true, std::memory_order_relaxed)) { + qCWarning(GstDmaBufLog) + << "DMABuf modifier" << Qt::hex << drmModifier + << "rejected for direct GL import" + << (hasModifiersExt ? "(EGL modifier import present but disabled for safety)" + : "(EGL_EXT_image_dma_buf_import_modifiers absent)") + << "— falling back to CPU"; + } + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::DmaBuf, + GstHwPathTelemetry::HwFallbackReason::ModifierRejected); + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + static const auto eglImageTargetTexture2D = + reinterpret_cast(eglGetProcAddress("glEGLImageTargetTexture2DOES")); + if (!eglImageTargetTexture2D) { + qCWarning(GstDmaBufLog) << "glEGLImageTargetTexture2DOES unavailable"; + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + // Desktop GL with GL_EXT_EGL_image_storage: bind LINEAR DMABuf via immutable storage to skip the per-frame + // texture-storage realloc the mutable OES path forces (OBS measured ~100x on discrete Intel). Tiled DMABuf stays + // on glEGLImageTargetTexture2DOES; Mesa/Gallium can segfault when tiled VA images take the tex-storage path. + // GLES also keeps the OES path: the storage ext isn't reliably exposed and the OES bind is the contract there. + PFNGLEGLIMAGETARGETTEXSTORAGEEXTPROC eglImageTargetTexStorage = nullptr; + if (const QOpenGLContext* ctx = QOpenGLContext::currentContext()) { + const bool useTexStorage = + texStorageImportAllowed(ctx->isOpenGLES(), + ctx->hasExtension(QByteArrayLiteral("GL_EXT_EGL_image_storage")), drmModifier); + eglImageTargetTexStorage = useTexStorage + ? reinterpret_cast( + eglGetProcAddress("glEGLImageTargetTexStorageEXT")) + : nullptr; + } + const auto bindEglImage = [&](GLenum target, EGLImage img) { + if (eglImageTargetTexStorage) + eglImageTargetTexStorage(target, img, nullptr); + else + eglImageTargetTexture2D(target, img); + }; + + // gst_video_frame_map(GST_MAP_READ) on DMABuf mmaps and defeats zero-copy; read offsets from GstVideoMeta. + // Qt 6.12's upstream GStreamer path also prefers GstVideoMeta/video-info offsets; preserve this no-mmap behavior when + // reconciling against newer Qt sources. + GstVideoMeta* vmeta = gst_buffer_get_video_meta(buffer); + const int planeCount = std::clamp(int(GST_VIDEO_INFO_N_PLANES(&_videoInfo)), 1, kMaxPlanes); + const int memCount = int(gst_buffer_n_memory(buffer)); + // memCount==1: either multi-plane shared fd (NV12) or packed single-plane (YUY2/UYVY/…). + const int cachedSingleFourcc = (memCount == 1) ? GstHw::drmFourccForSingleFd(_videoInfo) : -1; + bool singleFdPacking = (cachedSingleFourcc != -1); + if (singleFdPacking && !singleFdImportEnabled()) { + if (!s_loggedSingleFdDisabled.exchange(true, std::memory_order_relaxed)) { + qCInfo(GstDmaBufLog) << "Single-EGLImage DMABuf import disabled by" + << "QGC_GST_DMABUF_SINGLE_EGLIMAGE; using per-plane import" + << "when the buffer layout permits, otherwise CPU fallback"; + } + singleFdPacking = false; + } + if (memCount != planeCount && !singleFdPacking) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + // Pre-flight RHI format/size support before any EGLImage import / createFrom() so an unsupported frame demotes to + // CPU on a query instead of after the GL/EGL work. + if (!GstHwImportPreflight::preflightOrRecord(&rhi, HwVideoBufferPath::DmaBuf, _format.pixelFormat(), + _format.frameSize())) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + // No texture reuse: caching QRhiTextures across frames caused periodic green frames on real RTSP H.264, and the + // ~us/frame win isn't worth it. + std::array names{}; + QOpenGLFunctions functions(nativeHandles->context); + functions.glGenTextures(planeCount, names.data()); + + // Modifier attribs only with the ext present; otherwise impl assumes LINEAR (matches our rejection above). + const EGLAttrib modLo = static_cast(drmModifier & 0xFFFFFFFFu); + const EGLAttrib modHi = static_cast((drmModifier >> 32) & 0xFFFFFFFFu); + + bool ok = true; + + // Single-fd fast path: one EGLImage for all planes (Mesa VA-API NV12 ships memCount==1; iHD rejects split-plane + // R8/GR88 imports). + if (singleFdPacking) { + if (planeCount > kMaxPlanes) { + qCWarning(GstDmaBufLog) << "single-fd format has" << planeCount << "planes (>" << kMaxPlanes + << "); falling back to per-plane"; + goto per_plane_path; + } + const int singleFourcc = cachedSingleFourcc; + if (singleFourcc == -1) { + goto per_plane_path; + } + const int fd0 = gst_dmabuf_memory_get_fd(gst_buffer_peek_memory(buffer, 0)); + // attribPush writes 2 EGLAttribs/call: 3 header keys + up to 5/plane*kMaxPlanes, *2, +1 EGL_NONE terminator. + EGLAttrib attribs[2 * (3 + kMaxPlanes * 5) + 1]; + int n = 0; + bool attribOverflow = false; + auto attribPush = [&](EGLAttrib k, EGLAttrib v) { + if (n >= int(std::size(attribs)) - 1) { + attribOverflow = true; + return; + } + attribs[n++] = k; + attribs[n++] = v; + }; + attribPush(EGL_WIDTH, GST_VIDEO_INFO_WIDTH(&_videoInfo)); + attribPush(EGL_HEIGHT, GST_VIDEO_INFO_HEIGHT(&_videoInfo)); + attribPush(EGL_LINUX_DRM_FOURCC_EXT, singleFourcc); + for (int p = 0; p < planeCount; ++p) { + const auto off = vmeta ? vmeta->offset[p] : GST_VIDEO_INFO_PLANE_OFFSET(&_videoInfo, p); + const auto pitch = vmeta ? vmeta->stride[p] : GST_VIDEO_INFO_PLANE_STRIDE(&_videoInfo, p); + attribPush(kPlFd[p], fd0); + attribPush(kPlOffset[p], static_cast(off)); + attribPush(kPlPitch[p], static_cast(pitch)); + if (hasModifiersExt) { // interleaved per spec: modifier attribs follow FD/OFFSET/PITCH for same plane + attribPush(kPlModLo[p], modLo); + attribPush(kPlModHi[p], modHi); + } + } + if (attribOverflow) { + qCWarning(GstDmaBufLog) << "single-fd EGL attrib list overflow; falling back to per-plane"; + goto per_plane_path; + } + attribs[n++] = EGL_NONE; + const bool cacheOn = imageCacheEnabled(); + const DmaImageKey key{fd0, drmModifier, GST_VIDEO_INFO_WIDTH(&_videoInfo), GST_VIDEO_INFO_HEIGHT(&_videoInfo), + singleFourcc}; + EGLImage singleImage = EGL_NO_IMAGE_KHR; + bool singleImageCached = false; + if (cacheOn) { + const QMutexLocker lock(&s_dmaCacheMutex); + singleImage = cachedDmaImageLocked(key, eglDpy); + singleImageCached = (singleImage != EGL_NO_IMAGE_KHR); + } + if (singleImage == EGL_NO_IMAGE_KHR) { + singleImage = eglCreateImage(eglDpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, attribs); + if (cacheOn) { + GstHwPathTelemetry::recordImageCacheMiss(HwVideoBufferPath::DmaBuf); + } + } else if (cacheOn) { + GstHwPathTelemetry::recordImageCacheHit(HwVideoBufferPath::DmaBuf); + } + if (singleImage != EGL_NO_IMAGE_KHR) { + for (int p = 0; p < planeCount; ++p) { + functions.glBindTexture(GL_TEXTURE_2D, names[p]); + bindEglImage(GL_TEXTURE_2D, singleImage); + // glGetError forces a pipeline flush — only call when category debug is on. + if (Q_UNLIKELY(GstDmaBufLog().isDebugEnabled())) { + if (const GLenum glErr = functions.glGetError(); glErr != GL_NO_ERROR) { + qCDebug(GstDmaBufLog) << "single-fd eglImageTargetTexture2D plane" << p << "glError=" << Qt::hex + << glErr << "eglError=" << eglGetError(); + } + } + } + if (cacheOn && !singleImageCached) { + const QMutexLocker lock(&s_dmaCacheMutex); + insertDmaImageLocked(key, eglDpy, singleImage, gst_buffer_peek_memory(buffer, 0)); + } else if (!cacheOn) { + eglDestroyImage(eglDpy, singleImage); + } + goto textures_built; + } +#if defined(DRM_FORMAT_Y210) || defined(DRM_FORMAT_Y410) + // Y210/Y410 have no per-plane fallback; warn once that frames will be black. + { + static std::atomic s_warnedY210{false}; + static std::atomic s_warnedY410{false}; +#if defined(DRM_FORMAT_Y210) + if (singleFourcc == DRM_FORMAT_Y210 && !s_warnedY210.exchange(true, std::memory_order_relaxed)) + qCWarning(GstDmaBufLog) << "EGL DMABuf import of Y210 unsupported on this driver; frames will be black"; +#endif +#if defined(DRM_FORMAT_Y410) + if (singleFourcc == DRM_FORMAT_Y410 && !s_warnedY410.exchange(true, std::memory_order_relaxed)) + qCWarning(GstDmaBufLog) << "EGL DMABuf import of Y410 unsupported on this driver; frames will be black"; +#endif + } +#endif + const EGLint singleFdErr = eglGetError(); + if (drmModifier != 0) { + // No per-plane fallback for tiled: GL_TEXTURE_2D import on tiled memory is unsafe (driver + // crash/corruption). + qCWarning(GstDmaBufLog) << "single-fd tiled eglCreateImage failed (" << Qt::hex << singleFdErr + << ") — no per-plane fallback for tiled"; + if (singleFdErr == EGL_BAD_MATCH) { + maybePermanentlyDisable(eglDpy, 0, "single-fd-tiled"); + } + ok = false; + goto textures_built; + } + qCWarning(GstDmaBufLog) << "single-fd eglCreateImage failed (" << Qt::hex << singleFdErr + << "); falling back to per-plane"; + if (singleFdErr == EGL_BAD_MATCH) { + // BAD_MATCH on single-fd is permanent (per-plane hits the same issue); mark disabled but still try + // per-plane to surface the second log. + maybePermanentlyDisable(eglDpy, 0, "single-fd"); + } + } + +per_plane_path: + for (int i = 0; i < planeCount && ok; ++i) { + const int memIdx = singleFdPacking ? 0 : i; + const int fd = gst_dmabuf_memory_get_fd(gst_buffer_peek_memory(buffer, memIdx)); + // Dedicated-fd planes already start at the plane data; the offset only applies to single-fd packing. + const auto offset = + singleFdPacking ? (vmeta ? vmeta->offset[i] : GST_VIDEO_INFO_PLANE_OFFSET(&_videoInfo, i)) : 0; + const auto stride = vmeta ? vmeta->stride[i] : GST_VIDEO_INFO_PLANE_STRIDE(&_videoInfo, i); + if (stride <= 0 || + static_cast(offset) > static_cast((std::numeric_limits::max)())) { + qCWarning(GstDmaBufLog) << "implausible plane stride/offset (stride=" << stride << "offset=" << offset + << "); CPU fallback"; + ok = false; + break; + } + const int planeWidth = GST_VIDEO_INFO_COMP_WIDTH(&_videoInfo, i); + const int planeHeight = GST_VIDEO_INFO_COMP_HEIGHT(&_videoInfo, i); + const int fourcc = GstHw::drmFourccForPlane(_videoInfo, i); + if (fourcc == -1) { + qCWarning(GstDmaBufLog) << "no DRM fourcc for format" + << gst_video_format_to_string(GST_VIDEO_INFO_FORMAT(&_videoInfo)); + ok = false; + break; + } + + // EGL attribute list — sized for the maximum (with modifier ext) and zero-terminated. + EGLAttrib attribs[18]; + int n = 0; + bool attribOverflow = false; + auto attribPush = [&](EGLAttrib k, EGLAttrib v) { + if (n >= int(std::size(attribs)) - 1) { + attribOverflow = true; + return; + } + attribs[n++] = k; + attribs[n++] = v; + }; + attribPush(EGL_WIDTH, planeWidth); + attribPush(EGL_HEIGHT, planeHeight); + attribPush(EGL_LINUX_DRM_FOURCC_EXT, fourcc); + attribPush(kPlFd[0], fd); + attribPush(kPlOffset[0], static_cast(offset)); + attribPush(kPlPitch[0], static_cast(stride)); + if (hasModifiersExt) { + // Modifier keys index EGLImage plane slots, not source planes; data sits in slot 0 so the modifier goes to + // PLANE0 ([i] would push PLANE1+ attribs that AMD RADV rejects). + attribPush(kPlModLo[0], modLo); + attribPush(kPlModHi[0], modHi); + } + if (attribOverflow) { + qCWarning(GstDmaBufLog) << "per-plane EGL attrib list overflow; CPU fallback"; + ok = false; + break; + } + attribs[n++] = EGL_NONE; + const bool cacheOn = imageCacheEnabled(); + // Per-plane key: same fd may back several planes (NV12 single-fd), so width/height/fourcc disambiguate slots. + const DmaImageKey key{fd, drmModifier, planeWidth, planeHeight, fourcc}; + EGLImage image = EGL_NO_IMAGE_KHR; + bool imageCached = false; + if (cacheOn) { + const QMutexLocker lock(&s_dmaCacheMutex); + image = cachedDmaImageLocked(key, eglDpy); + imageCached = (image != EGL_NO_IMAGE_KHR); + } + if (image == EGL_NO_IMAGE_KHR) { + image = eglCreateImage(eglDpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, attribs); + if (cacheOn) { + GstHwPathTelemetry::recordImageCacheMiss(HwVideoBufferPath::DmaBuf); + } + } else if (cacheOn) { + GstHwPathTelemetry::recordImageCacheHit(HwVideoBufferPath::DmaBuf); + } + if (image == EGL_NO_IMAGE_KHR) { + const EGLint eglErr = eglGetError(); + qCWarning(GstDmaBufLog) << "eglCreateImage failed plane" << i << "err" << Qt::hex << eglErr; + if (eglErr == EGL_BAD_MATCH) { + maybePermanentlyDisable(eglDpy, i, "per-plane"); + } + ok = false; + break; + } + functions.glBindTexture(GL_TEXTURE_2D, names[i]); + bindEglImage(GL_TEXTURE_2D, image); + if (Q_UNLIKELY(GstDmaBufLog().isDebugEnabled())) { + if (const GLenum glErr = functions.glGetError(); glErr != GL_NO_ERROR) { + qCDebug(GstDmaBufLog) << "per-plane eglImageTargetTexture2D plane" << i << "glError=" << Qt::hex + << glErr << "eglError=" << eglGetError(); + } + } + if (cacheOn && !imageCached) { + const QMutexLocker lock(&s_dmaCacheMutex); + insertDmaImageLocked(key, eglDpy, image, gst_buffer_peek_memory(buffer, memIdx)); + } else if (!cacheOn) { + eglDestroyImage(eglDpy, image); + } + } + +textures_built: + + if (!ok) { + functions.glDeleteTextures(planeCount, names.data()); + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + auto frameTextures = + std::make_unique(&rhi, _format.frameSize(), _format.pixelFormat(), names, planeCount); + // FrameTextures null-resets planes createFrom() rejects; a partial bundle renders black without bumping the failure + // counter, so bail loudly instead. + for (int i = 0; i < planeCount; ++i) { + if (!frameTextures->texture(i)) { + qCWarning(GstDmaBufLog) << "FrameTextures plane" << i << "createFrom failed — dropping frame"; + // ~FrameTextures glDeleteTextures via releaseGLTextures() — names are owned now. + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + } + frameTextures->setSourceSample(takeSample()); + return frameTextures; +} + +#endif // QGC_HAS_GST_DMABUF_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaBufVideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaBufVideoBuffer.h new file mode 100644 index 000000000000..6ad012e7a09b --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaBufVideoBuffer.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + +#include +#include + +#include "GstHwVideoBuffer.h" + +class QRhi; + +/// \brief Zero-copy QVideoFrame backing for GStreamer DMABuf samples; imports per-plane fds into EGLImages on demand in +/// mapTextures() (render thread, current GL context). +class GstDmaBufVideoBuffer final : public GstHwVideoBuffer +{ +public: + GstDmaBufVideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, const QVideoFrameFormat& format, + EGLDisplay eglDisplay); + + bool isDmaBuf() const override { return true; } + + QVideoFrameTexturesUPtr mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& oldTextures) override; + bool validatePlaneHandles() const override; + + const char* storageTag() const override { return "DMABuf"; } + + static void resetCachedState() noexcept; + +#ifdef QGC_GST_BUILD_TESTING + static bool singleFdImportEnabledForTest() noexcept; + static bool directGlImportAllowedForTest(bool hasModifiersExt, guint64 drmModifier) noexcept; + static bool texStorageImportAllowedForTest(bool contextIsOpenGles, bool hasTexStorageExt, + guint64 drmModifier) noexcept; +#endif + +private: +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + QVideoFrameTexturesUPtr importVulkan(QRhi& rhi); +#endif + + EGLDisplay _eglDisplay = EGL_NO_DISPLAY; + guint64 _drmModifier = 0; // 0 = LINEAR / non-DRM; parsed once from caps in the ctor +}; + +#endif // QGC_HAS_GST_DMABUF_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaBufVulkanImport.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaBufVulkanImport.cc new file mode 100644 index 000000000000..583955f7fba7 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaBufVulkanImport.cc @@ -0,0 +1,301 @@ +#include "GstDmaBufVulkanImport.h" + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) && defined(QGC_HAS_GST_VULKAN_GPU_PATH) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "GstDmaBufVideoBuffer.h" +#include "GstDmaFourcc.h" +#include "GstHwImportPreflight.h" +#include "GstHwPathTelemetry.h" +#include "GstHwVideoBuffer.h" +#include "GstVulkanFrameTextures.h" +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(GstDmaBufVulkanLog, "Video.GStreamer.HwBuffers.GstDmaBufVulkan") + +namespace { + +std::atomic s_loggedVulkanUnimpl{false}; + +// DRM fourcc -> single-plane VkFormat for formats importable as one VkImage. Returns VK_FORMAT_UNDEFINED for layouts +// that need a multi-disjoint-plane import (handled by the CPU fallback). +VkFormat vkFormatForDrmFourcc(int fourcc) +{ + switch (fourcc) { + case DRM_FORMAT_ABGR8888: + case DRM_FORMAT_XBGR8888: + return VK_FORMAT_R8G8B8A8_UNORM; + case DRM_FORMAT_ARGB8888: + case DRM_FORMAT_XRGB8888: + return VK_FORMAT_B8G8R8A8_UNORM; + case DRM_FORMAT_NV12: + return VK_FORMAT_G8_B8R8_2PLANE_420_UNORM; + case DRM_FORMAT_P010: + return VK_FORMAT_G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16; + case DRM_FORMAT_YUV420: + return VK_FORMAT_G8_B8_R8_3PLANE_420_UNORM; + default: + return VK_FORMAT_UNDEFINED; + } +} + +// Process-cached Vulkan dispatch for the DMABuf import. Device-level entry points are resolved through +// vkGetDeviceProcAddr (itself obtained from the instance loader); vkGetPhysicalDeviceMemoryProperties stays on the +// instance loader. The QRhi VkDevice is process-stable, so a single resolve serves every frame. +struct VulkanImportFns +{ + PFN_vkCreateImage createImage = nullptr; + PFN_vkDestroyImage destroyImage = nullptr; + PFN_vkAllocateMemory allocateMemory = nullptr; + PFN_vkFreeMemory freeMemory = nullptr; + PFN_vkBindImageMemory bindImageMemory = nullptr; + PFN_vkGetImageMemoryRequirements getImageMemoryRequirements = nullptr; + PFN_vkGetMemoryFdPropertiesKHR getMemoryFdProperties = nullptr; + PFN_vkGetPhysicalDeviceMemoryProperties getPhysicalDeviceMemoryProperties = nullptr; + + bool ok() const noexcept + { + return createImage && destroyImage && allocateMemory && freeMemory && bindImageMemory && + getImageMemoryRequirements && getMemoryFdProperties && getPhysicalDeviceMemoryProperties; + } +}; + +const VulkanImportFns& resolveVulkanImportFns(QVulkanInstance& inst, VkDevice dev) +{ + static VulkanImportFns fns; + static std::once_flag once; + std::call_once(once, [&] { + const auto getDeviceProcAddr = + reinterpret_cast(inst.getInstanceProcAddr("vkGetDeviceProcAddr")); + const auto dev_fn = [&](const char* name) -> PFN_vkVoidFunction { + return getDeviceProcAddr ? getDeviceProcAddr(dev, name) : nullptr; + }; + fns.createImage = reinterpret_cast(dev_fn("vkCreateImage")); + fns.destroyImage = reinterpret_cast(dev_fn("vkDestroyImage")); + fns.allocateMemory = reinterpret_cast(dev_fn("vkAllocateMemory")); + fns.freeMemory = reinterpret_cast(dev_fn("vkFreeMemory")); + fns.bindImageMemory = reinterpret_cast(dev_fn("vkBindImageMemory")); + fns.getImageMemoryRequirements = + reinterpret_cast(dev_fn("vkGetImageMemoryRequirements")); + fns.getMemoryFdProperties = + reinterpret_cast(dev_fn("vkGetMemoryFdPropertiesKHR")); + fns.getPhysicalDeviceMemoryProperties = reinterpret_cast( + inst.getInstanceProcAddr("vkGetPhysicalDeviceMemoryProperties")); + }); + return fns; +} + +} // namespace + +namespace GstDmaBufVulkan { + +void resetLoggedState() noexcept +{ + s_loggedVulkanUnimpl.store(false, std::memory_order_release); +} + +} // namespace GstDmaBufVulkan + +QVideoFrameTexturesUPtr GstDmaBufVideoBuffer::importVulkan(QRhi& rhi) +{ + // dmabuf-fd -> VkImage via VK_EXT_external_memory_dma_buf + VkImageDrmFormatModifierExplicitCreateInfoEXT, wrapped + // with QRhiTexture::createFrom. Every step is capability-gated; any miss returns fail() so the caller does the CPU + // upload. Single VkImage per buffer only (multi-plane YUV uses disjoint VkFormat planes); odd layouts fall back. + const auto* nh = static_cast(rhi.nativeHandles()); + if (!nh || nh->dev == VK_NULL_HANDLE || nh->physDev == VK_NULL_HANDLE || !nh->inst) { + QGC_HW_WARN_ONCE(GstDmaBufVulkanLog, s_loggedVulkanUnimpl, "Vulkan import: native device handles unavailable"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + const VkDevice dev = nh->dev; + // Resolve once per process: device-level functions via vkGetDeviceProcAddr (an instance-level lookup of these may + // return a trampoline or null), instance-level vkGetPhysicalDeviceMemoryProperties via the instance loader. The + // QRhi VkDevice is stable for the process, so caching the dispatch is safe and skips 8 lookups every frame. + const VulkanImportFns& fns = resolveVulkanImportFns(*nh->inst, dev); + if (!fns.ok()) { + QGC_HW_WARN_ONCE(GstDmaBufVulkanLog, s_loggedVulkanUnimpl, + "Vulkan import: required device functions (VK_EXT_external_memory_dma_buf) unavailable"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + const auto pfnCreateImage = fns.createImage; + const auto pfnDestroyImage = fns.destroyImage; + const auto pfnAllocateMemory = fns.allocateMemory; + const auto pfnFreeMemory = fns.freeMemory; + const auto pfnBindImageMemory = fns.bindImageMemory; + const auto pfnGetMemReq = fns.getImageMemoryRequirements; + const auto pfnGetFdProps = fns.getMemoryFdProperties; + const auto pfnGetPhysMemProps = fns.getPhysicalDeviceMemoryProperties; + + GstBuffer* buffer = gst_sample_get_buffer(_sample); + if (!buffer || gst_buffer_n_memory(buffer) != 1) { + // Multi-fd disjoint planes: out of scope for this conservative single-VkImage import. + QGC_HW_WARN_ONCE(GstDmaBufVulkanLog, s_loggedVulkanUnimpl, + "Vulkan import: multi-fd DMABuf not supported — CPU upload"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + const int fourcc = GstHw::drmFourccForSingleFd(_videoInfo); + const VkFormat vkFormat = vkFormatForDrmFourcc(fourcc); + if (vkFormat == VK_FORMAT_UNDEFINED) { + QGC_HW_WARN_ONCE(GstDmaBufVulkanLog, s_loggedVulkanUnimpl, + "Vulkan import: no VkFormat for DMABuf layout — CPU upload"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + // Pre-flight RHI format/size support before building the VkImage so an unsupported import demotes to CPU on a + // query. + if (!GstHwImportPreflight::preflightOrRecord(&rhi, HwVideoBufferPath::DmaBuf, _format.pixelFormat(), + _format.frameSize())) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + const int fd = gst_dmabuf_memory_get_fd(gst_buffer_peek_memory(buffer, 0)); + if (fd < 0) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + // memory-plane count == video-plane count only for the uncompressed modifiers this path accepts; a compressed + // modifier (e.g. CCS) adds metadata planes and would need the modifier's own plane count here. + const int planeCount = std::clamp(int(GST_VIDEO_INFO_N_PLANES(&_videoInfo)), 1, GstHw::kMaxPlanes); + GstVideoMeta* vmeta = gst_buffer_get_video_meta(buffer); + VkSubresourceLayout planeLayouts[GstHw::kMaxPlanes] = {}; + for (int p = 0; p < planeCount; ++p) { + planeLayouts[p].offset = vmeta ? vmeta->offset[p] : GST_VIDEO_INFO_PLANE_OFFSET(&_videoInfo, p); + planeLayouts[p].rowPitch = vmeta ? vmeta->stride[p] : GST_VIDEO_INFO_PLANE_STRIDE(&_videoInfo, p); + } + + VkImageDrmFormatModifierExplicitCreateInfoEXT modInfo = {}; + modInfo.sType = VK_STRUCTURE_TYPE_IMAGE_DRM_FORMAT_MODIFIER_EXPLICIT_CREATE_INFO_EXT; + modInfo.drmFormatModifier = _drmModifier; + modInfo.drmFormatModifierPlaneCount = static_cast(planeCount); + modInfo.pPlaneLayouts = planeLayouts; + + VkExternalMemoryImageCreateInfo extImg = {}; + extImg.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO; + extImg.pNext = &modInfo; + extImg.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; + + VkImageCreateInfo imgInfo = {}; + imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imgInfo.pNext = &extImg; + imgInfo.imageType = VK_IMAGE_TYPE_2D; + imgInfo.format = vkFormat; + imgInfo.extent = {static_cast(GST_VIDEO_INFO_WIDTH(&_videoInfo)), + static_cast(GST_VIDEO_INFO_HEIGHT(&_videoInfo)), 1}; + imgInfo.mipLevels = 1; + imgInfo.arrayLayers = 1; + imgInfo.samples = VK_SAMPLE_COUNT_1_BIT; + imgInfo.tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT; + imgInfo.usage = VK_IMAGE_USAGE_SAMPLED_BIT; + imgInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + imgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + + VkImage image = VK_NULL_HANDLE; + if (pfnCreateImage(dev, &imgInfo, nullptr, &image) != VK_SUCCESS || image == VK_NULL_HANDLE) { + QGC_HW_WARN_ONCE(GstDmaBufVulkanLog, s_loggedVulkanUnimpl, "Vulkan import: vkCreateImage failed — CPU upload"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + const auto destroyImage = qScopeGuard([&] { + if (image != VK_NULL_HANDLE) + pfnDestroyImage(dev, image, nullptr); + }); + + // dup the fd: Vulkan takes ownership of the imported fd on successful allocate. + const int dupFd = ::fcntl(fd, F_DUPFD_CLOEXEC, 0); + if (dupFd < 0) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + bool fdConsumed = false; + const auto closeFd = qScopeGuard([&] { + if (!fdConsumed && dupFd >= 0) + ::close(dupFd); + }); + + VkMemoryFdPropertiesKHR fdProps = {}; + fdProps.sType = VK_STRUCTURE_TYPE_MEMORY_FD_PROPERTIES_KHR; + if (pfnGetFdProps(dev, VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, dupFd, &fdProps) != VK_SUCCESS) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + VkMemoryRequirements memReq = {}; + pfnGetMemReq(dev, image, &memReq); + VkPhysicalDeviceMemoryProperties physMem = {}; + pfnGetPhysMemProps(nh->physDev, &physMem); + uint32_t memTypeIndex = UINT32_MAX; + const uint32_t allowed = memReq.memoryTypeBits & fdProps.memoryTypeBits; + for (uint32_t i = 0; i < physMem.memoryTypeCount; ++i) { + if (allowed & (1u << i)) { + memTypeIndex = i; + break; + } + } + if (memTypeIndex == UINT32_MAX) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + VkImportMemoryFdInfoKHR importInfo = {}; + importInfo.sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_FD_INFO_KHR; + importInfo.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; + importInfo.fd = dupFd; + + VkMemoryDedicatedAllocateInfo dedicated = {}; + dedicated.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO; + dedicated.image = image; + dedicated.pNext = &importInfo; + + VkMemoryAllocateInfo allocInfo = {}; + allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocInfo.pNext = &dedicated; + allocInfo.allocationSize = memReq.size; + allocInfo.memoryTypeIndex = memTypeIndex; + + // VK_KHR_external_memory_fd transfers fd ownership to the implementation only on a SUCCESSFUL import; on failure + // the application still owns the fd and must close it. Disarm the close guard only after VK_SUCCESS. + VkDeviceMemory memory = VK_NULL_HANDLE; + if (pfnAllocateMemory(dev, &allocInfo, nullptr, &memory) != VK_SUCCESS || memory == VK_NULL_HANDLE) { + QGC_HW_WARN_ONCE(GstDmaBufVulkanLog, s_loggedVulkanUnimpl, + "Vulkan import: vkAllocateMemory failed — CPU upload"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + fdConsumed = true; + const auto freeMemory = qScopeGuard([&] { + if (memory != VK_NULL_HANDLE) + pfnFreeMemory(dev, memory, nullptr); + }); + + if (pfnBindImageMemory(dev, image, memory, 0) != VK_SUCCESS) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + + auto frameTextures = + std::make_unique(&rhi, _format.frameSize(), _format.pixelFormat(), image); + if (!frameTextures->valid()) { + QGC_HW_WARN_ONCE(GstDmaBufVulkanLog, s_loggedVulkanUnimpl, + "Vulkan import: QRhiTexture::createFrom(VkImage) failed"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::DmaBuf); + } + // Ownership of image/memory passes to the bundle; disarm the scope guards. + frameTextures->adoptVulkanResources(dev, image, memory, pfnDestroyImage, pfnFreeMemory); + image = VK_NULL_HANDLE; + memory = VK_NULL_HANDLE; + frameTextures->setSourceSample(takeSample()); + static std::atomic s_loggedVulkanOk{false}; + if (!s_loggedVulkanOk.exchange(true, std::memory_order_relaxed)) { + qCInfo(GstDmaBufVulkanLog) << "First Vulkan DMABuf zero-copy import success: format=" + << int(_format.pixelFormat()); + } + return frameTextures; +} + +#endif // QGC_HAS_GST_DMABUF_GPU_PATH && QGC_HAS_GST_VULKAN_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaBufVulkanImport.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaBufVulkanImport.h new file mode 100644 index 000000000000..5d86a8a3b2be --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaBufVulkanImport.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) && defined(QGC_HAS_GST_VULKAN_GPU_PATH) + +namespace GstDmaBufVulkan { + +/// Reset the once-logged warning flags for the Vulkan DMABuf import (test/reinit hook). Render-thread or pre-init only. +void resetLoggedState() noexcept; + +} // namespace GstDmaBufVulkan + +#endif // QGC_HAS_GST_DMABUF_GPU_PATH && QGC_HAS_GST_VULKAN_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaDrmCaps.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaDrmCaps.cc new file mode 100644 index 000000000000..5b2491e13f01 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaDrmCaps.cc @@ -0,0 +1,207 @@ +#include "GstDmaDrmCaps.h" + +#if GST_CHECK_VERSION(1, 24, 0) +#include +#endif + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) && GST_CHECK_VERSION(1, 24, 0) +#include "GstDmaFourcc.h" +#include "GstEglHelpers.h" + +#include "QGCLoggingCategory.h" + +#include +#include +#include + +#include +#include + +#include +#include + +QGC_LOGGING_CATEGORY(GstDmaDrmCapsLog, "Video.GStreamer.HwBuffers.GstDmaDrmCaps") +#endif + +namespace GstHw { + +bool dmaDrmAwareVideoInfo(GstCaps* caps, GstVideoInfo* info) +{ + if (!caps || !info) { + return false; + } +#if GST_CHECK_VERSION(1, 24, 0) + if (gst_video_is_dma_drm_caps(caps)) { + GstVideoInfoDmaDrm drmInfo; + gst_video_info_dma_drm_init(&drmInfo); + return gst_video_info_dma_drm_from_caps(&drmInfo, caps) && gst_video_info_dma_drm_to_video_info(&drmInfo, info); + } +#endif + return gst_video_info_from_caps(info, caps); +} + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) && GST_CHECK_VERSION(1, 24, 0) + +namespace { + +constexpr const char* kModifiersExt = "EGL_EXT_image_dma_buf_import_modifiers"; + +// Renderable tokens from the kFormats brace list ("{ NV12, NV21, ... }"); intersected with EGL results below so we +// never advertise a format the Qt mapper would reject. +QSet parseFormatTokens(const char* gstFormatList) +{ + QSet tokens; + if (!gstFormatList) + return tokens; + for (QByteArray tok : QByteArray(gstFormatList).split(',')) { + tok.replace('{', "").replace('}', ""); + tok = tok.trimmed(); + if (!tok.isEmpty()) + tokens.insert(tok); + } + return tokens; +} + +// LINEAR-only: tiled external DMABuf from gst-va/iHD can be negotiated and even imported, but binding the resulting +// EGLImage through Qt's render-thread GL path segfaults Mesa/Gallium on this stack. The default glupload path can +// handle those tiled buffers; direct-QGC DMABuf only advertises layouts it can safely bind itself. +bool isImportableModifier(EGLuint64KHR mod) noexcept +{ + return mod == 0; // DRM_FORMAT_MOD_LINEAR +} + +} // namespace + +std::string buildSupportedDmaDrmCaps(const char* gstFormatList) +{ + const QSet allowed = parseFormatTokens(gstFormatList); + if (allowed.isEmpty()) + return std::string(); + + // Built on the GstVideo worker thread with no current GL context, so the share-context display is + // EGL_NO_DISPLAY; fall back to the default display (modifiers are GPU-global). Never eglTerminate'd. + EGLDisplay dpy = GstEglHelpers::resolveEglDisplay(QOpenGLContext::globalShareContext()); + if (dpy == EGL_NO_DISPLAY) + dpy = eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (dpy != EGL_NO_DISPLAY) + eglInitialize(dpy, nullptr, nullptr); + if (dpy == EGL_NO_DISPLAY || !GstEglHelpers::displaySupportsExtension(dpy, kModifiersExt)) { + qCDebug(GstDmaDrmCapsLog) << "DMA_DRM modifier query unavailable (no EGL display or " + "EGL_EXT_image_dma_buf_import_modifiers); offering no DMA_DRM caps"; + return std::string(); + } + + const auto queryFormats = + reinterpret_cast(eglGetProcAddress("eglQueryDmaBufFormatsEXT")); + const auto queryModifiers = + reinterpret_cast(eglGetProcAddress("eglQueryDmaBufModifiersEXT")); + if (!queryFormats || !queryModifiers) + return std::string(); + + EGLint numFormats = 0; + if (queryFormats(dpy, 0, nullptr, &numFormats) != EGL_TRUE || numFormats <= 0) + return std::string(); + std::vector formats(static_cast(numFormats)); + if (queryFormats(dpy, numFormats, formats.data(), &numFormats) != EGL_TRUE || numFormats <= 0) + return std::string(); + formats.resize(static_cast(numFormats)); + + std::string entries; + int formatCount = 0; + int modifierCount = 0; + int excludedCount = 0; + for (const EGLint fourcc : formats) { + const char* gstName = gstFormatNameForImportableFourcc(static_cast(fourcc)); + if (!gstName || !allowed.contains(QByteArray(gstName))) + continue; + + EGLint numMods = 0; + if (queryModifiers(dpy, fourcc, 0, nullptr, nullptr, &numMods) != EGL_TRUE || numMods <= 0) + continue; + std::vector mods(static_cast(numMods)); + if (queryModifiers(dpy, fourcc, numMods, mods.data(), nullptr, &numMods) != EGL_TRUE || numMods <= 0) + continue; + mods.resize(static_cast(numMods)); + + bool counted = false; + for (const EGLuint64KHR mod : mods) { + if (!isImportableModifier(mod)) { + ++excludedCount; + continue; + } + // drm-format wants the DRM fourcc 4CC string, not the GStreamer format name; to_string emits a bare + // fourcc for LINEAR and "FOURCC:0xMOD" otherwise ("FOURCC:0x0" fails to parse in gst 1.24). + gchar* drmStr = gst_video_dma_drm_fourcc_to_string(static_cast(fourcc), mod); + if (!drmStr) + continue; + if (!entries.empty()) + entries += ", "; + entries += drmStr; + g_free(drmStr); + ++modifierCount; + counted = true; + } + if (counted) + ++formatCount; + } + + if (entries.empty()) + return std::string(); + + qCInfo(GstDmaDrmCapsLog) << "EGL-derived DMA_DRM caps:" << formatCount << "formats," << modifierCount + << "modifiers offered," << excludedCount << "non-importable modifiers excluded"; + return std::string("video/x-raw(memory:DMABuf), format=DMA_DRM, drm-format={ ") + entries + " }; "; +} + +std::string buildLinearDmaDrmCaps(const char* gstFormatList) +{ + std::string entries; + for (const QByteArray& tok : parseFormatTokens(gstFormatList)) { + const GstVideoFormat fmt = gst_video_format_from_string(tok.constData()); + if (fmt == GST_VIDEO_FORMAT_UNKNOWN) + continue; + const guint32 fourcc = gst_video_dma_drm_fourcc_from_format(fmt); + if (fourcc == 0) // DRM_FORMAT_INVALID + continue; + gchar* drmStr = gst_video_dma_drm_fourcc_to_string(fourcc, 0); // 0 == DRM_FORMAT_MOD_LINEAR; bare fourcc + if (!drmStr) + continue; + if (!entries.empty()) + entries += ", "; + entries += drmStr; + g_free(drmStr); + } + if (entries.empty()) + return std::string(); + return std::string("video/x-raw(memory:DMABuf), format=DMA_DRM, drm-format={ ") + entries + " }; "; +} + +#ifdef QGC_GST_BUILD_TESTING +bool dmaDrmModifierAdvertisedForTest(guint64 modifier) noexcept +{ + return isImportableModifier(static_cast(modifier)); +} +#endif + +#else + +std::string buildSupportedDmaDrmCaps(const char*) +{ + return std::string(); +} + +std::string buildLinearDmaDrmCaps(const char*) +{ + return std::string(); +} + +#ifdef QGC_GST_BUILD_TESTING +bool dmaDrmModifierAdvertisedForTest(guint64) noexcept +{ + return false; +} +#endif + +#endif // QGC_HAS_GST_DMABUF_GPU_PATH && GST_CHECK_VERSION(1, 24, 0) + +} // namespace GstHw diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaDrmCaps.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaDrmCaps.h new file mode 100644 index 000000000000..1b65afa4aaef --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaDrmCaps.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +#include + +namespace GstHw { + +/// Parse @p caps into @p info, handling the GStreamer 1.24+ DMA_DRM format that plain gst_video_info_from_caps cannot +/// decode. False on parse failure or null args. +bool dmaDrmAwareVideoInfo(GstCaps* caps, GstVideoInfo* info); + +/// Best-effort DMA_DRM caps string built from the GPU's EGL-reported (format, modifier) pairs intersected with the +/// renderable @p gstFormatList. Returns a single ready-to-prepend "video/x-raw(memory:DMABuf), format=DMA_DRM, +/// drm-format={ FMT:0xMOD, ... }; " fragment, or "" on ANY failure (no display/ext/results/error). Additive only: +/// callers must keep the existing system catch-all. @p gstFormatList is the kFormats brace list (e.g. "{ NV12, ... }"). +std::string buildSupportedDmaDrmCaps(const char* gstFormatList); + +/// LINEAR-modifier DMA_DRM caps fragment for @p gstFormatList (the kFormats brace list), as a forced fallback when the +/// driver mis-reports modifiers. Maps each GStreamer format to its DRM fourcc (bare = LINEAR). "" if none map. +std::string buildLinearDmaDrmCaps(const char* gstFormatList); + +#ifdef QGC_GST_BUILD_TESTING +bool dmaDrmModifierAdvertisedForTest(guint64 modifier) noexcept; +#endif + +} // namespace GstHw diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaFourcc.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaFourcc.cc new file mode 100644 index 000000000000..0510c20b2a37 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaFourcc.cc @@ -0,0 +1,132 @@ +#include "GstDmaFourcc.h" + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + +#include + +namespace GstHw { + +int drmFourccForPlane(const GstVideoInfo& info, int plane) +{ + const GstVideoFormat fmt = GST_VIDEO_INFO_FORMAT(&info); +#if G_BYTE_ORDER == G_LITTLE_ENDIAN + constexpr int argbFourcc = DRM_FORMAT_ARGB8888; + constexpr int rgbaFourcc = DRM_FORMAT_ABGR8888; + constexpr int rgbFourcc = DRM_FORMAT_BGR888; + constexpr int rgFourcc = DRM_FORMAT_GR88; +#else + constexpr int argbFourcc = DRM_FORMAT_BGRA8888; + constexpr int rgbaFourcc = DRM_FORMAT_RGBA8888; + constexpr int rgbFourcc = DRM_FORMAT_RGB888; + constexpr int rgFourcc = DRM_FORMAT_RG88; +#endif + + switch (fmt) { + case GST_VIDEO_FORMAT_RGB16: + case GST_VIDEO_FORMAT_BGR16: + return DRM_FORMAT_RGB565; + case GST_VIDEO_FORMAT_RGB: + case GST_VIDEO_FORMAT_BGR: + return rgbFourcc; + // BGRx/BGRA map to argb_fourcc (LE: ARGB8888 vs ABGR8888), mirrors Qt's fourccFromVideoInfo(). + case GST_VIDEO_FORMAT_BGRA: + case GST_VIDEO_FORMAT_BGRx: + return argbFourcc; + case GST_VIDEO_FORMAT_RGBA: + case GST_VIDEO_FORMAT_RGBx: + case GST_VIDEO_FORMAT_ARGB: + case GST_VIDEO_FORMAT_xRGB: + case GST_VIDEO_FORMAT_ABGR: + case GST_VIDEO_FORMAT_xBGR: + case GST_VIDEO_FORMAT_AYUV: + return rgbaFourcc; +#if defined(DRM_FORMAT_BGRA1010102) + case GST_VIDEO_FORMAT_BGR10A2_LE: + return DRM_FORMAT_BGRA1010102; +#endif + case GST_VIDEO_FORMAT_GRAY8: + return DRM_FORMAT_R8; + case GST_VIDEO_FORMAT_YUY2: + case GST_VIDEO_FORMAT_UYVY: + case GST_VIDEO_FORMAT_GRAY16_LE: + case GST_VIDEO_FORMAT_GRAY16_BE: + return rgFourcc; + case GST_VIDEO_FORMAT_NV12: + case GST_VIDEO_FORMAT_NV21: + return plane == 0 ? DRM_FORMAT_R8 : rgFourcc; + case GST_VIDEO_FORMAT_I420: + case GST_VIDEO_FORMAT_YV12: + case GST_VIDEO_FORMAT_Y42B: + case GST_VIDEO_FORMAT_Y444: + return DRM_FORMAT_R8; + case GST_VIDEO_FORMAT_P010_10LE: + case GST_VIDEO_FORMAT_P010_10BE: + return plane == 0 ? DRM_FORMAT_R16 : DRM_FORMAT_RG1616; + default: + return -1; + } +} + +int drmFourccForSingleFd(const GstVideoInfo& info) +{ + switch (GST_VIDEO_INFO_FORMAT(&info)) { + case GST_VIDEO_FORMAT_NV12: + return DRM_FORMAT_NV12; + case GST_VIDEO_FORMAT_NV21: + return DRM_FORMAT_NV21; + case GST_VIDEO_FORMAT_P010_10LE: + return DRM_FORMAT_P010; + case GST_VIDEO_FORMAT_I420: + return DRM_FORMAT_YUV420; + case GST_VIDEO_FORMAT_YV12: + return DRM_FORMAT_YVU420; + // packed single-memory formats: planeCount==1, memCount==1 + case GST_VIDEO_FORMAT_YUY2: + return DRM_FORMAT_YUYV; + case GST_VIDEO_FORMAT_UYVY: + return DRM_FORMAT_UYVY; +#ifdef DRM_FORMAT_YVYU + case GST_VIDEO_FORMAT_YVYU: + return DRM_FORMAT_YVYU; +#endif +#ifdef DRM_FORMAT_VYUY + case GST_VIDEO_FORMAT_VYUY: + return DRM_FORMAT_VYUY; +#endif + // AYUV excluded: EGL importers don't support DRM_FORMAT_AYUV; per-plane RGBA path handles it. +#ifdef DRM_FORMAT_Y210 + case GST_VIDEO_FORMAT_Y210: + return DRM_FORMAT_Y210; +#endif +#ifdef DRM_FORMAT_Y410 + case GST_VIDEO_FORMAT_Y410: + return DRM_FORMAT_Y410; +#endif + default: + return -1; + } +} + +const char* gstFormatNameForImportableFourcc(uint32_t fourcc) noexcept +{ +#if G_BYTE_ORDER == G_LITTLE_ENDIAN + constexpr uint32_t bgraFourcc = DRM_FORMAT_ARGB8888; + constexpr uint32_t rgbaFourcc = DRM_FORMAT_ABGR8888; +#else + constexpr uint32_t bgraFourcc = DRM_FORMAT_BGRA8888; + constexpr uint32_t rgbaFourcc = DRM_FORMAT_RGBA8888; +#endif + // Restricted to single-image formats the EGLImage/Vulkan import path consumes; mirrors the env-LINEAR offer set. + if (fourcc == static_cast(DRM_FORMAT_NV12)) return "NV12"; + if (fourcc == static_cast(DRM_FORMAT_NV21)) return "NV21"; + if (fourcc == static_cast(DRM_FORMAT_YUV420)) return "I420"; + if (fourcc == static_cast(DRM_FORMAT_YVU420)) return "YV12"; + if (fourcc == static_cast(DRM_FORMAT_P010)) return "P010_10LE"; + if (fourcc == bgraFourcc) return "BGRA"; + if (fourcc == rgbaFourcc) return "RGBA"; + return nullptr; +} + +} // namespace GstHw + +#endif // QGC_HAS_GST_DMABUF_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaFourcc.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaFourcc.h new file mode 100644 index 000000000000..5000d89aafea --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstDmaFourcc.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + +#include + +#include + +namespace GstHw { + +/// DRM fourcc for plane @p plane of @p info (per-plane EGLImage import). Returns -1 for formats with no DRM mapping. +/// Modeled on Qt's fourccFromVideoInfo() (qgstvideobuffer.cpp, LGPL-3). +int drmFourccForPlane(const GstVideoInfo& info, int plane); + +/// DRM fourcc for formats importable as a single EGLImage/VkImage (all planes, one fd). Returns -1 otherwise. +int drmFourccForSingleFd(const GstVideoInfo& info); + +/// GStreamer format token (e.g. "NV12") for a DRM fourcc our import path can consume; nullptr otherwise. Reverse of +/// the fourccs drmFourccForSingleFd / drmFourccForPlane emit, restricted to renderable single-image formats so caps +/// advertising it stays consistent with what the EGLImage importer actually accepts. +const char* gstFormatNameForImportableFourcc(uint32_t fourcc) noexcept; + +} // namespace GstHw + +#endif // QGC_HAS_GST_DMABUF_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstEglHelpers.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstEglHelpers.cc new file mode 100644 index 000000000000..f50569fb0af9 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstEglHelpers.cc @@ -0,0 +1,74 @@ +#include "GstEglHelpers.h" + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) || defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace GstEglHelpers { + +EGLDisplay resolveEglDisplay(QOpenGLContext* qtCtx) noexcept +{ + if (qtCtx) { + if (auto* egl = qtCtx->nativeInterface()) { + const EGLDisplay d = egl->display(); + if (d != EGL_NO_DISPLAY) + return d; + } + } + return eglGetCurrentDisplay(); +} + +namespace { + +QMutex s_extMutex; +// Hash key = (display, extension name); names are static literals, copy cost paid once per miss. +QHash, bool> s_extCache; + +} // namespace + +bool displaySupportsExtension(EGLDisplay display, const char* extension) +{ + if (display == EGL_NO_DISPLAY || !extension) + return false; + const QByteArray extKey(extension); + // Lock held across read->query->write to prevent concurrent insertion of a conflicting result for the same key. + QMutexLocker lock(&s_extMutex); + auto it = s_extCache.constFind(std::make_pair(display, extKey)); + if (it != s_extCache.constEnd()) + return it.value(); + // Must not eglInitialize Qt's display (a stray eglTerminate drops Qt's state); uninitialized returns nullptr. + const char* exts = eglQueryString(display, EGL_EXTENSIONS); + // Token check required: strstr would falsely match EGL_EXT_image_dma_buf_import inside + // EGL_EXT_image_dma_buf_import_modifiers. + bool supported = false; + if (exts && extension) { + const std::size_t extLen = std::strlen(extension); + for (const char* p = exts; (p = std::strstr(p, extension)) != nullptr; p += extLen) { + const char before = p == exts ? ' ' : *(p - 1); + const char after = *(p + extLen); + if ((before == ' ' || before == '\0') && (after == ' ' || after == '\0')) { + supported = true; + break; + } + } + } + s_extCache.insert(std::make_pair(display, extKey), supported); + return supported; +} + +void resetExtensionCache() +{ + QMutexLocker lock(&s_extMutex); + s_extCache.clear(); +} + +} // namespace GstEglHelpers + +#endif // QGC_HAS_GST_DMABUF_GPU_PATH || QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstEglHelpers.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstEglHelpers.h new file mode 100644 index 000000000000..ef895c0e2b37 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/dmabuf/GstEglHelpers.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) || defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + +#include + +class QOpenGLContext; + +/// Shared EGL helpers for EGL-backed zero-copy paths (DMABuf, AHB): display resolution + extension cache. +namespace GstEglHelpers { + +/// EGLDisplay bound to @p qtCtx (else current-thread display, else EGL_NO_DISPLAY); does NOT initialize it. +EGLDisplay resolveEglDisplay(QOpenGLContext* qtCtx) noexcept; + +/// True iff @p extension is in @p display's EGL_EXTENSIONS; does NOT eglInitialize, cached per (display, extension), +/// thread-safe. +bool displaySupportsExtension(EGLDisplay display, const char* extension); + +/// Clears the per-display extension cache; call from sceneGraph/context teardown. +void resetExtensionCache(); + +} // namespace GstEglHelpers + +#endif // QGC_HAS_GST_DMABUF_GPU_PATH || QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlContextBridge.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlContextBridge.cc new file mode 100644 index 000000000000..5ed70d6c7b14 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlContextBridge.cc @@ -0,0 +1,303 @@ +#include "GstGlContextBridge.h" + +#include "GstBridgePrimeRetry.h" +#include "GstContextBridgeCommon.h" + +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + +#include +#include +#include +#include +#include +#include +#include + +#include "QGCLoggingCategory.h" +// Shared by Linux and Windows-ANGLE (ANGLE ships EGL/egl.h); on Windows it's a fallback behind D3D11/D3D12. +// GLX/Wayland/X11 wrapping stays Linux-only below. +#if defined(__linux__) || (defined(_WIN32) && __has_include()) +#include +#include +#define QGC_GST_BRIDGE_HAS_EGL 1 +#endif +#if defined(__linux__) +// Qt 6 on xcb uses GLX by default — fall back to GLX context wrapping when EGL isn't available. +#if __has_include() && __has_include() +#include +#include +#include +#define QGC_GST_BRIDGE_HAS_GLX 1 +#endif +// Wayland: downstream elements probe GST_IS_GL_DISPLAY_WAYLAND; wl_display must be tagged on GstGLDisplay or zero-copy +// paths are silently missed. +#if __has_include() +#include +#define QGC_GST_BRIDGE_HAS_WAYLAND 1 +#endif +#endif + +QGC_LOGGING_CATEGORY(GstGlBridgeLog, "Video.GStreamer.HwBuffers.GstGlBridge") + +namespace GstGlContextBridge { +namespace { + +QMutex s_mutex; +GstGLDisplay* s_display = nullptr; +GstGLContext* s_context = nullptr; +// Bounds globalShareContext retry spam when Qt GL is never initialized. +GstBridgePrimeRetry::PrimeRetryState s_retry; + +#if defined(QGC_GST_BRIDGE_HAS_EGL) +EGLDisplay qtEglDisplay(QOpenGLContext* qtCtx) +{ + // QEGLContext::display() is the only handle that guarantees EGLImage import/sample compatibility; fall back to + // EGL_DEFAULT_DISPLAY when unavailable. + if (qtCtx) { + if (auto* egl = qtCtx->nativeInterface()) { + EGLDisplay d = egl->display(); + if (d != EGL_NO_DISPLAY) + return d; + } + } + return eglGetDisplay(EGL_DEFAULT_DISPLAY); +} + +EGLContext qtEglContext(QOpenGLContext* qtCtx) +{ + if (!qtCtx) + return EGL_NO_CONTEXT; + if (auto* egl = qtCtx->nativeInterface()) { + return egl->nativeContext(); + } + return EGL_NO_CONTEXT; +} +#endif + +bool primeLocked() +{ + switch (GstBridgePrimeRetry::primeRetryGuard(s_retry)) { + case GstBridgePrimeRetry::Decision::AlreadyPrimed: + return true; + case GstBridgePrimeRetry::Decision::GiveUp: + return false; + case GstBridgePrimeRetry::Decision::ShouldRetry: + break; + } + + QOpenGLContext* qtCtx = QOpenGLContext::globalShareContext(); + if (!qtCtx) { + if (GstBridgePrimeRetry::rearmRetry(s_retry)) { + qCInfo(GstGlBridgeLog) << "globalShareContext() is null — Qt GL not initialized yet" + << "(attempt" << s_retry.nullCount << "/" << s_retry.maxRetries << ")"; + } else if (GstBridgePrimeRetry::justGaveUp(s_retry)) { + qCWarning(GstGlBridgeLog) << "globalShareContext() still null after" << s_retry.maxRetries + << "retries; GL bridge giving up"; + } + return false; + } + +#if defined(QGC_GST_BRIDGE_HAS_EGL) + // Try EGL first (Linux: Wayland/eglfs/xcb_egl; Windows: ANGLE-EGL); fall back to GLX (Linux xcb default on Qt 6). + EGLContext eglCtx = qtEglContext(qtCtx); + EGLDisplay eglDisp = (eglCtx != EGL_NO_CONTEXT) ? qtEglDisplay(qtCtx) : EGL_NO_DISPLAY; + // Reset s_primeAttempted on each bail so a window-recreate or reset() gets a retry. + auto bail = [](const char*) -> bool { + s_retry.primeAttempted = false; + return false; + }; + if (eglCtx != EGL_NO_CONTEXT && eglDisp != EGL_NO_DISPLAY) { +#if defined(QGC_GST_BRIDGE_HAS_WAYLAND) + // On Wayland, primary display must be GstGLDisplayWayland; derived EGL display is marked foreign so unref + // doesn't tear down Qt's EGLDisplay. + const QString platformName = QGuiApplication::platformName(); + if (platformName == QLatin1String("wayland") || platformName == QLatin1String("wayland-egl")) { + struct wl_display* wlDisp = nullptr; + if (auto* wl = qGuiApp->nativeInterface()) { + wlDisp = wl->display(); + } + if (wlDisp) { + GstGLDisplayWayland* displayWl = gst_gl_display_wayland_new_with_display(wlDisp); + if (displayWl) { + s_display = GST_GL_DISPLAY(displayWl); +#if GST_CHECK_VERSION(1, 26, 0) + // set_foreign(TRUE) is mandatory: Qt owns the EGLDisplay; without it gst calls eglTerminate on Qt's + // display. + if (GstGLDisplayEGL* derived = gst_gl_display_egl_from_gl_display(s_display)) { + gst_gl_display_egl_set_foreign(derived, TRUE); + gst_object_unref(derived); + } +#endif + } + } + } +#endif + if (!s_display) { + GstGLDisplayEGL* displayEgl = gst_gl_display_egl_new_with_egl_display(eglDisp); + if (!displayEgl) { + qCWarning(GstGlBridgeLog) << "gst_gl_display_egl_new_with_egl_display failed"; + return bail("displayEgl"); + } + s_display = GST_GL_DISPLAY(displayEgl); + } + + s_context = gst_gl_context_new_wrapped(s_display, reinterpret_cast(eglCtx), GST_GL_PLATFORM_EGL, + static_cast(GST_GL_API_GLES2 | GST_GL_API_OPENGL)); + if (!s_context) { + qCWarning(GstGlBridgeLog) << "gst_gl_context_new_wrapped (EGL) failed"; + gst_clear_object(&s_display); + return bail("ctxEgl"); + } +#if defined(QGC_GST_BRIDGE_HAS_WAYLAND) + const bool isWayland = GST_IS_GL_DISPLAY_WAYLAND(s_display); + qCInfo(GstGlBridgeLog) << (isWayland ? "GL bridge primed (Wayland+EGL)" : "GL bridge primed (EGL)"); +#else + qCInfo(GstGlBridgeLog) << "GL bridge primed (EGL)"; +#endif + } else { +#if defined(QGC_GST_BRIDGE_HAS_GLX) + // Qt's QGLXContext exposes the X11 Display* + GLXContext we need to wrap. + auto* glx = qtCtx->nativeInterface(); + if (!glx) { + qCWarning(GstGlBridgeLog) << "Qt GL context exposes neither EGL nor GLX; GL bridge disabled"; + return bail("noGlx"); + } + Display* xdisp = nullptr; + if (auto* x11 = qGuiApp->nativeInterface()) { + xdisp = x11->display(); + } + if (!xdisp) { + qCWarning(GstGlBridgeLog) << "X11 Display unresolvable; GL bridge disabled"; + return bail("xdisp"); + } + GstGLDisplayX11* displayX11 = gst_gl_display_x11_new_with_display(xdisp); + if (!displayX11) { + qCWarning(GstGlBridgeLog) << "gst_gl_display_x11_new_with_display failed"; + return bail("displayX11"); + } + s_display = GST_GL_DISPLAY(displayX11); + s_context = gst_gl_context_new_wrapped(s_display, reinterpret_cast(glx->nativeContext()), + GST_GL_PLATFORM_GLX, static_cast(GST_GL_API_OPENGL)); + if (!s_context) { + qCWarning(GstGlBridgeLog) << "gst_gl_context_new_wrapped (GLX) failed"; + gst_clear_object(&s_display); + return bail("ctxGlx"); + } + qCInfo(GstGlBridgeLog) << "GL bridge primed (GLX)"; +#else + qCWarning(GstGlBridgeLog) << "Qt EGLContext unresolvable and GLX bridge not built; GL bridge disabled"; + return bail("noEglNoGlx"); +#endif + } + + s_retry.primed = true; + // Don't call gst_gl_context_fill_info(): a freshly wrapped context has no active thread yet, and GStreamer fills + // info lazily on first activation. + qCDebug(GstGlBridgeLog) << "GL bridge primed: display=" << s_display << "context=" << s_context; + return true; +#else + // EGL-only by design; Windows uses the D3D11/D3D12 bridge, macOS/iOS use CVPixelBuffer. No WGL/CGL wrap: forcing + // QSG_RHI_BACKEND=opengl on Windows (rare; RHI defaults to D3D11) just falls back to the CPU path here. + qCInfo(GstGlBridgeLog) << "GL bridge inactive on this platform (non-EGL)"; + return false; +#endif +} + +} // namespace + +namespace { + +constexpr char kGlAppContextType[] = "gst.gl.app_context"; +const char* const kContextTypes[] = {GST_GL_DISPLAY_CONTEXT_TYPE, kGlAppContextType}; + +const QLoggingCategory& vtCat(void*) +{ + return GstGlBridgeLog(); +} + +QMutex& vtMutex(void*) +{ + return s_mutex; +} + +bool vtPrime(void*) +{ + return primeLocked(); +} + +GstObject* vtRefObject(void*, const char* contextType) +{ + if (g_strcmp0(contextType, GST_GL_DISPLAY_CONTEXT_TYPE) == 0) { + return s_display ? GST_OBJECT(gst_object_ref(s_display)) : nullptr; + } + return s_context ? GST_OBJECT(gst_object_ref(s_context)) : nullptr; +} + +GstContext* vtBuildContext(void*, const char* contextType, GstObject* object) +{ + if (g_strcmp0(contextType, GST_GL_DISPLAY_CONTEXT_TYPE) == 0) { + GstContext* ctx = gst_context_new(GST_GL_DISPLAY_CONTEXT_TYPE, TRUE); + gst_context_set_gl_display(ctx, GST_GL_DISPLAY(object)); + return ctx; + } + GstContext* ctx = gst_context_new(kGlAppContextType, TRUE); + GstStructure* s = gst_context_writable_structure(ctx); + gst_structure_set(s, "context", GST_TYPE_GL_CONTEXT, GST_GL_CONTEXT(object), NULL); + return ctx; +} + +const GstContextBridge::BridgeVTable s_vtable = { + "GL", kContextTypes, 2, &vtCat, &vtMutex, &vtPrime, &vtRefObject, &vtBuildContext, nullptr, +}; + +} // namespace + +bool prime() +{ + QMutexLocker lock(&s_mutex); + return primeLocked(); +} + +GstBusSyncReply handleSyncMessage(GstMessage* message) +{ + return GstContextBridge::handleSyncMessage(s_vtable, nullptr, message); +} + +bool answerContextQuery(GstQuery* query) +{ + return GstContextBridge::answerContextQuery(s_vtable, nullptr, query); +} + +void reset() +{ + QMutexLocker lock(&s_mutex); + gst_clear_object(&s_context); + gst_clear_object(&s_display); + GstBridgePrimeRetry::resetRetry(s_retry); + qCDebug(GstGlBridgeLog) << "GL bridge reset"; +} + +void rearm() +{ + QMutexLocker lock(&s_mutex); + if (GstBridgePrimeRetry::rearmAfterExhaustion(s_retry)) { + qCInfo(GstGlBridgeLog) << "GL bridge rearm: clearing exhausted retry latch"; + } +} + +namespace { +struct GlBridgeRegistrar +{ + GlBridgeRegistrar() + { + GstContextBridge::registerBridge(GstGlBridgeLog(), "GL", &GstGlContextBridge::handleSyncMessage, + &GstGlContextBridge::reset); + } +}; + +static GlBridgeRegistrar s_glBridgeRegistrar; +} // anonymous namespace + +} // namespace GstGlContextBridge + +#endif // QGC_HAS_GST_GLMEMORY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlContextBridge.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlContextBridge.h new file mode 100644 index 000000000000..913a6cdeb51b --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlContextBridge.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + +#include + +/// Process-wide shared GstGL display/context answering gst-gl NEED_CONTEXT so decoders allocate textures QRhi can +/// sample (zero-copy); without it gst-gl isolates. +namespace GstGlContextBridge { + +/// Idempotent; builds a shared display+context from QOpenGLContext::globalShareContext. True on success. +bool prime(); + +/// Inspect a NEED_CONTEXT and respond with the shared display/context; returns GST_BUS_DROP when consumed, else +/// GST_BUS_PASS. Thread-safe. +GstBusSyncReply handleSyncMessage(GstMessage* message); + +/// Answer a downstream GST_QUERY_CONTEXT (gst.gl.GLDisplay/app_context); true -> caller signals GST_PAD_PROBE_HANDLED. +bool answerContextQuery(GstQuery* query); + +/// Drop the cached display/context so the next prime() rebuilds; call from receiver teardown. +void reset(); + +/// Clear exhausted-retry latch so a later NEED_CONTEXT can prime; no-op if already primed. +void rearm(); + +} // namespace GstGlContextBridge + +#endif // QGC_HAS_GST_GLMEMORY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlFrameTextures.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlFrameTextures.h new file mode 100644 index 000000000000..7646715d7971 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlFrameTextures.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "GstHwFrameTexturesBase.h" +#include "GstHwVideoBuffer.h" + +/// Shared base for GL-texture-backed `QVideoFrameTextures` wrappers (GLMemory and DMABuf-via-EGLImage). +class GstGlFrameTextures : public GstHwFrameTexturesBase +{ +public: + using FallbackPolicy = QVideoTextureHelper::TextureDescription::FallbackPolicy; + + GstGlFrameTextures(QRhi* rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, + std::array names, int count, + FallbackPolicy fallback = FallbackPolicy::Enable) + : _rhi(rhi), _size(size), _pixelFormat(pixelFormat), _names(names) + { + _count = count; + const auto* desc = QVideoTextureHelper::textureDescription(pixelFormat); + if (!desc) + return; + for (int i = 0; i < _count; ++i) { + // GL_NONE (0) is silently accepted by createFrom but samples as black; gst-gl can hand us 0 if a plane + // wasn't uploaded yet. + if (_names[i] == 0) + continue; + const QSize planeSize = desc->rhiPlaneSize(size, i, rhi); + _textures[i].reset(rhi->newTexture(desc->rhiTextureFormat(i, rhi, fallback), planeSize, 1, {})); + if (_textures[i] && !_textures[i]->createFrom({_names[i], 0})) { + _textures[i].reset(); + } + } + } + +protected: + QRhi* _rhi = nullptr; + QSize _size; + QVideoFrameFormat::PixelFormat _pixelFormat = QVideoFrameFormat::Format_Invalid; + std::array _names{}; +}; diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlVideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlVideoBuffer.cc new file mode 100644 index 000000000000..1df423f5002e --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlVideoBuffer.cc @@ -0,0 +1,202 @@ +#include "GstGlVideoBuffer.h" + +#include "GstGlFrameTextures.h" +#include "GstHwImportCache.h" +#include "GstHwImportPreflight.h" +#include "GstHwPathTelemetry.h" +#include "GstHwVideoBuffer.h" + +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "QGCLoggingCategory.h" + +// Qt's portable GL types (GLuint et al.) — works on Linux/macOS/Android without GLES2 SDK headers. +#include +#include +#include + +QGC_LOGGING_CATEGORY(GstGlBufLog, "Video.GStreamer.HwBuffers.GstGlBuf") + +namespace { + +using GstHw::kMaxPlanes; + +class FrameTextures final : public GstGlFrameTextures +{ +public: + using GstGlFrameTextures::GstGlFrameTextures; + + HwVideoBufferPath sourcePath() const override { return HwVideoBufferPath::GlMemory; } + + // Reuse-eligible when ids match: gst-gl rotates ids within a fixed pool, so the QRhiTexture view transparently + // samples new data. + bool matches(QRhi* rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, + const std::array& names, int count) const + { + if (_rhi != rhi || _size != size || _pixelFormat != pixelFormat || _count != count) { + return false; + } + for (int i = 0; i < _count; ++i) { + if (_names[i] == 0 || _names[i] != names[i] || !_textures[i]) { + return false; + } + } + return true; + } +}; + +GstHw::MapDiagnostics s_diag; + +// Pool-ring recycle key: the gst-gl texture id tuple for a frame. gst-gl rotates a fixed set of ids, so re-seeing a +// tuple means a pool slot recycled (the immediate `old`-bundle reuse below only covers the last frame, not the ring). +struct GlTexKey +{ + std::array names{}; + int count = 0; + + bool operator==(const GlTexKey& o) const noexcept { return count == o.count && names == o.names; } +}; + +struct GlTexKeyHash +{ + std::size_t operator()(const GlTexKey& k) const noexcept + { + std::size_t h = std::hash{}(k.count); + for (int i = 0; i < k.count; ++i) { + h ^= std::hash{}(k.names[i]) + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); + } + return h; + } +}; + +// Render-thread-confined (mapTextures runs on the QRhi thread): no locking needed. Values are non-owning markers — the +// GL textures belong to gst-gl, so the deleter is a no-op; the cache only tracks ring membership for reuse telemetry. +constexpr std::size_t kGlRingCapacity = 8; +GstHw::GstHwImportCache s_glRingCache{kGlRingCapacity, [](const GlTexKey&, char&) {}}; + +} // namespace + +GstGlVideoBuffer::GstGlVideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, const QVideoFrameFormat& format) + : GstHwVideoBuffer(QVideoFrame::RhiTextureHandle, sample, videoInfo, format) +{} + +bool GstGlVideoBuffer::validatePlaneHandles() const +{ + // Allocator-only — GLuint check needs GST_MAP_GL, too expensive on streaming thread. + return validatePlanes([](GstMemory* mem) { return mem && gst_is_gl_memory(mem); }); +} + +QVideoFrameTexturesUPtr GstGlVideoBuffer::mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& old) +{ + const GstHwPathTelemetry::ScopedMapTimer mapTimer(HwVideoBufferPath::GlMemory); + // QRhi::OpenGLES2 covers both desktop GL and GLES — Qt collapses both; there is no separate QRhi::OpenGL. + GstBuffer* buffer = nullptr; + if (!checkMapPreconditions(rhi, static_cast(QRhi::OpenGLES2), GstGlBufLog(), s_diag, buffer)) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::GlMemory); + } + + // Defensive: custom RHI integrations may not have Qt's GL context current on QSGRenderThread entry. + rhi.makeThreadLocalNativeContextCurrent(); + + GstMemory* mem0 = gst_buffer_peek_memory(buffer, 0); + if (!mem0 || !gst_is_gl_memory(mem0)) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::GlMemory); + } + + GstVideoFrame frame = {}; + // GST_MAP_GL (a gst-gl extension flag) OR'd with GST_MAP_READ triggers GstGLMemory upload. + if (!gst_video_frame_map(&frame, &_videoInfo, buffer, static_cast(GST_MAP_READ | GST_MAP_GL))) { + qCWarning(GstGlBufLog) << "gst_video_frame_map(GST_MAP_READ | GST_MAP_GL) failed"; + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::GlMemory, + GstHwPathTelemetry::HwFallbackReason::MapFailed); + return GstHwPathTelemetry::fail(HwVideoBufferPath::GlMemory); + } + + // Mirror Qt's mapFromGlTexture: set_sync_point + GPU-side wait; synthesize meta when upstream omits one. + GstGLContext* glCtx = GST_GL_BASE_MEMORY_CAST(mem0)->context; + if (glCtx) { + GstGLSyncMeta* syncMeta = gst_buffer_get_gl_sync_meta(buffer); + GstBuffer* throwaway = nullptr; + if (!syncMeta) { + throwaway = gst_buffer_new(); + if (!throwaway) { + qCWarning(GstGlBufLog) << "gst_buffer_new() failed while creating GL sync meta holder"; + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::GlMemory, + GstHwPathTelemetry::HwFallbackReason::MapFailed); + gst_video_frame_unmap(&frame); + return GstHwPathTelemetry::fail(HwVideoBufferPath::GlMemory); + } + syncMeta = gst_buffer_add_gl_sync_meta(glCtx, throwaway); + } + if (syncMeta) { + gst_gl_sync_meta_set_sync_point(syncMeta, glCtx); + gst_gl_sync_meta_wait(syncMeta, glCtx); + GstHwPathTelemetry::recordSyncWait(HwVideoBufferPath::GlMemory, /*gpuSide=*/true); + } + if (throwaway) { + gst_buffer_unref(throwaway); + } + } + + const int planeCount = std::clamp(int(GST_VIDEO_FRAME_N_PLANES(&frame)), 1, kMaxPlanes); + std::array names{}; + for (int i = 0; i < planeCount; ++i) { + // GST_MAP_GL maps return a *pointer to* the GLuint texture id, not the id itself. + const GLuint* texIdPtr = static_cast(GST_VIDEO_FRAME_PLANE_DATA(&frame, i)); + names[i] = texIdPtr ? *texIdPtr : 0; + } + + gst_video_frame_unmap(&frame); + + if (auto* prev = GstHwFrameTexturesBase::reusableBundle(old, HwVideoBufferPath::GlMemory)) { + if (prev->matches(&rhi, _format.frameSize(), _format.pixelFormat(), names, planeCount)) { + GstHwPathTelemetry::recordTextureReuse(HwVideoBufferPath::GlMemory); + prev->setSourceSample(takeSample()); + QVideoFrameTexturesUPtr reused = std::move(old); + return reused; + } + } + + // Ring recycle: the `old` bundle missed (different frame) but this id tuple was validated within the last + // kGlRingCapacity frames, so the pool reused a slot. Record the reuse; the createFrom below is a cheap non-owning + // view over the existing gst-gl texture (no real re-import). + const GlTexKey ringKey{names, planeCount}; + const bool ringHit = (s_glRingCache.find(ringKey) != nullptr); + + // Pre-flight the per-plane RHI format/size before createFrom() so an unsupported import demotes to CPU on a cheap + // capability query instead of a driver error. + if (!GstHwImportPreflight::preflightOrRecord(&rhi, HwVideoBufferPath::GlMemory, _format.pixelFormat(), + _format.frameSize())) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::GlMemory); + } + + auto textures = + std::make_unique(&rhi, _format.frameSize(), _format.pixelFormat(), names, planeCount); + // For NV12/I420 chroma can fail while luma succeeds; must verify all planes, not just plane 0. + for (int i = 0; i < planeCount; ++i) { + if (!textures->texture(static_cast(i))) { + qCWarning(GstGlBufLog) << "createFrom failed for plane" << i << "format=" << _format.pixelFormat(); + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::GlMemory, + GstHwPathTelemetry::HwFallbackReason::MapFailed); + return GstHwPathTelemetry::fail(HwVideoBufferPath::GlMemory); + } + } + if (ringHit) { + GstHwPathTelemetry::recordTextureReuse(HwVideoBufferPath::GlMemory); + } + s_glRingCache.insert(ringKey, char{}); + textures->setSourceSample(takeSample()); + return textures; +} + +#endif // QGC_HAS_GST_GLMEMORY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlVideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlVideoBuffer.h new file mode 100644 index 000000000000..812af8465b77 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/gl/GstGlVideoBuffer.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + +#include "GstHwVideoBuffer.h" + +class QRhi; + +/// Zero-copy QVideoFrame backing for GstGLMemory samples; QRhi GL context must share with GstGLContext (see +/// GstGlContextBridge). +class GstGlVideoBuffer final : public GstHwVideoBuffer +{ +public: + /// @p sample is ref'd; the buffer keeps it alive until destruction. + GstGlVideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, const QVideoFrameFormat& format); + + QVideoFrameTexturesUPtr mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& oldTextures) override; + bool validatePlaneHandles() const override; + + const char* storageTag() const override { return "GstGL"; } +}; + +#endif // QGC_HAS_GST_GLMEMORY_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanContextBridge.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanContextBridge.cc new file mode 100644 index 000000000000..37d6fcb687d7 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanContextBridge.cc @@ -0,0 +1,257 @@ +#include "GstVulkanContextBridge.h" + +#include + +#include "GstBridgePrimeRetry.h" +#include "GstContextBridgeCommon.h" + +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) && QT_CONFIG(vulkan) + +#include +#include +#include +#include +#include +#include + +// gstvkdecoder.h's `slots` member collides with Qt's `slots` keyword macro — see GstVulkanVideoBuffer.cc. +#pragma push_macro("slots") +#undef slots +// gstvkqueue.h (gst-vulkan 1.24.x) omits G_BEGIN_DECLS, so gst_context_set_vulkan_queue would get C++ +// linkage and fail to link against the C symbol; the explicit extern "C" restores it (harmless for the +// sibling headers that already self-guard). +extern "C" { +#include +#include +#include +#include +#include +} +#pragma pop_macro("slots") + +#include "QGCLoggingCategory.h" +#include "QGCRhiCapture.h" + +QGC_LOGGING_CATEGORY(GstVulkanBridgeLog, "Video.GStreamer.HwBuffers.GstVulkanBridge") + +namespace GstVulkanContextBridge { +namespace { + +QMutex s_mutex; +GstVulkanInstance* s_instance = nullptr; +GstVulkanDevice* s_device = nullptr; +GstVulkanQueue* s_queue = nullptr; +GstBridgePrimeRetry::PrimeRetryState s_retry; + +// Resolve QRhi's Vulkan native handles; nullptr unless the active RHI is the Vulkan backend. Desktop Linux pins GL in +// Platform::initialize (Vulkan import is dormant), so on Linux this is the expected no-op path. +// We read nativeHandles() rather than QSGRendererInterface::getResource(): getResource exposes instance/physDev/ +// queue-family but has no VkDevice accessor (and no D3D adapter-LUID query), so nativeHandles() is the required source. +const QRhiVulkanNativeHandles* qrhiVulkanHandles() +{ + QRhi* rhi = QGCRhiCapture::cachedRhi(); + if (!rhi || rhi->backend() != QRhi::Vulkan) { + return nullptr; + } + return static_cast(rhi->nativeHandles()); +} + +// gst-vulkan 1.24 exposes no wrapped-instance/device constructor, so we cannot hand vulkanh26xdec QRhi's *own* +// VkInstance/VkDevice. We build a gst instance, then bind a GstVulkanDevice to the GstVulkanPhysicalDevice matching +// QRhi's physDev. That yields same-physical-device but a distinct VkDevice; the importer's device-match guard then +// routes those (foreign-VkDevice) frames to CPU. True same-VkDevice zero-copy needs a wrap API not present here. +GstVulkanPhysicalDevice* matchPhysicalDevice(GstVulkanInstance* instance, VkPhysicalDevice want) +{ + const guint n = instance->n_physical_devices; + for (guint i = 0; i < n; ++i) { + GstVulkanPhysicalDevice* phys = gst_vulkan_physical_device_new(instance, i); + if (!phys) { + continue; + } + if (phys->device == want) { + return phys; + } + gst_object_unref(phys); + } + return nullptr; +} + +bool primeLocked() +{ + switch (GstBridgePrimeRetry::primeRetryGuard(s_retry)) { + case GstBridgePrimeRetry::Decision::AlreadyPrimed: + return true; + case GstBridgePrimeRetry::Decision::GiveUp: + return false; + case GstBridgePrimeRetry::Decision::ShouldRetry: + break; + } + + const QRhiVulkanNativeHandles* nh = qrhiVulkanHandles(); + if (!nh || nh->physDev == VK_NULL_HANDLE) { + // Expected on the default GL RHI build; retry in case the Vulkan RHI initializes later. + if (!GstBridgePrimeRetry::rearmRetry(s_retry) && GstBridgePrimeRetry::justGaveUp(s_retry)) { + qCInfo(GstVulkanBridgeLog) << "active RHI is not Vulkan after" << s_retry.maxRetries + << "retries; Vulkan bridge inactive"; + } + return false; + } + + auto bail = [](const char* what) -> bool { + qCWarning(GstVulkanBridgeLog) << "Vulkan bridge prime failed:" << what; + gst_clear_object(&s_queue); + gst_clear_object(&s_device); + gst_clear_object(&s_instance); + s_retry.primeAttempted = false; + return false; + }; + + s_instance = gst_vulkan_instance_new(); + if (!s_instance || !gst_vulkan_instance_open(s_instance, nullptr)) { + return bail("gst_vulkan_instance_open"); + } + + GstVulkanPhysicalDevice* phys = matchPhysicalDevice(s_instance, nh->physDev); + if (!phys) { + return bail("no GstVulkanPhysicalDevice matches QRhi physDev"); + } + s_device = gst_vulkan_device_new(phys); + gst_object_unref(phys); + if (!s_device || !gst_vulkan_device_open(s_device, nullptr)) { + return bail("gst_vulkan_device_open"); + } + s_queue = gst_vulkan_device_get_queue(s_device, nh->gfxQueueFamilyIdx, nh->gfxQueueIdx); + if (!s_queue) { + return bail("gst_vulkan_device_get_queue"); + } + + s_retry.primed = true; + // Distinct-VkDevice limitation is by design until a wrap API lands; flag it once so the CPU fallback isn't mistaken + // for a bug. + qCInfo(GstVulkanBridgeLog) << "Vulkan bridge primed on QRhi physical device — note: gst VkDevice differs from " + "QRhi VkDevice, so import falls back to CPU until same-device wrapping is available"; + return true; +} + +GstVulkanInstance* refInstance() +{ + return s_instance ? GST_VULKAN_INSTANCE(gst_object_ref(s_instance)) : nullptr; +} + +GstVulkanDevice* refDevice() +{ + return s_device ? GST_VULKAN_DEVICE(gst_object_ref(s_device)) : nullptr; +} + +GstVulkanQueue* refQueue() +{ + return s_queue ? GST_VULKAN_QUEUE(gst_object_ref(s_queue)) : nullptr; +} + +} // namespace + +namespace { + +const char* const kContextTypes[] = { + GST_VULKAN_INSTANCE_CONTEXT_TYPE_STR, + GST_VULKAN_DEVICE_CONTEXT_TYPE_STR, + GST_VULKAN_QUEUE_CONTEXT_TYPE_STR, +}; + +const QLoggingCategory& vtCat(void*) +{ + return GstVulkanBridgeLog(); +} + +QMutex& vtMutex(void*) +{ + return s_mutex; +} + +bool vtPrime(void*) +{ + return primeLocked(); +} + +GstObject* vtRefObject(void*, const char* contextType) +{ + if (g_strcmp0(contextType, GST_VULKAN_INSTANCE_CONTEXT_TYPE_STR) == 0) { + return GST_OBJECT(refInstance()); + } + if (g_strcmp0(contextType, GST_VULKAN_DEVICE_CONTEXT_TYPE_STR) == 0) { + return GST_OBJECT(refDevice()); + } + return GST_OBJECT(refQueue()); +} + +GstContext* vtBuildContext(void*, const char* contextType, GstObject* object) +{ + if (g_strcmp0(contextType, GST_VULKAN_INSTANCE_CONTEXT_TYPE_STR) == 0) { + GstContext* ctx = gst_context_new(GST_VULKAN_INSTANCE_CONTEXT_TYPE_STR, TRUE); + gst_context_set_vulkan_instance(ctx, GST_VULKAN_INSTANCE(object)); + return ctx; + } + if (g_strcmp0(contextType, GST_VULKAN_DEVICE_CONTEXT_TYPE_STR) == 0) { + GstContext* ctx = gst_context_new(GST_VULKAN_DEVICE_CONTEXT_TYPE_STR, TRUE); + gst_context_set_vulkan_device(ctx, GST_VULKAN_DEVICE(object)); + return ctx; + } + GstContext* ctx = gst_context_new(GST_VULKAN_QUEUE_CONTEXT_TYPE_STR, TRUE); + gst_context_set_vulkan_queue(ctx, GST_VULKAN_QUEUE(object)); + return ctx; +} + +const GstContextBridge::BridgeVTable s_vtable = { + "Vulkan", kContextTypes, 3, &vtCat, &vtMutex, &vtPrime, &vtRefObject, &vtBuildContext, nullptr, +}; + +} // namespace + +bool prime() +{ + QMutexLocker lock(&s_mutex); + return primeLocked(); +} + +GstBusSyncReply handleSyncMessage(GstMessage* message) +{ + return GstContextBridge::handleSyncMessage(s_vtable, nullptr, message); +} + +bool answerContextQuery(GstQuery* query) +{ + return GstContextBridge::answerContextQuery(s_vtable, nullptr, query); +} + +void reset() +{ + QMutexLocker lock(&s_mutex); + gst_clear_object(&s_queue); + gst_clear_object(&s_device); + gst_clear_object(&s_instance); + GstBridgePrimeRetry::resetRetry(s_retry); + qCDebug(GstVulkanBridgeLog) << "Vulkan bridge reset"; +} + +void rearm() +{ + QMutexLocker lock(&s_mutex); + GstBridgePrimeRetry::rearmAfterExhaustion(s_retry); +} + +namespace { +struct VulkanBridgeRegistrar +{ + VulkanBridgeRegistrar() + { + GstContextBridge::registerBridge(GstVulkanBridgeLog(), "Vulkan", &GstVulkanContextBridge::handleSyncMessage, + &GstVulkanContextBridge::reset); + } +}; + +static VulkanBridgeRegistrar s_vulkanBridgeRegistrar; +} // namespace + +} // namespace GstVulkanContextBridge + +#endif // QGC_HAS_GST_VULKAN_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanContextBridge.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanContextBridge.h new file mode 100644 index 000000000000..71244a695f6f --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanContextBridge.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + +#include + +/// Answers gst.vulkan.instance/device/queue NEED_CONTEXT so vulkanh26{4,5}dec allocates its GstVulkanImageMemory on the +/// VkDevice that QRhi (Vulkan backend) renders with — the precondition for zero-copy import. No-ops when the active RHI +/// isn't Vulkan, so the importer's per-frame device-match guard is the hard safety net. +namespace GstVulkanContextBridge { + +/// Idempotent; builds a GstVulkanInstance/Device/Queue from QRhi's native Vulkan handles. True on success. +bool prime(); + +/// Inspect a NEED_CONTEXT and respond with the shared instance/device/queue; GST_BUS_DROP when consumed, else +/// GST_BUS_PASS. Thread-safe. +GstBusSyncReply handleSyncMessage(GstMessage* message); + +/// Answer a downstream GST_QUERY_CONTEXT for gst.vulkan.{instance,device,queue}; true -> GST_PAD_PROBE_HANDLED. +bool answerContextQuery(GstQuery* query); + +/// Drop the cached instance/device so the next prime() rebuilds; call from receiver teardown. +void reset(); + +/// Clear exhausted-retry latch so a later NEED_CONTEXT can prime; no-op if already primed. +void rearm(); + +} // namespace GstVulkanContextBridge + +#endif // QGC_HAS_GST_VULKAN_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanFrameTextures.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanFrameTextures.h new file mode 100644 index 000000000000..9d8181e259d0 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanFrameTextures.h @@ -0,0 +1,105 @@ +#pragma once + +#include + +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) && QT_CONFIG(vulkan) + +#include +#include +#include +#include +#include + +#include "GstHwFrameTexturesBase.h" + +/// Wraps a single multi-plane VkImage as a QRhiTexture (NV12/P010/RGBA). Owning and borrowing variants share this +/// wrap; only the image lifetime differs. `_textures[0]` is null when the wrap fails, surfaced via valid(). +class GstVulkanFrameTexturesBase : public GstHwFrameTexturesBase +{ +public: + bool valid() const noexcept { return _textures[0] != nullptr; } + +protected: + void wrapImage(QRhi* rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, VkImage image, int layout) + { + _count = 1; + const auto* desc = QVideoTextureHelper::textureDescription(pixelFormat); + if (!desc) { + return; + } + _textures[0].reset(rhi->newTexture( + desc->rhiTextureFormat(0, rhi, QVideoTextureHelper::TextureDescription::FallbackPolicy::Disable), size, 1, + {})); + if (_textures[0] && !_textures[0]->createFrom({reinterpret_cast(image), layout})) { + _textures[0].reset(); + } + } +}; + +/// Owns the imported VkImage + backing VkDeviceMemory (DMABuf zero-copy import); destroyed when Qt finishes the frame. +class GstVulkanOwnedFrameTextures final : public GstVulkanFrameTexturesBase +{ +public: + GstVulkanOwnedFrameTextures(QRhi* rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, VkImage image) + { + wrapImage(rhi, size, pixelFormat, image, VK_IMAGE_LAYOUT_UNDEFINED); + } + + ~GstVulkanOwnedFrameTextures() override { releaseVulkan(); } + + void onFrameEndInvoked() override + { + releaseVulkan(); + GstHwFrameTexturesBase::onFrameEndInvoked(); + } + + HwVideoBufferPath sourcePath() const override { return HwVideoBufferPath::DmaBuf; } + + /// Transfers VkImage/VkDeviceMemory ownership into the bundle for deferred destruction. + void adoptVulkanResources(VkDevice dev, VkImage image, VkDeviceMemory memory, PFN_vkDestroyImage destroyImage, + PFN_vkFreeMemory freeMemory) noexcept + { + _dev = dev; + _image = image; + _memory = memory; + _destroyImage = destroyImage; + _freeMemory = freeMemory; + } + +private: + void releaseVulkan() + { + // QRhiTexture must be gone before the VkImage it wraps; reset it first. + _textures[0].reset(); + if (_image != VK_NULL_HANDLE && _destroyImage) { + _destroyImage(_dev, _image, nullptr); + } + if (_memory != VK_NULL_HANDLE && _freeMemory) { + _freeMemory(_dev, _memory, nullptr); + } + _image = VK_NULL_HANDLE; + _memory = VK_NULL_HANDLE; + } + + VkDevice _dev = VK_NULL_HANDLE; + VkImage _image = VK_NULL_HANDLE; + VkDeviceMemory _memory = VK_NULL_HANDLE; + PFN_vkDestroyImage _destroyImage = nullptr; + PFN_vkFreeMemory _freeMemory = nullptr; +}; + +/// Borrows a VkImage owned by GstVulkanImageMemory and kept alive via the held GstSample (setSourceSample). Never +/// destroys the image: createFrom() does not take ownership. +class GstVulkanBorrowedFrameTextures final : public GstVulkanFrameTexturesBase +{ +public: + GstVulkanBorrowedFrameTextures(QRhi* rhi, QSize size, QVideoFrameFormat::PixelFormat pixelFormat, VkImage image, + int layout) + { + wrapImage(rhi, size, pixelFormat, image, layout); + } + + HwVideoBufferPath sourcePath() const override { return HwVideoBufferPath::Vulkan; } +}; + +#endif // QGC_HAS_GST_VULKAN_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanVideoBuffer.cc b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanVideoBuffer.cc new file mode 100644 index 000000000000..2911ef2c61de --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanVideoBuffer.cc @@ -0,0 +1,135 @@ +#include "GstVulkanVideoBuffer.h" + +#include + +#include "GstHwImportPreflight.h" +#include "GstHwPathTelemetry.h" +#include "GstHwVideoBuffer.h" +#include "GstVulkanFrameTextures.h" + +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) && QT_CONFIG(vulkan) + +#include +#include +#include +#include +#include +#include + +// gst-vulkan's gstvkdecoder.h declares a struct member named `slots`, which collides with Qt's `slots` keyword macro +// (qtmetamacros.h). Suppress the macro across the gst/vulkan includes only. +#pragma push_macro("slots") +#undef slots +#include +#include +#pragma pop_macro("slots") + +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(GstVulkanBufLog, "Video.GStreamer.HwBuffers.GstVulkanBuf") + +namespace { + +using GstHw::kMaxPlanes; + +GstHw::MapDiagnostics s_diag; +std::atomic s_loggedDeviceMismatch{false}; +std::atomic s_loggedMultiMemory{false}; +std::atomic s_loggedSyncFallback{false}; +std::atomic s_loggedFirstSuccess{false}; + +} // namespace + +GstVulkanVideoBuffer::GstVulkanVideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, + const QVideoFrameFormat& format) + : GstHwVideoBuffer(QVideoFrame::RhiTextureHandle, sample, videoInfo, format) +{} + +bool GstVulkanVideoBuffer::validatePlaneHandles() const +{ + return validatePlanes([](GstMemory* mem) { return mem && gst_is_vulkan_image_memory(mem); }); +} + +QVideoFrameTexturesUPtr GstVulkanVideoBuffer::mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& /*old*/) +{ + const GstHwPathTelemetry::ScopedMapTimer mapTimer(HwVideoBufferPath::Vulkan); + if (!rhi.thread()->isCurrentThread()) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::Vulkan); + } + + GstBuffer* buffer = nullptr; + if (!checkMapPreconditions(rhi, static_cast(QRhi::Vulkan), GstVulkanBufLog(), s_diag, buffer)) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::Vulkan); + } + + // Vulkan video decode yields one multiplanar VkImage (NV12/P010); disjoint multi-memory layouts are out of scope. + if (gst_buffer_n_memory(buffer) != 1) { + QGC_HW_WARN_ONCE(GstVulkanBufLog, s_loggedMultiMemory, + "Vulkan import: multi-memory buffer not supported — CPU fallback"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::Vulkan); + } + + GstMemory* mem0 = gst_buffer_peek_memory(buffer, 0); + if (!mem0 || !gst_is_vulkan_image_memory(mem0)) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::Vulkan); + } + auto* vkMem = reinterpret_cast(mem0); + + const auto* nh = static_cast(rhi.nativeHandles()); + if (!nh || nh->dev == VK_NULL_HANDLE) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::Vulkan); + } + + // Hard safety net: createFrom() requires the texture belong to QRhi's VkDevice. A VkImage from a different VkDevice + // is unusable (UB), so any mismatch routes to the CPU memcpy path. This is the expected outcome until gst-vulkan + // offers a same-VkDevice wrap (see GstVulkanContextBridge). + if (!vkMem->device || vkMem->device->device != nh->dev) { + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::Vulkan, + GstHwPathTelemetry::HwFallbackReason::NoExt); + QGC_HW_WARN_ONCE(GstVulkanBufLog, s_loggedDeviceMismatch, + "Vulkan import: GstVulkanImageMemory VkDevice != QRhi VkDevice — CPU fallback"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::Vulkan); + } + + // No decoder->QRhi sync wired: real zero-copy needs a shared timeline semaphore (GstVulkanOperation); the only + // correct alternative — a per-frame vkDeviceWaitIdle — would stall the whole UI. Handing QRhi an unsynchronized + // VkImage would tear, so demote to CPU until sync lands: flip kVulkanSyncImplemented and add the semaphore wait + // here. Unreachable on gst-vulkan 1.24.2 (the device-match guard above fails first); reachable once a same-VkDevice + // wrap exists. + static const bool kVulkanSyncImplemented = false; + if (!kVulkanSyncImplemented) { + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::Vulkan, + GstHwPathTelemetry::HwFallbackReason::VulkanNoSync); + QGC_HW_WARN_ONCE(GstVulkanBufLog, s_loggedSyncFallback, + "Vulkan import: decoder sync unimplemented (no timeline semaphore) — CPU fallback"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::Vulkan); + } + + const VkImage image = vkMem->image; + const int layout = static_cast(vkMem->barrier.image_layout); + if (image == VK_NULL_HANDLE) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::Vulkan); + } + + // Pre-flight RHI format/size support before createFrom() so an unsupported import demotes to CPU on a query. + if (!GstHwImportPreflight::preflightOrRecord(&rhi, HwVideoBufferPath::Vulkan, _format.pixelFormat(), + _format.frameSize())) { + return GstHwPathTelemetry::fail(HwVideoBufferPath::Vulkan); + } + + auto textures = std::make_unique(&rhi, _format.frameSize(), _format.pixelFormat(), + image, layout); + if (!textures->texture(0)) { + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::Vulkan, + GstHwPathTelemetry::HwFallbackReason::MapFailed); + QGC_HW_WARN_ONCE(GstVulkanBufLog, s_diag.loggedTextureCreateFail, + "Vulkan import: QRhiTexture::createFrom(VkImage) failed — CPU fallback"); + return GstHwPathTelemetry::fail(HwVideoBufferPath::Vulkan); + } + + logFirstSuccess(s_loggedFirstSuccess, GstVulkanBufLog(), "Vulkan", _format.frameSize(), _format.pixelFormat(), 1); + textures->setSourceSample(takeSample()); + return textures; +} + +#endif // QGC_HAS_GST_VULKAN_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanVideoBuffer.h b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanVideoBuffer.h new file mode 100644 index 000000000000..a9b0b19d477f --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/HwBuffers/vulkan/GstVulkanVideoBuffer.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#if defined(QGC_HAS_GST_VULKAN_GPU_PATH) + +#include +#include +#include + +#include "GstHwVideoBuffer.h" + +class QRhi; + +/// Zero-copy wrapper for vulkanh264dec/vulkanh265dec output: imports the decoder's GstVulkanImageMemory VkImage as a +/// non-owning QRhiTexture. The VkImage stays owned by GstVulkanImageMemory and is kept alive via the held GstSample; +/// only valid when QRhi's VkDevice matches the gst-vulkan device (else mapTextures fails -> CPU fallback). +class GstVulkanVideoBuffer final : public GstHwVideoBuffer +{ +public: + GstVulkanVideoBuffer(GstSample* sample, const GstVideoInfo& videoInfo, const QVideoFrameFormat& format); + + const char* storageTag() const override { return "Vulkan"; } + bool validatePlaneHandles() const override; + QVideoFrameTexturesUPtr mapTextures(QRhi& rhi, QVideoFrameTexturesUPtr& old) override; +}; + +#endif // QGC_HAS_GST_VULKAN_GPU_PATH diff --git a/src/VideoManager/VideoReceiver/GStreamer/QGCQVideoSinkController.cc b/src/VideoManager/VideoReceiver/GStreamer/QGCQVideoSinkController.cc new file mode 100644 index 000000000000..f68a95c5bd12 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/QGCQVideoSinkController.cc @@ -0,0 +1,209 @@ +#include "QGCQVideoSinkController.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "GstScoped.h" +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(QGCQVideoSinkControllerLog, "Video.GStreamer.QGCQVideoSinkController") + +QGCQVideoSinkController::QGCQVideoSinkController(GstElement* element, QObject* parent) + : QObject(parent), _element(element ? GST_ELEMENT(gst_object_ref(element)) : nullptr) +{ + _emitTimer.setInterval(1000); + _emitTimer.setSingleShot(false); + QObject::connect(&_emitTimer, &QTimer::timeout, this, &QGCQVideoSinkController::_onEmitTimer); + _emitTimer.start(); +} + +QGCQVideoSinkController::~QGCQVideoSinkController() +{ + _releaseElementBinding(); + if (_element) { + gst_object_unref(_element); + _element = nullptr; + } + _emitTimer.stop(); +} + +QList QGCQVideoSinkController::controllersOf(const QObject* receiver) +{ + return receiver ? receiver->findChildren(QString(), Qt::FindDirectChildrenOnly) + : QList{}; +} + +void QGCQVideoSinkController::syncActiveToWindowVisibility(QObject* receiver, QQuickVideoOutput* videoOutput) +{ + if (!receiver || !videoOutput) + return; + + auto applyVisibility = [receiver](QWindow* win) { + const QWindow::Visibility v = win ? win->visibility() : QWindow::Hidden; + const bool active = win && (v != QWindow::Hidden && v != QWindow::Minimized); + for (auto* c : controllersOf(receiver)) + c->setActive(active); + }; + // Track the previous connection so windowChanged drops it before wiring the new window, + // else an old hidden window keeps gating the live receiver. + auto prevConn = std::make_shared(); + auto wireWindow = [applyVisibility, prevConn, receiver](QQuickWindow* qw) { + if (*prevConn) { + QObject::disconnect(*prevConn); + *prevConn = QMetaObject::Connection{}; + } + if (!qw) { + applyVisibility(nullptr); + return; + } + applyVisibility(qw); + *prevConn = QObject::connect(qw, &QWindow::visibilityChanged, receiver, + [applyVisibility, qw](QWindow::Visibility) { applyVisibility(qw); }); + }; + wireWindow(videoOutput->window()); + QObject::connect(videoOutput, &QQuickVideoOutput::windowChanged, receiver, wireWindow); +} + +GstElement* QGCQVideoSinkController::element() const noexcept +{ + return _element; +} + +void QGCQVideoSinkController::updateNegotiation(const QString& format, const QSize& resolution) +{ + if (thread() != QThread::currentThread()) { + qCCritical(QGCQVideoSinkControllerLog) << "called from wrong thread"; + return; + } + if (_bindingReleased) + return; + bool changed = false; + { + QMutexLocker locker(&_stateMutex); + if (format != _negotiatedFormat || resolution != _negotiatedResolution) { + _negotiatedFormat = format; + _negotiatedResolution = resolution; + changed = true; + } + } + if (changed) { + qCDebug(QGCQVideoSinkControllerLog).noquote() + << "Negotiation update: format=" << format << "size=" << resolution; + emit negotiationChanged(); + } +} + +void QGCQVideoSinkController::refreshLatency() +{ + if (thread() != QThread::currentThread()) { + qCCritical(QGCQVideoSinkControllerLog) << "called from wrong thread"; + return; + } + if (!_element || _bindingReleased) + return; + // Pipeline-level latency was recalculated upstream (e.g. RTSP jitter-buffer reconfigure); + // re-query the sink so GstBaseSink re-primes its cached latency for the new depth. + const GStreamer::GstQueryPtr query = GStreamer::adoptQuery(gst_query_new_latency()); + if (!gst_element_query(_element, query.get())) { + qCDebug(QGCQVideoSinkControllerLog) << "Latency query not handled by sink element"; + } +} + +void QGCQVideoSinkController::setActive(bool active) +{ + if (thread() != QThread::currentThread()) { + qCCritical(QGCQVideoSinkControllerLog) << "called from wrong thread"; + return; + } + if (!_element || _bindingReleased) + return; + g_object_set(_element, "active", active ? TRUE : FALSE, nullptr); +} + +void QGCQVideoSinkController::setVideoSink(QPointer sink) +{ + if (thread() != QThread::currentThread()) { + qCCritical(QGCQVideoSinkControllerLog) << "called from wrong thread"; + return; + } + if (!_element) + return; + if (_sinkDestroyedConnection) { + QObject::disconnect(_sinkDestroyedConnection); + _sinkDestroyedConnection = {}; + } + QVideoSink* raw = sink.data(); + if (!raw) { + // Caller's QVideoSink was destroyed between the call site and here — clear the + // element's snapshot and gate show_frame until a replacement sink is installed. + g_object_set(_element, "active", FALSE, "qvideosink", static_cast(nullptr), nullptr); + _bindingReleased = true; + return; + } + g_object_set(_element, "qvideosink", static_cast(raw), nullptr); + _sinkDestroyedConnection = QObject::connect(raw, &QObject::destroyed, this, [this]() { + setVideoSink(QPointer()); + }); + _bindingReleased = false; +} + +void QGCQVideoSinkController::prepareForRelease() +{ + if (thread() != QThread::currentThread()) { + qCCritical(QGCQVideoSinkControllerLog) << "called from wrong thread"; + return; + } + _releaseElementBinding(); + _emitTimer.stop(); +} + +quint64 QGCQVideoSinkController::frameCount() const noexcept +{ + if (!_element || _bindingReleased) + return 0; + guint64 delivered = 0; + g_object_get(_element, "frames-delivered", &delivered, nullptr); + return static_cast(delivered); +} + +QString QGCQVideoSinkController::negotiatedFormat() const +{ + QMutexLocker locker(&_stateMutex); + return _negotiatedFormat; +} + +QSize QGCQVideoSinkController::negotiatedResolution() const +{ + QMutexLocker locker(&_stateMutex); + return _negotiatedResolution; +} + +void QGCQVideoSinkController::_releaseElementBinding() noexcept +{ + if (!_element || _bindingReleased) + return; + + if (_sinkDestroyedConnection) { + QObject::disconnect(_sinkDestroyedConnection); + _sinkDestroyedConnection = {}; + } + g_object_set(_element, "active", FALSE, "qvideosink", static_cast(nullptr), nullptr); + _bindingReleased = true; +} + +void QGCQVideoSinkController::_onEmitTimer() +{ + if (!_element || _bindingReleased) + return; + guint64 delivered = 0; + g_object_get(_element, "frames-delivered", &delivered, nullptr); + if (delivered != _lastEmittedFrameTotal) { + _lastEmittedFrameTotal = delivered; + emit frameCountsChanged(); + } +} diff --git a/src/VideoManager/VideoReceiver/GStreamer/QGCQVideoSinkController.h b/src/VideoManager/VideoReceiver/GStreamer/QGCQVideoSinkController.h new file mode 100644 index 000000000000..1e01b719e0a8 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/QGCQVideoSinkController.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QQuickVideoOutput; +class QVideoSink; + +/// GUI-thread companion for the GstQgcQVideoSink element: mirrors negotiation/telemetry into +/// Q_PROPERTYs for QML and owns the 1 Hz timer polling `frames-delivered`. Driven by +/// GstVideoReceiver's bus pump. Ownership: parent owns the controller; the controller owns one +/// GstElement ref so deferred QObject teardown can still clear the element binding. +class QGCQVideoSinkController : public QObject +{ + Q_OBJECT + + Q_PROPERTY(quint64 frameCount READ frameCount NOTIFY frameCountsChanged) + Q_PROPERTY(QString negotiatedFormat READ negotiatedFormat NOTIFY negotiationChanged) + Q_PROPERTY(QSize negotiatedResolution READ negotiatedResolution NOTIFY negotiationChanged) + +public: + /// @p element is the GstQgcQVideoSink to control. Controller takes a ref so QObject teardown + /// can safely clear the element binding even if the parent bin is released first. + QGCQVideoSinkController(GstElement* element, QObject* parent = nullptr); + ~QGCQVideoSinkController() override; + + /// A receiver's owning controllers — direct children only, never a deep QObject-tree walk. + static QList controllersOf(const QObject* receiver); + + /// Sync every controller owned by @p receiver to @p videoOutput's window visibility (drop frames + /// while hidden/minimized), re-wiring across windowChanged. Wiring is parented to @p receiver. + static void syncActiveToWindowVisibility(QObject* receiver, QQuickVideoOutput* videoOutput); + + // setActive(false) drops frames at the element; setVideoSink swaps the destination under + // GST_OBJECT_LOCK. QPointer so a caller-thread destruction race is caught here, not in GObject. + void setActive(bool active); + void setVideoSink(QPointer sink); + + /// Stop the poll timer synchronously ahead of deleteLater so a deferred destruction can't keep + /// binding the element while a replacement controller is installed on it. Idempotent. + void prepareForRelease(); + + GstElement* element() const noexcept; + void updateNegotiation(const QString& format, const QSize& resolution); + + quint64 frameCount() const noexcept; + QString negotiatedFormat() const; + QSize negotiatedResolution() const; + +public slots: + /// Re-prime sink-side latency after a pipeline latency recalculation (e.g. RTSP + /// jitter-buffer reconfigure). Re-queries the element latency and pushes it back. + void refreshLatency(); + +signals: + void frameCountsChanged(); + void negotiationChanged(); + +private: + void _releaseElementBinding() noexcept; + void _onEmitTimer(); + + GstElement* _element = nullptr; // owned ref + QMetaObject::Connection _sinkDestroyedConnection; + bool _bindingReleased = false; + QTimer _emitTimer; + + mutable QMutex _stateMutex; + QString _negotiatedFormat; + QSize _negotiatedResolution; + quint64 _lastEmittedFrameTotal = 0; +}; diff --git a/src/VideoManager/VideoReceiver/GStreamer/README.md b/src/VideoManager/VideoReceiver/GStreamer/README.md index f90eefa38e29..a1ce61372536 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/README.md +++ b/src/VideoManager/VideoReceiver/GStreamer/README.md @@ -2,13 +2,43 @@ QGroundControl uses GStreamer for UDP RTP and RTSP video streaming in the Main Flight Display. +## Source Code Architecture + +The pipeline is split into focused components (all in this directory): + +| Component | Role | +|-----------|------| +| `GstVideoReceiver` | Pipeline owner: builds/starts/stops the receiver pipeline, recording, watchdog, and `.dot` dumps. Exposes decoder/telemetry as `Q_PROPERTY`s to QML. | +| `GstSourceFactory` | Constructs the source element from the stream URI and applies RTP jitter-buffer policy for `application/x-rtp` sources. | +| `GStreamerEnvironment` | Process-wide environment setup (plugin/scanner/helper/GIO paths, env hygiene) run before `gst_init()`. See [Runtime Environment Setup](#runtime-environment-setup). | +| `GStreamerHelpers` | Small utilities (element-rank overrides, etc.). | +| `GStreamerLogging` | Routes GStreamer's log output through QGC's logging and applies the persisted debug level. | +| `GstScoped` | RAII owners for transfer-full GStreamer returns so refs can't leak on early return. | + +### `gstqgc` — custom GStreamer plugin + +A QGC-owned plugin (`gstqgc/`) bridging the pipeline into Qt: + +- **`qgcqvideosink`** — a `GstVideoSink` that pushes decoded frames into a Qt `QVideoSink`. +- **`qgcvideosinkbin`** — a bin wrapping a format-restriction capsfilter plus `qgcqvideosink`. +- **`QGCQVideoSinkController`** — GUI-thread companion that mirrors negotiation/telemetry from the sink into QGC. + +### `HwBuffers` — zero-copy GPU paths + +Per-platform zero-copy import of decoded GPU frames, all sharing `GstHwFrameTexturesBase : QVideoFrameTextures`: + +- **Platforms**: `dmabuf` (Linux/VA-API), `gl`, `vulkan`, `d3d` (D3D11/D3D12), `cuda`, `apple` (IOSurface), `android` (AHardwareBuffer). +- **`GstContextBridgeRegistry`** fans `GstBus` sync messages out to every compiled context bridge for cross-thread GPU-context handoff. +- **`GstHwPathTelemetry`** tracks per-format/per-path counters (map failures, reuse hits, sync waits) for fallback diagnostics. +- **`CpuVideoFramePool`** recycles CPU-backed `QVideoFrame` storage (WebRTC-style slab) for the software-fallback path, avoiding per-frame allocation. + ## Build Configuration - **Enable/disable**: Set `QGC_ENABLE_GST_VIDEOSTREAMING` CMake option to `ON`/`OFF` - **Version & URLs**: Defined in [`.github/build-config.json`](../../../../.github/build-config.json), parsed by [`cmake/BuildConfig.cmake`](../../../../cmake/BuildConfig.cmake) -- **SDK discovery & auto-download**: [`cmake/find-modules/FindQGCGStreamer.cmake`](../../../../cmake/find-modules/FindQGCGStreamer.cmake) -- **Plugin allowlist**: `GSTREAMER_PLUGINS` in `FindQGCGStreamer.cmake` — controls both static linking (mobile) and dynamic install (desktop) -- **Install helpers**: [`cmake/find-modules/GStreamerHelpers.cmake`](../../../../cmake/find-modules/GStreamerHelpers.cmake) +- **SDK discovery & auto-download**: [`cmake/GStreamer/Orchestrator.cmake`](../../../../cmake/GStreamer/Orchestrator.cmake) +- **Plugin allowlist**: `gstreamer.plugins` in [`.github/build-config.json`](../../../../.github/build-config.json) — controls both static linking (mobile) and dynamic install (desktop) +- **Install helpers**: [`cmake/GStreamer/Helpers.cmake`](../../../../cmake/GStreamer/Helpers.cmake) (aggregator), with focused submodules in [`cmake/GStreamer/`](../../../../cmake/GStreamer/) ## Runtime Environment Setup @@ -29,6 +59,19 @@ Use `python3 tools/setup/install_dependencies`, or manually: sudo apt-get install libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgstreamer-plugins-bad1.0-dev python3-gi python3-gst-1.0 ``` +#### DMABuf zero-copy diagnostics + +Linux DMABuf zero-copy normally tries the single-EGLImage importer for shared-fd +multi-plane formats such as NV12/P010, matching the common VA-API exporter +layout. To force CPU fallback or per-plane import during driver triage, disable +that importer before launching QGC: + +``` +export QGC_GST_DMABUF_SINGLE_EGLIMAGE=0 +``` + +Unset the variable, or set it to `1`, to restore the default behavior. + ### macOS / Windows / iOS GStreamer SDKs are auto-downloaded during CMake configure. To use a local installation instead, set `GStreamer_ROOT_DIR`. @@ -39,6 +82,16 @@ Auto-downloaded during CMake configure. No manual setup required. > **Windows users building for Android**: Enable Developer Mode (Settings > System > For developers) to support symbolic links during the build. +### Caching downloaded SDKs + +Auto-downloaded SDK archives are cached to `${CPM_SOURCE_CACHE}/gstreamer-*` when `CPM_SOURCE_CACHE` is set, otherwise to `${CMAKE_BINARY_DIR}/_deps/gstreamer-*` (lost on `rm -rf build`). Set `CPM_SOURCE_CACHE` to a stable location (e.g. `~/.cache/CPM`) to avoid re-downloading the 200-700 MB archives on every clean build: + +``` +export CPM_SOURCE_CACHE=$HOME/.cache/CPM +``` + +Cached archives are checksum-verified against the upstream `.sha256` on every hit. + ## Testing Pipelines ### Sending test video (h.264 over UDP) @@ -62,3 +115,47 @@ UDP RTP, RTSP, TCP-MPEG2, MPEG-TS. - In-app: Use QGroundControl's logging settings - Environment variables: See - Command line options: `--gst-debug-level`, `--gst-debug`, `--gst-debug-help`, etc. + +## Pipeline Observability + +QGC supports the standard GStreamer observability env vars. Set them before launching QGC. + +### Pipeline graph dumps (`.dot` files) + +When `GST_DEBUG_DUMP_DOT_DIR` is set, QGC writes the receiver pipeline graph at key transitions (`pipeline-initial`, `pipeline-started`, `pipeline-with-videosink`, `pipeline-error`, `pipeline-watchdog-timeout`, `pipeline-recording-stopped`, …): + +``` +export GST_DEBUG_DUMP_DOT_DIR=/tmp/qgc-pipeline-dots +./QGroundControl +dot -Tpng /tmp/qgc-pipeline-dots/0.00.00.*-pipeline-started.dot -o pipeline.png +``` + +When the env var is **unset**, QGC still writes a rotating snapshot (≤10 files) to `/qgc-pipeline-dot/-.dot` on `ERROR` and on watchdog timeout, so field-bug-report bundles include the topology automatically. The `GstVideoReceiver::dumpPipelineGraph(tag)` slot (callable from QML) writes a snapshot on demand for use from a debug menu. + +### Latency tracer + +Per-element latency from source to sink: + +``` +GST_TRACERS="latency(flags=pipeline+element)" GST_DEBUG=GST_TRACER:7 ./QGroundControl 2> latency.log +``` + +Plot the resulting `element-latency` / `latency` log lines with any of the GStreamer community tools (e.g. `gst-stats`, GStreamerLatencyPlotter). + +### Live pipeline visualizer (`gst-dots-viewer`, GStreamer ≥ 1.26) + +``` +GST_TRACERS=dots ./QGroundControl & +gst-dots-viewer +# open http://localhost:3000 +``` + +The viewer streams the live pipeline graph over WebSocket and exposes a snapshot button — no `xdot` viewer needed. + +### Leak / lifetime debugging + +``` +GST_TRACERS=leaks GST_DEBUG=GST_TRACER:7 ./QGroundControl +``` + +Logs every `GstObject` / `GstMiniObject` that wasn't released by exit. Useful when triaging pad-leak regressions in the source factory or sink bin. diff --git a/src/VideoManager/VideoReceiver/GStreamer/gst_static_plugins.c.in b/src/VideoManager/VideoReceiver/GStreamer/gst_static_plugins.c.in new file mode 100644 index 000000000000..f187aaf5b44b --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/gst_static_plugins.c.in @@ -0,0 +1,79 @@ +/* + * QGC GStreamer static-plugin registration shim. Auto-generated. + * + * Provides gst_init_static_plugins() — invoked by GStreamer::_registerPlugins() + * on platforms where GStreamer ships as a static archive: + * • Android: plugin .a's whole-archived into the app (_qgc_create_android_mobile_target) + * • iOS: GStreamer.xcframework (cmake/GStreamer/platform/IOS.cmake) + * + * Each platform fills the PLUGINS_DECLARATION / PLUGINS_REGISTRATION / G_IO_MODULES_* + * substitution slots (slot names kept @-free here so configure_file(@ONLY) doesn't expand + * the plugin blocks into this comment). GIO+TLS block ports upstream gstreamer_android-1.0.c.in. + */ + +#include +#include + +#define GST_G_IO_MODULE_DECLARE(name) \ +extern void G_PASTE(g_io_, G_PASTE(name, _load)) (gpointer data) + +/* Skip the load when the backend GType is already present: the Android SDK gst_init() loads + * gioopenssl, and re-registering its GType warns. Type name is openssl-specific (QGC's only module). */ +#define GST_G_IO_MODULE_LOAD(name) \ + do { \ + if (g_type_from_name ("GTlsBackendOpenssl") == 0) { \ + G_PASTE(g_io_, G_PASTE(name, _load)) (NULL); \ + } \ + } while (0) + +/* Registry-guard each register: the Android SDK gst_init() pre-registers its bundled static + * plugins, so an unconditional re-register aborts ("cannot register existing type GstBaseQTMux"). + * Lookup keys on the plugin short name, which matches the GST_PLUGIN_STATIC_REGISTER arg. */ +#define QGC_REGISTER_STATIC_PLUGIN(name) \ + do { \ + GstPlugin *_qgc_p = gst_registry_find_plugin (gst_registry_get (), G_STRINGIFY (name)); \ + if (_qgc_p == NULL) { \ + GST_PLUGIN_STATIC_REGISTER (name); \ + } else { \ + gst_object_unref (_qgc_p); \ + } \ + } while (0) + +@PLUGINS_DECLARATION@ + +@G_IO_MODULES_DECLARE@ + +/* Load static GIO modules and install the bundled CA bundle as the default TLS database. + * Port of upstream gstreamer_android-1.0.c:gst_android_load_gio_modules(); no-op when the slot is empty. */ +static void +qgc_load_gio_modules_and_ca (void) +{ + const gchar *ca_certs; + GTlsBackend *backend; + +@G_IO_MODULES_LOAD@ + + ca_certs = g_getenv ("CA_CERTIFICATES"); + backend = g_tls_backend_get_default (); + if (backend && ca_certs && *ca_certs) { + GTlsDatabase *db; + GError *error = NULL; + + db = g_tls_file_database_new (ca_certs, &error); + if (db) { + g_tls_backend_set_default_database (backend, db); + g_object_unref (db); + } else { + g_warning ("QGC: failed to load CA bundle '%s': %s", + ca_certs, error ? error->message : "unknown error"); + g_clear_error (&error); + } + } +} + +void +gst_init_static_plugins (void) +{ +@PLUGINS_REGISTRATION@ + qgc_load_gio_modules_and_ca (); +} diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/CMakeLists.txt b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/CMakeLists.txt index dd02ee7bf51e..bbbff2df60d5 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/CMakeLists.txt +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/CMakeLists.txt @@ -1,23 +1,20 @@ -# Compiled as object files into the main exe; gst_plugin_register_static() replaces gst-plugin-scanner -# discovery, so qgcvideosinkbin is available only after gst_init() walks the static plugin list. - -if(GStreamer_USE_STATIC_LIBS) - foreach(_plugin IN LISTS GSTREAMER_PLUGINS) - if(GST_PLUGIN_${_plugin}_FOUND) - target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE GST_PLUGIN_${_plugin}_FOUND) - endif() - endforeach() -endif() - -target_sources(${CMAKE_PROJECT_NAME} +# gstqgc: the QGC GStreamer plugin (qgcvideosinkbin + qvideosink + zero-copy frame mapping). +# Sources compile into QGCGStreamer; GStreamer.cc strong-refs gst_plugin_qgc_register via +# GST_PLUGIN_STATIC_DECLARE/REGISTER(qgc), pulling the member from the same archive. +target_sources(QGCGStreamer PRIVATE gstqgc.cc gstqgcelement.cc gstqgcelements.h gstqgcvideosinkbin.cc gstqgcvideosinkbin.h + gstqgcqvideosink.cc + gstqgcqvideosink.h + GStreamerFrameMap.cc + GStreamerFrameMap.h + GstQgcAllocation.cc + GstQgcAllocation.h + GstQgcCaps.cc + GstQgcCaps.h + GstQgcVideoFormats.h ) - -target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) - -target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE QGC_GST_STREAMING) diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GStreamerFrameMap.cc b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GStreamerFrameMap.cc new file mode 100644 index 000000000000..d66fae79813b --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GStreamerFrameMap.cc @@ -0,0 +1,250 @@ +#include "GStreamerFrameMap.h" + +#include +#include +#include +#include +#include +#include + +#include "GstQgcVideoFormats.h" +#include "HwBuffers/common/CpuVideoFramePool.h" +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(GStreamerFrameMapLog, "Video.GStreamer.FrameMap") + +QVideoFrameFormat::ColorSpace toQtColorSpace(GstVideoColorMatrix matrix) +{ + switch (matrix) { + case GST_VIDEO_COLOR_MATRIX_BT601: + return QVideoFrameFormat::ColorSpace_BT601; + case GST_VIDEO_COLOR_MATRIX_BT709: + return QVideoFrameFormat::ColorSpace_BT709; + case GST_VIDEO_COLOR_MATRIX_BT2020: + return QVideoFrameFormat::ColorSpace_BT2020; + case GST_VIDEO_COLOR_MATRIX_SMPTE240M: + // Matches Qt qgst.cpp convention (shared D65 white point), not full colorimetric equivalence. + return QVideoFrameFormat::ColorSpace_AdobeRgb; + // Qt groups FCC with UNKNOWN/RGB and leaves it Undefined (qgst.cpp); the + // resolution heuristic below then picks BT601/BT709. + default: + return QVideoFrameFormat::ColorSpace_Undefined; + } +} + +QVideoFrameFormat::ColorTransfer toQtColorTransfer(GstVideoTransferFunction transfer) +{ + // Mirrors Qt's qgst.cpp QGstCaps::formatAndVideoInfo() (cross-checked Qt 6.10.3). + switch (transfer) { + case GST_VIDEO_TRANSFER_BT601: + return QVideoFrameFormat::ColorTransfer_BT601; + case GST_VIDEO_TRANSFER_BT2020_10: + case GST_VIDEO_TRANSFER_BT2020_12: + case GST_VIDEO_TRANSFER_BT709: + return QVideoFrameFormat::ColorTransfer_BT709; + case GST_VIDEO_TRANSFER_GAMMA20: + return QVideoFrameFormat::ColorTransfer_BT709; // best fit per Qt + // SMPTE 240M uses a ~2.2 power curve, not BT.709 piecewise-linear; Qt groups it with Gamma22. + case GST_VIDEO_TRANSFER_SMPTE240M: + case GST_VIDEO_TRANSFER_GAMMA22: + case GST_VIDEO_TRANSFER_SRGB: + case GST_VIDEO_TRANSFER_ADOBERGB: + return QVideoFrameFormat::ColorTransfer_Gamma22; + case GST_VIDEO_TRANSFER_GAMMA18: + return QVideoFrameFormat::ColorTransfer_BT709; // matches Qt qgst.cpp GAMMA18 mapping + case GST_VIDEO_TRANSFER_GAMMA28: + return QVideoFrameFormat::ColorTransfer_Gamma28; + case GST_VIDEO_TRANSFER_GAMMA10: + return QVideoFrameFormat::ColorTransfer_Linear; + case GST_VIDEO_TRANSFER_SMPTE2084: + return QVideoFrameFormat::ColorTransfer_ST2084; + case GST_VIDEO_TRANSFER_ARIB_STD_B67: + return QVideoFrameFormat::ColorTransfer_STD_B67; + // GST_VIDEO_TRANSFER_LOG100 / LOG316 have no Qt equivalent — leave as Unknown + default: + return QVideoFrameFormat::ColorTransfer_Unknown; + } +} + +QVideoFrameFormat::PixelFormat toQtPixelFormat(GstVideoFormat fmt) +{ + for (const auto& e : GstQgc::kVideoFormatTable) { + if (e.gst == fmt) { + return e.qt; + } + } + return QVideoFrameFormat::Format_Invalid; +} + +QVideoFrameFormat::ColorRange toQtColorRange(GstVideoColorRange range) +{ + switch (range) { + case GST_VIDEO_COLOR_RANGE_0_255: + return QVideoFrameFormat::ColorRange_Full; + case GST_VIDEO_COLOR_RANGE_16_235: + return QVideoFrameFormat::ColorRange_Video; + default: + return QVideoFrameFormat::ColorRange_Unknown; + } +} + +// Operates on the GstVideoOrientationMethod enum, which is in 's +// always-present subset — independent of QGC_HAS_GST_VIDEO_ORIENTATION_META. +void applyOrientationToFrame(QVideoFrame& frame, GstVideoOrientationMethod method) +{ + switch (method) { + case GST_VIDEO_ORIENTATION_IDENTITY: + frame.setRotation(QtVideo::Rotation::None); + frame.setMirrored(false); + break; + case GST_VIDEO_ORIENTATION_90R: + frame.setRotation(QtVideo::Rotation::Clockwise90); + frame.setMirrored(false); + break; + case GST_VIDEO_ORIENTATION_180: + frame.setRotation(QtVideo::Rotation::Clockwise180); + frame.setMirrored(false); + break; + case GST_VIDEO_ORIENTATION_90L: + frame.setRotation(QtVideo::Rotation::Clockwise270); + frame.setMirrored(false); + break; + case GST_VIDEO_ORIENTATION_HORIZ: + frame.setRotation(QtVideo::Rotation::None); + frame.setMirrored(true); + break; + case GST_VIDEO_ORIENTATION_VERT: + frame.setRotation(QtVideo::Rotation::Clockwise180); + frame.setMirrored(true); + break; + case GST_VIDEO_ORIENTATION_UL_LR: + frame.setRotation(QtVideo::Rotation::Clockwise90); + frame.setMirrored(true); + break; + case GST_VIDEO_ORIENTATION_UR_LL: + frame.setRotation(QtVideo::Rotation::Clockwise270); + frame.setMirrored(true); + break; + default: + static std::atomic s_warnedUnhandled{false}; + if (!s_warnedUnhandled.exchange(true, std::memory_order_relaxed)) { + qCWarning(GStreamerFrameMapLog) + << "Unhandled GstVideoOrientationMethod" << method << "— treating as identity"; + } + frame.setRotation(QtVideo::Rotation::None); + frame.setMirrored(false); + break; + } +} + +void applyOrientationAndTiming(QVideoFrame& frame, [[maybe_unused]] GstBuffer* buffer, int streamOrientation) +{ + // Per-buffer meta wins (per-frame override) when gst-video exports it; stream-level fallback + // works on every install. +#ifdef QGC_HAS_GST_VIDEO_ORIENTATION_META + if (GstVideoOrientationMeta* meta = gst_buffer_get_video_orientation_meta(buffer)) { + applyOrientationToFrame(frame, meta->orientation); + } else +#endif + if (streamOrientation != static_cast(GST_VIDEO_ORIENTATION_IDENTITY)) { + applyOrientationToFrame(frame, static_cast(streamOrientation)); + } + if (GST_BUFFER_PTS_IS_VALID(buffer)) { + // GstClockTime is ns; QVideoFrame timestamps are µs. + frame.setStartTime(GST_BUFFER_PTS(buffer) / GST_USECOND); + if (GST_BUFFER_DURATION_IS_VALID(buffer)) { + frame.setEndTime((GST_BUFFER_PTS(buffer) + GST_BUFFER_DURATION(buffer)) / GST_USECOND); + } else { + // No duration: collapse the interval so consumers never see a stale/zero endTime. + frame.setEndTime(GST_BUFFER_PTS(buffer) / GST_USECOND); + } + } +} + +void applyColorimetry(QVideoFrameFormat& format, const GstVideoInfo& info, GstCaps* caps) +{ + const GstVideoColorimetry& colorimetry = GST_VIDEO_INFO_COLORIMETRY(&info); + QVideoFrameFormat::ColorSpace colorSpace = toQtColorSpace(colorimetry.matrix); + // Live RTSP sources often omit colorimetry caps; match Qt's renderer fallback + // (qvideotexturehelper.cpp): height > 576 is HD/BT.709, otherwise SD/BT.601. + if (colorSpace == QVideoFrameFormat::ColorSpace_Undefined) { + const int height = GST_VIDEO_INFO_HEIGHT(&info); + if (height > 0) { + colorSpace = (height > 576) ? QVideoFrameFormat::ColorSpace_BT709 : QVideoFrameFormat::ColorSpace_BT601; + } + } + format.setColorSpace(colorSpace); + format.setColorTransfer(toQtColorTransfer(colorimetry.transfer)); + QVideoFrameFormat::ColorRange range = toQtColorRange(colorimetry.range); + // H.264/H.265 omit VUI range but encode limited per spec — else Qt skips its limited->full offset. + // Only infer for a known YUV matrix: an UNKNOWN matrix tells us nothing, and RGB is always full-range. + const bool knownYuvMatrix = + (colorimetry.matrix == GST_VIDEO_COLOR_MATRIX_BT601) || (colorimetry.matrix == GST_VIDEO_COLOR_MATRIX_BT709) || + (colorimetry.matrix == GST_VIDEO_COLOR_MATRIX_BT2020) || + (colorimetry.matrix == GST_VIDEO_COLOR_MATRIX_SMPTE240M) || (colorimetry.matrix == GST_VIDEO_COLOR_MATRIX_FCC); + if (range == QVideoFrameFormat::ColorRange_Unknown && knownYuvMatrix) { + range = QVideoFrameFormat::ColorRange_Video; + } + format.setColorRange(range); + + // Prefer MaxCLL (tighter tone-mapping target) over mastering-display max-luminance. + GstVideoContentLightLevel cll; + bool clipApplied = false; + if (caps && gst_video_content_light_level_from_caps(&cll, caps) && cll.max_content_light_level > 0) { + format.setMaxLuminance(static_cast(cll.max_content_light_level)); + clipApplied = true; + } + if (!clipApplied) { + GstVideoMasteringDisplayInfo masteringInfo; + if (caps && gst_video_mastering_display_info_from_caps(&masteringInfo, caps)) { + // GstVideoMasteringDisplayColorVolume max_luma is in 0.0001 cd/m². + const double maxLuminance = static_cast(masteringInfo.max_display_mastering_luminance) / 10000.0; + if (maxLuminance > 0.0) { + format.setMaxLuminance(static_cast(maxLuminance)); + } + } + } +} + +// QQuickVideoOutput computes sample rect as viewport/frameSize (qquickvideooutput.cpp:498); +// externalTextureMatrix is only used for Format_SamplerExternalOES, so can't crop standard formats. +QVideoFrameFormat applyCropMeta(QVideoFrameFormat format, GstBuffer* buffer) +{ + if (GstVideoCropMeta* crop = gst_buffer_get_video_crop_meta(buffer)) { + format.setViewport(QRect(crop->x, crop->y, crop->width, crop->height)); + } + return format; +} + +MappedFrame mapSampleToFrame(GstBuffer* buffer, [[maybe_unused]] GstCaps* caps, const GstVideoInfo& info, + const QVideoFrameFormat& format, [[maybe_unused]] const HwVideoBufferContext& hwContext, + [[maybe_unused]] HwResolvedPathCache* pathCache) noexcept +{ + MappedFrame out; +#if defined(QGC_HAS_ANY_GPU_PATH) + // GPU-only: GstHwVideoBuffer holds the sample for the frame's lifetime; makeHwVideoBuffer + // takes its own ref, so drop ours immediately after. + HwVideoBufferPath matchedPath = HwVideoBufferPath::None; + if (GstSample* sample = gst_sample_new(buffer, caps, nullptr, nullptr)) { + auto hwBuf = makeHwVideoBuffer(sample, info, format, hwContext, matchedPath, pathCache); + gst_sample_unref(sample); + if (hwBuf) { + out.frame = QVideoFrame(std::move(hwBuf)); + out.source = MappedFrame::Source::Gpu; + out.gpuPath = matchedPath; + return out; + } + } + // HW selection failed though GPU was requested: signal the demotion. show_frame owns the once-per-epoch + // latch and telemetry so this mapper stays free of the telemetry singleton. + out.demoted = hwContext.gpuEnabled; + out.gpuPath = matchedPath; +#endif + if (auto cpuBuf = CpuVideoFramePool::wrapZeroCopy(buffer, info, format)) { + out.frame = QVideoFrame(std::move(cpuBuf)); + } else { + out.frame = CpuVideoFramePool::copyFromBuffer(buffer, info, format); + } + out.source = MappedFrame::Source::Cpu; + return out; +} diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GStreamerFrameMap.h b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GStreamerFrameMap.h new file mode 100644 index 000000000000..06363836dd6f --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GStreamerFrameMap.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "HwBuffers/common/HwBuffers.h" + +/// Sample-to-frame helpers for qgcqvideosink's show_frame; pure functions, streaming-thread safe. + +QVideoFrameFormat::ColorSpace toQtColorSpace(GstVideoColorMatrix matrix); +QVideoFrameFormat::ColorTransfer toQtColorTransfer(GstVideoTransferFunction transfer); +QVideoFrameFormat::PixelFormat toQtPixelFormat(GstVideoFormat fmt); +QVideoFrameFormat::ColorRange toQtColorRange(GstVideoColorRange range); + +/// Apply rotation + mirror flags derived from GstVideoOrientationMethod. +void applyOrientationToFrame(QVideoFrame& frame, GstVideoOrientationMethod method); + +/// Per-frame orientation (meta wins, else stream fallback) + PTS/duration timing; +/// @p streamOrientation is GstVideoOrientationMethod cast to int. +void applyOrientationAndTiming(QVideoFrame& frame, GstBuffer* buffer, int streamOrientation); + +/// Set color-space/transfer/range from gst-video colorimetry + HDR (MaxCLL preferred); +/// infers BT.601/709 from height when caps omit it. +void applyColorimetry(QVideoFrameFormat& format, const GstVideoInfo& info, GstCaps* caps); + +/// Apply video crop meta to @p format's viewport. Pass-through when no crop meta present. +QVideoFrameFormat applyCropMeta(QVideoFrameFormat format, GstBuffer* buffer); + +/// Sample-to-frame result (GPU wins when it can, CPU fallback). mapSampleToFrame() does NOT ref +/// @p buffer — GPU wraps it in a transient GstSample (own ref), CPU copies; caller keeps @p buffer/@p caps. +struct MappedFrame +{ + QVideoFrame frame; + enum class Source + { + Cpu, + Gpu + } source = Source::Cpu; +#if defined(QGC_HAS_ANY_GPU_PATH) + HwVideoBufferPath gpuPath = HwVideoBufferPath::None; + bool demoted = false; ///< A GPU path was requested for this stream but this frame fell back to CPU. +#endif +}; + +MappedFrame mapSampleToFrame(GstBuffer* buffer, GstCaps* caps, const GstVideoInfo& info, + const QVideoFrameFormat& format, const HwVideoBufferContext& hwContext, + HwResolvedPathCache* pathCache = nullptr) noexcept; diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcAllocation.cc b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcAllocation.cc new file mode 100644 index 000000000000..d264b7c758fb --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcAllocation.cc @@ -0,0 +1,237 @@ +#include "GstQgcAllocation.h" + +#include + +#include +#include + +#include "HwBuffers/dmabuf/GstDmaDrmCaps.h" +#include "HwBuffers/common/HwBuffers.h" +#if GST_CHECK_VERSION(1, 24, 0) +#include +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) +#include +#endif +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) +#include +#include "HwBuffers/d3d/GstD3D11ContextBridge.h" +#endif +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) +#include +#include "HwBuffers/d3d/GstD3D12ContextBridge.h" +#endif + +namespace GstQgc { + +namespace { + +// VAAPI/H.264 ref-frame queue typically 4–8; min=2 forced fallback allocations. +constexpr guint kProposedMinBuffers = 4; + +// Advertise every meta API qgcqvideosink/GStreamerFrameMap consume so upstream keeps them; +// else gst-vaapi/v4l2 strip crop/orientation metas and mapSampleToFrame does a full copy/rotate. +void addConsumedAllocationMetas(GstQuery* query) +{ + gst_query_add_allocation_meta(query, GST_VIDEO_META_API_TYPE, NULL); + gst_query_add_allocation_meta(query, GST_VIDEO_CROP_META_API_TYPE, NULL); +#if defined(QGC_HAS_GST_VIDEO_ORIENTATION_META) + gst_query_add_allocation_meta(query, GST_VIDEO_ORIENTATION_META_API_TYPE, NULL); +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + // glupload reads this to skip its own upload pass when upstream already provides GL texture. + gst_query_add_allocation_meta(query, GST_VIDEO_GL_TEXTURE_UPLOAD_META_API_TYPE, NULL); + // Let the producer attach a sync meta and set its fence on the producing context; GstGlVideoBuffer + // waits on it at import time. Without this it synthesizes a same-context fence after the fact, which + // doesn't synchronize against the real producer — a cross-context tearing hazard on the Qt render thread. + gst_query_add_allocation_meta(query, GST_GL_SYNC_META_API_TYPE, NULL); +#endif +} + +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) +// Bind allocation to QRhi's shared device so import stays same-device instead of hitting +// GstD3D11VideoBuffer's foreign-device copy path; SHADER_RESOURCE matches the importer's sampled texture. +bool tryProposeD3D11Pool(GstQuery* query, GstCaps* caps, const GstVideoInfo* vinfo, gsize size) +{ + GstD3D11Device* device = GstD3D11ContextBridge::currentDevice(); + if (!device) { + return false; + } + bool proposed = false; + if (GstBufferPool* pool = gst_d3d11_buffer_pool_new(device)) { + GstStructure* config = gst_buffer_pool_get_config(pool); + gst_buffer_pool_config_set_params(config, caps, size, kProposedMinBuffers, 0); + gst_buffer_pool_config_add_option(config, GST_BUFFER_POOL_OPTION_VIDEO_META); + if (GstD3D11AllocationParams* params = gst_d3d11_allocation_params_new( + device, vinfo, GST_D3D11_ALLOCATION_FLAG_DEFAULT, D3D11_BIND_SHADER_RESOURCE, 0)) { + gst_buffer_pool_config_set_d3d11_allocation_params(config, params); + gst_d3d11_allocation_params_free(params); + } + if (gst_buffer_pool_set_config(pool, config)) { + // The d3d11 pool may grow the buffer size to the allocated texture's pitch; re-read it. + GstStructure* updated = gst_buffer_pool_get_config(pool); + guint poolSize = static_cast(size); + gst_buffer_pool_config_get_params(updated, nullptr, &poolSize, nullptr, nullptr); + gst_structure_free(updated); + gst_query_add_allocation_pool(query, pool, poolSize, kProposedMinBuffers, 0); + proposed = true; + } + gst_object_unref(pool); + } + gst_object_unref(device); // currentDevice() returns a transfer-full ref. + return proposed; +} +#endif + +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) +// D3D12 counterpart of tryProposeD3D11Pool — steers allocation onto QRhi's shared (LUID-matched) adapter. +bool tryProposeD3D12Pool(GstQuery* query, GstCaps* caps, const GstVideoInfo* vinfo, gsize size) +{ + GstD3D12Device* device = GstD3D12ContextBridge::currentDevice(); + if (!device) { + return false; + } + bool proposed = false; + if (GstBufferPool* pool = gst_d3d12_buffer_pool_new(device)) { + GstStructure* config = gst_buffer_pool_get_config(pool); + gst_buffer_pool_config_set_params(config, caps, size, kProposedMinBuffers, 0); + gst_buffer_pool_config_add_option(config, GST_BUFFER_POOL_OPTION_VIDEO_META); + if (GstD3D12AllocationParams* params = gst_d3d12_allocation_params_new( + device, vinfo, GST_D3D12_ALLOCATION_FLAG_DEFAULT, D3D12_RESOURCE_FLAG_NONE, + D3D12_HEAP_FLAG_NONE)) { + gst_buffer_pool_config_set_d3d12_allocation_params(config, params); + gst_d3d12_allocation_params_free(params); + } + if (gst_buffer_pool_set_config(pool, config)) { + GstStructure* updated = gst_buffer_pool_get_config(pool); + guint poolSize = static_cast(size); + gst_buffer_pool_config_get_params(updated, nullptr, &poolSize, nullptr, nullptr); + gst_structure_free(updated); + gst_query_add_allocation_pool(query, pool, poolSize, kProposedMinBuffers, 0); + proposed = true; + } + gst_object_unref(pool); + } + gst_object_unref(device); + return proposed; +} +#endif + +// Propose a shared-device pool for D3D11/D3D12 caps; false for other HW memory or before the bridge is primed. +bool tryProposeDeviceBoundPool(GstQuery* query, GstCaps* caps, const GstVideoInfo* vinfo, gsize size) +{ + GstCapsFeatures* features = gst_caps_get_features(caps, 0); + if (!features) { + return false; + } +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) + if (gst_caps_features_contains(features, GST_CAPS_FEATURE_MEMORY_D3D11_MEMORY)) { + return tryProposeD3D11Pool(query, caps, vinfo, size); + } +#endif +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) + if (gst_caps_features_contains(features, GST_CAPS_FEATURE_MEMORY_D3D12_MEMORY)) { + return tryProposeD3D12Pool(query, caps, vinfo, size); + } +#endif + (void) query; + (void) vinfo; + (void) size; + return false; +} + +bool tryProposeMetaPool(GstQuery* query, GstCaps* caps, gsize size) +{ + GstBufferPool* pool = gst_buffer_pool_new(); + if (!pool) { + return false; + } + + bool proposed = false; + GstStructure* config = gst_buffer_pool_get_config(pool); + gst_buffer_pool_config_set_params(config, caps, size, kProposedMinBuffers, 0); + gst_buffer_pool_config_add_option(config, GST_BUFFER_POOL_OPTION_VIDEO_META); + if (gst_buffer_pool_set_config(pool, config)) { + gst_query_add_allocation_pool(query, pool, size, kProposedMinBuffers, 0); + proposed = true; + } + gst_object_unref(pool); + return proposed; +} + +} // namespace + +void populateAllocationQuery(GstQuery* query) +{ + GstCaps* caps = nullptr; + gboolean need_pool = FALSE; + gst_query_parse_allocation(query, &caps, &need_pool); + if (!caps) { + return; + } + + GstVideoInfo vinfo; + // DMA_DRM caps need the dma_drm-aware parser, else the min-buffer hint is dropped and va + // stays on its copy-threshold pool. + if (!GstHw::dmaDrmAwareVideoInfo(caps, &vinfo)) { + return; + } + const gsize size = GST_VIDEO_INFO_SIZE(&vinfo); + + // ANY features means upstream hasn't committed to a memory type yet; treat it as HW so we + // advertise the min-buffer hint instead of forcing a CPU pool that would defeat zero-copy. + GstCapsFeatures* features = gst_caps_get_features(caps, 0); + const bool is_system_memory = + !features || gst_caps_features_is_equal(features, GST_CAPS_FEATURES_MEMORY_SYSTEM_MEMORY); + + if (!is_system_memory) { + // Prefer a shared-device D3D pool (same-device import). Other HW-memory producers own their native + // allocator/pool; QGC only advertises VideoMeta plus a min-buffer hint so gst-va/v4l2 can build the right pool. + if (!tryProposeDeviceBoundPool(query, caps, &vinfo, size)) { + gst_query_add_allocation_pool(query, NULL, size, kProposedMinBuffers, 0); + } + addConsumedAllocationMetas(query); + return; + } + + if (!need_pool) { + // Upstream still needs to know which metas we accept even when we don't propose a pool. + addConsumedAllocationMetas(query); + return; + } + + (void) tryProposeMetaPool(query, caps, size); + // Advertise accepted metas even if the pool config was rejected, else upstream va/v4l2 strips + // VideoMeta/crop and mapSampleToFrame falls back to a full copy. + addConsumedAllocationMetas(query); +} + +GstPadProbeReturn videosinkQueryProbe(GstPad* pad, GstPadProbeInfo* info, gpointer user_data) +{ + (void) pad; + (void) user_data; + GstQuery* query = GST_PAD_PROBE_INFO_QUERY(info); + if (!query) { + return GST_PAD_PROBE_OK; + } + + switch (GST_QUERY_TYPE(query)) { + case GST_QUERY_ALLOCATION: + // The bin path has capsfilters/ghost pads in front of qgcqvideosink. Answer the allocation query at the + // terminal sink pad too so transform elements such as gst-va always see VideoMeta before deciding their + // DMABuf pool. qgcqvideosink's vmethod still covers direct use of the element. + populateAllocationQuery(query); + return GST_PAD_PROBE_HANDLED; + case GST_QUERY_CONTEXT: + // Synchronous answer for gst.gl.GLDisplay/app_context — bus NEED_CONTEXT fallback races state changes and + // can isolate glupload from Qt's RHI context. + if (HwBuffers::answerSinkBinContextQuery(query)) { + return GST_PAD_PROBE_HANDLED; + } + return GST_PAD_PROBE_OK; + default: + return GST_PAD_PROBE_OK; + } +} + +} // namespace GstQgc diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcAllocation.h b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcAllocation.h new file mode 100644 index 000000000000..ea400bd5fb80 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcAllocation.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace GstQgc { + +/// Downstream query probe for qgcqvideosink's sink pad. Answers CONTEXT queries synchronously, +/// so negotiation terminates at the sink instead of racing NEED_CONTEXT bus fallback. +GstPadProbeReturn videosinkQueryProbe(GstPad* pad, GstPadProbeInfo* info, gpointer user_data); + +/// Populate an ALLOCATION query with qgcqvideosink's consumed metas and, when needed, a min-buffer pool hint. +/// Called from the sink's propose_allocation vmethod. +void populateAllocationQuery(GstQuery* query); + +} // namespace GstQgc diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcCaps.cc b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcCaps.cc new file mode 100644 index 000000000000..aea2cb73376f --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcCaps.cc @@ -0,0 +1,75 @@ +#include "GstQgcCaps.h" + +#include + +#include "GstQgcVideoFormats.h" +#include "HwBuffers/dmabuf/GstDmaDrmCaps.h" +#include "HwBuffers/common/HwBuffers.h" + +namespace GstQgc { + +std::string buildCpuCapsString() +{ + return std::string("video/x-raw, format=") + advertisedFormatList(); +} + +std::string buildGpuCapsString() +{ + const std::string kFormats = advertisedFormatList(); + std::string capsStr; +#if defined(QGC_GST_BIN_USE_GLUPLOAD) + // texture-target=2D is load-bearing: it forces glcolorconvert to convert amcvideodec's external-OES + // Surface textures to GL_TEXTURE_2D; the default ANY target passes external-OES through and the Qt + // RHI 2D sink samples it black. + // Keep this path single-plane for Qt's GL import. VA H.265 can negotiate NV12 GLMemory here, which + // reaches QVideoFrame delivery cleanly but renders as intermittent green frames through Qt's sampler. + capsStr = "video/x-raw(memory:GLMemory), texture-target=2D, format={ BGRA, RGBA }"; +#else +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) +#if GST_CHECK_VERSION(1, 24, 0) + // Best-effort: advertise the GPU's actually-supported (format, modifier) pairs (EGL query) so modifier-aware + // drivers negotiate a tiling QGC's importer can consume. Additive — empty on any query failure (see below). + capsStr += GstHw::buildSupportedDmaDrmCaps(kFormats.c_str()); + // QGC_GST_OFFER_DMA_DRM_LINEAR=1 force-offers LINEAR-modifier (0x0) DMA_DRM as a fallback/override so a driver + // that mis-reports modifiers still gets a guaranteed-LINEAR option and iHD can't pick tiled+CCS. + if (HwBuffers::hwBufferEnvConfig().offerDmaDrmLinear) { + capsStr += GstHw::buildLinearDmaDrmCaps("{ NV12, NV21, I420, P010_10LE, BGRA, RGBA }"); + } +#endif + // Legacy memory:DMABuf covers v4l2h264dec/Mali/V3D LINEAR. DMA_DRM omitted: gst-va iHD + // negotiates tiled+CCS that crashes both paths (system catch-all routes va to GstVaMemory). + capsStr += "video/x-raw(memory:DMABuf), format="; + capsStr += kFormats; + capsStr += "; "; +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + capsStr += "video/x-raw(memory:D3D11Memory), format="; + capsStr += kFormats; + capsStr += "; "; +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + capsStr += "video/x-raw(memory:D3D12Memory), format="; + capsStr += kFormats; + capsStr += "; "; +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) && !defined(QGC_GST_BIN_USE_DMABUF) + // Keep GLMemory after native platform-memory paths. On Windows the active QRhi is D3D, so D3D11/D3D12 memory must + // win negotiation before the GL fallback is offered. + capsStr += "video/x-raw(memory:GLMemory), format="; + capsStr += kFormats; + capsStr += "; "; +#endif +#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + capsStr += "video/x-raw(memory:AHardwareBuffer), format="; + capsStr += kFormats; + capsStr += "; "; +#endif + // System catch-all required: dropping it returns GST_PAD_LINK_NOFORMAT (-4) on + // system-only upstream and tears down the receiver. + capsStr += "video/x-raw, format="; + capsStr += kFormats; +#endif + return capsStr; +} + +} // namespace GstQgc diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcCaps.h b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcCaps.h new file mode 100644 index 000000000000..1d0b0761f22d --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcCaps.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace GstQgc { + +/// Builds the upstream caps string for gpu_zerocopy=TRUE, branching on enabled GPU paths and the +/// bin variant. Pure policy: no GObject state, no allocations. +std::string buildGpuCapsString(); + +/// System-memory caps for the CPU branch's format capsfilter; shares the Qt-renderable format set +/// with buildGpuCapsString() so the two can't drift. +std::string buildCpuCapsString(); + +} // namespace GstQgc diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcVideoFormats.h b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcVideoFormats.h new file mode 100644 index 000000000000..b2f7e8164ebc --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/GstQgcVideoFormats.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include + +namespace GstQgc { + +/// Single source of truth linking a GStreamer video format to its Qt pixel format and (optionally) its +/// negotiation caps token. Both the advertised caps string (GstQgcCaps) and toQtPixelFormat() +/// (GStreamerFrameMap) derive from this table, so a format can never be advertised without a Qt mapping, +/// nor mapped-and-renderable without being offered. +struct VideoFormatEntry +{ + GstVideoFormat gst; + QVideoFrameFormat::PixelFormat qt; + const char* capsToken; ///< non-null: advertised in negotiation caps; null: accepted-only (defensive map). +}; + +inline constexpr VideoFormatEntry kVideoFormatTable[] = { + // Advertised set — order is preserved in the generated caps string (negotiation preference order). + {GST_VIDEO_FORMAT_NV12, QVideoFrameFormat::Format_NV12, "NV12"}, + {GST_VIDEO_FORMAT_NV21, QVideoFrameFormat::Format_NV21, "NV21"}, + {GST_VIDEO_FORMAT_I420, QVideoFrameFormat::Format_YUV420P, "I420"}, + {GST_VIDEO_FORMAT_YV12, QVideoFrameFormat::Format_YV12, "YV12"}, + {GST_VIDEO_FORMAT_Y42B, QVideoFrameFormat::Format_YUV422P, "Y42B"}, + {GST_VIDEO_FORMAT_P010_10LE, QVideoFrameFormat::Format_P010, "P010_10LE"}, + {GST_VIDEO_FORMAT_AYUV, QVideoFrameFormat::Format_AYUV, "AYUV"}, + {GST_VIDEO_FORMAT_YUY2, QVideoFrameFormat::Format_YUYV, "YUY2"}, + {GST_VIDEO_FORMAT_UYVY, QVideoFrameFormat::Format_UYVY, "UYVY"}, + {GST_VIDEO_FORMAT_GRAY8, QVideoFrameFormat::Format_Y8, "GRAY8"}, + {GST_VIDEO_FORMAT_GRAY16_LE, QVideoFrameFormat::Format_Y16, "GRAY16_LE"}, + {GST_VIDEO_FORMAT_BGRA, QVideoFrameFormat::Format_BGRA8888, "BGRA"}, + {GST_VIDEO_FORMAT_RGBA, QVideoFrameFormat::Format_RGBA8888, "RGBA"}, + // Accepted but not advertised: mapped defensively if upstream or a GPU path delivers them anyway. + {GST_VIDEO_FORMAT_BGRx, QVideoFrameFormat::Format_BGRX8888, nullptr}, + {GST_VIDEO_FORMAT_RGBx, QVideoFrameFormat::Format_RGBX8888, nullptr}, + {GST_VIDEO_FORMAT_ARGB, QVideoFrameFormat::Format_ARGB8888, nullptr}, + {GST_VIDEO_FORMAT_xRGB, QVideoFrameFormat::Format_XRGB8888, nullptr}, + {GST_VIDEO_FORMAT_I420_10LE, QVideoFrameFormat::Format_YUV420P10, nullptr}, + {GST_VIDEO_FORMAT_P016_LE, QVideoFrameFormat::Format_P016, nullptr}, + // GST_VIDEO_FORMAT_BGR/RGB intentionally absent: Qt6 has no 24-bit packed format, so they resolve to + // Format_Invalid (the lookup default) rather than corrupting stride arithmetic. +}; + +/// "{ NV12, NV21, ... }" — the advertised format list for a caps string. Built once at caps setup. +inline std::string advertisedFormatList() +{ + std::string out = "{ "; + bool first = true; + for (const auto& e : kVideoFormatTable) { + if (!e.capsToken) { + continue; + } + if (!first) { + out += ", "; + } + out += e.capsToken; + first = false; + } + out += " }"; + return out; +} + +} // namespace GstQgc diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgc.cc b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgc.cc index 2d9fccae67c0..779e5ef9ba6a 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgc.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgc.cc @@ -1,22 +1,20 @@ #include "gstqgcelements.h" #include "qgc_version.h" -static gboolean -plugin_init(GstPlugin *plugin) +static gboolean plugin_init(GstPlugin* plugin) { - return GST_ELEMENT_REGISTER(qgcvideosinkbin, plugin); + if (!GST_ELEMENT_REGISTER(qgcvideosinkbin, plugin)) + return FALSE; + if (!GST_ELEMENT_REGISTER(qgcqvideosink, plugin)) + return FALSE; + return TRUE; } -#define GST_PACKAGE_NAME "GStreamer plugin for QGC's Video Receiver" +#define GST_PACKAGE_NAME "GStreamer plugin for QGC's Video Receiver" #define GST_PACKAGE_ORIGIN "https://qgroundcontrol.com/" -#define GST_LICENSE "LGPL" -#define PACKAGE "QGC Video Receiver" -#define PACKAGE_VERSION QGC_APP_VERSION_STR +#define GST_LICENSE "LGPL" +#define PACKAGE "QGC Video Receiver" +#define PACKAGE_VERSION QGC_APP_VERSION_STR -GST_PLUGIN_DEFINE( - GST_VERSION_MAJOR, GST_VERSION_MINOR, - qgc, - "QGC Video Receiver Plugin", - plugin_init, - PACKAGE_VERSION, GST_LICENSE, GST_PACKAGE_NAME, GST_PACKAGE_ORIGIN -) +GST_PLUGIN_DEFINE(GST_VERSION_MAJOR, GST_VERSION_MINOR, qgc, "QGC Video Receiver Plugin", plugin_init, PACKAGE_VERSION, + GST_LICENSE, GST_PACKAGE_NAME, GST_PACKAGE_ORIGIN) diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcelement.cc b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcelement.cc index 6932ee6a2385..0130455dc7a5 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcelement.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcelement.cc @@ -1,15 +1,14 @@ #include "gstqgcelements.h" #define GST_CAT_DEFAULT gst_qgc_debug -GST_DEBUG_CATEGORY (GST_CAT_DEFAULT); +GST_DEBUG_CATEGORY(GST_CAT_DEFAULT); -void -qgc_element_init(GstPlugin *plugin) +void qgc_element_init(GstPlugin* plugin) { (void) plugin; static gsize res = FALSE; if (g_once_init_enter(&res)) { - GST_DEBUG_CATEGORY_INIT (gst_qgc_debug, "qgc", 0, "QGC"); + GST_DEBUG_CATEGORY_INIT(gst_qgc_debug, "qgc", 0, "QGC"); g_once_init_leave(&res, TRUE); } } diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcelements.h b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcelements.h index 32e3623f45ee..3c7ccfdecae1 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcelements.h +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcelements.h @@ -5,12 +5,13 @@ G_BEGIN_DECLS -extern GstDebugCategory *gst_qgc_debug; +extern GstDebugCategory* gst_qgc_debug; -void qgc_element_init(GstPlugin *plugin); +void qgc_element_init(GstPlugin* plugin); GST_ELEMENT_REGISTER_DECLARE(qgcvideosinkbin); +GST_ELEMENT_REGISTER_DECLARE(qgcqvideosink); G_END_DECLS -#endif // GST_QGC_ELEMENTS_H +#endif // GST_QGC_ELEMENTS_H diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcqvideosink.cc b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcqvideosink.cc new file mode 100644 index 000000000000..1ce0116fe720 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcqvideosink.cc @@ -0,0 +1,464 @@ +#include "gstqgcqvideosink.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "GStreamerFrameMap.h" +#include "GstQgcAllocation.h" +#include "HwBuffers/dmabuf/GstDmaDrmCaps.h" +#include "QGCLoggingCategory.h" +#include "gstqgcelements.h" +#if GST_CHECK_VERSION(1, 24, 0) +#include +#endif + +#include + +QGC_LOGGING_CATEGORY(GstQgcQVideoSinkLog, "Video.GStreamer.QgcQVideoSink") + +#define GST_CAT_DEFAULT gst_qgc_debug + +namespace { + +/// Non-POD state hung off the GObject instance via `priv`. Owned, new'd in instance_init, +/// delete'd in finalize. +struct PrivState +{ + QVideoFrameFormat format; + // Written under GST_OBJECT_LOCK from the GUI thread, snapshotted by show_frame. + // Default (gpuEnabled=false) keeps the CPU memcpy path until the controller wires it. + HwVideoBufferContext hw_context = {}; + std::atomic cpu_frames{0}; + std::atomic last_pts_ns{-1}; + std::atomic input_frames{0}; + std::atomic dropped_frames{0}; + std::atomic consecutive_map_failures{0}; // sustained run escalates show_frame to error + // Per-element render counter (read via `frames-delivered`) so multi-receiver setups + // don't see a shared process-global total. + std::atomic delivered_frames{0}; + // Negotiated caps held from set_caps; avoids per-frame allocation and preserves DRM modifiers. + GstCaps* cached_caps{nullptr}; +#if defined(QGC_HAS_ANY_GPU_PATH) + // Per-caps-epoch resolved-path cache; reset on set_caps so a format change re-runs path selection. + HwResolvedPathCache resolved_path_cache = {}; +#endif +}; + +inline PrivState* priv_of(GstQgcQVideoSink* self) +{ + return static_cast(self->priv); +} + +/// Snapshot the QVideoSink* under GST_OBJECT_LOCK as a QPointer: the sink may be destroyed +/// on its owner thread between snapshot and push. +QPointer snapshot_sink(GstQgcQVideoSink* self, HwVideoBufferContext* hwOut = nullptr) +{ + QPointer out; + GST_OBJECT_LOCK(self); + QVideoSink* raw = static_cast(self->qvideosink); + out = raw; + if (hwOut) + *hwOut = priv_of(self)->hw_context; + GST_OBJECT_UNLOCK(self); + return out; +} + +void push_frame_queued(QVideoSink* sink, QVideoFrame&& frame) +{ + if (!sink) + return; + // Method-pointer overload guards a destroyed receiver internally — safe across the streaming + // thread boundary where a QPointer captured into a lambda is not. + QMetaObject::invokeMethod(sink, &QVideoSink::setVideoFrame, Qt::QueuedConnection, std::move(frame)); +} + +} // namespace + +/// Pad template — sink accepts any video caps. The downstream conversion +/// (videoconvert/glupload) already happens upstream in qgcvideosinkbin. +static GstStaticPadTemplate sink_template = + GST_STATIC_PAD_TEMPLATE("sink", GST_PAD_SINK, GST_PAD_ALWAYS, GST_STATIC_CAPS_ANY); + +enum +{ + PROP_0, + PROP_QVIDEOSINK, + PROP_ACTIVE, + PROP_GPU_ZEROCOPY, + PROP_FRAMES_INPUT, + PROP_FRAMES_DROPPED, + PROP_FRAMES_DELIVERED, +}; + +G_DEFINE_FINAL_TYPE(GstQgcQVideoSink, gst_qgc_q_video_sink, GST_TYPE_VIDEO_SINK) +GST_ELEMENT_REGISTER_DEFINE(qgcqvideosink, "qgcqvideosink", GST_RANK_NONE, GST_TYPE_QGC_Q_VIDEO_SINK) + +static void gst_qgc_q_video_sink_init(GstQgcQVideoSink* self) +{ + self->qvideosink = nullptr; + self->active = TRUE; + self->gpu_zerocopy = FALSE; + self->caps_valid = FALSE; + gst_video_info_init(&self->video_info); + self->priv = new PrivState(); + // sync=FALSE: drone video is "as fast as decoded"; GstBaseSink clock-sync would + // stall on live RTSP. async=FALSE skips preroll wait so caps changes don't stall. + gst_base_sink_set_sync(GST_BASE_SINK(self), FALSE); + gst_base_sink_set_async_enabled(GST_BASE_SINK(self), FALSE); + // Don't retain the last buffer: it would pin an upstream pool slot for the stream's lifetime. + gst_base_sink_set_last_sample_enabled(GST_BASE_SINK(self), FALSE); +} + +void gst_qgc_q_video_sink_set_hw_context(GstQgcQVideoSink* self, const HwVideoBufferContext& ctx) +{ + if (!self) + return; + GST_OBJECT_LOCK(self); + priv_of(self)->hw_context = ctx; + GST_OBJECT_UNLOCK(self); +} + +static void gst_qgc_q_video_sink_finalize(GObject* obj) +{ + GstQgcQVideoSink* self = GST_QGC_Q_VIDEO_SINK(obj); + gst_clear_caps(&priv_of(self)->cached_caps); + delete priv_of(self); + self->priv = nullptr; + G_OBJECT_CLASS(gst_qgc_q_video_sink_parent_class)->finalize(obj); +} + +static void gst_qgc_q_video_sink_set_property(GObject* obj, guint id, const GValue* val, GParamSpec* pspec) +{ + GstQgcQVideoSink* self = GST_QGC_Q_VIDEO_SINK(obj); + GST_OBJECT_LOCK(self); + switch (id) { + case PROP_QVIDEOSINK: { + gpointer raw = g_value_get_pointer(val); + self->qvideosink = raw; + // Reset PTS guard on sink swap so a new sink doesn't see a stale last_pts_ns + // from the previous sink and erroneously drop the first frames. + priv_of(self)->last_pts_ns.store(-1, std::memory_order_relaxed); + break; + } + case PROP_ACTIVE: + // Read lock-free on the streaming thread (show_frame); publish atomically. + g_atomic_int_set(&self->active, g_value_get_boolean(val)); + break; + case PROP_GPU_ZEROCOPY: + self->gpu_zerocopy = g_value_get_boolean(val); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, id, pspec); + break; + } + GST_OBJECT_UNLOCK(self); +} + +static void gst_qgc_q_video_sink_get_property(GObject* obj, guint id, GValue* val, GParamSpec* pspec) +{ + GstQgcQVideoSink* self = GST_QGC_Q_VIDEO_SINK(obj); + GST_OBJECT_LOCK(self); + switch (id) { + case PROP_QVIDEOSINK: + g_value_set_pointer(val, self->qvideosink); + break; + case PROP_ACTIVE: + g_value_set_boolean(val, self->active); + break; + case PROP_GPU_ZEROCOPY: + g_value_set_boolean(val, self->gpu_zerocopy); + break; + case PROP_FRAMES_INPUT: + g_value_set_uint64(val, priv_of(self)->input_frames.load(std::memory_order_relaxed)); + break; + case PROP_FRAMES_DROPPED: + g_value_set_uint64(val, priv_of(self)->dropped_frames.load(std::memory_order_relaxed)); + break; + case PROP_FRAMES_DELIVERED: + g_value_set_uint64(val, priv_of(self)->delivered_frames.load(std::memory_order_relaxed)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, id, pspec); + break; + } + GST_OBJECT_UNLOCK(self); +} + +static gboolean gst_qgc_q_video_sink_set_caps(GstBaseSink* bsink, GstCaps* caps) +{ + GstQgcQVideoSink* self = GST_QGC_Q_VIDEO_SINK(bsink); + PrivState* p = priv_of(self); + + GstVideoInfo parsedInfo = {}; + if (!GstHw::dmaDrmAwareVideoInfo(caps, &parsedInfo)) { + qCWarning(GstQgcQVideoSinkLog) << "set_caps: failed to parse video info from caps"; + return FALSE; + } + + const QVideoFrameFormat::PixelFormat pixelFormat = toQtPixelFormat(GST_VIDEO_INFO_FORMAT(&parsedInfo)); + if (pixelFormat == QVideoFrameFormat::Format_Invalid) { + qCWarning(GstQgcQVideoSinkLog) << "set_caps: unsupported video format" + << gst_video_format_to_string(GST_VIDEO_INFO_FORMAT(&parsedInfo)); + return FALSE; + } + + const int w = GST_VIDEO_INFO_WIDTH(&parsedInfo); + const int h = GST_VIDEO_INFO_HEIGHT(&parsedInfo); + if (w <= 0 || h <= 0) { + qCWarning(GstQgcQVideoSinkLog) << "set_caps: invalid dimensions" << w << "x" << h; + return FALSE; + } + + QVideoFrameFormat fmt(QSize(w, h), pixelFormat); + applyColorimetry(fmt, parsedInfo, caps); + const int fpsN = GST_VIDEO_INFO_FPS_N(&parsedInfo); + const int fpsD = GST_VIDEO_INFO_FPS_D(&parsedInfo); + if (fpsN > 0 && fpsD > 0) { + fmt.setStreamFrameRate(static_cast(fpsN) / static_cast(fpsD)); + } + + self->video_info = parsedInfo; + p->format = std::move(fmt); + gst_clear_caps(&p->cached_caps); + // Hold the negotiated caps verbatim: rebuilding from video_info drops DRM modifiers and other + // negotiated fields the downstream frame mapping relies on. + p->cached_caps = gst_caps_ref(caps); +#if defined(QGC_HAS_ANY_GPU_PATH) + p->resolved_path_cache = HwResolvedPathCache{}; +#endif + // New caps = new segment; clear PTS history so a restart/format change that resumes at a + // lower PTS isn't wedged by the monotonic-PTS guard in show_frame. + p->last_pts_ns.store(-1, std::memory_order_relaxed); + // caps_valid is read lock-free on the streaming thread (show_frame); publish atomically. + g_atomic_int_set(&self->caps_valid, TRUE); + + // Posted on the bus so the controller can mirror negotiation state into Q_PROPERTY. + { + gchar* fmtName = g_strdup(gst_video_format_to_string(GST_VIDEO_INFO_FORMAT(&parsedInfo))); + GstStructure* s = gst_structure_new("qgc-caps-info", "width", G_TYPE_INT, w, "height", G_TYPE_INT, h, "format", + G_TYPE_STRING, fmtName, nullptr); + gst_element_post_message(GST_ELEMENT(self), gst_message_new_element(GST_OBJECT(self), s)); + g_free(fmtName); + } + + qCInfo(GstQgcQVideoSinkLog).noquote() + << "set_caps: format=" << gst_video_format_to_string(GST_VIDEO_INFO_FORMAT(&parsedInfo)) << w << "x" << h + << "pixfmt=" << int(pixelFormat); + return TRUE; +} + +// Sustained run of map failures (not a transient hiccup) means the import is broken — tear down + restart. +constexpr quint64 kMaxConsecutiveMapFailures = 120; + +static const char* describeMappedPath([[maybe_unused]] const MappedFrame& m) noexcept +{ +#if defined(QGC_HAS_ANY_GPU_PATH) + if (m.source == MappedFrame::Source::Gpu) { + switch (m.gpuPath) { + case HwVideoBufferPath::DmaBuf: + return "GPU/DmaBuf"; + case HwVideoBufferPath::GlMemory: + return "GPU/GlMemory"; + case HwVideoBufferPath::D3D11: + return "GPU/D3D11"; + case HwVideoBufferPath::D3D12: + return "GPU/D3D12"; + case HwVideoBufferPath::IOSurface: + return "GPU/IOSurface"; + case HwVideoBufferPath::AHardwareBuffer: + return "GPU/AHardwareBuffer"; + case HwVideoBufferPath::Vulkan: + return "GPU/Vulkan"; + case HwVideoBufferPath::None: + break; + } + return "GPU/Unknown"; + } +#endif + return "CPU"; +} + +static GstFlowReturn gst_qgc_q_video_sink_show_frame(GstVideoSink* vsink, GstBuffer* buf) +{ + GstQgcQVideoSink* self = GST_QGC_Q_VIDEO_SINK(vsink); + PrivState* p = priv_of(self); + + p->input_frames.fetch_add(1, std::memory_order_relaxed); + + if (!g_atomic_int_get(&self->caps_valid)) { + // Should never happen — GstBaseSink calls set_caps before show_frame. + return GST_FLOW_NOT_NEGOTIATED; + } + if (!g_atomic_int_get(&self->active)) { + p->dropped_frames.fetch_add(1, std::memory_order_relaxed); + return GST_FLOW_OK; // drop silently — controller drives the active flag + } + + HwVideoBufferContext hwCtx; + QPointer sink = snapshot_sink(self, &hwCtx); + if (!sink) { + p->dropped_frames.fetch_add(1, std::memory_order_relaxed); + return GST_FLOW_OK; // no destination yet; drop + } + + // PTS regression guard ahead of mapping: a regressed timestamp wedges QVideoOutput's internal + // advance, and checking first avoids a wasted full-frame map on a buffer we'd drop anyway. + // last_pts_ns is advanced only once the frame is actually delivered (below) so a transient map + // failure doesn't push it past a buffer we never rendered. + const bool hasPts = GST_BUFFER_PTS_IS_VALID(buf); + const int64_t pts = hasPts ? static_cast(GST_BUFFER_PTS(buf)) : -1; + if (hasPts) { + const int64_t lastPts = p->last_pts_ns.load(std::memory_order_acquire); + if (lastPts >= 0 && pts < lastPts) { + p->dropped_frames.fetch_add(1, std::memory_order_relaxed); + return GST_FLOW_OK; + } + } + + // Build a cropped format copy only when crop meta is present (rare); otherwise pass + // p->format by reference to avoid a per-frame QVideoFrameFormat refcount bump. + QVideoFrameFormat croppedFmt; + const bool hasCrop = (gst_buffer_get_video_crop_meta(buf) != nullptr); + if (hasCrop) { + croppedFmt = applyCropMeta(p->format, buf); + } + MappedFrame mapped = + mapSampleToFrame(buf, p->cached_caps, self->video_info, hasCrop ? croppedFmt : p->format, hwCtx, +#if defined(QGC_HAS_ANY_GPU_PATH) + &p->resolved_path_cache); +#else + nullptr); +#endif + if (!mapped.frame.isValid()) { + p->dropped_frames.fetch_add(1, std::memory_order_relaxed); + const quint64 c = p->consecutive_map_failures.fetch_add(1, std::memory_order_relaxed) + 1; + if ((c & 0x3F) == 1) { + qCWarning(GstQgcQVideoSinkLog) << "show_frame: mapping failed, consecutive=" << c; + } + if (c >= kMaxConsecutiveMapFailures) { + qCWarning(GstQgcQVideoSinkLog) << "show_frame:" << c << "consecutive map failures — erroring out"; + return GST_FLOW_ERROR; + } + return GST_FLOW_OK; // drop transient failure, keep the stream alive + } + p->consecutive_map_failures.store(0, std::memory_order_relaxed); + + // Stream orientation is always IDENTITY here; tag-driven orientation lives in the + // controller (GST_TAG_IMAGE_ORIENTATION). Per-buffer GstVideoOrientationMeta still wins. + applyOrientationAndTiming(mapped.frame, buf, static_cast(GST_VIDEO_ORIENTATION_IDENTITY)); + + // Telemetry — process-global `GstHwPathTelemetry` accumulator. Per-element render + // counts live in `delivered_frames` (read by the controller via `frames-delivered`). + if (mapped.source == MappedFrame::Source::Cpu) { + p->cpu_frames.fetch_add(1, std::memory_order_relaxed); + GstHwPathTelemetry::recordDelivered(HwVideoBufferPath::None); +#if defined(QGC_HAS_ANY_GPU_PATH) + // Stream started HW-capable but this frame fell back to CPU — record the demotion once per epoch. + if (mapped.demoted && !p->resolved_path_cache.demotionRecorded) { + p->resolved_path_cache.demotionRecorded = true; + GstHwPathTelemetry::recordStreamDemotion(mapped.gpuPath); + } +#endif + } +#if defined(QGC_HAS_ANY_GPU_PATH) + else { + GstHwPathTelemetry::recordDelivered(mapped.gpuPath); + } +#endif + + const quint64 delivered = p->delivered_frames.fetch_add(1, std::memory_order_relaxed) + 1; + if (delivered == 1) { + qCInfo(GstQgcQVideoSinkLog).noquote() + << "first frame delivered via" << describeMappedPath(mapped) << "path" + << QStringLiteral("%1x%2").arg(mapped.frame.width()).arg(mapped.frame.height()); + } else if ((delivered % 300) == 0) { + qCDebug(GstQgcQVideoSinkLog).noquote() + << "frame flow:" << describeMappedPath(mapped) << "delivered=" << delivered + << "input=" << p->input_frames.load(std::memory_order_relaxed) + << "dropped=" << p->dropped_frames.load(std::memory_order_relaxed) + << "cpuFrames=" << p->cpu_frames.load(std::memory_order_relaxed); + } + if (hasPts) { + p->last_pts_ns.store(pts, std::memory_order_release); + } + push_frame_queued(sink.data(), std::move(mapped.frame)); + return GST_FLOW_OK; +} + +// Add qgcqvideosink's consumed metas + min-buffer pool hint, then chain so GstBaseSink's default +// allocation bookkeeping still runs. Replaces the former QUERY_DOWNSTREAM pad probe for ALLOCATION. +static gboolean gst_qgc_q_video_sink_propose_allocation(GstBaseSink* bsink, GstQuery* query) +{ + GstQgc::populateAllocationQuery(query); + // GstBaseSink/GstVideoSink install no default propose_allocation vmethod, so the parent pointer + // is NULL for a direct subclass; chaining unconditionally would call 0x0 on the first ALLOCATION query. + GstBaseSinkClass* parentClass = GST_BASE_SINK_CLASS(gst_qgc_q_video_sink_parent_class); + if (parentClass->propose_allocation) { + return parentClass->propose_allocation(bsink, query); + } + return TRUE; +} + +static void gst_qgc_q_video_sink_class_init(GstQgcQVideoSinkClass* klass) +{ + GObjectClass* gobject_class = G_OBJECT_CLASS(klass); + GstElementClass* element_class = GST_ELEMENT_CLASS(klass); + GstBaseSinkClass* basesink_class = GST_BASE_SINK_CLASS(klass); + GstVideoSinkClass* videosink_class = GST_VIDEO_SINK_CLASS(klass); + + gobject_class->set_property = gst_qgc_q_video_sink_set_property; + gobject_class->get_property = gst_qgc_q_video_sink_get_property; + gobject_class->finalize = gst_qgc_q_video_sink_finalize; + + g_object_class_install_property( + gobject_class, PROP_QVIDEOSINK, + g_param_spec_pointer("qvideosink", "QVideoSink target", + "QVideoSink* to push frames into. Caller-owned; element never unrefs.", + (GParamFlags) (G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS))); + + g_object_class_install_property( + gobject_class, PROP_ACTIVE, + g_param_spec_boolean("active", "Active", + "When FALSE, show_frame drops buffers instead of pushing to the QVideoSink.", TRUE, + (GParamFlags) (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS))); + + g_object_class_install_property( + gobject_class, PROP_GPU_ZEROCOPY, + g_param_spec_boolean("gpu-zerocopy", "GPU zero-copy", + "Attempt GPU-zerocopy mapping in show_frame; false forces CPU memcpy.", FALSE, + (GParamFlags) (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS))); + + g_object_class_install_property( + gobject_class, PROP_FRAMES_INPUT, + g_param_spec_uint64("frames-input", "Frames input", "Total buffers seen by show_frame, including drops.", 0, + G_MAXUINT64, 0, (GParamFlags) (G_PARAM_READABLE | G_PARAM_STATIC_STRINGS))); + + g_object_class_install_property( + gobject_class, PROP_FRAMES_DROPPED, + g_param_spec_uint64("frames-dropped", "Frames dropped", + "Buffers rejected by show_frame (inactive sink, missing QVideoSink, " + "PTS regression, or map failure). Detailed map failures are tracked separately " + "via GstHwPathTelemetry::peekMapFailureCount.", + 0, G_MAXUINT64, 0, (GParamFlags) (G_PARAM_READABLE | G_PARAM_STATIC_STRINGS))); + + g_object_class_install_property( + gobject_class, PROP_FRAMES_DELIVERED, + g_param_spec_uint64("frames-delivered", "Frames delivered", + "Buffers that survived every drop check and were queued to the QVideoSink. " + "Per-element — the GUI controller reads this for the QML frameCount.", + 0, G_MAXUINT64, 0, (GParamFlags) (G_PARAM_READABLE | G_PARAM_STATIC_STRINGS))); + + gst_element_class_set_static_metadata(element_class, "QGC QVideoSink", "Sink/Video", + "Pushes decoded GstVideoFrames into a Qt QVideoSink", + "QGroundControl "); + gst_element_class_add_static_pad_template(element_class, &sink_template); + + basesink_class->set_caps = gst_qgc_q_video_sink_set_caps; + basesink_class->propose_allocation = gst_qgc_q_video_sink_propose_allocation; + videosink_class->show_frame = gst_qgc_q_video_sink_show_frame; +} diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcqvideosink.h b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcqvideosink.h new file mode 100644 index 000000000000..95beb4dc4dba --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcqvideosink.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +G_BEGIN_DECLS + +#define GST_TYPE_QGC_Q_VIDEO_SINK (gst_qgc_q_video_sink_get_type()) +G_DECLARE_FINAL_TYPE(GstQgcQVideoSink, gst_qgc_q_video_sink, GST, QGC_Q_VIDEO_SINK, GstVideoSink) + +/// \brief GstVideoSink that pushes decoded frames into a Qt QVideoSink; sole sink in qgcvideosinkbin. +/// Streaming-thread vfuncs MUST NOT touch QObject members directly (thread-affinity asserts) — hand +/// off via QMetaObject::invokeMethod / bus messages. +struct _GstQgcQVideoSink +{ + GstVideoSink parent; + + // Properties backed inline; GObject's property system is the cross-thread boundary (GST_OBJECT_LOCK). + gpointer qvideosink; // QVideoSink* (caller-owned; never unref'd by us) + gboolean active; + gboolean gpu_zerocopy; + + // Cached negotiated state (set_caps writes, show_frame reads, both streaming-thread, + // serialised by GstBaseSink). `priv` holds heap C++ non-POD state so this struct stays POD. + gboolean caps_valid; + GstVideoInfo video_info; + gpointer priv; // owned (new/delete in instance_init / finalize) +}; + +G_END_DECLS + +#ifdef __cplusplus +struct HwVideoBufferContext; + +/// Install the GPU zero-copy context; pushed once from the GUI thread under GST_OBJECT_LOCK +/// so the streaming-thread show_frame snapshot stays race-free. +void gst_qgc_q_video_sink_set_hw_context(GstQgcQVideoSink* self, const HwVideoBufferContext& ctx); +#endif diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.cc b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.cc index 8c5c227b5dc3..77e499b45ce5 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.cc @@ -1,281 +1,250 @@ #include "gstqgcvideosinkbin.h" -#include "gstqgcelements.h" -#include -#include -#include -#if GST_CHECK_VERSION(1, 24, 0) -# include -#endif +#include "gstqgcelements.h" +#include +#include #include -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) -# include "../HwBuffers/GstGlContextBridge.h" -#endif +#include "GstQgcAllocation.h" +#include "GstQgcCaps.h" #define GST_CAT_DEFAULT gst_qgc_debug -#define DEFAULT_ENABLE_LAST_SAMPLE FALSE -#define DEFAULT_SYNC FALSE -#define DEFAULT_MAX_LATENESS G_GINT64_CONSTANT(-1) - -#define PROP_ENABLE_LAST_SAMPLE_NAME "enable-last-sample" -#define PROP_LAST_SAMPLE_NAME "last-sample" -#define PROP_SYNC_NAME "sync" -#define PROP_MAX_LATENESS_NAME "max-lateness" - enum { PROP_0, - PROP_ENABLE_LAST_SAMPLE, - PROP_LAST_SAMPLE, - PROP_SYNC, - PROP_MAX_LATENESS, PROP_GPU_ZEROCOPY, PROP_CONVERSION_ELEMENT, PROP_DISABLE_PAR, + PROP_SYNC, + PROP_QOS, + PROP_PROCESSING_DEADLINE, PROP_LAST }; -static GParamSpec *properties[PROP_LAST]; +static GParamSpec* properties[PROP_LAST]; -static GstStaticPadTemplate sink_factory = GST_STATIC_PAD_TEMPLATE( - "sink", - GST_PAD_SINK, - GST_PAD_ALWAYS, - GST_STATIC_CAPS_ANY); +// video/x-raw(ANY) accepts every memory feature plus system catch-all; narrows from CAPS_ANY so +// non-raw links fail at link time (not PAUSED negotiation) without dropping any zero-copy path. +static GstStaticPadTemplate sink_factory = + GST_STATIC_PAD_TEMPLATE("sink", GST_PAD_SINK, GST_PAD_ALWAYS, GST_STATIC_CAPS("video/x-raw(ANY)")); -#define gst_qgc_video_sink_bin_parent_class parent_class -G_DEFINE_TYPE(GstQgcVideoSinkBin, gst_qgc_video_sink_bin, GST_TYPE_BIN); - -GST_ELEMENT_REGISTER_DEFINE_WITH_CODE(qgcvideosinkbin,"qgcvideosinkbin", - GST_RANK_NONE, - GST_TYPE_QGC_VIDEO_SINK_BIN, - qgc_element_init(plugin)); +namespace { -static void gst_qgc_video_sink_bin_set_property(GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec); -static void gst_qgc_video_sink_bin_get_property(GObject *object, guint prop_id, GValue *value, GParamSpec *pspec); -static void gst_qgc_video_sink_bin_constructed(GObject *object); -static void gst_qgc_video_sink_bin_dispose(GObject *object); -static GstStateChangeReturn gst_qgc_video_sink_bin_change_state(GstElement *element, GstStateChange transition); - -static void -gst_qgc_video_sink_bin_class_init(GstQgcVideoSinkBinClass *klass) +// Linked element chain with automatic rollback: adopt() elements (bin takes the floating ref), +// linkChain() in flow order, ghostSink() the head, commit() on success; ~BinChain unwinds the rest. +class BinChain { - GObjectClass *object_class = G_OBJECT_CLASS(klass); - GstElementClass *element_class = GST_ELEMENT_CLASS(klass); +public: + explicit BinChain(GstBin* bin) : m_bin(bin) {} - object_class->set_property = gst_qgc_video_sink_bin_set_property; - object_class->get_property = gst_qgc_video_sink_bin_get_property; - object_class->constructed = gst_qgc_video_sink_bin_constructed; - object_class->dispose = gst_qgc_video_sink_bin_dispose; - element_class->change_state = gst_qgc_video_sink_bin_change_state; + ~BinChain() + { + if (m_committed) { + return; + } + if (m_ghost) { + gst_element_remove_pad(GST_ELEMENT(m_bin), m_ghost); + } + while (m_ownedCount > 0) { + gst_bin_remove(m_bin, m_owned[--m_ownedCount]); + } + } - properties[PROP_ENABLE_LAST_SAMPLE] = g_param_spec_boolean( - "enable-last-sample", "Enable last sample", - "Retain the most recent buffer for UI snapshotting", - DEFAULT_ENABLE_LAST_SAMPLE, - (GParamFlags)(G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS) - ); - - properties[PROP_LAST_SAMPLE] = g_param_spec_boxed( - "last-sample", "Last sample", - "Last preroll/played sample held by the sink", - GST_TYPE_SAMPLE, - (GParamFlags)(G_PARAM_READABLE | G_PARAM_STATIC_STRINGS) - ); - - properties[PROP_SYNC] = g_param_spec_boolean( - "sync", "Sync", - "Synchronise frame presentation to the pipeline clock", - DEFAULT_SYNC, - (GParamFlags)(G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS) - ); - - properties[PROP_MAX_LATENESS] = g_param_spec_int64( - "max-lateness", "Max lateness", - "Maximum number of nanoseconds a buffer can be late before it is dropped (-1 unlimited)", - -1, G_MAXINT64, - DEFAULT_MAX_LATENESS, - (GParamFlags)(G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS) - ); + BinChain(const BinChain&) = delete; + BinChain& operator=(const BinChain&) = delete; - properties[PROP_GPU_ZEROCOPY] = g_param_spec_boolean( - "gpu-zerocopy", - "GPU zero-copy", - "Enable DMABuf zero-copy path (Linux only). Construct-only.", - FALSE, - (GParamFlags)(G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); - - properties[PROP_CONVERSION_ELEMENT] = g_param_spec_string( - "conversion-element", - "Conversion element factory", - "Factory name to use for the CPU branch's color conversion. Empty/NULL = auto-probe.", - NULL, - (GParamFlags)(G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); + GstElement* adopt(GstElement* element) + { + if (!element) { + return nullptr; + } + if (m_ownedCount == m_owned.size()) { + gst_object_unref(element); + return nullptr; + } + if (!gst_bin_add(m_bin, element)) { + gst_object_unref(element); + return nullptr; + } + m_owned[m_ownedCount++] = element; + return element; + } - properties[PROP_DISABLE_PAR] = g_param_spec_boolean( - "disable-par", - "Disable PAR=1/1 capsfilter", - "Skip the pixel-aspect-ratio=1/1 capsfilter on the CPU branch (workaround for " - "v4l2 drivers without VIDIOC_CROPCAP). Construct-only.", - FALSE, - (GParamFlags)(G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); + bool linkChain(std::initializer_list chain) + { + GstElement* prev = nullptr; + for (GstElement* element : chain) { + if (!element) { + return false; + } + if (prev && !gst_element_link(prev, element)) { + return false; + } + prev = element; + } + return true; + } - g_object_class_install_properties(object_class, PROP_LAST, properties); + bool ghostSink(GstElement* head) + { + GstPad* sinkpad = gst_element_get_static_pad(head, "sink"); + if (!sinkpad) { + return false; + } + m_ghost = gst_ghost_pad_new("sink", sinkpad); + gst_object_unref(sinkpad); + if (!m_ghost) { + return false; + } + if (!gst_element_add_pad(GST_ELEMENT(m_bin), m_ghost)) { + gst_object_unref(m_ghost); + m_ghost = nullptr; + return false; + } + return true; + } - gst_element_class_add_static_pad_template(GST_ELEMENT_CLASS(klass), &sink_factory); + void commit() { m_committed = true; } - gst_element_class_set_static_metadata(element_class, - "QGC Video Sink Bin", "Sink/Video/Bin", - "Appsink-based video sink for QGroundControl (frames pushed to a QVideoSink)", - "QGroundControl team" - ); -} +private: + GstBin* m_bin; + std::array m_owned{}; + std::size_t m_ownedCount = 0; + GstPad* m_ghost = nullptr; + bool m_committed = false; +}; -// VAAPI/H.264 ref-frame queue typically 4–8; min=2 forced fallback allocations. -constexpr guint kProposedMinBuffers = 4; +} // namespace -static GstPadProbeReturn -gst_qgc_handle_allocation_query(GstQuery *query) +#ifdef QGC_GST_BUILD_TESTING +gboolean gst_qgc_video_sink_bin_rejects_failed_adopt_for_test() { - GstCaps *caps = nullptr; - gboolean need_pool = FALSE; - gst_query_parse_allocation(query, &caps, &need_pool); - if (!caps) { - return GST_PAD_PROBE_OK; + GstElement* target = gst_bin_new("qgc-adopt-target"); + GstElement* parent = gst_bin_new("qgc-adopt-existing-parent"); + GstElement* element = gst_element_factory_make("identity", "preparented"); + if (!target || !parent || !element) { + gst_clear_object(&element); + gst_clear_object(&parent); + gst_clear_object(&target); + return FALSE; } - GstVideoInfo vinfo; - // DMA_DRM caps need the dma_drm parser; plain gst_video_info_from_caps fails and the - // min-buffer hint silently disappears, leaving va on its copy-threshold pool. -#if GST_CHECK_VERSION(1, 24, 0) - if (gst_video_is_dma_drm_caps(caps)) { - GstVideoInfoDmaDrm drmInfo; - gst_video_info_dma_drm_init(&drmInfo); - if (!gst_video_info_dma_drm_from_caps(&drmInfo, caps) - || !gst_video_info_dma_drm_to_video_info(&drmInfo, &vinfo)) { - return GST_PAD_PROBE_OK; - } - } else -#endif - if (!gst_video_info_from_caps(&vinfo, caps)) { - return GST_PAD_PROBE_OK; - } - const gsize size = GST_VIDEO_INFO_SIZE(&vinfo); - - GstCapsFeatures *features = gst_caps_get_features(caps, 0); - const bool is_system_memory = !features - || gst_caps_features_is_any(features) - || gst_caps_features_is_equal(features, GST_CAPS_FEATURES_MEMORY_SYSTEM_MEMORY); - - if (!is_system_memory) { - // Advertise min-buffer hint for HW memory (DMABuf/GL/D3D/NVMM/AHB) so upstream v4l2/VA does not enable copy threshold. - gst_query_add_allocation_pool(query, NULL, size, kProposedMinBuffers, 0); - gst_query_add_allocation_meta(query, GST_VIDEO_META_API_TYPE, NULL); - return GST_PAD_PROBE_OK; + if (!gst_bin_add(GST_BIN(parent), element)) { + gst_object_unref(parent); + gst_object_unref(target); + return FALSE; } - if (!need_pool) { - return GST_PAD_PROBE_OK; - } + auto* chain = new BinChain(GST_BIN(target)); + gst_object_ref(element); + GstElement* adopted = chain->adopt(element); + chain->commit(); + delete chain; - GstBufferPool *pool = gst_buffer_pool_new(); - if (!pool) { - return GST_PAD_PROBE_OK; + GstObject* currentParent = gst_object_get_parent(GST_OBJECT(element)); + const gboolean rejected = (adopted == nullptr) && (currentParent == GST_OBJECT(parent)); + if (currentParent) { + gst_object_unref(currentParent); } - GstStructure *config = gst_buffer_pool_get_config(pool); - gst_buffer_pool_config_set_params(config, caps, size, kProposedMinBuffers, 0); - gst_buffer_pool_config_add_option(config, GST_BUFFER_POOL_OPTION_VIDEO_META); - if (gst_buffer_pool_set_config(pool, config)) { - gst_query_add_allocation_pool(query, pool, size, kProposedMinBuffers, 0); - gst_query_add_allocation_meta(query, GST_VIDEO_META_API_TYPE, NULL); - } - gst_object_unref(pool); - return GST_PAD_PROBE_OK; + gst_object_unref(parent); + gst_object_unref(target); + return rejected; } +#endif -static GstPadProbeReturn -gst_qgc_appsink_query_probe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data) -{ - (void)pad; - (void)user_data; - GstQuery *query = GST_PAD_PROBE_INFO_QUERY(info); - if (!query) { - return GST_PAD_PROBE_OK; - } +#define gst_qgc_video_sink_bin_parent_class parent_class +G_DEFINE_FINAL_TYPE(GstQgcVideoSinkBin, gst_qgc_video_sink_bin, GST_TYPE_BIN); - switch (GST_QUERY_TYPE(query)) { - case GST_QUERY_ALLOCATION: - return gst_qgc_handle_allocation_query(query); - case GST_QUERY_CONTEXT: -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - // Synchronous answer for gst.gl.GLDisplay/app_context — bus NEED_CONTEXT fallback races state changes and can isolate glupload from Qt's RHI context. - if (GstGlContextBridge::answerContextQuery(query)) { - return GST_PAD_PROBE_HANDLED; - } -#endif - return GST_PAD_PROBE_OK; - default: - return GST_PAD_PROBE_OK; - } -} +GST_ELEMENT_REGISTER_DEFINE_WITH_CODE(qgcvideosinkbin, "qgcvideosinkbin", GST_RANK_NONE, GST_TYPE_QGC_VIDEO_SINK_BIN, + qgc_element_init(plugin)); -static gboolean -gst_qgc_video_sink_bin_ghost_pad(GstQgcVideoSinkBin *self, GstElement *inner) +static void gst_qgc_video_sink_bin_set_property(GObject* object, guint prop_id, const GValue* value, GParamSpec* pspec); +static void gst_qgc_video_sink_bin_get_property(GObject* object, guint prop_id, GValue* value, GParamSpec* pspec); +static void gst_qgc_video_sink_bin_constructed(GObject* object); +static void gst_qgc_video_sink_bin_dispose(GObject* object); +static GstStateChangeReturn gst_qgc_video_sink_bin_change_state(GstElement* element, GstStateChange transition); + +static void gst_qgc_video_sink_bin_class_init(GstQgcVideoSinkBinClass* klass) { - GstPad *sinkpad = gst_element_get_static_pad(inner, "sink"); - if (!sinkpad) { - GST_ERROR_OBJECT(self, "gst_element_get_static_pad('sink') failed"); - return FALSE; - } + GObjectClass* object_class = G_OBJECT_CLASS(klass); + GstElementClass* element_class = GST_ELEMENT_CLASS(klass); - GstPad *ghostpad = gst_ghost_pad_new("sink", sinkpad); - gst_object_unref(sinkpad); - if (!ghostpad) { - GST_ERROR_OBJECT(self, "gst_ghost_pad_new('sink') failed"); - return FALSE; - } + object_class->set_property = gst_qgc_video_sink_bin_set_property; + object_class->get_property = gst_qgc_video_sink_bin_get_property; + object_class->constructed = gst_qgc_video_sink_bin_constructed; + object_class->dispose = gst_qgc_video_sink_bin_dispose; + element_class->change_state = gst_qgc_video_sink_bin_change_state; - if (!gst_element_add_pad(GST_ELEMENT(self), ghostpad)) { - GST_ERROR_OBJECT(self, "gst_element_add_pad() failed"); - gst_object_unref(ghostpad); - return FALSE; - } + properties[PROP_GPU_ZEROCOPY] = g_param_spec_boolean( + "gpu-zerocopy", "GPU zero-copy", "Enable the platform GPU zero-copy path. Construct-only.", FALSE, + (GParamFlags) (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); - return TRUE; + properties[PROP_CONVERSION_ELEMENT] = + g_param_spec_string("conversion-element", "Conversion element factory", + "Factory name to use for the CPU branch's color conversion. Empty/NULL = auto-probe.", NULL, + (GParamFlags) (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); + + properties[PROP_DISABLE_PAR] = g_param_spec_boolean( + "disable-par", "Disable PAR=1/1 capsfilter", + "Skip the pixel-aspect-ratio=1/1 capsfilter on the CPU branch (workaround for " + "v4l2 drivers without VIDIOC_CROPCAP). Construct-only.", + FALSE, (GParamFlags) (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); + + properties[PROP_SYNC] = + g_param_spec_boolean("sync", "Sync", "Proxied to the inner basesink: sync rendering to the clock.", FALSE, + (GParamFlags) (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS)); + + // Opt-in QoS: default FALSE leaves the existing no-QoS behavior unchanged (sync=FALSE means the + // basesink generates no QoS events anyway until a caller enables it). + properties[PROP_QOS] = + g_param_spec_boolean("qos", "QoS", "Proxied to the inner basesink: generate QoS events upstream.", FALSE, + (GParamFlags) (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS)); + + properties[PROP_PROCESSING_DEADLINE] = g_param_spec_uint64( + "processing-deadline", "Processing deadline", + "Proxied to the inner basesink: maximum buffer processing time in nanoseconds.", 0, G_MAXUINT64, + G_GUINT64_CONSTANT(20000000), (GParamFlags) (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS)); + + g_object_class_install_properties(object_class, PROP_LAST, properties); + + gst_element_class_add_static_pad_template(GST_ELEMENT_CLASS(klass), &sink_factory); + + gst_element_class_set_static_metadata(element_class, "QGC Video Sink Bin", "Sink/Video/Bin", + "QVideoSink-based video sink for QGroundControl", "QGroundControl team"); } -static void -gst_qgc_video_sink_bin_init(GstQgcVideoSinkBin *self) +static void gst_qgc_video_sink_bin_init(GstQgcVideoSinkBin* self) { self->videoconvert = NULL; self->glupload = NULL; - self->appsink = NULL; + self->videosink = NULL; + self->format_capsfilter = NULL; self->par_capsfilter = NULL; self->gpu_zerocopy = FALSE; self->conversion_element = NULL; self->disable_par = FALSE; + self->sync = FALSE; + self->qos = FALSE; + self->processing_deadline = G_GUINT64_CONSTANT(20000000); } // Probe: caller override → SoC-native (imxvideoconvert_g2d/nvvidconv) → videoconvert. -static GstElement * -gst_qgc_video_sink_bin_make_conversion_element(GstQgcVideoSinkBin *self) +static GstElement* gst_qgc_video_sink_bin_make_conversion_element(GstQgcVideoSinkBin* self) { if (self->conversion_element && self->conversion_element[0] != '\0') { - GstElement *e = gst_element_factory_make(self->conversion_element, NULL); + GstElement* e = gst_element_factory_make(self->conversion_element, NULL); if (e) { GST_INFO_OBJECT(self, "Using conversion-element override '%s'", self->conversion_element); return e; } - GST_WARNING_OBJECT(self, - "conversion-element='%s' factory missing — falling through to defaults", - self->conversion_element); + GST_WARNING_OBJECT(self, "conversion-element='%s' factory missing — falling through to defaults", + self->conversion_element); } - static const char *kSoCFactories[] = { "imxvideoconvert_g2d", "nvvidconv", NULL }; + static const char* kSoCFactories[] = {"imxvideoconvert_g2d", "nvvidconv", NULL}; for (int i = 0; kSoCFactories[i] != NULL; ++i) { - if (GstElement *e = gst_element_factory_make(kSoCFactories[i], NULL)) { + if (GstElement* e = gst_element_factory_make(kSoCFactories[i], NULL)) { GST_INFO_OBJECT(self, "Using SoC conversion element '%s'", kSoCFactories[i]); return e; } @@ -283,356 +252,285 @@ gst_qgc_video_sink_bin_make_conversion_element(GstQgcVideoSinkBin *self) return gst_element_factory_make("videoconvert", NULL); } -static void -gst_qgc_video_sink_bin_setup(GstQgcVideoSinkBin *self) +// Wire format-capsfilter -> qgcqvideosink (optional glupload prefix); self pointers set only on commit. +static gboolean wireGpuPath(GstQgcVideoSinkBin* self, GstElement* videosink, GstElement* capsf) { - self->appsink = gst_element_factory_make("appsink", "qgcappsink"); - if (!self->appsink) { - GST_ERROR_OBJECT(self, "Failed to create appsink element"); - return; + BinChain chain(GST_BIN(self)); + if (!chain.adopt(videosink)) { + gst_object_unref(capsf); + GST_ERROR_OBJECT(self, "Failed to add qgcqvideosink GPU sink element to bin"); + return FALSE; + } + if (!chain.adopt(capsf)) { + GST_ERROR_OBJECT(self, "Failed to add qgcqvideosink GPU capsfilter element to bin"); + return FALSE; } - // Attach probe before the gpu/cpu branch so both paths share the same downstream query handler. - if (GstPad *appsinkPad = gst_element_get_static_pad(self->appsink, "sink")) { - // probe_id == 0 means add failed (e.g. pad in dispose) — without this probe, GL-context queries fall back to the slower bus NEED_CONTEXT path that races with state changes. - const gulong probe_id = gst_pad_add_probe(appsinkPad, GST_PAD_PROBE_TYPE_QUERY_DOWNSTREAM, - gst_qgc_appsink_query_probe, NULL, NULL); - if (probe_id == 0) { - GST_WARNING_OBJECT(self, "gst_pad_add_probe(QUERY_DOWNSTREAM) returned 0 — appsink query interception disabled"); - } - gst_object_unref(appsinkPad); + const std::string capsStr = GstQgc::buildGpuCapsString(); + GstCaps* caps = gst_caps_from_string(capsStr.c_str()); + if (!caps) { + GST_ERROR_OBJECT(self, "gst_caps_from_string() returned NULL for GPU caps"); + return FALSE; } + // format_capsfilter restricts negotiation to Qt-renderable formats; qgcqvideosink's pad + // template is CAPS_ANY, so without it upstream could pick a format that fails in set_caps. + g_object_set(capsf, "caps", caps, NULL); + gst_caps_unref(caps); - if (self->gpu_zerocopy) { - // List only features the build can consume zero-copy; stale features waste a caps-intersection pass on every link. - // Y444 omitted: Qt 6.10 has no Format_YUV444* and toQtPixelFormat returns Invalid - // → onNewSample errors out. Re-add when Qt grows the enum. - static constexpr const char kFormats[] = - "{ NV12, NV21, I420, YV12, Y42B, P010_10LE, AYUV, YUY2, UYVY, " - "GRAY8, GRAY16_LE, BGRA, RGBA }"; - std::string capsStr; #if defined(QGC_GST_BIN_USE_GLUPLOAD) - // Linux desktop: va decoder produces DMA_DRM DMABuf which appsink can't consume directly; routing through glupload imports DMABuf into GL textures (still zero-copy via EGLImage) and feeds GLMemory to the appsink, which the adapter unwraps via GstGlVideoBuffer. - capsStr = "video/x-raw(memory:GLMemory), format="; - capsStr += kFormats; + GstElement* glupload = chain.adopt(gst_element_factory_make("glupload", NULL)); + if (!glupload) { + GST_ERROR_OBJECT(self, "Failed to create glupload element"); + return FALSE; + } + // Converts amcvideodec's external-OES Surface textures to GL_TEXTURE_2D (else the Qt RHI 2D sink + // samples them black); no-op passthrough when upstream is already 2D (Linux va/DMABuf). + GstElement* glcolorconvert = chain.adopt(gst_element_factory_make("glcolorconvert", NULL)); + if (!glcolorconvert) { + GST_ERROR_OBJECT(self, "Failed to create glcolorconvert element"); + return FALSE; + } + if (!chain.linkChain({glupload, glcolorconvert, capsf, videosink}) || !chain.ghostSink(glupload)) { + GST_ERROR_OBJECT(self, "Failed to link/ghost glupload→glcolorconvert→capsfilter→qgcqvideosink GPU path"); + return FALSE; + } #else -# if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - // Legacy memory:DMABuf,format=NV12 only — covers v4l2h264dec / Mali / V3D LINEAR - // DMABuf paths. DMA_DRM caps deliberately omitted: gst-va on Intel iHD negotiates - // tiled+CCS layouts that crash both GPU and CPU paths. The system catch-all below - // routes va to GstVaMemory whose map() detiles via libva. - capsStr += "video/x-raw(memory:DMABuf), format="; - capsStr += kFormats; - capsStr += "; "; -# endif -# if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) && !defined(QGC_GST_BIN_USE_DMABUF) - // No glupload in USE_DMABUF bin — offering GLMemory lets upstream try (and fail) it. - capsStr += "video/x-raw(memory:GLMemory), format="; - capsStr += kFormats; - capsStr += "; "; -# endif -# if defined(QGC_HAS_GST_D3D11_GPU_PATH) - capsStr += "video/x-raw(memory:D3D11Memory), format="; - capsStr += kFormats; - capsStr += "; "; -# endif -# if defined(QGC_HAS_GST_D3D12_GPU_PATH) - capsStr += "video/x-raw(memory:D3D12Memory), format="; - capsStr += kFormats; - capsStr += "; "; -# endif -# if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) - capsStr += "video/x-raw(memory:AHardwareBuffer), format="; - capsStr += kFormats; - capsStr += "; "; -# endif - // System catch-all required: dropping it returns GST_PAD_LINK_NOFORMAT (-4) when - // upstream offers only system caps, and the receiver tears down. - capsStr += "video/x-raw, format="; - capsStr += kFormats; -#endif - GstCaps *caps = gst_caps_from_string(capsStr.c_str()); - if (!caps) { - GST_ERROR_OBJECT(self, "gst_caps_from_string() returned NULL for GPU caps"); - gst_clear_object(&self->appsink); - return; - } - // emit-signals=FALSE: GstAppSinkAdapter installs callbacks via gst_app_sink_set_callbacks() — flipping this to TRUE silently breaks frame delivery (samples queue with no consumer). - g_object_set(self->appsink, - "caps", caps, - "emit-signals", FALSE, - "max-buffers", 1, - "drop", TRUE, - "sync", FALSE, - "wait-on-eos", FALSE, - NULL); - gst_caps_unref(caps); - -#if defined(QGC_GST_BUILD_VERSION_MAJOR) && \ - (QGC_GST_BUILD_VERSION_MAJOR > 1 || \ - (QGC_GST_BUILD_VERSION_MAJOR == 1 && QGC_GST_BUILD_VERSION_MINOR >= 24)) - gst_app_sink_set_max_time(GST_APP_SINK(self->appsink), 33 * GST_MSECOND); + GstElement* glupload = nullptr; + if (!chain.linkChain({capsf, videosink}) || !chain.ghostSink(capsf)) { + GST_ERROR_OBJECT(self, "Failed to link/ghost qgcqvideosink (GPU path)"); + return FALSE; + } #endif + chain.commit(); + self->glupload = glupload; + self->format_capsfilter = capsf; + self->videosink = videosink; + #if defined(QGC_GST_BIN_USE_GLUPLOAD) - self->glupload = gst_element_factory_make("glupload", NULL); - if (!self->glupload) { - GST_ERROR_OBJECT(self, "Failed to create glupload element"); - gst_clear_object(&self->appsink); - return; - } - gst_bin_add_many(GST_BIN(self), self->glupload, self->appsink, NULL); - if (!gst_element_link(self->glupload, self->appsink) - || !gst_qgc_video_sink_bin_ghost_pad(self, self->glupload)) { - GST_ERROR_OBJECT(self, "Failed to link/ghost glupload→appsink GPU path"); - gst_bin_remove(GST_BIN(self), self->glupload); - gst_bin_remove(GST_BIN(self), self->appsink); - self->glupload = NULL; - self->appsink = NULL; - return; - } - GST_INFO_OBJECT(self, "Using glupload→appsink GPU path (DMABuf→GL EGLImage import)"); + GST_INFO_OBJECT(self, "Using glupload→qgcqvideosink GPU path (DMABuf→GL EGLImage import)"); +#elif defined(QGC_GST_BIN_USE_DMABUF) + GST_INFO_OBJECT(self, "Using qgcqvideosink GPU path (direct DMABuf import, no glupload)"); #else - gst_bin_add(GST_BIN(self), self->appsink); - if (!gst_qgc_video_sink_bin_ghost_pad(self, self->appsink)) { - GST_ERROR_OBJECT(self, "Failed to ghost-pad appsink (GPU path)"); - gst_bin_remove(GST_BIN(self), self->appsink); - self->appsink = NULL; - return; - } -# if defined(QGC_GST_BIN_USE_DMABUF) - GST_INFO_OBJECT(self, "Using appsink GPU path (direct DMABuf import, no glupload)"); -# else - GST_INFO_OBJECT(self, "Using appsink GPU path (native memory passthrough)"); -# endif + GST_INFO_OBJECT(self, "Using qgcqvideosink GPU path (native memory passthrough)"); #endif - } else { - self->videoconvert = gst_qgc_video_sink_bin_make_conversion_element(self); - if (!self->videoconvert) { - GST_ERROR_OBJECT(self, "Failed to create video conversion element"); - gst_clear_object(&self->appsink); - return; - } + return TRUE; +} - // QVideoSink renders these natively; listing them avoids forcing videoconvert to BGRA. - GstCaps *caps = gst_caps_from_string( - "video/x-raw,format={ NV12, NV21, I420, YV12, Y42B, P010_10LE, " - "AYUV, YUY2, UYVY, GRAY8, GRAY16_LE, BGRA, RGBA }"); - if (!caps) { - GST_ERROR_OBJECT(self, "gst_caps_from_string() returned NULL for CPU caps"); - gst_clear_object(&self->videoconvert); - gst_clear_object(&self->appsink); - return; - } - // emit-signals=FALSE: GstAppSinkAdapter installs callbacks via gst_app_sink_set_callbacks() — flipping this to TRUE silently breaks frame delivery (samples queue with no consumer). - g_object_set(self->appsink, - "caps", caps, - "emit-signals", FALSE, - "max-buffers", 1, - "drop", TRUE, - "sync", FALSE, - "wait-on-eos", FALSE, - NULL); - gst_caps_unref(caps); - -#if defined(QGC_GST_BUILD_VERSION_MAJOR) && \ - (QGC_GST_BUILD_VERSION_MAJOR > 1 || \ - (QGC_GST_BUILD_VERSION_MAJOR == 1 && QGC_GST_BUILD_VERSION_MINOR >= 24)) - gst_app_sink_set_max_time(GST_APP_SINK(self->appsink), 33 * GST_MSECOND); -#endif +// Wire videoconvert -> (optional PAR=1/1) -> format-capsfilter -> qgcqvideosink; self pointers set only on commit. +static gboolean wireCpuPath(GstQgcVideoSinkBin* self, GstElement* videosink, GstElement* capsf) +{ + BinChain chain(GST_BIN(self)); + if (!chain.adopt(videosink)) { + gst_object_unref(capsf); + GST_ERROR_OBJECT(self, "Failed to add qgcqvideosink CPU sink element to bin"); + return FALSE; + } + if (!chain.adopt(capsf)) { + GST_ERROR_OBJECT(self, "Failed to add qgcqvideosink CPU capsfilter element to bin"); + return FALSE; + } - // PAR=1/1 capsfilter normalizes non-square-pixel sources. disable-par for v4l2 - // drivers without VIDIOC_CROPCAP that deadlock negotiation (GStreamer MR #6242). - if (!self->disable_par) { - self->par_capsfilter = gst_element_factory_make("capsfilter", NULL); - if (!self->par_capsfilter) { - GST_WARNING_OBJECT(self, "capsfilter factory missing — PAR normalization disabled"); - } else { - GstCaps *par_caps = gst_caps_from_string( - "video/x-raw, pixel-aspect-ratio=(fraction)1/1"); - g_object_set(self->par_capsfilter, "caps", par_caps, NULL); - gst_caps_unref(par_caps); - } - } + GstElement* videoconvert = chain.adopt(gst_qgc_video_sink_bin_make_conversion_element(self)); + if (!videoconvert) { + GST_ERROR_OBJECT(self, "Failed to create video conversion element"); + return FALSE; + } - if (self->par_capsfilter) { - gst_bin_add_many(GST_BIN(self), self->videoconvert, self->par_capsfilter, - self->appsink, NULL); - if (!gst_element_link_many(self->videoconvert, self->par_capsfilter, self->appsink, NULL) - || !gst_qgc_video_sink_bin_ghost_pad(self, self->videoconvert)) { - GST_ERROR_OBJECT(self, "Failed to link/ghost appsink path (with PAR filter)"); - gst_bin_remove(GST_BIN(self), self->videoconvert); - gst_bin_remove(GST_BIN(self), self->par_capsfilter); - gst_bin_remove(GST_BIN(self), self->appsink); - self->videoconvert = NULL; - self->par_capsfilter = NULL; - self->appsink = NULL; - return; - } + // QVideoSink renders these natively; listing them avoids forcing videoconvert to BGRA. + GstCaps* caps = gst_caps_from_string(GstQgc::buildCpuCapsString().c_str()); + if (!caps) { + GST_ERROR_OBJECT(self, "gst_caps_from_string() returned NULL for CPU caps"); + return FALSE; + } + g_object_set(capsf, "caps", caps, NULL); + gst_caps_unref(caps); + + // PAR=1/1 capsfilter normalizes non-square pixels; disable-par for v4l2 drivers without + // VIDIOC_CROPCAP that deadlock negotiation (GStreamer MR #6242). + GstElement* par = nullptr; + if (!self->disable_par) { + par = chain.adopt(gst_element_factory_make("capsfilter", "qgc-par-filter")); + if (!par) { + GST_WARNING_OBJECT(self, "capsfilter factory missing — PAR normalization disabled"); } else { - gst_bin_add_many(GST_BIN(self), self->videoconvert, self->appsink, NULL); - if (!gst_element_link(self->videoconvert, self->appsink) - || !gst_qgc_video_sink_bin_ghost_pad(self, self->videoconvert)) { - GST_ERROR_OBJECT(self, "Failed to link/ghost appsink path"); - gst_bin_remove(GST_BIN(self), self->videoconvert); - gst_bin_remove(GST_BIN(self), self->appsink); - self->videoconvert = NULL; - self->appsink = NULL; - return; + GstCaps* parCaps = gst_caps_from_string("video/x-raw, pixel-aspect-ratio=(fraction)1/1"); + if (parCaps) { + g_object_set(par, "caps", parCaps, NULL); + gst_caps_unref(parCaps); } } + } + + const bool linked = par ? chain.linkChain({videoconvert, par, capsf, videosink}) + : chain.linkChain({videoconvert, capsf, videosink}); + if (!linked || !chain.ghostSink(videoconvert)) { + GST_ERROR_OBJECT(self, "Failed to link/ghost CPU path"); + return FALSE; + } + + chain.commit(); + self->videoconvert = videoconvert; + self->par_capsfilter = par; + self->format_capsfilter = capsf; + self->videosink = videosink; + + GST_INFO_OBJECT(self, "Using qgcqvideosink CPU path (videoconvert%s → caps → qgcqvideosink → QVideoSink)", + par ? " → PAR=1/1" : ""); + return TRUE; +} + +static void gst_qgc_video_sink_bin_setup(GstQgcVideoSinkBin* self) +{ + // gpu-zerocopy is construct-only — pass it to construction; g_object_set after the fact is rejected. + GstElement* videosink = gst_element_factory_make_full("qgcqvideosink", "name", "qgcqvideosink", "gpu-zerocopy", + self->gpu_zerocopy, NULL); + if (!videosink) { + GST_ERROR_OBJECT(self, "Failed to create qgcqvideosink element"); + return; + } + g_object_set(videosink, "active", (gboolean) TRUE, "sync", (gboolean) self->sync, "qos", (gboolean) self->qos, + "processing-deadline", self->processing_deadline, NULL); + + GstElement* capsf = gst_element_factory_make("capsfilter", "qgc-format-filter"); + if (!capsf) { + GST_ERROR_OBJECT(self, "Failed to create capsfilter for qgcqvideosink"); + gst_clear_object(&videosink); + return; + } - GST_INFO_OBJECT(self, "Using appsink (videoconvert%s → appsink → QVideoSink)", - self->par_capsfilter ? " → PAR=1/1" : ""); + // On failure the wire*Path() BinChain rolls the bin back and frees videosink/capsf; self + // pointers stay NULL so change_state surfaces the construction error. + if (!(self->gpu_zerocopy ? wireGpuPath(self, videosink, capsf) : wireCpuPath(self, videosink, capsf))) { + return; + } + + // Probe the videosink sink pad so ALLOCATION/CONTEXT queries terminate here instead of racing + // the bus NEED_CONTEXT fallback. Installed post-commit so a wire failure leaves no orphan probe. + if (GstPad* videosinkPad = gst_element_get_static_pad(videosink, "sink")) { + const gulong probe_id = gst_pad_add_probe(videosinkPad, GST_PAD_PROBE_TYPE_QUERY_DOWNSTREAM, + GstQgc::videosinkQueryProbe, NULL, NULL); + if (probe_id == 0) { + GST_WARNING_OBJECT( + self, "gst_pad_add_probe(QUERY_DOWNSTREAM) returned 0 — qgcqvideosink query interception disabled"); + } + gst_object_unref(videosinkPad); } } -static void -gst_qgc_video_sink_bin_constructed(GObject *object) +static void gst_qgc_video_sink_bin_constructed(GObject* object) { G_OBJECT_CLASS(gst_qgc_video_sink_bin_parent_class)->constructed(object); gst_qgc_video_sink_bin_setup(GST_QGC_VIDEO_SINK_BIN(object)); } -// GstBin's dispose unrefs all child elements; our cached self->appsink/videoconvert/glupload pointers -// would then dangle. NULL them BEFORE chaining so any concurrent property accessor (which checks -// G_LIKELY(self->appsink)) sees NULL instead of touching freed memory. -static void -gst_qgc_video_sink_bin_dispose(GObject *object) +// GstBin dispose unrefs children; NULL our cached pointers BEFORE chaining so a concurrent +// property accessor sees NULL instead of freed memory. +static void gst_qgc_video_sink_bin_dispose(GObject* object) { - GstQgcVideoSinkBin *self = GST_QGC_VIDEO_SINK_BIN(object); - self->appsink = NULL; + GstQgcVideoSinkBin* self = GST_QGC_VIDEO_SINK_BIN(object); + self->videosink = NULL; self->videoconvert = NULL; self->par_capsfilter = NULL; + self->format_capsfilter = NULL; self->glupload = NULL; g_clear_pointer(&self->conversion_element, g_free); G_OBJECT_CLASS(gst_qgc_video_sink_bin_parent_class)->dispose(object); } -// Surfaces _setup() failures to the parent pipeline's bus on NULL→READY; without this the bin sits half-constructed (no ghost pad) and the parent reports a generic "no compatible pad" instead of the real cause logged at construction time. -static GstStateChangeReturn -gst_qgc_video_sink_bin_change_state(GstElement *element, GstStateChange transition) +// Surfaces _setup() failures to the parent bus on NULL->READY; without it the bin sits without +// a ghost pad and the parent reports a generic "no compatible pad" instead of the real cause. +static GstStateChangeReturn gst_qgc_video_sink_bin_change_state(GstElement* element, GstStateChange transition) { - GstQgcVideoSinkBin *self = GST_QGC_VIDEO_SINK_BIN(element); - if (transition == GST_STATE_CHANGE_NULL_TO_READY && !self->appsink) { + GstQgcVideoSinkBin* self = GST_QGC_VIDEO_SINK_BIN(element); + if (transition == GST_STATE_CHANGE_NULL_TO_READY && !self->videosink) { GST_ELEMENT_ERROR(self, RESOURCE, NOT_FOUND, - ("qgcvideosinkbin construction failed; cannot transition to READY"), - ("see prior GST_ERROR messages from this element for the underlying cause")); + ("qgcvideosinkbin construction failed; cannot transition to READY"), + ("see prior GST_ERROR messages from this element for the underlying cause")); return GST_STATE_CHANGE_FAILURE; } return GST_ELEMENT_CLASS(gst_qgc_video_sink_bin_parent_class)->change_state(element, transition); } -static void -gst_qgc_video_sink_bin_set_property(GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) +static void gst_qgc_video_sink_bin_set_property(GObject* object, guint prop_id, const GValue* value, GParamSpec* pspec) { - GstQgcVideoSinkBin *self = GST_QGC_VIDEO_SINK_BIN(object); + GstQgcVideoSinkBin* self = GST_QGC_VIDEO_SINK_BIN(object); switch (prop_id) { - case PROP_ENABLE_LAST_SAMPLE: - if (G_LIKELY(self->appsink)) - g_object_set(self->appsink, - PROP_ENABLE_LAST_SAMPLE_NAME, - g_value_get_boolean(value), - NULL); - break; - case PROP_SYNC: - if (G_LIKELY(self->appsink)) - g_object_set(self->appsink, - PROP_SYNC_NAME, - g_value_get_boolean(value), - NULL); - break; - case PROP_MAX_LATENESS: - if (G_LIKELY(self->appsink)) - g_object_set(self->appsink, - PROP_MAX_LATENESS_NAME, - g_value_get_int64(value), - NULL); - break; - case PROP_GPU_ZEROCOPY: - self->gpu_zerocopy = g_value_get_boolean(value); - break; - case PROP_CONVERSION_ELEMENT: - g_free(self->conversion_element); - self->conversion_element = g_value_dup_string(value); - break; - case PROP_DISABLE_PAR: - self->disable_par = g_value_get_boolean(value); - break; - default: - G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec); - break; + case PROP_GPU_ZEROCOPY: + self->gpu_zerocopy = g_value_get_boolean(value); + break; + case PROP_CONVERSION_ELEMENT: + GST_OBJECT_LOCK(self); + g_free(self->conversion_element); + self->conversion_element = g_value_dup_string(value); + GST_OBJECT_UNLOCK(self); + break; + case PROP_DISABLE_PAR: + self->disable_par = g_value_get_boolean(value); + break; + case PROP_SYNC: + self->sync = g_value_get_boolean(value); + if (self->videosink) + g_object_set(self->videosink, "sync", (gboolean) self->sync, NULL); + break; + case PROP_QOS: + self->qos = g_value_get_boolean(value); + if (self->videosink) + g_object_set(self->videosink, "qos", (gboolean) self->qos, NULL); + break; + case PROP_PROCESSING_DEADLINE: + self->processing_deadline = g_value_get_uint64(value); + if (self->videosink) + g_object_set(self->videosink, "processing-deadline", self->processing_deadline, NULL); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec); + break; } } -static void -gst_qgc_video_sink_bin_get_property(GObject *object, guint prop_id, GValue *value, GParamSpec *pspec) +static void gst_qgc_video_sink_bin_get_property(GObject* object, guint prop_id, GValue* value, GParamSpec* pspec) { - GstQgcVideoSinkBin *self = GST_QGC_VIDEO_SINK_BIN(object); + GstQgcVideoSinkBin* self = GST_QGC_VIDEO_SINK_BIN(object); switch (prop_id) { - case PROP_ENABLE_LAST_SAMPLE: { - gboolean enable = FALSE; - if (G_LIKELY(self->appsink)) - g_object_get(self->appsink, - PROP_ENABLE_LAST_SAMPLE_NAME, - &enable, - NULL); - g_value_set_boolean(value, enable); - break; - } - case PROP_LAST_SAMPLE: { - GstSample *sample = NULL; - if (G_LIKELY(self->appsink)) - g_object_get(self->appsink, - PROP_LAST_SAMPLE_NAME, - &sample, - NULL); - if (sample) { - gst_value_set_sample(value, sample); - gst_sample_unref(sample); - } - break; - } - case PROP_SYNC: { - gboolean enable = FALSE; - if (G_LIKELY(self->appsink)) - g_object_get(self->appsink, - PROP_SYNC_NAME, - &enable, - NULL); - g_value_set_boolean(value, enable); - break; - } - case PROP_MAX_LATENESS: { - gint64 lateness = DEFAULT_MAX_LATENESS; - if (G_LIKELY(self->appsink)) - g_object_get(self->appsink, - PROP_MAX_LATENESS_NAME, - &lateness, - NULL); - g_value_set_int64(value, lateness); - break; - } - case PROP_GPU_ZEROCOPY: - g_value_set_boolean(value, self->gpu_zerocopy); - break; - case PROP_CONVERSION_ELEMENT: - g_value_set_string(value, self->conversion_element); - break; - case PROP_DISABLE_PAR: - g_value_set_boolean(value, self->disable_par); - break; - default: - G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec); - break; + case PROP_GPU_ZEROCOPY: + g_value_set_boolean(value, self->gpu_zerocopy); + break; + case PROP_CONVERSION_ELEMENT: + GST_OBJECT_LOCK(self); + g_value_set_string(value, self->conversion_element); + GST_OBJECT_UNLOCK(self); + break; + case PROP_DISABLE_PAR: + g_value_set_boolean(value, self->disable_par); + break; + case PROP_SYNC: + g_value_set_boolean(value, self->sync); + break; + case PROP_QOS: + g_value_set_boolean(value, self->qos); + break; + case PROP_PROCESSING_DEADLINE: + g_value_set_uint64(value, self->processing_deadline); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec); + break; } } -GstElement * -gst_qgc_video_sink_bin_get_appsink(GstQgcVideoSinkBin *self) +GstElement* gst_qgc_video_sink_bin_get_qvideosink(GstQgcVideoSinkBin* self) { g_return_val_if_fail(GST_IS_QGC_VIDEO_SINK_BIN(self), NULL); - return self->appsink ? GST_ELEMENT(gst_object_ref(self->appsink)) : NULL; + return self->videosink ? GST_ELEMENT(gst_object_ref(self->videosink)) : NULL; +} + +gboolean gst_qgc_video_sink_bin_get_gpu_zerocopy(GstElement* bin) +{ + if (!bin || !GST_IS_QGC_VIDEO_SINK_BIN(bin)) { + return FALSE; + } + return GST_QGC_VIDEO_SINK_BIN(bin)->gpu_zerocopy; } diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.h b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.h index aedcc16e516f..ec3b3da6522d 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.h +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.h @@ -5,28 +5,42 @@ G_BEGIN_DECLS #define GST_TYPE_QGC_VIDEO_SINK_BIN (gst_qgc_video_sink_bin_get_type()) -G_DECLARE_FINAL_TYPE (GstQgcVideoSinkBin, gst_qgc_video_sink_bin, GST, QGC_VIDEO_SINK_BIN, GstBin) +G_DECLARE_FINAL_TYPE(GstQgcVideoSinkBin, gst_qgc_video_sink_bin, GST, QGC_VIDEO_SINK_BIN, GstBin) struct _GstQgcVideoSinkBin { GstBin parent; - GstElement *videoconvert; - GstElement *glupload; - GstElement *appsink; - /// PAR=1/1 capsfilter inserted between videoconvert and appsink on the CPU branch. - /// Suppressed when the disable-par construct property is TRUE (workaround for v4l2 - /// drivers without VIDIOC_CROPCAP that deadlock negotiation when PAR is forced). - GstElement *par_capsfilter; + GstElement* videoconvert; + GstElement* glupload; + /// qgcqvideosink terminal element; caller-set "qvideosink" property targets a QVideoSink. + GstElement* videosink; + /// Format-restriction capsfilter before videosink; qgcqvideosink's pad template is CAPS_ANY, + /// so without it upstream could negotiate formats Qt cannot render. + GstElement* format_capsfilter; + /// PAR=1/1 capsfilter on the CPU branch; suppressed when disable-par is TRUE (v4l2 drivers + /// without VIDIOC_CROPCAP deadlock when PAR is forced). + GstElement* par_capsfilter; gboolean gpu_zerocopy; - /// Construct-only override for the CPU branch's videoconvert factory. Empty/NULL - /// means auto-probe (SoC-native imxvideoconvert_g2d / nvvidconv → videoconvert). - gchar *conversion_element; + /// Construct-only videoconvert factory override; empty/NULL auto-probes + /// (imxvideoconvert_g2d/nvvidconv -> videoconvert). + gchar* conversion_element; gboolean disable_par; + /// Proxied to the inner basesink's "sync" so callers can configure clock sync on the bin. + gboolean sync; + /// Proxied to the inner basesink's "qos"; default FALSE (off) preserves the existing no-QoS behavior. + gboolean qos; + /// Proxied to the inner basesink's "processing-deadline" (ns); default matches the basesink default. + guint64 processing_deadline; }; -/// Returns the internal appsink element with a ref. Caller unrefs (transfer-full, -/// matching gst_bin_get_by_name semantics). Returns NULL if the bin is not -/// fully constructed yet. -GstElement *gst_qgc_video_sink_bin_get_appsink(GstQgcVideoSinkBin *self); +/// Returns the internal qgcqvideosink element, transfer-full (caller unrefs); NULL if not yet constructed. +GstElement* gst_qgc_video_sink_bin_get_qvideosink(GstQgcVideoSinkBin* self); + +/// Whether the bin built its GPU zero-copy pipeline (mirrors "gpu-zerocopy"); NULL-safe (FALSE). +gboolean gst_qgc_video_sink_bin_get_gpu_zerocopy(GstElement* bin); + +#ifdef QGC_GST_BUILD_TESTING +gboolean gst_qgc_video_sink_bin_rejects_failed_adopt_for_test(); +#endif G_END_DECLS diff --git a/src/VideoManager/VideoReceiver/Offscreen/CMakeLists.txt b/src/VideoManager/VideoReceiver/Offscreen/CMakeLists.txt new file mode 100644 index 000000000000..3c8e6cc4a844 --- /dev/null +++ b/src/VideoManager/VideoReceiver/Offscreen/CMakeLists.txt @@ -0,0 +1,13 @@ +# ============================================================================ +# Offscreen QML Compositor +# QQuickRenderControl-based offscreen renderer for PiP / headless compositing. +# Reusable component; not wired into the live UI. Depends on rhi/qrhi.h (GuiPrivate). +# ============================================================================ + +target_sources(${CMAKE_PROJECT_NAME} + PRIVATE + QGCOffscreenRenderer.cc + QGCOffscreenRenderer.h +) + +target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/VideoManager/VideoReceiver/Offscreen/QGCOffscreenRenderer.cc b/src/VideoManager/VideoReceiver/Offscreen/QGCOffscreenRenderer.cc new file mode 100644 index 000000000000..3d6cd3e33b4b --- /dev/null +++ b/src/VideoManager/VideoReceiver/Offscreen/QGCOffscreenRenderer.cc @@ -0,0 +1,174 @@ +#include "QGCOffscreenRenderer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(QGCOffscreenRendererLog, "Video.QGCOffscreenRenderer") + +QGCOffscreenRenderer::QGCOffscreenRenderer(QObject* parent) : QObject(parent) {} + +QGCOffscreenRenderer::~QGCOffscreenRenderer() +{ + if (_renderControl) { + _renderControl->invalidate(); + } + releaseRhiResources(); +} + +void QGCOffscreenRenderer::releaseRhiResources() +{ + _rpDesc.reset(); + _renderTarget.reset(); + _depthStencil.reset(); + _texture.reset(); +} + +bool QGCOffscreenRenderer::ensureRhiTarget() +{ + _rhi = _renderControl->rhi(); + if (!_rhi) { + qCWarning(QGCOffscreenRendererLog) << "QQuickRenderControl produced no QRhi"; + return false; + } + + _texture.reset(_rhi->newTexture(QRhiTexture::RGBA8, _pixelSize, 1, + QRhiTexture::RenderTarget | QRhiTexture::UsedAsTransferSource)); + if (!_texture->create()) { + qCWarning(QGCOffscreenRendererLog) << "Failed to create offscreen texture"; + return false; + } + + _depthStencil.reset(_rhi->newRenderBuffer(QRhiRenderBuffer::DepthStencil, _pixelSize)); + if (!_depthStencil->create()) { + qCWarning(QGCOffscreenRendererLog) << "Failed to create depth-stencil buffer"; + return false; + } + + QRhiTextureRenderTargetDescription rtDesc(QRhiColorAttachment(_texture.get()), _depthStencil.get()); + _renderTarget.reset(_rhi->newTextureRenderTarget(rtDesc)); + _rpDesc.reset(_renderTarget->newCompatibleRenderPassDescriptor()); + _renderTarget->setRenderPassDescriptor(_rpDesc.get()); + if (!_renderTarget->create()) { + qCWarning(QGCOffscreenRendererLog) << "Failed to create texture render target"; + return false; + } + + _quickWindow->setRenderTarget(QQuickRenderTarget::fromRhiRenderTarget(_renderTarget.get())); + return true; +} + +bool QGCOffscreenRenderer::load(const QUrl& qmlSource, const QSize& pixelSize) +{ + if (pixelSize.isEmpty()) { + qCWarning(QGCOffscreenRendererLog) << "Invalid pixel size" << pixelSize; + return false; + } + _pixelSize = pixelSize; + + _renderControl = std::make_unique(this); + _quickWindow = std::make_unique(_renderControl.get()); + + _qmlEngine = std::make_unique(); + if (!_qmlEngine->incubationController()) { + _qmlEngine->setIncubationController(_quickWindow->incubationController()); + } + + _qmlComponent = std::make_unique(_qmlEngine.get(), qmlSource); + if (_qmlComponent->isError()) { + for (const QQmlError& error : _qmlComponent->errors()) { + qCWarning(QGCOffscreenRendererLog) << error.toString(); + } + return false; + } + + std::unique_ptr rootObject(_qmlComponent->create()); + if (_qmlComponent->isError()) { + for (const QQmlError& error : _qmlComponent->errors()) { + qCWarning(QGCOffscreenRendererLog) << error.toString(); + } + return false; + } + + _rootItem = qobject_cast(rootObject.get()); + if (!_rootItem) { + qCWarning(QGCOffscreenRendererLog) << "QML root is not a QQuickItem"; + return false; + } + rootObject.release()->setParent(_quickWindow.get()); + + _rootItem->setParentItem(_quickWindow->contentItem()); + _rootItem->setSize(_pixelSize); + _quickWindow->setGeometry(0, 0, _pixelSize.width(), _pixelSize.height()); + + // initialize() creates the QRhi (scene graph not on a native surface). Must precede target setup. + if (!_renderControl->initialize()) { + qCWarning(QGCOffscreenRendererLog) << "QQuickRenderControl::initialize() failed"; + return false; + } + + if (!ensureRhiTarget()) { + return false; + } + + _initialized = true; + return true; +} + +QImage QGCOffscreenRenderer::renderToImage() +{ + if (!_initialized) { + qCWarning(QGCOffscreenRendererLog) << "renderToImage() before successful load()"; + return {}; + } + + // Documented QQuickRenderControl loop: polishItems -> beginFrame -> sync -> render -> endFrame. + _renderControl->polishItems(); + _renderControl->beginFrame(); + _renderControl->sync(); + _renderControl->render(); + + QImage result; + QRhiReadbackResult readback; + bool done = false; + readback.completed = [&]() { + const QImage img(reinterpret_cast(readback.data.constData()), readback.pixelSize.width(), + readback.pixelSize.height(), QImage::Format_RGBA8888_Premultiplied); + result = img.copy(); // detach from the soon-to-be-freed readback buffer + done = true; + }; + + QRhiResourceUpdateBatch* batch = _rhi->nextResourceUpdateBatch(); + QRhiReadbackDescription rb(_texture.get()); + batch->readBackTexture(rb, &readback); + + QRhiCommandBuffer* cb = _renderControl->commandBuffer(); + if (cb) { + cb->resourceUpdate(batch); + } else { + batch->release(); + qCWarning(QGCOffscreenRendererLog) << "No command buffer for readback"; + } + + _renderControl->endFrame(); + + if (!done) { + qCWarning(QGCOffscreenRendererLog) << "Texture readback did not complete"; + return {}; + } + + // RHI Y-up vs QImage top-down: backends that report isYUpInFramebuffer() need a flip. + if (_rhi->isYUpInFramebuffer()) { + result.flip(Qt::Vertical); + } + return result; +} diff --git a/src/VideoManager/VideoReceiver/Offscreen/QGCOffscreenRenderer.h b/src/VideoManager/VideoReceiver/Offscreen/QGCOffscreenRenderer.h new file mode 100644 index 000000000000..1230e4898f5a --- /dev/null +++ b/src/VideoManager/VideoReceiver/Offscreen/QGCOffscreenRenderer.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include +#include + +class QQmlEngine; +class QQmlComponent; +class QQuickItem; +class QQuickWindow; +class QQuickRenderControl; +class QRhi; +class QRhiTexture; +class QRhiRenderBuffer; +class QRhiTextureRenderTarget; +class QRhiRenderPassDescriptor; + +/// Renders a QML scene into an offscreen RHI texture via QQuickRenderControl and reads it back to a +/// QImage. Self-contained and reusable for PiP / headless compositing; not wired into the live UI. +/// +/// Usage: +/// QGCOffscreenRenderer r; +/// if (r.load(QUrl("qrc:/.../Scene.qml"), QSize(640, 360))) { +/// const QImage img = r.renderToImage(); +/// } +/// +/// The render loop is the documented QQuickRenderControl sequence +/// (initialize -> polishItems -> beginFrame -> sync -> render -> readback -> endFrame). All calls +/// must happen on the thread that owns the renderer; this class drives a single-threaded loop. +class QGCOffscreenRenderer : public QObject +{ + Q_OBJECT + +public: + explicit QGCOffscreenRenderer(QObject* parent = nullptr); + ~QGCOffscreenRenderer() override; + + /// Load a QML component and build the offscreen RHI target at the given pixel size. + /// Returns false on QML errors, RHI init failure, or a non-Item root. + bool load(const QUrl& qmlSource, const QSize& pixelSize); + + /// Render one frame and read it back. Returns a null QImage on failure. + QImage renderToImage(); + + bool isValid() const { return _initialized; } + +private: + void releaseRhiResources(); + bool ensureRhiTarget(); + + std::unique_ptr _renderControl; + std::unique_ptr _quickWindow; + std::unique_ptr _qmlEngine; + std::unique_ptr _qmlComponent; + QQuickItem* _rootItem = nullptr; + + QRhi* _rhi = nullptr; // owned by the render control + std::unique_ptr _texture; + std::unique_ptr _depthStencil; + std::unique_ptr _renderTarget; + std::unique_ptr _rpDesc; + + QSize _pixelSize; + bool _initialized = false; +}; diff --git a/src/VideoManager/VideoReceiver/QtMultimedia/CMakeLists.txt b/src/VideoManager/VideoReceiver/QtMultimedia/CMakeLists.txt index 06958ec3fb63..23e111d359da 100644 --- a/src/VideoManager/VideoReceiver/QtMultimedia/CMakeLists.txt +++ b/src/VideoManager/VideoReceiver/QtMultimedia/CMakeLists.txt @@ -1,6 +1,7 @@ # ============================================================================ # Qt Multimedia Video Receiver Backend -# Qt6 Multimedia-based video streaming and UVC camera support +# Qt6 Multimedia-based video streaming and UVC camera support — always built; +# selected at runtime when QGC_GST_STREAMING is not defined. # ============================================================================ target_sources(${CMAKE_PROJECT_NAME} @@ -12,17 +13,3 @@ target_sources(${CMAKE_PROJECT_NAME} ) target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) - -# ---------------------------------------------------------------------------- -# Qt Multimedia Video Streaming -# ---------------------------------------------------------------------------- -if(QGC_ENABLE_QT_VIDEOSTREAMING) - target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE QGC_QT_STREAMING) -endif() - -# ---------------------------------------------------------------------------- -# UVC Camera Support -# ---------------------------------------------------------------------------- -if(NOT QGC_ENABLE_UVC) - target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE QGC_DISABLE_UVC) -endif() diff --git a/src/VideoManager/VideoReceiver/QtMultimedia/QtMultimediaReceiver.cc b/src/VideoManager/VideoReceiver/QtMultimedia/QtMultimediaReceiver.cc index 9d869d8af037..e53942f70877 100644 --- a/src/VideoManager/VideoReceiver/QtMultimedia/QtMultimediaReceiver.cc +++ b/src/VideoManager/VideoReceiver/QtMultimedia/QtMultimediaReceiver.cc @@ -1,6 +1,6 @@ #include "QtMultimediaReceiver.h" -#include "QGCLoggingCategory.h" +#include #include #include #include @@ -10,15 +10,16 @@ #include #include #include -#include + +#include "QGCLoggingCategory.h" QGC_LOGGING_CATEGORY(QtMultimediaReceiverLog, "Video.QtMultimediaReceiver") -QtMultimediaReceiver::QtMultimediaReceiver(QObject *parent) - : VideoReceiver(parent) - , _mediaPlayer(new QMediaPlayer(this)) - , _captureSession(new QMediaCaptureSession(this)) - , _mediaRecorder(new QMediaRecorder(this)) +QtMultimediaReceiver::QtMultimediaReceiver(QObject* parent) + : VideoReceiver(parent), + _mediaPlayer(new QMediaPlayer(this)), + _captureSession(new QMediaCaptureSession(this)), + _mediaRecorder(new QMediaRecorder(this)) { // qCDebug(QtMultimediaReceiverLog) << Q_FUNC_INFO << this; @@ -26,20 +27,21 @@ QtMultimediaReceiver::QtMultimediaReceiver(QObject *parent) (void) connect(_mediaPlayer, &QMediaPlayer::playingChanged, this, &QtMultimediaReceiver::streamingChanged); (void) connect(_mediaPlayer, &QMediaPlayer::hasVideoChanged, this, &QtMultimediaReceiver::decodingChanged); - (void) connect(_mediaPlayer, &QMediaPlayer::playbackStateChanged, this, [this](QMediaPlayer::PlaybackState newState) { - if (newState == QMediaPlayer::PlaybackState::PlayingState) { - _frameTimer.start(); - } else if (newState == QMediaPlayer::PlaybackState::StoppedState) { - _frameTimer.stop(); - } - }); + (void) connect(_mediaPlayer, &QMediaPlayer::playbackStateChanged, this, + [this](QMediaPlayer::PlaybackState newState) { + if (newState == QMediaPlayer::PlaybackState::PlayingState) { + _frameTimer.start(); + } else if (newState == QMediaPlayer::PlaybackState::StoppedState) { + _frameTimer.stop(); + } + }); (void) connect(_mediaPlayer, &QMediaPlayer::mediaStatusChanged, this, [this](QMediaPlayer::MediaStatus status) { switch (status) { - case QMediaPlayer::MediaStatus::LoadingMedia: - _streamDevice = _mediaPlayer->sourceDevice(); - break; - default: - break; + case QMediaPlayer::MediaStatus::LoadingMedia: + _streamDevice = _mediaPlayer->sourceDevice(); + break; + default: + break; } }); (void) connect(_mediaPlayer, &QMediaPlayer::metaDataChanged, this, []() { @@ -50,38 +52,41 @@ QtMultimediaReceiver::QtMultimediaReceiver(QObject *parent) (void) connect(_mediaPlayer, &QMediaPlayer::bufferProgressChanged, this, [](float filled) { qCDebug(QtMultimediaReceiverLog) << Q_FUNC_INFO << "Buffer Progress:" << filled; }); - (void) connect(_mediaPlayer, &QMediaPlayer::errorOccurred, this, [](QMediaPlayer::Error error, const QString &errorString) { - switch (error) { - case QMediaPlayer::Error::NetworkError: - break; - default: - break; - } - - qCDebug(QtMultimediaReceiverLog) << Q_FUNC_INFO << errorString; - }); + (void) connect(_mediaPlayer, &QMediaPlayer::errorOccurred, this, + [](QMediaPlayer::Error error, const QString& errorString) { + switch (error) { + case QMediaPlayer::Error::NetworkError: + break; + default: + break; + } + + qCDebug(QtMultimediaReceiverLog) << Q_FUNC_INFO << errorString; + }); // _mediaRecorder->setEncodingMode(QMediaRecorder::EncodingMode::AverageBitRateEncoding); // _mediaRecorder->setQuality(QMediaRecorder::Quality::HighQuality); // _mediaRecorder->setVideoBitRate() _mediaRecorder->setVideoFrameRate(0); _mediaRecorder->setVideoResolution(QSize()); - (void) connect(_mediaRecorder, &QMediaRecorder::recorderStateChanged, this, [this](QMediaRecorder::RecorderState state) { - if (state == QMediaRecorder::RecorderState::RecordingState) { - emit recordingStarted(_mediaRecorder->actualLocation().toString()); - } - emit recordingChanged(_mediaRecorder->recorderState() == QMediaRecorder::RecorderState::RecordingState); - }); - (void) connect(_mediaRecorder, &QMediaRecorder::errorOccurred, this, [](QMediaRecorder::Error error, const QString &errorString) { - switch (error) { - case QMediaRecorder::Error::OutOfSpaceError: - break; - default: - break; - } - - qCDebug(QtMultimediaReceiverLog) << Q_FUNC_INFO << errorString; - }); + (void) connect( + _mediaRecorder, &QMediaRecorder::recorderStateChanged, this, [this](QMediaRecorder::RecorderState state) { + if (state == QMediaRecorder::RecorderState::RecordingState) { + emit recordingStarted(_mediaRecorder->actualLocation().toString()); + } + emit recordingChanged(_mediaRecorder->recorderState() == QMediaRecorder::RecorderState::RecordingState); + }); + (void) connect(_mediaRecorder, &QMediaRecorder::errorOccurred, this, + [](QMediaRecorder::Error error, const QString& errorString) { + switch (error) { + case QMediaRecorder::Error::OutOfSpaceError: + break; + default: + break; + } + + qCDebug(QtMultimediaReceiverLog) << Q_FUNC_INFO << errorString; + }); _frameTimer.setSingleShot(true); _frameTimer.setTimerType(Qt::PreciseTimer); @@ -93,29 +98,20 @@ QtMultimediaReceiver::~QtMultimediaReceiver() qCDebug(QtMultimediaReceiverLog) << Q_FUNC_INFO << this; } -bool QtMultimediaReceiver::enabled() -{ -#ifdef QGC_QT_STREAMING - return true; -#else - return false; -#endif -} - -void *QtMultimediaReceiver::createVideoSink(QQuickItem *widget, QObject *parent) +void* QtMultimediaReceiver::createVideoSink(QQuickItem* widget, QObject* parent) { Q_UNUSED(parent); - QVideoSink *videoSink = nullptr; + QVideoSink* videoSink = nullptr; if (widget) { - QQuickVideoOutput *const videoOutput = reinterpret_cast(widget); + QQuickVideoOutput* const videoOutput = reinterpret_cast(widget); videoSink = videoOutput->videoSink(); } return videoSink; } -void QtMultimediaReceiver::releaseVideoSink(void * /*sink*/) +void QtMultimediaReceiver::releaseVideoSink(void* /*sink*/) { /*if (!sink) { return; @@ -125,7 +121,7 @@ void QtMultimediaReceiver::releaseVideoSink(void * /*sink*/) videoSink->deleteLater();*/ } -VideoReceiver *QtMultimediaReceiver::createVideoReceiver(QObject *parent) +VideoReceiver* QtMultimediaReceiver::createVideoReceiver(QObject* parent) { Q_UNUSED(parent); return new QtMultimediaReceiver(nullptr); @@ -186,7 +182,7 @@ void QtMultimediaReceiver::stop() emit onStopComplete(STATUS_OK); } -void QtMultimediaReceiver::startDecoding(void *sink) +void QtMultimediaReceiver::startDecoding(void* sink) { qCDebug(QtMultimediaReceiverLog) << Q_FUNC_INFO; @@ -205,10 +201,9 @@ void QtMultimediaReceiver::startDecoding(void *sink) } _videoSink = reinterpret_cast(sink); - _videoSizeUpdater = connect(_videoSink, &QVideoSink::videoSizeChanged, this, [this]() { - emit videoSizeChanged(_videoSink->videoSize()); - }); - _videoFrameUpdater = connect(_videoSink, &QVideoSink::videoFrameChanged, this, [this](const QVideoFrame &frame) { + _videoSizeUpdater = connect(_videoSink, &QVideoSink::videoSizeChanged, this, + [this]() { emit videoSizeChanged(_videoSink->videoSize()); }); + _videoFrameUpdater = connect(_videoSink, &QVideoSink::videoFrameChanged, this, [this](const QVideoFrame& frame) { if (frame.isValid()) { _frameTimer.start(); } @@ -242,7 +237,7 @@ void QtMultimediaReceiver::stopDecoding() emit onStopDecodingComplete(STATUS_OK); } -void QtMultimediaReceiver::startRecording(const QString &videoFile, FILE_FORMAT format) +void QtMultimediaReceiver::startRecording(const QString& videoFile, FILE_FORMAT format) { qCDebug(QtMultimediaReceiverLog) << Q_FUNC_INFO; @@ -253,19 +248,19 @@ void QtMultimediaReceiver::startRecording(const QString &videoFile, FILE_FORMAT } switch (format) { - case FILE_FORMAT_MKV: - _mediaRecorder->setMediaFormat(QMediaFormat::FileFormat::Matroska); - break; - case FILE_FORMAT_MOV: - _mediaRecorder->setMediaFormat(QMediaFormat::FileFormat::QuickTime); - break; - case FILE_FORMAT_MP4: - _mediaRecorder->setMediaFormat(QMediaFormat::FileFormat::MPEG4); - break; - default: - // QMediaFormat::AVI, WMV, Ogg, WebM - _mediaRecorder->setMediaFormat(QMediaFormat::FileFormat::UnspecifiedFormat); - break; + case FILE_FORMAT_MKV: + _mediaRecorder->setMediaFormat(QMediaFormat::FileFormat::Matroska); + break; + case FILE_FORMAT_MOV: + _mediaRecorder->setMediaFormat(QMediaFormat::FileFormat::QuickTime); + break; + case FILE_FORMAT_MP4: + _mediaRecorder->setMediaFormat(QMediaFormat::FileFormat::MPEG4); + break; + default: + // QMediaFormat::AVI, WMV, Ogg, WebM + _mediaRecorder->setMediaFormat(QMediaFormat::FileFormat::UnspecifiedFormat); + break; } _mediaRecorder->setOutputLocation(QUrl::fromLocalFile(videoFile)); @@ -288,7 +283,7 @@ void QtMultimediaReceiver::stopRecording() emit onStopRecordingComplete(STATUS_OK); } -void QtMultimediaReceiver::takeScreenshot(const QString &imageFile) +void QtMultimediaReceiver::takeScreenshot(const QString& imageFile) { qCDebug(QtMultimediaReceiverLog) << Q_FUNC_INFO; @@ -305,18 +300,27 @@ void QtMultimediaReceiver::takeScreenshot(const QString &imageFile) return; } - // const QVideoFrameFormat frameFormat = frame.surfaceFormat(); - // const QImage frameImage = frame.toImage(); - - _videoOutput = reinterpret_cast(_mediaPlayer->videoOutput()); - const QSize targetSize = _mediaRecorder->videoResolution(); - QSharedPointer screenshot = _videoOutput->grabToImage(targetSize); - // (void) connect(&screenshot, &QQuickItemGrabResult::ready, this, [screenshot, imageFile]() { - // screenshot->saveToFile(imageFile); - // } - screenshot->saveToFile(imageFile); - - qCDebug(QtMultimediaReceiverLog) << "Screenshot"; + // toImage() maps RhiTextureHandle frames GPU->CPU internally, yielding the actual decoded + // frame rather than a grab of the Quick item (which was the prior broken behavior). + const QImage image = frame.toImage(); + if (!image.isNull()) { + if (!image.save(imageFile)) { + qCWarning(QtMultimediaReceiverLog) << "Screenshot save failed:" << imageFile; + emit onTakeScreenshotComplete(STATUS_FAIL); + return; + } + qCDebug(QtMultimediaReceiverLog) << "Screenshot saved:" << imageFile; + emit onTakeScreenshotComplete(STATUS_OK); + return; + } - emit onTakeScreenshotComplete(STATUS_NOT_IMPLEMENTED); + // Fallback only matters for GPU-backed frames; a manual RHI readback here needs the frame's + // native texture via private QtMultimedia APIs on the render thread, which is fragile -- fail + // loudly instead of hand-rolling it. + if (frame.handleType() == QVideoFrame::RhiTextureHandle) { + qCWarning(QtMultimediaReceiverLog) << "Screenshot: GPU frame readback unavailable (toImage returned null)"; + } else { + qCWarning(QtMultimediaReceiverLog) << "Screenshot: frame.toImage() returned null"; + } + emit onTakeScreenshotComplete(STATUS_FAIL); } diff --git a/src/VideoManager/VideoReceiver/QtMultimedia/QtMultimediaReceiver.h b/src/VideoManager/VideoReceiver/QtMultimedia/QtMultimediaReceiver.h index 8b1ac9924e21..1b24feecc18d 100644 --- a/src/VideoManager/VideoReceiver/QtMultimedia/QtMultimediaReceiver.h +++ b/src/VideoManager/VideoReceiver/QtMultimedia/QtMultimediaReceiver.h @@ -22,7 +22,6 @@ class QtMultimediaReceiver : public VideoReceiver explicit QtMultimediaReceiver(QObject *parent = nullptr); virtual ~QtMultimediaReceiver(); - static bool enabled(); static void *createVideoSink(QQuickItem *widget, QObject *parent = nullptr); static void releaseVideoSink(void *sink); static VideoReceiver *createVideoReceiver(QObject *parent); diff --git a/src/VideoManager/VideoReceiver/QtMultimedia/UVCReceiver.cc b/src/VideoManager/VideoReceiver/QtMultimedia/UVCReceiver.cc index 2528141baba8..1ce37d612c37 100644 --- a/src/VideoManager/VideoReceiver/QtMultimedia/UVCReceiver.cc +++ b/src/VideoManager/VideoReceiver/QtMultimedia/UVCReceiver.cc @@ -43,11 +43,7 @@ UVCReceiver::~UVCReceiver() bool UVCReceiver::enabled() { -#ifdef QGC_DISABLE_UVC - return false; -#else return !QMediaDevices::videoInputs().isEmpty(); -#endif } void UVCReceiver::adjustAspectRatio() diff --git a/src/VideoManager/VideoReceiver/SceneGraph/CMakeLists.txt b/src/VideoManager/VideoReceiver/SceneGraph/CMakeLists.txt new file mode 100644 index 000000000000..8c7817669185 --- /dev/null +++ b/src/VideoManager/VideoReceiver/SceneGraph/CMakeLists.txt @@ -0,0 +1,13 @@ +# ============================================================================ +# Scene-Graph Video Node +# Parked QSGRenderNode-based video item for HDR-passthrough (not wired into the +# live path). Depends on rhi/qrhi.h (GuiPrivate, linked at the src/ target level). +# ============================================================================ + +target_sources(${CMAKE_PROJECT_NAME} + PRIVATE + QGCVideoNodeItem.cc + QGCVideoNodeItem.h +) + +target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/VideoManager/VideoReceiver/SceneGraph/QGCVideoNodeItem.cc b/src/VideoManager/VideoReceiver/SceneGraph/QGCVideoNodeItem.cc new file mode 100644 index 000000000000..c945aaa59701 --- /dev/null +++ b/src/VideoManager/VideoReceiver/SceneGraph/QGCVideoNodeItem.cc @@ -0,0 +1,100 @@ +#include "QGCVideoNodeItem.h" + +#include +#include + +QGCVideoNodeItem::QGCVideoNodeItem(QQuickItem* parent) : QQuickItem(parent) +{ + setFlag(ItemHasContents, true); +} + +QGCVideoNodeItem::~QGCVideoNodeItem() = default; + +void QGCVideoNodeItem::setCurrentTexture(QRhiTexture* texture, const QSize& frameSize, + QVideoFrameFormat::PixelFormat pixelFormat, + const QMatrix4x4& externalTextureMatrix) +{ + _pendingTexture = texture; + _frameSize = frameSize; + _pixelFormat = pixelFormat; + _externalTextureMatrix = externalTextureMatrix; + _dirty = true; + update(); +} + +QSGNode* QGCVideoNodeItem::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) +{ + auto* node = static_cast(oldNode); + if (!node) { + node = new QGCVideoRenderNode; + } + if (_dirty) { + node->setFrame(_pendingTexture, _frameSize, _pixelFormat, _externalTextureMatrix); + _dirty = false; + } + node->markDirty(QSGNode::DirtyMaterial); + return node; +} + +QGCVideoRenderNode::QGCVideoRenderNode() = default; + +QGCVideoRenderNode::~QGCVideoRenderNode() = default; + +void QGCVideoRenderNode::setFrame(QRhiTexture* texture, const QSize& frameSize, + QVideoFrameFormat::PixelFormat pixelFormat, + const QMatrix4x4& externalTextureMatrix) +{ + _texture = texture; + _frameSize = frameSize; + _pixelFormat = pixelFormat; + _externalTextureMatrix = externalTextureMatrix; +} + +QSGRenderNode::StateFlags QGCVideoRenderNode::changedStates() const +{ + return {BlendState, ScissorState, ViewportState}; +} + +QSGRenderNode::RenderingFlags QGCVideoRenderNode::flags() const +{ + return {BoundedRectRendering, OpaqueRendering}; +} + +QRectF QGCVideoRenderNode::rect() const +{ + return QRectF(QPointF(0, 0), QSizeF(_frameSize)); +} + +void QGCVideoRenderNode::prepare() +{ + // TODO: build/update the QRhiGraphicsPipeline, sampler, SRB, and per-frame uniform buffer here (this runs inside + // QRhi::beginFrame, before render()). Currently a no-op stub. +} + +void QGCVideoRenderNode::render(const RenderState* /*state*/) +{ + // PARKED STUB — does not draw. To make this node present video for real, implement, using commandBuffer() and + // renderTarget() from the QSGRenderNode base and the imported _texture: + // + // - Plane sampling: NV12/P010 (R8+RG8 / R16+RG16 two-plane) and RGBA fast path; Android external-OES needs a + // samplerExternalOES variant. Pick the shader by _pixelFormat + QRhi backend. + // - Colour conversion matrices: BT.601 / BT.709 / BT.2020, selected from the frame's colour space. + // - Colour range: full vs limited (video) range scale/offset before the YUV->RGB matrix. + // - HDR tonemap: PQ (ST2084) / HLG transfer handling + BT2390 tonemap to the swapchain's max luminance; this is + // the whole point of bypassing QQuickVideoOutput. Honour the surface QRhiSwapChain::Format (SDR/HDR). + // - Orientation / external-texture matrix: apply _externalTextureMatrix and the item's transform/mirroring. + // - Subtitle overlay: composite QtMultimedia's subtitle layout (or our own) on top. + // - Per-backend shader variants: GL / Vulkan / D3D / Metal QShader permutations incl. the external-OES path. + // + // TODO (#7, compute-pipeline conversion): a QRhiComputePipeline (QRhi::Compute feature + + // QRhiTexture::UsedWithLoadStore on the RGB target) could do P010/NV12 -> RGB and the HDR tonemap on the shared + // GStreamer/QRhi device, avoiding a fragment pass. It is only meaningful inside this custom-node path (the + // QtMultimedia sink can't host it) and must keep a fragment-shader fallback for GLES < 3.1 / GL < 4.3 where compute + // is unavailable. +} + +void QGCVideoRenderNode::releaseResources() +{ + // TODO: destroy the pipeline / sampler / SRB / uniform buffers created in prepare(). No-op while stubbed. + _texture = nullptr; +} diff --git a/src/VideoManager/VideoReceiver/SceneGraph/QGCVideoNodeItem.h b/src/VideoManager/VideoReceiver/SceneGraph/QGCVideoNodeItem.h new file mode 100644 index 000000000000..c148284c9f39 --- /dev/null +++ b/src/VideoManager/VideoReceiver/SceneGraph/QGCVideoNodeItem.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include +#include + +class QRhiTexture; + +/// \brief PARKED HDR-passthrough alternative to the QtMultimedia QQuickVideoOutput sink. +/// +/// Scaffold for a custom scene-graph video item that presents an already-imported zero-copy `QRhiTexture` (from the +/// HwBuffers GPU paths) straight into QGC's render thread via a `QSGRenderNode`, bypassing QQuickVideoOutput / +/// QGCQVideoSinkController. The motivation is HDR passthrough: routing the decoder's P010 surface through our own +/// node lets us keep the wide-gamut/PQ data instead of QtMultimedia's SDR-leaning conversion. +/// +/// This is NOT wired into the live video path and does not replace the QtMultimedia sink. `render()` is a documented +/// stub; the TODO list there enumerates exactly what must be implemented to go live. It exists to compile-check the +/// scene-graph plumbing and hold the design. +class QGCVideoNodeItem : public QQuickItem +{ + Q_OBJECT + +public: + explicit QGCVideoNodeItem(QQuickItem* parent = nullptr); + ~QGCVideoNodeItem() override; + + /// Hand the node the next frame's imported texture + metadata. Render-thread consumes it in updatePaintNode. + /// No ownership taken: the caller (the GPU path's QVideoFrameTextures) keeps the texture alive for the frame. + void setCurrentTexture(QRhiTexture* texture, const QSize& frameSize, QVideoFrameFormat::PixelFormat pixelFormat, + const QMatrix4x4& externalTextureMatrix); + +protected: + QSGNode* updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData* data) override; + +private: + QRhiTexture* _pendingTexture = nullptr; + QSize _frameSize; + QVideoFrameFormat::PixelFormat _pixelFormat = QVideoFrameFormat::Format_Invalid; + QMatrix4x4 _externalTextureMatrix; + bool _dirty = false; +}; + +/// Scene-graph node that draws the imported video texture with its own RHI pipeline. Stub: see render() TODOs. +class QGCVideoRenderNode : public QSGRenderNode +{ +public: + QGCVideoRenderNode(); + ~QGCVideoRenderNode() override; + + void setFrame(QRhiTexture* texture, const QSize& frameSize, QVideoFrameFormat::PixelFormat pixelFormat, + const QMatrix4x4& externalTextureMatrix); + + StateFlags changedStates() const override; + RenderingFlags flags() const override; + QRectF rect() const override; + + void prepare() override; + void render(const RenderState* state) override; + void releaseResources() override; + +private: + QRhiTexture* _texture = nullptr; + QSize _frameSize; + QVideoFrameFormat::PixelFormat _pixelFormat = QVideoFrameFormat::Format_Invalid; + QMatrix4x4 _externalTextureMatrix; +}; diff --git a/src/VideoManager/VideoReceiver/VideoBackend.cc b/src/VideoManager/VideoReceiver/VideoBackend.cc new file mode 100644 index 000000000000..f9bc6f6b2b7d --- /dev/null +++ b/src/VideoManager/VideoReceiver/VideoBackend.cc @@ -0,0 +1,150 @@ +#include "VideoBackend.h" + +#include "AppMessages.h" +#include "QGCApplication.h" + +#include +#include + +#ifdef QGC_GST_STREAMING +#include "GStreamer.h" +#include "GStreamerHelpers.h" +#include "SettingsManager.h" +#include "VideoSettings.h" +#include "Fact.h" +#else +#include "QtMultimediaReceiver.h" +#endif + +#ifdef QGC_GST_STREAMING +namespace { + +bool d3d12ZeroCopyUnsupported() +{ +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) + // GStreamer 1.28 can match adapter LUID but cannot wrap Qt's ID3D12Device; D3D12 zero-copy disabled until + // same-device import is possible. + return QQuickWindow::graphicsApi() == QSGRendererInterface::Direct3D12 || + qEnvironmentVariable("QSG_RHI_BACKEND").compare(QLatin1String("d3d12"), Qt::CaseInsensitive) == 0; +#else + return false; +#endif +} + +} // namespace +#endif + +bool VideoBackend::gpuZeroCopyAllowedForCurrentGraphicsApi(bool forceCpuVideoPath, bool forceSoftwareDecoder) +{ +#ifdef QGC_GST_STREAMING + return !forceCpuVideoPath && !forceSoftwareDecoder && !d3d12ZeroCopyUnsupported(); +#else + Q_UNUSED(forceCpuVideoPath); + Q_UNUSED(forceSoftwareDecoder); + return false; +#endif +} + +VideoReceiver *VideoBackend::createReceiver(QObject *parent) +{ +#ifdef QGC_GST_STREAMING + return GStreamer::createVideoReceiver(parent); +#else + return QtMultimediaReceiver::createVideoReceiver(parent); +#endif +} + +void *VideoBackend::createSink(QQuickItem *widget, QObject *parent) +{ + Q_ASSERT(QThread::currentThread() == qApp->thread()); +#ifdef QGC_GST_STREAMING + Q_UNUSED(widget); + Q_UNUSED(parent); + // Resolve construct-only sink tunables from settings here (the layer that owns the + // settings singleton) so the GStreamer facade takes config as an argument. + VideoSettings *const vs = SettingsManager::instance()->videoSettings(); + GStreamer::VideoSinkConfig config; + config.conversionElement = vs->videoConversionElement()->rawValue().toString().toUtf8(); + config.disablePixelAspectRatio = vs->disablePixelAspectRatio()->rawValue().toBool(); + const bool forceCpu = vs->forceCpuVideoPath()->rawValue().toBool(); + const bool swDecoder = vs->forceVideoDecoder()->rawValue().toInt() == GStreamer::ForceVideoDecoderSoftware; + config.gpuZeroCopy = gpuZeroCopyAllowedForCurrentGraphicsApi(forceCpu, swDecoder); + return GStreamer::createVideoSink(config); +#else + return QtMultimediaReceiver::createVideoSink(widget, parent); +#endif +} + +void VideoBackend::releaseSink(void *sink) +{ +#ifdef QGC_GST_STREAMING + GStreamer::releaseVideoSink(sink); +#else + QtMultimediaReceiver::releaseVideoSink(sink); +#endif +} + +bool VideoBackend::disabledForUnitTests() +{ + return qgcApp() && QGC::runningUnitTests() && !qEnvironmentVariableIsSet("QGC_TEST_ENABLE_GSTREAMER"); +} + +VideoBackend::EnvPrepResult VideoBackend::prepareEnvironment() +{ +#ifdef QGC_GST_STREAMING + const GStreamer::Environment::ValidationResult r = GStreamer::prepareEnvironment(); + return { r.ok, r.error }; +#else + return {}; +#endif +} + +bool VideoBackend::initialize(const QStringList &arguments, const EnvPrepResult &envResult) +{ +#ifdef QGC_GST_STREAMING + return GStreamer::initialize(arguments, { envResult.ok, envResult.error }); +#else + Q_UNUSED(arguments); + Q_UNUSED(envResult); + return true; +#endif +} + +void VideoBackend::applyDecoderPriorities(int rawOption) +{ +#ifdef QGC_GST_STREAMING + GStreamer::setCodecPriorities(rawOption); +#else + Q_UNUSED(rawOption); +#endif +} + +void VideoBackend::onMainWindowReady(QQuickWindow *window) +{ +#ifdef QGC_GST_STREAMING + GStreamer::onMainWindowReady(window); +#else + Q_UNUSED(window); +#endif +} + +void VideoBackend::bindDebugLevelFact(Fact *fact, QObject *context) +{ +#ifdef QGC_GST_STREAMING + GStreamer::bindDebugLevelFact(fact, context); +#else + Q_UNUSED(fact); + Q_UNUSED(context); +#endif +} + +void VideoBackend::attachSink(QObject *receiver, void *sink, QQuickItem *widget) +{ +#ifdef QGC_GST_STREAMING + GStreamer::attachAppSink(receiver, sink, widget); +#else + Q_UNUSED(receiver); + Q_UNUSED(sink); + Q_UNUSED(widget); +#endif +} diff --git a/src/VideoManager/VideoReceiver/VideoBackend.h b/src/VideoManager/VideoReceiver/VideoBackend.h new file mode 100644 index 000000000000..5fc1194eb636 --- /dev/null +++ b/src/VideoManager/VideoReceiver/VideoBackend.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +class Fact; +class QObject; +class QQuickItem; +class QQuickWindow; +class VideoReceiver; + +/// Backend-neutral video backend facade: object creation plus process/runtime lifecycle. +/// Selects GStreamer or QtMultimedia at compile time via QGC_GST_STREAMING so callers +/// (VideoManager, QGCCorePlugin) never name a concrete backend. +namespace VideoBackend +{ + +// --- Object creation --- +VideoReceiver *createReceiver(QObject *parent); +void *createSink(QQuickItem *widget, QObject *parent); +void releaseSink(void *sink); + +/// Applies backend-wide sink policy before constructing the GStreamer sink. +bool gpuZeroCopyAllowedForCurrentGraphicsApi(bool forceCpuVideoPath, bool forceSoftwareDecoder); + +// --- Lifecycle --- + +/// True when a streaming backend that requires asynchronous global init is compiled in +/// (GStreamer). QtMultimedia needs no global init, so callers treat it as ready immediately. +constexpr bool needsAsyncInit() noexcept +{ +#ifdef QGC_GST_STREAMING + return true; +#else + return false; +#endif +} + +/// True when the backend should be skipped under unit tests (opt back in with QGC_TEST_ENABLE_GSTREAMER). +bool disabledForUnitTests(); + +/// Outcome of prepareEnvironment(), threaded into initialize() so backend setup +/// validity is passed as a value rather than stashed in process-global state. +struct EnvPrepResult { + bool ok = true; + QString error; +}; + +EnvPrepResult prepareEnvironment(); +bool initialize(const QStringList &arguments, const EnvPrepResult &envResult); +void applyDecoderPriorities(int rawOption); +void onMainWindowReady(QQuickWindow *window); +void bindDebugLevelFact(Fact *fact, QObject *context); +void attachSink(QObject *receiver, void *sink, QQuickItem *widget); + +} // namespace VideoBackend diff --git a/src/VideoManager/VideoReceiver/VideoReceiver.h b/src/VideoManager/VideoReceiver/VideoReceiver.h index e0dadba16ebf..da06bab7fdd7 100644 --- a/src/VideoManager/VideoReceiver/VideoReceiver.h +++ b/src/VideoManager/VideoReceiver/VideoReceiver.h @@ -14,27 +14,37 @@ class VideoReceiver : public QObject QML_ELEMENT QML_UNCREATABLE("") public: + /// Backend-specific decoded-frame sink. The GStreamer backend stores a `GstElement *` + /// (a `qgcvideosinkbin`); a hypothetical QtMultimedia-only backend would store a + /// `QMediaPlayer *` or similar. Typedef-only — does not constrain ownership; the + /// receiver owns the sink for the duration of its decoding session. + using VideoSinkHandle = void *; + explicit VideoReceiver(QObject *parent = nullptr) : QObject(parent) {} bool isThermal() const { return (_name == QStringLiteral("thermalVideo")); } - void *sink() { return _sink; } + VideoSinkHandle sink() { return _sink; } QQuickItem *widget() { return _widget; } QString name() const { return _name; } QString uri() const { return _uri; } bool started() const { return _started; } bool lowLatency() const { return _lowLatency; } + int rtpJitterLatencyMs() const { return _rtpJitterLatencyMs; } + bool autoReconnect() const { return _autoReconnect; } QGCVideoStreamInfo *videoStreamInfo() { return _videoStreamInfo; } QString recordingOutput() const { return _recordingOutput; } - virtual void setSink(void *sink) { if (sink != _sink) { _sink = sink; emit sinkChanged(_sink); } } + virtual void setSink(VideoSinkHandle sink) { if (sink != _sink) { _sink = sink; emit sinkChanged(_sink); } } virtual void setWidget(QQuickItem *widget) { if (widget != _widget) { _widget = widget; emit widgetChanged(_widget); } } void setName(const QString &name) { if (name != _name) { _name = name; emit nameChanged(_name); } } void setUri(const QString &uri) { if (uri != _uri) { _uri = uri; emit uriChanged(_uri); } } void setStarted(bool started) { if (started != _started) { _started = started; emit startedChanged(_started); } } void setLowLatency(bool lowLatency) { if (lowLatency != _lowLatency) { _lowLatency = lowLatency; emit lowLatencyChanged(_lowLatency); } } + void setRtpJitterLatencyMs(int ms) { if (ms != _rtpJitterLatencyMs) { _rtpJitterLatencyMs = ms; emit rtpJitterLatencyMsChanged(_rtpJitterLatencyMs); } } + void setAutoReconnect(bool enabled) { if (enabled != _autoReconnect) { _autoReconnect = enabled; emit autoReconnectChanged(_autoReconnect); } } void setVideoStreamInfo(QGCVideoStreamInfo *videoStreamInfo) { if (videoStreamInfo != _videoStreamInfo) { _videoStreamInfo = videoStreamInfo; emit videoStreamInfoChanged(); } } // QMediaFormat::FileFormat @@ -68,11 +78,13 @@ class VideoReceiver : public QObject void recordingStarted(const QString &filename); void videoSizeChanged(QSize size); - void sinkChanged(void *sink); + void sinkChanged(VideoSinkHandle sink); void nameChanged(const QString &name); void uriChanged(const QString &uri); void startedChanged(bool started); void lowLatencyChanged(bool lowLatency); + void rtpJitterLatencyMsChanged(int ms); + void autoReconnectChanged(bool enabled); void videoStreamInfoChanged(); void widgetChanged(QQuickItem *widget); @@ -87,14 +99,14 @@ class VideoReceiver : public QObject public slots: virtual void start(uint32_t timeout) = 0; virtual void stop() = 0; - virtual void startDecoding(void *sink) = 0; + virtual void startDecoding(VideoSinkHandle sink) = 0; virtual void stopDecoding() = 0; virtual void startRecording(const QString &videoFile, FILE_FORMAT format) = 0; virtual void stopRecording() = 0; virtual void takeScreenshot(const QString &imageFile) = 0; protected: - void *_sink = nullptr; + VideoSinkHandle _sink = nullptr; QQuickItem *_widget = nullptr; QGCVideoStreamInfo *_videoStreamInfo = nullptr; QString _name; @@ -104,6 +116,8 @@ public slots: bool _recording = false; bool _streaming = false; bool _lowLatency = false; + int _rtpJitterLatencyMs = 80; + bool _autoReconnect = true; ///< RTSP/UDP auto-reconnect with exponential backoff on watchdog/error. bool _resetVideoSink = false; bool _endOfStream = false; bool _removingDecoder = false; @@ -115,8 +129,8 @@ public slots: int _buffer = 0; qint64 _lastSourceFrameTime = 0; qint64 _lastVideoFrameTime = 0; + int _statsTickCounter = 0; QTimer _watchdogTimer; - uint32_t _signalDepth = 0; uint32_t _timeout = 0; QString _recordingOutput; diff --git a/src/main.cc b/src/main.cc index 2c45a15ebc35..f04354841e87 100644 --- a/src/main.cc +++ b/src/main.cc @@ -25,7 +25,7 @@ int main(int argc, char *argv[]) QGCApplication app(argc, argv, args); - LogManager::installHandler(); + LogManager::installHandler(args.logOutput); Platform::setupPostApp(); @@ -45,11 +45,11 @@ int main(int argc, char *argv[]) #endif case AppMode::BootTest: if (!app.bootTestPassed()) { - qCCritical(MainLog) << "Simple boot test failed during GStreamer initialization"; - return 1; + qCCritical(MainLog) << "Simple boot test failed"; + return EXIT_FAILURE; } qCInfo(MainLog) << "Simple boot test completed"; - return 0; + return EXIT_SUCCESS; case AppMode::Gui: qCInfo(MainLog) << "Starting application event loop"; return app.exec(); diff --git a/test/QmlUITests/QmlUITestBase.cc b/test/QmlUITests/QmlUITestBase.cc index 800735f4e17c..9a2dfb6d6822 100644 --- a/test/QmlUITests/QmlUITestBase.cc +++ b/test/QmlUITests/QmlUITestBase.cc @@ -122,6 +122,14 @@ void QmlUITestBase::startUI() QRegularExpression(QStringLiteral("Error transferring"))); ignoreLogMessage("Terrain.TerrainTileManager", QtWarningMsg, QRegularExpression(QStringLiteral("Elevation tile fetching returned error"))); + + // Slow headless/software-GL runners can leave async-incubated QML items still + // creating when the engine is torn down (destroyUIEngine drains best-effort but + // cannot guarantee completion). Benign at shutdown, so ignore it rather than fail + // strict mode on a timing artifact. + ignoreLogMessage("default", QtWarningMsg, + QRegularExpression(QStringLiteral("items in the process of being created at engine destruction"))); + #ifdef QT_DEBUG // Debug builds on macOS are ad-hoc signed with an unbound Info.plist, so // macOS never shows the camera permission dialog and silently denies access. diff --git a/test/Utilities/QGCCommandLineParserTest.cc b/test/Utilities/QGCCommandLineParserTest.cc index d024ee1ab643..bc0a01bba1e7 100644 --- a/test/Utilities/QGCCommandLineParserTest.cc +++ b/test/Utilities/QGCCommandLineParserTest.cc @@ -36,7 +36,6 @@ void QGCCommandLineParserTest::_testDefaultResult() QCOMPARE(result.fakeMobile, false); QCOMPARE(result.allowMultiple, false); - QCOMPARE(result.useDesktopGL, false); QCOMPARE(result.useSwRast, false); QCOMPARE(result.quietWindowsAsserts, false); } diff --git a/test/VideoManager/GStreamer/CMakeLists.txt b/test/VideoManager/GStreamer/CMakeLists.txt index 948feb581222..985de203303b 100644 --- a/test/VideoManager/GStreamer/CMakeLists.txt +++ b/test/VideoManager/GStreamer/CMakeLists.txt @@ -1,9 +1,16 @@ -# TODO: QGC tests universally add to ${CMAKE_PROJECT_NAME} (test/Vehicle/, test/MissionManager/); a dedicated target would require reworking the whole test framework. +# QGC tests add sources to ${CMAKE_PROJECT_NAME} (test/Vehicle/, test/MissionManager/); keep this test on the same target. target_sources(${CMAKE_PROJECT_NAME} PRIVATE GStreamerTest.cc GStreamerTest.h + HwBuffers/common/GStreamerHwBuffersCommonTest.cc + HwBuffers/d3d/GStreamerD3DTest.cc + HwBuffers/dmabuf/GStreamerDmaBufTest.cc + HwBuffers/gl/GStreamerGlTest.cc + HwBuffers/vulkan/GStreamerVulkanTest.cc + SourceFactory/GStreamerSourceFactoryTest.cc + gstqgc/GStreamerGstQgcTest.cc ) target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/test/VideoManager/GStreamer/GStreamerTest.cc b/test/VideoManager/GStreamer/GStreamerTest.cc index 3c7d920bbff1..3359e8651832 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.cc +++ b/test/VideoManager/GStreamer/GStreamerTest.cc @@ -1,56 +1,29 @@ #include "GStreamerTest.h" + #include "QGCLoggingCategory.h" QGC_LOGGING_CATEGORY(GStreamerTestLog, "Video.GStreamer.GStreamerTest") #ifdef QGC_GST_STREAMING -#include "Fixtures/RAIIFixtures.h" -#include "GStreamer.h" -#include "GStreamerHelpers.h" -#include "GStreamerLogging.h" -#include "GstVideoReceiver.h" - #include #include +#include #include +#include +#include #include - -#include -#include #include #include -#include "GstAppSinkAdapter.h" -#include -#include - -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) -# include "GstDmaBufVideoBuffer.h" -#endif -#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) -# include "GstGlContextBridge.h" -# include -#endif -#if defined(QGC_HAS_GST_D3D11_GPU_PATH) -# include "GstD3D11ContextBridge.h" -#endif -#if defined(QGC_HAS_GST_D3D12_GPU_PATH) -# include "GstD3D12ContextBridge.h" -#endif -#include "GstContextBridgeRegistry.h" -#include "GstHwVideoBuffer.h" -#include "GstHwVideoBufferFactory.h" -#include "gstqgc/gstqgcvideosinkbin.h" -#if defined(QGC_HAS_ANY_GPU_PATH) -# include "QGCRhiCapture.h" -#endif -#include -#include -#include -#include -#include -#include +#include "Fixtures/RAIIFixtures.h" +#include "Fact.h" +#include "GStreamer.h" +#include "GStreamerHelpers.h" +#include "GStreamerLogging.h" +#include "GstVideoReceiver.h" +#include "LogManager.h" +#include "VideoBackend.h" void GStreamerTest::init() { @@ -62,8 +35,8 @@ void GStreamerTest::init() static const QRegularExpression sGLibTypeRe( QStringLiteral("cannot register existing type|" - "g_type_add_interface_static.*G_TYPE_IS_INSTANTIATABLE|" - "g_once_init_leave.*result != 0")); + "g_type_add_interface_static.*G_TYPE_IS_INSTANTIATABLE|" + "g_once_init_leave.*result != 0")); // GStreamer/GLib type registration warnings are environment/startup-order dependent // and may occur 0..N times across test process lifetime. ignoreLogMessage("Video.GStreamer.GStreamerLogging", QtCriticalMsg, sGLibTypeRe); @@ -79,7 +52,7 @@ void GStreamerTest::init() GStreamer::prepareEnvironment(); GStreamer::redirectGLibLogging(); - GError *error = nullptr; + GError* error = nullptr; if (!gst_init_check(nullptr, nullptr, &error)) { const QString msg = error ? QString::fromUtf8(error->message) : QStringLiteral("unknown error"); g_clear_error(&error); @@ -105,7 +78,7 @@ void GStreamerTest::_testIsValidRtspUri() void GStreamerTest::_testIsHardwareDecoderFactory() { - GList *factories = gst_element_factory_list_get_elements( + GList* factories = gst_element_factory_list_get_elements( static_cast(GST_ELEMENT_FACTORY_TYPE_DECODER | GST_ELEMENT_FACTORY_TYPE_MEDIA_VIDEO), GST_RANK_NONE); if (!factories) { @@ -116,11 +89,11 @@ void GStreamerTest::_testIsHardwareDecoderFactory() int hwCount = 0; int swCount = 0; - for (GList *node = factories; node != nullptr; node = node->next) { - GstElementFactory *factory = GST_ELEMENT_FACTORY(node->data); + for (GList* node = factories; node != nullptr; node = node->next) { + GstElementFactory* factory = GST_ELEMENT_FACTORY(node->data); QVERIFY(factory != nullptr); - const gchar *name = gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(factory)); + const gchar* name = gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(factory)); QVERIFY(name != nullptr); if (GStreamer::isHardwareDecoderFactory(factory)) { @@ -131,8 +104,8 @@ void GStreamerTest::_testIsHardwareDecoderFactory() ++total; } - qCDebug(GStreamerTestLog) << "Decoder factory classification:" << total << "total," - << hwCount << "hardware," << swCount << "software"; + qCDebug(GStreamerTestLog) << "Decoder factory classification:" << total << "total," << hwCount << "hardware," + << swCount << "software"; QVERIFY(total > 0); QVERIFY(swCount > 0); @@ -145,15 +118,132 @@ void GStreamerTest::_testSetCodecPrioritiesDefault() { GStreamer::setCodecPriorities(GStreamer::ForceVideoDecoderDefault); - GstRegistry *registry = gst_registry_get(); + GstRegistry* registry = gst_registry_get(); QVERIFY(registry != nullptr); } +void GStreamerTest::_testSetCodecPrioritiesDefaultPrefersMatchingD3DDecoder() +{ +#ifndef Q_OS_WIN + QSKIP("D3D decoder rank steering is Windows-only"); +#else + GstRegistry* registry = gst_registry_get(); + QVERIFY(registry != nullptr); + + auto lookup = [registry](const char* featureName) { + return gst_registry_lookup_feature(registry, featureName); + }; + + GstPluginFeature* software = lookup("avdec_h265"); + GstPluginFeature* d3d11 = lookup("d3d11h265dec"); + GstPluginFeature* d3d12 = lookup("d3d12h265dec"); + if (!software || !d3d11 || !d3d12) { + if (software) { + gst_object_unref(software); + } + if (d3d11) { + gst_object_unref(d3d11); + } + if (d3d12) { + gst_object_unref(d3d12); + } + QSKIP("Required H.265 software/D3D decoder factories are not installed"); + } + + const guint oldSoftwareRank = gst_plugin_feature_get_rank(software); + const guint oldD3D11Rank = gst_plugin_feature_get_rank(d3d11); + const guint oldD3D12Rank = gst_plugin_feature_get_rank(d3d12); + const auto restoreRanks = qScopeGuard([software, d3d11, d3d12, oldSoftwareRank, oldD3D11Rank, oldD3D12Rank]() { + gst_plugin_feature_set_rank(software, oldSoftwareRank); + gst_plugin_feature_set_rank(d3d11, oldD3D11Rank); + gst_plugin_feature_set_rank(d3d12, oldD3D12Rank); + gst_object_unref(software); + gst_object_unref(d3d11); + gst_object_unref(d3d12); + }); + + const QByteArray oldRhiBackend = qgetenv("QSG_RHI_BACKEND"); + qputenv("QSG_RHI_BACKEND", QByteArray("d3d11")); + const auto restoreRhiEnv = qScopeGuard([oldRhiBackend]() { + if (oldRhiBackend.isEmpty()) { + qunsetenv("QSG_RHI_BACKEND"); + } else { + qputenv("QSG_RHI_BACKEND", oldRhiBackend); + } + }); + + gst_plugin_feature_set_rank(software, GST_RANK_PRIMARY + 1); + gst_plugin_feature_set_rank(d3d11, GST_RANK_MARGINAL); + gst_plugin_feature_set_rank(d3d12, GST_RANK_PRIMARY + 2); + + GStreamer::setCodecPriorities(GStreamer::ForceVideoDecoderDefault); + + const guint softwareRank = gst_plugin_feature_get_rank(software); + const guint d3d11Rank = gst_plugin_feature_get_rank(d3d11); + const guint d3d12Rank = gst_plugin_feature_get_rank(d3d12); + + QVERIFY2(d3d11Rank > softwareRank, "Default Windows D3D11 RHI must prefer d3d11h265dec over avdec_h265"); + QCOMPARE(d3d12Rank, static_cast(GST_RANK_NONE)); +#endif +} + +void GStreamerTest::_testSetCodecPrioritiesSkipsAbsentD3DDecoders() +{ +#ifndef Q_OS_WIN + QSKIP("D3D decoder rank steering is Windows-only"); +#else + GstRegistry* registry = gst_registry_get(); + QVERIFY(registry != nullptr); + + const QByteArray oldLoggingRules = qgetenv("QT_LOGGING_RULES"); + QLoggingCategory::setFilterRules(QStringLiteral("*.debug=false\nVideo.GStreamer.GStreamerHelpers.debug=true")); + const auto restoreLoggingRules = qScopeGuard([oldLoggingRules]() { + QLoggingCategory::setFilterRules(QString::fromUtf8(oldLoggingRules)); + }); + + LogManager::clearCapturedMessages(); + QVERIFY(!GStreamer::changeFeatureRank(registry, "__qgc_missing_d3d_decoder_for_test__", GST_RANK_NONE)); + + const QList helperMessages = + LogManager::capturedMessages(QStringLiteral("Video.GStreamer.GStreamerHelpers")); + for (const LogEntry& entry : helperMessages) { + QVERIFY2(!entry.message.contains(QStringLiteral("Feature does not exist")), + qPrintable(QStringLiteral("Optional D3D decoder factory was logged as a failure: %1") + .arg(entry.message))); + } +#endif +} + +void GStreamerTest::_testD3D12RhiDisablesGpuZeroCopySink() +{ +#if !defined(Q_OS_WIN) || !defined(QGC_HAS_GST_D3D12_GPU_PATH) + QSKIP("D3D12 sink policy is Windows D3D12-only"); +#else + const QByteArray oldRhiBackend = qgetenv("QSG_RHI_BACKEND"); + qputenv("QSG_RHI_BACKEND", QByteArray("d3d12")); + const auto restoreRhiEnv = qScopeGuard([oldRhiBackend]() { + if (oldRhiBackend.isEmpty()) { + qunsetenv("QSG_RHI_BACKEND"); + } else { + qputenv("QSG_RHI_BACKEND", oldRhiBackend); + } + }); + + QVERIFY2(!VideoBackend::gpuZeroCopyAllowedForCurrentGraphicsApi(false, false), + "D3D12 RHI must force the CPU sink path because gst-d3d12 cannot wrap Qt's ID3D12Device"); + QVERIFY(VideoBackend::gpuZeroCopyAllowedForCurrentGraphicsApi(false, true) == false); + QVERIFY(VideoBackend::gpuZeroCopyAllowedForCurrentGraphicsApi(true, false) == false); + + qputenv("QSG_RHI_BACKEND", QByteArray("d3d11")); + QVERIFY(VideoBackend::gpuZeroCopyAllowedForCurrentGraphicsApi(false, false)); +#endif +} + void GStreamerTest::_testSetCodecPrioritiesSoftware() { GStreamer::setCodecPriorities(GStreamer::ForceVideoDecoderSoftware); - GList *factories = gst_element_factory_list_get_elements( + GList* factories = gst_element_factory_list_get_elements( static_cast(GST_ELEMENT_FACTORY_TYPE_DECODER | GST_ELEMENT_FACTORY_TYPE_MEDIA_VIDEO), GST_RANK_NONE); if (!factories) { @@ -161,9 +251,10 @@ void GStreamerTest::_testSetCodecPrioritiesSoftware() } bool foundPrioritizedSoftware = false; - for (GList *node = factories; node != nullptr; node = node->next) { - GstElementFactory *factory = GST_ELEMENT_FACTORY(node->data); - if (!factory) continue; + for (GList* node = factories; node != nullptr; node = node->next) { + GstElementFactory* factory = GST_ELEMENT_FACTORY(node->data); + if (!factory) + continue; if (!GStreamer::isHardwareDecoderFactory(factory)) { const guint rank = gst_plugin_feature_get_rank(GST_PLUGIN_FEATURE(factory)); @@ -183,16 +274,17 @@ void GStreamerTest::_testSetCodecPrioritiesHardware() { GStreamer::setCodecPriorities(GStreamer::ForceVideoDecoderHardware); - GList *factories = gst_element_factory_list_get_elements( + GList* factories = gst_element_factory_list_get_elements( static_cast(GST_ELEMENT_FACTORY_TYPE_DECODER | GST_ELEMENT_FACTORY_TYPE_MEDIA_VIDEO), GST_RANK_NONE); if (!factories) { QSKIP("No video decoder factories available on this system"); } - for (GList *node = factories; node != nullptr; node = node->next) { - GstElementFactory *factory = GST_ELEMENT_FACTORY(node->data); - if (!factory) continue; + for (GList* node = factories; node != nullptr; node = node->next) { + GstElementFactory* factory = GST_ELEMENT_FACTORY(node->data); + if (!factory) + continue; if (!GStreamer::isHardwareDecoderFactory(factory)) { const guint rank = gst_plugin_feature_get_rank(GST_PLUGIN_FEATURE(factory)); @@ -218,21 +310,42 @@ void GStreamerTest::_testRedirectGLibLogging() verifyExpectedLogMessage(); } +void GStreamerTest::_testConfigureDebugLoggingIsIdempotent() +{ + GStreamer::configureDebugLogging(); + GStreamer::configureDebugLogging(); + + GST_DEBUG_CATEGORY_STATIC(qgcTestDebug); + GST_DEBUG_CATEGORY_INIT(qgcTestDebug, "qgc-test-debug", 0, "QGC GStreamer test debug category"); + if (!qgcTestDebug) { + QSKIP("GStreamer debug categories unavailable"); + } + gst_debug_category_set_threshold(qgcTestDebug, GST_LEVEL_WARNING); + + expectLogMessage("Video.GStreamer.GStreamerAPI", QtWarningMsg, + QRegularExpression(QStringLiteral("idempotent configureDebugLogging probe"))); + gst_debug_log(qgcTestDebug, GST_LEVEL_WARNING, __FILE__, Q_FUNC_INFO, __LINE__, nullptr, "%s", + "idempotent configureDebugLogging probe"); + verifyExpectedLogMessage(); + + gst_debug_category_set_threshold(qgcTestDebug, GST_LEVEL_NONE); +} + void GStreamerTest::_testVerifyRequiredPlugins() { - GstRegistry *registry = gst_registry_get(); + GstRegistry* registry = gst_registry_get(); QVERIFY(registry != nullptr); - GstPlugin *corePlugin = gst_registry_find_plugin(registry, "coreelements"); + GstPlugin* corePlugin = gst_registry_find_plugin(registry, "coreelements"); QVERIFY2(corePlugin, "Required plugin not found: coreelements"); gst_clear_object(&corePlugin); - GList *plugins = gst_registry_get_plugin_list(registry); + GList* plugins = gst_registry_get_plugin_list(registry); const int pluginCount = g_list_length(plugins); gst_plugin_list_free(plugins); QVERIFY2(pluginCount > 0, "GStreamer registry contains no plugins at all"); - GstElementFactory *playbinFactory = gst_element_factory_find("playbin"); + GstElementFactory* playbinFactory = gst_element_factory_find("playbin"); QVERIFY2(playbinFactory, "Required factory not found: playbin"); gst_object_unref(playbinFactory); } @@ -240,48 +353,82 @@ void GStreamerTest::_testVerifyRequiredPlugins() void GStreamerTest::_testEnvironmentSetup() { // Save and clear relevant env vars - static constexpr const char *envVars[] = { - "GIO_EXTRA_MODULES", "GIO_MODULE_DIR", "GIO_USE_VFS", - "GST_PTP_HELPER", "GST_PTP_HELPER_1_0", - "GST_PLUGIN_PATH", "GST_PLUGIN_PATH_1_0", - "GST_PLUGIN_SYSTEM_PATH", "GST_PLUGIN_SYSTEM_PATH_1_0", - "GST_PLUGIN_SCANNER", "GST_PLUGIN_SCANNER_1_0", + static constexpr const char* envVars[] = { + "GIO_EXTRA_MODULES", + "GIO_MODULE_DIR", + "GIO_USE_VFS", + "GST_PTP_HELPER", + "GST_PTP_HELPER_1_0", + "GST_PLUGIN_PATH", + "GST_PLUGIN_PATH_1_0", + "GST_PLUGIN_SYSTEM_PATH", + "GST_PLUGIN_SYSTEM_PATH_1_0", + "GST_PLUGIN_SCANNER", + "GST_PLUGIN_SCANNER_1_0", "GST_REGISTRY_REUSE_PLUGIN_SCANNER", "GTK_PATH", - "PYTHONHOME", "PYTHONPATH", "PYTHONUSERBASE", - "VIRTUAL_ENV", "CONDA_PREFIX", "CONDA_DEFAULT_ENV", + "PYTHONHOME", + "PYTHONPATH", + "PYTHONUSERBASE", + "VIRTUAL_ENV", + "CONDA_PREFIX", + "CONDA_DEFAULT_ENV", "PYTHONNOUSERSITE", }; std::vector envBackups; envBackups.reserve(std::size(envVars)); - for (const char *var : envVars) { + for (const char* var : envVars) { envBackups.emplace_back(var); qunsetenv(var); } GStreamer::prepareEnvironment(); - for (const char *var : {"GST_PLUGIN_PATH", "GST_PLUGIN_PATH_1_0", - "GST_PLUGIN_SYSTEM_PATH", "GST_PLUGIN_SYSTEM_PATH_1_0"}) { + for (const char* var : + {"GST_PLUGIN_PATH", "GST_PLUGIN_PATH_1_0", "GST_PLUGIN_SYSTEM_PATH", "GST_PLUGIN_SYSTEM_PATH_1_0"}) { if (qEnvironmentVariableIsSet(var)) { const QString path = qEnvironmentVariable(var); // Paths may be colon-separated; check each component const QStringList parts = path.split(QDir::listSeparator(), Qt::SkipEmptyParts); - for (const QString &part : parts) { + for (const QString& part : parts) { QVERIFY2(QDir(part).exists(), - qPrintable(QStringLiteral("%1 contains non-existent path: %2").arg(var, part))); + qPrintable(QStringLiteral("%1 contains non-existent path: %2").arg(var, part))); } } } - for (const char *var : {"GST_PLUGIN_SCANNER", "GST_PLUGIN_SCANNER_1_0"}) { + for (const char* var : {"GST_PLUGIN_SCANNER", "GST_PLUGIN_SCANNER_1_0"}) { if (qEnvironmentVariableIsSet(var)) { const QString path = qEnvironmentVariable(var); QVERIFY2(QFileInfo(path).isExecutable(), - qPrintable(QStringLiteral("%1 is not executable: %2").arg(var, path))); + qPrintable(QStringLiteral("%1 is not executable: %2").arg(var, path))); } } +} +void GStreamerTest::_testWritePipelineDotReturnsEmptyOnWriteFailure() +{ + if (QStandardPaths::writableLocation(QStandardPaths::CacheLocation).isEmpty()) { + QSKIP("No writable cache location available"); + } + + const bool hadDumpDir = qEnvironmentVariableIsSet("GST_DEBUG_DUMP_DOT_DIR"); + const QByteArray oldDumpDir = qgetenv("GST_DEBUG_DUMP_DOT_DIR"); + qunsetenv("GST_DEBUG_DUMP_DOT_DIR"); + const auto restoreDumpDir = qScopeGuard([&] { + if (hadDumpDir) { + qputenv("GST_DEBUG_DUMP_DOT_DIR", oldDumpDir); + } else { + qunsetenv("GST_DEBUG_DUMP_DOT_DIR"); + } + }); + + GstElement* pipeline = gst_pipeline_new("dot-write-failure-test"); + QVERIFY(pipeline); + const auto cleanup = qScopeGuard([&] { gst_object_unref(pipeline); }); + + const QString path = GStreamer::writePipelineDot(pipeline, "missing-dir/pipeline"); + QVERIFY2(path.isEmpty(), qPrintable(QStringLiteral("Expected empty path for failed dot write, got %1").arg(path))); } void GStreamerTest::_testCompleteInit() @@ -290,18 +437,18 @@ void GStreamerTest::_testCompleteInit() const bool result = GStreamer::completeInit(); QVERIFY2(result, "GStreamer::completeInit() failed"); - GstRegistry *registry = gst_registry_get(); + GstRegistry* registry = gst_registry_get(); QVERIFY(registry); - GstPlugin *qgcPlugin = gst_registry_find_plugin(registry, "qgc"); + GstPlugin* qgcPlugin = gst_registry_find_plugin(registry, "qgc"); QVERIFY2(qgcPlugin, "Static plugin 'qgc' not registered after completeInit()"); gst_clear_object(&qgcPlugin); - GstElementFactory *sinkFactory = gst_element_factory_find("appsink"); + GstElementFactory* sinkFactory = gst_element_factory_find("appsink"); QVERIFY2(sinkFactory, "Factory 'appsink' not found after completeInit()"); gst_object_unref(sinkFactory); - GstElementFactory *binFactory = gst_element_factory_find("qgcvideosinkbin"); + GstElementFactory* binFactory = gst_element_factory_find("qgcvideosinkbin"); QVERIFY2(binFactory, "Factory 'qgcvideosinkbin' not found after completeInit()"); gst_object_unref(binFactory); } @@ -313,39 +460,10 @@ void GStreamerTest::_testCreateVideoReceiver() QVERIFY(qobject_cast(receiver.get())); } -void GStreamerTest::_testPipelineSmokeTest() +void GStreamerTest::_testBindDebugLevelFactRejectsNullContext() { - GstElement *pipeline = gst_parse_launch("videotestsrc num-buffers=5 ! fakesink", nullptr); - QVERIFY2(pipeline, "Failed to create videotestsrc ! fakesink pipeline"); - - GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING); - QVERIFY2(ret != GST_STATE_CHANGE_FAILURE, "Pipeline failed to transition to PLAYING"); - - GstBus *bus = gst_element_get_bus(pipeline); - QVERIFY(bus); - - GstMessage *msg = gst_bus_timed_pop_filtered(bus, 5 * GST_SECOND, - static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); - QVERIFY2(msg, "Pipeline timed out waiting for EOS or ERROR"); - - if (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { - GError *err = nullptr; - gst_message_parse_error(msg, &err, nullptr); - const QString errMsg = err ? QString::fromUtf8(err->message) : QStringLiteral("unknown"); - g_clear_error(&err); - gst_message_unref(msg); - gst_object_unref(bus); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); - QFAIL(qPrintable(QStringLiteral("Pipeline error: %1").arg(errMsg))); - } - - QCOMPARE(GST_MESSAGE_TYPE(msg), GST_MESSAGE_EOS); - gst_message_unref(msg); - gst_object_unref(bus); - - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); + Fact fact; + GStreamer::bindDebugLevelFact(&fact, nullptr); } void GStreamerTest::_testRuntimeVersionCheck() @@ -354,1375 +472,115 @@ void GStreamerTest::_testRuntimeVersionCheck() gst_version(&major, &minor, µ, &nano); QVERIFY2(major == 1, "Unexpected GStreamer major version"); - QVERIFY2(minor >= 20, qPrintable(QStringLiteral( - "GStreamer runtime version %1.%2.%3 is below minimum 1.20.0") - .arg(major).arg(minor).arg(micro))); + QVERIFY2(minor >= 20, qPrintable(QStringLiteral("GStreamer runtime version %1.%2.%3 is below minimum 1.20.0") + .arg(major) + .arg(minor) + .arg(micro))); #ifdef QGC_GST_BUILD_VERSION_MAJOR if (major != QGC_GST_BUILD_VERSION_MAJOR || minor != QGC_GST_BUILD_VERSION_MINOR) { - qCWarning(GStreamerTestLog) << "GStreamer version mismatch: built against" - << QGC_GST_BUILD_VERSION_MAJOR << "." << QGC_GST_BUILD_VERSION_MINOR - << "but runtime is" << major << "." << minor; + qCWarning(GStreamerTestLog) << "GStreamer version mismatch: built against" << QGC_GST_BUILD_VERSION_MAJOR << "." + << QGC_GST_BUILD_VERSION_MINOR << "but runtime is" << major << "." << minor; } #endif } -void GStreamerTest::_testAppsinkFrameDelivery() -{ - // Ensure the qgc plugin (including qgcvideosinkbin) is registered. - // _testCompleteInit runs before this slot, but guard against reorder. - GstElementFactory *guardFactory = gst_element_factory_find("qgcvideosinkbin"); - if (!guardFactory) { - GStreamer::completeInit(); - } else { - gst_object_unref(guardFactory); - } - - GstElementFactory *factory = gst_element_factory_find("qgcvideosinkbin"); - QVERIFY2(factory, "qgcvideosinkbin factory not found"); - gst_object_unref(factory); - - GError *error = nullptr; - GstElement *pipeline = gst_parse_launch( - "videotestsrc num-buffers=10 ! " - "video/x-raw,format=I420,width=320,height=240,framerate=30/1 ! " - "videoconvert ! " - "video/x-raw,format=BGRA ! " - "qgcvideosinkbin name=sink", - &error); - if (error) { - const QString msg = QString::fromUtf8(error->message); - g_clear_error(&error); - QFAIL(qPrintable(QStringLiteral("Pipeline parse error: %1").arg(msg))); - } - QVERIFY2(pipeline, "Failed to create appsink test pipeline"); - - GstElement *sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); - QVERIFY2(sinkBin, "Could not find 'sink' element in pipeline"); - - QVideoSink videoSink; - GstAppSinkAdapter adapter; - - int frameCount = 0; - QSize lastFrameSize; - QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &adapter, [&](const QVideoFrame &frame) { - frameCount++; - lastFrameSize = frame.size(); - }); - - const bool setupOk = adapter.setup(sinkBin, &videoSink); - QVERIFY2(setupOk, "GstAppSinkAdapter::setup() failed"); - - gst_object_unref(sinkBin); - - GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING); - QVERIFY2(ret != GST_STATE_CHANGE_FAILURE, "Pipeline failed to transition to PLAYING"); - - GstBus *bus = gst_element_get_bus(pipeline); - QVERIFY(bus); - - GstMessage *msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, - static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); - QVERIFY2(msg, "Pipeline timed out waiting for EOS or ERROR"); - - if (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { - GError *err = nullptr; - gchar *debug = nullptr; - gst_message_parse_error(msg, &err, &debug); - const QString errMsg = QStringLiteral("%1 (%2)") - .arg(err ? QString::fromUtf8(err->message) : QStringLiteral("unknown")) - .arg(debug ? QString::fromUtf8(debug) : QString()); - g_clear_error(&err); - g_free(debug); - gst_message_unref(msg); - gst_object_unref(bus); - adapter.teardown(); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); - QFAIL(qPrintable(QStringLiteral("Pipeline error: %1").arg(errMsg))); - } - - QCOMPARE(GST_MESSAGE_TYPE(msg), GST_MESSAGE_EOS); - gst_message_unref(msg); - gst_object_unref(bus); - - QTRY_VERIFY_WITH_TIMEOUT(frameCount > 0, TestTimeout::mediumMs()); - QCOMPARE(lastFrameSize, QSize(320, 240)); - - adapter.teardown(); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); -} - -void GStreamerTest::_testAppsinkYuvPassthrough() -{ - GstElementFactory *guardFactory = gst_element_factory_find("qgcvideosinkbin"); - if (!guardFactory) { - GStreamer::completeInit(); - } else { - gst_object_unref(guardFactory); - } - - GError *error = nullptr; - GstElement *pipeline = gst_parse_launch( - "videotestsrc num-buffers=10 ! " - "video/x-raw,format=I420,width=320,height=240,framerate=30/1 ! " - "qgcvideosinkbin name=sink", - &error); - if (error) { - const QString msg = QString::fromUtf8(error->message); - g_clear_error(&error); - QFAIL(qPrintable(QStringLiteral("Pipeline parse error: %1").arg(msg))); - } - QVERIFY2(pipeline, "Failed to create YUV passthrough pipeline"); - - GstElement *sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); - QVERIFY2(sinkBin, "Could not find 'sink' element"); - - QVideoSink videoSink; - GstAppSinkAdapter adapter; - - int frameCount = 0; - QVideoFrameFormat::PixelFormat lastPixelFormat = QVideoFrameFormat::Format_Invalid; - QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &adapter, [&](const QVideoFrame &frame) { - frameCount++; - lastPixelFormat = frame.pixelFormat(); - }); - - QVERIFY2(adapter.setup(sinkBin, &videoSink), "GstAppSinkAdapter::setup() failed"); - gst_object_unref(sinkBin); - - QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, - "Pipeline failed to PLAY"); - - GstBus *bus = gst_element_get_bus(pipeline); - GstMessage *msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, - static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); - QVERIFY2(msg, "Pipeline timed out"); - if (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { - gst_message_unref(msg); - gst_object_unref(bus); - adapter.teardown(); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); - QFAIL("YUV passthrough pipeline errored"); - } - QCOMPARE(GST_MESSAGE_TYPE(msg), GST_MESSAGE_EOS); - gst_message_unref(msg); - gst_object_unref(bus); - - QTRY_VERIFY_WITH_TIMEOUT(frameCount > 0, TestTimeout::mediumMs()); - QCOMPARE(lastPixelFormat, QVideoFrameFormat::Format_YUV420P); - - adapter.teardown(); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); -} - -void GStreamerTest::_testAppsinkPtsAndColorimetry() -{ - GstElementFactory *guardFactory = gst_element_factory_find("qgcvideosinkbin"); - if (!guardFactory) { - GStreamer::completeInit(); - } else { - gst_object_unref(guardFactory); - } - - GError *error = nullptr; - GstElement *pipeline = gst_parse_launch( - "videotestsrc num-buffers=5 do-timestamp=true ! " - "video/x-raw,format=I420,width=64,height=48,framerate=30/1," - "colorimetry=(string)bt709 ! " - "qgcvideosinkbin name=sink", - &error); - if (error) { - const QString msg = QString::fromUtf8(error->message); - g_clear_error(&error); - QFAIL(qPrintable(QStringLiteral("Pipeline parse error: %1").arg(msg))); - } - QVERIFY2(pipeline, "Failed to create PTS/colorimetry pipeline"); - - GstElement *sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); - QVERIFY2(sinkBin, "Could not find 'sink' element"); - - QVideoSink videoSink; - GstAppSinkAdapter adapter; - - int frameCount = 0; - qint64 lastStartTime = -1; - QVideoFrameFormat::ColorSpace lastColorSpace = QVideoFrameFormat::ColorSpace_Undefined; - QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &adapter, [&](const QVideoFrame &frame) { - frameCount++; - lastStartTime = frame.startTime(); - lastColorSpace = frame.surfaceFormat().colorSpace(); - }); - - QVERIFY2(adapter.setup(sinkBin, &videoSink), "GstAppSinkAdapter::setup() failed"); - gst_object_unref(sinkBin); - - QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, - "Pipeline failed to PLAY"); - - GstBus *bus = gst_element_get_bus(pipeline); - GstMessage *msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, - static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); - QVERIFY2(msg, "Pipeline timed out"); - if (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { - gst_message_unref(msg); - gst_object_unref(bus); - adapter.teardown(); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); - QFAIL("PTS/colorimetry pipeline errored"); - } - QCOMPARE(GST_MESSAGE_TYPE(msg), GST_MESSAGE_EOS); - gst_message_unref(msg); - gst_object_unref(bus); - - QTRY_VERIFY_WITH_TIMEOUT(frameCount > 0, TestTimeout::mediumMs()); - QVERIFY2(lastStartTime >= 0, "GstBuffer PTS not forwarded to QVideoFrame::startTime"); - QCOMPARE(lastColorSpace, QVideoFrameFormat::ColorSpace_BT709); - - adapter.teardown(); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); -} - -void GStreamerTest::_testQgcVideoSinkBinGpuZeroCopyProperty() -{ - GStreamer::redirectGLibLogging(); - QVERIFY2(GStreamer::completeInit(), "GStreamer::completeInit() failed"); - - GstElementFactory *factory = gst_element_factory_find("qgcvideosinkbin"); - QVERIFY2(factory, "qgcvideosinkbin factory not found"); - - { - GstElement *bin = gst_element_factory_create_full(factory, - "gpu-zerocopy", FALSE, - NULL); - QVERIFY2(bin, "Failed to create qgcvideosinkbin (CPU branch)"); - - gboolean prop = TRUE; - g_object_get(bin, "gpu-zerocopy", &prop, NULL); - QCOMPARE(prop, FALSE); - - GstElement *appsink = gst_bin_get_by_name(GST_BIN(bin), "qgcappsink"); - QVERIFY2(appsink, "appsink missing from CPU bin"); - - GstIterator *it = gst_bin_iterate_elements(GST_BIN(bin)); - int elementCount = 0; - bool sawVideoconvert = false; - gboolean done = FALSE; - GValue val = G_VALUE_INIT; - while (!done) { - switch (gst_iterator_next(it, &val)) { - case GST_ITERATOR_OK: { - ++elementCount; - GstElement *child = GST_ELEMENT(g_value_get_object(&val)); - gchar *name = gst_element_get_name(child); - if (name && QString::fromUtf8(name).startsWith(QStringLiteral("videoconvert"))) { - sawVideoconvert = true; - } - g_free(name); - g_value_reset(&val); - break; - } - case GST_ITERATOR_RESYNC: gst_iterator_resync(it); break; - case GST_ITERATOR_ERROR: - case GST_ITERATOR_DONE: done = TRUE; break; - } - } - g_value_unset(&val); - gst_iterator_free(it); - - // CPU branch: videoconvert + PAR=1/1 capsfilter + appsink (3 children). - QCOMPARE(elementCount, 3); - QVERIFY2(sawVideoconvert, "CPU bin missing videoconvert"); - - gst_object_unref(appsink); - gst_object_unref(bin); - } - - { - // disable-par=TRUE drops the capsfilter (videoconvert + appsink only). - GstElement *bin = gst_element_factory_create_full(factory, - "gpu-zerocopy", FALSE, - "disable-par", TRUE, - NULL); - QVERIFY2(bin, "Failed to create qgcvideosinkbin (CPU branch, disable-par=TRUE)"); - GstIterator *it = gst_bin_iterate_elements(GST_BIN(bin)); - int elementCount = 0; - gboolean done = FALSE; - GValue val = G_VALUE_INIT; - while (!done) { - switch (gst_iterator_next(it, &val)) { - case GST_ITERATOR_OK: ++elementCount; g_value_reset(&val); break; - case GST_ITERATOR_RESYNC: gst_iterator_resync(it); break; - case GST_ITERATOR_ERROR: - case GST_ITERATOR_DONE: done = TRUE; break; - } - } - g_value_unset(&val); - gst_iterator_free(it); - QCOMPARE(elementCount, 2); - gst_object_unref(bin); - } - -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - { - GstElement *bin = gst_element_factory_create_full(factory, - "gpu-zerocopy", TRUE, - NULL); - QVERIFY2(bin, "Failed to create qgcvideosinkbin (GPU branch)"); - - gboolean prop = FALSE; - g_object_get(bin, "gpu-zerocopy", &prop, NULL); - QCOMPARE(prop, TRUE); - - GstElement *appsink = gst_bin_get_by_name(GST_BIN(bin), "qgcappsink"); - QVERIFY2(appsink, "appsink missing from GPU bin"); - - GstIterator *it = gst_bin_iterate_elements(GST_BIN(bin)); - int elementCount = 0; -#if defined(QGC_GST_BIN_USE_GLUPLOAD) - bool sawGlupload = false; -#endif - gboolean done = FALSE; - GValue val = G_VALUE_INIT; - while (!done) { - switch (gst_iterator_next(it, &val)) { - case GST_ITERATOR_OK: { - ++elementCount; - GstElement *child = GST_ELEMENT(g_value_get_object(&val)); - gchar *name = gst_element_get_name(child); -#if defined(QGC_GST_BIN_USE_GLUPLOAD) - if (name && QString::fromUtf8(name).startsWith(QStringLiteral("glupload"))) { - sawGlupload = true; - } -#endif - g_free(name); - g_value_reset(&val); - break; - } - case GST_ITERATOR_RESYNC: gst_iterator_resync(it); break; - case GST_ITERATOR_ERROR: - case GST_ITERATOR_DONE: done = TRUE; break; - } - } - g_value_unset(&val); - gst_iterator_free(it); - - GstCaps *caps = nullptr; - g_object_get(appsink, "caps", &caps, NULL); - QVERIFY2(caps, "GPU bin appsink has null caps"); - gchar *capsStr = gst_caps_to_string(caps); - const QString s = QString::fromUtf8(capsStr ? capsStr : ""); - g_free(capsStr); - gst_caps_unref(caps); - -#if defined(QGC_GST_BIN_USE_GLUPLOAD) - // Linux desktop: bin is glupload → appsink so the appsink demands GLMemory only. - QCOMPARE(elementCount, 2); - QVERIFY2(sawGlupload, "GPU bin missing glupload"); - QVERIFY2(s.contains(QStringLiteral("memory:GLMemory")), - qUtf8Printable(QStringLiteral("GPU bin caps missing memory:GLMemory: ") + s)); #else - QCOMPARE(elementCount, 1); - QVERIFY2(s.contains(QStringLiteral("memory:DMABuf")), - qUtf8Printable(QStringLiteral("GPU bin caps missing memory:DMABuf: ") + s)); -#endif - - gst_object_unref(appsink); - gst_object_unref(bin); - } -#endif - gst_object_unref(factory); -} - -void GStreamerTest::_testGlMemoryDispatch() -{ -#if !defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - QSKIP("GLMemory zero-copy GPU path not compiled (QGC_HAS_GST_GLMEMORY_GPU_PATH undefined)"); -#else -#ifdef Q_OS_MACOS - // On macOS, Qt's Cocoa platform plugin emits an uncategorized warning when - // GStreamer tries to create an OpenGL context outside the main thread. - ignoreLogMessage("default", QtWarningMsg, QRegularExpression(QStringLiteral("This plugin does not support createPlatformOpenGLContext"))); - // GStreamer-GL emits an NSApplication warning on macOS when running outside the main thread. - ignoreLogMessage("Video.GStreamer.GStreamerLogging", QtWarningMsg, - QRegularExpression(QStringLiteral("An NSApplication needs to be running"))); -#endif - GStreamer::redirectGLibLogging(); - QVERIFY2(GStreamer::completeInit(), "GStreamer::completeInit() failed"); - - GstElementFactory *gluploadFactory = gst_element_factory_find("glupload"); - if (!gluploadFactory) { - QSKIP("glupload factory unavailable — gst-gl not registered in this build"); - } - gst_object_unref(gluploadFactory); - - GError *error = nullptr; - GstElement *pipeline = gst_parse_launch( - "videotestsrc num-buffers=10 ! " - "video/x-raw,format=RGBA,width=320,height=240,framerate=30/1 ! " - "glupload ! " - "video/x-raw(memory:GLMemory) ! " - "qgcvideosinkbin name=sink gpu-zerocopy=true", - &error); - if (error) { - const QString msg = QString::fromUtf8(error->message); - g_clear_error(&error); - QSKIP(qPrintable(QStringLiteral("GLMemory pipeline parse skipped: %1").arg(msg))); - } - QVERIFY2(pipeline, "Failed to create GLMemory test pipeline"); - - GstElement *sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); - QVERIFY2(sinkBin, "Could not find 'sink' element"); - GstElement *appsink = gst_bin_get_by_name(GST_BIN(sinkBin), "qgcappsink"); - QVERIFY2(appsink, "Could not find 'qgcappsink' inside sink bin"); - GstPad *appsinkPad = gst_element_get_static_pad(appsink, "sink"); - QVERIFY2(appsinkPad, "appsink has no sink pad"); - - struct ProbeState { - std::atomic bufferCount{0}; - std::atomic glMemoryCount{0}; - } probe; - auto probeCb = +[](GstPad * /*pad*/, GstPadProbeInfo *info, gpointer userData) -> GstPadProbeReturn { - auto *st = static_cast(userData); - GstBuffer *buf = GST_PAD_PROBE_INFO_BUFFER(info); - if (buf) { - st->bufferCount.fetch_add(1, std::memory_order_relaxed); - GstMemory *mem = gst_buffer_peek_memory(buf, 0); - if (mem && mem->allocator && mem->allocator->mem_type - && g_str_has_prefix(mem->allocator->mem_type, "GLMemory")) { - st->glMemoryCount.fetch_add(1, std::memory_order_relaxed); - } - } - return GST_PAD_PROBE_OK; - }; - const gulong probeId = gst_pad_add_probe(appsinkPad, GST_PAD_PROBE_TYPE_BUFFER, probeCb, &probe, nullptr); - QVERIFY(probeId); - - GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING); - if (ret == GST_STATE_CHANGE_FAILURE) { - gst_pad_remove_probe(appsinkPad, probeId); - gst_object_unref(appsinkPad); - gst_object_unref(appsink); - gst_object_unref(sinkBin); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); - QSKIP("GLMemory pipeline failed to PLAY (no GL context available?)"); - } - - GstBus *bus = gst_element_get_bus(pipeline); - GstMessage *msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, - static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); - - QString errMsg; - bool sawError = false; - if (msg && GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { - GError *err = nullptr; - gchar *debug = nullptr; - gst_message_parse_error(msg, &err, &debug); - errMsg = QStringLiteral("%1 (%2)") - .arg(err ? QString::fromUtf8(err->message) : QStringLiteral("?")) - .arg(debug ? QString::fromUtf8(debug) : QString()); - g_clear_error(&err); - g_free(debug); - sawError = true; - } - if (msg) gst_message_unref(msg); - gst_object_unref(bus); - - GstCaps *negotiated = gst_pad_get_current_caps(appsinkPad); - QString negotiatedStr; - if (negotiated) { - gchar *s = gst_caps_to_string(negotiated); - negotiatedStr = QString::fromUtf8(s ? s : ""); - g_free(s); - gst_caps_unref(negotiated); - } - - gst_pad_remove_probe(appsinkPad, probeId); - gst_object_unref(appsinkPad); - gst_object_unref(appsink); - gst_object_unref(sinkBin); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); - - if (sawError) { - // Common in headless envs without an EGL/GLX-capable display. Treat as - // skip so the test is informative when GL is available, harmless when not. - QSKIP(qPrintable(QStringLiteral("GLMemory pipeline error (likely no GL context): %1").arg(errMsg))); - } - QVERIFY2(probe.bufferCount.load() > 0, "No buffers reached qgcappsink under GLMemory caps"); - QVERIFY2(probe.glMemoryCount.load() > 0, - qPrintable(QStringLiteral("GLMemory negotiated but buffers carried non-GL allocator. " - "Buffers: %1, negotiated caps: %2") - .arg(probe.bufferCount.load()).arg(negotiatedStr))); - QVERIFY2(negotiatedStr.contains(QStringLiteral("memory:GLMemory")), - qPrintable(QStringLiteral("Appsink negotiated caps lack memory:GLMemory: %1").arg(negotiatedStr))); -#endif -} - -namespace { - -struct PipelineRunResult { - int frameCount = 0; - QSize lastFrameSize; - QVideoFrameFormat::PixelFormat lastPixelFormat = QVideoFrameFormat::Format_Invalid; - bool eos = false; - QString errorMessage; -}; - -PipelineRunResult runPipelineThroughAdapter(GstAppSinkAdapter &adapter, - QVideoSink &videoSink, - const char *capsLine, - int numBuffers = 5, - bool gpuZerocopy = false) -{ - PipelineRunResult r; - const QString launch = QStringLiteral( - "videotestsrc num-buffers=%1 ! %2 ! qgcvideosinkbin name=sink gpu-zerocopy=%3") - .arg(numBuffers).arg(QString::fromUtf8(capsLine)).arg(gpuZerocopy ? "true" : "false"); - GError *err = nullptr; - GstElement *pipeline = gst_parse_launch(launch.toUtf8().constData(), &err); - if (err) { - r.errorMessage = QString::fromUtf8(err->message); - g_clear_error(&err); - return r; - } - GstElement *sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); - if (!sinkBin || !adapter.setup(sinkBin, &videoSink)) { - r.errorMessage = QStringLiteral("adapter.setup() failed"); - if (sinkBin) gst_object_unref(sinkBin); - gst_object_unref(pipeline); - return r; - } - QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &adapter, - [&](const QVideoFrame &f) { - ++r.frameCount; - r.lastFrameSize = f.size(); - r.lastPixelFormat = f.pixelFormat(); - }); - gst_object_unref(sinkBin); - - if (gst_element_set_state(pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { - r.errorMessage = QStringLiteral("set_state(PLAYING) failed"); - adapter.teardown(); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); - return r; - } - GstBus *bus = gst_element_get_bus(pipeline); - GstMessage *msg = gst_bus_timed_pop_filtered(bus, 5 * GST_SECOND, - static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); - if (msg) { - if (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_EOS) r.eos = true; - else { - GError *e = nullptr; - gst_message_parse_error(msg, &e, nullptr); - r.errorMessage = e ? QString::fromUtf8(e->message) : QStringLiteral("unknown error"); - g_clear_error(&e); - } - gst_message_unref(msg); - } else { - r.errorMessage = QStringLiteral("timeout waiting for EOS"); - } - gst_object_unref(bus); - // Drain any queued videoFrameChanged deliveries (bridged onto this thread) before teardown. - // No positive count target here -- frameCount is asserted by the caller via QTRY -- so this - // is a bounded settle drain rather than a condition wait. - { - QElapsedTimer drain; - drain.start(); - while (drain.elapsed() < 50) { - QCoreApplication::processEvents(QEventLoop::AllEvents, 50); - } - } - adapter.teardown(); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); - return r; -} - -} // namespace - -void GStreamerTest::_testCapsCacheInvalidation() -{ - GStreamer::redirectGLibLogging(); - QVERIFY2(GStreamer::completeInit(), "completeInit failed"); - - QVideoSink videoSink; - GstAppSinkAdapter adapter; - - auto r1 = runPipelineThroughAdapter(adapter, videoSink, - "video/x-raw,format=I420,width=320,height=240,framerate=30/1 ! " - "videoconvert ! video/x-raw,format=BGRA"); - QVERIFY2(r1.eos, qUtf8Printable(QStringLiteral("Session 1: %1").arg(r1.errorMessage))); - QTRY_VERIFY_WITH_TIMEOUT(r1.frameCount > 0, 2000); - QCOMPARE(r1.lastPixelFormat, QVideoFrameFormat::Format_BGRA8888); - - auto r2 = runPipelineThroughAdapter(adapter, videoSink, - "video/x-raw,format=I420,width=320,height=240,framerate=30/1 ! " - "videoconvert ! video/x-raw,format=RGBA"); - QVERIFY2(r2.eos, qUtf8Printable(QStringLiteral("Session 2: %1").arg(r2.errorMessage))); - QTRY_VERIFY_WITH_TIMEOUT(r2.frameCount > 0, 2000); - QCOMPARE(r2.lastPixelFormat, QVideoFrameFormat::Format_RGBA8888); -} - -void GStreamerTest::_testGpuZeroCopyFallback() -{ - GStreamer::redirectGLibLogging(); - QVERIFY2(GStreamer::completeInit(), "completeInit failed"); - -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - const quint64 dmabufBefore = GstDmaBufVideoBuffer::peekMapFailureCount(); -#endif - - QVideoSink videoSink; - GstAppSinkAdapter adapter; - auto r = runPipelineThroughAdapter(adapter, videoSink, - "video/x-raw,format=BGRA,width=320,height=240,framerate=30/1", - /*numBuffers*/ 5, /*gpuZerocopy*/ true); - QVERIFY2(r.eos, qUtf8Printable(QStringLiteral("Pipeline: %1").arg(r.errorMessage))); - QTRY_VERIFY_WITH_TIMEOUT(r.frameCount > 0, 2000); - QCOMPARE(r.lastPixelFormat, QVideoFrameFormat::Format_BGRA8888); - -#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) - QCOMPARE(GstDmaBufVideoBuffer::peekMapFailureCount(), dmabufBefore); -#endif -} - -void GStreamerTest::_testAppsinkTeardownUnderLoad() -{ - GStreamer::redirectGLibLogging(); - QVERIFY2(GStreamer::completeInit(), "completeInit failed"); - - QVideoSink videoSink; - GstAppSinkAdapter adapter; - - GError *err = nullptr; - GstElement *pipeline = gst_parse_launch( - "videotestsrc is-live=true ! " - "video/x-raw,format=BGRA,width=160,height=120,framerate=60/1 ! " - "qgcvideosinkbin name=sink", &err); - if (err) { g_clear_error(&err); } - QVERIFY(pipeline); - - GstElement *sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); - QVERIFY(sinkBin); - QVERIFY(adapter.setup(sinkBin, &videoSink)); - gst_object_unref(sinkBin); - - QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); - // Tear down only once the pipeline is genuinely "under load" (frames reaching the appsink), - // rather than after a fixed sleep that may race ahead of the first buffer. - QVERIFY(UnitTest::waitForCondition([&] { return adapter.appsinkInputFrames() > 0; }, - TestTimeout::shortMs(), QStringLiteral("appsinkInputFrames() > 0"))); - adapter.teardown(); - - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); - - auto r = runPipelineThroughAdapter(adapter, videoSink, - "video/x-raw,format=RGBA,width=160,height=120,framerate=30/1"); - QVERIFY2(r.eos, qUtf8Printable(QStringLiteral("Restart: %1").arg(r.errorMessage))); - QTRY_VERIFY_WITH_TIMEOUT(r.frameCount > 0, 2000); - QCOMPARE(r.lastPixelFormat, QVideoFrameFormat::Format_RGBA8888); -} - -void GStreamerTest::_testBridgeDispatcherFanout() -{ -#if !defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) && !defined(QGC_HAS_GST_D3D11_GPU_PATH) \ - && !defined(QGC_HAS_GST_D3D12_GPU_PATH) - QSKIP("No GPU bridge compiled in this build"); -#else - GStreamer::redirectGLibLogging(); - QVERIFY2(GStreamer::completeInit(), "completeInit failed"); - - GstElement *dummy = gst_element_factory_make("identity", nullptr); - QVERIFY(dummy); - GstMessage *unrelated = gst_message_new_need_context(GST_OBJECT(dummy), - "totally.unrelated.context"); - QVERIFY(unrelated); -# if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - QCOMPARE(GstGlContextBridge::handleSyncMessage(unrelated), GST_BUS_PASS); -# endif -# if defined(QGC_HAS_GST_D3D11_GPU_PATH) - QCOMPARE(GstD3D11ContextBridge::handleSyncMessage(unrelated), GST_BUS_PASS); -# endif -# if defined(QGC_HAS_GST_D3D12_GPU_PATH) - QCOMPARE(GstD3D12ContextBridge::handleSyncMessage(unrelated), GST_BUS_PASS); -# endif - gst_message_unref(unrelated); - -# if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) - GstMessage *glReq = gst_message_new_need_context(GST_OBJECT(dummy), - GST_GL_DISPLAY_CONTEXT_TYPE); - QVERIFY(glReq); - const GstBusSyncReply r = GstGlContextBridge::handleSyncMessage(glReq); - // Either PASS (couldn't prime — expected in CI without GL) or DROP (primed - // and consumed). Both are valid; the contract is "never crash". - QVERIFY(r == GST_BUS_PASS || r == GST_BUS_DROP); - if (r == GST_BUS_PASS) gst_message_unref(glReq); -# endif - - gst_object_unref(dummy); -#endif -} - -void GStreamerTest::_testHwBufferMapTexturesGuard() -{ -#if !defined(QGC_HAS_GST_DMABUF_GPU_PATH) - QSKIP("DMABuf GPU path not compiled in this build"); -#else - GStreamer::redirectGLibLogging(); - - const quint64 failsBefore = GstDmaBufVideoBuffer::peekMapFailureCount(); - - GstElement *pipeline = gst_parse_launch( - "videotestsrc num-buffers=1 ! " - R"(capsfilter caps="video/x-raw(memory:DMABuf),format=NV12,width=64,height=64" ! )" - "fakesink name=sink sync=false", - nullptr); - if (!pipeline) { - QSKIP("Could not construct DMABuf test pipeline (element missing)"); - } - - GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PAUSED); - if (ret == GST_STATE_CHANGE_FAILURE) { - gst_object_unref(pipeline); - QSKIP("DMABuf pipeline failed to reach PAUSED (no DMABuf source on this machine)"); - } - gst_element_get_state(pipeline, nullptr, nullptr, 2 * GST_SECOND); - - GstElement *fakesink = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); - GstSample *sample = nullptr; - if (fakesink) { - g_object_get(fakesink, "last-sample", &sample, nullptr); - gst_object_unref(fakesink); - } - - if (!sample) { - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); - QSKIP("No DMABuf sample produced (caps negotiation failed on this machine)"); - } - - GstVideoInfo info; - GstCaps *caps = gst_sample_get_caps(sample); - gst_video_info_from_caps(&info, caps); - QVideoFrameFormat fmt(QSize(64, 64), QVideoFrameFormat::Format_NV12); - - GstDmaBufVideoBuffer buf(sample, info, fmt, EGL_NO_DISPLAY); - QVideoFrameTexturesUPtr old; - QVideoFrameTexturesUPtr result = buf.mapTextures(*reinterpret_cast(1), old); - QVERIFY(!result); - - const quint64 failsAfter = GstDmaBufVideoBuffer::peekMapFailureCount(); - QVERIFY(failsAfter > failsBefore); - - gst_sample_unref(sample); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); -#endif -} - - -void GStreamerTest::_testFrameCountsTelemetrySignal() -{ - GStreamer::redirectGLibLogging(); - QVERIFY2(GStreamer::completeInit(), "completeInit failed"); - - QVideoSink videoSink; - GstAppSinkAdapter adapter; - - QSignalSpy spy(&adapter, &GstAppSinkAdapter::frameCountsChanged); - - GError *err = nullptr; - GstElement *pipeline = gst_parse_launch( - "videotestsrc is-live=false num-buffers=90 ! " - "video/x-raw,format=BGRA,width=320,height=240,framerate=30/1 ! " - "qgcvideosinkbin name=sink", - &err); - if (err) { g_clear_error(&err); } - QVERIFY2(pipeline, "Pipeline construction failed"); - - GstElement *sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); - QVERIFY(sinkBin); - QVERIFY(adapter.setup(sinkBin, &videoSink)); - gst_object_unref(sinkBin); - - QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); - - // Timer fires at 1 Hz. 90 frames @ 30 fps = 3 s pipeline; give 5 s total. - // QTRY_VERIFY processes the event loop, allowing the QTimer to fire. - QTRY_VERIFY_WITH_TIMEOUT(spy.count() > 0, 5000); - - QVERIFY(adapter.cpuFrameCount() > 0); - QCOMPARE(adapter.gpuFallbackCount(), quint64(0)); - - adapter.teardown(); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); -} - -void GStreamerTest::_testGetAppsinkAccessor() -{ - GStreamer::redirectGLibLogging(); - QVERIFY2(GStreamer::completeInit(), "completeInit failed"); - - GstElement *bin = gst_element_factory_make("qgcvideosinkbin", nullptr); - QVERIFY2(bin, "qgcvideosinkbin factory not found"); - - // Construction is synchronous (GObject::constructed runs inside factory_make). - GstElement *appsink = gst_qgc_video_sink_bin_get_appsink(GST_QGC_VIDEO_SINK_BIN(bin)); - QVERIFY2(appsink, "appsink accessor returned NULL after factory_make"); - QVERIFY(GST_IS_ELEMENT(appsink)); - gst_object_unref(appsink); - - // After transitioning to READY the accessor must still return a valid element. - GstStateChangeReturn ret = gst_element_set_state(bin, GST_STATE_READY); - QVERIFY(ret != GST_STATE_CHANGE_FAILURE); - - GstElement *appsink2 = gst_qgc_video_sink_bin_get_appsink(GST_QGC_VIDEO_SINK_BIN(bin)); - QVERIFY2(appsink2, "appsink accessor returned NULL after READY"); - QVERIFY(GST_IS_ELEMENT(appsink2)); - gst_object_unref(appsink2); - - gst_element_set_state(bin, GST_STATE_NULL); - gst_object_unref(bin); -} - -void GStreamerTest::_testContextBridgeRegistry() -{ -#if !defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) && !defined(QGC_HAS_GST_D3D11_GPU_PATH) \ - && !defined(QGC_HAS_GST_D3D12_GPU_PATH) - QSKIP("No GPU context bridge compiled in this build"); -#else - GStreamer::redirectGLibLogging(); - QVERIFY2(GStreamer::completeInit(), "completeInit failed"); - - GstContextBridgeRegistry::clearForTest(); - - static bool s_invoked = false; - s_invoked = false; - - GstContextBridgeRegistry::registerBridgeHandler([](GstMessage *msg) -> GstBusSyncReply { - const gchar *type = nullptr; - gst_message_parse_context_type(msg, &type); - if (type && std::string_view(type) == "test-bridge-only") { - s_invoked = true; - return GST_BUS_DROP; - } - return GST_BUS_PASS; - }); - - GstElement *dummy = gst_element_factory_make("identity", nullptr); - QVERIFY(dummy); - - GstMessage *hit = gst_message_new_need_context(GST_OBJECT(dummy), "test-bridge-only"); - QVERIFY(hit); - QCOMPARE(GstContextBridgeRegistry::dispatchBridges(hit), GST_BUS_DROP); - QVERIFY(s_invoked); - gst_message_unref(hit); - - s_invoked = false; - GstMessage *miss = gst_message_new_need_context(GST_OBJECT(dummy), "other-context-type"); - QVERIFY(miss); - QCOMPARE(GstContextBridgeRegistry::dispatchBridges(miss), GST_BUS_PASS); - QVERIFY(!s_invoked); - gst_message_unref(miss); - - // Reset-callback round-trip: registerResetCallback + resetAllBridges must invoke every cb. - GstContextBridgeRegistry::clearForTest(); - static int s_resetCount = 0; - s_resetCount = 0; - GstContextBridgeRegistry::registerResetCallback([]() { ++s_resetCount; }); - GstContextBridgeRegistry::registerResetCallback([]() { s_resetCount += 10; }); - GstContextBridgeRegistry::resetAllBridges(); - QCOMPARE(s_resetCount, 11); - // clearForTest must invoke pending reset callbacks before zeroing the slots so cached - // bridge state can't leak across test cases. Drop the prior round's callbacks first so we - // measure exactly the new callback's invocation count, not 1+10+1=12 from leftovers. - GstContextBridgeRegistry::clearForTest(); - s_resetCount = 0; - GstContextBridgeRegistry::registerResetCallback([]() { ++s_resetCount; }); - GstContextBridgeRegistry::clearForTest(); - QCOMPARE(s_resetCount, 1); - GstContextBridgeRegistry::resetAllBridges(); // post-clear: no callbacks → no-op - QCOMPARE(s_resetCount, 1); - - // Coexistence: two bridges with different context types must not consume each other's messages. - GstContextBridgeRegistry::clearForTest(); - static bool s_aHit = false; - static bool s_bHit = false; - s_aHit = s_bHit = false; - GstContextBridgeRegistry::registerBridgeHandler([](GstMessage *m) -> GstBusSyncReply { - const gchar *t = nullptr; - gst_message_parse_context_type(m, &t); - if (t && std::string_view(t) == "type-A") { s_aHit = true; return GST_BUS_DROP; } - return GST_BUS_PASS; - }); - GstContextBridgeRegistry::registerBridgeHandler([](GstMessage *m) -> GstBusSyncReply { - const gchar *t = nullptr; - gst_message_parse_context_type(m, &t); - if (t && std::string_view(t) == "type-B") { s_bHit = true; return GST_BUS_DROP; } - return GST_BUS_PASS; - }); - GstMessage *msgA = gst_message_new_need_context(GST_OBJECT(dummy), "type-A"); - QCOMPARE(GstContextBridgeRegistry::dispatchBridges(msgA), GST_BUS_DROP); - QVERIFY(s_aHit); - QVERIFY(!s_bHit); - gst_message_unref(msgA); - GstMessage *msgB = gst_message_new_need_context(GST_OBJECT(dummy), "type-B"); - QCOMPARE(GstContextBridgeRegistry::dispatchBridges(msgB), GST_BUS_DROP); - QVERIFY(s_bHit); - gst_message_unref(msgB); - - gst_object_unref(dummy); -#endif -} - -void GStreamerTest::_testHwBufferFactoryDispatchSystemMemory() -{ -#if !defined(QGC_HAS_ANY_GPU_PATH) - QSKIP("HwVideoBuffer factory not compiled in this build (no GPU path enabled)"); -#else - GStreamer::redirectGLibLogging(); - QVERIFY2(GStreamer::completeInit(), "completeInit failed"); - - GError *err = nullptr; - // videotestsrc produces system memory; no GPU path should match. - GstElement *pipeline = gst_parse_launch( - "videotestsrc num-buffers=1 ! " - "video/x-raw,format=BGRA,width=64,height=64,framerate=30/1 ! " - "fakesink name=sink enable-last-sample=true sync=false", - &err); - if (err) { g_clear_error(&err); } - QVERIFY2(pipeline, "Pipeline construction failed"); - - QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); - GstBus *bus = gst_element_get_bus(pipeline); - GstMessage *msg = gst_bus_timed_pop_filtered(bus, 5 * GST_SECOND, - static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); - gst_object_unref(bus); - if (msg) gst_message_unref(msg); - - GstElement *fakesink = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); - QVERIFY(fakesink); - GstSample *sample = nullptr; - g_object_get(fakesink, "last-sample", &sample, nullptr); - gst_object_unref(fakesink); - - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); - - if (!sample) QSKIP("No sample produced (fakesink enable-last-sample may be off)"); - - GstVideoInfo info; - GstCaps *caps = gst_sample_get_caps(sample); - QVERIFY(gst_video_info_from_caps(&info, caps)); - - QVideoFrameFormat fmt(QSize(64, 64), QVideoFrameFormat::Format_BGRA8888); - HwVideoBufferPath path = HwVideoBufferPath::None; - - auto buf = makeHwVideoBuffer(sample, info, fmt, /*gpuEnabled=*/ true, - /*eglDisplay=*/ nullptr, - /*ahbEglDisplay=*/ nullptr, path); - QVERIFY(!buf); - QCOMPARE(path, HwVideoBufferPath::None); - - auto buf2 = makeHwVideoBuffer(sample, info, fmt, /*gpuEnabled=*/ false, - /*eglDisplay=*/ nullptr, - /*ahbEglDisplay=*/ nullptr, path); - QVERIFY(!buf2); - - gst_sample_unref(sample); -#endif -} - -void GStreamerTest::_testCpuMemcpyActiveRowStrideHandling() -{ - GStreamer::redirectGLibLogging(); - QVERIFY2(GStreamer::completeInit(), "completeInit failed"); - - // Width 753 is not 4-byte aligned; GStreamer will pad stride to 756. - // If the memcpy copies stride bytes instead of active-row bytes, the - // right-edge pixels of the right-most component will contain padding zeros. - QVideoSink videoSink; - GstAppSinkAdapter adapter; - - QVideoFrame capturedFrame; - QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &adapter, - [&](const QVideoFrame &f) { capturedFrame = f; }); - - auto r = runPipelineThroughAdapter(adapter, videoSink, - "video/x-raw,format=BGRA,width=753,height=432,framerate=30/1", - /*numBuffers=*/ 5); - QVERIFY2(r.eos, qUtf8Printable(QStringLiteral("Pipeline: %1").arg(r.errorMessage))); - QTRY_VERIFY_WITH_TIMEOUT(capturedFrame.isValid(), 2000); - - QVERIFY(capturedFrame.map(QVideoFrame::ReadOnly)); - const uchar *data = capturedFrame.bits(0); - const int stride = capturedFrame.bytesPerLine(0); - // Sample the last active pixel column (x=752) from row 0; BGRA so 4 bytes/pixel. - const uchar *lastPixel = data + 752 * 4; - // videotestsrc pattern=0 (smpte) fills with non-zero color in top rows. - const bool nonZero = (lastPixel[0] | lastPixel[1] | lastPixel[2] | lastPixel[3]) != 0; - capturedFrame.unmap(); - - (void)stride; - QVERIFY2(nonZero, "Last active column is all zeros — likely stride vs active-row memcpy bug"); -} - -void GStreamerTest::_testQGCRhiCaptureCacheLifecycle() -{ -#if !defined(QGC_HAS_ANY_GPU_PATH) - QSKIP("QGCRhiCapture not compiled in this build (no GPU path enabled)"); -#else - // cachedRhi() returns nullptr before any window has been connected. - QVERIFY(!QGCRhiCapture::cachedRhi()); - - // Create an offscreen window and connect it. The scene graph is never - // initialized here so cachedRhi() remains nullptr. - auto *window = new QQuickWindow(); - QGCRhiCapture::connectWindow(window); - QVERIFY(!QGCRhiCapture::cachedRhi()); - - // Destroying the window must clear the cache (no dangling QRhi*). - delete window; - QVERIFY(!QGCRhiCapture::cachedRhi()); -#endif -} - -void GStreamerTest::_testColorimetryPixelFormatMapping() -{ - // Every format advertised in qgcvideosinkbin caps must round-trip through toQtPixelFormat - // to a non-Invalid Qt format. Format_Invalid here means onNewSample() returns - // GST_FLOW_ERROR for any frame Qt can negotiate — total frame-delivery loss. - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_NV12), QVideoFrameFormat::Format_NV12); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_NV21), QVideoFrameFormat::Format_NV21); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_I420), QVideoFrameFormat::Format_YUV420P); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_YV12), QVideoFrameFormat::Format_YV12); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_Y42B), QVideoFrameFormat::Format_YUV422P); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_P010_10LE), QVideoFrameFormat::Format_P010); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_AYUV), QVideoFrameFormat::Format_AYUV); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_YUY2), QVideoFrameFormat::Format_YUYV); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_UYVY), QVideoFrameFormat::Format_UYVY); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_GRAY8), QVideoFrameFormat::Format_Y8); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_GRAY16_LE), QVideoFrameFormat::Format_Y16); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_BGRA), QVideoFrameFormat::Format_BGRA8888); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_RGBA), QVideoFrameFormat::Format_RGBA8888); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_I420_10LE), QVideoFrameFormat::Format_YUV420P10); - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_P016_LE), QVideoFrameFormat::Format_P016); - // Y444 is intentionally NOT in caps — Qt 6.10 has no Format_YUV444*, so any negotiation - // would dead-end at GST_FLOW_ERROR. Re-enable when Qt grows the enum. - QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_Y444), QVideoFrameFormat::Format_Invalid); -} - -void GStreamerTest::_testColorimetryColorSpaceMapping() -{ - QCOMPARE(toQtColorSpace(GST_VIDEO_COLOR_MATRIX_SMPTE240M), QVideoFrameFormat::ColorSpace_BT709); - QCOMPARE(toQtColorSpace(GST_VIDEO_COLOR_MATRIX_FCC), QVideoFrameFormat::ColorSpace_BT601); -} - -void GStreamerTest::_testColorimetryTransferMapping() -{ - QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_SRGB), QVideoFrameFormat::ColorTransfer_Gamma22); - // Regression: BT601 used to fall through to BT709 — Qt's own backend maps it distinctly. - QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_BT601), QVideoFrameFormat::ColorTransfer_BT601); - QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_BT709), QVideoFrameFormat::ColorTransfer_BT709); - QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_BT2020_10), QVideoFrameFormat::ColorTransfer_BT709); - QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_SMPTE2084), QVideoFrameFormat::ColorTransfer_ST2084); - QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_ARIB_STD_B67), QVideoFrameFormat::ColorTransfer_STD_B67); - QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_GAMMA10), QVideoFrameFormat::ColorTransfer_Linear); - QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_GAMMA28), QVideoFrameFormat::ColorTransfer_Gamma28); - QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_SMPTE240M), QVideoFrameFormat::ColorTransfer_BT709); - QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_ADOBERGB), QVideoFrameFormat::ColorTransfer_Gamma22); - QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_LOG100), QVideoFrameFormat::ColorTransfer_Unknown); -} - -void GStreamerTest::_testColorimetryFrameRatePropagation() -{ - // Verify that a 30/1 caps framerate is surfaced on the delivered QVideoFrame. - GstElementFactory *guardFactory = gst_element_factory_find("qgcvideosinkbin"); - if (!guardFactory) { - GStreamer::completeInit(); - } else { - gst_object_unref(guardFactory); - } - - GError *error = nullptr; - GstElement *pipeline = gst_parse_launch( - "videotestsrc num-buffers=3 ! " - "video/x-raw,format=I420,width=64,height=48,framerate=30/1 ! " - "qgcvideosinkbin name=sink", - &error); - if (error) { - const QString msg = QString::fromUtf8(error->message); - g_clear_error(&error); - QFAIL(qPrintable(QStringLiteral("Pipeline parse error: %1").arg(msg))); - } - QVERIFY2(pipeline, "Failed to create framerate pipeline"); - - GstElement *sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); - QVERIFY2(sinkBin, "Could not find 'sink' element"); - - QVideoSink videoSink; - GstAppSinkAdapter adapter; - - QVideoFrameFormat lastFormat; - QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &adapter, [&](const QVideoFrame &frame) { - lastFormat = frame.surfaceFormat(); - }); - - QVERIFY2(adapter.setup(sinkBin, &videoSink), "GstAppSinkAdapter::setup() failed"); - gst_object_unref(sinkBin); - - QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, - "Pipeline failed to PLAY"); - - GstBus *bus = gst_element_get_bus(pipeline); - GstMessage *msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, - static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); - QVERIFY2(msg, "Pipeline timed out"); - const bool isError = (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR); - gst_message_unref(msg); - gst_object_unref(bus); - if (isError) { - adapter.teardown(); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); - QFAIL("Framerate pipeline errored"); - } - - QTRY_VERIFY_WITH_TIMEOUT(lastFormat.isValid(), TestTimeout::mediumMs()); - QCOMPARE(lastFormat.streamFrameRate(), 30.0); - - adapter.teardown(); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); -} - -namespace { - -#if defined(QGC_HAS_ANY_GPU_PATH) -// Concrete subclass for unit-testing GstHwVideoBuffer's protected/base behavior. -class TestableHwVideoBuffer : public GstHwVideoBuffer -{ -public: - TestableHwVideoBuffer(GstSample *sample, const GstVideoInfo &info, QVideoFrameFormat fmt) - : GstHwVideoBuffer(QVideoFrame::NoHandle, sample, info, std::move(fmt)) {} - MapData map(QVideoFrame::MapMode) override { return {}; } - QVideoFrameTexturesUPtr mapTextures(QRhi &, QVideoFrameTexturesUPtr &) override { return {}; } -}; -#endif - -// Minimal GstSample factory: NV12 buffer at given size, optionally with a crop meta. -GstSample *makeNv12Sample(int width, int height, GstVideoInfo *outInfo, - bool addCrop, int cx, int cy, int cw, int ch) -{ - GstVideoInfo info; - gst_video_info_set_format(&info, GST_VIDEO_FORMAT_NV12, width, height); - GstCaps *caps = gst_video_info_to_caps(&info); - GstBuffer *buf = gst_buffer_new_allocate(nullptr, GST_VIDEO_INFO_SIZE(&info), nullptr); - if (addCrop) { - GstVideoCropMeta *crop = gst_buffer_add_video_crop_meta(buf); - crop->x = cx; crop->y = cy; crop->width = cw; crop->height = ch; - } - GstSample *sample = gst_sample_new(buf, caps, nullptr, nullptr); - gst_buffer_unref(buf); - gst_caps_unref(caps); - if (outInfo) *outInfo = info; - return sample; -} - -} // namespace - -void GStreamerTest::_testHwBufferCropMatrixIdentityWithoutMeta() -{ -#if !defined(QGC_HAS_ANY_GPU_PATH) - QSKIP("GstHwVideoBuffer not compiled in this build (no GPU path enabled)"); -#else - GstVideoInfo info; - GstSample *sample = makeNv12Sample(320, 240, &info, /*addCrop=*/false, 0, 0, 0, 0); - QVERIFY(sample); - QVideoFrameFormat fmt(QSize(320, 240), QVideoFrameFormat::Format_NV12); - TestableHwVideoBuffer hw(sample, info, fmt); - // Default externalTextureMatrix from QHwVideoBuffer is identity; we don't override it - // for crop because Qt only consults externalTextureMatrix for Format_SamplerExternalOES. - QCOMPARE(hw.externalTextureMatrix(), QMatrix4x4()); - gst_sample_unref(sample); -#endif -} - -void GStreamerTest::_testHwBufferCropMatrixFromVideoCropMeta() -{ - // Regression: GstVideoCropMeta is propagated via QVideoFrameFormat::viewport() in - // GstAppSinkAdapter::applyCropMeta, NOT via externalTextureMatrix. Verify the format - // path round-trips a crop rect into viewport(). - GstVideoInfo info; - GstSample *sample = makeNv12Sample(400, 200, &info, /*addCrop=*/true, - /*cx=*/100, /*cy=*/50, /*cw=*/200, /*ch=*/100); - QVERIFY(sample); - GstBuffer *buf = gst_sample_get_buffer(sample); - - QVideoFrameFormat in(QSize(400, 200), QVideoFrameFormat::Format_NV12); - QVideoFrameFormat out = applyCropMeta(in, buf); - QCOMPARE(out.viewport(), QRect(100, 50, 200, 100)); - QCOMPARE(out.frameSize(), QSize(400, 200)); // unchanged - - gst_sample_unref(sample); -} - -void GStreamerTest::_testApplyOrientationToFrameMapping() -{ -#ifndef QGC_HAS_GST_VIDEO_ORIENTATION_META - QSKIP("GStreamer build lacks GstVideoOrientationMeta"); -#else - // Verifies each GStreamer orientation enum maps to the correct (rotation, mirrored) tuple - // on QVideoFrame. Lock-down test: prior versions had subtle mismatches between gst's - // diagonal-flip semantics and Qt's rotate-then-mirror composition. - struct Case { - GstVideoOrientationMethod gst; - QtVideo::Rotation expectedRot; - bool expectedMirrored; - }; - const Case cases[] = { - { GST_VIDEO_ORIENTATION_IDENTITY, QtVideo::Rotation::None, false }, - { GST_VIDEO_ORIENTATION_90R, QtVideo::Rotation::Clockwise90, false }, - { GST_VIDEO_ORIENTATION_180, QtVideo::Rotation::Clockwise180, false }, - { GST_VIDEO_ORIENTATION_90L, QtVideo::Rotation::Clockwise270, false }, - { GST_VIDEO_ORIENTATION_HORIZ, QtVideo::Rotation::None, true }, - { GST_VIDEO_ORIENTATION_VERT, QtVideo::Rotation::Clockwise180, true }, - { GST_VIDEO_ORIENTATION_UL_LR, QtVideo::Rotation::Clockwise90, true }, - { GST_VIDEO_ORIENTATION_UR_LL, QtVideo::Rotation::Clockwise270, true }, - }; - for (const Case &c : cases) { - QVideoFrame frame{QVideoFrameFormat(QSize(2, 2), QVideoFrameFormat::Format_BGRA8888)}; - // Pre-poison so a no-op switch case (default branch) wouldn't accidentally match. - frame.setRotation(QtVideo::Rotation::Clockwise90); - frame.setMirrored(true); - applyOrientationToFrame(frame, c.gst); - QCOMPARE(frame.rotation(), c.expectedRot); - QCOMPARE(frame.mirrored(), c.expectedMirrored); - } -#endif -} - -void GStreamerTest::_testAdapterFlushDropsInFlightSamples() +void GStreamerTest::init() { - // Push GST_EVENT_FLUSH_START upstream of the adapter; verify subsequent buffers don't - // surface as QVideoFrames (the new_sample callback short-circuits on _flushing). Then - // push FLUSH_STOP and verify delivery resumes. - GStreamer::redirectGLibLogging(); - QVERIFY2(GStreamer::completeInit(), "completeInit failed"); - - QVideoSink sink; - GstAppSinkAdapter adapter; - - std::atomic deliveredFrames{0}; - QObject::connect(&sink, &QVideoSink::videoFrameChanged, &adapter, - [&](const QVideoFrame &) { deliveredFrames.fetch_add(1, std::memory_order_relaxed); }); - - GError *err = nullptr; - // identity is_live=false to avoid the live-source latency offset; videotestsrc → identity → qgcvideosinkbin. - GstElement *pipeline = gst_parse_launch( - "videotestsrc num-buffers=10 ! " - "video/x-raw,format=BGRA,width=64,height=48,framerate=30/1 ! " - "identity name=id ! " - "qgcvideosinkbin name=sink", - &err); - if (err) { g_clear_error(&err); } - QVERIFY2(pipeline, "Pipeline construction failed"); - GstElement *sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); - QVERIFY(sinkBin); - QVERIFY2(adapter.setup(sinkBin, &sink), "adapter.setup() failed"); - gst_object_unref(sinkBin); - - QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); - // Wait for a few frames to confirm baseline delivery works. - QTRY_VERIFY_WITH_TIMEOUT(deliveredFrames.load() > 0, 3000); - const int beforeFlush = deliveredFrames.load(std::memory_order_relaxed); - - // Send FLUSH_START upstream of the appsink. identity element forwards events. - GstElement *id = gst_bin_get_by_name(GST_BIN(pipeline), "id"); - QVERIFY(id); - GstPad *idSrc = gst_element_get_static_pad(id, "src"); - QVERIFY(idSrc); - QVERIFY(gst_pad_send_event(gst_pad_get_peer(idSrc), gst_event_new_flush_start())); - - // After FLUSH_START, push a few more buffers; they should all be dropped by the adapter. - // Use a short window so the test stays under a second. - const int duringFlushBaseline = deliveredFrames.load(std::memory_order_relaxed); - // Negative assertion: verify NO new frames are delivered while flushing. There is no - // positive condition to QTRY on -- we must hold a bounded window open and confirm the - // count stayed flat -- so keep a minimal fixed wait while pumping queued deliveries. - { - QElapsedTimer flushWindow; - flushWindow.start(); - while (flushWindow.elapsed() < 150) { - QCoreApplication::processEvents(QEventLoop::AllEvents, 50); - } - } - QCOMPARE(deliveredFrames.load(std::memory_order_relaxed), duringFlushBaseline); - - // Send FLUSH_STOP — restore normal flow. videotestsrc may have emitted EOS by now, - // so we don't strictly require new frames, just that the flag clears (no further drops). - QVERIFY(gst_pad_send_event(gst_pad_get_peer(idSrc), - gst_event_new_flush_stop(/*reset_time=*/ TRUE))); - QCOMPARE(adapter.appsinkInputFrames() >= quint64(beforeFlush), true); // sanity - - gst_object_unref(idSrc); - gst_object_unref(id); - adapter.teardown(); - gst_element_set_state(pipeline, GST_STATE_NULL); - gst_object_unref(pipeline); + UnitTest::init(); } -#else - -void GStreamerTest::init() { UnitTest::init(); QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testIsValidRtspUri() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testIsHardwareDecoderFactory() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testSetCodecPrioritiesDefault() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testSetCodecPrioritiesSoftware() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testSetCodecPrioritiesHardware() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testRedirectGLibLogging() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testVerifyRequiredPlugins() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testEnvironmentSetup() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testCompleteInit() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testCreateVideoReceiver() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testPipelineSmokeTest() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testRuntimeVersionCheck() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testAppsinkFrameDelivery() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testAppsinkYuvPassthrough() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testAppsinkPtsAndColorimetry() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testQgcVideoSinkBinGpuZeroCopyProperty() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testGlMemoryDispatch() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testCapsCacheInvalidation() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testGpuZeroCopyFallback() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testAppsinkTeardownUnderLoad() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testBridgeDispatcherFanout() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testHwBufferMapTexturesGuard() { QSKIP("GStreamer not enabled"); } - -void GStreamerTest::_testFrameCountsTelemetrySignal() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testGetAppsinkAccessor() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testContextBridgeRegistry() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testHwBufferFactoryDispatchSystemMemory() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testCpuMemcpyActiveRowStrideHandling() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testQGCRhiCaptureCacheLifecycle() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testColorimetryPixelFormatMapping() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testHwBufferCropMatrixIdentityWithoutMeta() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testHwBufferCropMatrixFromVideoCropMeta() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testColorimetryColorSpaceMapping() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testColorimetryTransferMapping() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testColorimetryFrameRatePropagation() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testApplyOrientationToFrameMapping() { QSKIP("GStreamer not enabled"); } -void GStreamerTest::_testAdapterFlushDropsInFlightSamples() { QSKIP("GStreamer not enabled"); } +#define QGC_GST_SKIP_TEST(fn) \ + void GStreamerTest::fn() \ + { \ + QSKIP("GStreamer not enabled"); \ + } + +QGC_GST_SKIP_TEST(_testIsValidRtspUri) +QGC_GST_SKIP_TEST(_testIsHardwareDecoderFactory) +QGC_GST_SKIP_TEST(_testSetCodecPrioritiesDefault) +QGC_GST_SKIP_TEST(_testSetCodecPrioritiesDefaultPrefersMatchingD3DDecoder) +QGC_GST_SKIP_TEST(_testSetCodecPrioritiesSkipsAbsentD3DDecoders) +QGC_GST_SKIP_TEST(_testSetCodecPrioritiesSoftware) +QGC_GST_SKIP_TEST(_testSetCodecPrioritiesHardware) +QGC_GST_SKIP_TEST(_testRedirectGLibLogging) +QGC_GST_SKIP_TEST(_testConfigureDebugLoggingIsIdempotent) +QGC_GST_SKIP_TEST(_testVerifyRequiredPlugins) +QGC_GST_SKIP_TEST(_testEnvironmentSetup) +QGC_GST_SKIP_TEST(_testWritePipelineDotReturnsEmptyOnWriteFailure) +QGC_GST_SKIP_TEST(_testCompleteInit) +QGC_GST_SKIP_TEST(_testCreateVideoReceiver) +QGC_GST_SKIP_TEST(_testBindDebugLevelFactRejectsNullContext) +QGC_GST_SKIP_TEST(_testRuntimeVersionCheck) +QGC_GST_SKIP_TEST(_testAppsinkFrameDelivery) +QGC_GST_SKIP_TEST(_testAppsinkYuvPassthrough) +QGC_GST_SKIP_TEST(_testAppsinkPtsAndColorimetry) +QGC_GST_SKIP_TEST(_testQgcVideoSinkBinGpuZeroCopyProperty) +QGC_GST_SKIP_TEST(_testQgcVideoSinkBinRejectsFailedAdopt) +QGC_GST_SKIP_TEST(_testGlMemoryDispatch) +QGC_GST_SKIP_TEST(_testDmaBufDispatch) +QGC_GST_SKIP_TEST(_testDmaDrmCapsRejectNonLinearModifiers) +QGC_GST_SKIP_TEST(_testDmaBufRejectsNonLinearDirectImport) +QGC_GST_SKIP_TEST(_testDmaBufTiledImportAvoidsTexStorage) +QGC_GST_SKIP_TEST(_testVulkanDispatchDemotesToCpu) +QGC_GST_SKIP_TEST(_testCapsCacheInvalidation) +QGC_GST_SKIP_TEST(_testGpuZeroCopyFallback) +QGC_GST_SKIP_TEST(_testAppsinkTeardownUnderLoad) +QGC_GST_SKIP_TEST(_testBridgeDispatcherFanout) +QGC_GST_SKIP_TEST(_testHwBufferMapTexturesGuard) +QGC_GST_SKIP_TEST(_testFrameCountsTelemetrySignal) +QGC_GST_SKIP_TEST(_testInactiveQgcQVideoSinkDropsAndCounts) +QGC_GST_SKIP_TEST(_testGetAppsinkAccessor) +QGC_GST_SKIP_TEST(_testQVideoSinkControllerClearsElementOnDestroy) +QGC_GST_SKIP_TEST(_testQVideoSinkControllerClearsElementWhenVideoSinkDestroyed) +QGC_GST_SKIP_TEST(_testQVideoSinkControllerNullSinkStillDeactivatesOnDestroy) +QGC_GST_SKIP_TEST(_testQVideoSinkControllerRepeatedSetupKeepsNewBindingActive) +QGC_GST_SKIP_TEST(_testQVideoSinkControllerNoWindowStartsInactive) +QGC_GST_SKIP_TEST(_testContextBridgeRegistry) +QGC_GST_SKIP_TEST(_testHwBufferLifecycleResetsNativeCaches) +QGC_GST_SKIP_TEST(_testHwBufferFactoryDispatchSystemMemory) +QGC_GST_SKIP_TEST(_testHwBufferFactoryCacheRejectsMemoryTypeChange) +QGC_GST_SKIP_TEST(_testDmaBufSingleFdImportEnvGate) +QGC_GST_SKIP_TEST(_testCpuZeroCopyFrameRejectsWritableMap) +QGC_GST_SKIP_TEST(_testCpuMemcpyActiveRowStrideHandling) +QGC_GST_SKIP_TEST(_testQGCRhiCaptureCacheLifecycle) +QGC_GST_SKIP_TEST(_testColorimetryPixelFormatMapping) +QGC_GST_SKIP_TEST(_testCpuCapsFormatsRoundTripToQt) +QGC_GST_SKIP_TEST(_testAllocationQueryHwMemoryPoolHint) +QGC_GST_SKIP_TEST(_testAllocationQuerySystemMemoryNoPoolStillAdvertisesMetas) +QGC_GST_SKIP_TEST(_testColorimetryColorSpaceMapping) +QGC_GST_SKIP_TEST(_testColorimetryResolutionHeuristicMatchesQt) +QGC_GST_SKIP_TEST(_testColorimetryTransferMapping) +QGC_GST_SKIP_TEST(_testColorimetryFrameRatePropagation) +QGC_GST_SKIP_TEST(_testHwBufferCropMatrixIdentityWithoutMeta) +QGC_GST_SKIP_TEST(_testHwBufferCropMatrixFromVideoCropMeta) +QGC_GST_SKIP_TEST(_testApplyOrientationToFrameMapping) +QGC_GST_SKIP_TEST(_testAdapterFlushDropsInFlightSamples) +QGC_GST_SKIP_TEST(_testSourceFactoryUdpRtpJitterBuffer) +QGC_GST_SKIP_TEST(_testSourceFactoryJitterBufferNone) +QGC_GST_SKIP_TEST(_testSourceFactoryNoRetransmission) +QGC_GST_SKIP_TEST(_testSourceFactoryRtspExcludesStaticJitterBuffer) +QGC_GST_SKIP_TEST(_testSourceFactoryRejectsBadUri) +QGC_GST_SKIP_TEST(_testSourceFactoryTcpMpegTs) +QGC_GST_SKIP_TEST(_testSourceFactoryRejectsBadTcpUri) +QGC_GST_SKIP_TEST(_testSourceFactoryUdp265Caps) +QGC_GST_SKIP_TEST(_testSourceFactoryUdpH264Caps) +QGC_GST_SKIP_TEST(_testSourceFactoryUdpMpegTs) +QGC_GST_SKIP_TEST(_testSourceFactorySchemeCaseInsensitive) +QGC_GST_SKIP_TEST(_testSourceFactoryNegativeLatencyClamped) +QGC_GST_SKIP_TEST(_testSourceFactoryDynamicRtpLinkFailureCleansJitterBuffer) +QGC_GST_SKIP_TEST(_testColorimetryColorRangeMapping) +QGC_GST_SKIP_TEST(_testPixelFormatAcceptedButNotAdvertised) +QGC_GST_SKIP_TEST(_testAdvertisedFormatListMatchesTable) +QGC_GST_SKIP_TEST(_testTelemetryMapDurationEwma) +QGC_GST_SKIP_TEST(_testTelemetryFallbackReasonMatrix) +QGC_GST_SKIP_TEST(_testTelemetrySyncWaitSplit) +QGC_GST_SKIP_TEST(_testTelemetryPathStatsFailuresAreNotDelivered) +QGC_GST_SKIP_TEST(_testTelemetryDmaBufExtraStatsDrain) + +#undef QGC_GST_SKIP_TEST #endif UT_REGISTER_TEST(GStreamerTest, TestLabel::Integration) diff --git a/test/VideoManager/GStreamer/GStreamerTest.h b/test/VideoManager/GStreamer/GStreamerTest.h index 3e6ca0bff618..f1658fe73461 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.h +++ b/test/VideoManager/GStreamer/GStreamerTest.h @@ -12,37 +12,92 @@ private slots: void _testIsValidRtspUri(); void _testIsHardwareDecoderFactory(); void _testSetCodecPrioritiesDefault(); + void _testSetCodecPrioritiesDefaultPrefersMatchingD3DDecoder(); + void _testSetCodecPrioritiesSkipsAbsentD3DDecoders(); + void _testD3D12RhiDisablesGpuZeroCopySink(); void _testSetCodecPrioritiesSoftware(); void _testSetCodecPrioritiesHardware(); void _testRedirectGLibLogging(); + void _testConfigureDebugLoggingIsIdempotent(); void _testVerifyRequiredPlugins(); void _testEnvironmentSetup(); + void _testWritePipelineDotReturnsEmptyOnWriteFailure(); void _testCompleteInit(); void _testCreateVideoReceiver(); - void _testPipelineSmokeTest(); + void _testBindDebugLevelFactRejectsNullContext(); void _testRuntimeVersionCheck(); void _testAppsinkFrameDelivery(); void _testAppsinkYuvPassthrough(); void _testAppsinkPtsAndColorimetry(); void _testQgcVideoSinkBinGpuZeroCopyProperty(); + void _testQgcVideoSinkBinRejectsFailedAdopt(); void _testGlMemoryDispatch(); + void _testD3D11MemoryDispatch(); + void _testD3D12MemoryDispatch(); + void _testD3D11MapTexturesWithQRhi(); + void _testD3D11MapNv12TexturesWithQRhi(); + void _testD3D12MapTexturesWithQRhi(); + void _testD3D12MapNv12TexturesWithQRhi(); + void _testDmaBufDispatch(); + void _testDmaDrmCapsRejectNonLinearModifiers(); + void _testDmaBufRejectsNonLinearDirectImport(); + void _testDmaBufTiledImportAvoidsTexStorage(); + void _testVulkanDispatchDemotesToCpu(); void _testCapsCacheInvalidation(); void _testGpuZeroCopyFallback(); void _testAppsinkTeardownUnderLoad(); void _testBridgeDispatcherFanout(); void _testHwBufferMapTexturesGuard(); void _testFrameCountsTelemetrySignal(); + void _testInactiveQgcQVideoSinkDropsAndCounts(); void _testGetAppsinkAccessor(); + void _testQVideoSinkControllerClearsElementOnDestroy(); + void _testQVideoSinkControllerClearsElementWhenVideoSinkDestroyed(); + void _testQVideoSinkControllerNullSinkStillDeactivatesOnDestroy(); + void _testQVideoSinkControllerRepeatedSetupKeepsNewBindingActive(); + void _testQVideoSinkControllerNoWindowStartsInactive(); void _testContextBridgeRegistry(); + void _testHwBufferLifecycleResetsNativeCaches(); + void _testHwFrameTexturesRejectsForeignOldTextures(); void _testHwBufferFactoryDispatchSystemMemory(); + void _testHwBufferFactoryCacheRejectsMemoryTypeChange(); + void _testDmaBufSingleFdImportEnvGate(); + void _testCpuZeroCopyFrameRejectsWritableMap(); void _testCpuMemcpyActiveRowStrideHandling(); void _testQGCRhiCaptureCacheLifecycle(); void _testColorimetryPixelFormatMapping(); + void _testCpuCapsFormatsRoundTripToQt(); + void _testAllocationQueryHwMemoryPoolHint(); + void _testQgcVideoSinkBinAllocationQueryAdvertisesVideoMeta(); + void _testAllocationQuerySystemMemoryNoPoolStillAdvertisesMetas(); void _testColorimetryColorSpaceMapping(); + void _testColorimetryResolutionHeuristicMatchesQt(); void _testColorimetryTransferMapping(); void _testColorimetryFrameRatePropagation(); void _testHwBufferCropMatrixIdentityWithoutMeta(); void _testHwBufferCropMatrixFromVideoCropMeta(); void _testApplyOrientationToFrameMapping(); void _testAdapterFlushDropsInFlightSamples(); + void _testSourceFactoryUdpRtpJitterBuffer(); + void _testSourceFactoryJitterBufferNone(); + void _testSourceFactoryNoRetransmission(); + void _testSourceFactoryRtspExcludesStaticJitterBuffer(); + void _testSourceFactoryRejectsBadUri(); + void _testSourceFactoryTcpMpegTs(); + void _testSourceFactoryRejectsBadTcpUri(); + void _testSourceFactoryUdp265Caps(); + void _testSourceFactoryUdp265UsesExplicitDepayAndParser(); + void _testSourceFactoryUdpH264Caps(); + void _testSourceFactoryUdpMpegTs(); + void _testSourceFactorySchemeCaseInsensitive(); + void _testSourceFactoryNegativeLatencyClamped(); + void _testSourceFactoryDynamicRtpLinkFailureCleansJitterBuffer(); + void _testColorimetryColorRangeMapping(); + void _testPixelFormatAcceptedButNotAdvertised(); + void _testAdvertisedFormatListMatchesTable(); + void _testTelemetryMapDurationEwma(); + void _testTelemetryFallbackReasonMatrix(); + void _testTelemetrySyncWaitSplit(); + void _testTelemetryPathStatsFailuresAreNotDelivered(); + void _testTelemetryDmaBufExtraStatsDrain(); }; diff --git a/test/VideoManager/GStreamer/HwBuffers/common/GStreamerHwBuffersCommonTest.cc b/test/VideoManager/GStreamer/HwBuffers/common/GStreamerHwBuffersCommonTest.cc new file mode 100644 index 000000000000..54041140ae0e --- /dev/null +++ b/test/VideoManager/GStreamer/HwBuffers/common/GStreamerHwBuffersCommonTest.cc @@ -0,0 +1,562 @@ +#include "GStreamerTest.h" + +#ifdef QGC_GST_STREAMING + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Fixtures/RAIIFixtures.h" +#include "Fact.h" +#include "GStreamer.h" +#include "GStreamerHelpers.h" +#include "GStreamerLogging.h" +#include "GstVideoReceiver.h" + +// New sink controller + telemetry includes (GstAppSinkAdapter removed). +#include +#include +#include + +#include "GStreamer.h" +#include "GStreamerFrameMap.h" +#include "CpuVideoFramePool.h" +#include "GstHwPathTelemetry.h" +#include "GstHwVideoBufferFactory.h" +#include "GstSourceFactory.h" +#include "QGCQVideoSinkController.h" +#include "HwBuffers/dmabuf/GstDmaDrmCaps.h" +#include "gstqgc/GstQgcAllocation.h" +#include "gstqgc/GstQgcCaps.h" +#include "gstqgc/GstQgcVideoFormats.h" + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) +#include "GstDmaBufVideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) +#include + +#include "GstGlContextBridge.h" +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) +#include "GstD3D11ContextBridge.h" +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) +#include "GstD3D12ContextBridge.h" +#endif +#include "GstContextBridgeRegistry.h" +#include "GstHwFrameTexturesBase.h" +#include "GstHwVideoBuffer.h" +#include "GstHwVideoBufferFactory.h" +#include "gstqgc/gstqgcvideosinkbin.h" +#if defined(QGC_HAS_ANY_GPU_PATH) +#include "QGCRhiCapture.h" +#endif +#include +#include +#include +#include +#include +#include +#include + +void GStreamerTest::_testBridgeDispatcherFanout() +{ +#if !defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) && !defined(QGC_HAS_GST_D3D11_GPU_PATH) && \ + !defined(QGC_HAS_GST_D3D12_GPU_PATH) + QSKIP("No GPU bridge compiled in this build"); +#else + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + GstElement* dummy = gst_element_factory_make("identity", nullptr); + QVERIFY(dummy); + GstMessage* unrelated = gst_message_new_need_context(GST_OBJECT(dummy), "totally.unrelated.context"); + QVERIFY(unrelated); +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + QCOMPARE(GstGlContextBridge::handleSyncMessage(unrelated), GST_BUS_PASS); +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + QCOMPARE(GstD3D11ContextBridge::handleSyncMessage(unrelated), GST_BUS_PASS); +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + QCOMPARE(GstD3D12ContextBridge::handleSyncMessage(unrelated), GST_BUS_PASS); +#endif + gst_message_unref(unrelated); + +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + GstMessage* glReq = gst_message_new_need_context(GST_OBJECT(dummy), GST_GL_DISPLAY_CONTEXT_TYPE); + QVERIFY(glReq); + const GstBusSyncReply r = GstGlContextBridge::handleSyncMessage(glReq); + // Either PASS (couldn't prime — expected in CI without GL) or DROP (primed + // and consumed). Both are valid; the contract is "never crash". + QVERIFY(r == GST_BUS_PASS || r == GST_BUS_DROP); + if (r == GST_BUS_PASS) + gst_message_unref(glReq); +#endif + + gst_object_unref(dummy); +#endif +} + +void GStreamerTest::_testContextBridgeRegistry() +{ +#if !defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) && !defined(QGC_HAS_GST_D3D11_GPU_PATH) && \ + !defined(QGC_HAS_GST_D3D12_GPU_PATH) + QSKIP("No GPU context bridge compiled in this build"); +#else + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + GstContextBridgeRegistry::clearForTest(); + + static bool s_invoked = false; + s_invoked = false; + + GstContextBridgeRegistry::registerBridgeHandler([](GstMessage* msg) -> GstBusSyncReply { + const gchar* type = nullptr; + gst_message_parse_context_type(msg, &type); + if (type && std::string_view(type) == "test-bridge-only") { + s_invoked = true; + return GST_BUS_DROP; + } + return GST_BUS_PASS; + }); + + GstElement* dummy = gst_element_factory_make("identity", nullptr); + QVERIFY(dummy); + + GstMessage* hit = gst_message_new_need_context(GST_OBJECT(dummy), "test-bridge-only"); + QVERIFY(hit); + QCOMPARE(GstContextBridgeRegistry::dispatchBridges(hit), GST_BUS_DROP); + QVERIFY(s_invoked); + gst_message_unref(hit); + + s_invoked = false; + GstMessage* miss = gst_message_new_need_context(GST_OBJECT(dummy), "other-context-type"); + QVERIFY(miss); + QCOMPARE(GstContextBridgeRegistry::dispatchBridges(miss), GST_BUS_PASS); + QVERIFY(!s_invoked); + gst_message_unref(miss); + + // Reset-callback round-trip: registerResetCallback + resetAllBridges must invoke every cb. + GstContextBridgeRegistry::clearForTest(); + static int s_resetCount = 0; + s_resetCount = 0; + GstContextBridgeRegistry::registerResetCallback([]() { ++s_resetCount; }); + GstContextBridgeRegistry::registerResetCallback([]() { s_resetCount += 10; }); + GstContextBridgeRegistry::resetAllBridges(); + QCOMPARE(s_resetCount, 11); + // clearForTest must invoke pending reset callbacks before zeroing the slots so cached + // bridge state can't leak across test cases. Drop the prior round's callbacks first so we + // measure exactly the new callback's invocation count, not 1+10+1=12 from leftovers. + GstContextBridgeRegistry::clearForTest(); + s_resetCount = 0; + GstContextBridgeRegistry::registerResetCallback([]() { ++s_resetCount; }); + GstContextBridgeRegistry::clearForTest(); + QCOMPARE(s_resetCount, 1); + GstContextBridgeRegistry::resetAllBridges(); // post-clear: no callbacks → no-op + QCOMPARE(s_resetCount, 1); + + // Coexistence: two bridges with different context types must not consume each other's messages. + GstContextBridgeRegistry::clearForTest(); + static bool s_aHit = false; + static bool s_bHit = false; + s_aHit = s_bHit = false; + GstContextBridgeRegistry::registerBridgeHandler([](GstMessage* m) -> GstBusSyncReply { + const gchar* t = nullptr; + gst_message_parse_context_type(m, &t); + if (t && std::string_view(t) == "type-A") { + s_aHit = true; + return GST_BUS_DROP; + } + return GST_BUS_PASS; + }); + GstContextBridgeRegistry::registerBridgeHandler([](GstMessage* m) -> GstBusSyncReply { + const gchar* t = nullptr; + gst_message_parse_context_type(m, &t); + if (t && std::string_view(t) == "type-B") { + s_bHit = true; + return GST_BUS_DROP; + } + return GST_BUS_PASS; + }); + GstMessage* msgA = gst_message_new_need_context(GST_OBJECT(dummy), "type-A"); + QCOMPARE(GstContextBridgeRegistry::dispatchBridges(msgA), GST_BUS_DROP); + QVERIFY(s_aHit); + QVERIFY(!s_bHit); + gst_message_unref(msgA); + GstMessage* msgB = gst_message_new_need_context(GST_OBJECT(dummy), "type-B"); + QCOMPARE(GstContextBridgeRegistry::dispatchBridges(msgB), GST_BUS_DROP); + QVERIFY(s_bHit); + gst_message_unref(msgB); + + gst_object_unref(dummy); +#endif +} + +void GStreamerTest::_testHwBufferLifecycleResetsNativeCaches() +{ +#if !defined(QGC_HAS_ANY_GPU_PATH) + QSKIP("HwBuffers not compiled in this build (no GPU path enabled)"); +#else + GstContextBridgeRegistry::clearForTest(); + auto guard = qScopeGuard([] { GstContextBridgeRegistry::clearForTest(); }); + + static int s_resetCount = 0; + s_resetCount = 0; + GstContextBridgeRegistry::registerResetCallback([]() { ++s_resetCount; }); + + HwBuffers::resetCachedGpuResources(); + QCOMPARE(s_resetCount, 1); + + GstElement* dummy = gst_element_factory_make("identity", nullptr); + QVERIFY(dummy); + GstMessage* ordinaryError = gst_message_new_error( + GST_OBJECT(dummy), g_error_new_literal(GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_FAILED, "ordinary stream error"), + "debug"); + QVERIFY(ordinaryError); + HwBuffers::dispatchBusMessage(ordinaryError); + QCOMPARE(s_resetCount, 1); + gst_message_unref(ordinaryError); + + GstMessage* deviceLostError = gst_message_new_error( + GST_OBJECT(dummy), g_error_new_literal(GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_FAILED, "DEVICE_REMOVED"), + "debug"); + QVERIFY(deviceLostError); + HwBuffers::dispatchBusMessage(deviceLostError); + QCOMPARE(s_resetCount, 2); + gst_message_unref(deviceLostError); + gst_object_unref(dummy); + + HwBuffers::onPipelineRestart(); + QCOMPARE(s_resetCount, 3); + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) || defined(QGC_HAS_GST_D3D11_GPU_PATH) || \ + defined(QGC_HAS_GST_D3D12_GPU_PATH) || defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) || \ + defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + GstContextBridgeRegistry::clearForTest(); + static int s_cacheReset = 0; + s_cacheReset = 0; + GstContextBridgeRegistry::registerCacheReset([]() { ++s_cacheReset; }); + GstContextBridgeRegistry::resetAllCaches(); + QCOMPARE(s_cacheReset, 1); + s_cacheReset = 0; + GstContextBridgeRegistry::resetAllBridges(); + QCOMPARE(s_cacheReset, 0); + GstContextBridgeRegistry::clearForTest(); +#endif +#endif +} + +void GStreamerTest::_testHwBufferFactoryDispatchSystemMemory() +{ +#if !defined(QGC_HAS_ANY_GPU_PATH) + QSKIP("HwVideoBuffer factory not compiled in this build (no GPU path enabled)"); +#else + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + GError* err = nullptr; + // videotestsrc produces system memory; no GPU path should match. + GstElement* pipeline = gst_parse_launch( + "videotestsrc num-buffers=1 ! " + "video/x-raw,format=BGRA,width=64,height=64,framerate=30/1 ! " + "fakesink name=sink enable-last-sample=true sync=false", + &err); + if (err) { + g_clear_error(&err); + } + QVERIFY2(pipeline, "Pipeline construction failed"); + + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + GstBus* bus = gst_element_get_bus(pipeline); + GstMessage* msg = gst_bus_timed_pop_filtered(bus, 5 * GST_SECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + gst_object_unref(bus); + if (msg) + gst_message_unref(msg); + + GstElement* fakesink = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY(fakesink); + GstSample* sample = nullptr; + g_object_get(fakesink, "last-sample", &sample, nullptr); + gst_object_unref(fakesink); + + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + + if (!sample) + QSKIP("No sample produced (fakesink enable-last-sample may be off)"); + + GstVideoInfo info; + GstCaps* caps = gst_sample_get_caps(sample); + QVERIFY(gst_video_info_from_caps(&info, caps)); + + QVideoFrameFormat fmt(QSize(64, 64), QVideoFrameFormat::Format_BGRA8888); + HwVideoBufferPath path = HwVideoBufferPath::None; + + HwVideoBufferContext ctxGpu; + ctxGpu.gpuEnabled = true; + auto buf = makeHwVideoBuffer(sample, info, fmt, ctxGpu, path); + QVERIFY(!buf); + QCOMPARE(path, HwVideoBufferPath::None); + + HwVideoBufferContext ctxCpu; + ctxCpu.gpuEnabled = false; + auto buf2 = makeHwVideoBuffer(sample, info, fmt, ctxCpu, path); + QVERIFY(!buf2); + + gst_sample_unref(sample); +#endif +} + +void GStreamerTest::_testQGCRhiCaptureCacheLifecycle() +{ +#if !defined(QGC_HAS_ANY_GPU_PATH) + QSKIP("QGCRhiCapture not compiled in this build (no GPU path enabled)"); +#else + // cachedRhi() returns nullptr before any window has been connected. + QVERIFY(!QGCRhiCapture::cachedRhi()); + + // Create an offscreen window and connect it. The scene graph is never + // initialized here so cachedRhi() remains nullptr. + auto* window = new QQuickWindow(); + QGCRhiCapture::connectWindow(window); + QVERIFY(!QGCRhiCapture::cachedRhi()); + + // Destroying the window must clear the cache (no dangling QRhi*). + delete window; + QVERIFY(!QGCRhiCapture::cachedRhi()); +#endif +} + +namespace { + +#if defined(QGC_HAS_ANY_GPU_PATH) +// Concrete subclass for unit-testing GstHwVideoBuffer's protected/base behavior. +class TestableHwVideoBuffer : public GstHwVideoBuffer +{ +public: + TestableHwVideoBuffer(GstSample* sample, const GstVideoInfo& info, QVideoFrameFormat fmt) + : GstHwVideoBuffer(QVideoFrame::NoHandle, sample, info, std::move(fmt)) + {} + + MapData map(QVideoFrame::MapMode) override { return {}; } + + QVideoFrameTexturesUPtr mapTextures(QRhi&, QVideoFrameTexturesUPtr&) override { return {}; } +}; + +class TestFrameTextures : public GstHwFrameTexturesBase +{ +public: + HwVideoBufferPath sourcePath() const override { return HwVideoBufferPath::GlMemory; } +}; + +class ForeignFrameTextures : public QVideoFrameTextures +{ +public: + QRhiTexture* texture(uint) const override { return nullptr; } +}; +#endif + +// Minimal GstSample factory: NV12 buffer at given size, optionally with a crop meta. +GstSample* makeNv12Sample(int width, int height, GstVideoInfo* outInfo, bool addCrop, int cx, int cy, int cw, int ch) +{ + GstVideoInfo info; + gst_video_info_set_format(&info, GST_VIDEO_FORMAT_NV12, width, height); + GstCaps* caps = gst_video_info_to_caps(&info); + GstBuffer* buf = gst_buffer_new_allocate(nullptr, GST_VIDEO_INFO_SIZE(&info), nullptr); + if (addCrop) { + GstVideoCropMeta* crop = gst_buffer_add_video_crop_meta(buf); + crop->x = cx; + crop->y = cy; + crop->width = cw; + crop->height = ch; + } + GstSample* sample = gst_sample_new(buf, caps, nullptr, nullptr); + gst_buffer_unref(buf); + gst_caps_unref(caps); + if (outInfo) + *outInfo = info; + return sample; +} + +} // namespace + +void GStreamerTest::_testHwFrameTexturesRejectsForeignOldTextures() +{ +#if !defined(QGC_HAS_ANY_GPU_PATH) + QSKIP("GstHwFrameTexturesBase not compiled in this build (no GPU path enabled)"); +#else + QVideoFrameTexturesUPtr foreign = std::make_unique(); + QVERIFY(!GstHwFrameTexturesBase::reusableBundle(foreign, HwVideoBufferPath::GlMemory)); + QVERIFY(foreign); + + QVideoFrameTexturesUPtr own = std::make_unique(); + QVERIFY(GstHwFrameTexturesBase::reusableBundle(own, HwVideoBufferPath::GlMemory)); + QVERIFY(own); +#endif +} + +void GStreamerTest::_testHwBufferCropMatrixIdentityWithoutMeta() +{ +#if !defined(QGC_HAS_ANY_GPU_PATH) + QSKIP("GstHwVideoBuffer not compiled in this build (no GPU path enabled)"); +#else + GstVideoInfo info; + GstSample* sample = makeNv12Sample(320, 240, &info, /*addCrop=*/false, 0, 0, 0, 0); + QVERIFY(sample); + QVideoFrameFormat fmt(QSize(320, 240), QVideoFrameFormat::Format_NV12); + TestableHwVideoBuffer hw(sample, info, fmt); + // Default externalTextureMatrix from QHwVideoBuffer is identity; we don't override it + // for crop because Qt only consults externalTextureMatrix for Format_SamplerExternalOES. + QCOMPARE(hw.externalTextureMatrix(), QMatrix4x4()); + gst_sample_unref(sample); +#endif +} + +void GStreamerTest::_testHwBufferCropMatrixFromVideoCropMeta() +{ + // Regression: GstVideoCropMeta is propagated via QVideoFrameFormat::viewport() in + // GStreamerFrameMap::applyCropMeta, NOT via externalTextureMatrix. Verify the format + // path round-trips a crop rect into viewport(). + GstVideoInfo info; + GstSample* sample = makeNv12Sample(400, 200, &info, /*addCrop=*/true, + /*cx=*/100, /*cy=*/50, /*cw=*/200, /*ch=*/100); + QVERIFY(sample); + GstBuffer* buf = gst_sample_get_buffer(sample); + + QVideoFrameFormat in(QSize(400, 200), QVideoFrameFormat::Format_NV12); + QVideoFrameFormat out = applyCropMeta(in, buf); + QCOMPARE(out.viewport(), QRect(100, 50, 200, 100)); + QCOMPARE(out.frameSize(), QSize(400, 200)); // unchanged + + gst_sample_unref(sample); +} + +void GStreamerTest::_testTelemetryMapDurationEwma() +{ + const HwVideoBufferPath path = HwVideoBufferPath::Vulkan; + + GstHwPathTelemetry::recordMapDuration(path, 80000); + const quint64 seeded = GstHwPathTelemetry::peekMapDurationUsEwma(path); + QVERIFY(seeded > 0); + + GstHwPathTelemetry::recordMapDuration(path, -5); + QCOMPARE(GstHwPathTelemetry::peekMapDurationUsEwma(path), seeded); + + GstHwPathTelemetry::recordMapDuration(path, 160000); + const quint64 expected = seeded - (seeded >> 3) + (160u >> 3); + QCOMPARE(GstHwPathTelemetry::peekMapDurationUsEwma(path), expected); +} + +void GStreamerTest::_testTelemetryFallbackReasonMatrix() +{ + using GstHwPathTelemetry::HwFallbackReason; + + (void) GstHwPathTelemetry::takeFallbackReason(HwVideoBufferPath::D3D11, HwFallbackReason::EglBadMatch); + (void) GstHwPathTelemetry::takeFallbackReason(HwVideoBufferPath::D3D11, HwFallbackReason::FenceTimeout); + (void) GstHwPathTelemetry::takeFallbackReason(HwVideoBufferPath::D3D12, HwFallbackReason::EglBadMatch); + + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::D3D11, HwFallbackReason::EglBadMatch); + GstHwPathTelemetry::recordFallbackReason(HwVideoBufferPath::D3D11, HwFallbackReason::EglBadMatch); + + QCOMPARE(GstHwPathTelemetry::peekFallbackReason(HwVideoBufferPath::D3D11, HwFallbackReason::EglBadMatch), + quint64(2)); + QCOMPARE(GstHwPathTelemetry::peekFallbackReason(HwVideoBufferPath::D3D11, HwFallbackReason::FenceTimeout), + quint64(0)); + QCOMPARE(GstHwPathTelemetry::peekFallbackReason(HwVideoBufferPath::D3D12, HwFallbackReason::EglBadMatch), + quint64(0)); + + QCOMPARE(GstHwPathTelemetry::takeFallbackReason(HwVideoBufferPath::D3D11, HwFallbackReason::EglBadMatch), + quint64(2)); + QCOMPARE(GstHwPathTelemetry::peekFallbackReason(HwVideoBufferPath::D3D11, HwFallbackReason::EglBadMatch), + quint64(0)); +} + +void GStreamerTest::_testTelemetrySyncWaitSplit() +{ + const HwVideoBufferPath path = HwVideoBufferPath::IOSurface; + + quint64 drainGpu = 0; + (void) GstHwPathTelemetry::takeSyncWaitCounts(path, drainGpu); + + GstHwPathTelemetry::recordSyncWait(path, false); + GstHwPathTelemetry::recordSyncWait(path, false); + GstHwPathTelemetry::recordSyncWait(path, true); + + quint64 gpuWaits = 0; + const quint64 cpuWaits = GstHwPathTelemetry::takeSyncWaitCounts(path, gpuWaits); + QCOMPARE(cpuWaits, quint64(2)); + QCOMPARE(gpuWaits, quint64(1)); + + quint64 gpuWaits2 = 99; + QCOMPARE(GstHwPathTelemetry::takeSyncWaitCounts(path, gpuWaits2), quint64(0)); + QCOMPARE(gpuWaits2, quint64(0)); +} + +void GStreamerTest::_testTelemetryPathStatsFailuresAreNotDelivered() +{ +#if !defined(QGC_HAS_ANY_GPU_PATH) + QSKIP("No GPU path compiled in this build"); +#else +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + constexpr HwVideoBufferPath path = HwVideoBufferPath::DmaBuf; +#elif defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + constexpr HwVideoBufferPath path = HwVideoBufferPath::GlMemory; +#elif defined(QGC_HAS_GST_D3D11_GPU_PATH) + constexpr HwVideoBufferPath path = HwVideoBufferPath::D3D11; +#elif defined(QGC_HAS_GST_D3D12_GPU_PATH) + constexpr HwVideoBufferPath path = HwVideoBufferPath::D3D12; +#elif defined(QGC_HAS_GST_IOSURFACE_GPU_PATH) + constexpr HwVideoBufferPath path = HwVideoBufferPath::IOSurface; +#elif defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH) + constexpr HwVideoBufferPath path = HwVideoBufferPath::AHardwareBuffer; +#else + constexpr HwVideoBufferPath path = HwVideoBufferPath::Vulkan; +#endif + + (void) GstHwPathTelemetry::takeDeliveredCount(path); + (void) GstHwPathTelemetry::takeMapFailureCount(path); + + GstHwPathTelemetry::recordMapFailure(path); + + const HwBuffers::PathStats stats = HwBuffers::formatPathStats(/*reset=*/true); + QVERIFY2(stats.line.contains(QStringLiteral("failures:1")), + qPrintable(QStringLiteral("Expected failure count in stats line, got:%1").arg(stats.line))); + QCOMPARE(stats.totalDelivered, quint64(0)); + QCOMPARE(GstHwPathTelemetry::peekMapFailureCount(path), quint64(0)); +#endif +} + +void GStreamerTest::_testTelemetryDmaBufExtraStatsDrain() +{ +#if !defined(QGC_HAS_GST_DMABUF_GPU_PATH) + QSKIP("DMABuf GPU path not compiled in this build"); +#else + GstHwPathTelemetry::recordFenceTimeout(HwVideoBufferPath::DmaBuf); + GstHwPathTelemetry::recordMmapBarrierHit(HwVideoBufferPath::DmaBuf); + GstHwPathTelemetry::recordExplicitFenceWait(HwVideoBufferPath::DmaBuf); + + const QString first = HwBuffers::takeExtraPathStats(); + QVERIFY(first.contains(QStringLiteral("DMABuf-fence-timeouts:"))); + QVERIFY(first.contains(QStringLiteral("DMABuf-mmap-barriers:"))); + QVERIFY(first.contains(QStringLiteral("DMABuf-explicit-fence-waits:"))); + + const QString second = HwBuffers::takeExtraPathStats(); + QVERIFY(second.contains(QStringLiteral("DMABuf-fence-timeouts:0"))); + QVERIFY(second.contains(QStringLiteral("DMABuf-mmap-barriers:0"))); + QVERIFY(second.contains(QStringLiteral("DMABuf-explicit-fence-waits:0"))); +#endif +} + +#endif diff --git a/test/VideoManager/GStreamer/HwBuffers/d3d/GStreamerD3DTest.cc b/test/VideoManager/GStreamer/HwBuffers/d3d/GStreamerD3DTest.cc new file mode 100644 index 000000000000..81fb93fb8b6a --- /dev/null +++ b/test/VideoManager/GStreamer/HwBuffers/d3d/GStreamerD3DTest.cc @@ -0,0 +1,364 @@ +#include "GStreamerTest.h" + +#ifdef QGC_GST_STREAMING + +#include +#include +#include +#include +#include +#include + +#include "GStreamer.h" +#include "GStreamerLogging.h" +#include "GstHwVideoBuffer.h" +#include "GstHwVideoBufferFactory.h" + +#if defined(Q_OS_WIN) && (defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH)) +#include +#include + +#include "QGCRhiCapture.h" +#include "GstHwFrameTexturesBase.h" + +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) +#include "GstD3D11ContextBridge.h" +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) +#include "GstD3D12ContextBridge.h" +#endif + +namespace { + +qint64 composeLuid(qint32 high, quint32 low) +{ + return (static_cast(high) << 32) | (static_cast(low) & 0xFFFFFFFFLL); +} + +struct SnapshotGuard +{ + SnapshotGuard() + : backend(QGCRhiCapture::deviceSnapshot().backend.load(std::memory_order_acquire)), + d3d11Device(QGCRhiCapture::deviceSnapshot().d3d11Device.load(std::memory_order_acquire)), + d3d12Device(QGCRhiCapture::deviceSnapshot().d3d12Device.load(std::memory_order_acquire)), + adapterLuid(QGCRhiCapture::deviceSnapshot().adapterLuid.load(std::memory_order_acquire)) + {} + + ~SnapshotGuard() + { + QGCRhiCapture::deviceSnapshot().d3d11Device.store(d3d11Device, std::memory_order_release); + QGCRhiCapture::deviceSnapshot().d3d12Device.store(d3d12Device, std::memory_order_release); + QGCRhiCapture::deviceSnapshot().adapterLuid.store(adapterLuid, std::memory_order_release); + QGCRhiCapture::deviceSnapshot().backend.store(backend, std::memory_order_release); + } + + int backend = -1; + void* d3d11Device = nullptr; + void* d3d12Device = nullptr; + qint64 adapterLuid = 0; +}; + +struct D3DSample +{ + GstElement* pipeline = nullptr; + GstElement* sink = nullptr; + GstSample* sample = nullptr; + GstVideoInfo info; + + ~D3DSample() + { + if (sample) { + gst_sample_unref(sample); + } + if (sink) { + gst_object_unref(sink); + } + if (pipeline) { + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + } + } +}; + +void pullD3DSample(D3DSample& result, const char* inputCaps, const char* uploadElement, const char* capsFilter, + HwVideoBufferPath bridgePath = HwVideoBufferPath::None) +{ + GstElementFactory* uploadFactory = gst_element_factory_find(uploadElement); + if (!uploadFactory) { + QSKIP(qPrintable(QStringLiteral("%1 factory unavailable").arg(QString::fromUtf8(uploadElement)))); + } + gst_object_unref(uploadFactory); + + const QString launch = QStringLiteral( + "videotestsrc num-buffers=1 ! " + "%1 ! " + "%2 ! %3 ! appsink name=sink sync=false") + .arg(QString::fromUtf8(inputCaps)) + .arg(QString::fromUtf8(uploadElement)) + .arg(QString::fromUtf8(capsFilter)); + + GError* error = nullptr; + GstElement* pipeline = gst_parse_launch(launch.toUtf8().constData(), &error); + if (error) { + const QString msg = QString::fromUtf8(error->message); + g_clear_error(&error); + QSKIP(qPrintable(QStringLiteral("D3D pipeline parse skipped: %1").arg(msg))); + } + QVERIFY2(pipeline, "Failed to create D3D dispatch test pipeline"); + + result.pipeline = pipeline; + + GstElement* sink = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY2(sink, "Could not find appsink"); + result.sink = sink; + + if (bridgePath != HwVideoBufferPath::None) { + GstBus* bus = gst_element_get_bus(pipeline); + QVERIFY2(bus, "Could not get D3D test pipeline bus"); + gst_bus_set_sync_handler( + bus, + [](GstBus*, GstMessage* message, gpointer userData) -> GstBusSyncReply { + const HwVideoBufferPath path = static_cast(GPOINTER_TO_INT(userData)); + switch (path) { +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + case HwVideoBufferPath::D3D11: + return GstD3D11ContextBridge::handleSyncMessage(message); +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + case HwVideoBufferPath::D3D12: + return GstD3D12ContextBridge::handleSyncMessage(message); +#endif + default: + break; + } + return GST_BUS_PASS; + }, + GINT_TO_POINTER(static_cast(bridgePath)), nullptr); + gst_object_unref(bus); + } + + GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING); + if (ret == GST_STATE_CHANGE_FAILURE) { + QSKIP("D3D pipeline failed to PLAY"); + } + + GstSample* sample = gst_app_sink_try_pull_sample(GST_APP_SINK(sink), 5 * GST_SECOND); + if (!sample) { + QSKIP("D3D pipeline produced no sample"); + } + result.sample = sample; + + gst_video_info_init(&result.info); + QVERIFY2(gst_video_info_from_caps(&result.info, gst_sample_get_caps(sample)), "Could not parse D3D sample caps"); +} + +void testD3DMemoryDispatch(const char* uploadElement, const char* capsFilter, HwVideoBufferPath expectedPath) +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "GStreamer::completeInit() failed"); + + D3DSample sample; + pullD3DSample(sample, "video/x-raw,format=RGBA,width=64,height=64,framerate=30/1", uploadElement, capsFilter); + + HwVideoBufferContext context; + context.gpuEnabled = true; + HwResolvedPathCache cache; + HwVideoBufferPath path = HwVideoBufferPath::None; + QVideoFrameFormat format(QSize(GST_VIDEO_INFO_WIDTH(&sample.info), GST_VIDEO_INFO_HEIGHT(&sample.info)), + QVideoFrameFormat::Format_RGBA8888); + + auto buffer = makeHwVideoBuffer(sample.sample, sample.info, format, context, path, &cache); + + QVERIFY2(buffer, "D3D memory sample did not create a hardware video buffer"); + QCOMPARE(path, expectedPath); + QVERIFY(cache.validated); + QCOMPARE(cache.path, expectedPath); +} + +void testD3DMapTextures(QRhi& rhi, const char* inputCaps, const char* uploadElement, const char* capsFilter, + QVideoFrameFormat::PixelFormat pixelFormat, int expectedPlanes, HwVideoBufferPath expectedPath) +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "GStreamer::completeInit() failed"); + + D3DSample sample; + pullD3DSample(sample, inputCaps, uploadElement, capsFilter, expectedPath); + + HwVideoBufferContext context; + context.gpuEnabled = true; + HwResolvedPathCache cache; + HwVideoBufferPath path = HwVideoBufferPath::None; + QVideoFrameFormat format(QSize(GST_VIDEO_INFO_WIDTH(&sample.info), GST_VIDEO_INFO_HEIGHT(&sample.info)), + pixelFormat); + + auto buffer = makeHwVideoBuffer(sample.sample, sample.info, format, context, path, &cache); + + QVERIFY2(buffer, "D3D memory sample did not create a hardware video buffer"); + QCOMPARE(path, expectedPath); + + QVideoFrameTexturesUPtr oldTextures; + QVideoFrameTexturesUPtr textures = buffer->mapTextures(rhi, oldTextures); + QVERIFY2(textures, "D3D hardware video buffer did not import into the active QRhi"); + for (int plane = 0; plane < expectedPlanes; ++plane) { + QVERIFY2(textures->texture(static_cast(plane)), + qPrintable(QStringLiteral("D3D QRhi texture bundle has no plane %1 texture").arg(plane))); + } + auto* base = dynamic_cast(textures.get()); + QVERIFY2(base, "D3D texture bundle does not use the HW frame texture base"); + QCOMPARE(base->sourcePath(), expectedPath); +} + +} // namespace +#endif + +void GStreamerTest::_testD3D11MemoryDispatch() +{ +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) + testD3DMemoryDispatch("d3d11upload", "video/x-raw(memory:D3D11Memory)", HwVideoBufferPath::D3D11); +#else + QSKIP("D3D11 GPU path not compiled in this build"); +#endif +} + +void GStreamerTest::_testD3D12MemoryDispatch() +{ +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) + testD3DMemoryDispatch("d3d12upload", "video/x-raw(memory:D3D12Memory)", HwVideoBufferPath::D3D12); +#else + QSKIP("D3D12 GPU path not compiled in this build"); +#endif +} + +void GStreamerTest::_testD3D11MapTexturesWithQRhi() +{ +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) + QRhiD3D11InitParams params; + std::unique_ptr rhi(QRhi::create(QRhi::D3D11, ¶ms)); + if (!rhi) { + QSKIP("Could not create D3D11 QRhi"); + } + auto* handles = static_cast(rhi->nativeHandles()); + if (!handles || !handles->dev) { + QSKIP("D3D11 QRhi exposes no native device handle"); + } + + SnapshotGuard snapshotGuard; + Q_UNUSED(snapshotGuard) + QGCRhiCapture::deviceSnapshot().d3d11Device.store(handles->dev, std::memory_order_release); + QGCRhiCapture::deviceSnapshot().d3d12Device.store(nullptr, std::memory_order_release); + QGCRhiCapture::deviceSnapshot().adapterLuid.store(composeLuid(handles->adapterLuidHigh, handles->adapterLuidLow), + std::memory_order_release); + QGCRhiCapture::deviceSnapshot().backend.store(static_cast(QRhi::D3D11), std::memory_order_release); + + GstD3D11ContextBridge::reset(); + auto bridgeGuard = qScopeGuard([] { GstD3D11ContextBridge::reset(); }); + QVERIFY2(GstD3D11ContextBridge::prime(), "Could not prime D3D11 context bridge from QRhi snapshot"); + + testD3DMapTextures(*rhi, "video/x-raw,format=RGBA,width=64,height=64,framerate=30/1", "d3d11upload", + "video/x-raw(memory:D3D11Memory)", QVideoFrameFormat::Format_RGBA8888, 1, + HwVideoBufferPath::D3D11); +#else + QSKIP("D3D11 GPU path not compiled in this build"); +#endif +} + +void GStreamerTest::_testD3D11MapNv12TexturesWithQRhi() +{ +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D11_GPU_PATH) + QRhiD3D11InitParams params; + std::unique_ptr rhi(QRhi::create(QRhi::D3D11, ¶ms)); + if (!rhi) { + QSKIP("Could not create D3D11 QRhi"); + } + auto* handles = static_cast(rhi->nativeHandles()); + if (!handles || !handles->dev) { + QSKIP("D3D11 QRhi exposes no native device handle"); + } + + SnapshotGuard snapshotGuard; + Q_UNUSED(snapshotGuard) + QGCRhiCapture::deviceSnapshot().d3d11Device.store(handles->dev, std::memory_order_release); + QGCRhiCapture::deviceSnapshot().d3d12Device.store(nullptr, std::memory_order_release); + QGCRhiCapture::deviceSnapshot().adapterLuid.store(composeLuid(handles->adapterLuidHigh, handles->adapterLuidLow), + std::memory_order_release); + QGCRhiCapture::deviceSnapshot().backend.store(static_cast(QRhi::D3D11), std::memory_order_release); + + GstD3D11ContextBridge::reset(); + auto bridgeGuard = qScopeGuard([] { GstD3D11ContextBridge::reset(); }); + QVERIFY2(GstD3D11ContextBridge::prime(), "Could not prime D3D11 context bridge from QRhi snapshot"); + + testD3DMapTextures(*rhi, "video/x-raw,format=NV12,width=64,height=64,framerate=30/1", "d3d11upload", + "video/x-raw(memory:D3D11Memory),format=NV12", QVideoFrameFormat::Format_NV12, 2, + HwVideoBufferPath::D3D11); +#else + QSKIP("D3D11 GPU path not compiled in this build"); +#endif +} + +void GStreamerTest::_testD3D12MapTexturesWithQRhi() +{ +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) + QRhiD3D12InitParams params; + std::unique_ptr rhi(QRhi::create(QRhi::D3D12, ¶ms)); + if (!rhi) { + QSKIP("Could not create D3D12 QRhi"); + } + auto* handles = static_cast(rhi->nativeHandles()); + if (!handles || !handles->dev) { + QSKIP("D3D12 QRhi exposes no native device handle"); + } + + SnapshotGuard snapshotGuard; + Q_UNUSED(snapshotGuard) + QGCRhiCapture::deviceSnapshot().d3d11Device.store(nullptr, std::memory_order_release); + QGCRhiCapture::deviceSnapshot().d3d12Device.store(handles->dev, std::memory_order_release); + QGCRhiCapture::deviceSnapshot().adapterLuid.store(composeLuid(handles->adapterLuidHigh, handles->adapterLuidLow), + std::memory_order_release); + QGCRhiCapture::deviceSnapshot().backend.store(static_cast(QRhi::D3D12), std::memory_order_release); + + GstD3D12ContextBridge::reset(); + auto bridgeGuard = qScopeGuard([] { GstD3D12ContextBridge::reset(); }); + QVERIFY2(GstD3D12ContextBridge::prime(), "Could not prime D3D12 context bridge from QRhi snapshot"); + + testD3DMapTextures(*rhi, "video/x-raw,format=RGBA,width=64,height=64,framerate=30/1", "d3d12upload", + "video/x-raw(memory:D3D12Memory)", QVideoFrameFormat::Format_RGBA8888, 1, + HwVideoBufferPath::D3D12); +#else + QSKIP("D3D12 GPU path not compiled in this build"); +#endif +} + +void GStreamerTest::_testD3D12MapNv12TexturesWithQRhi() +{ +#if defined(Q_OS_WIN) && defined(QGC_HAS_GST_D3D12_GPU_PATH) + QRhiD3D12InitParams params; + std::unique_ptr rhi(QRhi::create(QRhi::D3D12, ¶ms)); + if (!rhi) { + QSKIP("Could not create D3D12 QRhi"); + } + auto* handles = static_cast(rhi->nativeHandles()); + if (!handles || !handles->dev) { + QSKIP("D3D12 QRhi exposes no native device handle"); + } + + SnapshotGuard snapshotGuard; + Q_UNUSED(snapshotGuard) + QGCRhiCapture::deviceSnapshot().d3d11Device.store(nullptr, std::memory_order_release); + QGCRhiCapture::deviceSnapshot().d3d12Device.store(handles->dev, std::memory_order_release); + QGCRhiCapture::deviceSnapshot().adapterLuid.store(composeLuid(handles->adapterLuidHigh, handles->adapterLuidLow), + std::memory_order_release); + QGCRhiCapture::deviceSnapshot().backend.store(static_cast(QRhi::D3D12), std::memory_order_release); + + GstD3D12ContextBridge::reset(); + auto bridgeGuard = qScopeGuard([] { GstD3D12ContextBridge::reset(); }); + QVERIFY2(GstD3D12ContextBridge::prime(), "Could not prime D3D12 context bridge from QRhi snapshot"); + + testD3DMapTextures(*rhi, "video/x-raw,format=NV12,width=64,height=64,framerate=30/1", "d3d12upload", + "video/x-raw(memory:D3D12Memory),format=NV12", QVideoFrameFormat::Format_NV12, 2, + HwVideoBufferPath::D3D12); +#else + QSKIP("D3D12 GPU path not compiled in this build"); +#endif +} + +#endif diff --git a/test/VideoManager/GStreamer/HwBuffers/dmabuf/GStreamerDmaBufTest.cc b/test/VideoManager/GStreamer/HwBuffers/dmabuf/GStreamerDmaBufTest.cc new file mode 100644 index 000000000000..586a5a08e945 --- /dev/null +++ b/test/VideoManager/GStreamer/HwBuffers/dmabuf/GStreamerDmaBufTest.cc @@ -0,0 +1,478 @@ +#include "GStreamerTest.h" + +#ifdef QGC_GST_STREAMING + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Fixtures/RAIIFixtures.h" +#include "Fact.h" +#include "GStreamer.h" +#include "GStreamerHelpers.h" +#include "GStreamerLogging.h" +#include "GstVideoReceiver.h" + +// New sink controller + telemetry includes (GstAppSinkAdapter removed). +#include +#include +#include + +#include "GStreamer.h" +#include "GStreamerFrameMap.h" +#include "CpuVideoFramePool.h" +#include "GstHwPathTelemetry.h" +#include "GstHwVideoBufferFactory.h" +#include "GstSourceFactory.h" +#include "QGCQVideoSinkController.h" +#include "HwBuffers/dmabuf/GstDmaDrmCaps.h" +#include "gstqgc/GstQgcAllocation.h" +#include "gstqgc/GstQgcCaps.h" +#include "gstqgc/GstQgcVideoFormats.h" + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) +#include "GstDmaBufVideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) +#include + +#include "GstGlContextBridge.h" +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) +#include "GstD3D11ContextBridge.h" +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) +#include "GstD3D12ContextBridge.h" +#endif +#include "GstContextBridgeRegistry.h" +#include "GstHwVideoBuffer.h" +#include "GstHwVideoBufferFactory.h" +#include "gstqgc/gstqgcvideosinkbin.h" +#if defined(QGC_HAS_ANY_GPU_PATH) +#include "QGCRhiCapture.h" +#endif +#include +#include +#include +#include +#include +#include + +void GStreamerTest::_testDmaBufDispatch() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "GStreamer::completeInit() failed"); + + for (const char* name : {"vah264enc", "vah264dec", "vapostproc"}) { + GstElementFactory* f = gst_element_factory_find(name); + if (!f) { + QSKIP(qPrintable(QStringLiteral("VA-API not available: missing %1").arg(QString::fromUtf8(name)))); + } + gst_object_unref(f); + } + + const auto onlyDmaBufCaps = [](GstCaps* caps) -> GstCaps* { + GstCaps* out = gst_caps_new_empty(); + if (!caps) { + return out; + } + const guint n = gst_caps_get_size(caps); + for (guint i = 0; i < n; ++i) { + GstCapsFeatures* features = gst_caps_get_features(caps, i); + if (!features || !gst_caps_features_contains(features, "memory:DMABuf")) { + continue; + } + gst_caps_append_structure_full(out, gst_structure_copy(gst_caps_get_structure(caps, i)), + gst_caps_features_copy(features)); + } + return out; + }; + + GstCaps* qgcGpuCaps = gst_caps_from_string(GstQgc::buildGpuCapsString().c_str()); + QVERIFY2(qgcGpuCaps, "QGC GPU caps did not parse"); + auto qgcGpuCapsGuard = qScopeGuard([&] { gst_caps_unref(qgcGpuCaps); }); + + GstCaps* qgcDmaBufCaps = onlyDmaBufCaps(qgcGpuCaps); + QVERIFY2(qgcDmaBufCaps, "QGC DMABuf caps did not parse"); + auto qgcDmaBufCapsGuard = qScopeGuard([&] { gst_caps_unref(qgcDmaBufCaps); }); + QVERIFY2(!gst_caps_is_empty(qgcDmaBufCaps), "QGC GPU caps did not include a DMABuf structure"); + + { + GError* preflightError = nullptr; + GstElement* preflight = gst_parse_launch( + "videotestsrc num-buffers=2 ! " + "video/x-raw,format=NV12,width=320,height=240,framerate=30/1 ! " + "vah264enc ! h264parse ! vah264dec ! vapostproc ! " + "capsfilter name=qgccaps ! fakesink sync=false", + &preflightError); + if (preflightError) { + const QString msg = QString::fromUtf8(preflightError->message); + g_clear_error(&preflightError); + QSKIP(qPrintable(QStringLiteral("DMABuf preflight parse skipped: %1").arg(msg))); + } + QVERIFY2(preflight, "Failed to create DMABuf preflight pipeline"); + auto preflightGuard = qScopeGuard([&] { + gst_element_set_state(preflight, GST_STATE_NULL); + gst_object_unref(preflight); + }); + + GstElement* capsFilter = gst_bin_get_by_name(GST_BIN(preflight), "qgccaps"); + QVERIFY2(capsFilter, "Could not find DMABuf preflight capsfilter"); + g_object_set(capsFilter, "caps", qgcDmaBufCaps, nullptr); + gst_object_unref(capsFilter); + + const GstStateChangeReturn preflightRet = gst_element_set_state(preflight, GST_STATE_PLAYING); + if (preflightRet == GST_STATE_CHANGE_FAILURE) { + QSKIP("DMABuf preflight failed to PLAY (no VA driver / DRI device?)"); + } + + GstBus* bus = gst_element_get_bus(preflight); + GstMessage* msg = gst_bus_timed_pop_filtered(bus, 5 * GST_SECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + gst_object_unref(bus); + + bool sawPreflightError = false; + QString errMsg; + if (msg && GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { + GError* err = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(msg, &err, &debug); + errMsg = QStringLiteral("%1 (%2)") + .arg(err ? QString::fromUtf8(err->message) : QStringLiteral("?")) + .arg(debug ? QString::fromUtf8(debug) : QString()); + g_clear_error(&err); + g_free(debug); + sawPreflightError = true; + } + if (msg) { + gst_message_unref(msg); + } + + if (!msg || sawPreflightError) { + gchar* qgcCapsStr = gst_caps_to_string(qgcDmaBufCaps); + const QString reason = !msg ? QStringLiteral("timed out waiting for EOS") + : QStringLiteral("negotiation failed: %1").arg(errMsg); + const QString message = + QStringLiteral("VA driver cannot produce QGC-compatible direct DMABuf caps (%1). QGC: %2") + .arg(reason, QString::fromUtf8(qgcCapsStr ? qgcCapsStr : "")); + g_free(qgcCapsStr); + QSKIP(qPrintable(message)); + } + } + + QVideoSink videoSink; + + GError* error = nullptr; + GstElement* pipeline = gst_parse_launch( + "videotestsrc num-buffers=10 ! " + "video/x-raw,format=NV12,width=320,height=240,framerate=30/1 ! " + "vah264enc ! h264parse ! vah264dec ! vapostproc ! " + "capsfilter name=qgccaps ! " + "qgcvideosinkbin name=sink gpu-zerocopy=true", + &error); + if (error) { + const QString msg = QString::fromUtf8(error->message); + g_clear_error(&error); + QSKIP(qPrintable(QStringLiteral("DMABuf pipeline parse skipped: %1").arg(msg))); + } + QVERIFY2(pipeline, "Failed to create DMABuf test pipeline"); + auto pipelineGuard = qScopeGuard([&] { + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + }); + + GstElement* capsFilter = gst_bin_get_by_name(GST_BIN(pipeline), "qgccaps"); + QVERIFY2(capsFilter, "Could not find DMABuf capsfilter"); + g_object_set(capsFilter, "caps", qgcDmaBufCaps, nullptr); + gst_object_unref(capsFilter); + + GstElement* sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY2(sinkBin, "Could not find 'sink' element"); + auto sinkBinGuard = qScopeGuard([&] { gst_object_unref(sinkBin); }); + + // Without a real QVideoSink, makeAdapterContext(true) never sets gpuEnabled / resolves the + // EGLDisplay, so show_frame's HwVideoBufferContext stays CPU-only and the DmaBuf counter never moves. + auto owner = std::make_unique(); + if (!GStreamer::setupQVideoSinkElement(sinkBin, &videoSink, owner.get())) { + QSKIP("setupQVideoSinkElement() failed (no GPU context for DMABuf)"); + } + + GstElement* qvideosink = gst_bin_get_by_name(GST_BIN(sinkBin), "qgcqvideosink"); + QVERIFY2(qvideosink, "Could not find 'qgcqvideosink' inside sink bin"); + auto qvideosinkGuard = qScopeGuard([&] { gst_object_unref(qvideosink); }); + + GstPad* appsinkPad = gst_element_get_static_pad(qvideosink, "sink"); + QVERIFY2(appsinkPad, "qgcqvideosink has no sink pad"); + auto appsinkPadGuard = qScopeGuard([&] { gst_object_unref(appsinkPad); }); + + struct ProbeState + { + std::atomic bufferCount{0}; + std::atomic dmaBufCount{0}; + } probe; + + auto probeCb = +[](GstPad* /*pad*/, GstPadProbeInfo* info, gpointer userData) -> GstPadProbeReturn { + auto* st = static_cast(userData); + GstBuffer* buf = GST_PAD_PROBE_INFO_BUFFER(info); + if (buf) { + st->bufferCount.fetch_add(1, std::memory_order_relaxed); + GstMemory* mem = gst_buffer_peek_memory(buf, 0); + if (mem && mem->allocator && mem->allocator->mem_type && + g_str_has_prefix(mem->allocator->mem_type, "DMABuf")) { + st->dmaBufCount.fetch_add(1, std::memory_order_relaxed); + } + } + return GST_PAD_PROBE_OK; + }; + const gulong probeId = gst_pad_add_probe(appsinkPad, GST_PAD_PROBE_TYPE_BUFFER, probeCb, &probe, nullptr); + QVERIFY(probeId); + auto probeGuard = qScopeGuard([&] { gst_pad_remove_probe(appsinkPad, probeId); }); + + const quint64 deliveredBefore = GstHwPathTelemetry::peekDeliveredCount(HwVideoBufferPath::DmaBuf); + + GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING); + if (ret == GST_STATE_CHANGE_FAILURE) { + QSKIP("DMABuf pipeline failed to PLAY (no VA driver / DRI device?)"); + } + + GstBus* bus = gst_element_get_bus(pipeline); + GstMessage* msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + + QString errMsg; + bool sawError = false; + if (msg && GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { + GError* err = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(msg, &err, &debug); + errMsg = QStringLiteral("%1 (%2)") + .arg(err ? QString::fromUtf8(err->message) : QStringLiteral("?")) + .arg(debug ? QString::fromUtf8(debug) : QString()); + g_clear_error(&err); + g_free(debug); + sawError = true; + } + if (msg) + gst_message_unref(msg); + gst_object_unref(bus); + + // Settle drain so queued videoFrameChanged deliveries land before telemetry is read. + { + QElapsedTimer drain; + drain.start(); + while (drain.elapsed() < 100) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + } + } + + GstCaps* negotiated = gst_pad_get_current_caps(appsinkPad); + QString negotiatedStr; + if (negotiated) { + gchar* s = gst_caps_to_string(negotiated); + negotiatedStr = QString::fromUtf8(s ? s : ""); + g_free(s); + gst_caps_unref(negotiated); + } + + const quint64 deliveredAfter = GstHwPathTelemetry::peekDeliveredCount(HwVideoBufferPath::DmaBuf); + const int bufferCount = probe.bufferCount.load(); + const int dmaBufCount = probe.dmaBufCount.load(); + + if (sawError) { + QSKIP(qPrintable(QStringLiteral("DMABuf pipeline error (likely no VA driver): %1").arg(errMsg))); + } + QVERIFY2(bufferCount > 0, "No buffers reached qgcqvideosink under DMABuf caps"); + if (dmaBufCount == 0 || !negotiatedStr.contains(QStringLiteral("memory:DMABuf"))) { + QSKIP(qPrintable(QStringLiteral("VA driver chose system memory, not DMABuf. Buffers: %1, " + "negotiated caps: %2") + .arg(bufferCount) + .arg(negotiatedStr))); + } + QVERIFY2(deliveredAfter > deliveredBefore, + qPrintable(QStringLiteral("DMABuf negotiated but DmaBuf delivered-count did not advance " + "(before=%1 after=%2). Buffers: %3, caps: %4") + .arg(deliveredBefore) + .arg(deliveredAfter) + .arg(bufferCount) + .arg(negotiatedStr))); +} + +void GStreamerTest::_testDmaDrmCapsRejectNonLinearModifiers() +{ +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) && GST_CHECK_VERSION(1, 24, 0) + constexpr quint64 kIntelTileYModifier = 0x0100000000000009ULL; + QVERIFY2(!GstHw::dmaDrmModifierAdvertisedForTest(kIntelTileYModifier), + "Direct QGC DMABuf caps must not advertise tiled VA modifiers; Mesa/Gallium can segfault while " + "binding the resulting EGLImage on Qt's render thread"); +#else + QSKIP("DMABuf DMA_DRM caps unavailable in this build"); +#endif +} + +void GStreamerTest::_testDmaBufRejectsNonLinearDirectImport() +{ +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + constexpr guint64 kLinearModifier = 0; + constexpr guint64 kIntelTileYModifier = 0x0100000000000009ULL; + + QVERIFY(GstDmaBufVideoBuffer::directGlImportAllowedForTest(false, kLinearModifier)); + QVERIFY(GstDmaBufVideoBuffer::directGlImportAllowedForTest(true, kLinearModifier)); + QVERIFY2(!GstDmaBufVideoBuffer::directGlImportAllowedForTest(false, kIntelTileYModifier), + "Non-LINEAR DMABuf cannot be imported without EGL_EXT_image_dma_buf_import_modifiers"); + QVERIFY2(!GstDmaBufVideoBuffer::directGlImportAllowedForTest(true, kIntelTileYModifier), + "Direct QGC DMABuf must reject tiled VA modifiers even when EGL advertises modifier support; binding " + "the resulting EGLImage can segfault in Mesa/Gallium"); +#else + QSKIP("DMABuf unavailable in this build"); +#endif +} + +void GStreamerTest::_testDmaBufTiledImportAvoidsTexStorage() +{ +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + constexpr guint64 kLinearModifier = 0; + constexpr guint64 kIntelTileYModifier = 0x0100000000000009ULL; + + QVERIFY(GstDmaBufVideoBuffer::texStorageImportAllowedForTest(false, true, kLinearModifier)); + QVERIFY2(!GstDmaBufVideoBuffer::texStorageImportAllowedForTest(false, true, kIntelTileYModifier), + "Tiled DMABuf EGLImages must avoid GL_EXT_EGL_image_storage; Mesa/Gallium can segfault while " + "binding a tiled VA image through glEGLImageTargetTexStorageEXT"); + QVERIFY(!GstDmaBufVideoBuffer::texStorageImportAllowedForTest(true, true, kLinearModifier)); + QVERIFY(!GstDmaBufVideoBuffer::texStorageImportAllowedForTest(false, false, kLinearModifier)); +#else + QSKIP("DMABuf unavailable in this build"); +#endif +} + +void GStreamerTest::_testHwBufferMapTexturesGuard() +{ +#if !defined(QGC_HAS_GST_DMABUF_GPU_PATH) + QSKIP("DMABuf GPU path not compiled in this build"); +#else + GStreamer::redirectGLibLogging(); + + const quint64 failsBefore = GstHwPathTelemetry::peekMapFailureCount(HwVideoBufferPath::DmaBuf); + + GstElement* pipeline = gst_parse_launch( + "videotestsrc num-buffers=1 ! " + R"(capsfilter caps="video/x-raw(memory:DMABuf),format=NV12,width=64,height=64" ! )" + "fakesink name=sink sync=false", + nullptr); + if (!pipeline) { + QSKIP("Could not construct DMABuf test pipeline (element missing)"); + } + + GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PAUSED); + if (ret == GST_STATE_CHANGE_FAILURE) { + gst_object_unref(pipeline); + QSKIP("DMABuf pipeline failed to reach PAUSED (no DMABuf source on this machine)"); + } + gst_element_get_state(pipeline, nullptr, nullptr, 2 * GST_SECOND); + + GstElement* fakesink = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + GstSample* sample = nullptr; + if (fakesink) { + g_object_get(fakesink, "last-sample", &sample, nullptr); + gst_object_unref(fakesink); + } + + if (!sample) { + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + QSKIP("No DMABuf sample produced (caps negotiation failed on this machine)"); + } + + GstVideoInfo info; + GstCaps* caps = gst_sample_get_caps(sample); + gst_video_info_from_caps(&info, caps); + QVideoFrameFormat fmt(QSize(64, 64), QVideoFrameFormat::Format_NV12); + + GstDmaBufVideoBuffer buf(sample, info, fmt, EGL_NO_DISPLAY); + QVideoFrameTexturesUPtr old; + QVideoFrameTexturesUPtr result = buf.mapTextures(*reinterpret_cast(1), old); + QVERIFY(!result); + + const quint64 failsAfter = GstHwPathTelemetry::peekMapFailureCount(HwVideoBufferPath::DmaBuf); + QVERIFY(failsAfter > failsBefore); + + gst_sample_unref(sample); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); +#endif +} + +void GStreamerTest::_testHwBufferFactoryCacheRejectsMemoryTypeChange() +{ +#if !defined(QGC_HAS_GST_DMABUF_GPU_PATH) + QSKIP("DMABuf GPU path not compiled in this build"); +#else + GstVideoInfo info; + gst_video_info_init(&info); + gst_video_info_set_format(&info, GST_VIDEO_FORMAT_BGRA, 4, 4); + + GstBuffer* buffer = gst_buffer_new_allocate(nullptr, GST_VIDEO_INFO_SIZE(&info), nullptr); + QVERIFY(buffer); + const auto bufferGuard = qScopeGuard([&] { gst_buffer_unref(buffer); }); + + GstCaps* caps = gst_video_info_to_caps(&info); + QVERIFY(caps); + const auto capsGuard = qScopeGuard([&] { gst_caps_unref(caps); }); + + GstSample* sample = gst_sample_new(buffer, caps, nullptr, nullptr); + QVERIFY(sample); + const auto sampleGuard = qScopeGuard([&] { gst_sample_unref(sample); }); + + HwVideoBufferContext context; + context.gpuEnabled = true; + context.dmaBufEglDisplay = reinterpret_cast(quintptr(1)); + + HwResolvedPathCache cache; + cache.path = HwVideoBufferPath::DmaBuf; + cache.validated = true; + + HwVideoBufferPath path = HwVideoBufferPath::None; + QVideoFrameFormat format(QSize(4, 4), QVideoFrameFormat::Format_BGRA8888); + + auto hwBuffer = makeHwVideoBuffer(sample, info, format, context, path, &cache); + QVERIFY2(!hwBuffer, "Cached DMABuf path must reject a system-memory buffer and fall back to CPU mapping"); + QCOMPARE(path, HwVideoBufferPath::None); + QVERIFY(!cache.validated); +#endif +} + +void GStreamerTest::_testDmaBufSingleFdImportEnvGate() +{ +#if !defined(QGC_HAS_GST_DMABUF_GPU_PATH) + QSKIP("DMABuf GPU path not compiled in this build"); +#else + TestFixtures::EnvVarFixture gate("QGC_GST_DMABUF_SINGLE_EGLIMAGE"); + + qunsetenv("QGC_GST_DMABUF_SINGLE_EGLIMAGE"); + QVERIFY(GstDmaBufVideoBuffer::singleFdImportEnabledForTest()); + + qputenv("QGC_GST_DMABUF_SINGLE_EGLIMAGE", "0"); + QVERIFY(!GstDmaBufVideoBuffer::singleFdImportEnabledForTest()); + + qputenv("QGC_GST_DMABUF_SINGLE_EGLIMAGE", "false"); + QVERIFY(!GstDmaBufVideoBuffer::singleFdImportEnabledForTest()); + + qputenv("QGC_GST_DMABUF_SINGLE_EGLIMAGE", "off"); + QVERIFY(!GstDmaBufVideoBuffer::singleFdImportEnabledForTest()); + + qputenv("QGC_GST_DMABUF_SINGLE_EGLIMAGE", "1"); + QVERIFY(GstDmaBufVideoBuffer::singleFdImportEnabledForTest()); + + qputenv("QGC_GST_DMABUF_SINGLE_EGLIMAGE", "force"); + QVERIFY(GstDmaBufVideoBuffer::singleFdImportEnabledForTest()); +#endif +} + +#endif diff --git a/test/VideoManager/GStreamer/HwBuffers/gl/GStreamerGlTest.cc b/test/VideoManager/GStreamer/HwBuffers/gl/GStreamerGlTest.cc new file mode 100644 index 000000000000..10e9fce8e16c --- /dev/null +++ b/test/VideoManager/GStreamer/HwBuffers/gl/GStreamerGlTest.cc @@ -0,0 +1,199 @@ +#include "GStreamerTest.h" + +#ifdef QGC_GST_STREAMING + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Fixtures/RAIIFixtures.h" +#include "Fact.h" +#include "GStreamer.h" +#include "GStreamerHelpers.h" +#include "GStreamerLogging.h" +#include "GstVideoReceiver.h" + +// New sink controller + telemetry includes (GstAppSinkAdapter removed). +#include +#include +#include + +#include "GStreamer.h" +#include "GStreamerFrameMap.h" +#include "CpuVideoFramePool.h" +#include "GstHwPathTelemetry.h" +#include "GstHwVideoBufferFactory.h" +#include "GstSourceFactory.h" +#include "QGCQVideoSinkController.h" +#include "HwBuffers/dmabuf/GstDmaDrmCaps.h" +#include "gstqgc/GstQgcAllocation.h" +#include "gstqgc/GstQgcCaps.h" +#include "gstqgc/GstQgcVideoFormats.h" + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) +#include "GstDmaBufVideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) +#include + +#include "GstGlContextBridge.h" +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) +#include "GstD3D11ContextBridge.h" +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) +#include "GstD3D12ContextBridge.h" +#endif +#include "GstContextBridgeRegistry.h" +#include "GstHwVideoBuffer.h" +#include "GstHwVideoBufferFactory.h" +#include "gstqgc/gstqgcvideosinkbin.h" +#if defined(QGC_HAS_ANY_GPU_PATH) +#include "QGCRhiCapture.h" +#endif +#include +#include +#include +#include +#include +#include + +void GStreamerTest::_testGlMemoryDispatch() +{ + // Runtime glupload probe (not the build macro) so this runs in a direct-DMABuf build too. +#if defined(Q_OS_MACOS) || defined(Q_OS_WIN) + // Headless/off-main-thread platform plugins can emit an uncategorized + // warning when GStreamer-GL probes an OpenGL context. + ignoreLogMessage("default", QtWarningMsg, + QRegularExpression(QStringLiteral("This plugin does not support createPlatformOpenGLContext"))); +#endif +#ifdef Q_OS_MACOS + // GStreamer-GL emits an NSApplication warning on macOS when running outside the main thread. + ignoreLogMessage("Video.GStreamer.GStreamerLogging", QtWarningMsg, + QRegularExpression(QStringLiteral("An NSApplication needs to be running"))); +#endif + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "GStreamer::completeInit() failed"); + + GstElementFactory* gluploadFactory = gst_element_factory_find("glupload"); + if (!gluploadFactory) { + QSKIP("glupload factory unavailable — gst-gl not registered in this build"); + } + gst_object_unref(gluploadFactory); + + GError* error = nullptr; + GstElement* pipeline = gst_parse_launch( + "videotestsrc num-buffers=10 ! " + "video/x-raw,format=RGBA,width=320,height=240,framerate=30/1 ! " + "glupload ! " + "video/x-raw(memory:GLMemory) ! " + "qgcvideosinkbin name=sink gpu-zerocopy=true", + &error); + if (error) { + const QString msg = QString::fromUtf8(error->message); + g_clear_error(&error); + QSKIP(qPrintable(QStringLiteral("GLMemory pipeline parse skipped: %1").arg(msg))); + } + QVERIFY2(pipeline, "Failed to create GLMemory test pipeline"); + + GstElement* sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY2(sinkBin, "Could not find 'sink' element"); + GstElement* qvideosink = gst_bin_get_by_name(GST_BIN(sinkBin), "qgcqvideosink"); + QVERIFY2(qvideosink, "Could not find 'qgcqvideosink' inside sink bin"); + GstPad* appsinkPad = gst_element_get_static_pad(qvideosink, "sink"); + QVERIFY2(appsinkPad, "qgcqvideosink has no sink pad"); + + struct ProbeState + { + std::atomic bufferCount{0}; + std::atomic glMemoryCount{0}; + } probe; + + auto probeCb = +[](GstPad* /*pad*/, GstPadProbeInfo* info, gpointer userData) -> GstPadProbeReturn { + auto* st = static_cast(userData); + GstBuffer* buf = GST_PAD_PROBE_INFO_BUFFER(info); + if (buf) { + st->bufferCount.fetch_add(1, std::memory_order_relaxed); + GstMemory* mem = gst_buffer_peek_memory(buf, 0); + if (mem && mem->allocator && mem->allocator->mem_type && + g_str_has_prefix(mem->allocator->mem_type, "GLMemory")) { + st->glMemoryCount.fetch_add(1, std::memory_order_relaxed); + } + } + return GST_PAD_PROBE_OK; + }; + const gulong probeId = gst_pad_add_probe(appsinkPad, GST_PAD_PROBE_TYPE_BUFFER, probeCb, &probe, nullptr); + QVERIFY(probeId); + + GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING); + if (ret == GST_STATE_CHANGE_FAILURE) { + gst_pad_remove_probe(appsinkPad, probeId); + gst_object_unref(appsinkPad); + gst_object_unref(qvideosink); + gst_object_unref(sinkBin); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + QSKIP("GLMemory pipeline failed to PLAY (no GL context available?)"); + } + + GstBus* bus = gst_element_get_bus(pipeline); + GstMessage* msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + + QString errMsg; + bool sawError = false; + if (msg && GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { + GError* err = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(msg, &err, &debug); + errMsg = QStringLiteral("%1 (%2)") + .arg(err ? QString::fromUtf8(err->message) : QStringLiteral("?")) + .arg(debug ? QString::fromUtf8(debug) : QString()); + g_clear_error(&err); + g_free(debug); + sawError = true; + } + if (msg) + gst_message_unref(msg); + gst_object_unref(bus); + + GstCaps* negotiated = gst_pad_get_current_caps(appsinkPad); + QString negotiatedStr; + if (negotiated) { + gchar* s = gst_caps_to_string(negotiated); + negotiatedStr = QString::fromUtf8(s ? s : ""); + g_free(s); + gst_caps_unref(negotiated); + } + + gst_pad_remove_probe(appsinkPad, probeId); + gst_object_unref(appsinkPad); + gst_object_unref(qvideosink); + gst_object_unref(sinkBin); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + + if (sawError) { + // Common in headless envs without an EGL/GLX-capable display. Treat as + // skip so the test is informative when GL is available, harmless when not. + QSKIP(qPrintable(QStringLiteral("GLMemory pipeline error (likely no GL context): %1").arg(errMsg))); + } + QVERIFY2(probe.bufferCount.load() > 0, "No buffers reached qgcqvideosink under GLMemory caps"); + QVERIFY2(probe.glMemoryCount.load() > 0, + qPrintable(QStringLiteral("GLMemory negotiated but buffers carried non-GL allocator. " + "Buffers: %1, negotiated caps: %2") + .arg(probe.bufferCount.load()) + .arg(negotiatedStr))); + QVERIFY2(negotiatedStr.contains(QStringLiteral("memory:GLMemory")), + qPrintable(QStringLiteral("Appsink negotiated caps lack memory:GLMemory: %1").arg(negotiatedStr))); +} + +#endif diff --git a/test/VideoManager/GStreamer/HwBuffers/vulkan/GStreamerVulkanTest.cc b/test/VideoManager/GStreamer/HwBuffers/vulkan/GStreamerVulkanTest.cc new file mode 100644 index 000000000000..aa3635876ff0 --- /dev/null +++ b/test/VideoManager/GStreamer/HwBuffers/vulkan/GStreamerVulkanTest.cc @@ -0,0 +1,227 @@ +#include "GStreamerTest.h" + +#ifdef QGC_GST_STREAMING + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Fixtures/RAIIFixtures.h" +#include "Fact.h" +#include "GStreamer.h" +#include "GStreamerHelpers.h" +#include "GStreamerLogging.h" +#include "GstVideoReceiver.h" + +// New sink controller + telemetry includes (GstAppSinkAdapter removed). +#include +#include +#include + +#include "GStreamer.h" +#include "GStreamerFrameMap.h" +#include "CpuVideoFramePool.h" +#include "GstHwPathTelemetry.h" +#include "GstHwVideoBufferFactory.h" +#include "GstSourceFactory.h" +#include "QGCQVideoSinkController.h" +#include "HwBuffers/dmabuf/GstDmaDrmCaps.h" +#include "gstqgc/GstQgcAllocation.h" +#include "gstqgc/GstQgcCaps.h" +#include "gstqgc/GstQgcVideoFormats.h" + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) +#include "GstDmaBufVideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) +#include + +#include "GstGlContextBridge.h" +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) +#include "GstD3D11ContextBridge.h" +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) +#include "GstD3D12ContextBridge.h" +#endif +#include "GstContextBridgeRegistry.h" +#include "GstHwVideoBuffer.h" +#include "GstHwVideoBufferFactory.h" +#include "gstqgc/gstqgcvideosinkbin.h" +#if defined(QGC_HAS_ANY_GPU_PATH) +#include "QGCRhiCapture.h" +#endif +#include +#include +#include +#include +#include +#include + +void GStreamerTest::_testVulkanDispatchDemotesToCpu() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "GStreamer::completeInit() failed"); + + GstElementFactory* vkUpload = gst_element_factory_find("vulkanupload"); + if (!vkUpload) { + QSKIP("vulkanupload factory unavailable — gst-vulkan not registered in this build"); + } + gst_object_unref(vkUpload); + + QVideoSink videoSink; + int frameCount = 0; + + GError* error = nullptr; + GstElement* pipeline = gst_parse_launch( + "videotestsrc num-buffers=10 ! " + "video/x-raw,format=RGBA,width=320,height=240,framerate=30/1 ! " + "vulkanupload ! " + "video/x-raw(memory:VulkanImage) ! " + "qgcvideosinkbin name=sink gpu-zerocopy=true", + &error); + if (error) { + const QString msg = QString::fromUtf8(error->message); + g_clear_error(&error); + QSKIP(qPrintable(QStringLiteral("Vulkan pipeline parse skipped: %1").arg(msg))); + } + QVERIFY2(pipeline, "Failed to create Vulkan test pipeline"); + auto pipelineGuard = qScopeGuard([&] { + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + }); + + GstElement* sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY2(sinkBin, "Could not find 'sink' element"); + auto sinkBinGuard = qScopeGuard([&] { gst_object_unref(sinkBin); }); + + auto owner = std::make_unique(); + if (!GStreamer::setupQVideoSinkElement(sinkBin, &videoSink, owner.get())) { + QSKIP("setupQVideoSinkElement() failed (no GPU context for Vulkan)"); + } + + QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, owner.get(), + [&frameCount](const QVideoFrame&) { ++frameCount; }); + + GstElement* qvideosink = gst_bin_get_by_name(GST_BIN(sinkBin), "qgcqvideosink"); + QVERIFY2(qvideosink, "Could not find 'qgcqvideosink' inside sink bin"); + auto qvideosinkGuard = qScopeGuard([&] { gst_object_unref(qvideosink); }); + + GstPad* appsinkPad = gst_element_get_static_pad(qvideosink, "sink"); + QVERIFY2(appsinkPad, "qgcqvideosink has no sink pad"); + auto appsinkPadGuard = qScopeGuard([&] { gst_object_unref(appsinkPad); }); + + std::atomic bufferCount{0}; + auto probeCb = +[](GstPad* /*pad*/, GstPadProbeInfo* info, gpointer userData) -> GstPadProbeReturn { + auto* c = static_cast*>(userData); + if (GST_PAD_PROBE_INFO_BUFFER(info)) { + c->fetch_add(1, std::memory_order_relaxed); + } + return GST_PAD_PROBE_OK; + }; + const gulong probeId = gst_pad_add_probe(appsinkPad, GST_PAD_PROBE_TYPE_BUFFER, probeCb, &bufferCount, nullptr); + QVERIFY(probeId); + auto probeGuard = qScopeGuard([&] { gst_pad_remove_probe(appsinkPad, probeId); }); + + using GstHwPathTelemetry::HwFallbackReason; + const quint64 vkDeliveredBefore = GstHwPathTelemetry::peekDeliveredCount(HwVideoBufferPath::Vulkan); + const quint64 noSyncBefore = + GstHwPathTelemetry::peekFallbackReason(HwVideoBufferPath::Vulkan, HwFallbackReason::VulkanNoSync); + const quint64 noExtBefore = + GstHwPathTelemetry::peekFallbackReason(HwVideoBufferPath::Vulkan, HwFallbackReason::NoExt); + (void) GstHwPathTelemetry::takeStreamDemotions(HwVideoBufferPath::Vulkan); + + GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING); + if (ret == GST_STATE_CHANGE_FAILURE) { + QSKIP("Vulkan pipeline failed to PLAY (no Vulkan device in headless env?)"); + } + + GstBus* bus = gst_element_get_bus(pipeline); + GstMessage* msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + + QString errMsg; + bool sawError = false; + if (msg && GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { + GError* err = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(msg, &err, &debug); + errMsg = QStringLiteral("%1 (%2)") + .arg(err ? QString::fromUtf8(err->message) : QStringLiteral("?")) + .arg(debug ? QString::fromUtf8(debug) : QString()); + g_clear_error(&err); + g_free(debug); + sawError = true; + } + if (msg) + gst_message_unref(msg); + gst_object_unref(bus); + + // Demotion telemetry is recorded on the QRhi render path, which can land after the bus EOS; poll up + // to 2s (not a fixed window) so a slow demotion is observed rather than spuriously skipped. + quint64 streamDemotions = 0; + bool demotionSignalled = false; + { + QElapsedTimer drain; + drain.start(); + while (drain.elapsed() < 2000) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + streamDemotions += GstHwPathTelemetry::takeStreamDemotions(HwVideoBufferPath::Vulkan); + const quint64 noSyncNow = + GstHwPathTelemetry::peekFallbackReason(HwVideoBufferPath::Vulkan, HwFallbackReason::VulkanNoSync); + const quint64 noExtNow = + GstHwPathTelemetry::peekFallbackReason(HwVideoBufferPath::Vulkan, HwFallbackReason::NoExt); + if (streamDemotions > 0 || noSyncNow > noSyncBefore || noExtNow > noExtBefore) { + demotionSignalled = true; + break; + } + } + } + + GstCaps* negotiated = gst_pad_get_current_caps(appsinkPad); + QString negotiatedStr; + if (negotiated) { + gchar* s = gst_caps_to_string(negotiated); + negotiatedStr = QString::fromUtf8(s ? s : ""); + g_free(s); + gst_caps_unref(negotiated); + } + + const int probedBuffers = bufferCount.load(); + const quint64 vkDeliveredAfter = GstHwPathTelemetry::peekDeliveredCount(HwVideoBufferPath::Vulkan); + + if (sawError) { + QSKIP(qPrintable(QStringLiteral("Vulkan pipeline error (likely no Vulkan device): %1").arg(errMsg))); + } + if (probedBuffers == 0 || !negotiatedStr.contains(QStringLiteral("memory:VulkanImage"))) { + QSKIP(qPrintable(QStringLiteral("Vulkan memory not negotiated headless. Buffers: %1, caps: %2") + .arg(probedBuffers) + .arg(negotiatedStr))); + } + + // Vulkan is compiled but runtime-dormant: QGC pins the GL RHI, so the per-frame VkDevice-match + // guard demotes every foreign-device frame to CPU. Assert delivery still works, never zero-copy. + QVERIFY2(frameCount > 0 || probedBuffers > 0, "No frames delivered through the sink under Vulkan caps"); + QVERIFY2(vkDeliveredAfter == vkDeliveredBefore, + qPrintable(QStringLiteral("Vulkan zero-copy delivery was recorded but the device-match guard " + "should demote (before=%1 after=%2)") + .arg(vkDeliveredBefore) + .arg(vkDeliveredAfter))); + + if (!demotionSignalled) { + QSKIP(qPrintable(QStringLiteral("Vulkan frames negotiated VulkanImage but the device-match demotion " + "did not fire headless (mapTextures() needs a QRhi render thread). " + "caps: %1") + .arg(negotiatedStr))); + } +} + +#endif diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc new file mode 100644 index 000000000000..35895f2a9dba --- /dev/null +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -0,0 +1,357 @@ +#include "GStreamerTest.h" + +#ifdef QGC_GST_STREAMING + +#include +#include +#include + +#include "GstSourceFactory.h" + +namespace { + +// Borrowed (bin-owned) first child whose element-factory name matches, or nullptr. +GstElement* findChildByFactoryName(GstElement* bin, const char* factoryName) +{ + GstIterator* it = gst_bin_iterate_elements(GST_BIN(bin)); + GValue item = G_VALUE_INIT; + GstElement* match = nullptr; + bool done = false; + while (!done) { + switch (gst_iterator_next(it, &item)) { + case GST_ITERATOR_OK: { + GstElement* child = GST_ELEMENT(g_value_get_object(&item)); + GstElementFactory* f = gst_element_get_factory(child); + if (f && (g_strcmp0(GST_OBJECT_NAME(f), factoryName) == 0)) { + match = child; + done = true; + } + g_value_reset(&item); + break; + } + case GST_ITERATOR_RESYNC: + gst_iterator_resync(it); + break; + default: + done = true; + break; + } + } + g_value_unset(&item); + gst_iterator_free(it); + return match; +} + +} // namespace + +void GStreamerTest::_testSourceFactoryUdpRtpJitterBuffer() +{ + if (!gst_element_factory_find("udpsrc") || !gst_element_factory_find("rtpjitterbuffer")) { + QSKIP("udpsrc/rtpjitterbuffer plugin unavailable"); + } + + GStreamer::SourceFactory::Config config; + config.jitterBuffer = GStreamer::SourceFactory::JitterBuffer::DropOnLatency; + config.latencyMs = 80; + config.doRetransmission = true; + + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("udp://127.0.0.1:5600"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* jb = findChildByFactoryName(bin, "rtpjitterbuffer"); + QVERIFY2(jb, "static UDP-RTP path must insert an rtpjitterbuffer at NULL state"); + + guint latency = 0; + gboolean doLost = FALSE, doRtx = FALSE, dropOnLatency = FALSE; + gint rtxDelay = 0, rtxMaxRetries = 0; + g_object_get(jb, "latency", &latency, "do-lost", &doLost, "do-retransmission", &doRtx, "drop-on-latency", + &dropOnLatency, "rtx-delay", &rtxDelay, "rtx-max-retries", &rtxMaxRetries, nullptr); + QCOMPARE(latency, 80u); + QCOMPARE(doLost, TRUE); + QCOMPARE(doRtx, TRUE); + QCOMPARE(dropOnLatency, TRUE); + QCOMPARE(rtxDelay, 25); + QCOMPARE(rtxMaxRetries, 1); +} + +void GStreamerTest::_testSourceFactoryJitterBufferNone() +{ + if (!gst_element_factory_find("udpsrc")) { + QSKIP("udpsrc plugin unavailable"); + } + + GStreamer::SourceFactory::Config config; + config.jitterBuffer = GStreamer::SourceFactory::JitterBuffer::None; + + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("udp://127.0.0.1:5600"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + QVERIFY2(!findChildByFactoryName(bin, "rtpjitterbuffer"), + "JitterBuffer::None must link the source straight to the parser"); +} + +void GStreamerTest::_testSourceFactoryNoRetransmission() +{ + if (!gst_element_factory_find("udpsrc") || !gst_element_factory_find("rtpjitterbuffer")) { + QSKIP("udpsrc/rtpjitterbuffer plugin unavailable"); + } + + GStreamer::SourceFactory::Config config; + config.jitterBuffer = GStreamer::SourceFactory::JitterBuffer::Buffered; + config.doRetransmission = false; + + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("udp://127.0.0.1:5600"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* jb = findChildByFactoryName(bin, "rtpjitterbuffer"); + QVERIFY(jb); + + gboolean doRtx = TRUE, dropOnLatency = TRUE; + g_object_get(jb, "do-retransmission", &doRtx, "drop-on-latency", &dropOnLatency, nullptr); + QCOMPARE(doRtx, FALSE); + QCOMPARE(dropOnLatency, FALSE); +} + +void GStreamerTest::_testSourceFactoryRtspExcludesStaticJitterBuffer() +{ + if (!gst_element_factory_find("rtspsrc")) { + QSKIP("rtspsrc plugin unavailable"); + } + + GStreamer::SourceFactory::Config config; + config.jitterBuffer = GStreamer::SourceFactory::JitterBuffer::DropOnLatency; + + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("rtsp://127.0.0.1:8554/test"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + QVERIFY2(!findChildByFactoryName(bin, "rtpjitterbuffer"), + "rtspsrc owns its internal jitterbuffer; the factory must not add a second one"); +} + +void GStreamerTest::_testSourceFactoryRejectsBadUri() +{ + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtCriticalMsg, + QRegularExpression(QStringLiteral("URI is not specified|Invalid UDP port"))); + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, + QRegularExpression(QStringLiteral("Unsupported URI scheme"))); + + GStreamer::SourceFactory::Config config; + QVERIFY(!GStreamer::SourceFactory::create(QString(), config)); + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("ftp://127.0.0.1/x"), config)); + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("udp://127.0.0.1:0"), config)); + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("udp://127.0.0.1:99999"), config)); +} + +void GStreamerTest::_testSourceFactoryTcpMpegTs() +{ + if (!gst_element_factory_find("tcpclientsrc") || !gst_element_factory_find("tsdemux")) { + QSKIP("tcpclientsrc/tsdemux plugin unavailable"); + } + + GStreamer::SourceFactory::Config config; + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("tcp://192.168.1.50:5600"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* src = findChildByFactoryName(bin, "tcpclientsrc"); + QVERIFY2(src, "tcp:// must build a tcpclientsrc"); + gchar* host = nullptr; + gint port = 0; + g_object_get(src, "host", &host, "port", &port, nullptr); + const auto hostCleanup = qScopeGuard([&] { g_free(host); }); + QCOMPARE(QString::fromUtf8(host), QStringLiteral("192.168.1.50")); + QCOMPARE(port, 5600); + + QVERIFY2(findChildByFactoryName(bin, "tsdemux"), "MPEG-TS over TCP must insert tsdemux explicitly"); + QVERIFY2(!findChildByFactoryName(bin, "rtpjitterbuffer"), "raw MPEG-TS is not RTP; no jitterbuffer"); +} + +void GStreamerTest::_testSourceFactoryRejectsBadTcpUri() +{ + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtCriticalMsg, + QRegularExpression(QStringLiteral("Invalid TCP port|Missing host in TCP URI"))); + + GStreamer::SourceFactory::Config config; + QVERIFY2(!GStreamer::SourceFactory::create(QStringLiteral("tcp://192.168.1.50"), config), + "tcp:// without a port must be rejected"); + QVERIFY2(!GStreamer::SourceFactory::create(QStringLiteral("tcp://:5600"), config), + "tcp:// without a host must be rejected"); + QVERIFY2(!GStreamer::SourceFactory::create(QStringLiteral("tcp://192.168.1.50:0"), config), + "tcp:// with port 0 must be rejected"); +} + +void GStreamerTest::_testSourceFactoryUdp265Caps() +{ + if (!gst_element_factory_find("udpsrc") || !gst_element_factory_find("rtpjitterbuffer")) { + QSKIP("udpsrc/rtpjitterbuffer plugin unavailable"); + } + + GStreamer::SourceFactory::Config config; + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("udp265://127.0.0.1:5600"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* src = findChildByFactoryName(bin, "udpsrc"); + QVERIFY2(src, "udp265:// must build a udpsrc"); + + GstCaps* caps = nullptr; + g_object_get(src, "caps", &caps, nullptr); + QVERIFY2(caps, "udp265:// must set H265 RTP caps on udpsrc"); + const GstStructure* st = gst_caps_get_structure(caps, 0); + const gchar* encName = gst_structure_get_string(st, "encoding-name"); + QCOMPARE(QString::fromUtf8(encName), QStringLiteral("H265")); + gst_caps_unref(caps); + + QVERIFY2(findChildByFactoryName(bin, "rtpjitterbuffer"), "udp265 RTP path must insert a jitterbuffer"); + QVERIFY2(!findChildByFactoryName(bin, "tsdemux"), "udp265 is RTP, not MPEG-TS; no tsdemux"); +} + +void GStreamerTest::_testSourceFactoryUdp265UsesExplicitDepayAndParser() +{ + if (!gst_element_factory_find("udpsrc") || !gst_element_factory_find("rtph265depay") || + !gst_element_factory_find("h265parse")) { + QSKIP("udp265 RTP/H265 plugins unavailable"); + } + + GStreamer::SourceFactory::Config config; + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("udp265://127.0.0.1:5600"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + QVERIFY2(findChildByFactoryName(bin, "rtph265depay"), "udp265:// must depayload H265 RTP explicitly"); + + GstElement* parser = findChildByFactoryName(bin, "h265parse"); + QVERIFY2(parser, "udp265:// must parse H265 explicitly"); + + gint configInterval = 0; + g_object_get(parser, "config-interval", &configInterval, nullptr); + QCOMPARE(configInterval, -1); +} + +void GStreamerTest::_testSourceFactoryUdpH264Caps() +{ + if (!gst_element_factory_find("udpsrc")) { + QSKIP("udpsrc plugin unavailable"); + } + + GStreamer::SourceFactory::Config config; + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("udp://127.0.0.1:5600"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* src = findChildByFactoryName(bin, "udpsrc"); + QVERIFY(src); + + GstCaps* caps = nullptr; + g_object_get(src, "caps", &caps, nullptr); + QVERIFY2(caps, "udp:// must set H264 RTP caps"); + const GstStructure* st = gst_caps_get_structure(caps, 0); + const gchar* media = gst_structure_get_string(st, "media"); + const gchar* encName = gst_structure_get_string(st, "encoding-name"); + QCOMPARE(QString::fromUtf8(media), QStringLiteral("video")); + QCOMPARE(QString::fromUtf8(encName), QStringLiteral("H264")); + gst_caps_unref(caps); +} + +void GStreamerTest::_testSourceFactoryUdpMpegTs() +{ + if (!gst_element_factory_find("udpsrc") || !gst_element_factory_find("tsdemux")) { + QSKIP("udpsrc/tsdemux plugin unavailable"); + } + + GStreamer::SourceFactory::Config config; + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("mpegts://127.0.0.1:5600"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* src = findChildByFactoryName(bin, "udpsrc"); + QVERIFY2(src, "mpegts:// must build a udpsrc"); + + GstCaps* caps = nullptr; + g_object_get(src, "caps", &caps, nullptr); + if (caps) { + gst_caps_unref(caps); + } + QVERIFY2(!caps, "raw MPEG-TS over UDP must not force RTP caps on udpsrc"); + + QVERIFY2(findChildByFactoryName(bin, "tsdemux"), "mpegts:// must insert tsdemux"); + QVERIFY2(!findChildByFactoryName(bin, "rtpjitterbuffer"), "raw MPEG-TS is not RTP; no jitterbuffer"); +} + +void GStreamerTest::_testSourceFactorySchemeCaseInsensitive() +{ + if (!gst_element_factory_find("udpsrc")) { + QSKIP("udpsrc plugin unavailable"); + } + + GStreamer::SourceFactory::Config config; + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("UDP://127.0.0.1:5600"), config); + QVERIFY2(bin, "uppercase scheme must be accepted (scheme is lower-cased before matching)"); + gst_object_unref(bin); +} + +void GStreamerTest::_testSourceFactoryNegativeLatencyClamped() +{ + if (!gst_element_factory_find("udpsrc") || !gst_element_factory_find("rtpjitterbuffer")) { + QSKIP("udpsrc/rtpjitterbuffer plugin unavailable"); + } + + GStreamer::SourceFactory::Config config; + config.latencyMs = -100; + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("udp://127.0.0.1:5600"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* jb = findChildByFactoryName(bin, "rtpjitterbuffer"); + QVERIFY(jb); + guint latency = 99999; + g_object_get(jb, "latency", &latency, nullptr); + QCOMPARE(latency, 0u); +} + +void GStreamerTest::_testSourceFactoryDynamicRtpLinkFailureCleansJitterBuffer() +{ + if (!gst_element_factory_find("udpsrc") || !gst_element_factory_find("tsdemux") || + !gst_element_factory_find("rtpjitterbuffer")) { + QSKIP("udpsrc/tsdemux/rtpjitterbuffer plugin unavailable"); + } + + GStreamer::SourceFactory::Config config; + config.jitterBuffer = GStreamer::SourceFactory::JitterBuffer::DropOnLatency; + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("mpegts://127.0.0.1:5600"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* tsdemux = findChildByFactoryName(bin, "tsdemux"); + QVERIFY(tsdemux); + GstElement* parser = findChildByFactoryName(bin, "parsebin"); + QVERIFY(parser); + + GstPad* parserSink = gst_element_get_static_pad(parser, "sink"); + QVERIFY(parserSink); + const auto parserSinkCleanup = qScopeGuard([&] { gst_object_unref(parserSink); }); + QVERIFY(!gst_pad_is_linked(parserSink)); + + static GstStaticPadTemplate sRtpPadTemplate = GST_STATIC_PAD_TEMPLATE( + "stray_rtp", GST_PAD_SRC, GST_PAD_SOMETIMES, + GST_STATIC_CAPS("application/x-rtp, media=(string)video, clock-rate=(int)90000, encoding-name=(string)H264")); + + GstPad* strayPad = gst_pad_new_from_static_template(&sRtpPadTemplate, "stray_rtp"); + QVERIFY(strayPad); + const auto padCleanup = qScopeGuard([&] { gst_object_unref(strayPad); }); + + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, + QRegularExpression(QStringLiteral("gst_element_link_pads\\(\\) failed"))); + g_signal_emit_by_name(tsdemux, "pad-added", strayPad); + + QVERIFY2(!gst_pad_is_linked(parserSink), + "a failed dynamic RTP pad link must leave parsebin available for the next valid pad"); + QVERIFY2(!findChildByFactoryName(bin, "rtpjitterbuffer"), + "a failed dynamic RTP pad link must remove its temporary jitterbuffer"); +} + +#endif diff --git a/test/VideoManager/GStreamer/gstqgc/GStreamerGstQgcTest.cc b/test/VideoManager/GStreamer/gstqgc/GStreamerGstQgcTest.cc new file mode 100644 index 000000000000..25b875ba1f41 --- /dev/null +++ b/test/VideoManager/GStreamer/gstqgc/GStreamerGstQgcTest.cc @@ -0,0 +1,1526 @@ +#include "GStreamerTest.h" + +#ifdef QGC_GST_STREAMING + +#include "CpuVideoFramePool.h" +#include "Fixtures/RAIIFixtures.h" +#include "Fact.h" +#include "GStreamer.h" +#include "GStreamerFrameMap.h" +#include "GStreamerHelpers.h" +#include "GStreamerLogging.h" +#include "GstVideoReceiver.h" +#include "GstHwPathTelemetry.h" +#include "GstHwVideoBufferFactory.h" +#include "GstSourceFactory.h" +#include "HwBuffers/dmabuf/GstDmaDrmCaps.h" +#include "QGCQVideoSinkController.h" +#include "gstqgc/GstQgcAllocation.h" +#include "gstqgc/GstQgcCaps.h" +#include "gstqgc/GstQgcVideoFormats.h" + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) +#include "GstDmaBufVideoBuffer.h" +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) +#include + +#include "GstGlContextBridge.h" +#endif +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) +#include "GstD3D11ContextBridge.h" +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) +#include "GstD3D12ContextBridge.h" +#endif +#include "GstContextBridgeRegistry.h" +#include "GstHwVideoBuffer.h" +#include "gstqgc/gstqgcvideosinkbin.h" +#if defined(QGC_HAS_ANY_GPU_PATH) +#include "QGCRhiCapture.h" +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void GStreamerTest::_testAppsinkFrameDelivery() +{ + // Ensure the qgc plugin (including qgcvideosinkbin) is registered. + // _testCompleteInit runs before this slot, but guard against reorder. + GstElementFactory* guardFactory = gst_element_factory_find("qgcvideosinkbin"); + if (!guardFactory) { + GStreamer::completeInit(); + } else { + gst_object_unref(guardFactory); + } + + GstElementFactory* factory = gst_element_factory_find("qgcvideosinkbin"); + QVERIFY2(factory, "qgcvideosinkbin factory not found"); + gst_object_unref(factory); + + GError* error = nullptr; + GstElement* pipeline = gst_parse_launch( + "videotestsrc num-buffers=10 ! " + "video/x-raw,format=I420,width=320,height=240,framerate=30/1 ! " + "videoconvert ! " + "video/x-raw,format=BGRA ! " + "qgcvideosinkbin name=sink", + &error); + if (error) { + const QString msg = QString::fromUtf8(error->message); + g_clear_error(&error); + QFAIL(qPrintable(QStringLiteral("Pipeline parse error: %1").arg(msg))); + } + QVERIFY2(pipeline, "Failed to create appsink test pipeline"); + + GstElement* sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY2(sinkBin, "Could not find 'sink' element in pipeline"); + + QVideoSink videoSink; + QObject controllerOwner; + + int frameCount = 0; + QSize lastFrameSize; + QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &controllerOwner, [&](const QVideoFrame& frame) { + frameCount++; + lastFrameSize = frame.size(); + }); + + QVERIFY2(GStreamer::setupQVideoSinkElement(sinkBin, &videoSink, &controllerOwner), + "GStreamer::setupQVideoSinkElement() failed"); + auto* controller = controllerOwner.findChild(QString(), Qt::FindDirectChildrenOnly); + QVERIFY(controller); + + gst_object_unref(sinkBin); + + GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING); + QVERIFY2(ret != GST_STATE_CHANGE_FAILURE, "Pipeline failed to transition to PLAYING"); + + GstBus* bus = gst_element_get_bus(pipeline); + QVERIFY(bus); + + GstMessage* msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + QVERIFY2(msg, "Pipeline timed out waiting for EOS or ERROR"); + + if (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { + GError* err = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(msg, &err, &debug); + const QString errMsg = QStringLiteral("%1 (%2)") + .arg(err ? QString::fromUtf8(err->message) : QStringLiteral("unknown")) + .arg(debug ? QString::fromUtf8(debug) : QString()); + g_clear_error(&err); + g_free(debug); + gst_message_unref(msg); + gst_object_unref(bus); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + QFAIL(qPrintable(QStringLiteral("Pipeline error: %1").arg(errMsg))); + } + + QCOMPARE(GST_MESSAGE_TYPE(msg), GST_MESSAGE_EOS); + gst_message_unref(msg); + gst_object_unref(bus); + + QTRY_VERIFY_WITH_TIMEOUT(frameCount > 0, TestTimeout::mediumMs()); + QCOMPARE(lastFrameSize, QSize(320, 240)); + + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); +} + +void GStreamerTest::_testAppsinkYuvPassthrough() +{ + GstElementFactory* guardFactory = gst_element_factory_find("qgcvideosinkbin"); + if (!guardFactory) { + GStreamer::completeInit(); + } else { + gst_object_unref(guardFactory); + } + + GError* error = nullptr; + GstElement* pipeline = gst_parse_launch( + "videotestsrc num-buffers=10 ! " + "video/x-raw,format=I420,width=320,height=240,framerate=30/1 ! " + "qgcvideosinkbin name=sink", + &error); + if (error) { + const QString msg = QString::fromUtf8(error->message); + g_clear_error(&error); + QFAIL(qPrintable(QStringLiteral("Pipeline parse error: %1").arg(msg))); + } + QVERIFY2(pipeline, "Failed to create YUV passthrough pipeline"); + + GstElement* sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY2(sinkBin, "Could not find 'sink' element"); + + QVideoSink videoSink; + QObject controllerOwner; + + int frameCount = 0; + QVideoFrameFormat::PixelFormat lastPixelFormat = QVideoFrameFormat::Format_Invalid; + QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &controllerOwner, [&](const QVideoFrame& frame) { + frameCount++; + lastPixelFormat = frame.pixelFormat(); + }); + + QVERIFY2(GStreamer::setupQVideoSinkElement(sinkBin, &videoSink, &controllerOwner), + "GStreamer::setupQVideoSinkElement() failed"); + gst_object_unref(sinkBin); + + QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, "Pipeline failed to PLAY"); + + GstBus* bus = gst_element_get_bus(pipeline); + GstMessage* msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + QVERIFY2(msg, "Pipeline timed out"); + if (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { + gst_message_unref(msg); + gst_object_unref(bus); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + QFAIL("YUV passthrough pipeline errored"); + } + QCOMPARE(GST_MESSAGE_TYPE(msg), GST_MESSAGE_EOS); + gst_message_unref(msg); + gst_object_unref(bus); + + QTRY_VERIFY_WITH_TIMEOUT(frameCount > 0, TestTimeout::mediumMs()); + QCOMPARE(lastPixelFormat, QVideoFrameFormat::Format_YUV420P); + + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); +} + +void GStreamerTest::_testAppsinkPtsAndColorimetry() +{ + GstElementFactory* guardFactory = gst_element_factory_find("qgcvideosinkbin"); + if (!guardFactory) { + GStreamer::completeInit(); + } else { + gst_object_unref(guardFactory); + } + + GError* error = nullptr; + GstElement* pipeline = gst_parse_launch( + "videotestsrc num-buffers=5 do-timestamp=true ! " + "video/x-raw,format=I420,width=64,height=48,framerate=30/1," + "colorimetry=(string)bt709 ! " + "qgcvideosinkbin name=sink", + &error); + if (error) { + const QString msg = QString::fromUtf8(error->message); + g_clear_error(&error); + QFAIL(qPrintable(QStringLiteral("Pipeline parse error: %1").arg(msg))); + } + QVERIFY2(pipeline, "Failed to create PTS/colorimetry pipeline"); + + GstElement* sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY2(sinkBin, "Could not find 'sink' element"); + + QVideoSink videoSink; + QObject controllerOwner; + + int frameCount = 0; + qint64 lastStartTime = -1; + QVideoFrameFormat::ColorSpace lastColorSpace = QVideoFrameFormat::ColorSpace_Undefined; + QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &controllerOwner, [&](const QVideoFrame& frame) { + frameCount++; + lastStartTime = frame.startTime(); + lastColorSpace = frame.surfaceFormat().colorSpace(); + }); + + QVERIFY2(GStreamer::setupQVideoSinkElement(sinkBin, &videoSink, &controllerOwner), + "GStreamer::setupQVideoSinkElement() failed"); + gst_object_unref(sinkBin); + + QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, "Pipeline failed to PLAY"); + + GstBus* bus = gst_element_get_bus(pipeline); + GstMessage* msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + QVERIFY2(msg, "Pipeline timed out"); + if (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { + gst_message_unref(msg); + gst_object_unref(bus); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + QFAIL("PTS/colorimetry pipeline errored"); + } + QCOMPARE(GST_MESSAGE_TYPE(msg), GST_MESSAGE_EOS); + gst_message_unref(msg); + gst_object_unref(bus); + + QTRY_VERIFY_WITH_TIMEOUT(frameCount > 0, TestTimeout::mediumMs()); + QVERIFY2(lastStartTime >= 0, "GstBuffer PTS not forwarded to QVideoFrame::startTime"); + QCOMPARE(lastColorSpace, QVideoFrameFormat::ColorSpace_BT709); + + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); +} + +void GStreamerTest::_testQgcVideoSinkBinGpuZeroCopyProperty() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "GStreamer::completeInit() failed"); + + GstElementFactory* factory = gst_element_factory_find("qgcvideosinkbin"); + QVERIFY2(factory, "qgcvideosinkbin factory not found"); + + { + GstElement* bin = gst_element_factory_create_full(factory, "gpu-zerocopy", FALSE, NULL); + QVERIFY2(bin, "Failed to create qgcvideosinkbin (CPU branch)"); + + gboolean prop = TRUE; + g_object_get(bin, "gpu-zerocopy", &prop, NULL); + QCOMPARE(prop, FALSE); + + GstElement* qvideosink = gst_bin_get_by_name(GST_BIN(bin), "qgcqvideosink"); + QVERIFY2(qvideosink, "qgcqvideosink missing from CPU bin"); + + gboolean sync = TRUE; + gboolean qos = TRUE; + guint64 processingDeadline = 0; + g_object_get(bin, "sync", &sync, "qos", &qos, "processing-deadline", &processingDeadline, NULL); + QCOMPARE(sync, FALSE); + QCOMPARE(qos, FALSE); + QCOMPARE(processingDeadline, G_GUINT64_CONSTANT(20000000)); + + g_object_get(qvideosink, "sync", &sync, "qos", &qos, "processing-deadline", &processingDeadline, NULL); + QCOMPARE(sync, FALSE); + QCOMPARE(qos, FALSE); + QCOMPARE(processingDeadline, G_GUINT64_CONSTANT(20000000)); + + constexpr guint64 kUpdatedDeadline = G_GUINT64_CONSTANT(1234567); + g_object_set(bin, "sync", TRUE, "qos", TRUE, "processing-deadline", kUpdatedDeadline, NULL); + + g_object_get(bin, "sync", &sync, "qos", &qos, "processing-deadline", &processingDeadline, NULL); + QCOMPARE(sync, TRUE); + QCOMPARE(qos, TRUE); + QCOMPARE(processingDeadline, kUpdatedDeadline); + + g_object_get(qvideosink, "sync", &sync, "qos", &qos, "processing-deadline", &processingDeadline, NULL); + QCOMPARE(sync, TRUE); + QCOMPARE(qos, TRUE); + QCOMPARE(processingDeadline, kUpdatedDeadline); + + GstIterator* it = gst_bin_iterate_elements(GST_BIN(bin)); + int elementCount = 0; + bool sawVideoconvert = false; + gboolean done = FALSE; + GValue val = G_VALUE_INIT; + while (!done) { + switch (gst_iterator_next(it, &val)) { + case GST_ITERATOR_OK: { + ++elementCount; + GstElement* child = GST_ELEMENT(g_value_get_object(&val)); + gchar* name = gst_element_get_name(child); + if (name && QString::fromUtf8(name).startsWith(QStringLiteral("videoconvert"))) { + sawVideoconvert = true; + } + g_free(name); + g_value_reset(&val); + break; + } + case GST_ITERATOR_RESYNC: + gst_iterator_resync(it); + break; + case GST_ITERATOR_ERROR: + case GST_ITERATOR_DONE: + done = TRUE; + break; + } + } + g_value_unset(&val); + gst_iterator_free(it); + + // CPU branch: videoconvert + PAR=1/1 capsfilter + format capsfilter + qgcqvideosink (4 children). + QCOMPARE(elementCount, 4); + QVERIFY2(sawVideoconvert, "CPU bin missing videoconvert"); + + gst_object_unref(qvideosink); + gst_object_unref(bin); + } + + { + // disable-par=TRUE drops the PAR capsfilter (videoconvert + format capsfilter + qgcqvideosink). + GstElement* bin = gst_element_factory_create_full(factory, "gpu-zerocopy", FALSE, "disable-par", TRUE, NULL); + QVERIFY2(bin, "Failed to create qgcvideosinkbin (CPU branch, disable-par=TRUE)"); + GstIterator* it = gst_bin_iterate_elements(GST_BIN(bin)); + int elementCount = 0; + gboolean done = FALSE; + GValue val = G_VALUE_INIT; + while (!done) { + switch (gst_iterator_next(it, &val)) { + case GST_ITERATOR_OK: + ++elementCount; + g_value_reset(&val); + break; + case GST_ITERATOR_RESYNC: + gst_iterator_resync(it); + break; + case GST_ITERATOR_ERROR: + case GST_ITERATOR_DONE: + done = TRUE; + break; + } + } + g_value_unset(&val); + gst_iterator_free(it); + QCOMPARE(elementCount, 3); + gst_object_unref(bin); + } + +#if defined(QGC_HAS_ANY_GPU_PATH) + { + GstElement* bin = gst_element_factory_create_full(factory, "gpu-zerocopy", TRUE, NULL); + QVERIFY2(bin, "Failed to create qgcvideosinkbin (GPU branch)"); + + gboolean prop = FALSE; + g_object_get(bin, "gpu-zerocopy", &prop, NULL); + QCOMPARE(prop, TRUE); + + GstElement* qvideosink = gst_bin_get_by_name(GST_BIN(bin), "qgcqvideosink"); + QVERIFY2(qvideosink, "qgcqvideosink missing from GPU bin"); + + GstIterator* it = gst_bin_iterate_elements(GST_BIN(bin)); + int elementCount = 0; +#if defined(QGC_GST_BIN_USE_GLUPLOAD) || \ + (defined(Q_OS_LINUX) && defined(QGC_HAS_GST_DMABUF_GPU_PATH) && defined(QGC_HAS_GST_GLMEMORY_GPU_PATH)) + bool sawGlupload = false; + bool sawGlColorConvert = false; +#endif + gboolean done = FALSE; + GValue val = G_VALUE_INIT; + while (!done) { + switch (gst_iterator_next(it, &val)) { + case GST_ITERATOR_OK: { + ++elementCount; + GstElement* child = GST_ELEMENT(g_value_get_object(&val)); + gchar* name = gst_element_get_name(child); +#if defined(QGC_GST_BIN_USE_GLUPLOAD) || \ + (defined(Q_OS_LINUX) && defined(QGC_HAS_GST_DMABUF_GPU_PATH) && defined(QGC_HAS_GST_GLMEMORY_GPU_PATH)) + if (name && QString::fromUtf8(name).startsWith(QStringLiteral("glupload"))) { + sawGlupload = true; + } + if (name && QString::fromUtf8(name).startsWith(QStringLiteral("glcolorconvert"))) { + sawGlColorConvert = true; + } +#endif + g_free(name); + g_value_reset(&val); + break; + } + case GST_ITERATOR_RESYNC: + gst_iterator_resync(it); + break; + case GST_ITERATOR_ERROR: + case GST_ITERATOR_DONE: + done = TRUE; + break; + } + } + g_value_unset(&val); + gst_iterator_free(it); + + // Caps are on the format capsfilter; qgcqvideosink is a GstVideoSink with no "caps" property. + GstElement* formatFilter = gst_bin_get_by_name(GST_BIN(bin), "qgc-format-filter"); + QVERIFY2(formatFilter, "GPU bin missing qgc-format-filter"); + GstCaps* caps = nullptr; + g_object_get(formatFilter, "caps", &caps, NULL); + QVERIFY2(caps, "GPU bin format capsfilter has null caps"); + gchar* capsStr = gst_caps_to_string(caps); + const QString s = QString::fromUtf8(capsStr ? capsStr : ""); + g_free(capsStr); + gst_caps_unref(caps); + gst_object_unref(formatFilter); + +#if defined(Q_OS_LINUX) && defined(QGC_HAS_GST_DMABUF_GPU_PATH) && defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + // Linux must prefer QGC's direct DMABuf importer when it is compiled; otherwise gst-gl/glupload consumes the + // stream first and DMABuf dispatch never gets exercised in normal gpu-zerocopy pipelines. + QCOMPARE(elementCount, 2); + QVERIFY2(!sawGlupload, "Linux DMABuf GPU bin must not insert glupload when direct DMABuf is available"); + QVERIFY2(!sawGlColorConvert, + "Linux DMABuf GPU bin must not insert glcolorconvert when direct DMABuf is available"); + QVERIFY2(s.contains(QStringLiteral("memory:DMABuf")), + qUtf8Printable(QStringLiteral("GPU bin caps missing memory:DMABuf: ") + s)); + QVERIFY2(!s.contains(QStringLiteral("memory:GLMemory")), + qUtf8Printable(QStringLiteral("Direct DMABuf bin caps must not advertise GLMemory first: ") + s)); +#elif defined(QGC_GST_BIN_USE_GLUPLOAD) + // Linux desktop: glupload → glcolorconvert → format_capsfilter → qgcqvideosink (4 children). + QCOMPARE(elementCount, 4); + QVERIFY2(sawGlupload, "GPU bin missing glupload"); + QVERIFY2(sawGlColorConvert, "GPU bin missing glcolorconvert"); + QVERIFY2(s.contains(QStringLiteral("memory:GLMemory")), + qUtf8Printable(QStringLiteral("GPU bin caps missing memory:GLMemory: ") + s)); + QVERIFY2(!s.contains(QStringLiteral("NV12")), + qUtf8Printable(QStringLiteral("GLMemory caps must force RGB(A), not multi-plane NV12: ") + s)); + QVERIFY2(s.contains(QStringLiteral("RGBA")) || s.contains(QStringLiteral("BGRA")), + qUtf8Printable(QStringLiteral("GLMemory caps missing RGB(A) format: ") + s)); +#elif defined(Q_OS_WIN) && (defined(QGC_HAS_GST_D3D11_GPU_PATH) || defined(QGC_HAS_GST_D3D12_GPU_PATH)) + // Windows: format_capsfilter -> qgcqvideosink (2 children). D3D memory must be offered before + // GLMemory when both are compiled, because Qt Quick's Windows QRhi is D3D-backed. + QCOMPARE(elementCount, 2); +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + QVERIFY2(s.contains(QStringLiteral("memory:D3D11Memory")), + qUtf8Printable(QStringLiteral("GPU bin caps missing memory:D3D11Memory: ") + s)); +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + QVERIFY2(s.contains(QStringLiteral("memory:D3D12Memory")), + qUtf8Printable(QStringLiteral("GPU bin caps missing memory:D3D12Memory: ") + s)); +#endif +#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH) + const qsizetype glIndex = s.indexOf(QStringLiteral("memory:GLMemory")); + if (glIndex >= 0) { +#if defined(QGC_HAS_GST_D3D11_GPU_PATH) + const qsizetype d3d11Index = s.indexOf(QStringLiteral("memory:D3D11Memory")); + QVERIFY2(d3d11Index >= 0 && d3d11Index < glIndex, + qUtf8Printable(QStringLiteral("D3D11Memory must be advertised before GLMemory: ") + s)); +#endif +#if defined(QGC_HAS_GST_D3D12_GPU_PATH) + const qsizetype d3d12Index = s.indexOf(QStringLiteral("memory:D3D12Memory")); + QVERIFY2(d3d12Index >= 0 && d3d12Index < glIndex, + qUtf8Printable(QStringLiteral("D3D12Memory must be advertised before GLMemory: ") + s)); +#endif + } +#endif +#else + // Direct DMABuf: format_capsfilter → qgcqvideosink (2 children). + QCOMPARE(elementCount, 2); + QVERIFY2(s.contains(QStringLiteral("memory:DMABuf")), + qUtf8Printable(QStringLiteral("GPU bin caps missing memory:DMABuf: ") + s)); +#endif + + gst_object_unref(qvideosink); + gst_object_unref(bin); + } +#endif + + gst_object_unref(factory); +} + +void GStreamerTest::_testQgcVideoSinkBinRejectsFailedAdopt() +{ +#ifdef QGC_GST_BUILD_TESTING + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "GStreamer::completeInit() failed"); + QVERIFY2(gst_qgc_video_sink_bin_rejects_failed_adopt_for_test(), + "BinChain::adopt must reject elements that gst_bin_add() did not accept"); +#else + QSKIP("GStreamer test helpers are unavailable"); +#endif +} + +namespace { + +struct PipelineRunResult +{ + int frameCount = 0; + QSize lastFrameSize; + QVideoFrameFormat::PixelFormat lastPixelFormat = QVideoFrameFormat::Format_Invalid; + bool eos = false; + QString errorMessage; +}; + +PipelineRunResult runPipelineThroughQVideoSink(QVideoSink& videoSink, QGCQVideoSinkController*& outController, + const char* capsLine, int numBuffers = 5, bool gpuZerocopy = false) +{ + PipelineRunResult r; + const QString launch = + QStringLiteral("videotestsrc num-buffers=%1 ! %2 ! qgcvideosinkbin name=sink gpu-zerocopy=%3") + .arg(numBuffers) + .arg(QString::fromUtf8(capsLine)) + .arg(gpuZerocopy ? "true" : "false"); + GError* err = nullptr; + GstElement* pipeline = gst_parse_launch(launch.toUtf8().constData(), &err); + if (err) { + r.errorMessage = QString::fromUtf8(err->message); + g_clear_error(&err); + return r; + } + if (!pipeline) { + r.errorMessage = QStringLiteral("Pipeline construction failed"); + return r; + } + GstElement* sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + const std::unique_ptr owner = std::make_unique(); + if (!sinkBin || !GStreamer::setupQVideoSinkElement(sinkBin, &videoSink, owner.get())) { + r.errorMessage = QStringLiteral("setupQVideoSinkElement() failed"); + if (sinkBin) + gst_object_unref(sinkBin); + gst_object_unref(pipeline); + return r; + } + outController = owner->findChild(QString(), Qt::FindDirectChildrenOnly); + if (!outController) { + r.errorMessage = QStringLiteral("controller not created by setupQVideoSinkElement()"); + gst_object_unref(sinkBin); + gst_object_unref(pipeline); + return r; + } + QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, owner.get(), [&](const QVideoFrame& f) { + ++r.frameCount; + r.lastFrameSize = f.size(); + r.lastPixelFormat = f.pixelFormat(); + }); + gst_object_unref(sinkBin); + + if (gst_element_set_state(pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { + r.errorMessage = QStringLiteral("set_state(PLAYING) failed"); + outController = nullptr; + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + return r; + } + GstBus* bus = gst_element_get_bus(pipeline); + GstMessage* msg = gst_bus_timed_pop_filtered(bus, 5 * GST_SECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + if (msg) { + if (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_EOS) + r.eos = true; + else { + GError* e = nullptr; + gst_message_parse_error(msg, &e, nullptr); + r.errorMessage = e ? QString::fromUtf8(e->message) : QStringLiteral("unknown error"); + g_clear_error(&e); + } + gst_message_unref(msg); + } else { + r.errorMessage = QStringLiteral("timeout waiting for EOS"); + } + gst_object_unref(bus); + // Drain any queued videoFrameChanged deliveries (bridged onto this thread) before teardown. + // No positive count target here -- frameCount is asserted by the caller via QTRY -- so this + // is a bounded settle drain rather than a condition wait. + { + QElapsedTimer drain; + drain.start(); + while (drain.elapsed() < 50) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + } + } + outController = nullptr; + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + return r; +} + +} // namespace + +void GStreamerTest::_testCapsCacheInvalidation() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + QVideoSink videoSink; + QGCQVideoSinkController* controller = nullptr; + + auto r1 = runPipelineThroughQVideoSink(videoSink, controller, + "video/x-raw,format=I420,width=320,height=240,framerate=30/1 ! " + "videoconvert ! video/x-raw,format=BGRA"); + QVERIFY2(r1.eos, qUtf8Printable(QStringLiteral("Session 1: %1").arg(r1.errorMessage))); + QTRY_VERIFY_WITH_TIMEOUT(r1.frameCount > 0, 2000); + QCOMPARE(r1.lastPixelFormat, QVideoFrameFormat::Format_BGRA8888); + + auto r2 = runPipelineThroughQVideoSink(videoSink, controller, + "video/x-raw,format=I420,width=320,height=240,framerate=30/1 ! " + "videoconvert ! video/x-raw,format=RGBA"); + QVERIFY2(r2.eos, qUtf8Printable(QStringLiteral("Session 2: %1").arg(r2.errorMessage))); + QTRY_VERIFY_WITH_TIMEOUT(r2.frameCount > 0, 2000); + QCOMPARE(r2.lastPixelFormat, QVideoFrameFormat::Format_RGBA8888); +} + +void GStreamerTest::_testGpuZeroCopyFallback() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + const quint64 dmabufBefore = GstHwPathTelemetry::peekMapFailureCount(HwVideoBufferPath::DmaBuf); +#endif + + QVideoSink videoSink; + QGCQVideoSinkController* controller = nullptr; + auto r = runPipelineThroughQVideoSink(videoSink, controller, + "video/x-raw,format=BGRA,width=320,height=240,framerate=30/1", + /*numBuffers*/ 5, /*gpuZerocopy*/ true); + // The gpu-zerocopy bin inserts glupload on GL builds; a headless runner with no EGL/GL context can't start it. + if (!r.eos && (r.errorMessage.contains(QStringLiteral("egl"), Qt::CaseInsensitive) || + r.errorMessage.contains(QStringLiteral("gl context"), Qt::CaseInsensitive))) { + QSKIP(qPrintable( + QStringLiteral("gpu-zerocopy pipeline needs a GL context (headless env): %1").arg(r.errorMessage))); + } + QVERIFY2(r.eos, qUtf8Printable(QStringLiteral("Pipeline: %1").arg(r.errorMessage))); + QTRY_VERIFY_WITH_TIMEOUT(r.frameCount > 0, 2000); + QCOMPARE(r.lastPixelFormat, QVideoFrameFormat::Format_BGRA8888); + +#if defined(QGC_HAS_GST_DMABUF_GPU_PATH) + QCOMPARE(GstHwPathTelemetry::peekMapFailureCount(HwVideoBufferPath::DmaBuf), dmabufBefore); +#endif +} + +void GStreamerTest::_testAppsinkTeardownUnderLoad() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + QVideoSink videoSink; + + { + GError* err = nullptr; + GstElement* pipeline = gst_parse_launch( + "videotestsrc is-live=true ! " + "video/x-raw,format=BGRA,width=160,height=120,framerate=60/1 ! " + "qgcvideosinkbin name=sink", + &err); + if (err) { + g_clear_error(&err); + } + QVERIFY(pipeline); + + GstElement* sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY(sinkBin); + QObject controllerOwner1; + QVERIFY(GStreamer::setupQVideoSinkElement(sinkBin, &videoSink, &controllerOwner1)); + gst_object_unref(sinkBin); + + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QTest::qWait(150); + // controllerOwner1 goes out of scope here — implicit teardown. + + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + } + + QGCQVideoSinkController* controller = nullptr; + auto r = runPipelineThroughQVideoSink(videoSink, controller, + "video/x-raw,format=RGBA,width=160,height=120,framerate=30/1"); + QVERIFY2(r.eos, qUtf8Printable(QStringLiteral("Restart: %1").arg(r.errorMessage))); + QTRY_VERIFY_WITH_TIMEOUT(r.frameCount > 0, 2000); + QCOMPARE(r.lastPixelFormat, QVideoFrameFormat::Format_RGBA8888); +} + +void GStreamerTest::_testFrameCountsTelemetrySignal() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + // Telemetry counters are process-global and leak across tests; drain them so the + // assertions below measure only this pipeline's contribution. + for (auto p : + {HwVideoBufferPath::None, HwVideoBufferPath::DmaBuf, HwVideoBufferPath::GlMemory, HwVideoBufferPath::D3D11, + HwVideoBufferPath::D3D12, HwVideoBufferPath::IOSurface, HwVideoBufferPath::AHardwareBuffer}) { + (void) GstHwPathTelemetry::takeDeliveredCount(p); + (void) GstHwPathTelemetry::takeMapFailureCount(p); + } + + QVideoSink videoSink; + QObject controllerOwner; + + GError* err = nullptr; + GstElement* pipeline = gst_parse_launch( + "videotestsrc is-live=false num-buffers=90 ! " + "video/x-raw,format=BGRA,width=320,height=240,framerate=30/1 ! " + "qgcvideosinkbin name=sink", + &err); + if (err) { + g_clear_error(&err); + } + QVERIFY2(pipeline, "Pipeline construction failed"); + + GstElement* sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY(sinkBin); + QVERIFY(GStreamer::setupQVideoSinkElement(sinkBin, &videoSink, &controllerOwner)); + gst_object_unref(sinkBin); + + auto* controller = controllerOwner.findChild(QString(), Qt::FindDirectChildrenOnly); + QVERIFY(controller); + QSignalSpy spy(controller, &QGCQVideoSinkController::frameCountsChanged); + + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + + QTRY_VERIFY_WITH_TIMEOUT(spy.size() > 0, 5000); + + QVERIFY(GstHwPathTelemetry::peekDeliveredCount(HwVideoBufferPath::None) > 0); + quint64 gpuFallback = 0; + for (auto p : {HwVideoBufferPath::DmaBuf, HwVideoBufferPath::GlMemory, HwVideoBufferPath::D3D11, + HwVideoBufferPath::D3D12, HwVideoBufferPath::IOSurface, HwVideoBufferPath::AHardwareBuffer}) { + gpuFallback += GstHwPathTelemetry::peekMapFailureCount(p); + } + QCOMPARE(gpuFallback, quint64(0)); + + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); +} + +void GStreamerTest::_testInactiveQgcQVideoSinkDropsAndCounts() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + QVideoSink videoSink; + QObject controllerOwner; + int frameCount = 0; + QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &controllerOwner, [&](const QVideoFrame&) { + ++frameCount; + }); + + GError* err = nullptr; + GstElement* pipeline = gst_parse_launch( + "videotestsrc is-live=false num-buffers=12 ! " + "video/x-raw,format=BGRA,width=96,height=64,framerate=30/1 ! " + "qgcvideosinkbin name=sink", + &err); + if (err) { + const QString msg = QString::fromUtf8(err->message); + g_clear_error(&err); + QFAIL(qPrintable(QStringLiteral("Pipeline parse error: %1").arg(msg))); + } + QVERIFY2(pipeline, "Pipeline construction failed"); + const auto pipelineGuard = qScopeGuard([&] { + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + }); + + GstElement* sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY(sinkBin); + QVERIFY(GStreamer::setupQVideoSinkElement(sinkBin, &videoSink, &controllerOwner)); + + GstElement* qvideosink = gst_bin_get_by_name(GST_BIN(sinkBin), "qgcqvideosink"); + gst_object_unref(sinkBin); + QVERIFY(qvideosink); + const auto sinkGuard = qScopeGuard([&] { gst_object_unref(qvideosink); }); + + g_object_set(qvideosink, "active", FALSE, nullptr); + + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + GstBus* bus = gst_element_get_bus(pipeline); + QVERIFY(bus); + GstMessage* msg = gst_bus_timed_pop_filtered(bus, 5 * GST_SECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + gst_object_unref(bus); + QVERIFY2(msg, "Pipeline timed out waiting for EOS or ERROR"); + const auto msgGuard = qScopeGuard([&] { gst_message_unref(msg); }); + + if (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { + GError* gstError = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(msg, &gstError, &debug); + const QString errorMessage = QStringLiteral("%1 (%2)") + .arg(gstError ? QString::fromUtf8(gstError->message) + : QStringLiteral("unknown")) + .arg(debug ? QString::fromUtf8(debug) : QString()); + g_clear_error(&gstError); + g_free(debug); + QFAIL(qPrintable(QStringLiteral("Pipeline error: %1").arg(errorMessage))); + } + QCOMPARE(GST_MESSAGE_TYPE(msg), GST_MESSAGE_EOS); + + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + QCOMPARE(frameCount, 0); + + guint64 input = 0; + guint64 dropped = 0; + guint64 delivered = 0; + g_object_get(qvideosink, "frames-input", &input, "frames-dropped", &dropped, "frames-delivered", &delivered, + nullptr); + QVERIFY(input >= guint64(12)); + QCOMPARE(dropped, input); + QCOMPARE(delivered, guint64(0)); +} + +void GStreamerTest::_testGetAppsinkAccessor() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + GstElement* bin = gst_element_factory_make("qgcvideosinkbin", nullptr); + QVERIFY2(bin, "qgcvideosinkbin factory not found"); + + // Construction is synchronous (GObject::constructed runs inside factory_make). + GstElement* qvideosink = gst_qgc_video_sink_bin_get_qvideosink(GST_QGC_VIDEO_SINK_BIN(bin)); + QVERIFY2(qvideosink, "qvideosink accessor returned NULL after factory_make"); + QVERIFY(GST_IS_ELEMENT(qvideosink)); + gst_object_unref(qvideosink); + + // After transitioning to READY the accessor must still return a valid element. + GstStateChangeReturn ret = gst_element_set_state(bin, GST_STATE_READY); + QVERIFY(ret != GST_STATE_CHANGE_FAILURE); + + GstElement* qvideosink2 = gst_qgc_video_sink_bin_get_qvideosink(GST_QGC_VIDEO_SINK_BIN(bin)); + QVERIFY2(qvideosink2, "qvideosink accessor returned NULL after READY"); + QVERIFY(GST_IS_ELEMENT(qvideosink2)); + gst_object_unref(qvideosink2); + + gst_element_set_state(bin, GST_STATE_NULL); + gst_object_unref(bin); +} + +void GStreamerTest::_testQVideoSinkControllerClearsElementOnDestroy() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + GstElement* bin = gst_element_factory_make("qgcvideosinkbin", nullptr); + QVERIFY2(bin, "qgcvideosinkbin factory not found"); + + GstElement* qvideosink = gst_qgc_video_sink_bin_get_qvideosink(GST_QGC_VIDEO_SINK_BIN(bin)); + QVERIFY2(qvideosink, "qvideosink accessor returned NULL after factory_make"); + + QVideoSink videoSink; + auto controllerOwner = std::make_unique(); + QVERIFY2(GStreamer::setupQVideoSinkElement(bin, &videoSink, controllerOwner.get()), + "setupQVideoSinkElement() failed"); + + gpointer installedSink = nullptr; + gboolean active = FALSE; + g_object_get(qvideosink, "qvideosink", &installedSink, "active", &active, nullptr); + QCOMPARE(installedSink, static_cast(&videoSink)); + QCOMPARE(active, TRUE); + + controllerOwner.reset(); + + installedSink = &videoSink; + active = TRUE; + g_object_get(qvideosink, "qvideosink", &installedSink, "active", &active, nullptr); + QCOMPARE(installedSink, static_cast(nullptr)); + QCOMPARE(active, FALSE); + + gst_object_unref(qvideosink); + gst_object_unref(bin); +} + +void GStreamerTest::_testQVideoSinkControllerClearsElementWhenVideoSinkDestroyed() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + GstElement* bin = gst_element_factory_make("qgcvideosinkbin", nullptr); + QVERIFY2(bin, "qgcvideosinkbin factory not found"); + + GstElement* qvideosink = gst_qgc_video_sink_bin_get_qvideosink(GST_QGC_VIDEO_SINK_BIN(bin)); + QVERIFY2(qvideosink, "qvideosink accessor returned NULL after factory_make"); + + auto videoSink = std::make_unique(); + QObject controllerOwner; + QVERIFY2(GStreamer::setupQVideoSinkElement(bin, videoSink.get(), &controllerOwner), + "setupQVideoSinkElement() failed"); + + gpointer installedSink = nullptr; + gboolean active = FALSE; + g_object_get(qvideosink, "qvideosink", &installedSink, "active", &active, nullptr); + QCOMPARE(installedSink, static_cast(videoSink.get())); + QCOMPARE(active, TRUE); + + videoSink.reset(); + QCoreApplication::sendPostedEvents(&controllerOwner, QEvent::MetaCall); + + installedSink = reinterpret_cast(quintptr(0x1)); + active = FALSE; + g_object_get(qvideosink, "qvideosink", &installedSink, "active", &active, nullptr); + QCOMPARE(installedSink, static_cast(nullptr)); + QCOMPARE(active, FALSE); + + gst_object_unref(qvideosink); + gst_object_unref(bin); +} + +void GStreamerTest::_testQVideoSinkControllerNullSinkStillDeactivatesOnDestroy() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + GstElement* bin = gst_element_factory_make("qgcvideosinkbin", nullptr); + QVERIFY2(bin, "qgcvideosinkbin factory not found"); + + GstElement* qvideosink = gst_qgc_video_sink_bin_get_qvideosink(GST_QGC_VIDEO_SINK_BIN(bin)); + QVERIFY2(qvideosink, "qvideosink accessor returned NULL after factory_make"); + + QVideoSink videoSink; + auto controllerOwner = std::make_unique(); + QVERIFY2(GStreamer::setupQVideoSinkElement(bin, &videoSink, controllerOwner.get()), + "setupQVideoSinkElement() failed"); + + auto* controller = controllerOwner->findChild(QString(), Qt::FindDirectChildrenOnly); + QVERIFY(controller); + + controller->setVideoSink(QPointer()); + + gpointer installedSink = &videoSink; + gboolean active = FALSE; + g_object_get(qvideosink, "qvideosink", &installedSink, "active", &active, nullptr); + QCOMPARE(installedSink, static_cast(nullptr)); + QCOMPARE(active, FALSE); + + controllerOwner.reset(); + + active = FALSE; + g_object_get(qvideosink, "active", &active, nullptr); + QCOMPARE(active, FALSE); + + gst_object_unref(qvideosink); + gst_object_unref(bin); +} + +void GStreamerTest::_testQVideoSinkControllerRepeatedSetupKeepsNewBindingActive() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + GstElement* bin = gst_element_factory_make("qgcvideosinkbin", nullptr); + QVERIFY2(bin, "qgcvideosinkbin factory not found"); + + GstElement* qvideosink = gst_qgc_video_sink_bin_get_qvideosink(GST_QGC_VIDEO_SINK_BIN(bin)); + QVERIFY2(qvideosink, "qvideosink accessor returned NULL after factory_make"); + + auto firstSink = std::make_unique(); + auto secondSink = std::make_unique(); + QObject controllerOwner; + + QVERIFY2(GStreamer::setupQVideoSinkElement(bin, firstSink.get(), &controllerOwner), + "initial setupQVideoSinkElement() failed"); + auto* firstController = + controllerOwner.findChild(QString(), Qt::FindDirectChildrenOnly); + QVERIFY(firstController); + + QVERIFY2(GStreamer::setupQVideoSinkElement(bin, secondSink.get(), &controllerOwner), + "repeated setupQVideoSinkElement() failed"); + + firstController->setActive(false); + + firstSink.reset(); + QCoreApplication::sendPostedEvents(&controllerOwner, QEvent::MetaCall); + + gpointer installedSink = nullptr; + gboolean active = FALSE; + g_object_get(qvideosink, "qvideosink", &installedSink, "active", &active, nullptr); + QCOMPARE(installedSink, static_cast(secondSink.get())); + QCOMPARE(active, TRUE); + + QCoreApplication::sendPostedEvents(&controllerOwner, QEvent::DeferredDelete); + + installedSink = nullptr; + active = FALSE; + g_object_get(qvideosink, "qvideosink", &installedSink, "active", &active, nullptr); + QCOMPARE(installedSink, static_cast(secondSink.get())); + QCOMPARE(active, TRUE); + + gst_object_unref(qvideosink); + gst_object_unref(bin); +} + +void GStreamerTest::_testQVideoSinkControllerNoWindowStartsInactive() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + GstElement* bin = gst_element_factory_make("qgcvideosinkbin", nullptr); + QVERIFY2(bin, "qgcvideosinkbin factory not found"); + + GstElement* qvideosink = gst_qgc_video_sink_bin_get_qvideosink(GST_QGC_VIDEO_SINK_BIN(bin)); + QVERIFY2(qvideosink, "qvideosink accessor returned NULL after factory_make"); + + QObject controllerOwner; + QGCQVideoSinkController controller(qvideosink, &controllerOwner); + controller.setActive(true); + + QQuickVideoOutput videoOutput; + QVERIFY(videoOutput.window() == nullptr); + QGCQVideoSinkController::syncActiveToWindowVisibility(&controllerOwner, &videoOutput); + + gboolean active = TRUE; + g_object_get(qvideosink, "active", &active, nullptr); + QCOMPARE(active, FALSE); + + gst_object_unref(qvideosink); + gst_object_unref(bin); +} + +void GStreamerTest::_testCpuZeroCopyFrameRejectsWritableMap() +{ + GstVideoInfo info; + gst_video_info_init(&info); + gst_video_info_set_format(&info, GST_VIDEO_FORMAT_BGRA, 4, 4); + + GstBuffer* buffer = gst_buffer_new_allocate(nullptr, GST_VIDEO_INFO_SIZE(&info), nullptr); + QVERIFY(buffer); + auto bufferGuard = qScopeGuard([&] { gst_buffer_unref(buffer); }); + + GstMapInfo mapInfo; + QVERIFY(gst_buffer_map(buffer, &mapInfo, GST_MAP_WRITE)); + std::memset(mapInfo.data, 0x7f, mapInfo.size); + gst_buffer_unmap(buffer, &mapInfo); + + QVideoFrameFormat format(QSize(4, 4), QVideoFrameFormat::Format_BGRA8888); + auto wrapped = CpuVideoFramePool::wrapZeroCopy(buffer, info, format); + QVERIFY(wrapped); + + QVideoFrame frame(std::move(wrapped)); + QVERIFY(frame.isValid()); + + QVERIFY(frame.map(QVideoFrame::ReadOnly)); + frame.unmap(); + + QVERIFY(!frame.map(QVideoFrame::WriteOnly)); + QVERIFY(!frame.map(QVideoFrame::ReadWrite)); +} + +void GStreamerTest::_testCpuMemcpyActiveRowStrideHandling() +{ + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + // Width 753 is not 4-byte aligned; GStreamer will pad stride to 756. + // If the memcpy copies stride bytes instead of active-row bytes, the + // right-edge pixels of the right-most component will contain padding zeros. + QVideoSink videoSink; + QGCQVideoSinkController* controller = nullptr; + QObject controllerOwner; + + QVideoFrame capturedFrame; + QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &controllerOwner, + [&](const QVideoFrame& f) { capturedFrame = f; }); + + auto r = runPipelineThroughQVideoSink(videoSink, controller, + "video/x-raw,format=BGRA,width=753,height=432,framerate=30/1", + /*numBuffers=*/5); + QVERIFY2(r.eos, qUtf8Printable(QStringLiteral("Pipeline: %1").arg(r.errorMessage))); + QTRY_VERIFY_WITH_TIMEOUT(capturedFrame.isValid(), 2000); + + QVERIFY(capturedFrame.map(QVideoFrame::ReadOnly)); + const uchar* data = capturedFrame.bits(0); + const int stride = capturedFrame.bytesPerLine(0); + // Sample the last active pixel column (x=752) from row 0; BGRA so 4 bytes/pixel. + const uchar* lastPixel = data + 752 * 4; + // videotestsrc pattern=0 (smpte) fills with non-zero color in top rows. + const bool nonZero = (lastPixel[0] | lastPixel[1] | lastPixel[2] | lastPixel[3]) != 0; + capturedFrame.unmap(); + + (void) stride; + QVERIFY2(nonZero, "Last active column is all zeros — likely stride vs active-row memcpy bug"); +} + +void GStreamerTest::_testColorimetryPixelFormatMapping() +{ + // Every format advertised in qgcvideosinkbin caps must round-trip through toQtPixelFormat + // to a non-Invalid Qt format. Format_Invalid here means onNewSample() returns + // GST_FLOW_ERROR for any frame Qt can negotiate — total frame-delivery loss. + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_NV12), QVideoFrameFormat::Format_NV12); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_NV21), QVideoFrameFormat::Format_NV21); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_I420), QVideoFrameFormat::Format_YUV420P); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_YV12), QVideoFrameFormat::Format_YV12); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_Y42B), QVideoFrameFormat::Format_YUV422P); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_P010_10LE), QVideoFrameFormat::Format_P010); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_AYUV), QVideoFrameFormat::Format_AYUV); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_YUY2), QVideoFrameFormat::Format_YUYV); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_UYVY), QVideoFrameFormat::Format_UYVY); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_GRAY8), QVideoFrameFormat::Format_Y8); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_GRAY16_LE), QVideoFrameFormat::Format_Y16); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_BGRA), QVideoFrameFormat::Format_BGRA8888); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_RGBA), QVideoFrameFormat::Format_RGBA8888); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_I420_10LE), QVideoFrameFormat::Format_YUV420P10); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_P016_LE), QVideoFrameFormat::Format_P016); + // Y444 is intentionally NOT in caps — Qt 6.10 has no Format_YUV444*, so any negotiation + // would dead-end at GST_FLOW_ERROR. Re-enable when Qt grows the enum. + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_Y444), QVideoFrameFormat::Format_Invalid); +} + +void GStreamerTest::_testCpuCapsFormatsRoundTripToQt() +{ + // Single-source-of-truth guard: every format buildCpuCapsString() advertises (shared with + // buildGpuCapsString via kFormats) must map to a non-Invalid Qt format. A format added to the + // caps that Qt can't render dead-ends negotiation at GST_FLOW_ERROR — fail in CI, not in the field. + GstCaps* caps = gst_caps_from_string(GstQgc::buildCpuCapsString().c_str()); + QVERIFY2(caps, "buildCpuCapsString() did not parse"); + const GstStructure* s = gst_caps_get_structure(caps, 0); + const GValue* fmt = gst_structure_get_value(s, "format"); + QVERIFY2(fmt && GST_VALUE_HOLDS_LIST(fmt), "CPU caps format field is not a list"); + const guint n = gst_value_list_get_size(fmt); + QVERIFY2(n > 0, "CPU caps format list empty"); + for (guint i = 0; i < n; ++i) { + const char* name = g_value_get_string(gst_value_list_get_value(fmt, i)); + const GstVideoFormat gf = gst_video_format_from_string(name); + QVERIFY2(gf != GST_VIDEO_FORMAT_UNKNOWN, + qPrintable(QStringLiteral("caps lists unknown gst format '%1'").arg(name))); + QVERIFY2(toQtPixelFormat(gf) != QVideoFrameFormat::Format_Invalid, + qPrintable(QStringLiteral("format '%1' advertised but not Qt-renderable").arg(name))); + } + gst_caps_unref(caps); +} + +void GStreamerTest::_testAllocationQueryHwMemoryPoolHint() +{ + // HW-memory ALLOCATION: populateAllocationQuery (the qgcqvideosink propose_allocation path) must + // advertise a pool-less min-buffer hint plus the consumed metas. Native producers such as gst-va own the + // platform allocator/pool; QGC only needs VideoMeta visible before they decide allocation. + GstCaps* caps = gst_caps_from_string( + "video/x-raw(memory:DMABuf), format=(string)NV12, width=(int)64, height=(int)64, framerate=(fraction)30/1"); + QVERIFY2(caps, "HW caps did not parse"); + + GstQuery* query = gst_query_new_allocation(caps, TRUE); + GstQgc::populateAllocationQuery(query); + + QCOMPARE(gst_query_get_n_allocation_pools(query), 1U); + GstBufferPool* pool = nullptr; + guint size = 0, minBuffers = 0, maxBuffers = 0; + gst_query_parse_nth_allocation_pool(query, 0, &pool, &size, &minBuffers, &maxBuffers); + QVERIFY2(pool == nullptr, "HW path must not propose a generic pool for native memory"); + QVERIFY2(minBuffers > 0, "min-buffer hint must be non-zero"); + QVERIFY2(gst_query_find_allocation_meta(query, GST_VIDEO_META_API_TYPE, nullptr), + "VideoMeta not advertised on HW path"); + + gst_query_unref(query); + gst_caps_unref(caps); +} + +void GStreamerTest::_testQgcVideoSinkBinAllocationQueryAdvertisesVideoMeta() +{ + QVERIFY2(GStreamer::completeInit(), "GStreamer::completeInit() failed"); + + GstElement* bin = gst_element_factory_make_full("qgcvideosinkbin", "gpu-zerocopy", TRUE, nullptr); + QVERIFY2(bin, "qgcvideosinkbin factory did not create an element"); + auto binGuard = qScopeGuard([&] { gst_object_unref(bin); }); + + GstPad* sinkPad = gst_element_get_static_pad(bin, "sink"); + QVERIFY2(sinkPad, "qgcvideosinkbin has no sink pad"); + auto sinkPadGuard = qScopeGuard([&] { gst_object_unref(sinkPad); }); + + GstCaps* caps = gst_caps_from_string( + "video/x-raw(memory:DMABuf), format=(string)NV12, width=(int)64, height=(int)64, framerate=(fraction)30/1"); + QVERIFY2(caps, "HW caps did not parse"); + auto capsGuard = qScopeGuard([&] { gst_caps_unref(caps); }); + + GstQuery* query = gst_query_new_allocation(caps, TRUE); + QVERIFY2(query, "Could not allocate ALLOCATION query"); + auto queryGuard = qScopeGuard([&] { gst_query_unref(query); }); + + QVERIFY2(gst_pad_query(sinkPad, query), "qgcvideosinkbin did not answer ALLOCATION query"); + QVERIFY2(gst_query_find_allocation_meta(query, GST_VIDEO_META_API_TYPE, nullptr), + "qgcvideosinkbin ALLOCATION query must advertise VideoMeta for gst-va DMABuf"); + + QCOMPARE(gst_query_get_n_allocation_pools(query), 1U); + GstBufferPool* pool = nullptr; + guint size = 0, minBuffers = 0, maxBuffers = 0; + gst_query_parse_nth_allocation_pool(query, 0, &pool, &size, &minBuffers, &maxBuffers); + QVERIFY2(pool == nullptr, "qgcvideosinkbin must not propose a generic pool for native memory"); + QVERIFY2(minBuffers > 0, "qgcvideosinkbin min-buffer hint must be non-zero"); +} + +void GStreamerTest::_testAllocationQuerySystemMemoryNoPoolStillAdvertisesMetas() +{ + // When upstream does not need a pool, qgcqvideosink must not force one. It still has to advertise consumed metas + // so crop/orientation/video metadata survive into GStreamerFrameMap. + GstCaps* caps = gst_caps_from_string( + "video/x-raw, format=(string)BGRA, width=(int)64, height=(int)64, framerate=(fraction)30/1"); + QVERIFY2(caps, "System-memory caps did not parse"); + + GstQuery* query = gst_query_new_allocation(caps, FALSE); + GstQgc::populateAllocationQuery(query); + + QCOMPARE(gst_query_get_n_allocation_pools(query), 0U); + QVERIFY2(gst_query_find_allocation_meta(query, GST_VIDEO_META_API_TYPE, nullptr), + "VideoMeta not advertised for system-memory caps"); + QVERIFY2(gst_query_find_allocation_meta(query, GST_VIDEO_CROP_META_API_TYPE, nullptr), + "VideoCropMeta not advertised for system-memory caps"); + + gst_query_unref(query); + gst_caps_unref(caps); +} + +void GStreamerTest::_testColorimetryColorSpaceMapping() +{ + // Mirrors Qt 6.10.3 qgst.cpp: SMPTE240M maps to AdobeRgb, FCC remains Undefined. + QCOMPARE(toQtColorSpace(GST_VIDEO_COLOR_MATRIX_SMPTE240M), QVideoFrameFormat::ColorSpace_AdobeRgb); + QCOMPARE(toQtColorSpace(GST_VIDEO_COLOR_MATRIX_FCC), QVideoFrameFormat::ColorSpace_Undefined); +} + +void GStreamerTest::_testColorimetryResolutionHeuristicMatchesQt() +{ + const auto inferredColorSpace = [](int height) { + GstVideoInfo info; + gst_video_info_init(&info); + gst_video_info_set_format(&info, GST_VIDEO_FORMAT_I420, 1280, height); + info.colorimetry.matrix = GST_VIDEO_COLOR_MATRIX_UNKNOWN; + info.colorimetry.range = GST_VIDEO_COLOR_RANGE_UNKNOWN; + info.colorimetry.transfer = GST_VIDEO_TRANSFER_UNKNOWN; + + QVideoFrameFormat format(QSize(1280, height), QVideoFrameFormat::Format_YUV420P); + applyColorimetry(format, info, nullptr); + return format.colorSpace(); + }; + + QCOMPARE(inferredColorSpace(576), QVideoFrameFormat::ColorSpace_BT601); + QCOMPARE(inferredColorSpace(720), QVideoFrameFormat::ColorSpace_BT709); +} + +void GStreamerTest::_testColorimetryTransferMapping() +{ + QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_SRGB), QVideoFrameFormat::ColorTransfer_Gamma22); + // Regression: BT601 used to fall through to BT709 — Qt's own backend maps it distinctly. + QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_BT601), QVideoFrameFormat::ColorTransfer_BT601); + QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_BT709), QVideoFrameFormat::ColorTransfer_BT709); + QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_BT2020_10), QVideoFrameFormat::ColorTransfer_BT709); + QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_SMPTE2084), QVideoFrameFormat::ColorTransfer_ST2084); + QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_ARIB_STD_B67), QVideoFrameFormat::ColorTransfer_STD_B67); + QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_GAMMA10), QVideoFrameFormat::ColorTransfer_Linear); + QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_GAMMA28), QVideoFrameFormat::ColorTransfer_Gamma28); + QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_SMPTE240M), QVideoFrameFormat::ColorTransfer_Gamma22); + QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_ADOBERGB), QVideoFrameFormat::ColorTransfer_Gamma22); + QCOMPARE(toQtColorTransfer(GST_VIDEO_TRANSFER_LOG100), QVideoFrameFormat::ColorTransfer_Unknown); +} + +void GStreamerTest::_testColorimetryColorRangeMapping() +{ + QCOMPARE(toQtColorRange(GST_VIDEO_COLOR_RANGE_0_255), QVideoFrameFormat::ColorRange_Full); + QCOMPARE(toQtColorRange(GST_VIDEO_COLOR_RANGE_16_235), QVideoFrameFormat::ColorRange_Video); + QCOMPARE(toQtColorRange(GST_VIDEO_COLOR_RANGE_UNKNOWN), QVideoFrameFormat::ColorRange_Unknown); +} + +void GStreamerTest::_testPixelFormatAcceptedButNotAdvertised() +{ + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_BGRx), QVideoFrameFormat::Format_BGRX8888); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_RGBx), QVideoFrameFormat::Format_RGBX8888); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_ARGB), QVideoFrameFormat::Format_ARGB8888); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_xRGB), QVideoFrameFormat::Format_XRGB8888); + + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_BGR), QVideoFrameFormat::Format_Invalid); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_RGB), QVideoFrameFormat::Format_Invalid); + QCOMPARE(toQtPixelFormat(GST_VIDEO_FORMAT_UNKNOWN), QVideoFrameFormat::Format_Invalid); +} + +void GStreamerTest::_testAdvertisedFormatListMatchesTable() +{ + const std::string list = GstQgc::advertisedFormatList(); + for (const auto& e : GstQgc::kVideoFormatTable) { + if (e.capsToken) { + QVERIFY2( + list.find(e.capsToken) != std::string::npos, + qPrintable( + QStringLiteral("advertised token '%1' missing from list").arg(QString::fromUtf8(e.capsToken)))); + } + } + QVERIFY2(list.find("BGRx") == std::string::npos, "accepted-only BGRx must not be advertised"); + QVERIFY2(list.find("I420_10LE") == std::string::npos, "accepted-only I420_10LE must not be advertised"); + QVERIFY2(list.rfind("{ ", 0) == 0 && list.find(" }") != std::string::npos, "list must be brace-wrapped"); +} + +void GStreamerTest::_testColorimetryFrameRatePropagation() +{ + // Verify that a 30/1 caps framerate is surfaced on the delivered QVideoFrame. + GstElementFactory* guardFactory = gst_element_factory_find("qgcvideosinkbin"); + if (!guardFactory) { + GStreamer::completeInit(); + } else { + gst_object_unref(guardFactory); + } + + GError* error = nullptr; + GstElement* pipeline = gst_parse_launch( + "videotestsrc num-buffers=3 ! " + "video/x-raw,format=I420,width=64,height=48,framerate=30/1 ! " + "qgcvideosinkbin name=sink", + &error); + if (error) { + const QString msg = QString::fromUtf8(error->message); + g_clear_error(&error); + QFAIL(qPrintable(QStringLiteral("Pipeline parse error: %1").arg(msg))); + } + QVERIFY2(pipeline, "Failed to create framerate pipeline"); + + GstElement* sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY2(sinkBin, "Could not find 'sink' element"); + + QVideoSink videoSink; + QObject controllerOwner; + + QVideoFrameFormat lastFormat; + QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &controllerOwner, + [&](const QVideoFrame& frame) { lastFormat = frame.surfaceFormat(); }); + + QVERIFY2(GStreamer::setupQVideoSinkElement(sinkBin, &videoSink, &controllerOwner), + "GStreamer::setupQVideoSinkElement() failed"); + gst_object_unref(sinkBin); + + QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, "Pipeline failed to PLAY"); + + GstBus* bus = gst_element_get_bus(pipeline); + GstMessage* msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + QVERIFY2(msg, "Pipeline timed out"); + const bool isError = (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR); + gst_message_unref(msg); + gst_object_unref(bus); + if (isError) { + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + QFAIL("Framerate pipeline errored"); + } + + QTRY_VERIFY_WITH_TIMEOUT(lastFormat.isValid(), TestTimeout::mediumMs()); + QCOMPARE(lastFormat.streamFrameRate(), 30.0); + + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); +} + +void GStreamerTest::_testApplyOrientationToFrameMapping() +{ +#ifndef QGC_HAS_GST_VIDEO_ORIENTATION_META + QSKIP("GStreamer build lacks GstVideoOrientationMeta"); +#else + // Verifies each GStreamer orientation enum maps to the correct (rotation, mirrored) tuple + // on QVideoFrame. Lock-down test: prior versions had subtle mismatches between gst's + // diagonal-flip semantics and Qt's rotate-then-mirror composition. + struct Case + { + GstVideoOrientationMethod gst; + QtVideo::Rotation expectedRot; + bool expectedMirrored; + }; + + const Case cases[] = { + {GST_VIDEO_ORIENTATION_IDENTITY, QtVideo::Rotation::None, false}, + {GST_VIDEO_ORIENTATION_90R, QtVideo::Rotation::Clockwise90, false}, + {GST_VIDEO_ORIENTATION_180, QtVideo::Rotation::Clockwise180, false}, + {GST_VIDEO_ORIENTATION_90L, QtVideo::Rotation::Clockwise270, false}, + {GST_VIDEO_ORIENTATION_HORIZ, QtVideo::Rotation::None, true}, + {GST_VIDEO_ORIENTATION_VERT, QtVideo::Rotation::Clockwise180, true}, + {GST_VIDEO_ORIENTATION_UL_LR, QtVideo::Rotation::Clockwise90, true}, + {GST_VIDEO_ORIENTATION_UR_LL, QtVideo::Rotation::Clockwise270, true}, + }; + for (const Case& c : cases) { + QVideoFrame frame = QVideoFrame(QVideoFrameFormat(QSize(2, 2), QVideoFrameFormat::Format_BGRA8888)); + // Pre-poison so a no-op switch case (default branch) wouldn't accidentally match. + frame.setRotation(QtVideo::Rotation::Clockwise90); + frame.setMirrored(true); + applyOrientationToFrame(frame, c.gst); + QCOMPARE(frame.rotation(), c.expectedRot); + QCOMPARE(frame.mirrored(), c.expectedMirrored); + } +#endif +} + +void GStreamerTest::_testAdapterFlushDropsInFlightSamples() +{ + // Push GST_EVENT_FLUSH_START upstream of the adapter; verify subsequent buffers don't + // surface as QVideoFrames (the new_sample callback short-circuits on _flushing). Then + // push FLUSH_STOP and verify delivery resumes. + GStreamer::redirectGLibLogging(); + QVERIFY2(GStreamer::completeInit(), "completeInit failed"); + + QVideoSink sink; + QObject controllerOwner; + + std::atomic deliveredFrames{0}; + QObject::connect(&sink, &QVideoSink::videoFrameChanged, &controllerOwner, + [&](const QVideoFrame&) { deliveredFrames.fetch_add(1, std::memory_order_relaxed); }); + + GError* err = nullptr; + // identity is_live=false to avoid the live-source latency offset; videotestsrc → identity → qgcvideosinkbin. + GstElement* pipeline = gst_parse_launch( + "videotestsrc num-buffers=10 ! " + "video/x-raw,format=BGRA,width=64,height=48,framerate=30/1 ! " + "identity name=id ! " + "qgcvideosinkbin name=sink", + &err); + if (err) { + g_clear_error(&err); + } + QVERIFY2(pipeline, "Pipeline construction failed"); + GstElement* sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY(sinkBin); + QVERIFY2(GStreamer::setupQVideoSinkElement(sinkBin, &sink, &controllerOwner), + "GStreamer::setupQVideoSinkElement() failed"); + gst_object_unref(sinkBin); + + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + // Wait for a few frames to confirm baseline delivery works. + QTRY_VERIFY_WITH_TIMEOUT(deliveredFrames.load() > 0, 3000); + + // Send FLUSH_START upstream of qgcqvideosink. identity element forwards events. + GstElement* id = gst_bin_get_by_name(GST_BIN(pipeline), "id"); + QVERIFY(id); + GstPad* idSrc = gst_element_get_static_pad(id, "src"); + QVERIFY(idSrc); + GstPad* flushStartPeer = gst_pad_get_peer(idSrc); + QVERIFY(flushStartPeer); + const gboolean flushStartSent = gst_pad_send_event(flushStartPeer, gst_event_new_flush_start()); + gst_object_unref(flushStartPeer); + QVERIFY(flushStartSent); + + GstElement* vsink = gst_bin_get_by_name(GST_BIN(pipeline), "qgcqvideosink"); + QVERIFY(vsink); + + // Snapshot the in-flight accounting just before the flush window. frames-input counts + // every buffer the sink's show_frame saw; frames-delivered counts those queued to the + // QVideoSink. The gap (input - delivered - dropped) is the set of buffers currently in + // flight inside the base sink that a flush must discard. + const int duringFlushBaseline = deliveredFrames.load(std::memory_order_relaxed); + guint64 inputBefore = 0, deliveredBefore = 0, droppedBefore = 0; + g_object_get(vsink, "frames-input", &inputBefore, "frames-delivered", &deliveredBefore, "frames-dropped", + &droppedBefore, nullptr); + + // During FLUSH_START the base sink rejects buffers before show_frame, so no buffer may + // surface as a QVideoFrame and no new buffer may be counted as input. Negative assertion: + // hold a bounded window open and confirm the delivered count stays flat while pumping + // queued deliveries. + { + QElapsedTimer flushWindow; + flushWindow.start(); + while (flushWindow.elapsed() < 150) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + } + } + QCOMPARE(deliveredFrames.load(std::memory_order_relaxed), duringFlushBaseline); + + guint64 inputDuring = 0, deliveredDuring = 0; + g_object_get(vsink, "frames-input", &inputDuring, "frames-delivered", &deliveredDuring, nullptr); + QCOMPARE(deliveredDuring, deliveredBefore); + QCOMPARE(inputDuring, inputBefore); + + // Send FLUSH_STOP — restore normal flow. videotestsrc may have emitted EOS by now, + // so we don't strictly require new frames, just that the in-flight set was discarded. + GstPad* flushStopPeer = gst_pad_get_peer(idSrc); + QVERIFY(flushStopPeer); + const gboolean flushStopSent = gst_pad_send_event(flushStopPeer, gst_event_new_flush_stop(/*reset_time=*/TRUE)); + gst_object_unref(flushStopPeer); + QVERIFY(flushStopSent); + + // Drain the Qt event loop so any push_frame_queued lambdas queued before the flush run. + QTest::qWait(100); + + // The flush must leave no buffer stuck in flight: every buffer the sink accepted is now + // accounted for as either delivered or dropped. A leaked in-flight buffer would make + // input strictly exceed delivered+dropped. + guint64 inputAfter = 0, deliveredAfter = 0, droppedAfter = 0; + g_object_get(vsink, "frames-input", &inputAfter, "frames-delivered", &deliveredAfter, "frames-dropped", + &droppedAfter, nullptr); + QCOMPARE(inputAfter, deliveredAfter + droppedAfter); + gst_object_unref(vsink); + + gst_object_unref(idSrc); + gst_object_unref(id); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); +} + +#endif diff --git a/test/VideoManager/VideoManagerInitTest.cc b/test/VideoManager/VideoManagerInitTest.cc index c2c65bcfc61d..edff6b6e147f 100644 --- a/test/VideoManager/VideoManagerInitTest.cc +++ b/test/VideoManager/VideoManagerInitTest.cc @@ -21,7 +21,7 @@ void VideoManagerInitTest::init() ignoreLogMessage("Video.GStreamer.GStreamerLogging", QtCriticalMsg, sGStreamerCriticalRe); } -void VideoManagerInitTest::_testQmlReadyBeforeGstReady() +void VideoManagerInitTest::_testQmlReadyBeforeBackendReady() { VideoManager videoManager; QQuickWindow mainWindow; @@ -38,17 +38,17 @@ void VideoManagerInitTest::_testQmlReadyBeforeGstReady() QCOMPARE(videoManager._initState, VideoManager::InitState::QmlReady); QCOMPARE(createReceiversCount, 0); - videoManager._onGstInitComplete(true); + videoManager._onBackendInitComplete(true); QCOMPARE(videoManager._initState, VideoManager::InitState::Running); QCOMPARE(createReceiversCount, 1); - expectLogMessage("Video.VideoManager", QtWarningMsg, QRegularExpression(QStringLiteral("_onGstInitComplete: unexpected state"))); - videoManager._onGstInitComplete(true); + expectLogMessage("Video.VideoManager", QtWarningMsg, QRegularExpression(QStringLiteral("_onBackendInitComplete: unexpected state"))); + videoManager._onBackendInitComplete(true); verifyExpectedLogMessage(); QCOMPARE(createReceiversCount, 1); } -void VideoManagerInitTest::_testGstReadyBeforeQmlReady() +void VideoManagerInitTest::_testBackendReadyBeforeQmlReady() { VideoManager videoManager; QQuickWindow mainWindow; @@ -61,8 +61,8 @@ void VideoManagerInitTest::_testGstReadyBeforeQmlReady() videoManager._initState = VideoManager::InitState::Pending; - videoManager._onGstInitComplete(true); - QCOMPARE(videoManager._initState, VideoManager::InitState::GstReady); + videoManager._onBackendInitComplete(true); + QCOMPARE(videoManager._initState, VideoManager::InitState::BackendReady); QCOMPARE(createReceiversCount, 0); videoManager._initAfterQmlIsReady(); @@ -75,7 +75,7 @@ void VideoManagerInitTest::_testGstReadyBeforeQmlReady() QCOMPARE(createReceiversCount, 1); } -void VideoManagerInitTest::_testGstInitFailure() +void VideoManagerInitTest::_testBackendInitFailure() { VideoManager videoManager; QQuickWindow mainWindow; @@ -88,13 +88,13 @@ void VideoManagerInitTest::_testGstInitFailure() videoManager._initState = VideoManager::InitState::Pending; - expectLogMessage("Video.VideoManager", QtCriticalMsg, QRegularExpression(QStringLiteral("GStreamer initialization failed"))); - videoManager._onGstInitComplete(false); + expectLogMessage("Video.VideoManager", QtCriticalMsg, QRegularExpression(QStringLiteral("video initialization failed"))); + videoManager._onBackendInitComplete(false); verifyExpectedLogMessage(); QCOMPARE(videoManager._initState, VideoManager::InitState::Failed); QCOMPARE(createReceiversCount, 0); - expectLogMessage("Video.VideoManager", QtWarningMsg, QRegularExpression(QStringLiteral("QML ready but GStreamer init failed"))); + expectLogMessage("Video.VideoManager", QtWarningMsg, QRegularExpression(QStringLiteral("QML ready but video init failed"))); videoManager._initAfterQmlIsReady(); verifyExpectedLogMessage(); QCOMPARE(videoManager._initState, VideoManager::InitState::Failed); @@ -104,9 +104,9 @@ void VideoManagerInitTest::_testGstInitFailure() #else void VideoManagerInitTest::init() { UnitTest::init(); QSKIP("GStreamer not enabled"); } -void VideoManagerInitTest::_testQmlReadyBeforeGstReady() { QSKIP("GStreamer not enabled"); } -void VideoManagerInitTest::_testGstReadyBeforeQmlReady() { QSKIP("GStreamer not enabled"); } -void VideoManagerInitTest::_testGstInitFailure() { QSKIP("GStreamer not enabled"); } +void VideoManagerInitTest::_testQmlReadyBeforeBackendReady() { QSKIP("GStreamer not enabled"); } +void VideoManagerInitTest::_testBackendReadyBeforeQmlReady() { QSKIP("GStreamer not enabled"); } +void VideoManagerInitTest::_testBackendInitFailure() { QSKIP("GStreamer not enabled"); } #endif diff --git a/test/VideoManager/VideoManagerInitTest.h b/test/VideoManager/VideoManagerInitTest.h index a44816444456..0147e21a4ad7 100644 --- a/test/VideoManager/VideoManagerInitTest.h +++ b/test/VideoManager/VideoManagerInitTest.h @@ -9,7 +9,7 @@ class VideoManagerInitTest : public UnitTest private slots: void init() override; - void _testQmlReadyBeforeGstReady(); - void _testGstReadyBeforeQmlReady(); - void _testGstInitFailure(); + void _testQmlReadyBeforeBackendReady(); + void _testBackendReadyBeforeQmlReady(); + void _testBackendInitFailure(); }; diff --git a/tools/README.md b/tools/README.md index c9468c3035e1..9b3ced9f1bce 100644 --- a/tools/README.md +++ b/tools/README.md @@ -458,7 +458,7 @@ Version numbers and build settings are centralized in `.github/build-config.json { "qt_version": "6.10.1", "qt_modules": "qtgraphs qtlocation ...", - "gstreamer_default_version": "1.24.13", + "gstreamer": { "version": { "default": "1.24.13", ... }, ... }, "ndk_version": "r27c", ... } diff --git a/tools/check_deps.py b/tools/check_deps.py index 366af4c4764f..264f7a60091a 100644 --- a/tools/check_deps.py +++ b/tools/check_deps.py @@ -140,7 +140,7 @@ def check_gstreamer_version() -> None: """Check configured and installed GStreamer versions.""" log_info("Checking GStreamer version...") current_version = get_build_config_value( - "gstreamer_version", + "gstreamer.version.default", "unknown", start=Path(__file__).resolve(), ) diff --git a/tools/common/build_config.py b/tools/common/build_config.py index 272c4ad60539..3623ecd3580a 100644 --- a/tools/common/build_config.py +++ b/tools/common/build_config.py @@ -14,12 +14,12 @@ "qt_version", "qt_minimum_version", "qt_modules", - "gstreamer_minimum_version", - "gstreamer_default_version", - "gstreamer_macos_version", - "gstreamer_ios_version", - "gstreamer_android_version", - "gstreamer_windows_version", + "gstreamer.version.minimum", + "gstreamer.version.default", + "gstreamer.version.macos", + "gstreamer.version.ios", + "gstreamer.version.android", + "gstreamer.version.windows", "xcode_version", "xcode_ios_version", "ndk_version", @@ -87,6 +87,8 @@ def get_build_config_value( ) -> str: """Return a string value from the build config or *default*. + Dotted keys (e.g. "gstreamer.version.default") walk nested objects. + File-missing / unreadable → silent default (callers may run outside the repo). Parse / schema errors → raise: an existing-but-corrupted config silently swapping defaults can produce wrong artifacts without warning. @@ -95,7 +97,12 @@ def get_build_config_value( config = load_build_config(config_file, start=start, extra_candidates=extra_candidates) except (FileNotFoundError, OSError): return default - value = config.get(key, default) + value: Any = config + for part in key.split("."): + if isinstance(value, dict) and part in value: + value = value[part] + else: + return default return str(value) if value is not None else default @@ -105,8 +112,15 @@ def derive_ios_qt_modules(qt_modules: str) -> str: return " ".join(modules) -_EXPORT_KEY_ALIASES: dict[str, str] = { - "gstreamer_default_version": "GSTREAMER_VERSION", +_EXPORT_KEY_ALIASES: dict[str, str] = {} + +_GSTREAMER_VERSION_ENV_NAMES: dict[str, str] = { + "gstreamer.version.default": "GSTREAMER_VERSION", + "gstreamer.version.minimum": "GSTREAMER_MINIMUM_VERSION", + "gstreamer.version.android": "GSTREAMER_ANDROID_VERSION", + "gstreamer.version.ios": "GSTREAMER_IOS_VERSION", + "gstreamer.version.macos": "GSTREAMER_MACOS_VERSION", + "gstreamer.version.windows": "GSTREAMER_WINDOWS_VERSION", } @@ -118,9 +132,19 @@ def export_build_config_values( """Return uppercase env-style values exported from the config.""" exported: dict[str, str] = {} for key in keys or DEFAULT_EXPORT_KEYS: - if key in config: - export_name = _EXPORT_KEY_ALIASES.get(key, key.upper()) - exported[export_name] = str(config[key]) + value: Any = config + for part in key.split("."): + if isinstance(value, dict) and part in value: + value = value[part] + else: + value = None + break + if value is not None: + primary = _GSTREAMER_VERSION_ENV_NAMES.get(key, key.replace(".", "_").upper()) + exported[primary] = str(value) + alias = _EXPORT_KEY_ALIASES.get(key) + if alias: + exported[alias] = str(value) return exported diff --git a/tools/debuggers/gst_latency_parser.py b/tools/debuggers/gst_latency_parser.py new file mode 100755 index 000000000000..141fb31fbf7f --- /dev/null +++ b/tools/debuggers/gst_latency_parser.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Parse GStreamer `latency` tracer output and emit per-element latency stats. + +Usage: + GST_TRACERS="latency(flags=pipeline+element+reported)" \\ + GST_DEBUG="GST_TRACER:7" \\ + GST_DEBUG_FILE=/tmp/gst.log \\ + QGroundControl + + python3 tools/debuggers/gst_latency_parser.py /tmp/gst.log + python3 tools/debuggers/gst_latency_parser.py --threshold-ms 100 /tmp/gst.log + python3 tools/debuggers/gst_latency_parser.py --json /tmp/gst.log | jq + +Exit code 1 if any element's p95 exceeds --threshold-ms (CI gate). +""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import sys +from collections import defaultdict +from pathlib import Path + +# Tracer line shape (gst_tracer_record_log): +# TRACE GST_TRACER :0:: , key=(type)value, key=(type)value, ...; +# Records emitted by the latency tracer: +# pipeline-latency: source-to-sink wall-clock latency +# element-latency: per-element processing time +# element-reported-latency: latency claimed by the element's LATENCY query +_RECORD_RE = re.compile(r"TRACE\s+GST_TRACER\s+:\d+::\s+(?P[\w-]+),\s*(?P.*?);?\s*$") +_FIELD_RE = re.compile(r"(?P[\w-]+)=\((?P[\w]+)\)(?P[^,;]+)(?:,|$)") + + +def _parse_fields(rest: str) -> dict[str, str]: + out: dict[str, str] = {} + # _FIELD_RE leaves the final field unterminated; pad with a comma so the regex matches uniformly. + for m in _FIELD_RE.finditer(rest + ","): + v = m.group("v").strip() + if v.startswith('"') and v.endswith('"'): + v = v[1:-1] + out[m.group("k")] = v + return out + + +def parse(path: Path) -> dict[str, list[int]]: + """Return {bucket_label: [ns, ns, ...]} for each tracer record kind we care about.""" + buckets: dict[str, list[int]] = defaultdict(list) + with path.open("r", encoding="utf-8", errors="replace") as f: + for line in f: + m = _RECORD_RE.search(line) + if not m: + continue + rec = m.group("rec") + fields = _parse_fields(m.group("rest")) + time_ns_str = fields.get("time") + if time_ns_str is None: + continue + try: + time_ns = int(time_ns_str) + except ValueError: + continue + if rec == "pipeline-latency": + buckets[f"pipeline:{fields.get('element-id', '?')}"].append(time_ns) + elif rec == "element-latency": + src = fields.get("src-element") or fields.get("element") or "?" + sink = fields.get("sink-element") or "?" + buckets[f"element:{src}->{sink}"].append(time_ns) + elif rec == "element-reported-latency": + el = fields.get("element") or "?" + buckets[f"reported:{el}"].append(time_ns) + return buckets + + +def _percentile(sorted_vals: list[int], pct: float) -> int: + if not sorted_vals: + return 0 + # Nearest-rank, no interpolation — keeps the integer ns and avoids picking a value that didn't occur. + k = max(0, min(len(sorted_vals) - 1, math.ceil(pct / 100.0 * len(sorted_vals)) - 1)) + return sorted_vals[k] + + +def summarize(buckets: dict[str, list[int]]) -> list[dict]: + rows: list[dict] = [] + for label, vals in sorted(buckets.items()): + s = sorted(vals) + rows.append({ + "label": label, + "count": len(s), + "p50_ms": _percentile(s, 50) / 1e6, + "p95_ms": _percentile(s, 95) / 1e6, + "p99_ms": _percentile(s, 99) / 1e6, + "max_ms": s[-1] / 1e6 if s else 0, + }) + return rows + + +def _format_table(rows: list[dict]) -> str: + if not rows: + return "(no latency records found — was GST_TRACERS=latency set with GST_DEBUG=GST_TRACER:7?)" + widths = {"label": max(48, max(len(r["label"]) for r in rows))} + header = f"{'bucket':<{widths['label']}} {'count':>7} {'p50ms':>8} {'p95ms':>8} {'p99ms':>8} {'maxms':>8}" + sep = "-" * len(header) + lines = [header, sep] + for r in rows: + lines.append( + f"{r['label']:<{widths['label']}} {r['count']:>7d} " + f"{r['p50_ms']:>8.2f} {r['p95_ms']:>8.2f} {r['p99_ms']:>8.2f} {r['max_ms']:>8.2f}" + ) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("log", type=Path, help="GStreamer debug log file (GST_DEBUG_FILE output)") + p.add_argument("--threshold-ms", type=float, default=None, + help="Fail (exit 1) if any bucket's p95 exceeds this. CI gate.") + p.add_argument("--json", action="store_true", help="Emit JSON instead of a table") + p.add_argument("--filter", default=None, help="Only show buckets containing this substring") + args = p.parse_args(argv) + + if not args.log.exists(): + print(f"error: {args.log} not found", file=sys.stderr) + return 2 + + buckets = parse(args.log) + if args.filter: + buckets = {k: v for k, v in buckets.items() if args.filter in k} + rows = summarize(buckets) + + if args.json: + json.dump(rows, sys.stdout, indent=2) + sys.stdout.write("\n") + else: + print(_format_table(rows)) + + if args.threshold_ms is not None: + bad = [r for r in rows if r["p95_ms"] > args.threshold_ms] + if bad: + print(f"\nFAIL: {len(bad)} bucket(s) exceeded p95 threshold {args.threshold_ms:.1f} ms:", + file=sys.stderr) + for r in bad: + print(f" {r['label']}: p95={r['p95_ms']:.2f} ms", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/setup/build-gstreamer.py b/tools/setup/build-gstreamer.py index 07ff5155eed2..8a5d4ebde704 100755 --- a/tools/setup/build-gstreamer.py +++ b/tools/setup/build-gstreamer.py @@ -693,7 +693,7 @@ def main() -> int: args = parse_args() # Resolve defaults - version = args.version or get_build_config_value("gstreamer_default_version", "1.24.13") + version = args.version or get_build_config_value("gstreamer.version.default", "1.24.13") arch = args.arch or get_default_arch(args.platform) prefix = Path(args.prefix) if args.prefix else None work_dir = Path(args.work_dir) diff --git a/tools/setup/install_dependencies/_macos.py b/tools/setup/install_dependencies/_macos.py index b5453a895a09..341565d05f63 100644 --- a/tools/setup/install_dependencies/_macos.py +++ b/tools/setup/install_dependencies/_macos.py @@ -51,7 +51,7 @@ def install_macos(dry_run: bool = False) -> bool: if not _c.run_command(_c.get_brew_install_command(packages), dry_run): return False - gst_version = _c.get_config_value("gstreamer_macos_version") + gst_version = _c.get_config_value("gstreamer.version.macos") macos_gst_root = Path("/Library/Frameworks/GStreamer.framework") if not gst_version: print() diff --git a/tools/setup/install_dependencies/_packages.py b/tools/setup/install_dependencies/_packages.py index b7c0cf9d2272..78f00ffd0af7 100644 --- a/tools/setup/install_dependencies/_packages.py +++ b/tools/setup/install_dependencies/_packages.py @@ -166,9 +166,11 @@ "libgstreamer-plugins-base1.0-dev", "libgstreamer-plugins-bad1.0-dev", # Runtime plugin .so packages for the AppDir (the -dev packages above are link-only); - # install verification needs playback/tcp (base) and rtsp/rtp/rtpmanager/udp (good). + # install verification needs base/good/bad (openh264) + gl (opengl) to match the native set. "gstreamer1.0-plugins-base", "gstreamer1.0-plugins-good", + "gstreamer1.0-plugins-bad", + "gstreamer1.0-gl", "libusb-1.0-0-dev", "libsdl2-dev", ], diff --git a/tools/setup/install_dependencies/_windows.py b/tools/setup/install_dependencies/_windows.py index 35c7f81c6538..e47485f4cd19 100644 --- a/tools/setup/install_dependencies/_windows.py +++ b/tools/setup/install_dependencies/_windows.py @@ -232,7 +232,7 @@ def install_windows( return False if not skip_gstreamer: - version = gstreamer_version or _c.get_config_value("gstreamer_windows_version") + version = gstreamer_version or _c.get_config_value("gstreamer.version.windows") if not version: print( "Error: GStreamer version not found. " diff --git a/tools/setup/read_config.py b/tools/setup/read_config.py index cdce2ee50b06..df4447ac2dc9 100755 --- a/tools/setup/read_config.py +++ b/tools/setup/read_config.py @@ -24,6 +24,7 @@ import os import sys from pathlib import Path +from typing import Any _tools_dir = Path(__file__).resolve().parents[1] if str(_tools_dir) not in sys.path: @@ -108,13 +109,18 @@ def main() -> int: return 1 if args.get: - key = args.get.lower() - # Legacy alias: --get gstreamer_version resolves to gstreamer_default_version. - if key == "gstreamer_version" and "gstreamer_default_version" in config: - print(config["gstreamer_default_version"]) - return 0 - if key in config: - print(config[key]) + key = args.get.lower() # Normalize to lowercase + if key == "gstreamer_version": + key = "gstreamer.version.default" + value: Any = config + for part in key.split("."): + if isinstance(value, dict) and part in value: + value = value[part] + else: + value = None + break + if value is not None: + print(value if isinstance(value, (str, int, float, bool)) else json.dumps(value)) return 0 print(f"Error: Key '{key}' not found in config", file=sys.stderr) print(f"Available keys: {', '.join(sorted(config.keys()))}", file=sys.stderr) diff --git a/tools/tests/test_read_config.py b/tools/tests/test_read_config.py index 1db2c32e44c0..28f6ac17554c 100644 --- a/tools/tests/test_read_config.py +++ b/tools/tests/test_read_config.py @@ -31,8 +31,16 @@ def _write_config(path: Path) -> None: "qt_version": "6.10.2", "qt_minimum_version": "6.8.0", "qt_modules": "qtpositioning qtserialport qtscxml", - "gstreamer_default_version": "1.28.1", - "gstreamer_windows_version": "1.26.6", + "gstreamer": { + "version": { + "default": "1.28.2", + "minimum": "1.24.0", + "android": "1.28.1", + "macos": "1.28.2", + "ios": "1.28.2", + "windows": "1.26.6", + }, + }, "android_platform": "35", } path.write_text(json.dumps(config), encoding="utf-8") @@ -60,12 +68,12 @@ def test_missing_key_returns_error(tmp_path: Path) -> None: def test_legacy_gstreamer_version_alias_returns_default_version(tmp_path: Path) -> None: config = tmp_path / "build-config.json" - config.write_text(json.dumps({"gstreamer_default_version": "1.28.1"}), encoding="utf-8") + config.write_text(json.dumps({"gstreamer": {"version": {"default": "1.28.2"}}}), encoding="utf-8") result = _run_read_config("--get", "gstreamer_version", env={"CONFIG_FILE": str(config)}) assert result.returncode == 0 - assert result.stdout.strip() == "1.28.1" + assert result.stdout.strip() == "1.28.2" def test_export_bash_format(tmp_path: Path) -> None: @@ -77,7 +85,7 @@ def test_export_bash_format(tmp_path: Path) -> None: assert result.returncode == 0 assert 'export QT_VERSION="6.10.2"' in result.stdout assert 'export QT_MODULES="qtpositioning qtserialport qtscxml"' in result.stdout - assert 'export GSTREAMER_VERSION="1.28.1"' in result.stdout + assert 'export GSTREAMER_VERSION="1.28.2"' in result.stdout def test_export_bash_preserves_bang_character(tmp_path: Path) -> None: @@ -112,10 +120,14 @@ def test_github_output_includes_ios_modules(tmp_path: Path) -> None: output_text = github_output.read_text(encoding="utf-8") assert "qt_version=6.10.2" in output_text assert "qt_minimum_version=6.8.0" in output_text - assert "gstreamer_version=1.28.1" in output_text + assert "gstreamer_version=1.28.2" in output_text assert "gstreamer_windows_version=1.26.6" in output_text + assert "gstreamer_minimum_version=1.24.0" in output_text + assert "gstreamer_android_version=1.28.1" in output_text + assert "gstreamer_macos_version=1.28.2" in output_text + assert "gstreamer_ios_version=1.28.2" in output_text # Derived value excludes qtserialport and normalizes spacing. - assert "qt_modules_ios=qtpositioning" in output_text + assert "qt_modules_ios=qtpositioning qtscxml" in output_text env_text = github_env.read_text(encoding="utf-8") assert "QT_VERSION=6.10.2" in env_text