#include "camera.h" #include "photo_handler.h" #include #include #include #include #include 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()), 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(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(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(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(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(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(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(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 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(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(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(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 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_); }