Files
max_map_camera_flutter/plugins/camera_desktop/linux/camera.cc
2026-06-22 09:39:31 +07:00

772 lines
28 KiB
C++

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