add plugin camera desktop
This commit is contained in:
54
plugins/camera_desktop/linux/CMakeLists.txt
Normal file
54
plugins/camera_desktop/linux/CMakeLists.txt
Normal file
@@ -0,0 +1,54 @@
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
set(PROJECT_NAME "camera_desktop")
|
||||
project(${PROJECT_NAME} LANGUAGES CXX)
|
||||
|
||||
set(PLUGIN_NAME "camera_desktop_plugin")
|
||||
|
||||
list(APPEND PLUGIN_SOURCES
|
||||
"camera_desktop_plugin.cc"
|
||||
"camera_texture.cc"
|
||||
"camera.cc"
|
||||
"device_enumerator.cc"
|
||||
"photo_handler.cc"
|
||||
"record_handler.cc"
|
||||
"image_stream_ffi.cc"
|
||||
"pipewire_portal.cc"
|
||||
)
|
||||
|
||||
add_library(${PLUGIN_NAME} SHARED
|
||||
${PLUGIN_SOURCES}
|
||||
)
|
||||
|
||||
apply_standard_settings(${PLUGIN_NAME})
|
||||
|
||||
set_target_properties(${PLUGIN_NAME} PROPERTIES
|
||||
CXX_VISIBILITY_PRESET hidden)
|
||||
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
|
||||
|
||||
target_include_directories(${PLUGIN_NAME} INTERFACE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||
target_include_directories(${PLUGIN_NAME} PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
|
||||
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter)
|
||||
target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK)
|
||||
|
||||
# GStreamer dependencies
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GSTREAMER REQUIRED
|
||||
gstreamer-1.0
|
||||
gstreamer-app-1.0
|
||||
gstreamer-video-1.0
|
||||
)
|
||||
target_include_directories(${PLUGIN_NAME} PRIVATE ${GSTREAMER_INCLUDE_DIRS})
|
||||
target_link_libraries(${PLUGIN_NAME} PRIVATE ${GSTREAMER_LIBRARIES})
|
||||
|
||||
set(camera_desktop_bundled_libraries
|
||||
""
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
if(include_camera_desktop_tests)
|
||||
add_subdirectory(test)
|
||||
endif()
|
||||
771
plugins/camera_desktop/linux/camera.cc
Normal file
771
plugins/camera_desktop/linux/camera.cc
Normal file
@@ -0,0 +1,771 @@
|
||||
#include "camera.h"
|
||||
#include "photo_handler.h"
|
||||
|
||||
#include <gio/gio.h>
|
||||
#include <gst/video/video.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
static const guint kInitTimeoutMs = 8000;
|
||||
|
||||
Camera::Camera(int camera_id,
|
||||
FlTextureRegistrar* texture_registrar,
|
||||
FlMethodChannel* method_channel,
|
||||
const CameraConfig& config)
|
||||
: camera_id_(camera_id),
|
||||
texture_id_(-1),
|
||||
state_(CameraState::kCreated),
|
||||
config_(config),
|
||||
texture_registrar_(texture_registrar),
|
||||
method_channel_(method_channel),
|
||||
texture_(nullptr),
|
||||
pipeline_(nullptr),
|
||||
tee_(nullptr),
|
||||
appsink_(nullptr),
|
||||
videoflip_(nullptr),
|
||||
bus_watch_id_(0),
|
||||
init_timeout_id_(0),
|
||||
record_handler_(std::make_unique<RecordHandler>()),
|
||||
pending_init_call_(nullptr),
|
||||
first_frame_received_(false),
|
||||
preview_paused_(false),
|
||||
image_streaming_(false),
|
||||
image_stream_callback_(nullptr),
|
||||
actual_width_(0),
|
||||
actual_height_(0) {}
|
||||
|
||||
Camera::~Camera() {
|
||||
Dispose();
|
||||
}
|
||||
|
||||
int64_t Camera::RegisterTexture() {
|
||||
texture_ = camera_texture_new();
|
||||
FlTexture* fl_tex = camera_texture_as_fl_texture(texture_);
|
||||
if (!fl_texture_registrar_register_texture(texture_registrar_, fl_tex)) {
|
||||
g_info("[camera_desktop] Camera %d: failed to register Flutter texture",
|
||||
camera_id_);
|
||||
g_object_unref(texture_);
|
||||
texture_ = nullptr;
|
||||
return -1;
|
||||
}
|
||||
texture_id_ = fl_texture_get_id(fl_tex);
|
||||
return texture_id_;
|
||||
}
|
||||
|
||||
void Camera::Initialize(FlMethodCall* method_call) {
|
||||
if (state_.load() != CameraState::kCreated) {
|
||||
g_info("[camera_desktop] Camera %d: Initialize called in unexpected state %d",
|
||||
camera_id_, static_cast<int>(state_.load()));
|
||||
g_autoptr(FlValue) error_details = fl_value_new_null();
|
||||
fl_method_call_respond_error(method_call, "already_initialized",
|
||||
"Camera is already initialized or disposed",
|
||||
error_details, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
state_.store(CameraState::kInitializing);
|
||||
pending_init_call_ = FL_METHOD_CALL(g_object_ref(method_call));
|
||||
first_frame_received_.store(false);
|
||||
|
||||
g_info("[camera_desktop] Initializing camera %d (%s backend, %dx%d@%dfps)",
|
||||
camera_id_,
|
||||
config_.backend == CameraBackend::kPipeWire ? "PipeWire" : "V4L2",
|
||||
config_.target_width, config_.target_height, config_.target_fps);
|
||||
|
||||
GError* error = nullptr;
|
||||
if (!BuildPipeline(&error)) {
|
||||
g_info("[camera_desktop] Camera %d: BuildPipeline failed: %s", camera_id_,
|
||||
error ? error->message : "unknown error");
|
||||
RespondToPendingInit(false, error->message);
|
||||
g_error_free(error);
|
||||
state_.store(CameraState::kCreated);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set pipeline to PLAYING.
|
||||
GstStateChangeReturn ret =
|
||||
gst_element_set_state(pipeline_, GST_STATE_PLAYING);
|
||||
if (ret == GST_STATE_CHANGE_FAILURE) {
|
||||
g_info("[camera_desktop] Camera %d: gst_element_set_state(PLAYING) failed",
|
||||
camera_id_);
|
||||
RespondToPendingInit(false, "Failed to start GStreamer pipeline");
|
||||
gst_object_unref(pipeline_);
|
||||
pipeline_ = nullptr;
|
||||
appsink_ = nullptr;
|
||||
state_.store(CameraState::kCreated);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set a timeout for initialization, if no frame arrives in time, fail.
|
||||
init_timeout_id_ =
|
||||
g_timeout_add(kInitTimeoutMs, Camera::OnInitTimeout, this);
|
||||
}
|
||||
|
||||
bool Camera::BuildPipeline(GError** error) {
|
||||
// Build pipeline with a tee to support branching for recording:
|
||||
// [source] ! videoconvert ! videoscale ! videorate ! caps ! tee name=t
|
||||
// t. ! queue ! appsink (preview)
|
||||
// t. ! [recording branch, added later by RecordHandler]
|
||||
//
|
||||
// videoscale/videorate let v4l2src negotiate a native mode (e.g. MJPEG or a
|
||||
// lower YUYV resolution) instead of failing not-negotiated when the device
|
||||
// cannot output the target size/fps in uncompressed YUV (common on USB cams).
|
||||
const int w = config_.target_width;
|
||||
const int h = config_.target_height;
|
||||
const int fps = config_.target_fps;
|
||||
|
||||
gchar preview_tail[512];
|
||||
g_snprintf(
|
||||
preview_tail, sizeof(preview_tail),
|
||||
"! videoflip name=flip method=horizontal-flip "
|
||||
"! videoscale ! videorate "
|
||||
"! video/x-raw,format=RGBA,width=%d,height=%d,framerate=%d/1 "
|
||||
"! tee name=t "
|
||||
"t. ! queue name=preview_queue ! "
|
||||
"appsink name=sink emit-signals=true max-buffers=2 drop=true "
|
||||
"sync=false",
|
||||
w, h, fps);
|
||||
|
||||
gchar* pipeline_str = nullptr;
|
||||
|
||||
if (config_.backend == CameraBackend::kPipeWire) {
|
||||
// PipeWire portal path: use pipewiresrc with the portal-provided fd.
|
||||
std::string pw_node_id = config_.device_path.substr(3);
|
||||
pipeline_str = g_strdup_printf(
|
||||
"pipewiresrc fd=%d path=%s do-timestamp=true "
|
||||
"! videoconvert "
|
||||
"%s",
|
||||
config_.pw_fd, pw_node_id.c_str(), preview_tail);
|
||||
} else {
|
||||
// V4L2: prefer MJPEG at the target size when the device actually supports
|
||||
// it (native 720p/1080p on many USB cameras, and the only way some can
|
||||
// deliver high resolutions within USB 2.0 bandwidth). The choice must be
|
||||
// probed up front: gst_parse_launch() succeeds even for an MJPEG pipeline
|
||||
// the camera cannot satisfy, so a non-MJPEG camera would otherwise fail at
|
||||
// PLAYING with a runtime "not-negotiated" error instead of falling back.
|
||||
// Only width/height are pinned on the MJPEG caps; the native frame rate is
|
||||
// left to float and the downstream videorate adapts it to the target fps,
|
||||
// so requesting an fps the camera does not offer natively in MJPEG does not
|
||||
// break negotiation. Cameras exposing only raw formats (e.g. NV12/YUYV) use
|
||||
// the unconstrained capture + scale path, which negotiates any native mode
|
||||
// and adapts it to the target via videoscale/videorate.
|
||||
if (DeviceEnumerator::SupportsMjpeg(config_.device_path, w, h)) {
|
||||
pipeline_str = g_strdup_printf(
|
||||
"v4l2src device=%s "
|
||||
"! image/jpeg,width=%d,height=%d "
|
||||
"! jpegdec ! videoconvert "
|
||||
"%s",
|
||||
config_.device_path.c_str(), w, h, preview_tail);
|
||||
} else {
|
||||
pipeline_str = g_strdup_printf("v4l2src device=%s "
|
||||
"! videoconvert "
|
||||
"%s",
|
||||
config_.device_path.c_str(), preview_tail);
|
||||
}
|
||||
}
|
||||
|
||||
if (pipeline_str) {
|
||||
g_info("[camera_desktop] Pipeline: %s", pipeline_str);
|
||||
pipeline_ = gst_parse_launch(pipeline_str, error);
|
||||
g_free(pipeline_str);
|
||||
}
|
||||
|
||||
if (!pipeline_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the tee element (needed for recording branch attachment).
|
||||
tee_ = gst_bin_get_by_name(GST_BIN(pipeline_), "t");
|
||||
if (!tee_) {
|
||||
g_info("[camera_desktop] Camera %d: tee element not found in pipeline",
|
||||
camera_id_);
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Failed to find tee in pipeline");
|
||||
gst_object_unref(pipeline_);
|
||||
pipeline_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
// Release our ref (pipeline holds one).
|
||||
gst_object_unref(tee_);
|
||||
|
||||
// Get the videoflip element for runtime mirror toggling.
|
||||
videoflip_ = gst_bin_get_by_name(GST_BIN(pipeline_), "flip");
|
||||
if (videoflip_) {
|
||||
gst_object_unref(videoflip_); // Pipeline holds the ref.
|
||||
} else {
|
||||
g_info("[camera_desktop] Camera %d: videoflip element not found in pipeline"
|
||||
" (mirror toggling will be unavailable)", camera_id_);
|
||||
}
|
||||
|
||||
// Get the appsink element.
|
||||
appsink_ = gst_bin_get_by_name(GST_BIN(pipeline_), "sink");
|
||||
if (!appsink_) {
|
||||
g_info("[camera_desktop] Camera %d: appsink element not found in pipeline",
|
||||
camera_id_);
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Failed to find appsink in pipeline");
|
||||
gst_object_unref(pipeline_);
|
||||
pipeline_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Connect the new-sample signal.
|
||||
GstAppSinkCallbacks callbacks = {};
|
||||
callbacks.new_sample = Camera::OnNewSample;
|
||||
gst_app_sink_set_callbacks(GST_APP_SINK(appsink_), &callbacks, this,
|
||||
nullptr);
|
||||
|
||||
// Set up bus watch for error messages.
|
||||
GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline_));
|
||||
bus_watch_id_ = gst_bus_add_watch(bus, Camera::OnBusMessage, this);
|
||||
gst_object_unref(bus);
|
||||
|
||||
// Release our ref on the appsink (pipeline holds one).
|
||||
gst_object_unref(appsink_);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void Camera::RespondToPendingInit(bool success, const char* error_message) {
|
||||
if (!pending_init_call_) return;
|
||||
|
||||
// Cancel the timeout.
|
||||
if (init_timeout_id_ > 0) {
|
||||
g_source_remove(init_timeout_id_);
|
||||
init_timeout_id_ = 0;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
g_autoptr(FlValue) result = fl_value_new_map();
|
||||
fl_value_set_string_take(result, "previewWidth",
|
||||
fl_value_new_float((double)actual_width_.load()));
|
||||
fl_value_set_string_take(result, "previewHeight",
|
||||
fl_value_new_float((double)actual_height_.load()));
|
||||
fl_method_call_respond_success(pending_init_call_, result, nullptr);
|
||||
} else {
|
||||
g_autoptr(FlValue) details = fl_value_new_null();
|
||||
fl_method_call_respond_error(pending_init_call_, "initialization_failed",
|
||||
error_message ? error_message : "Unknown error",
|
||||
details, nullptr);
|
||||
}
|
||||
|
||||
g_object_unref(pending_init_call_);
|
||||
pending_init_call_ = nullptr;
|
||||
}
|
||||
|
||||
GstFlowReturn Camera::OnNewSample(GstAppSink* sink, gpointer user_data) {
|
||||
Camera* self = static_cast<Camera*>(user_data);
|
||||
|
||||
GstSample* sample = gst_app_sink_pull_sample(sink);
|
||||
if (!sample) {
|
||||
g_warning("[camera_desktop] OnNewSample: gst_app_sink_pull_sample returned null");
|
||||
return GST_FLOW_ERROR;
|
||||
}
|
||||
|
||||
GstBuffer* buffer = gst_sample_get_buffer(sample);
|
||||
GstCaps* caps = gst_sample_get_caps(sample);
|
||||
|
||||
GstVideoInfo info;
|
||||
if (!gst_video_info_from_caps(&info, caps)) {
|
||||
g_warning("[camera_desktop] OnNewSample: gst_video_info_from_caps failed");
|
||||
gst_sample_unref(sample);
|
||||
return GST_FLOW_ERROR;
|
||||
}
|
||||
|
||||
int width = GST_VIDEO_INFO_WIDTH(&info);
|
||||
int height = GST_VIDEO_INFO_HEIGHT(&info);
|
||||
int stride = GST_VIDEO_INFO_PLANE_STRIDE(&info, 0);
|
||||
|
||||
GstMapInfo map;
|
||||
if (!gst_buffer_map(buffer, &map, GST_MAP_READ)) {
|
||||
g_warning("[camera_desktop] OnNewSample: gst_buffer_map failed");
|
||||
gst_sample_unref(sample);
|
||||
return GST_FLOW_ERROR;
|
||||
}
|
||||
|
||||
// Handle first-frame initialization response.
|
||||
// C-2: first_frame_received_ is atomic, safe cross-thread read/write.
|
||||
bool is_first_frame = !self->first_frame_received_.load();
|
||||
if (is_first_frame) {
|
||||
g_info("[camera_desktop] Camera %d: first frame received (%dx%d)",
|
||||
self->camera_id_, width, height);
|
||||
self->first_frame_received_.store(true);
|
||||
// H-2: actual_width_/height_ are atomic, safe cross-thread write.
|
||||
self->actual_width_.store(width);
|
||||
self->actual_height_.store(height);
|
||||
self->state_.store(CameraState::kRunning);
|
||||
}
|
||||
|
||||
// Update the texture only if preview is not paused (or if this is the first
|
||||
// frame, which we need for initialization).
|
||||
// C-3: preview_paused_ is atomic, safe cross-thread read.
|
||||
if (!self->preview_paused_.load() || is_first_frame) {
|
||||
if (stride == width * 4) {
|
||||
// No padding, direct copy.
|
||||
camera_texture_update(self->texture_, map.data, width, height);
|
||||
} else {
|
||||
// Stride has padding, copy row-by-row into a tight buffer.
|
||||
// M-1 note: this intermediate allocation is unavoidable here since
|
||||
// camera_texture_update requires a tightly-packed buffer.
|
||||
size_t tight_size = (size_t)width * height * 4;
|
||||
uint8_t* tight = (uint8_t*)g_malloc(tight_size);
|
||||
for (int row = 0; row < height; row++) {
|
||||
memcpy(tight + row * width * 4, map.data + row * stride, width * 4);
|
||||
}
|
||||
camera_texture_update(self->texture_, tight, width, height);
|
||||
g_free(tight);
|
||||
}
|
||||
|
||||
// Notify Flutter that a new frame is available.
|
||||
fl_texture_registrar_mark_texture_frame_available(
|
||||
self->texture_registrar_,
|
||||
camera_texture_as_fl_texture(self->texture_));
|
||||
}
|
||||
|
||||
// Send frame to Dart image stream if streaming is active.
|
||||
if (self->image_streaming_.load()) {
|
||||
// C-4: load the callback pointer atomically once, then use the local copy.
|
||||
// This prevents a TOCTOU race where the pointer is nulled between the
|
||||
// check and the call.
|
||||
ImageStreamCallback cb = self->image_stream_callback_.load();
|
||||
if (cb) {
|
||||
// FFI path: write to shared buffer, notify Dart directly.
|
||||
size_t frame_size = (size_t)width * height * 4;
|
||||
size_t total_size = offsetof(Camera::ImageStreamBuffer, pixels) + frame_size;
|
||||
|
||||
if (self->image_stream_buffer_size_ < total_size) {
|
||||
g_free(self->image_stream_buffer_);
|
||||
self->image_stream_buffer_ =
|
||||
(Camera::ImageStreamBuffer*)g_malloc(total_size);
|
||||
self->image_stream_buffer_size_ = total_size;
|
||||
}
|
||||
|
||||
auto* buf = self->image_stream_buffer_;
|
||||
buf->ready = 0;
|
||||
|
||||
if (stride == width * 4) {
|
||||
memcpy(buf->pixels, map.data, frame_size);
|
||||
} else {
|
||||
for (int row = 0; row < height; row++) {
|
||||
memcpy(buf->pixels + row * width * 4, map.data + row * stride,
|
||||
width * 4);
|
||||
}
|
||||
}
|
||||
|
||||
buf->width = width;
|
||||
buf->height = height;
|
||||
buf->bytes_per_row = width * 4;
|
||||
buf->format = 1; // RGBA (Linux GStreamer pipeline)
|
||||
buf->sequence = ++self->image_stream_sequence_;
|
||||
|
||||
// C-5: release fence, guarantees all pixel and metadata writes above
|
||||
// are visible to any thread that subsequently observes ready == 1.
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
buf->ready = 1;
|
||||
|
||||
cb(self->camera_id_);
|
||||
} else {
|
||||
// Legacy MethodChannel fallback path.
|
||||
size_t frame_size = (size_t)width * height * 4;
|
||||
uint8_t* frame_copy = (uint8_t*)g_malloc(frame_size);
|
||||
if (stride == width * 4) {
|
||||
memcpy(frame_copy, map.data, frame_size);
|
||||
} else {
|
||||
for (int row = 0; row < height; row++) {
|
||||
memcpy(frame_copy + row * width * 4, map.data + row * stride,
|
||||
width * 4);
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageStreamData {
|
||||
FlMethodChannel* channel;
|
||||
int camera_id;
|
||||
uint8_t* pixels;
|
||||
int width;
|
||||
int height;
|
||||
size_t size;
|
||||
};
|
||||
|
||||
auto* stream_data = new ImageStreamData();
|
||||
stream_data->channel = self->method_channel_;
|
||||
stream_data->camera_id = self->camera_id_;
|
||||
stream_data->pixels = frame_copy;
|
||||
stream_data->width = width;
|
||||
stream_data->height = height;
|
||||
stream_data->size = frame_size;
|
||||
|
||||
g_idle_add(
|
||||
[](gpointer user_data) -> gboolean {
|
||||
auto* data = static_cast<ImageStreamData*>(user_data);
|
||||
|
||||
g_autoptr(FlValue) args = fl_value_new_map();
|
||||
fl_value_set_string_take(args, "cameraId",
|
||||
fl_value_new_int(data->camera_id));
|
||||
fl_value_set_string_take(args, "width",
|
||||
fl_value_new_int(data->width));
|
||||
fl_value_set_string_take(args, "height",
|
||||
fl_value_new_int(data->height));
|
||||
fl_value_set_string_take(
|
||||
args, "bytes",
|
||||
fl_value_new_uint8_list(data->pixels, data->size));
|
||||
|
||||
fl_method_channel_invoke_method(data->channel, "imageStreamFrame",
|
||||
args, nullptr, nullptr, nullptr);
|
||||
|
||||
g_free(data->pixels);
|
||||
delete data;
|
||||
return G_SOURCE_REMOVE;
|
||||
},
|
||||
stream_data);
|
||||
}
|
||||
}
|
||||
|
||||
gst_buffer_unmap(buffer, &map);
|
||||
gst_sample_unref(sample);
|
||||
|
||||
// Dispatch init response to the main thread (OnNewSample runs on the
|
||||
// GStreamer streaming thread, but fl_method_call_respond_* must be called
|
||||
// from the main GLib thread).
|
||||
if (is_first_frame) {
|
||||
g_idle_add(
|
||||
[](gpointer user_data) -> gboolean {
|
||||
Camera* cam = static_cast<Camera*>(user_data);
|
||||
cam->RespondToPendingInit(true, nullptr);
|
||||
return G_SOURCE_REMOVE;
|
||||
},
|
||||
self);
|
||||
}
|
||||
|
||||
return GST_FLOW_OK;
|
||||
}
|
||||
|
||||
gboolean Camera::OnBusMessage(GstBus* bus, GstMessage* msg,
|
||||
gpointer user_data) {
|
||||
Camera* self = static_cast<Camera*>(user_data);
|
||||
|
||||
switch (GST_MESSAGE_TYPE(msg)) {
|
||||
case GST_MESSAGE_ERROR: {
|
||||
GError* err = nullptr;
|
||||
gchar* debug = nullptr;
|
||||
gst_message_parse_error(msg, &err, &debug);
|
||||
|
||||
g_info("[camera_desktop] Camera %d: GStreamer error: %s (debug: %s)",
|
||||
self->camera_id_,
|
||||
err ? err->message : "unknown",
|
||||
debug ? debug : "none");
|
||||
|
||||
// C-2: load state_ atomically.
|
||||
CameraState s = self->state_.load();
|
||||
if (s == CameraState::kInitializing) {
|
||||
self->RespondToPendingInit(false, err->message);
|
||||
self->state_.store(CameraState::kCreated);
|
||||
} else if (s == CameraState::kRunning || s == CameraState::kPaused) {
|
||||
self->SendError(err->message);
|
||||
}
|
||||
|
||||
g_error_free(err);
|
||||
g_free(debug);
|
||||
break;
|
||||
}
|
||||
case GST_MESSAGE_EOS: {
|
||||
// End of stream (e.g., device unplugged).
|
||||
CameraState s = self->state_.load();
|
||||
if (s == CameraState::kRunning || s == CameraState::kPaused) {
|
||||
self->SendError("Camera stream ended unexpectedly");
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean Camera::OnInitTimeout(gpointer user_data) {
|
||||
Camera* self = static_cast<Camera*>(user_data);
|
||||
self->init_timeout_id_ = 0;
|
||||
|
||||
if (self->state_.load() == CameraState::kInitializing) {
|
||||
self->RespondToPendingInit(
|
||||
false, "Camera initialization timed out, no frames received");
|
||||
if (self->pipeline_) {
|
||||
gst_element_set_state(self->pipeline_, GST_STATE_NULL);
|
||||
}
|
||||
self->state_.store(CameraState::kCreated);
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
void Camera::SendError(const std::string& description) {
|
||||
g_autoptr(FlValue) args = fl_value_new_map();
|
||||
fl_value_set_string_take(args, "cameraId",
|
||||
fl_value_new_int(camera_id_));
|
||||
fl_value_set_string_take(args, "description",
|
||||
fl_value_new_string(description.c_str()));
|
||||
fl_method_channel_invoke_method(method_channel_, "cameraError", args,
|
||||
nullptr, nullptr, nullptr);
|
||||
}
|
||||
|
||||
void Camera::TakePicture(FlMethodCall* method_call) {
|
||||
CameraState s = state_.load();
|
||||
if (s != CameraState::kRunning && s != CameraState::kPaused) {
|
||||
g_info("[camera_desktop] Camera %d: TakePicture called but camera is not"
|
||||
" running (state=%d)", camera_id_, static_cast<int>(s));
|
||||
g_autoptr(FlValue) details = fl_value_new_null();
|
||||
fl_method_call_respond_error(method_call, "not_running",
|
||||
"Camera is not running", details, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a unique temporary file path using an atomic sequence counter
|
||||
// rather than the wall clock to prevent collisions under NTP corrections.
|
||||
static std::atomic<int64_t> capture_seq{0};
|
||||
gchar* tmp_path =
|
||||
g_strdup_printf("%s/camera_desktop_%d_%" G_GINT64_FORMAT ".jpg",
|
||||
g_get_tmp_dir(), camera_id_,
|
||||
capture_seq.fetch_add(1, std::memory_order_relaxed));
|
||||
|
||||
// C-7: gst_video_convert_sample performs synchronous JPEG encoding which
|
||||
// can take 30-200 ms at 1080p. Offload to a GLib thread-pool task so the
|
||||
// main/UI thread is never blocked.
|
||||
//
|
||||
// Take a GStreamer reference to appsink_ so it stays alive for the duration
|
||||
// of the task even if Dispose() is called concurrently.
|
||||
struct TakePictureData {
|
||||
GstElement* appsink; // holds a gst_object_ref
|
||||
std::string output_path;
|
||||
std::string error_message;
|
||||
bool success;
|
||||
FlMethodCall* method_call; // holds a g_object_ref
|
||||
};
|
||||
|
||||
auto* d = new TakePictureData();
|
||||
d->appsink = GST_ELEMENT(gst_object_ref(appsink_));
|
||||
d->output_path = tmp_path;
|
||||
d->success = false;
|
||||
d->method_call = FL_METHOD_CALL(g_object_ref(method_call));
|
||||
g_free(tmp_path);
|
||||
|
||||
GTask* task = g_task_new(nullptr, nullptr, nullptr, nullptr);
|
||||
g_task_set_task_data(task, d, nullptr);
|
||||
g_task_run_in_thread(
|
||||
task,
|
||||
[](GTask* /*task*/, gpointer /*source*/, gpointer task_data,
|
||||
GCancellable* /*cancel*/) {
|
||||
auto* d = static_cast<TakePictureData*>(task_data);
|
||||
GError* err = nullptr;
|
||||
d->success = PhotoHandler::TakePicture(d->appsink, d->output_path, &err);
|
||||
gst_object_unref(d->appsink);
|
||||
d->appsink = nullptr;
|
||||
if (!d->success && err) {
|
||||
d->error_message = err->message;
|
||||
g_error_free(err);
|
||||
}
|
||||
// Marshal the method-channel response back to the main GLib thread.
|
||||
g_idle_add(
|
||||
[](gpointer p) -> gboolean {
|
||||
auto* d = static_cast<TakePictureData*>(p);
|
||||
if (d->success) {
|
||||
g_autoptr(FlValue) result =
|
||||
fl_value_new_string(d->output_path.c_str());
|
||||
fl_method_call_respond_success(d->method_call, result, nullptr);
|
||||
} else {
|
||||
g_autoptr(FlValue) details = fl_value_new_null();
|
||||
fl_method_call_respond_error(
|
||||
d->method_call, "capture_failed",
|
||||
d->error_message.empty() ? "Failed to capture image"
|
||||
: d->error_message.c_str(),
|
||||
details, nullptr);
|
||||
}
|
||||
g_object_unref(d->method_call);
|
||||
delete d;
|
||||
return G_SOURCE_REMOVE;
|
||||
},
|
||||
d);
|
||||
});
|
||||
g_object_unref(task);
|
||||
}
|
||||
|
||||
void Camera::StartVideoRecording(FlMethodCall* method_call) {
|
||||
CameraState s = state_.load();
|
||||
if (s != CameraState::kRunning && s != CameraState::kPaused) {
|
||||
g_info("[camera_desktop] Camera %d: StartVideoRecording called but camera"
|
||||
" is not running (state=%d)", camera_id_, static_cast<int>(s));
|
||||
g_autoptr(FlValue) details = fl_value_new_null();
|
||||
fl_method_call_respond_error(method_call, "not_running",
|
||||
"Camera is not running", details, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up the recording branch on first use.
|
||||
if (!record_handler_->is_recording()) {
|
||||
GError* error = nullptr;
|
||||
// H-2: load actual dimensions atomically, they are written from the
|
||||
// GStreamer streaming thread on first frame.
|
||||
if (!record_handler_->Setup(pipeline_, tee_,
|
||||
actual_width_.load(), actual_height_.load(),
|
||||
config_.target_fps,
|
||||
config_.target_bitrate,
|
||||
config_.audio_bitrate,
|
||||
config_.enable_audio, &error)) {
|
||||
g_info("[camera_desktop] Camera %d: RecordHandler::Setup failed: %s",
|
||||
camera_id_, error ? error->message : "unknown error");
|
||||
g_autoptr(FlValue) details = fl_value_new_null();
|
||||
fl_method_call_respond_error(
|
||||
method_call, "recording_setup_failed",
|
||||
error ? error->message : "Failed to set up recording", details,
|
||||
nullptr);
|
||||
if (error) g_error_free(error);
|
||||
return;
|
||||
}
|
||||
if (config_.enable_audio && !record_handler_->has_audio()) {
|
||||
SendError("Audio recording was requested but audio setup failed. "
|
||||
"Recording will continue without audio.");
|
||||
}
|
||||
}
|
||||
|
||||
// H-6: derive extension from the muxer that was actually selected so the
|
||||
// file's extension always matches its container format.
|
||||
static std::atomic<int64_t> rec_seq{0};
|
||||
gchar* tmp_path = g_strdup_printf(
|
||||
"%s/camera_desktop_%d_%" G_GINT64_FORMAT ".%s",
|
||||
g_get_tmp_dir(), camera_id_,
|
||||
rec_seq.fetch_add(1, std::memory_order_relaxed),
|
||||
record_handler_->output_extension());
|
||||
|
||||
GError* error = nullptr;
|
||||
if (!record_handler_->StartRecording(tmp_path, &error)) {
|
||||
g_autoptr(FlValue) details = fl_value_new_null();
|
||||
fl_method_call_respond_error(
|
||||
method_call, "recording_start_failed",
|
||||
error ? error->message : "Failed to start recording", details,
|
||||
nullptr);
|
||||
if (error) g_error_free(error);
|
||||
g_free(tmp_path);
|
||||
return;
|
||||
}
|
||||
|
||||
g_free(tmp_path);
|
||||
fl_method_call_respond_success(method_call, fl_value_new_null(), nullptr);
|
||||
}
|
||||
|
||||
void Camera::StopVideoRecording(FlMethodCall* method_call) {
|
||||
if (!record_handler_->is_recording()) {
|
||||
g_info("[camera_desktop] Camera %d: StopVideoRecording called but not"
|
||||
" recording", camera_id_);
|
||||
g_autoptr(FlValue) details = fl_value_new_null();
|
||||
fl_method_call_respond_error(method_call, "not_recording",
|
||||
"No recording in progress", details, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
record_handler_->StopRecording(method_call);
|
||||
}
|
||||
|
||||
void Camera::StartImageStream() {
|
||||
g_info("[camera_desktop] Camera %d: starting image stream", camera_id_);
|
||||
image_streaming_ = true;
|
||||
}
|
||||
|
||||
void Camera::StopImageStream() {
|
||||
g_info("[camera_desktop] Camera %d: stopping image stream", camera_id_);
|
||||
image_streaming_ = false;
|
||||
}
|
||||
|
||||
void Camera::RegisterImageStreamCallback(void (*callback)(int32_t)) {
|
||||
// C-4: atomic store, safe to write from main thread while GStreamer thread
|
||||
// reads. The GStreamer thread loads the pointer once per frame (see
|
||||
// OnNewSample) so it cannot race between check and call.
|
||||
image_stream_callback_.store(callback);
|
||||
}
|
||||
|
||||
void Camera::UnregisterImageStreamCallback() {
|
||||
image_stream_callback_.store(nullptr);
|
||||
}
|
||||
|
||||
void Camera::PausePreview() {
|
||||
g_info("[camera_desktop] Camera %d: pausing preview", camera_id_);
|
||||
// C-3: atomic store, safe cross-thread write.
|
||||
preview_paused_.store(true);
|
||||
}
|
||||
|
||||
void Camera::ResumePreview() {
|
||||
g_info("[camera_desktop] Camera %d: resuming preview", camera_id_);
|
||||
preview_paused_.store(false);
|
||||
}
|
||||
|
||||
void Camera::SetMirror(bool mirrored) {
|
||||
if (!videoflip_) {
|
||||
g_info("[camera_desktop] Camera %d: SetMirror(%s) ignored, videoflip_ is null",
|
||||
camera_id_, mirrored ? "true" : "false");
|
||||
return;
|
||||
}
|
||||
g_info("[camera_desktop] Camera %d: SetMirror(%s)", camera_id_,
|
||||
mirrored ? "true" : "false");
|
||||
// GstVideoFlipMethod: 0 = none (identity), 4 = horizontal-flip
|
||||
g_object_set(videoflip_, "method", mirrored ? 4 : 0, nullptr);
|
||||
}
|
||||
|
||||
void Camera::Dispose() {
|
||||
g_info("[camera_desktop] Camera %d: disposing", camera_id_);
|
||||
// C-2: use atomic exchange so the check-and-set is race-free. If two threads
|
||||
// somehow call Dispose() concurrently, only one proceeds.
|
||||
CameraState prev = state_.exchange(CameraState::kDisposing);
|
||||
if (prev == CameraState::kDisposed || prev == CameraState::kDisposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel pending init if still waiting (main thread → main thread, safe).
|
||||
if (pending_init_call_) {
|
||||
RespondToPendingInit(false, "Camera disposed during initialization");
|
||||
}
|
||||
|
||||
// C-4: null the callback atomically BEFORE stopping the pipeline. This
|
||||
// prevents new FFI callbacks from being registered while we're tearing down,
|
||||
// but does NOT free the buffer yet, that must wait until the pipeline stops.
|
||||
image_stream_callback_.store(nullptr);
|
||||
|
||||
// C-1 FIX: stop the pipeline BEFORE freeing image_stream_buffer_.
|
||||
// gst_element_set_state(NULL) blocks until the GStreamer streaming thread
|
||||
// (which runs OnNewSample and accesses image_stream_buffer_) is fully
|
||||
// stopped. Freeing before this point was a use-after-free.
|
||||
if (pipeline_) {
|
||||
videoflip_ = nullptr;
|
||||
gst_element_set_state(pipeline_, GST_STATE_NULL);
|
||||
if (bus_watch_id_ > 0) {
|
||||
g_source_remove(bus_watch_id_);
|
||||
bus_watch_id_ = 0;
|
||||
}
|
||||
gst_object_unref(pipeline_);
|
||||
pipeline_ = nullptr;
|
||||
appsink_ = nullptr;
|
||||
}
|
||||
|
||||
// Now safe: the GStreamer streaming thread is guaranteed to have exited
|
||||
// OnNewSample and will never access image_stream_buffer_ again.
|
||||
if (image_stream_buffer_) {
|
||||
g_free(image_stream_buffer_);
|
||||
image_stream_buffer_ = nullptr;
|
||||
image_stream_buffer_size_ = 0;
|
||||
}
|
||||
|
||||
// Unregister the texture.
|
||||
if (texture_ && texture_registrar_) {
|
||||
fl_texture_registrar_unregister_texture(
|
||||
texture_registrar_, camera_texture_as_fl_texture(texture_));
|
||||
g_object_unref(texture_);
|
||||
texture_ = nullptr;
|
||||
}
|
||||
|
||||
// Send closing event to Dart.
|
||||
g_autoptr(FlValue) args = fl_value_new_map();
|
||||
fl_value_set_string_take(args, "cameraId",
|
||||
fl_value_new_int(camera_id_));
|
||||
fl_method_channel_invoke_method(method_channel_, "cameraClosing", args,
|
||||
nullptr, nullptr, nullptr);
|
||||
|
||||
state_.store(CameraState::kDisposed);
|
||||
g_info("[camera_desktop] Camera %d: disposed", camera_id_);
|
||||
}
|
||||
172
plugins/camera_desktop/linux/camera.h
Normal file
172
plugins/camera_desktop/linux/camera.h
Normal file
@@ -0,0 +1,172 @@
|
||||
#ifndef CAMERA_H_
|
||||
#define CAMERA_H_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#include <gst/gst.h>
|
||||
#include <gst/app/gstappsink.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "camera_texture.h"
|
||||
#include "device_enumerator.h"
|
||||
#include "record_handler.h"
|
||||
|
||||
enum class CameraBackend {
|
||||
kV4L2, // Traditional /dev/video* + v4l2src
|
||||
kPipeWire, // Portal-authorized pipewiresrc
|
||||
};
|
||||
|
||||
enum class CameraState {
|
||||
kCreated,
|
||||
kInitializing,
|
||||
kRunning,
|
||||
kPaused,
|
||||
kDisposing,
|
||||
kDisposed,
|
||||
};
|
||||
|
||||
// Alias for the image-stream callback function pointer type.
|
||||
using ImageStreamCallback = void (*)(int32_t);
|
||||
|
||||
struct CameraConfig {
|
||||
std::string device_path;
|
||||
int resolution_preset;
|
||||
bool enable_audio;
|
||||
int target_width;
|
||||
int target_height;
|
||||
int target_fps;
|
||||
int target_bitrate;
|
||||
int audio_bitrate = 0;
|
||||
CameraBackend backend = CameraBackend::kV4L2;
|
||||
int pw_fd = -1; // PipeWire remote fd (only used when backend == kPipeWire)
|
||||
};
|
||||
|
||||
class Camera {
|
||||
public:
|
||||
Camera(int camera_id,
|
||||
FlTextureRegistrar* texture_registrar,
|
||||
FlMethodChannel* method_channel,
|
||||
const CameraConfig& config);
|
||||
~Camera();
|
||||
|
||||
int camera_id() const { return camera_id_; }
|
||||
int64_t texture_id() const { return texture_id_; }
|
||||
CameraState state() const { return state_; }
|
||||
|
||||
// Allocates the texture and registers it. Must be called before Initialize.
|
||||
// Returns the texture_id on success, -1 on failure.
|
||||
int64_t RegisterTexture();
|
||||
|
||||
// Builds and starts the GStreamer pipeline. Responds to |method_call|
|
||||
// asynchronously once the first frame arrives or an error/timeout occurs.
|
||||
void Initialize(FlMethodCall* method_call);
|
||||
|
||||
// Captures a still image and saves it to a temporary JPEG file.
|
||||
// Responds to |method_call| with the file path or an error.
|
||||
void TakePicture(FlMethodCall* method_call);
|
||||
|
||||
// Pauses/resumes the live preview.
|
||||
void PausePreview();
|
||||
void ResumePreview();
|
||||
|
||||
// Starts video recording (silent, no audio).
|
||||
void StartVideoRecording(FlMethodCall* method_call);
|
||||
|
||||
// Stops video recording and returns the file path.
|
||||
void StopVideoRecording(FlMethodCall* method_call);
|
||||
|
||||
// Starts/stops sending raw frame data to Dart via method channel.
|
||||
void StartImageStream();
|
||||
void StopImageStream();
|
||||
|
||||
// FFI image stream access.
|
||||
void* GetImageStreamBuffer() const { return image_stream_buffer_; }
|
||||
void RegisterImageStreamCallback(void (*callback)(int32_t));
|
||||
void UnregisterImageStreamCallback();
|
||||
|
||||
// Toggles horizontal mirroring on the live video feed.
|
||||
void SetMirror(bool mirrored);
|
||||
|
||||
// Tears down the pipeline and releases all resources.
|
||||
void Dispose();
|
||||
|
||||
private:
|
||||
bool BuildPipeline(GError** error);
|
||||
void RespondToPendingInit(bool success, const char* error_message);
|
||||
|
||||
// GStreamer callbacks (static with user_data = Camera*).
|
||||
static GstFlowReturn OnNewSample(GstAppSink* sink, gpointer user_data);
|
||||
static gboolean OnBusMessage(GstBus* bus, GstMessage* msg,
|
||||
gpointer user_data);
|
||||
static gboolean OnInitTimeout(gpointer user_data);
|
||||
|
||||
// Sends an error event to Dart via the method channel.
|
||||
void SendError(const std::string& description);
|
||||
|
||||
int camera_id_;
|
||||
int64_t texture_id_;
|
||||
// state_ is written from the GStreamer streaming thread (OnNewSample) and
|
||||
// read/written from the main thread. Must be atomic. (C-2)
|
||||
std::atomic<CameraState> state_;
|
||||
CameraConfig config_;
|
||||
|
||||
FlTextureRegistrar* texture_registrar_; // Not owned.
|
||||
FlMethodChannel* method_channel_; // Not owned.
|
||||
CameraTexture* texture_; // Owned (GObject ref).
|
||||
|
||||
GstElement* pipeline_;
|
||||
GstElement* tee_; // For branching preview + recording.
|
||||
GstElement* appsink_;
|
||||
GstElement* videoflip_; // Named element in pipeline for mirror toggle.
|
||||
guint bus_watch_id_;
|
||||
guint init_timeout_id_;
|
||||
|
||||
std::unique_ptr<RecordHandler> record_handler_;
|
||||
|
||||
// Pending async initialization, stores the FlMethodCall until first frame.
|
||||
// Only accessed from the main thread (set in Initialize, cleared in
|
||||
// RespondToPendingInit which is always dispatched via g_idle_add to main).
|
||||
FlMethodCall* pending_init_call_;
|
||||
|
||||
// Written from the GStreamer streaming thread; read from main thread. (C-2)
|
||||
std::atomic<bool> first_frame_received_;
|
||||
|
||||
// Read from GStreamer streaming thread, written from main thread. (C-3)
|
||||
std::atomic<bool> preview_paused_;
|
||||
|
||||
std::atomic<bool> image_streaming_;
|
||||
|
||||
// FFI image stream shared buffer.
|
||||
// NOTE: The |ready| field acts as a release/acquire flag between the
|
||||
// GStreamer thread (writer) and Dart (reader). The native side MUST issue a
|
||||
// std::atomic_thread_fence(release) before writing ready=1, ensuring all
|
||||
// pixel writes are visible before Dart observes ready==1. (C-5)
|
||||
struct ImageStreamBuffer {
|
||||
int64_t sequence;
|
||||
int32_t width;
|
||||
int32_t height;
|
||||
int32_t bytes_per_row;
|
||||
int32_t format; // 0=BGRA, 1=RGBA
|
||||
int32_t ready; // 1=Dart may read, 0=native writing
|
||||
int32_t _pad;
|
||||
uint8_t pixels[]; // flexible array member
|
||||
};
|
||||
|
||||
ImageStreamBuffer* image_stream_buffer_ = nullptr;
|
||||
size_t image_stream_buffer_size_ = 0;
|
||||
|
||||
// Written from the main thread, read from the GStreamer streaming thread.
|
||||
// Must be atomic to avoid data races and torn reads. (C-4)
|
||||
std::atomic<ImageStreamCallback> image_stream_callback_{nullptr};
|
||||
|
||||
int64_t image_stream_sequence_ = 0;
|
||||
|
||||
// Written from the GStreamer streaming thread on first frame, read from the
|
||||
// main thread in StartVideoRecording. Must be atomic. (H-2)
|
||||
std::atomic<int> actual_width_;
|
||||
std::atomic<int> actual_height_;
|
||||
};
|
||||
|
||||
#endif // CAMERA_H_
|
||||
467
plugins/camera_desktop/linux/camera_desktop_plugin.cc
Normal file
467
plugins/camera_desktop/linux/camera_desktop_plugin.cc
Normal file
@@ -0,0 +1,467 @@
|
||||
#include "include/camera_desktop/camera_desktop_plugin.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#include <gst/gst.h>
|
||||
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
#include "camera.h"
|
||||
#include "device_enumerator.h"
|
||||
#include "pipewire_portal.h"
|
||||
|
||||
int64_t camera_desktop_ffi_register_stream_handle(Camera* camera);
|
||||
void camera_desktop_ffi_release_stream_handle(int64_t stream_handle);
|
||||
void camera_desktop_ffi_release_handles_for_camera(Camera* camera);
|
||||
|
||||
// Plugin data stored as an opaque C++ pointer inside the GObject struct.
|
||||
struct PluginData {
|
||||
std::map<int, std::unique_ptr<Camera>> cameras;
|
||||
int next_camera_id = 1;
|
||||
std::unique_ptr<PipeWirePortal> portal;
|
||||
bool use_pipewire = false;
|
||||
};
|
||||
|
||||
#define CAMERA_DESKTOP_PLUGIN(obj) \
|
||||
(G_TYPE_CHECK_INSTANCE_CAST((obj), camera_desktop_plugin_get_type(), \
|
||||
CameraDesktopPlugin))
|
||||
|
||||
struct _CameraDesktopPlugin {
|
||||
GObject parent_instance;
|
||||
FlMethodChannel* channel;
|
||||
FlTextureRegistrar* texture_registrar;
|
||||
PluginData* data;
|
||||
};
|
||||
|
||||
G_DEFINE_TYPE(CameraDesktopPlugin, camera_desktop_plugin, g_object_get_type())
|
||||
|
||||
// --- Method handlers ---
|
||||
|
||||
// Builds the Flutter response list from a vector of DeviceInfo.
|
||||
static void respond_with_devices(FlMethodCall* method_call,
|
||||
const std::vector<DeviceInfo>& devices) {
|
||||
g_autoptr(FlValue) result = fl_value_new_list();
|
||||
for (const auto& device : devices) {
|
||||
g_autoptr(FlValue) entry = fl_value_new_map();
|
||||
// Format: "Friendly Name (/dev/videoN)" or "Friendly Name (pw:42)"
|
||||
std::string display_name =
|
||||
device.name + " (" + device.device_path + ")";
|
||||
fl_value_set_string_take(entry, "name",
|
||||
fl_value_new_string(display_name.c_str()));
|
||||
fl_value_set_string_take(entry, "lensDirection",
|
||||
fl_value_new_int(device.lens_direction));
|
||||
fl_value_set_string_take(entry, "sensorOrientation",
|
||||
fl_value_new_int(device.sensor_orientation));
|
||||
fl_value_append(result, entry);
|
||||
}
|
||||
fl_method_call_respond_success(method_call, result, nullptr);
|
||||
}
|
||||
|
||||
static void handle_available_cameras(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
if (self->data->use_pipewire && self->data->portal) {
|
||||
// Async path: request portal permission, enumerate PipeWire nodes.
|
||||
g_object_ref(method_call);
|
||||
self->data->portal->EnumerateDevicesAsync(
|
||||
[method_call](std::vector<DeviceInfo> devices) {
|
||||
// If portal returned no devices, fall back to V4L2.
|
||||
if (devices.empty()) {
|
||||
g_info("[camera_desktop] PipeWire returned no cameras, falling back to V4L2");
|
||||
devices = DeviceEnumerator::EnumerateDevices();
|
||||
}
|
||||
g_info("[camera_desktop] availableCameras returning %zu device(s)",
|
||||
devices.size());
|
||||
respond_with_devices(method_call, devices);
|
||||
g_object_unref(method_call);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// V4L2 path (synchronous, for native Linux installs).
|
||||
auto devices = DeviceEnumerator::EnumerateDevices();
|
||||
g_info("[camera_desktop] availableCameras returning %zu device(s)",
|
||||
devices.size());
|
||||
respond_with_devices(method_call, devices);
|
||||
}
|
||||
|
||||
static void handle_get_platform_capabilities(FlMethodCall* method_call) {
|
||||
g_autoptr(FlValue) result = fl_value_new_map();
|
||||
fl_value_set_string_take(result, "supportsMirrorControl",
|
||||
fl_value_new_bool(true));
|
||||
fl_value_set_string_take(result, "supportsVideoFpsControl",
|
||||
fl_value_new_bool(true));
|
||||
fl_value_set_string_take(result, "supportsVideoBitrateControl",
|
||||
fl_value_new_bool(true));
|
||||
fl_method_call_respond_success(method_call, result, nullptr);
|
||||
}
|
||||
|
||||
static void handle_create(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
FlValue* args = fl_method_call_get_args(method_call);
|
||||
const char* camera_name =
|
||||
fl_value_get_string(fl_value_lookup_string(args, "cameraName"));
|
||||
int resolution_preset =
|
||||
fl_value_get_int(fl_value_lookup_string(args, "resolutionPreset"));
|
||||
FlValue* audio_val = fl_value_lookup_string(args, "enableAudio");
|
||||
bool enable_audio = audio_val ? fl_value_get_bool(audio_val) : false;
|
||||
|
||||
int target_fps = 30;
|
||||
FlValue* fps_val = fl_value_lookup_string(args, "fps");
|
||||
if (fps_val && fl_value_get_type(fps_val) == FL_VALUE_TYPE_INT) {
|
||||
target_fps = fl_value_get_int(fps_val);
|
||||
} else if (fps_val && fl_value_get_type(fps_val) == FL_VALUE_TYPE_FLOAT) {
|
||||
target_fps = static_cast<int>(fl_value_get_float(fps_val));
|
||||
}
|
||||
if (target_fps < 5) {
|
||||
g_info("[camera_desktop] fps %d clamped to minimum 5", target_fps);
|
||||
target_fps = 5;
|
||||
}
|
||||
if (target_fps > 60) {
|
||||
g_info("[camera_desktop] fps %d clamped to maximum 60", target_fps);
|
||||
target_fps = 60;
|
||||
}
|
||||
|
||||
int target_bitrate = 0;
|
||||
FlValue* bitrate_val = fl_value_lookup_string(args, "videoBitrate");
|
||||
if (bitrate_val && fl_value_get_type(bitrate_val) == FL_VALUE_TYPE_INT) {
|
||||
target_bitrate = fl_value_get_int(bitrate_val);
|
||||
} else if (bitrate_val &&
|
||||
fl_value_get_type(bitrate_val) == FL_VALUE_TYPE_FLOAT) {
|
||||
target_bitrate = static_cast<int>(fl_value_get_float(bitrate_val));
|
||||
}
|
||||
if (target_bitrate < 0) {
|
||||
g_info("[camera_desktop] videoBitrate %d clamped to minimum 0",
|
||||
target_bitrate);
|
||||
target_bitrate = 0;
|
||||
}
|
||||
|
||||
int audio_bitrate = 0;
|
||||
FlValue* audio_bitrate_val = fl_value_lookup_string(args, "audioBitrate");
|
||||
if (audio_bitrate_val &&
|
||||
fl_value_get_type(audio_bitrate_val) == FL_VALUE_TYPE_INT) {
|
||||
audio_bitrate = fl_value_get_int(audio_bitrate_val);
|
||||
} else if (audio_bitrate_val &&
|
||||
fl_value_get_type(audio_bitrate_val) == FL_VALUE_TYPE_FLOAT) {
|
||||
audio_bitrate = static_cast<int>(fl_value_get_float(audio_bitrate_val));
|
||||
}
|
||||
if (audio_bitrate < 0) {
|
||||
g_info("[camera_desktop] audioBitrate %d clamped to minimum 0",
|
||||
audio_bitrate);
|
||||
audio_bitrate = 0;
|
||||
}
|
||||
|
||||
// Extract device path from the camera name.
|
||||
// Format: "Friendly Name (/dev/videoN)", extract the path in parentheses.
|
||||
std::string name_str(camera_name);
|
||||
std::string device_path;
|
||||
size_t paren_start = name_str.rfind('(');
|
||||
size_t paren_end = name_str.rfind(')');
|
||||
if (paren_start != std::string::npos && paren_end != std::string::npos &&
|
||||
paren_end > paren_start) {
|
||||
device_path = name_str.substr(paren_start + 1, paren_end - paren_start - 1);
|
||||
} else {
|
||||
// Fallback: treat the whole name as a device path.
|
||||
g_info("[camera_desktop] No parentheses found in camera name '%s',"
|
||||
" using full name as device path", camera_name);
|
||||
device_path = name_str;
|
||||
}
|
||||
|
||||
// Detect backend from device path prefix.
|
||||
CameraBackend backend = CameraBackend::kV4L2;
|
||||
if (device_path.size() > 3 && device_path.substr(0, 3) == "pw:") {
|
||||
backend = CameraBackend::kPipeWire;
|
||||
} else if (device_path.empty() || device_path.find("/dev/") != 0) {
|
||||
g_autoptr(FlValue) details = fl_value_new_null();
|
||||
fl_method_call_respond_error(method_call, "invalid_camera_name",
|
||||
"Could not extract device path from camera name",
|
||||
details, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enumerate resolutions based on backend.
|
||||
std::vector<ResolutionInfo> resolutions;
|
||||
if (backend == CameraBackend::kPipeWire) {
|
||||
resolutions = PipeWirePortal::GetDefaultResolutions();
|
||||
} else {
|
||||
resolutions = DeviceEnumerator::EnumerateResolutions(device_path);
|
||||
}
|
||||
auto selected = DeviceEnumerator::SelectResolution(resolutions,
|
||||
resolution_preset);
|
||||
|
||||
CameraConfig config;
|
||||
config.device_path = device_path;
|
||||
config.resolution_preset = resolution_preset;
|
||||
config.enable_audio = enable_audio;
|
||||
config.target_width = selected.width;
|
||||
config.target_height = selected.height;
|
||||
config.target_fps = target_fps > 0 ? target_fps : selected.max_fps;
|
||||
config.target_bitrate = target_bitrate;
|
||||
config.audio_bitrate = audio_bitrate;
|
||||
config.backend = backend;
|
||||
if (backend == CameraBackend::kPipeWire && self->data->portal) {
|
||||
config.pw_fd = self->data->portal->pw_fd();
|
||||
}
|
||||
|
||||
g_info("[camera_desktop] Creating camera: device=%s, backend=%s, %dx%d@%dfps",
|
||||
config.device_path.c_str(),
|
||||
config.backend == CameraBackend::kPipeWire ? "PipeWire" : "V4L2",
|
||||
config.target_width, config.target_height, config.target_fps);
|
||||
|
||||
int camera_id = self->data->next_camera_id++;
|
||||
auto camera = std::make_unique<Camera>(
|
||||
camera_id, self->texture_registrar, self->channel, config);
|
||||
|
||||
int64_t texture_id = camera->RegisterTexture();
|
||||
if (texture_id < 0) {
|
||||
g_info("[camera_desktop] Camera %d: texture registration failed",
|
||||
camera_id);
|
||||
g_autoptr(FlValue) details = fl_value_new_null();
|
||||
fl_method_call_respond_error(method_call, "texture_registration_failed",
|
||||
"Failed to register Flutter texture",
|
||||
details, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
self->data->cameras[camera_id] = std::move(camera);
|
||||
|
||||
g_autoptr(FlValue) result = fl_value_new_map();
|
||||
fl_value_set_string_take(result, "cameraId",
|
||||
fl_value_new_int(camera_id));
|
||||
fl_value_set_string_take(result, "textureId",
|
||||
fl_value_new_int(texture_id));
|
||||
fl_method_call_respond_success(method_call, result, nullptr);
|
||||
}
|
||||
|
||||
static Camera* find_camera(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
FlValue* args = fl_method_call_get_args(method_call);
|
||||
int camera_id = fl_value_get_int(fl_value_lookup_string(args, "cameraId"));
|
||||
auto it = self->data->cameras.find(camera_id);
|
||||
if (it == self->data->cameras.end()) {
|
||||
g_info("[camera_desktop] find_camera: camera_id %d not found", camera_id);
|
||||
g_autoptr(FlValue) details = fl_value_new_null();
|
||||
fl_method_call_respond_error(method_call, "camera_not_found",
|
||||
"No camera found with the given ID",
|
||||
details, nullptr);
|
||||
return nullptr;
|
||||
}
|
||||
return it->second.get();
|
||||
}
|
||||
|
||||
static void handle_initialize(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
Camera* camera = find_camera(self, method_call);
|
||||
if (!camera) return;
|
||||
// Camera::Initialize responds asynchronously.
|
||||
camera->Initialize(method_call);
|
||||
}
|
||||
|
||||
static void handle_take_picture(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
Camera* camera = find_camera(self, method_call);
|
||||
if (!camera) return;
|
||||
camera->TakePicture(method_call);
|
||||
}
|
||||
|
||||
static void handle_start_video_recording(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
Camera* camera = find_camera(self, method_call);
|
||||
if (!camera) return;
|
||||
camera->StartVideoRecording(method_call);
|
||||
}
|
||||
|
||||
static void handle_stop_video_recording(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
Camera* camera = find_camera(self, method_call);
|
||||
if (!camera) return;
|
||||
camera->StopVideoRecording(method_call);
|
||||
}
|
||||
|
||||
static void handle_start_image_stream(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
Camera* camera = find_camera(self, method_call);
|
||||
if (!camera) return;
|
||||
camera->StartImageStream();
|
||||
const int64_t stream_handle = camera_desktop_ffi_register_stream_handle(camera);
|
||||
FlValue* args = fl_method_call_get_args(method_call);
|
||||
int camera_id = fl_value_get_int(fl_value_lookup_string(args, "cameraId"));
|
||||
g_info("[camera_desktop] Camera %d: image stream started, streamHandle=%" G_GINT64_FORMAT,
|
||||
camera_id, stream_handle);
|
||||
g_autoptr(FlValue) result = fl_value_new_map();
|
||||
fl_value_set_string_take(result, "streamHandle",
|
||||
fl_value_new_int(stream_handle));
|
||||
fl_method_call_respond_success(method_call, result, nullptr);
|
||||
}
|
||||
|
||||
static void handle_stop_image_stream(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
Camera* camera = find_camera(self, method_call);
|
||||
if (!camera) return;
|
||||
FlValue* args = fl_method_call_get_args(method_call);
|
||||
int camera_id = fl_value_get_int(fl_value_lookup_string(args, "cameraId"));
|
||||
FlValue* handle_val = fl_value_lookup_string(args, "streamHandle");
|
||||
if (handle_val && fl_value_get_type(handle_val) == FL_VALUE_TYPE_INT) {
|
||||
int64_t stream_handle = fl_value_get_int(handle_val);
|
||||
g_info("[camera_desktop] Camera %d: stopping image stream,"
|
||||
" streamHandle=%" G_GINT64_FORMAT, camera_id, stream_handle);
|
||||
camera_desktop_ffi_release_stream_handle(stream_handle);
|
||||
} else {
|
||||
g_info("[camera_desktop] Camera %d: stopping image stream (no handle)",
|
||||
camera_id);
|
||||
}
|
||||
camera->StopImageStream();
|
||||
fl_method_call_respond_success(method_call, fl_value_new_null(), nullptr);
|
||||
}
|
||||
|
||||
static void handle_pause_preview(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
Camera* camera = find_camera(self, method_call);
|
||||
if (!camera) return;
|
||||
camera->PausePreview();
|
||||
fl_method_call_respond_success(method_call, fl_value_new_null(), nullptr);
|
||||
}
|
||||
|
||||
static void handle_resume_preview(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
Camera* camera = find_camera(self, method_call);
|
||||
if (!camera) return;
|
||||
camera->ResumePreview();
|
||||
fl_method_call_respond_success(method_call, fl_value_new_null(), nullptr);
|
||||
}
|
||||
|
||||
static void handle_set_mirror(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
Camera* camera = find_camera(self, method_call);
|
||||
if (!camera) return;
|
||||
|
||||
FlValue* args = fl_method_call_get_args(method_call);
|
||||
int camera_id = fl_value_get_int(fl_value_lookup_string(args, "cameraId"));
|
||||
FlValue* mirrored_val = fl_value_lookup_string(args, "mirrored");
|
||||
bool mirrored = mirrored_val ? fl_value_get_bool(mirrored_val) : true;
|
||||
|
||||
g_info("[camera_desktop] Camera %d: setMirror(%s)", camera_id,
|
||||
mirrored ? "true" : "false");
|
||||
camera->SetMirror(mirrored);
|
||||
fl_method_call_respond_success(method_call, fl_value_new_null(), nullptr);
|
||||
}
|
||||
|
||||
static void handle_dispose(CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
FlValue* args = fl_method_call_get_args(method_call);
|
||||
int camera_id = fl_value_get_int(fl_value_lookup_string(args, "cameraId"));
|
||||
|
||||
g_info("[camera_desktop] handle_dispose: camera_id=%d", camera_id);
|
||||
auto it = self->data->cameras.find(camera_id);
|
||||
if (it != self->data->cameras.end()) {
|
||||
camera_desktop_ffi_release_handles_for_camera(it->second.get());
|
||||
it->second->Dispose();
|
||||
self->data->cameras.erase(it);
|
||||
}
|
||||
fl_method_call_respond_success(method_call, fl_value_new_null(), nullptr);
|
||||
}
|
||||
|
||||
// --- Plugin lifecycle ---
|
||||
|
||||
static void camera_desktop_plugin_handle_method_call(
|
||||
CameraDesktopPlugin* self,
|
||||
FlMethodCall* method_call) {
|
||||
const gchar* method = fl_method_call_get_name(method_call);
|
||||
|
||||
if (strcmp(method, "availableCameras") == 0) {
|
||||
handle_available_cameras(self, method_call);
|
||||
} else if (strcmp(method, "getPlatformCapabilities") == 0) {
|
||||
handle_get_platform_capabilities(method_call);
|
||||
} else if (strcmp(method, "create") == 0) {
|
||||
handle_create(self, method_call);
|
||||
} else if (strcmp(method, "initialize") == 0) {
|
||||
handle_initialize(self, method_call);
|
||||
} else if (strcmp(method, "takePicture") == 0) {
|
||||
handle_take_picture(self, method_call);
|
||||
} else if (strcmp(method, "startVideoRecording") == 0) {
|
||||
handle_start_video_recording(self, method_call);
|
||||
} else if (strcmp(method, "stopVideoRecording") == 0) {
|
||||
handle_stop_video_recording(self, method_call);
|
||||
} else if (strcmp(method, "startImageStream") == 0) {
|
||||
handle_start_image_stream(self, method_call);
|
||||
} else if (strcmp(method, "stopImageStream") == 0) {
|
||||
handle_stop_image_stream(self, method_call);
|
||||
} else if (strcmp(method, "pausePreview") == 0) {
|
||||
handle_pause_preview(self, method_call);
|
||||
} else if (strcmp(method, "resumePreview") == 0) {
|
||||
handle_resume_preview(self, method_call);
|
||||
} else if (strcmp(method, "setMirror") == 0) {
|
||||
handle_set_mirror(self, method_call);
|
||||
} else if (strcmp(method, "dispose") == 0) {
|
||||
handle_dispose(self, method_call);
|
||||
} else {
|
||||
fl_method_call_respond_not_implemented(method_call, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
static void method_call_cb(FlMethodChannel* channel,
|
||||
FlMethodCall* method_call,
|
||||
gpointer user_data) {
|
||||
CameraDesktopPlugin* plugin = CAMERA_DESKTOP_PLUGIN(user_data);
|
||||
camera_desktop_plugin_handle_method_call(plugin, method_call);
|
||||
}
|
||||
|
||||
static void camera_desktop_plugin_dispose(GObject* object) {
|
||||
CameraDesktopPlugin* self = CAMERA_DESKTOP_PLUGIN(object);
|
||||
|
||||
// Dispose all cameras.
|
||||
if (self->data) {
|
||||
g_info("[camera_desktop] Plugin teardown: disposing %zu camera(s)",
|
||||
self->data->cameras.size());
|
||||
for (auto& pair : self->data->cameras) {
|
||||
camera_desktop_ffi_release_handles_for_camera(pair.second.get());
|
||||
pair.second->Dispose();
|
||||
}
|
||||
delete self->data;
|
||||
self->data = nullptr;
|
||||
}
|
||||
|
||||
g_clear_object(&self->channel);
|
||||
|
||||
G_OBJECT_CLASS(camera_desktop_plugin_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
static void camera_desktop_plugin_class_init(CameraDesktopPluginClass* klass) {
|
||||
G_OBJECT_CLASS(klass)->dispose = camera_desktop_plugin_dispose;
|
||||
}
|
||||
|
||||
static void camera_desktop_plugin_init(CameraDesktopPlugin* self) {
|
||||
self->data = new PluginData();
|
||||
self->data->use_pipewire = PipeWirePortal::ShouldUsePipeWire();
|
||||
if (self->data->use_pipewire) {
|
||||
g_info("[camera_desktop] Plugin init: PipeWire mode enabled");
|
||||
self->data->portal = std::make_unique<PipeWirePortal>();
|
||||
} else {
|
||||
g_info("[camera_desktop] Plugin init: V4L2 mode (no PipeWire)");
|
||||
}
|
||||
}
|
||||
|
||||
void camera_desktop_plugin_register_with_registrar(
|
||||
FlPluginRegistrar* registrar) {
|
||||
// Initialize GStreamer (safe to call multiple times).
|
||||
gst_init(nullptr, nullptr);
|
||||
g_info("[camera_desktop] GStreamer initialized (version %s)",
|
||||
gst_version_string());
|
||||
|
||||
CameraDesktopPlugin* plugin = CAMERA_DESKTOP_PLUGIN(
|
||||
g_object_new(camera_desktop_plugin_get_type(), nullptr));
|
||||
|
||||
plugin->texture_registrar =
|
||||
fl_plugin_registrar_get_texture_registrar(registrar);
|
||||
|
||||
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||
plugin->channel = fl_method_channel_new(
|
||||
fl_plugin_registrar_get_messenger(registrar),
|
||||
"plugins.flutter.io/camera_desktop", FL_METHOD_CODEC(codec));
|
||||
fl_method_channel_set_method_call_handler(
|
||||
plugin->channel, method_call_cb, g_object_ref(plugin), g_object_unref);
|
||||
|
||||
g_object_unref(plugin);
|
||||
}
|
||||
163
plugins/camera_desktop/linux/camera_texture.cc
Normal file
163
plugins/camera_desktop/linux/camera_texture.cc
Normal file
@@ -0,0 +1,163 @@
|
||||
#include "camera_texture.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
// Triple-buffer texture for safe GStreamer→Flutter frame delivery.
|
||||
//
|
||||
// - GStreamer streaming thread writes to buffers[write_idx].
|
||||
// - After writing, it swaps write_idx ↔ ready_idx (under mutex).
|
||||
// - Flutter render thread (copy_pixels) swaps ready_idx ↔ read_idx (under
|
||||
// mutex) and returns buffers[read_idx]. This buffer is safe because neither
|
||||
// the GStreamer thread nor the swap touches it until the next copy_pixels.
|
||||
|
||||
struct _CameraTexture {
|
||||
FlPixelBufferTexture parent_instance;
|
||||
|
||||
uint8_t* buffers[3];
|
||||
int write_idx;
|
||||
int read_idx;
|
||||
int ready_idx;
|
||||
gboolean has_new_frame;
|
||||
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
size_t buffer_size; // width * height * 4
|
||||
|
||||
GMutex mutex;
|
||||
};
|
||||
|
||||
G_DEFINE_TYPE(CameraTexture, camera_texture,
|
||||
fl_pixel_buffer_texture_get_type())
|
||||
|
||||
static gboolean camera_texture_copy_pixels_impl(
|
||||
FlPixelBufferTexture* texture,
|
||||
const uint8_t** out_buffer,
|
||||
uint32_t* width,
|
||||
uint32_t* height,
|
||||
GError** error) {
|
||||
CameraTexture* self = CAMERA_TEXTURE(texture);
|
||||
|
||||
g_mutex_lock(&self->mutex);
|
||||
|
||||
if (self->width == 0 || self->height == 0 ||
|
||||
self->buffers[self->read_idx] == nullptr) {
|
||||
g_mutex_unlock(&self->mutex);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// Swap ready → read if a new frame is available.
|
||||
if (self->has_new_frame) {
|
||||
int tmp = self->read_idx;
|
||||
self->read_idx = self->ready_idx;
|
||||
self->ready_idx = tmp;
|
||||
self->has_new_frame = FALSE;
|
||||
}
|
||||
|
||||
*out_buffer = self->buffers[self->read_idx];
|
||||
*width = self->width;
|
||||
*height = self->height;
|
||||
|
||||
g_mutex_unlock(&self->mutex);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static void camera_texture_dispose(GObject* object) {
|
||||
CameraTexture* self = CAMERA_TEXTURE(object);
|
||||
|
||||
g_mutex_lock(&self->mutex);
|
||||
for (int i = 0; i < 3; i++) {
|
||||
g_free(self->buffers[i]);
|
||||
self->buffers[i] = nullptr;
|
||||
}
|
||||
self->width = 0;
|
||||
self->height = 0;
|
||||
self->buffer_size = 0;
|
||||
g_mutex_unlock(&self->mutex);
|
||||
|
||||
g_mutex_clear(&self->mutex);
|
||||
|
||||
G_OBJECT_CLASS(camera_texture_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
static void camera_texture_class_init(CameraTextureClass* klass) {
|
||||
G_OBJECT_CLASS(klass)->dispose = camera_texture_dispose;
|
||||
FL_PIXEL_BUFFER_TEXTURE_CLASS(klass)->copy_pixels =
|
||||
camera_texture_copy_pixels_impl;
|
||||
}
|
||||
|
||||
static void camera_texture_init(CameraTexture* self) {
|
||||
g_mutex_init(&self->mutex);
|
||||
self->write_idx = 0;
|
||||
self->read_idx = 1;
|
||||
self->ready_idx = 2;
|
||||
self->has_new_frame = FALSE;
|
||||
self->width = 0;
|
||||
self->height = 0;
|
||||
self->buffer_size = 0;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
self->buffers[i] = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
CameraTexture* camera_texture_new(void) {
|
||||
return CAMERA_TEXTURE(g_object_new(CAMERA_TEXTURE_TYPE, nullptr));
|
||||
}
|
||||
|
||||
void camera_texture_update(CameraTexture* self,
|
||||
const uint8_t* data,
|
||||
uint32_t width,
|
||||
uint32_t height) {
|
||||
g_return_if_fail(CAMERA_IS_TEXTURE(self));
|
||||
g_return_if_fail(data != nullptr);
|
||||
|
||||
size_t required = (size_t)width * height * 4;
|
||||
|
||||
// --- Phase 1: ensure buffers are allocated and capture write_idx. ---
|
||||
// The lock is held briefly only to check/update dimensions and read
|
||||
// write_idx. Reallocation (rare, only on resolution change) also happens
|
||||
// here, under the lock, because copy_pixels_impl may be reading
|
||||
// buffers[read_idx] concurrently and we must not free it mid-read.
|
||||
g_mutex_lock(&self->mutex);
|
||||
|
||||
if (required != self->buffer_size) {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
g_free(self->buffers[i]);
|
||||
self->buffers[i] = (uint8_t*)g_malloc(required);
|
||||
}
|
||||
self->buffer_size = required;
|
||||
self->width = width;
|
||||
self->height = height;
|
||||
}
|
||||
|
||||
// Capture the current write index while the lock is held. write_idx is
|
||||
// exclusively owned by this thread (only this function ever modifies it),
|
||||
// so it will not change between now and Phase 3.
|
||||
int wi = self->write_idx;
|
||||
|
||||
g_mutex_unlock(&self->mutex);
|
||||
|
||||
// --- Phase 2: copy frame data into the write buffer (NO lock). ---
|
||||
// C-6 FIX: The memcpy (up to 8 MB at 1080p) previously held the mutex for
|
||||
// its full duration, which forced Flutter's render thread to stall every
|
||||
// time it called copy_pixels_impl. Moving it outside the lock eliminates
|
||||
// that contention. This is safe because:
|
||||
// - write_idx is producer-exclusive (only this function touches it).
|
||||
// - The consumer (copy_pixels_impl) only ever swaps ready_idx ↔ read_idx,
|
||||
// never write_idx. So buffers[wi] is not touched by any other thread
|
||||
// while we're here.
|
||||
memcpy(self->buffers[wi], data, required);
|
||||
|
||||
// --- Phase 3: atomically swap write ↔ ready under the lock. ---
|
||||
g_mutex_lock(&self->mutex);
|
||||
|
||||
int tmp = self->write_idx;
|
||||
self->write_idx = self->ready_idx;
|
||||
self->ready_idx = tmp;
|
||||
self->has_new_frame = TRUE;
|
||||
|
||||
g_mutex_unlock(&self->mutex);
|
||||
}
|
||||
|
||||
FlTexture* camera_texture_as_fl_texture(CameraTexture* self) {
|
||||
return FL_TEXTURE(self);
|
||||
}
|
||||
30
plugins/camera_desktop/linux/camera_texture.h
Normal file
30
plugins/camera_desktop/linux/camera_texture.h
Normal file
@@ -0,0 +1,30 @@
|
||||
#ifndef CAMERA_TEXTURE_H_
|
||||
#define CAMERA_TEXTURE_H_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
#define CAMERA_TEXTURE_TYPE (camera_texture_get_type())
|
||||
G_DECLARE_FINAL_TYPE(CameraTexture, camera_texture, CAMERA, TEXTURE,
|
||||
FlPixelBufferTexture)
|
||||
|
||||
// Creates a new CameraTexture instance.
|
||||
CameraTexture* camera_texture_new(void);
|
||||
|
||||
// Updates the texture with new RGBA frame data.
|
||||
// |data| must point to tightly-packed RGBA pixels (stride == width * 4).
|
||||
// |width| and |height| are the frame dimensions.
|
||||
// This is called from the GStreamer streaming thread; it writes to the
|
||||
// internal write buffer and swaps it into the ready slot.
|
||||
void camera_texture_update(CameraTexture* self,
|
||||
const uint8_t* data,
|
||||
uint32_t width,
|
||||
uint32_t height);
|
||||
|
||||
// Returns the FlTexture base pointer (for registrar calls).
|
||||
FlTexture* camera_texture_as_fl_texture(CameraTexture* self);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif // CAMERA_TEXTURE_H_
|
||||
322
plugins/camera_desktop/linux/device_enumerator.cc
Normal file
322
plugins/camera_desktop/linux/device_enumerator.cc
Normal file
@@ -0,0 +1,322 @@
|
||||
#include "device_enumerator.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <glib.h>
|
||||
#include <linux/videodev2.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <set>
|
||||
|
||||
static const int kMaxDeviceIndex = 64;
|
||||
static const int kMinFps = 15;
|
||||
|
||||
// Standard resolutions to probe when the device reports stepwise/continuous
|
||||
// frame sizes instead of discrete sizes.
|
||||
static const int kStandardHeights[] = {240, 480, 720, 1080, 2160};
|
||||
static const int kStandardWidths[] = {320, 640, 1280, 1920, 3840};
|
||||
|
||||
// Maximum height per resolution preset.
|
||||
static int MaxHeightForPreset(int preset) {
|
||||
switch (preset) {
|
||||
case ResolutionPreset::kLow:
|
||||
return 240;
|
||||
case ResolutionPreset::kMedium:
|
||||
return 480;
|
||||
case ResolutionPreset::kHigh:
|
||||
return 720;
|
||||
case ResolutionPreset::kVeryHigh:
|
||||
return 1080;
|
||||
case ResolutionPreset::kUltraHigh:
|
||||
return 2160;
|
||||
case ResolutionPreset::kMax:
|
||||
default:
|
||||
return 99999;
|
||||
}
|
||||
}
|
||||
|
||||
// Queries the maximum FPS for a given format and resolution via
|
||||
// VIDIOC_ENUM_FRAMEINTERVALS. Returns 0 if it cannot be determined.
|
||||
static int QueryMaxFps(int fd, __u32 pixel_format, int width, int height) {
|
||||
struct v4l2_frmivalenum frmival;
|
||||
memset(&frmival, 0, sizeof(frmival));
|
||||
frmival.pixel_format = pixel_format;
|
||||
frmival.width = width;
|
||||
frmival.height = height;
|
||||
frmival.index = 0;
|
||||
|
||||
int max_fps = 0;
|
||||
while (ioctl(fd, VIDIOC_ENUM_FRAMEINTERVALS, &frmival) == 0) {
|
||||
if (frmival.type == V4L2_FRMIVAL_TYPE_DISCRETE) {
|
||||
if (frmival.discrete.numerator > 0) {
|
||||
int fps = frmival.discrete.denominator / frmival.discrete.numerator;
|
||||
if (fps > max_fps) max_fps = fps;
|
||||
}
|
||||
} else if (frmival.type == V4L2_FRMIVAL_TYPE_STEPWISE ||
|
||||
frmival.type == V4L2_FRMIVAL_TYPE_CONTINUOUS) {
|
||||
// Use the minimum interval (= maximum fps).
|
||||
if (frmival.stepwise.min.numerator > 0) {
|
||||
int fps =
|
||||
frmival.stepwise.min.denominator / frmival.stepwise.min.numerator;
|
||||
if (fps > max_fps) max_fps = fps;
|
||||
}
|
||||
break; // Only one entry for stepwise/continuous.
|
||||
}
|
||||
frmival.index++;
|
||||
}
|
||||
return max_fps > 0 ? max_fps : 30; // Default to 30 if unknown.
|
||||
}
|
||||
|
||||
std::vector<DeviceInfo> DeviceEnumerator::EnumerateDevices() {
|
||||
std::vector<DeviceInfo> devices;
|
||||
std::set<std::string> seen_bus_info;
|
||||
int open_failures = 0;
|
||||
|
||||
for (int i = 0; i < kMaxDeviceIndex; i++) {
|
||||
char path[32];
|
||||
snprintf(path, sizeof(path), "/dev/video%d", i);
|
||||
|
||||
int fd = open(path, O_RDONLY | O_NONBLOCK);
|
||||
if (fd < 0) {
|
||||
open_failures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
struct v4l2_capability cap;
|
||||
memset(&cap, 0, sizeof(cap));
|
||||
if (ioctl(fd, VIDIOC_QUERYCAP, &cap) != 0) {
|
||||
close(fd);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use per-node device_caps when available, otherwise fall back to
|
||||
// device-wide capabilities.
|
||||
__u32 effective_caps = (cap.capabilities & V4L2_CAP_DEVICE_CAPS)
|
||||
? cap.device_caps
|
||||
: cap.capabilities;
|
||||
|
||||
// Must support video capture (single-plane or multi-plane).
|
||||
bool is_capture =
|
||||
(effective_caps &
|
||||
(V4L2_CAP_VIDEO_CAPTURE | V4L2_CAP_VIDEO_CAPTURE_MPLANE)) != 0;
|
||||
|
||||
// Filter out non-camera nodes (M2M, metadata, output-only).
|
||||
bool is_non_camera =
|
||||
(effective_caps & (V4L2_CAP_VIDEO_M2M | V4L2_CAP_VIDEO_M2M_MPLANE |
|
||||
V4L2_CAP_META_CAPTURE | V4L2_CAP_VIDEO_OUTPUT)) !=
|
||||
0;
|
||||
|
||||
if (!is_capture || is_non_camera) {
|
||||
close(fd);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deduplicate by bus_info, each physical camera may expose multiple nodes.
|
||||
std::string bus(reinterpret_cast<const char*>(cap.bus_info));
|
||||
if (!bus.empty() && seen_bus_info.count(bus)) {
|
||||
close(fd);
|
||||
continue;
|
||||
}
|
||||
if (!bus.empty()) seen_bus_info.insert(bus);
|
||||
|
||||
DeviceInfo info;
|
||||
info.device_path = path;
|
||||
info.name = reinterpret_cast<const char*>(cap.card);
|
||||
info.bus_info = bus;
|
||||
// Most Linux webcams are external USB cameras.
|
||||
info.lens_direction = 2; // CameraLensDirection.external
|
||||
info.sensor_orientation = 0;
|
||||
devices.push_back(info);
|
||||
|
||||
close(fd);
|
||||
}
|
||||
if (open_failures > 0) {
|
||||
g_info("[camera_desktop] V4L2 enumeration: %d /dev/videoN node(s) could"
|
||||
" not be opened (normal if indices are sparse)", open_failures);
|
||||
}
|
||||
g_info("[camera_desktop] V4L2 enumeration found %zu camera(s)", devices.size());
|
||||
for (const auto& d : devices) {
|
||||
g_info("[camera_desktop] → %s (%s)", d.name.c_str(), d.device_path.c_str());
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
std::vector<ResolutionInfo> DeviceEnumerator::EnumerateResolutions(
|
||||
const std::string& device_path) {
|
||||
std::vector<ResolutionInfo> resolutions;
|
||||
|
||||
int fd = open(device_path.c_str(), O_RDONLY | O_NONBLOCK);
|
||||
if (fd < 0) {
|
||||
g_info("[camera_desktop] EnumerateResolutions: failed to open %s",
|
||||
device_path.c_str());
|
||||
return resolutions;
|
||||
}
|
||||
|
||||
struct v4l2_fmtdesc fmt;
|
||||
memset(&fmt, 0, sizeof(fmt));
|
||||
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
fmt.index = 0;
|
||||
|
||||
// Track seen width×height pairs to avoid duplicates across formats.
|
||||
std::set<std::pair<int, int>> seen;
|
||||
|
||||
while (ioctl(fd, VIDIOC_ENUM_FMT, &fmt) == 0) {
|
||||
struct v4l2_frmsizeenum frmsize;
|
||||
memset(&frmsize, 0, sizeof(frmsize));
|
||||
frmsize.pixel_format = fmt.pixelformat;
|
||||
frmsize.index = 0;
|
||||
|
||||
while (ioctl(fd, VIDIOC_ENUM_FRAMESIZES, &frmsize) == 0) {
|
||||
if (frmsize.type == V4L2_FRMSIZE_TYPE_DISCRETE) {
|
||||
int w = frmsize.discrete.width;
|
||||
int h = frmsize.discrete.height;
|
||||
int fps = QueryMaxFps(fd, fmt.pixelformat, w, h);
|
||||
if (!seen.count({w, h})) {
|
||||
seen.insert({w, h});
|
||||
resolutions.push_back({w, h, fps});
|
||||
} else {
|
||||
// Same resolution may appear in MJPEG and YUYV with different fps.
|
||||
for (auto& r : resolutions) {
|
||||
if (r.width == w && r.height == h && fps > r.max_fps) {
|
||||
r.max_fps = fps;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (frmsize.type == V4L2_FRMSIZE_TYPE_STEPWISE ||
|
||||
frmsize.type == V4L2_FRMSIZE_TYPE_CONTINUOUS) {
|
||||
// Generate standard resolutions within the reported range.
|
||||
for (int si = 0; si < 5; si++) {
|
||||
int w = kStandardWidths[si];
|
||||
int h = kStandardHeights[si];
|
||||
if (w >= (int)frmsize.stepwise.min_width &&
|
||||
w <= (int)frmsize.stepwise.max_width &&
|
||||
h >= (int)frmsize.stepwise.min_height &&
|
||||
h <= (int)frmsize.stepwise.max_height &&
|
||||
!seen.count({w, h})) {
|
||||
seen.insert({w, h});
|
||||
int fps = QueryMaxFps(fd, fmt.pixelformat, w, h);
|
||||
resolutions.push_back({w, h, fps});
|
||||
}
|
||||
}
|
||||
break; // One entry for stepwise/continuous.
|
||||
}
|
||||
frmsize.index++;
|
||||
}
|
||||
fmt.index++;
|
||||
}
|
||||
|
||||
close(fd);
|
||||
|
||||
// Sort by resolution (height primary, width secondary) descending.
|
||||
std::sort(resolutions.begin(), resolutions.end(),
|
||||
[](const ResolutionInfo& a, const ResolutionInfo& b) {
|
||||
if (a.height != b.height) return a.height > b.height;
|
||||
return a.width > b.width;
|
||||
});
|
||||
|
||||
g_info("[camera_desktop] Device %s: %zu resolution(s) available",
|
||||
device_path.c_str(), resolutions.size());
|
||||
|
||||
return resolutions;
|
||||
}
|
||||
|
||||
// Returns true if |pixel_format| on |fd| offers |width|x|height| as a discrete
|
||||
// size or within a stepwise/continuous range. Conservative: returns false if
|
||||
// the size is not advertised. Frame rate is intentionally not checked here; see
|
||||
// SupportsMjpeg.
|
||||
static bool FormatSupportsSize(int fd, __u32 pixel_format, int width,
|
||||
int height) {
|
||||
struct v4l2_frmsizeenum frmsize;
|
||||
memset(&frmsize, 0, sizeof(frmsize));
|
||||
frmsize.pixel_format = pixel_format;
|
||||
frmsize.index = 0;
|
||||
|
||||
while (ioctl(fd, VIDIOC_ENUM_FRAMESIZES, &frmsize) == 0) {
|
||||
if (frmsize.type == V4L2_FRMSIZE_TYPE_DISCRETE) {
|
||||
if ((int)frmsize.discrete.width == width &&
|
||||
(int)frmsize.discrete.height == height) {
|
||||
return true;
|
||||
}
|
||||
} else if (frmsize.type == V4L2_FRMSIZE_TYPE_STEPWISE ||
|
||||
frmsize.type == V4L2_FRMSIZE_TYPE_CONTINUOUS) {
|
||||
// One entry for stepwise/continuous; the size is in range or it is not.
|
||||
return width >= (int)frmsize.stepwise.min_width &&
|
||||
width <= (int)frmsize.stepwise.max_width &&
|
||||
height >= (int)frmsize.stepwise.min_height &&
|
||||
height <= (int)frmsize.stepwise.max_height;
|
||||
}
|
||||
frmsize.index++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool DeviceEnumerator::SupportsMjpeg(const std::string& device_path,
|
||||
int width, int height) {
|
||||
int fd = open(device_path.c_str(), O_RDONLY | O_NONBLOCK);
|
||||
if (fd < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool supported = false;
|
||||
struct v4l2_fmtdesc fmt;
|
||||
memset(&fmt, 0, sizeof(fmt));
|
||||
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
fmt.index = 0;
|
||||
|
||||
while (ioctl(fd, VIDIOC_ENUM_FMT, &fmt) == 0) {
|
||||
// Cameras advertise motion-JPEG as either V4L2_PIX_FMT_MJPEG or the older
|
||||
// V4L2_PIX_FMT_JPEG; GStreamer's image/jpeg + jpegdec decodes both. Only the
|
||||
// target size must be offered: the MJPEG pipeline pins width/height but lets
|
||||
// the native frame rate float, and the downstream videorate adapts it to the
|
||||
// requested fps (the same way the raw-capture path does).
|
||||
if ((fmt.pixelformat == V4L2_PIX_FMT_MJPEG ||
|
||||
fmt.pixelformat == V4L2_PIX_FMT_JPEG) &&
|
||||
FormatSupportsSize(fd, fmt.pixelformat, width, height)) {
|
||||
supported = true;
|
||||
break;
|
||||
}
|
||||
fmt.index++;
|
||||
}
|
||||
|
||||
close(fd);
|
||||
g_info("[camera_desktop] SupportsMjpeg(%s, %dx%d) = %s",
|
||||
device_path.c_str(), width, height, supported ? "true" : "false");
|
||||
return supported;
|
||||
}
|
||||
|
||||
ResolutionInfo DeviceEnumerator::SelectResolution(
|
||||
const std::vector<ResolutionInfo>& resolutions,
|
||||
int preset) {
|
||||
int max_height = MaxHeightForPreset(preset);
|
||||
|
||||
// Find the highest resolution that fits within the preset ceiling and
|
||||
// has at least kMinFps. Resolutions are sorted descending.
|
||||
for (const auto& r : resolutions) {
|
||||
if (r.height <= max_height && r.max_fps >= kMinFps) {
|
||||
g_info("[camera_desktop] SelectResolution(preset=%d): primary match"
|
||||
" %dx%d@%dfps", preset, r.width, r.height, r.max_fps);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
// Fallback: relax FPS requirement.
|
||||
for (const auto& r : resolutions) {
|
||||
if (r.height <= max_height) {
|
||||
g_info("[camera_desktop] SelectResolution(preset=%d): relaxed-FPS"
|
||||
" fallback %dx%d@%dfps", preset, r.width, r.height, r.max_fps);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
// Absolute fallback: return the lowest resolution available.
|
||||
if (!resolutions.empty()) {
|
||||
const auto& r = resolutions.back();
|
||||
g_info("[camera_desktop] SelectResolution(preset=%d): lowest-available"
|
||||
" fallback %dx%d@%dfps", preset, r.width, r.height, r.max_fps);
|
||||
return r;
|
||||
}
|
||||
// No resolutions found, return a default and let GStreamer negotiate.
|
||||
g_info("[camera_desktop] SelectResolution(preset=%d): no resolutions"
|
||||
" available, using hardcoded default 640x480@30fps", preset);
|
||||
return {640, 480, 30};
|
||||
}
|
||||
63
plugins/camera_desktop/linux/device_enumerator.h
Normal file
63
plugins/camera_desktop/linux/device_enumerator.h
Normal file
@@ -0,0 +1,63 @@
|
||||
#ifndef DEVICE_ENUMERATOR_H_
|
||||
#define DEVICE_ENUMERATOR_H_
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct DeviceInfo {
|
||||
std::string device_path; // e.g. "/dev/video0"
|
||||
std::string name; // e.g. "Integrated Camera" (from v4l2 card field)
|
||||
std::string bus_info; // e.g. "usb-0000:00:14.0-4" (for deduplication)
|
||||
int lens_direction; // 0=front, 1=back, 2=external
|
||||
int sensor_orientation; // 0 for most Linux webcams
|
||||
};
|
||||
|
||||
struct ResolutionInfo {
|
||||
int width;
|
||||
int height;
|
||||
int max_fps; // Best framerate at this resolution
|
||||
};
|
||||
|
||||
// Resolution preset indices (matches Dart ResolutionPreset enum order).
|
||||
enum ResolutionPreset {
|
||||
kLow = 0, // <= 240p
|
||||
kMedium = 1, // <= 480p
|
||||
kHigh = 2, // <= 720p
|
||||
kVeryHigh = 3, // <= 1080p
|
||||
kUltraHigh = 4, // <= 2160p
|
||||
kMax = 5, // Highest available
|
||||
};
|
||||
|
||||
class DeviceEnumerator {
|
||||
public:
|
||||
// Scans /dev/video* and returns capture-capable devices, deduplicated by
|
||||
// bus_info so each physical camera appears only once.
|
||||
static std::vector<DeviceInfo> EnumerateDevices();
|
||||
|
||||
// Enumerates supported resolutions and frame rates for a device.
|
||||
// Handles discrete, stepwise, and continuous frame size types.
|
||||
static std::vector<ResolutionInfo> EnumerateResolutions(
|
||||
const std::string& device_path);
|
||||
|
||||
// Picks the best resolution for a given preset from the list of supported
|
||||
// resolutions. Returns the highest resolution whose height fits within
|
||||
// the preset ceiling, with at least 15 FPS.
|
||||
static ResolutionInfo SelectResolution(
|
||||
const std::vector<ResolutionInfo>& resolutions,
|
||||
int preset);
|
||||
|
||||
// Returns true if |device_path| can deliver motion-JPEG (either
|
||||
// V4L2_PIX_FMT_MJPEG or the older V4L2_PIX_FMT_JPEG) at the given
|
||||
// width/height. Used to decide between an MJPEG and a raw capture pipeline
|
||||
// BEFORE building it: gst_parse_launch() succeeds even for an MJPEG pipeline
|
||||
// the camera cannot satisfy, so the choice must be probed up front rather
|
||||
// than inferred from a parse failure that never happens. Frame rate is not
|
||||
// checked because the MJPEG pipeline lets the native rate float and adapts it
|
||||
// downstream with videorate; pinning a specific source fps would spuriously
|
||||
// reject cameras that offer the size at a different native rate. Returns false
|
||||
// on any uncertainty so callers fall back to the always-safe raw capture path.
|
||||
static bool SupportsMjpeg(const std::string& device_path, int width,
|
||||
int height);
|
||||
};
|
||||
|
||||
#endif // DEVICE_ENUMERATOR_H_
|
||||
77
plugins/camera_desktop/linux/image_stream_ffi.cc
Normal file
77
plugins/camera_desktop/linux/image_stream_ffi.cc
Normal file
@@ -0,0 +1,77 @@
|
||||
#include "camera.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex g_stream_handles_mutex;
|
||||
int64_t g_next_stream_handle = 1;
|
||||
std::unordered_map<int64_t, Camera*> g_stream_handles;
|
||||
|
||||
Camera* FindCameraByHandle(int64_t stream_handle) {
|
||||
std::lock_guard<std::mutex> lk(g_stream_handles_mutex);
|
||||
auto it = g_stream_handles.find(stream_handle);
|
||||
if (it == g_stream_handles.end()) return nullptr;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int64_t camera_desktop_ffi_register_stream_handle(Camera* camera) {
|
||||
if (!camera) return 0;
|
||||
std::lock_guard<std::mutex> lk(g_stream_handles_mutex);
|
||||
const int64_t handle = g_next_stream_handle++;
|
||||
g_stream_handles.emplace(handle, camera);
|
||||
return handle;
|
||||
}
|
||||
|
||||
void camera_desktop_ffi_release_stream_handle(int64_t stream_handle) {
|
||||
if (stream_handle == 0) return;
|
||||
std::lock_guard<std::mutex> lk(g_stream_handles_mutex);
|
||||
g_stream_handles.erase(stream_handle);
|
||||
}
|
||||
|
||||
void camera_desktop_ffi_release_handles_for_camera(Camera* camera) {
|
||||
if (!camera) return;
|
||||
std::lock_guard<std::mutex> lk(g_stream_handles_mutex);
|
||||
for (auto it = g_stream_handles.begin(); it != g_stream_handles.end();) {
|
||||
if (it->second == camera) {
|
||||
it = g_stream_handles.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
|
||||
__attribute__((visibility("default")))
|
||||
void camera_desktop_image_stream_noop_callback(int32_t camera_id) {
|
||||
(void)camera_id;
|
||||
}
|
||||
|
||||
__attribute__((visibility("default")))
|
||||
void* camera_desktop_get_image_stream_buffer(int64_t stream_handle) {
|
||||
Camera* camera = FindCameraByHandle(stream_handle);
|
||||
if (!camera) return nullptr;
|
||||
return camera->GetImageStreamBuffer();
|
||||
}
|
||||
|
||||
__attribute__((visibility("default")))
|
||||
void camera_desktop_register_image_stream_callback(
|
||||
int64_t stream_handle, void (*callback)(int32_t)) {
|
||||
Camera* camera = FindCameraByHandle(stream_handle);
|
||||
if (!camera) return;
|
||||
camera->RegisterImageStreamCallback(callback);
|
||||
}
|
||||
|
||||
__attribute__((visibility("default")))
|
||||
void camera_desktop_unregister_image_stream_callback(int64_t stream_handle) {
|
||||
Camera* camera = FindCameraByHandle(stream_handle);
|
||||
if (!camera) return;
|
||||
camera->UnregisterImageStreamCallback();
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,26 @@
|
||||
#ifndef FLUTTER_PLUGIN_CAMERA_DESKTOP_PLUGIN_H_
|
||||
#define FLUTTER_PLUGIN_CAMERA_DESKTOP_PLUGIN_H_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
#ifdef FLUTTER_PLUGIN_IMPL
|
||||
#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default")))
|
||||
#else
|
||||
#define FLUTTER_PLUGIN_EXPORT
|
||||
#endif
|
||||
|
||||
typedef struct _CameraDesktopPlugin CameraDesktopPlugin;
|
||||
typedef struct {
|
||||
GObjectClass parent_class;
|
||||
} CameraDesktopPluginClass;
|
||||
|
||||
FLUTTER_PLUGIN_EXPORT GType camera_desktop_plugin_get_type();
|
||||
|
||||
FLUTTER_PLUGIN_EXPORT void camera_desktop_plugin_register_with_registrar(
|
||||
FlPluginRegistrar* registrar);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif // FLUTTER_PLUGIN_CAMERA_DESKTOP_PLUGIN_H_
|
||||
72
plugins/camera_desktop/linux/photo_handler.cc
Normal file
72
plugins/camera_desktop/linux/photo_handler.cc
Normal file
@@ -0,0 +1,72 @@
|
||||
#include "photo_handler.h"
|
||||
|
||||
#include <gio/gio.h>
|
||||
#include <gst/app/gstappsink.h>
|
||||
#include <gst/video/video.h>
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
bool PhotoHandler::TakePicture(GstElement* appsink,
|
||||
const std::string& output_path,
|
||||
GError** error) {
|
||||
if (!appsink) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Appsink is null, camera not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use the last-sample property (read-only) to avoid consumer conflicts
|
||||
// with the preview stream.
|
||||
GstSample* sample = nullptr;
|
||||
g_object_get(appsink, "last-sample", &sample, nullptr);
|
||||
if (!sample) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"No frame available for capture");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert the RGBA sample to JPEG.
|
||||
GstCaps* jpeg_caps = gst_caps_from_string("image/jpeg");
|
||||
GError* convert_error = nullptr;
|
||||
GstSample* converted = gst_video_convert_sample(
|
||||
sample, jpeg_caps, GST_SECOND * 5, &convert_error);
|
||||
gst_caps_unref(jpeg_caps);
|
||||
gst_sample_unref(sample);
|
||||
|
||||
if (!converted) {
|
||||
g_propagate_error(error, convert_error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the JPEG buffer and write to file.
|
||||
GstBuffer* buffer = gst_sample_get_buffer(converted);
|
||||
GstMapInfo map;
|
||||
if (!gst_buffer_map(buffer, &map, GST_MAP_READ)) {
|
||||
gst_sample_unref(converted);
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Failed to map JPEG buffer");
|
||||
return false;
|
||||
}
|
||||
|
||||
FILE* file = fopen(output_path.c_str(), "wb");
|
||||
if (!file) {
|
||||
gst_buffer_unmap(buffer, &map);
|
||||
gst_sample_unref(converted);
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Failed to open output file: %s", output_path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t written = fwrite(map.data, 1, map.size, file);
|
||||
fclose(file);
|
||||
gst_buffer_unmap(buffer, &map);
|
||||
gst_sample_unref(converted);
|
||||
|
||||
if (written != map.size) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Incomplete write to output file");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
18
plugins/camera_desktop/linux/photo_handler.h
Normal file
18
plugins/camera_desktop/linux/photo_handler.h
Normal file
@@ -0,0 +1,18 @@
|
||||
#ifndef PHOTO_HANDLER_H_
|
||||
#define PHOTO_HANDLER_H_
|
||||
|
||||
#include <gst/gst.h>
|
||||
#include <string>
|
||||
|
||||
class PhotoHandler {
|
||||
public:
|
||||
// Captures a still image from the appsink's last-sample property (read-only,
|
||||
// no consumer conflict with the preview stream). Converts the RGBA frame to
|
||||
// JPEG via gst_video_convert_sample and writes it to |output_path|.
|
||||
// Returns true on success; sets |error| on failure.
|
||||
static bool TakePicture(GstElement* appsink,
|
||||
const std::string& output_path,
|
||||
GError** error);
|
||||
};
|
||||
|
||||
#endif // PHOTO_HANDLER_H_
|
||||
362
plugins/camera_desktop/linux/pipewire_portal.cc
Normal file
362
plugins/camera_desktop/linux/pipewire_portal.cc
Normal file
@@ -0,0 +1,362 @@
|
||||
#include "pipewire_portal.h"
|
||||
|
||||
#include <gio/gunixfdlist.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
static const char* kPortalBusName = "org.freedesktop.portal.Desktop";
|
||||
static const char* kPortalObjectPath = "/org/freedesktop/portal/desktop";
|
||||
static const char* kCameraInterface = "org.freedesktop.portal.Camera";
|
||||
static const char* kRequestInterface = "org.freedesktop.portal.Request";
|
||||
|
||||
PipeWirePortal::PipeWirePortal()
|
||||
: connection_(nullptr),
|
||||
pw_fd_(-1),
|
||||
signal_subscription_id_(0),
|
||||
request_counter_(0) {
|
||||
GError* error = nullptr;
|
||||
connection_ = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error);
|
||||
if (error) {
|
||||
g_warning("PipeWirePortal: failed to connect to session bus: %s",
|
||||
error->message);
|
||||
g_error_free(error);
|
||||
}
|
||||
}
|
||||
|
||||
PipeWirePortal::~PipeWirePortal() {
|
||||
if (signal_subscription_id_ > 0 && connection_) {
|
||||
g_dbus_connection_signal_unsubscribe(connection_, signal_subscription_id_);
|
||||
signal_subscription_id_ = 0;
|
||||
}
|
||||
if (pw_fd_ >= 0) {
|
||||
close(pw_fd_);
|
||||
pw_fd_ = -1;
|
||||
}
|
||||
g_clear_object(&connection_);
|
||||
}
|
||||
|
||||
bool PipeWirePortal::IsFlatpak() {
|
||||
return g_file_test("/.flatpak-info", G_FILE_TEST_EXISTS);
|
||||
}
|
||||
|
||||
bool PipeWirePortal::HasPipeWireSrc() {
|
||||
GstElementFactory* factory = gst_element_factory_find("pipewiresrc");
|
||||
if (factory) {
|
||||
gst_object_unref(factory);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PipeWirePortal::ShouldUsePipeWire() {
|
||||
bool flatpak = IsFlatpak();
|
||||
bool has_pw = flatpak ? HasPipeWireSrc() : false;
|
||||
g_info("[camera_desktop] Flatpak detected: %s, pipewiresrc available: %s → %s",
|
||||
flatpak ? "yes" : "no",
|
||||
has_pw ? "yes" : "no",
|
||||
(flatpak && has_pw) ? "PipeWire backend" : "V4L2 backend");
|
||||
return flatpak && has_pw;
|
||||
}
|
||||
|
||||
std::vector<ResolutionInfo> PipeWirePortal::GetDefaultResolutions() {
|
||||
return {
|
||||
{3840, 2160, 30},
|
||||
{1920, 1080, 30},
|
||||
{1280, 720, 30},
|
||||
{640, 480, 30},
|
||||
{320, 240, 30},
|
||||
};
|
||||
}
|
||||
|
||||
std::string PipeWirePortal::MakeHandleToken() {
|
||||
char buf[64];
|
||||
snprintf(buf, sizeof(buf), "camera_desktop_%d_%d", getpid(),
|
||||
++request_counter_);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
void PipeWirePortal::EnumerateDevicesAsync(EnumerateCallback callback) {
|
||||
pending_callback_ = std::move(callback);
|
||||
|
||||
if (!connection_) {
|
||||
FinishWithFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// If we already have a valid PipeWire fd from a previous call, skip the
|
||||
// portal permission flow and go straight to enumeration.
|
||||
if (pw_fd_ >= 0) {
|
||||
g_info("[camera_desktop] PipeWirePortal: reusing cached pw_fd=%d", pw_fd_);
|
||||
EnumeratePipeWireNodes();
|
||||
return;
|
||||
}
|
||||
|
||||
std::string handle_token = MakeHandleToken();
|
||||
|
||||
// Build the expected request object path.
|
||||
// Format: /org/freedesktop/portal/desktop/request/<sender>/<handle_token>
|
||||
// where <sender> is the unique bus name with ':' removed and '.' -> '_'.
|
||||
const gchar* unique_name = g_dbus_connection_get_unique_name(connection_);
|
||||
if (!unique_name) {
|
||||
g_info("[camera_desktop] PipeWirePortal: D-Bus unique name is null,"
|
||||
" cannot build request path");
|
||||
FinishWithFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Transform ":1.42" -> "1_42"
|
||||
std::string sender(unique_name);
|
||||
if (!sender.empty() && sender[0] == ':') {
|
||||
sender = sender.substr(1);
|
||||
}
|
||||
for (auto& c : sender) {
|
||||
if (c == '.') c = '_';
|
||||
}
|
||||
|
||||
std::string request_path = std::string(kPortalObjectPath) +
|
||||
"/request/" + sender + "/" + handle_token;
|
||||
|
||||
// Subscribe to the Response signal BEFORE making the call to avoid races.
|
||||
signal_subscription_id_ = g_dbus_connection_signal_subscribe(
|
||||
connection_,
|
||||
kPortalBusName,
|
||||
kRequestInterface,
|
||||
"Response",
|
||||
request_path.c_str(),
|
||||
nullptr,
|
||||
G_DBUS_SIGNAL_FLAGS_NO_MATCH_RULE,
|
||||
PipeWirePortal::OnPortalResponse,
|
||||
this,
|
||||
nullptr);
|
||||
|
||||
// Build options dict with handle_token.
|
||||
GVariantBuilder options;
|
||||
g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}"));
|
||||
g_variant_builder_add(&options, "{sv}", "handle_token",
|
||||
g_variant_new_string(handle_token.c_str()));
|
||||
|
||||
g_dbus_connection_call(
|
||||
connection_,
|
||||
kPortalBusName,
|
||||
kPortalObjectPath,
|
||||
kCameraInterface,
|
||||
"AccessCamera",
|
||||
g_variant_new("(a{sv})", &options),
|
||||
G_VARIANT_TYPE("(o)"),
|
||||
G_DBUS_CALL_FLAGS_NONE,
|
||||
-1, // default timeout
|
||||
nullptr,
|
||||
PipeWirePortal::OnAccessCameraReply,
|
||||
this);
|
||||
}
|
||||
|
||||
void PipeWirePortal::OnAccessCameraReply(GObject* source, GAsyncResult* res,
|
||||
gpointer user_data) {
|
||||
auto* self = static_cast<PipeWirePortal*>(user_data);
|
||||
GError* error = nullptr;
|
||||
GVariant* result = g_dbus_connection_call_finish(
|
||||
G_DBUS_CONNECTION(source), res, &error);
|
||||
|
||||
if (error) {
|
||||
g_warning("PipeWirePortal: AccessCamera call failed: %s", error->message);
|
||||
g_error_free(error);
|
||||
self->FinishWithFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// The result is the request object path. The actual response comes
|
||||
// through the signal we already subscribed to.
|
||||
if (result) {
|
||||
g_variant_unref(result);
|
||||
}
|
||||
}
|
||||
|
||||
void PipeWirePortal::OnPortalResponse(GDBusConnection* connection,
|
||||
const gchar* sender_name,
|
||||
const gchar* object_path,
|
||||
const gchar* interface_name,
|
||||
const gchar* signal_name,
|
||||
GVariant* parameters,
|
||||
gpointer user_data) {
|
||||
auto* self = static_cast<PipeWirePortal*>(user_data);
|
||||
|
||||
// Unsubscribe immediately (one-shot signal).
|
||||
if (self->signal_subscription_id_ > 0) {
|
||||
g_dbus_connection_signal_unsubscribe(connection,
|
||||
self->signal_subscription_id_);
|
||||
self->signal_subscription_id_ = 0;
|
||||
}
|
||||
|
||||
guint32 response = 0;
|
||||
GVariant* results = nullptr;
|
||||
g_variant_get(parameters, "(u@a{sv})", &response, &results);
|
||||
|
||||
if (results) {
|
||||
g_variant_unref(results);
|
||||
}
|
||||
|
||||
self->HandleAccessResponse(response);
|
||||
}
|
||||
|
||||
void PipeWirePortal::HandleAccessResponse(guint32 response) {
|
||||
if (response != 0) {
|
||||
// User denied or dialog was dismissed.
|
||||
g_info("PipeWirePortal: camera access denied (response=%u)", response);
|
||||
FinishWithFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Permission granted. Get the PipeWire remote fd.
|
||||
OpenPipeWireRemote();
|
||||
}
|
||||
|
||||
void PipeWirePortal::OpenPipeWireRemote() {
|
||||
if (!connection_) {
|
||||
FinishWithFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
GVariantBuilder options;
|
||||
g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}"));
|
||||
|
||||
GError* error = nullptr;
|
||||
GUnixFDList* fd_list = nullptr;
|
||||
|
||||
GVariant* result = g_dbus_connection_call_with_unix_fd_list_sync(
|
||||
connection_,
|
||||
kPortalBusName,
|
||||
kPortalObjectPath,
|
||||
kCameraInterface,
|
||||
"OpenPipeWireRemote",
|
||||
g_variant_new("(a{sv})", &options),
|
||||
G_VARIANT_TYPE("(h)"),
|
||||
G_DBUS_CALL_FLAGS_NONE,
|
||||
-1,
|
||||
nullptr, // in fd list
|
||||
&fd_list, // out fd list
|
||||
nullptr,
|
||||
&error);
|
||||
|
||||
if (error) {
|
||||
g_warning("PipeWirePortal: OpenPipeWireRemote failed: %s", error->message);
|
||||
g_error_free(error);
|
||||
FinishWithFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// The result contains a fd index (h) into the fd list.
|
||||
gint32 fd_index = 0;
|
||||
g_variant_get(result, "(h)", &fd_index);
|
||||
g_variant_unref(result);
|
||||
|
||||
if (!fd_list || g_unix_fd_list_get_length(fd_list) <= fd_index) {
|
||||
g_warning("PipeWirePortal: no fd received from OpenPipeWireRemote");
|
||||
if (fd_list) g_object_unref(fd_list);
|
||||
FinishWithFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
pw_fd_ = g_unix_fd_list_get(fd_list, fd_index, &error);
|
||||
g_object_unref(fd_list);
|
||||
|
||||
if (error || pw_fd_ < 0) {
|
||||
g_warning("PipeWirePortal: failed to extract fd: %s",
|
||||
error ? error->message : "unknown");
|
||||
if (error) g_error_free(error);
|
||||
pw_fd_ = -1;
|
||||
FinishWithFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
g_info("[camera_desktop] Portal: camera access granted, pw_fd=%d", pw_fd_);
|
||||
|
||||
// We have the PipeWire remote fd. Enumerate camera nodes.
|
||||
EnumeratePipeWireNodes();
|
||||
}
|
||||
|
||||
void PipeWirePortal::EnumeratePipeWireNodes() {
|
||||
std::vector<DeviceInfo> devices;
|
||||
|
||||
// Use GstDeviceMonitor to discover PipeWire camera sources.
|
||||
// This avoids linking against libpipewire directly.
|
||||
GstDeviceMonitor* monitor = gst_device_monitor_new();
|
||||
gst_device_monitor_add_filter(monitor, "Video/Source", nullptr);
|
||||
|
||||
// Start the monitor to populate the device list, then stop it.
|
||||
if (!gst_device_monitor_start(monitor)) {
|
||||
g_warning("PipeWirePortal: failed to start GstDeviceMonitor");
|
||||
gst_object_unref(monitor);
|
||||
FinishWithFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
GList* gst_devices = gst_device_monitor_get_devices(monitor);
|
||||
gst_device_monitor_stop(monitor);
|
||||
|
||||
for (GList* l = gst_devices; l != nullptr; l = l->next) {
|
||||
GstDevice* dev = GST_DEVICE(l->data);
|
||||
GstStructure* props = gst_device_get_properties(dev);
|
||||
if (!props) {
|
||||
g_info("[camera_desktop] PipeWirePortal: device has no properties,"
|
||||
" skipping");
|
||||
gst_object_unref(dev);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the PipeWire node id.
|
||||
const gchar* node_id_str = gst_structure_get_string(props, "node.id");
|
||||
if (!node_id_str) {
|
||||
node_id_str = gst_structure_get_string(props, "object.id");
|
||||
}
|
||||
|
||||
gchar* display_name = gst_device_get_display_name(dev);
|
||||
|
||||
DeviceInfo info;
|
||||
if (node_id_str) {
|
||||
info.device_path = std::string("pw:") + node_id_str;
|
||||
} else {
|
||||
// Fallback: use a serial number as identifier.
|
||||
g_info("[camera_desktop] PipeWirePortal: node.id/object.id not found"
|
||||
" for device '%s', using auto fallback id",
|
||||
display_name ? display_name : "(unknown)");
|
||||
static int fallback_id = 0;
|
||||
char fallback[32];
|
||||
snprintf(fallback, sizeof(fallback), "pw:auto%d", fallback_id++);
|
||||
info.device_path = fallback;
|
||||
}
|
||||
info.name = display_name ? display_name : "PipeWire Camera";
|
||||
info.bus_info = "pipewire";
|
||||
info.lens_direction = 2; // CameraLensDirection.external
|
||||
info.sensor_orientation = 0;
|
||||
|
||||
devices.push_back(info);
|
||||
|
||||
g_free(display_name);
|
||||
gst_structure_free(props);
|
||||
gst_object_unref(dev);
|
||||
}
|
||||
g_list_free(gst_devices);
|
||||
gst_object_unref(monitor);
|
||||
|
||||
g_info("[camera_desktop] PipeWire enumeration found %zu camera(s)", devices.size());
|
||||
for (const auto& d : devices) {
|
||||
g_info("[camera_desktop] → %s (%s)", d.name.c_str(), d.device_path.c_str());
|
||||
}
|
||||
|
||||
if (pending_callback_) {
|
||||
auto cb = std::move(pending_callback_);
|
||||
pending_callback_ = nullptr;
|
||||
cb(std::move(devices));
|
||||
}
|
||||
}
|
||||
|
||||
void PipeWirePortal::FinishWithFallback() {
|
||||
g_info("[camera_desktop] PipeWire path unavailable, falling back to V4L2");
|
||||
if (pending_callback_) {
|
||||
auto cb = std::move(pending_callback_);
|
||||
pending_callback_ = nullptr;
|
||||
cb({}); // Empty vector triggers V4L2 fallback in the caller.
|
||||
}
|
||||
}
|
||||
74
plugins/camera_desktop/linux/pipewire_portal.h
Normal file
74
plugins/camera_desktop/linux/pipewire_portal.h
Normal file
@@ -0,0 +1,74 @@
|
||||
#ifndef PIPEWIRE_PORTAL_H_
|
||||
#define PIPEWIRE_PORTAL_H_
|
||||
|
||||
#include <gio/gio.h>
|
||||
#include <gst/gst.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "device_enumerator.h"
|
||||
|
||||
// Manages XDG Desktop Portal camera interaction for Flatpak sandbox support.
|
||||
// Uses D-Bus to request camera permission via org.freedesktop.portal.Camera,
|
||||
// then enumerates PipeWire camera nodes via GstDeviceMonitor.
|
||||
//
|
||||
// Lifecycle: one instance per plugin lifetime, cached in PluginData.
|
||||
class PipeWirePortal {
|
||||
public:
|
||||
PipeWirePortal();
|
||||
~PipeWirePortal();
|
||||
|
||||
// Returns true if running inside a Flatpak sandbox.
|
||||
static bool IsFlatpak();
|
||||
|
||||
// Returns true if the pipewiresrc GStreamer element is available.
|
||||
static bool HasPipeWireSrc();
|
||||
|
||||
// Returns true if both IsFlatpak() and HasPipeWireSrc().
|
||||
static bool ShouldUsePipeWire();
|
||||
|
||||
// Asynchronously requests camera access via the portal and enumerates
|
||||
// PipeWire camera nodes. Calls |callback| on the main thread with results.
|
||||
// On failure (portal unavailable, user denied), returns empty vector.
|
||||
using EnumerateCallback =
|
||||
std::function<void(std::vector<DeviceInfo> devices)>;
|
||||
void EnumerateDevicesAsync(EnumerateCallback callback);
|
||||
|
||||
// Returns the PipeWire remote fd. -1 if not connected.
|
||||
// Valid after a successful EnumerateDevicesAsync.
|
||||
int pw_fd() const { return pw_fd_; }
|
||||
|
||||
// Returns default resolutions for PipeWire cameras.
|
||||
// PipeWire does not expose frame sizes through the portal; GStreamer
|
||||
// negotiates the actual format with the camera at pipeline start.
|
||||
static std::vector<ResolutionInfo> GetDefaultResolutions();
|
||||
|
||||
private:
|
||||
static void OnAccessCameraReply(GObject* source, GAsyncResult* res,
|
||||
gpointer user_data);
|
||||
static void OnPortalResponse(GDBusConnection* connection,
|
||||
const gchar* sender_name,
|
||||
const gchar* object_path,
|
||||
const gchar* interface_name,
|
||||
const gchar* signal_name,
|
||||
GVariant* parameters,
|
||||
gpointer user_data);
|
||||
|
||||
void HandleAccessResponse(guint32 response);
|
||||
void OpenPipeWireRemote();
|
||||
void EnumeratePipeWireNodes();
|
||||
void FinishWithFallback();
|
||||
|
||||
// Builds a unique request token for the portal handle.
|
||||
std::string MakeHandleToken();
|
||||
|
||||
GDBusConnection* connection_;
|
||||
int pw_fd_;
|
||||
guint signal_subscription_id_;
|
||||
EnumerateCallback pending_callback_;
|
||||
int request_counter_;
|
||||
};
|
||||
|
||||
#endif // PIPEWIRE_PORTAL_H_
|
||||
442
plugins/camera_desktop/linux/record_handler.cc
Normal file
442
plugins/camera_desktop/linux/record_handler.cc
Normal file
@@ -0,0 +1,442 @@
|
||||
#include "record_handler.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
// H-5: Maximum recording queue size.
|
||||
// Bounds RAM consumed by the recording branch if the encoder falls behind
|
||||
// (e.g., during an antivirus scan or CPU spike). Backpressure will propagate
|
||||
// upstream rather than silently consuming all available memory.
|
||||
static const guint64 kRecQueueMaxTimeNs = 3 * GST_SECOND; // 3 s time limit
|
||||
static const guint kRecQueueMaxBytes = 256 * 1024 * 1024; // 256 MB hard cap
|
||||
|
||||
// Video encoder candidates in order of preference.
|
||||
static const char* kEncoderCandidates[] = {
|
||||
"x264enc",
|
||||
"vah264enc",
|
||||
"vaapih264enc",
|
||||
"openh264enc",
|
||||
};
|
||||
static const int kNumEncoderCandidates = 4;
|
||||
|
||||
// Audio encoder candidates in order of preference.
|
||||
static const char* kAudioEncoderCandidates[] = {
|
||||
"opusenc",
|
||||
"avenc_aac",
|
||||
"voaacenc",
|
||||
"lamemp3enc",
|
||||
};
|
||||
static const int kNumAudioEncoderCandidates = 4;
|
||||
|
||||
RecordHandler::RecordHandler()
|
||||
: pipeline_(nullptr),
|
||||
tee_(nullptr),
|
||||
queue_(nullptr),
|
||||
valve_(nullptr),
|
||||
videoconvert_(nullptr),
|
||||
encoder_(nullptr),
|
||||
h264parse_(nullptr),
|
||||
muxer_(nullptr),
|
||||
filesink_(nullptr),
|
||||
audio_source_(nullptr),
|
||||
audio_convert_(nullptr),
|
||||
audio_resample_(nullptr),
|
||||
audio_encoder_(nullptr),
|
||||
audio_queue_(nullptr),
|
||||
audio_valve_(nullptr),
|
||||
is_recording_(false),
|
||||
is_setup_(false),
|
||||
has_audio_(false),
|
||||
pending_stop_call_(nullptr) {}
|
||||
|
||||
RecordHandler::~RecordHandler() {
|
||||
if (pending_stop_call_) {
|
||||
g_object_unref(pending_stop_call_);
|
||||
pending_stop_call_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
std::string RecordHandler::DetectEncoder() {
|
||||
for (int i = 0; i < kNumEncoderCandidates; i++) {
|
||||
GstElementFactory* factory =
|
||||
gst_element_factory_find(kEncoderCandidates[i]);
|
||||
if (factory) {
|
||||
gst_object_unref(factory);
|
||||
return kEncoderCandidates[i];
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string RecordHandler::DetectAudioEncoder() {
|
||||
for (int i = 0; i < kNumAudioEncoderCandidates; i++) {
|
||||
GstElementFactory* factory =
|
||||
gst_element_factory_find(kAudioEncoderCandidates[i]);
|
||||
if (factory) {
|
||||
gst_object_unref(factory);
|
||||
return kAudioEncoderCandidates[i];
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
bool RecordHandler::Setup(GstElement* pipeline, GstElement* tee,
|
||||
int width, int height, int fps, int video_bitrate,
|
||||
int audio_bitrate, bool enable_audio,
|
||||
GError** error) {
|
||||
if (is_setup_) return true;
|
||||
|
||||
encoder_name_ = DetectEncoder();
|
||||
if (encoder_name_.empty()) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"No H.264 encoder available. Install gstreamer1.0-plugins-ugly "
|
||||
"(x264enc) or gstreamer1.0-vaapi (vaapih264enc).");
|
||||
return false;
|
||||
}
|
||||
|
||||
pipeline_ = pipeline;
|
||||
tee_ = tee;
|
||||
|
||||
// Create video recording branch elements.
|
||||
queue_ = gst_element_factory_make("queue", "rec_queue");
|
||||
valve_ = gst_element_factory_make("valve", "rec_valve");
|
||||
videoconvert_ = gst_element_factory_make("videoconvert", "rec_convert");
|
||||
encoder_ = gst_element_factory_make(encoder_name_.c_str(), "rec_encoder");
|
||||
h264parse_ = gst_element_factory_make("h264parse", "rec_h264parse");
|
||||
|
||||
// H-6: prefer mp4mux so the output file is a genuine MP4 container.
|
||||
// Fall back to matroskamux if mp4mux is unavailable; the output extension
|
||||
// is set accordingly in camera.cc so the container and extension always match.
|
||||
muxer_ = gst_element_factory_make("mp4mux", "rec_mux");
|
||||
if (!muxer_) {
|
||||
muxer_ = gst_element_factory_make("matroskamux", "rec_mux");
|
||||
using_matroskamux_ = true;
|
||||
}
|
||||
|
||||
filesink_ = gst_element_factory_make("filesink", "rec_filesink");
|
||||
|
||||
if (!queue_ || !valve_ || !videoconvert_ || !encoder_ || !h264parse_ ||
|
||||
!muxer_ || !filesink_) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Failed to create recording pipeline elements");
|
||||
return false;
|
||||
}
|
||||
|
||||
// mp4mux/matroskamux expect byte-stream H.264; encoders link only through
|
||||
// h264parse (also sets stream-format for MP4).
|
||||
g_object_set(h264parse_, "config-interval", 1, nullptr);
|
||||
|
||||
// Configure the valve to start closed (dropping all data).
|
||||
g_object_set(valve_, "drop", TRUE, nullptr);
|
||||
|
||||
// H-5: bound the recording queue so the process cannot OOM if the encoder
|
||||
// stalls. Use time-based limiting (3 s) plus a 256 MB hard cap.
|
||||
// leaky=no means backpressure propagates upstream rather than silently
|
||||
// dropping frames, preserving recording integrity.
|
||||
g_object_set(queue_,
|
||||
"max-size-buffers", (guint)0,
|
||||
"max-size-time", kRecQueueMaxTimeNs,
|
||||
"max-size-bytes", kRecQueueMaxBytes,
|
||||
"leaky", (gint)0, // GST_QUEUE_NO_LEAK
|
||||
nullptr);
|
||||
|
||||
// Configure encoder settings based on type.
|
||||
if (encoder_name_ == "x264enc") {
|
||||
int x264_kbps = 4000;
|
||||
if (video_bitrate > 0) {
|
||||
x264_kbps = video_bitrate / 1000;
|
||||
if (x264_kbps <= 0) x264_kbps = 1;
|
||||
}
|
||||
g_object_set(encoder_, "tune", 4 /* zerolatency */, "speed-preset", 2
|
||||
/* superfast */, "bitrate", x264_kbps, nullptr);
|
||||
} else if (encoder_name_ == "openh264enc") {
|
||||
int openh264_bps = video_bitrate > 0 ? video_bitrate : 4000000;
|
||||
g_object_set(encoder_, "bitrate", openh264_bps, nullptr);
|
||||
} else if (encoder_name_ == "vah264enc" || encoder_name_ == "vaapih264enc") {
|
||||
if (video_bitrate > 0) {
|
||||
g_object_set(encoder_, "bitrate", video_bitrate / 1000, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
// Add all video elements to the pipeline.
|
||||
gst_bin_add_many(GST_BIN(pipeline_), queue_, valve_, videoconvert_,
|
||||
encoder_, h264parse_, muxer_, filesink_, nullptr);
|
||||
|
||||
// Link: queue → valve → videoconvert → encoder → h264parse → muxer → filesink
|
||||
if (!gst_element_link_many(queue_, valve_, videoconvert_, encoder_,
|
||||
h264parse_, muxer_, filesink_, nullptr)) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Failed to link recording pipeline elements");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Link tee to the recording queue.
|
||||
GstPad* tee_pad = gst_element_request_pad_simple(tee_, "src_%u");
|
||||
GstPad* queue_pad = gst_element_get_static_pad(queue_, "sink");
|
||||
GstPadLinkReturn link_ret = gst_pad_link(tee_pad, queue_pad);
|
||||
gst_object_unref(queue_pad);
|
||||
gst_object_unref(tee_pad);
|
||||
|
||||
if (link_ret != GST_PAD_LINK_OK) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Failed to link tee to recording branch");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sync video element states with the pipeline.
|
||||
gst_element_sync_state_with_parent(queue_);
|
||||
gst_element_sync_state_with_parent(valve_);
|
||||
gst_element_sync_state_with_parent(videoconvert_);
|
||||
gst_element_sync_state_with_parent(encoder_);
|
||||
gst_element_sync_state_with_parent(h264parse_);
|
||||
gst_element_sync_state_with_parent(muxer_);
|
||||
gst_element_sync_state_with_parent(filesink_);
|
||||
|
||||
// Set up audio branch if requested.
|
||||
if (enable_audio) {
|
||||
GError* audio_error = nullptr;
|
||||
if (SetupAudioBranch(audio_bitrate, &audio_error)) {
|
||||
has_audio_ = true;
|
||||
} else {
|
||||
// Audio setup failed, log warning but continue without audio.
|
||||
g_warning("Audio setup failed: %s. Recording without audio.",
|
||||
audio_error ? audio_error->message : "unknown error");
|
||||
if (audio_error) g_error_free(audio_error);
|
||||
has_audio_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
is_setup_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RecordHandler::SetupAudioBranch(int audio_bitrate, GError** error) {
|
||||
audio_encoder_name_ = DetectAudioEncoder();
|
||||
if (audio_encoder_name_.empty()) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"No audio encoder available");
|
||||
return false;
|
||||
}
|
||||
|
||||
audio_source_ = gst_element_factory_make("autoaudiosrc", "rec_audio_src");
|
||||
audio_convert_ = gst_element_factory_make("audioconvert", "rec_audio_conv");
|
||||
audio_resample_ =
|
||||
gst_element_factory_make("audioresample", "rec_audio_resample");
|
||||
audio_encoder_ = gst_element_factory_make(audio_encoder_name_.c_str(),
|
||||
"rec_audio_enc");
|
||||
audio_queue_ = gst_element_factory_make("queue", "rec_audio_queue");
|
||||
audio_valve_ = gst_element_factory_make("valve", "rec_audio_valve");
|
||||
|
||||
if (!audio_source_ || !audio_convert_ || !audio_resample_ ||
|
||||
!audio_encoder_ || !audio_queue_ || !audio_valve_) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Failed to create audio pipeline elements");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start with audio valve closed.
|
||||
g_object_set(audio_valve_, "drop", TRUE, nullptr);
|
||||
|
||||
if (audio_bitrate > 0) {
|
||||
g_object_set(audio_encoder_, "bitrate", audio_bitrate, nullptr);
|
||||
}
|
||||
|
||||
// Add audio elements to pipeline.
|
||||
gst_bin_add_many(GST_BIN(pipeline_), audio_source_, audio_queue_,
|
||||
audio_valve_, audio_convert_, audio_resample_,
|
||||
audio_encoder_, nullptr);
|
||||
|
||||
// Link: autoaudiosrc → queue → valve → audioconvert → audioresample →
|
||||
// encoder
|
||||
if (!gst_element_link_many(audio_source_, audio_queue_, audio_valve_,
|
||||
audio_convert_, audio_resample_, audio_encoder_,
|
||||
nullptr)) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Failed to link audio pipeline elements");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Link audio encoder to the muxer.
|
||||
GstPad* audio_src = gst_element_get_static_pad(audio_encoder_, "src");
|
||||
GstPad* mux_audio_sink =
|
||||
gst_element_request_pad_simple(muxer_, "audio_%u");
|
||||
if (!audio_src || !mux_audio_sink) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Failed to get audio pads for muxer");
|
||||
if (audio_src) gst_object_unref(audio_src);
|
||||
if (mux_audio_sink) gst_object_unref(mux_audio_sink);
|
||||
return false;
|
||||
}
|
||||
|
||||
GstPadLinkReturn ret = gst_pad_link(audio_src, mux_audio_sink);
|
||||
gst_object_unref(audio_src);
|
||||
gst_object_unref(mux_audio_sink);
|
||||
|
||||
if (ret != GST_PAD_LINK_OK) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Failed to link audio encoder to muxer");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sync audio element states.
|
||||
gst_element_sync_state_with_parent(audio_source_);
|
||||
gst_element_sync_state_with_parent(audio_queue_);
|
||||
gst_element_sync_state_with_parent(audio_valve_);
|
||||
gst_element_sync_state_with_parent(audio_convert_);
|
||||
gst_element_sync_state_with_parent(audio_resample_);
|
||||
gst_element_sync_state_with_parent(audio_encoder_);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RecordHandler::StartRecording(const std::string& output_path,
|
||||
GError** error) {
|
||||
if (is_recording_) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Recording is already in progress");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is_setup_) {
|
||||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||||
"Recording pipeline not set up");
|
||||
return false;
|
||||
}
|
||||
|
||||
output_path_ = output_path;
|
||||
|
||||
// Reset the muxer and filesink states to accept new data.
|
||||
gst_element_set_state(muxer_, GST_STATE_NULL);
|
||||
gst_element_set_state(filesink_, GST_STATE_NULL);
|
||||
g_object_set(filesink_, "location", output_path.c_str(), nullptr);
|
||||
gst_element_sync_state_with_parent(muxer_);
|
||||
gst_element_sync_state_with_parent(filesink_);
|
||||
|
||||
// Open the video valve to let data flow.
|
||||
g_object_set(valve_, "drop", FALSE, nullptr);
|
||||
|
||||
// Open the audio valve if audio is enabled.
|
||||
if (has_audio_ && audio_valve_) {
|
||||
g_object_set(audio_valve_, "drop", FALSE, nullptr);
|
||||
}
|
||||
|
||||
is_recording_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
struct StopRecordingData {
|
||||
RecordHandler* handler;
|
||||
FlMethodCall* method_call;
|
||||
std::string output_path;
|
||||
std::string container;
|
||||
std::string video_codec;
|
||||
std::string audio_codec;
|
||||
};
|
||||
|
||||
GstPadProbeReturn RecordHandler::OnEosEvent(GstPad* pad,
|
||||
GstPadProbeInfo* info,
|
||||
gpointer user_data) {
|
||||
if (GST_EVENT_TYPE(GST_PAD_PROBE_INFO_EVENT(info)) != GST_EVENT_EOS) {
|
||||
return GST_PAD_PROBE_PASS;
|
||||
}
|
||||
|
||||
StopRecordingData* data = static_cast<StopRecordingData*>(user_data);
|
||||
|
||||
// Respond on the main thread.
|
||||
g_idle_add(
|
||||
[](gpointer user_data) -> gboolean {
|
||||
StopRecordingData* data = static_cast<StopRecordingData*>(user_data);
|
||||
|
||||
g_autoptr(FlValue) result = fl_value_new_map();
|
||||
fl_value_set_string_take(result, "path",
|
||||
fl_value_new_string(data->output_path.c_str()));
|
||||
fl_value_set_string_take(result, "container",
|
||||
fl_value_new_string(data->container.c_str()));
|
||||
fl_value_set_string_take(result, "videoCodec",
|
||||
fl_value_new_string(data->video_codec.c_str()));
|
||||
fl_value_set_string_take(result, "audioCodec",
|
||||
fl_value_new_string(data->audio_codec.c_str()));
|
||||
fl_method_call_respond_success(data->method_call, result, nullptr);
|
||||
g_object_unref(data->method_call);
|
||||
|
||||
data->handler->is_recording_ = false;
|
||||
delete data;
|
||||
return G_SOURCE_REMOVE;
|
||||
},
|
||||
data);
|
||||
|
||||
return GST_PAD_PROBE_REMOVE;
|
||||
}
|
||||
|
||||
void RecordHandler::StopRecording(FlMethodCall* method_call) {
|
||||
if (!is_recording_) {
|
||||
g_autoptr(FlValue) details = fl_value_new_null();
|
||||
fl_method_call_respond_error(method_call, "not_recording",
|
||||
"No recording in progress", details, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up an EOS probe on the filesink's sink pad BEFORE sending EOS so we
|
||||
// don't miss the event.
|
||||
GstPad* filesink_pad = gst_element_get_static_pad(filesink_, "sink");
|
||||
|
||||
StopRecordingData* data = new StopRecordingData();
|
||||
data->handler = this;
|
||||
data->method_call = FL_METHOD_CALL(g_object_ref(method_call));
|
||||
data->output_path = output_path_;
|
||||
data->container = output_extension();
|
||||
data->video_codec = encoder_name_;
|
||||
data->audio_codec = has_audio_ ? audio_encoder_name_ : "";
|
||||
|
||||
if (filesink_pad) {
|
||||
gst_pad_add_probe(filesink_pad, GST_PAD_PROBE_TYPE_EVENT_DOWNSTREAM,
|
||||
RecordHandler::OnEosEvent, data, nullptr);
|
||||
gst_object_unref(filesink_pad);
|
||||
}
|
||||
|
||||
// M-5 FIX: Send EOS to the valve's sink pad (not the encoder's sink pad).
|
||||
// The GStreamer valve element passes events (including EOS) downstream even
|
||||
// when drop=TRUE. Sending EOS here propagates correctly through the full
|
||||
// chain: valve → videoconvert → encoder → muxer → filesink, giving each
|
||||
// element a chance to flush its internal state before the file is closed.
|
||||
//
|
||||
// Close the valve AFTER sending EOS so that EOS is ordered after any frames
|
||||
// still in-flight between the tee and the valve's input.
|
||||
GstPad* valve_sink = gst_element_get_static_pad(valve_, "sink");
|
||||
if (valve_sink) {
|
||||
gst_pad_send_event(valve_sink, gst_event_new_eos());
|
||||
gst_object_unref(valve_sink);
|
||||
}
|
||||
// Now close the valve to block any subsequent tee data from entering the
|
||||
// recording branch (the EOS already committed the end of the stream).
|
||||
g_object_set(valve_, "drop", TRUE, nullptr);
|
||||
|
||||
// Audio branch: close the audio valve and send EOS to the audio encoder.
|
||||
if (has_audio_ && audio_encoder_) {
|
||||
if (audio_valve_) {
|
||||
g_object_set(audio_valve_, "drop", TRUE, nullptr);
|
||||
}
|
||||
GstPad* audio_enc_sink =
|
||||
gst_element_get_static_pad(audio_encoder_, "sink");
|
||||
if (audio_enc_sink) {
|
||||
gst_pad_send_event(audio_enc_sink, gst_event_new_eos());
|
||||
gst_object_unref(audio_enc_sink);
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't set up the probe, respond immediately.
|
||||
if (!filesink_pad) {
|
||||
g_autoptr(FlValue) result = fl_value_new_map();
|
||||
fl_value_set_string_take(result, "path",
|
||||
fl_value_new_string(data->output_path.c_str()));
|
||||
fl_value_set_string_take(result, "container",
|
||||
fl_value_new_string(data->container.c_str()));
|
||||
fl_value_set_string_take(result, "videoCodec",
|
||||
fl_value_new_string(data->video_codec.c_str()));
|
||||
fl_value_set_string_take(result, "audioCodec",
|
||||
fl_value_new_string(data->audio_codec.c_str()));
|
||||
fl_method_call_respond_success(method_call, result, nullptr);
|
||||
g_object_unref(data->method_call);
|
||||
delete data;
|
||||
is_recording_ = false;
|
||||
}
|
||||
}
|
||||
97
plugins/camera_desktop/linux/record_handler.h
Normal file
97
plugins/camera_desktop/linux/record_handler.h
Normal file
@@ -0,0 +1,97 @@
|
||||
#ifndef RECORD_HANDLER_H_
|
||||
#define RECORD_HANDLER_H_
|
||||
|
||||
#include <gst/gst.h>
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
// Manages a video recording branch using a tee + valve + encoder + mux pipeline.
|
||||
//
|
||||
// Video pipeline:
|
||||
// tee → queue → valve → videoconvert → encoder → h264parse → mux → filesink
|
||||
//
|
||||
// Audio pipeline (optional, when enable_audio is true):
|
||||
// autoaudiosrc → audioconvert → audioresample → opusenc → mux
|
||||
//
|
||||
// The valve starts closed (drop=true). When recording starts, the valve opens
|
||||
// and frames flow through to the encoder. When recording stops, the valve
|
||||
// closes and an EOS event is sent downstream to finalize the file.
|
||||
class RecordHandler {
|
||||
public:
|
||||
RecordHandler();
|
||||
~RecordHandler();
|
||||
|
||||
// Detects the best available H.264 encoder at runtime.
|
||||
// Returns the GStreamer element factory name, or empty string if none found.
|
||||
static std::string DetectEncoder();
|
||||
|
||||
// Detects the best available audio encoder at runtime.
|
||||
static std::string DetectAudioEncoder();
|
||||
|
||||
// Sets up the recording branch and attaches it to the tee element.
|
||||
// |tee| is the pipeline tee element to branch from.
|
||||
// |width| and |height| are the video dimensions.
|
||||
// |fps| is the target frame rate.
|
||||
// |enable_audio| adds an audio source and encoder to the recording.
|
||||
// Returns true on success; sets |error| on failure.
|
||||
bool Setup(GstElement* pipeline, GstElement* tee,
|
||||
int width, int height, int fps, int video_bitrate,
|
||||
int audio_bitrate, bool enable_audio, GError** error);
|
||||
|
||||
// Starts recording to the given file path.
|
||||
// Returns true on success; sets |error| on failure.
|
||||
bool StartRecording(const std::string& output_path, GError** error);
|
||||
|
||||
// Stops recording. Sends EOS through the recording branch and waits
|
||||
// for the file to be finalized. |method_call| is responded to
|
||||
// asynchronously when the file is ready (or an error occurs).
|
||||
void StopRecording(FlMethodCall* method_call);
|
||||
|
||||
bool is_recording() const { return is_recording_; }
|
||||
bool has_audio() const { return has_audio_; }
|
||||
const std::string& encoder_name() const { return encoder_name_; }
|
||||
const std::string& audio_encoder_name() const { return audio_encoder_name_; }
|
||||
|
||||
// H-6: returns the correct file extension for the muxer that was selected.
|
||||
// "mp4" if mp4mux is available, "mkv" if matroskamux was the fallback.
|
||||
const char* output_extension() const {
|
||||
return using_matroskamux_ ? "mkv" : "mp4";
|
||||
}
|
||||
|
||||
private:
|
||||
static GstPadProbeReturn OnEosEvent(GstPad* pad, GstPadProbeInfo* info,
|
||||
gpointer user_data);
|
||||
|
||||
bool SetupAudioBranch(int audio_bitrate, GError** error);
|
||||
|
||||
GstElement* pipeline_; // Not owned.
|
||||
GstElement* tee_; // Not owned.
|
||||
GstElement* queue_; // Owned by pipeline.
|
||||
GstElement* valve_; // Owned by pipeline.
|
||||
GstElement* videoconvert_; // Owned by pipeline.
|
||||
GstElement* encoder_; // Owned by pipeline.
|
||||
GstElement* h264parse_; // Owned by pipeline.
|
||||
GstElement* muxer_; // Owned by pipeline.
|
||||
GstElement* filesink_; // Owned by pipeline.
|
||||
|
||||
// Audio elements (optional).
|
||||
GstElement* audio_source_; // Owned by pipeline.
|
||||
GstElement* audio_convert_; // Owned by pipeline.
|
||||
GstElement* audio_resample_; // Owned by pipeline.
|
||||
GstElement* audio_encoder_; // Owned by pipeline.
|
||||
GstElement* audio_queue_; // Owned by pipeline.
|
||||
GstElement* audio_valve_; // Owned by pipeline.
|
||||
|
||||
std::string encoder_name_;
|
||||
std::string audio_encoder_name_;
|
||||
std::string output_path_;
|
||||
bool is_recording_;
|
||||
bool is_setup_;
|
||||
bool has_audio_;
|
||||
bool using_matroskamux_ = false; // H-6: true when mp4mux was unavailable
|
||||
|
||||
FlMethodCall* pending_stop_call_; // Pending stop response.
|
||||
};
|
||||
|
||||
#endif // RECORD_HANDLER_H_
|
||||
33
plugins/camera_desktop/linux/test/CMakeLists.txt
Normal file
33
plugins/camera_desktop/linux/test/CMakeLists.txt
Normal file
@@ -0,0 +1,33 @@
|
||||
find_package(GTest QUIET)
|
||||
if(NOT GTest_FOUND)
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
googletest
|
||||
URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.tar.gz
|
||||
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
|
||||
)
|
||||
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(googletest)
|
||||
endif()
|
||||
|
||||
set(TEST_BINARY "camera_desktop_plugin_test")
|
||||
|
||||
add_executable(${TEST_BINARY}
|
||||
camera_desktop_plugin_test.cc
|
||||
)
|
||||
|
||||
target_compile_features(${TEST_BINARY} PRIVATE cxx_std_14)
|
||||
|
||||
target_include_directories(${TEST_BINARY} PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/.."
|
||||
)
|
||||
|
||||
target_link_libraries(${TEST_BINARY} PRIVATE
|
||||
camera_desktop_plugin
|
||||
flutter
|
||||
GTest::gtest_main
|
||||
GTest::gmock
|
||||
)
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(${TEST_BINARY})
|
||||
@@ -0,0 +1,20 @@
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "include/camera_desktop/camera_desktop_plugin.h"
|
||||
|
||||
namespace camera_desktop {
|
||||
namespace test {
|
||||
|
||||
TEST(CameraDesktopPlugin, PluginRegistration) {
|
||||
// Verify the plugin registration function exists and is callable.
|
||||
// Full lifecycle testing requires a running Flutter engine, so this
|
||||
// just validates the symbol is exported.
|
||||
EXPECT_NE(
|
||||
reinterpret_cast<void*>(&camera_desktop_plugin_register_with_registrar),
|
||||
nullptr);
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
} // namespace camera_desktop
|
||||
Reference in New Issue
Block a user