diff --git a/Cargo.toml b/Cargo.toml index 0cd39d8..9814411 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,11 +6,12 @@ members = [ "poc002", "poc003", "poc004", + "poc005", ] resolver = "3" [workspace.package] -version = "0.4.0" +version = "0.5.0" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/poc-qt" @@ -21,9 +22,18 @@ cxx = { version = "^1", features = ["c++14", "c++17", "c++20"] } cxx-qt = { version = "^0.8", features = [] } cxx-qt-build = { version = "^0.8", features = ["link_qt_object_files"] } cxx-qt-lib = { version = "^0.8", features = ["full", "link_qt_object_files"] } +glib = { version = "^0.22", features = [] } +gstreamer = { version = "^0.25", features = ["serde"] } +gstreamer-app = { version = "^0.25", features = [] } +gstreamer-video = { version = "^0.25", features = ["serde"] } jsonschema = { version = "^0.40", features = [] } serde = { version = "^1.0", features = ["derive"] } serde_json = { version = "^1.0", features = [] } +tokio = { version = "^1.50", features = ["full"] } +tokio-tungstenite = { version = "^0.29", features = ["rustls", "tokio-rustls", "url"] } +tracing = { version = "^0.1", features = ["async-await", "log"] } +tracing-subscriber = { version = "^0.3", features = ["ansi", "env-filter", "chrono", "serde", "json"] } +webrtc = { version = "^0.17", features = [] } [profile.dev] incremental = true # Compile your binary in smaller steps. diff --git a/poc005/Cargo.toml b/poc005/Cargo.toml new file mode 100644 index 0000000..e00f1e1 --- /dev/null +++ b/poc005/Cargo.toml @@ -0,0 +1,24 @@ +# file: poc-qt/poc005/Cargo.toml + +[package] +name = "poc-qt005" +version = "0.5.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 = [] } +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/poc005/build.rs b/poc005/build.rs new file mode 100644 index 0000000..daaa320 --- /dev/null +++ b/poc005/build.rs @@ -0,0 +1,298 @@ +// file: poc-qt/poc005/build.rs + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use cxx_qt_build::{CxxQtBuilder, QmlModule}; +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/bridge.rs"); + println!("cargo:rerun-if-changed=qml/main.qml"); + + 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); + + CxxQtBuilder::new_qml_module(QmlModule::new("com.sasedev.gstpreview01").qml_files(["qml/main.qml"])) + .qt_module("Core") + .qt_module("Qml") + .qt_module("Network") + .files(["src/bridge.rs"]) + .build(); + + emit_runtime_hints(profile, &profile_name); +} diff --git a/poc005/build_config.default.json b/poc005/build_config.default.json new file mode 100644 index 0000000..6a31de4 --- /dev/null +++ b/poc005/build_config.default.json @@ -0,0 +1,33 @@ +{ + "active_profile": "sinus", + "profiles": { + "sinus": { + "qt": { + "qmake": "/home/sinus/DEV/Qt/6.11.0/gcc_64/bin/qmake", + "lib_dir": "/home/sinus/DEV/Qt/6.11.0/gcc_64/lib", + "qml_dir": "/home/sinus/DEV/Qt/6.11.0/gcc_64/qml", + "plugins_dir": "/home/sinus/DEV/Qt/6.11.0/gcc_64/plugins", + "version_major": 6 + }, + "link": { + "rpaths": [ + "/home/sinus/DEV/Qt/6.11.0/gcc_64/lib" + ] + } + }, + "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/poc005/qml/main.qml b/poc005/qml/main.qml new file mode 100644 index 0000000..d2253bc --- /dev/null +++ b/poc005/qml/main.qml @@ -0,0 +1,38 @@ +// file: poc-qt/poc005/qml/main.qml + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import com.sasedev.gstpreview01 + +ApplicationWindow { + visible: true + width: 600 + height: 300 + title: "POC005 - GStreamer Rust" + + GstController { + id: gstController + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 12 + + Button { + text: "Start webcam (GStreamer)" + enabled: !gstController.started + onClicked: gstController.startPreview() + } + + Button { + text: "Stop" + enabled: gstController.started + onClicked: gstController.stopPreview() + } + + Label { + text: gstController.started ? "Started" : "Stopped" + } + } +} diff --git a/poc005/src/bridge.rs b/poc005/src/bridge.rs new file mode 100644 index 0000000..2a43540 --- /dev/null +++ b/poc005/src/bridge.rs @@ -0,0 +1,80 @@ +// file: poc-qt/poc005/src/bridge.rs + +use core::pin::Pin; +use gstreamer as gst; +use gstreamer::prelude::*; + +#[derive(Default)] +pub struct GstControllerRust { + started: bool, +} + +#[cxx_qt::bridge] +pub mod qobject { + extern "RustQt" { + #[qobject] + #[qml_element] + #[qproperty(bool, started)] + type GstController = super::GstControllerRust; + + #[qinvokable] + #[cxx_name = "startPreview"] + fn start_preview(self: Pin<&mut GstController>); + + #[qinvokable] + #[cxx_name = "stopPreview"] + fn stop_preview(self: Pin<&mut GstController>); + } +} + +impl qobject::GstController { + pub fn start_preview(self: Pin<&mut Self>) { + if *self.started() { + println!("preview already started"); + return; + } + + if let Err(err) = gst::init() { + eprintln!("gst init failed: {err}"); + return; + } + + let pipeline_str = "v4l2src ! videoconvert ! fakesink"; + + let element = match gst::parse::launch(pipeline_str) { + Ok(v) => v, + Err(err) => { + eprintln!("failed to parse pipeline: {err}"); + return; + } + }; + + let pipeline: gst::Pipeline = match element.downcast::() { + Ok(v) => v, + Err(_) => { + eprintln!("parsed element is not a gst::Pipeline"); + return; + } + }; + + match pipeline.set_state(gst::State::Playing) { + Ok(_) => { + println!("pipeline started"); + self.set_started(true); + } + Err(err) => { + eprintln!("failed to set pipeline to Playing: {err:?}"); + } + } + } + + pub fn stop_preview(self: Pin<&mut Self>) { + if !*self.started() { + println!("preview already stopped"); + return; + } + + self.set_started(false); + println!("pipeline stop requested"); + } +} diff --git a/poc005/src/main.rs b/poc005/src/main.rs new file mode 100644 index 0000000..4dccc95 --- /dev/null +++ b/poc005/src/main.rs @@ -0,0 +1,51 @@ +// file: poc-qt/poc005/src/main.rs + +mod bridge; + +use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QString, QUrl}; +use std::env; +use std::path::PathBuf; + +fn main() { + 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}"); + 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}"); + 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()); + return; + } + }; + + println!("loading qml from: {}", qml_path_str); + + if let Some(engine_ref) = engine.as_mut() { + let url = QUrl::from_local_file(&QString::from(qml_path_str)); + engine_ref.load(&url); + } else { + eprintln!("failed to create QQmlApplicationEngine"); + return; + } + + if let Some(app_ref) = app.as_mut() { + app_ref.exec(); + } +}