add plugin camera desktop

This commit is contained in:
kyo
2026-06-22 09:39:31 +07:00
parent 92daeef0c5
commit 5407afbd6e
144 changed files with 15900 additions and 1 deletions

View 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()

View 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_);
}

View 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_

View 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);
}

View 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);
}

View 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_

View 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};
}

View 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_

View 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"

View File

@@ -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_

View 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;
}

View 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_

View 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.
}
}

View 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_

View 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;
}
}

View 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_

View 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})

View File

@@ -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