diff --git a/Cargo.toml b/Cargo.toml index f78bc3c..0cd39d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,11 +5,12 @@ members = [ "poc001", "poc002", "poc003", + "poc004", ] resolver = "3" [workspace.package] -version = "0.3.0" +version = "0.4.0" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/poc-qt" diff --git a/poc002/build.rs b/poc002/build.rs index bad4202..c745197 100644 --- a/poc002/build.rs +++ b/poc002/build.rs @@ -269,7 +269,6 @@ 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"); diff --git a/poc003/build.rs b/poc003/build.rs index 88a7df6..8e2001c 100644 --- a/poc003/build.rs +++ b/poc003/build.rs @@ -269,7 +269,6 @@ 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"); diff --git a/poc004/Cargo.toml b/poc004/Cargo.toml new file mode 100644 index 0000000..c17670d --- /dev/null +++ b/poc004/Cargo.toml @@ -0,0 +1,20 @@ +# file: poc-qt/poc004/Cargo.toml + +[package] +name = "poc-qt004" +version = "0.4.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"] } + diff --git a/poc004/build.rs b/poc004/build.rs new file mode 100644 index 0000000..faab617 --- /dev/null +++ b/poc004/build.rs @@ -0,0 +1,296 @@ +// file: poc-qt/poc004/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=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.cam02").qml_files(["qml/main.qml"])) + .qt_module("Qml") + .qt_module("Network") + .qt_module("Multimedia") + .build(); + + emit_runtime_hints(profile, &profile_name); +} diff --git a/poc004/build_config.default.json b/poc004/build_config.default.json new file mode 100644 index 0000000..6a31de4 --- /dev/null +++ b/poc004/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/poc004/qml/main.qml b/poc004/qml/main.qml new file mode 100644 index 0000000..2081223 --- /dev/null +++ b/poc004/qml/main.qml @@ -0,0 +1,188 @@ +// file: poc-qt/poc004/qml/main.qml + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtMultimedia + +ApplicationWindow { + visible: true + width: 1100 + height: 760 + title: "POC004 - Webcam + Micro + Capture + Record" + + MediaDevices { + id: mediaDevices + } + + Camera { + id: camera + cameraDevice: mediaDevices.defaultVideoInput + } + + AudioInput { + id: microphone + } + + ImageCapture { + id: imageCapture + onImageSaved: function(requestId, filePath) { + statusLabel.text = "Image saved: " + filePath + } + onErrorOccurred: function(requestId, error, errorString) { + statusLabel.text = "Image error: " + errorString + } + } + + MediaRecorder { + id: recorder + quality: MediaRecorder.VeryHighQuality + onRecorderStateChanged: { + if (recorder.recorderState === MediaRecorder.RecordingState) { + statusLabel.text = "Recording video..." + } else if (recorder.recorderState === MediaRecorder.StoppedState) { + statusLabel.text = "Recorder stopped" + } + } + onErrorOccurred: function(error, errorString) { + statusLabel.text = "Recorder error: " + errorString + } + } + + CaptureSession { + id: captureSession + camera: camera + audioInput: microphone + imageCapture: imageCapture + recorder: recorder + videoOutput: videoOutput + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "black" + border.width: 1 + border.color: "#555" + + VideoOutput { + id: videoOutput + anchors.fill: parent + fillMode: VideoOutput.PreserveAspectFit + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Button { + text: camera.active ? "Stop camera" : "Start camera" + onClicked: { + camera.active = !camera.active + if (camera.active) { + statusLabel.text = "Camera started" + } else { + statusLabel.text = "Camera stopped" + } + } + } + + Button { + text: "Take snapshot" + onClicked: { + imageCapture.captureToFile("/home/sinus/Projects/poc-qt/target/captures_imgs/image_" + Date.now() + ".jpg") + } + } + + Button { + text: recorder.recorderState === MediaRecorder.RecordingState ? "Stop recording" : "Start recording" + onClicked: { + if (recorder.recorderState === MediaRecorder.RecordingState) { + recorder.stop() + } else { + recorder.outputLocation = "file:///home/sinus/Projects/poc-qt/target/captures_vids/video_" + Date.now() + ".mp4" + recorder.record() + } + } + } + + Button { + text: "Restart camera" + onClicked: { + camera.stop() + camera.start() + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: mediaDevices.videoInputs.length > 0 + ? ("Camera: " + camera.cameraDevice.description) + : "No camera detected" + Layout.fillWidth: true + wrapMode: Text.WrapAnywhere + } + + Label { + text: "Mic active" + } + } + + Label { + id: statusLabel + Layout.fillWidth: true + text: "Ready" + wrapMode: Text.WrapAnywhere + } + + ListView { + Layout.fillWidth: true + Layout.preferredHeight: 120 + clip: true + model: mediaDevices.videoInputs + + delegate: Rectangle { + width: ListView.view.width + height: 32 + color: camera.cameraDevice.id === modelData.id ? "#334" : "transparent" + + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 8 + text: modelData.description + color: "white" + } + + MouseArea { + anchors.fill: parent + onClicked: { + camera.stop() + camera.cameraDevice = modelData + camera.start() + statusLabel.text = "Switched camera to: " + modelData.description + } + } + } + } + } + + Component.onCompleted: { + if (mediaDevices.videoInputs.length > 0) { + camera.start() + statusLabel.text = "Camera started" + } else { + statusLabel.text = "No camera detected" + } + } +} diff --git a/poc004/src/main.rs b/poc004/src/main.rs new file mode 100644 index 0000000..28790d1 --- /dev/null +++ b/poc004/src/main.rs @@ -0,0 +1,54 @@ +// file: poc-qt/poc004/src/main.rs + +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 main_qml_path: PathBuf = current_dir.join("qml/main.qml"); + let main_qml_path = match main_qml_path.canonicalize() { + Ok(v) => v, + Err(err) => { + eprintln!( + "failed to resolve qml/main.qml from {}: {err}", + main_qml_path.display() + ); + return; + } + }; + + let main_qml_str = match main_qml_path.to_str() { + Some(v) => v.to_string(), + None => { + eprintln!("qml path is not valid UTF-8: {}", main_qml_path.display()); + return; + } + }; + + println!("loading qml from: {}", main_qml_str); + + if let Some(engine_ref) = engine.as_mut() { + let qml_url = QUrl::from_local_file(&QString::from(main_qml_str)); + engine_ref.load(&qml_url); + } else { + eprintln!("failed to create QQmlApplicationEngine"); + return; + } + + if let Some(app_ref) = app.as_mut() { + app_ref.exec(); + } else { + eprintln!("failed to create QGuiApplication"); + } +}