From ec30e64417680305b135d3361a5fa434f0c7033c Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sat, 4 Apr 2026 20:15:25 +0200 Subject: [PATCH] webcam from gstreamer v4l2src -> videoconvert -> glupload -> glcolorconvert -> qml6glsink --- Cargo.toml | 4 +- poc006/Cargo.toml | 25 +++ poc006/build.rs | 319 +++++++++++++++++++++++++++++++ poc006/build_config.default.json | 19 ++ poc006/qml/main.qml | 35 ++++ poc006/src/main.rs | 149 +++++++++++++++ poc006/src/qt_gst.rs | 18 ++ poc006/src/qt_gst_helpers.cpp | 95 +++++++++ poc006/src/qt_gst_helpers.h | 12 ++ 9 files changed, 675 insertions(+), 1 deletion(-) create mode 100644 poc006/Cargo.toml create mode 100644 poc006/build.rs create mode 100644 poc006/build_config.default.json create mode 100644 poc006/qml/main.qml create mode 100644 poc006/src/main.rs create mode 100644 poc006/src/qt_gst.rs create mode 100644 poc006/src/qt_gst_helpers.cpp create mode 100644 poc006/src/qt_gst_helpers.h diff --git a/Cargo.toml b/Cargo.toml index 9814411..e6478e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,11 +7,12 @@ members = [ "poc003", "poc004", "poc005", + "poc006", ] resolver = "3" [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/poc-qt" @@ -27,6 +28,7 @@ gstreamer = { version = "^0.25", features = ["serde"] } gstreamer-app = { version = "^0.25", features = [] } gstreamer-video = { version = "^0.25", features = ["serde"] } jsonschema = { version = "^0.40", features = [] } +pkg-config = { version = "^0.3", features = [] } serde = { version = "^1.0", features = ["derive"] } serde_json = { version = "^1.0", features = [] } tokio = { version = "^1.50", features = ["full"] } diff --git a/poc006/Cargo.toml b/poc006/Cargo.toml new file mode 100644 index 0000000..5744128 --- /dev/null +++ b/poc006/Cargo.toml @@ -0,0 +1,25 @@ +# file: poc-qt/poc006/Cargo.toml + +[package] +name = "poc-qt006" +version = "0.6.0" +edition.workspace = true +license.workspace = true +authors.workspace = true + +[build-dependencies] +cxx-qt-build = { workspace = true, features = ["link_qt_object_files"] } +jsonschema = { workspace = true, features = [] } +pkg-config = { workspace = true, features = [] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = [] } + +[dependencies] +cxx = { workspace = true, features = [] } +cxx-qt = { workspace = true, features = [] } +cxx-qt-lib = { workspace = true, features = ["full", "link_qt_object_files"] } +glib = { workspace = true, features = [] } +gstreamer = { workspace = true, features = ["serde"] } +gstreamer-app = { workspace = true, features = [] } +gstreamer-video = { workspace = true, features = ["serde"] } + diff --git a/poc006/build.rs b/poc006/build.rs new file mode 100644 index 0000000..8fa4442 --- /dev/null +++ b/poc006/build.rs @@ -0,0 +1,319 @@ +// file: poc-qt/poc006/build.rs + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use cxx_qt_build::CxxQtBuilder; +use serde::Deserialize; +use serde_json::Value; + +const DEFAULT_CONFIG_STR: &str = include_str!("build_config.default.json"); +const CONFIG_SCHEMA_STR: &str = include_str!("../build_config.schema.json"); + +#[derive(Debug, Deserialize)] +struct BuildConfig { + active_profile: String, + profiles: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct BuildProfile { + qt: QtConfig, + link: LinkConfig, +} + +#[derive(Debug, Deserialize)] +struct QtConfig { + qmake: String, + lib_dir: String, + qml_dir: String, + plugins_dir: String, + version_major: u8, +} + +#[derive(Debug, Deserialize)] +struct LinkConfig { + rpaths: Vec, +} + +fn ensure_user_config_exists(config_path: &Path) { + if config_path.exists() { + return; + } + + match fs::write(config_path, DEFAULT_CONFIG_STR) { + Ok(()) => { + println!( + "cargo:warning=build_config.json was missing and has been created from build_config.default.json" + ); + } + Err(err) => { + panic!( + "failed to create {} from embedded defaults: {}", + config_path.display(), + err + ); + } + } +} + +fn validate_embedded_schema() { + let schema_json = parse_json_str(CONFIG_SCHEMA_STR, "embedded build_config.schema.json"); + + match jsonschema::meta::validate(&schema_json) { + Ok(()) => {} + Err(err) => { + panic!("embedded build_config.schema.json is not a valid JSON Schema: {err}"); + } + } +} + +fn validate_default_config() { + validate_config_str_against_schema(DEFAULT_CONFIG_STR, "embedded build_config.default.json"); +} + +fn validate_user_config(config_path: &Path) { + let raw = match fs::read_to_string(config_path) { + Ok(v) => v, + Err(err) => { + panic!("failed to read {}: {}", config_path.display(), err); + } + }; + + validate_config_str_against_schema(&raw, &format!("{}", config_path.display())); +} + +fn validate_config_str_against_schema(config_raw: &str, config_name: &str) { + let schema_json = parse_json_str(CONFIG_SCHEMA_STR, "embedded build_config.schema.json"); + let config_json = parse_json_str(config_raw, config_name); + + let validator = match jsonschema::validator_for(&schema_json) { + Ok(v) => v, + Err(err) => { + panic!( + "failed to build JSON Schema validator for {}: {}", + config_name, err + ); + } + }; + + if validator.is_valid(&config_json) { + return; + } + + let mut errors = Vec::new(); + for err in validator.iter_errors(&config_json) { + errors.push(format!( + "- {} at instance path {}", + err, + err.instance_path() + )); + } + + let joined = errors.join("\n"); + panic!("{} failed schema validation:\n{}", config_name, joined); +} + +fn load_build_config(config_path: &Path) -> BuildConfig { + let raw = match fs::read_to_string(config_path) { + Ok(v) => v, + Err(err) => { + panic!("failed to read {}: {}", config_path.display(), err); + } + }; + + match serde_json::from_str::(&raw) { + Ok(v) => v, + Err(err) => { + panic!( + "failed to deserialize {} into BuildConfig: {}", + config_path.display(), + err + ); + } + } +} + +fn resolve_profile_name(build_config: &BuildConfig) -> String { + let env_profile = std::env::var("BUILD_CONFIG_PROFILE"); + match env_profile { + Ok(v) => { + if v.trim().is_empty() { + build_config.active_profile.clone() + } else { + v + } + } + Err(_) => build_config.active_profile.clone(), + } +} + +fn resolve_profile<'a>(build_config: &'a BuildConfig, profile_name: &str) -> &'a BuildProfile { + match build_config.profiles.get(profile_name) { + Some(v) => v, + None => { + let mut available = Vec::new(); + for key in build_config.profiles.keys() { + available.push(key.clone()); + } + panic!( + "active profile '{}' not found in build_config.json. available profiles: {}", + profile_name, + available.join(", ") + ); + } + } +} + +fn validate_profile_paths(profile: &BuildProfile, profile_name: &str) { + ensure_existing_file(&profile.qt.qmake, profile_name, "qt.qmake"); + ensure_existing_dir(&profile.qt.lib_dir, profile_name, "qt.lib_dir"); + ensure_existing_dir(&profile.qt.qml_dir, profile_name, "qt.qml_dir"); + ensure_existing_dir(&profile.qt.plugins_dir, profile_name, "qt.plugins_dir"); + + let mut index = 0usize; + while index < profile.link.rpaths.len() { + ensure_existing_dir( + &profile.link.rpaths[index], + profile_name, + &format!("link.rpaths[{index}]"), + ); + index += 1; + } +} + +fn ensure_existing_file(path_str: &str, profile_name: &str, field_name: &str) { + let path = Path::new(path_str); + if !path.exists() { + panic!( + "profile '{}' has invalid {}: path does not exist: {}", + profile_name, + field_name, + path.display() + ); + } + if !path.is_file() { + panic!( + "profile '{}' has invalid {}: path is not a file: {}", + profile_name, + field_name, + path.display() + ); + } +} + +fn ensure_existing_dir(path_str: &str, profile_name: &str, field_name: &str) { + let path = Path::new(path_str); + if !path.exists() { + panic!( + "profile '{}' has invalid {}: path does not exist: {}", + profile_name, + field_name, + path.display() + ); + } + if !path.is_dir() { + panic!( + "profile '{}' has invalid {}: path is not a directory: {}", + profile_name, + field_name, + path.display() + ); + } +} + +fn apply_build_environment(profile: &BuildProfile) { + unsafe { + std::env::set_var("QMAKE", &profile.qt.qmake); + std::env::set_var("QT_VERSION_MAJOR", profile.qt.version_major.to_string()); + } + + println!("cargo:rerun-if-env-changed=BUILD_CONFIG_PROFILE"); + println!("cargo:rerun-if-env-changed=QMAKE"); + println!("cargo:rerun-if-env-changed=QT_VERSION_MAJOR"); +} + +fn apply_linker_settings(profile: &BuildProfile) { + let mut index = 0usize; + while index < profile.link.rpaths.len() { + println!( + "cargo:rustc-link-arg=-Wl,-rpath,{}", + profile.link.rpaths[index] + ); + index += 1; + } +} + +fn emit_runtime_hints(profile: &BuildProfile, profile_name: &str) { + println!( + "cargo:warning=using build profile '{}' with qmake '{}'", + profile_name, profile.qt.qmake + ); + println!( + "cargo:warning=qt lib_dir='{}', qml_dir='{}', plugins_dir='{}'", + profile.qt.lib_dir, profile.qt.qml_dir, profile.qt.plugins_dir + ); +} + +fn parse_json_str(raw: &str, label: &str) -> Value { + match serde_json::from_str::(raw) { + Ok(v) => v, + Err(err) => { + panic!("failed to parse {} as JSON: {}", label, err); + } + } +} + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=build_config.default.json"); + println!("cargo:rerun-if-changed=build_config.schema.json"); + println!("cargo:rerun-if-changed=src/qt_gst.rs"); + println!("cargo:rerun-if-changed=src/qt_gst_helpers.h"); + println!("cargo:rerun-if-changed=src/qt_gst_helpers.cpp"); + println!("cargo:rerun-if-changed=qml/main.qml"); + + let gst = match pkg_config::Config::new().probe("gstreamer-1.0") { + Ok(v) => v, + Err(err) => { + panic!("failed to probe gstreamer-1.0 via pkg-config: {err}"); + } + }; + + let config_path = PathBuf::from("build_config.json"); + + ensure_user_config_exists(&config_path); + validate_embedded_schema(); + validate_default_config(); + validate_user_config(&config_path); + + let build_config = load_build_config(&config_path); + let profile_name = resolve_profile_name(&build_config); + let profile = resolve_profile(&build_config, &profile_name); + + validate_profile_paths(profile, &profile_name); + apply_build_environment(profile); + apply_linker_settings(profile); + + let mut builder = CxxQtBuilder::new() + .file("src/qt_gst.rs") + .cpp_file("src/qt_gst_helpers.cpp") + .qt_module("Core") + .qt_module("Gui") + .qt_module("Qml") + .qt_module("Quick"); + + unsafe { + builder = builder.cc_builder(|cc| { + cc.include("src"); + for include_path in &gst.include_paths { + cc.include(include_path); + } + }); + } + + builder.build(); + + emit_runtime_hints(profile, &profile_name); +} diff --git a/poc006/build_config.default.json b/poc006/build_config.default.json new file mode 100644 index 0000000..1fe934c --- /dev/null +++ b/poc006/build_config.default.json @@ -0,0 +1,19 @@ +{ + "active_profile": "system", + "profiles": { + "system": { + "qt": { + "qmake": "/usr/bin/qmake6", + "lib_dir": "/usr/lib/x86_64-linux-gnu", + "qml_dir": "/usr/lib/x86_64-linux-gnu/qt6/qml", + "plugins_dir": "/usr/lib/x86_64-linux-gnu/qt6/plugins", + "version_major": 6 + }, + "link": { + "rpaths": [ + "/usr/lib/x86_64-linux-gnu" + ] + } + } + } +} \ No newline at end of file diff --git a/poc006/qml/main.qml b/poc006/qml/main.qml new file mode 100644 index 0000000..17044a6 --- /dev/null +++ b/poc006/qml/main.qml @@ -0,0 +1,35 @@ +// file: poc-qt/poc006/qml/main.qml + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.freedesktop.gstreamer.Qt6GLVideoItem 1.0 + +ApplicationWindow { + visible: true + width: 960 + height: 700 + title: "POC006 - GStreamer qml6glsink" + + color: "black" + + GstGLQt6VideoItem { + id: video + objectName: "videoItem" + anchors.fill: parent + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 48 + color: "#66000000" + + Text { + anchors.centerIn: parent + color: "white" + text: "POC006 - webcam preview via GStreamer qml6glsink" + } + } +} diff --git a/poc006/src/main.rs b/poc006/src/main.rs new file mode 100644 index 0000000..932aac0 --- /dev/null +++ b/poc006/src/main.rs @@ -0,0 +1,149 @@ +// file: poc006/src/main.rs + +mod qt_gst; + +use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QString, QUrl}; +use glib::translate::ToGlibPtr; +use gstreamer as gst; +use gst::prelude::*; +use std::env; +use std::path::PathBuf; + +fn main() { + if let Err(err) = gst::init() { + eprintln!("gst init failed: {err}"); + return; + } + + let pipeline = gst::Pipeline::new(); + + let src = match gst::ElementFactory::make("v4l2src").build() { + Ok(v) => v, + Err(err) => { + eprintln!("failed to create v4l2src: {err}"); + return; + } + }; + + let videoconvert = match gst::ElementFactory::make("videoconvert").build() { + Ok(v) => v, + Err(err) => { + eprintln!("failed to create videoconvert: {err}"); + return; + } + }; + + let glupload = match gst::ElementFactory::make("glupload").build() { + Ok(v) => v, + Err(err) => { + eprintln!("failed to create glupload: {err}"); + return; + } + }; + + let glcolorconvert = match gst::ElementFactory::make("glcolorconvert").build() { + Ok(v) => v, + Err(err) => { + eprintln!("failed to create glcolorconvert: {err}"); + return; + } + }; + + // Important : créer qml6glsink avant le chargement du QML + let sink = match gst::ElementFactory::make("qml6glsink").build() { + Ok(v) => v, + Err(err) => { + eprintln!("failed to create qml6glsink: {err}"); + return; + } + }; + + if let Err(err) = pipeline.add_many([&src, &videoconvert, &glupload, &glcolorconvert, &sink]) { + eprintln!("failed to add elements to pipeline: {err}"); + return; + } + + if let Err(err) = + gst::Element::link_many([&src, &videoconvert, &glupload, &glcolorconvert, &sink]) + { + eprintln!("failed to link pipeline: {err}"); + let _ = pipeline.set_state(gst::State::Null); + return; + } + + let mut app = QGuiApplication::new(); + let mut engine = QQmlApplicationEngine::new(); + + let current_dir = match env::current_dir() { + Ok(v) => v, + Err(err) => { + eprintln!("failed to get current_dir: {err}"); + let _ = pipeline.set_state(gst::State::Null); + return; + } + }; + + let qml_path: PathBuf = current_dir.join("qml/main.qml"); + let qml_path = match qml_path.canonicalize() { + Ok(v) => v, + Err(err) => { + eprintln!("failed to resolve qml/main.qml: {err}"); + let _ = pipeline.set_state(gst::State::Null); + return; + } + }; + + let qml_path_str = match qml_path.to_str() { + Some(v) => v.to_string(), + None => { + eprintln!("qml path is not valid UTF-8: {}", qml_path.display()); + let _ = pipeline.set_state(gst::State::Null); + return; + } + }; + + println!("loading qml from: {}", qml_path_str); + + if let Some(mut engine_ref) = engine.as_mut() { + let qml_url = QUrl::from_local_file(&QString::from(qml_path_str)); + engine_ref.as_mut().load(&qml_url); + + let video_item = qt_gst::ffi::find_video_item(engine_ref.as_mut()); + if video_item.is_null() { + eprintln!("failed to find QML videoItem"); + let _ = pipeline.set_state(gst::State::Null); + return; + } + + let sink_ptr = + >::to_glib_none(&sink).0 + as usize; + + let ok = unsafe { qt_gst::ffi::bind_sink_widget(sink_ptr, video_item) }; + if !ok { + eprintln!("failed to bind qml6glsink widget"); + let _ = pipeline.set_state(gst::State::Null); + return; + } + + let pipeline_ptr = + >::to_glib_none(&pipeline).0 + as usize; + + if !qt_gst::ffi::schedule_pipeline_play(engine_ref.as_mut(), pipeline_ptr) { + eprintln!("failed to schedule pipeline play"); + let _ = pipeline.set_state(gst::State::Null); + return; + } + } else { + eprintln!("failed to create QQmlApplicationEngine"); + let _ = pipeline.set_state(gst::State::Null); + return; + } + + if let Some(app_ref) = app.as_mut() { + app_ref.exec(); + } + + let _ = pipeline.set_state(gst::State::Null); +} diff --git a/poc006/src/qt_gst.rs b/poc006/src/qt_gst.rs new file mode 100644 index 0000000..2307227 --- /dev/null +++ b/poc006/src/qt_gst.rs @@ -0,0 +1,18 @@ +// file: poc-qt/poc006/src/qt_gst.rs + +#[cxx::bridge] +pub mod ffi { + unsafe extern "C++" { + include!("qt_gst_helpers.h"); + + type QQmlApplicationEngine = cxx_qt_lib::QQmlApplicationEngine; + type QQuickItem; + + fn find_video_item(engine: Pin<&mut QQmlApplicationEngine>) -> *mut QQuickItem; + unsafe fn bind_sink_widget(sink_ptr: usize, item: *mut QQuickItem) -> bool; + fn schedule_pipeline_play( + engine: Pin<&mut QQmlApplicationEngine>, + pipeline_ptr: usize, + ) -> bool; + } +} diff --git a/poc006/src/qt_gst_helpers.cpp b/poc006/src/qt_gst_helpers.cpp new file mode 100644 index 0000000..5545bb4 --- /dev/null +++ b/poc006/src/qt_gst_helpers.cpp @@ -0,0 +1,95 @@ +// file: poc-qt/poc006/src/qt_gst_helpers.cpp + +#include "qt_gst_helpers.h" + +#include +#include +#include +#include +#include + +#include + +class SetPlaying : public QRunnable +{ +public: + explicit SetPlaying(GstElement* pipeline) + : pipeline_(pipeline ? GST_ELEMENT(gst_object_ref(pipeline)) : nullptr) + { + } + + ~SetPlaying() override + { + if (pipeline_) { + gst_object_unref(pipeline_); + pipeline_ = nullptr; + } + } + + void run() override + { + if (pipeline_) { + gst_element_set_state(pipeline_, GST_STATE_PLAYING); + } + } + +private: + GstElement* pipeline_; +}; + +QQuickItem* find_video_item(QQmlApplicationEngine& engine) +{ + const auto roots = engine.rootObjects(); + if (roots.isEmpty()) { + return nullptr; + } + + auto* root_window = qobject_cast(roots.first()); + if (!root_window) { + return nullptr; + } + + return root_window->findChild("videoItem"); +} + +bool bind_sink_widget(std::uintptr_t sink_ptr, QQuickItem* item) +{ + if (sink_ptr == 0 || item == nullptr) { + return false; + } + + auto* sink = reinterpret_cast(sink_ptr); + + g_object_set(sink, "widget", item, nullptr); + + // Recommandé pour propager correctement le contexte GL Qt/GStreamer + gst_element_set_state(sink, GST_STATE_READY); + + return true; +} + +bool schedule_pipeline_play(QQmlApplicationEngine& engine, std::uintptr_t pipeline_ptr) +{ + if (pipeline_ptr == 0) { + return false; + } + + const auto roots = engine.rootObjects(); + if (roots.isEmpty()) { + return false; + } + + auto* root_window = qobject_cast(roots.first()); + if (!root_window) { + return false; + } + + auto* pipeline = reinterpret_cast(pipeline_ptr); + + root_window->scheduleRenderJob( + new SetPlaying(pipeline), + QQuickWindow::BeforeSynchronizingStage + ); + + return true; +} diff --git a/poc006/src/qt_gst_helpers.h b/poc006/src/qt_gst_helpers.h new file mode 100644 index 0000000..20bc355 --- /dev/null +++ b/poc006/src/qt_gst_helpers.h @@ -0,0 +1,12 @@ +// file: poc-qt/poc006/src/qt_gst_helpers.h + +#pragma once + +#include + +class QQuickItem; +class QQmlApplicationEngine; + +QQuickItem* find_video_item(QQmlApplicationEngine& engine); +bool bind_sink_widget(std::uintptr_t sink_ptr, QQuickItem* item); +bool schedule_pipeline_play(QQmlApplicationEngine& engine, std::uintptr_t pipeline_ptr);