From 0922865f635b515fda05a076de396f163db8bad8 Mon Sep 17 00:00:00 2001 From: Emanuel Kagombora Date: Fri, 7 Aug 2026 09:37:23 +0300 Subject: [PATCH 1/2] feat(av_tools): improve camera capture with configurable settings --- .../av_tools_settings/av_tools_settings.json | 65 +++ .../test_av_tools_settings.py | 78 +++- av_tools/av_tools_hooks/capture.py | 42 ++ av_tools/hooks.py | 1 + av_tools/public/av_tools.bundle.js | 1 + av_tools/public/js/capture_override.js | 410 ++++++++++++++++++ 6 files changed, 587 insertions(+), 10 deletions(-) create mode 100644 av_tools/av_tools_hooks/capture.py create mode 100644 av_tools/public/js/capture_override.js diff --git a/av_tools/av_tools/doctype/av_tools_settings/av_tools_settings.json b/av_tools/av_tools/doctype/av_tools_settings/av_tools_settings.json index 7b85f4d..9b24ad7 100644 --- a/av_tools/av_tools/doctype/av_tools_settings/av_tools_settings.json +++ b/av_tools/av_tools/doctype/av_tools_settings/av_tools_settings.json @@ -28,6 +28,14 @@ "enable_trade_in", "item_remaining_qty_section", "enable_validate_item_remaining_qty", + "camera_capture_section", + "enable_camera_capture_override", + "force_web_capture_on_mobile", + "camera_capture_column_break", + "camera_capture_ideal_width", + "camera_capture_ideal_height", + "camera_capture_min_width", + "camera_capture_min_height", "parallel_approval_section", "enable_multi_approval_document", "approval_doctype" @@ -173,6 +181,63 @@ "fieldname": "enable_validate_item_remaining_qty", "fieldtype": "Check", "label": "Enable Item Remaining Qty Validation" + }, + { + "fieldname": "camera_capture_section", + "fieldtype": "Section Break", + "label": "Camera Capture" + }, + { + "default": "0", + "description": "Improve browser camera capture by applying AV Tools camera constraints before a photo is taken.", + "fieldname": "enable_camera_capture_override", + "fieldtype": "Check", + "label": "Enable Camera Capture Override" + }, + { + "default": "0", + "depends_on": "eval:doc.enable_camera_capture_override == 1", + "description": "When enabled, mobile users will use the browser camera dialog instead of the native device capture flow.", + "fieldname": "force_web_capture_on_mobile", + "fieldtype": "Check", + "label": "Force Web Capture On Mobile" + }, + { + "depends_on": "eval:doc.enable_camera_capture_override == 1", + "fieldname": "camera_capture_column_break", + "fieldtype": "Column Break" + }, + { + "default": "1920", + "depends_on": "eval:doc.enable_camera_capture_override == 1", + "description": "Preferred capture width requested from the browser camera stream.", + "fieldname": "camera_capture_ideal_width", + "fieldtype": "Int", + "label": "Ideal Width" + }, + { + "default": "1080", + "depends_on": "eval:doc.enable_camera_capture_override == 1", + "description": "Preferred capture height requested from the browser camera stream.", + "fieldname": "camera_capture_ideal_height", + "fieldtype": "Int", + "label": "Ideal Height" + }, + { + "default": "0", + "depends_on": "eval:doc.enable_camera_capture_override == 1", + "description": "Optional minimum width. Leave 0 to avoid enforcing a minimum width.", + "fieldname": "camera_capture_min_width", + "fieldtype": "Int", + "label": "Minimum Width" + }, + { + "default": "0", + "depends_on": "eval:doc.enable_camera_capture_override == 1", + "description": "Optional minimum height. Leave 0 to avoid enforcing a minimum height.", + "fieldname": "camera_capture_min_height", + "fieldtype": "Int", + "label": "Minimum Height" }, { "fieldname": "parallel_approval_section", diff --git a/av_tools/av_tools/doctype/av_tools_settings/test_av_tools_settings.py b/av_tools/av_tools/doctype/av_tools_settings/test_av_tools_settings.py index 5a17095..65424a4 100644 --- a/av_tools/av_tools/doctype/av_tools_settings/test_av_tools_settings.py +++ b/av_tools/av_tools/doctype/av_tools_settings/test_av_tools_settings.py @@ -1,12 +1,17 @@ import json from contextlib import contextmanager +from typing import ClassVar from unittest.mock import patch import frappe +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.stock.get_item_details import get_item_details as original_get_item_details from frappe.core.doctype.user.test_user import test_user from frappe.tests.utils import FrappeTestCase from av_tools.av_tools.doctype.av_tools_settings.av_tools_settings import AVToolsSettings +from av_tools.av_tools_hooks.capture import get_capture_settings from av_tools.av_tools_hooks.generic_erp_behavior_overrides import ( close_or_unclose_purchase_orders, get_item_details, @@ -15,20 +20,25 @@ ) from av_tools.patches.v1_0.migrate_generic_erp_behavior_overrides import ( SETTINGS_DOCTYPE, +) +from av_tools.patches.v1_0.migrate_generic_erp_behavior_overrides import ( execute as migrate_generic_settings, ) -from erpnext.accounts.test.accounts_mixin import AccountsTestMixin -from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order -from erpnext.stock.get_item_details import get_item_details as original_get_item_details class TestAVToolsSettings(AccountsTestMixin, FrappeTestCase): - settings_defaults = { + settings_defaults: ClassVar[dict[str, object]] = { "allow_reopen_of_po_based_on_role": 0, "role_to_reopen_po": "", "allow_reopen_of_material_request_based_on_role": 0, "role_to_reopen_material_request": "", "override_sales_invoice_qty": 0, + "enable_camera_capture_override": 0, + "force_web_capture_on_mobile": 0, + "camera_capture_ideal_width": 1920, + "camera_capture_ideal_height": 1080, + "camera_capture_min_width": 0, + "camera_capture_min_height": 0, } test_company_name = "Rubis Technical Services Limited" test_supplier_name = "AV Tools Test Supplier" @@ -147,12 +157,15 @@ def test_patch_migrates_settings_idempotently(self): "override_sales_invoice_qty": 1, } - with patch( - "av_tools.patches.v1_0.migrate_generic_erp_behavior_overrides.source_settings_doctype_exists", - return_value=True, - ), patch( - "av_tools.patches.v1_0.migrate_generic_erp_behavior_overrides.get_source_values", - return_value=expected_values, + with ( + patch( + "av_tools.patches.v1_0.migrate_generic_erp_behavior_overrides.source_settings_doctype_exists", + return_value=True, + ), + patch( + "av_tools.patches.v1_0.migrate_generic_erp_behavior_overrides.get_source_values", + return_value=expected_values, + ), ): migrate_generic_settings() migrate_generic_settings() @@ -324,3 +337,48 @@ def make_item_details_args(self): "ignore_pricing_rule": 1, "qty": 1, } + + +class TestAVToolsCaptureSettings(FrappeTestCase): + settings_defaults: ClassVar[dict[str, object]] = { + "enable_camera_capture_override": 0, + "force_web_capture_on_mobile": 0, + "camera_capture_ideal_width": 1920, + "camera_capture_ideal_height": 1080, + "camera_capture_min_width": 0, + "camera_capture_min_height": 0, + } + + def setUp(self): + frappe.set_user("Administrator") + self.set_settings() + + def tearDown(self): + frappe.set_user("Administrator") + self.set_settings() + frappe.db.rollback() + super().tearDown() + + def set_settings(self, **overrides): + values = {**self.settings_defaults, **overrides} + for fieldname, value in values.items(): + frappe.db.set_single_value(SETTINGS_DOCTYPE, fieldname, value) + + def test_get_capture_settings_uses_single_doctype_values(self): + self.set_settings( + enable_camera_capture_override=1, + force_web_capture_on_mobile=1, + camera_capture_ideal_width=2560, + camera_capture_ideal_height=1440, + camera_capture_min_width=1280, + camera_capture_min_height=720, + ) + + settings = get_capture_settings() + + self.assertTrue(settings["enabled"]) + self.assertTrue(settings["force_web_capture_on_mobile"]) + self.assertEqual(settings["ideal_width"], 2560) + self.assertEqual(settings["ideal_height"], 1440) + self.assertEqual(settings["min_width"], 1280) + self.assertEqual(settings["min_height"], 720) diff --git a/av_tools/av_tools_hooks/capture.py b/av_tools/av_tools_hooks/capture.py new file mode 100644 index 0000000..9c184a8 --- /dev/null +++ b/av_tools/av_tools_hooks/capture.py @@ -0,0 +1,42 @@ +import frappe +from frappe.utils import cint + +SETTINGS_DOCTYPE = "AV Tools Settings" +DEFAULT_CAPTURE_SETTINGS = { + "enabled": False, + "force_web_capture_on_mobile": False, + "ideal_width": 1920, + "ideal_height": 1080, + "min_width": 0, + "min_height": 0, +} + + +def _sanitize_positive_int(value, default=0): + value = cint(value) + if value > 0: + return value + return default + + +def _get_capture_settings(): + settings = DEFAULT_CAPTURE_SETTINGS.copy() + + try: + values = frappe.db.get_singles_dict(SETTINGS_DOCTYPE) or {} + except Exception: + return settings + + settings["enabled"] = bool(cint(values.get("enable_camera_capture_override"))) + settings["force_web_capture_on_mobile"] = bool(cint(values.get("force_web_capture_on_mobile"))) + settings["ideal_width"] = _sanitize_positive_int(values.get("camera_capture_ideal_width"), 1920) + settings["ideal_height"] = _sanitize_positive_int(values.get("camera_capture_ideal_height"), 1080) + settings["min_width"] = _sanitize_positive_int(values.get("camera_capture_min_width"), 0) + settings["min_height"] = _sanitize_positive_int(values.get("camera_capture_min_height"), 0) + + return settings + + +@frappe.whitelist() +def get_capture_settings(): + return _get_capture_settings() diff --git a/av_tools/hooks.py b/av_tools/hooks.py index a596e43..3a28e34 100644 --- a/av_tools/hooks.py +++ b/av_tools/hooks.py @@ -30,6 +30,7 @@ "/assets/av_tools/js/financial_statements_override.js", "/assets/av_tools/js/ai_assist.js", "/assets/av_tools/js/parallel_approval.js", + "av_tools.bundle.js", ] app_include_css = "/assets/av_tools/css/theme.css" diff --git a/av_tools/public/av_tools.bundle.js b/av_tools/public/av_tools.bundle.js index b2b1247..74439ca 100644 --- a/av_tools/public/av_tools.bundle.js +++ b/av_tools/public/av_tools.bundle.js @@ -1 +1,2 @@ import "./js/financial_statements_override"; +import "./js/capture_override"; diff --git a/av_tools/public/js/capture_override.js b/av_tools/public/js/capture_override.js new file mode 100644 index 0000000..d93df2a --- /dev/null +++ b/av_tools/public/js/capture_override.js @@ -0,0 +1,410 @@ +(function () { + frappe.provide("av_tools.capture_override"); + + var defaultCaptureSettings = { + enabled: 0, + force_web_capture_on_mobile: 0, + ideal_width: 1920, + ideal_height: 1080, + min_width: 0, + min_height: 0, + }; + var captureSettingsCache = null; + var captureSettingsPromise = null; + + function parsePositiveInt(value, fallback) { + var parsed = parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } + + function normalizeCaptureSettings(settings) { + return { + enabled: Boolean(Number(settings && settings.enabled)), + force_web_capture_on_mobile: Boolean( + Number(settings && settings.force_web_capture_on_mobile) + ), + ideal_width: parsePositiveInt(settings && settings.ideal_width, 1920), + ideal_height: parsePositiveInt(settings && settings.ideal_height, 1080), + min_width: parsePositiveInt(settings && settings.min_width, 0), + min_height: parsePositiveInt(settings && settings.min_height, 0), + }; + } + + function requestCaptureSettings() { + if (captureSettingsPromise) { + return captureSettingsPromise; + } + + captureSettingsPromise = new Promise(function (resolve) { + frappe.call({ + method: "av_tools.av_tools_hooks.capture.get_capture_settings", + callback: function (response) { + captureSettingsCache = normalizeCaptureSettings( + response.message || defaultCaptureSettings + ); + resolve(captureSettingsCache); + }, + error: function () { + captureSettingsCache = normalizeCaptureSettings(defaultCaptureSettings); + resolve(captureSettingsCache); + }, + }); + }).finally(function () { + captureSettingsPromise = null; + }); + + return captureSettingsPromise; + } + + function getCachedCaptureSettings(capture) { + if (capture && capture.__av_tools_capture_settings) { + return Promise.resolve(capture.__av_tools_capture_settings); + } + + if (captureSettingsCache) { + var settings = normalizeCaptureSettings(captureSettingsCache); + if (capture) { + capture.__av_tools_capture_settings = settings; + } + return Promise.resolve(settings); + } + + return Promise.resolve(null); + } + + function getCaptureSettings(capture, forceRefresh) { + if (!forceRefresh) { + return getCachedCaptureSettings(capture).then(function (settings) { + if (settings) { + return settings; + } + return requestCaptureSettings().then(function (freshSettings) { + if (capture) { + capture.__av_tools_capture_settings = freshSettings; + } + return freshSettings; + }); + }); + } + + return requestCaptureSettings().then(function (settings) { + if (capture) { + capture.__av_tools_capture_settings = settings; + } + return settings; + }); + } + + function warmCaptureSettingsCache() { + requestCaptureSettings().catch(function () { + return null; + }); + } + + function shouldForceAppWebCapture(settings) { + return settings.enabled && settings.force_web_capture_on_mobile; + } + + function shouldUseWebCaptureOnMobile(settings) { + var sysdefaults = (frappe.boot && frappe.boot.sysdefaults) || {}; + return ( + shouldForceAppWebCapture(settings) || + Boolean(Number(sysdefaults.force_web_capture_mode_for_uploads)) + ); + } + + function buildTrackConstraints(capture, settings) { + var constraints = { + facingMode: { + ideal: capture.facing_mode, + }, + }; + + if (settings.min_width || settings.ideal_width) { + constraints.width = {}; + if (settings.min_width) { + constraints.width.min = settings.min_width; + } + if (settings.ideal_width) { + constraints.width.ideal = settings.ideal_width; + } + } + + if (settings.min_height || settings.ideal_height) { + constraints.height = {}; + if (settings.min_height) { + constraints.height.min = settings.min_height; + } + if (settings.ideal_height) { + constraints.height.ideal = settings.ideal_height; + } + } + + if (settings.ideal_width || settings.ideal_height) { + constraints.advanced = [ + { + width: settings.ideal_width || undefined, + height: settings.ideal_height || undefined, + }, + ]; + } + + return constraints; + } + + function buildConstraints(capture, settings) { + return { + video: buildTrackConstraints(capture, settings), + audio: false, + }; + } + + function applyTrackConstraints(track, capture, settings) { + if (!track || typeof track.applyConstraints !== "function") { + return Promise.resolve(); + } + + return track + .applyConstraints(buildTrackConstraints(capture, settings)) + .catch(function (error) { + console.warn("[av_tools] Failed to apply camera constraints", error); + }); + } + + function blobToDataUrl(blob) { + return new Promise(function (resolve, reject) { + var reader = new FileReader(); + reader.onload = function () { + resolve(reader.result); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } + + function bitmapToPngDataUrl(bitmap) { + var canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext("2d").drawImage(bitmap, 0, 0); + + if (typeof bitmap.close === "function") { + bitmap.close(); + } + + return canvas.toDataURL("image/png"); + } + + function blobToPngDataUrl(blob) { + if (typeof createImageBitmap === "function") { + return createImageBitmap(blob).then(bitmapToPngDataUrl); + } + + return blobToDataUrl(blob).then(function (dataUrl) { + return new Promise(function (resolve, reject) { + var image = new Image(); + image.onload = function () { + var canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth || image.width; + canvas.height = image.naturalHeight || image.height; + canvas.getContext("2d").drawImage(image, 0, 0); + resolve(canvas.toDataURL("image/png")); + }; + image.onerror = reject; + image.src = dataUrl; + }); + }); + } + + function captureStillImage(capture) { + var fallbackImage = frappe._.get_data_uri(capture.video); + var track = + capture.stream && capture.stream.getVideoTracks && capture.stream.getVideoTracks()[0]; + + if (!track || typeof ImageCapture !== "function") { + return Promise.resolve(fallbackImage); + } + + try { + var imageCapture = new ImageCapture(track); + if (typeof imageCapture.takePhoto !== "function") { + return Promise.resolve(fallbackImage); + } + + return imageCapture + .takePhoto() + .then(blobToPngDataUrl) + .catch(function (error) { + console.warn("[av_tools] Falling back to stream-frame capture", error); + return fallbackImage; + }); + } catch (error) { + console.warn("[av_tools] Falling back to stream-frame capture", error); + return Promise.resolve(fallbackImage); + } + } + + function logCaptureDetails(capture, stream, constraints) { + if (!(frappe.boot && frappe.boot.developer_mode)) { + return; + } + + try { + var track = stream.getVideoTracks()[0]; + var actualSettings = track && track.getSettings ? track.getSettings() : {}; + var capabilities = track && track.getCapabilities ? track.getCapabilities() : {}; + console.info("[av_tools] Camera capture override active", { + requested_constraints: constraints, + actual_settings: actualSettings, + capabilities: capabilities, + video_dimensions: { + width: capture.video && capture.video.videoWidth, + height: capture.video && capture.video.videoHeight, + }, + }); + } catch (error) { + console.warn("[av_tools] Failed to log camera capture details", error); + } + } + + function renderStreamWithConstraints(capture, constraints, settings) { + return navigator.mediaDevices.getUserMedia(constraints).then(function (stream) { + capture.stream = stream; + var track = stream.getVideoTracks && stream.getVideoTracks()[0]; + + return applyTrackConstraints(track, capture, settings).then(function () { + capture.dialog.custom_actions.empty(); + capture.dialog.get_primary_btn().off("click"); + capture.setup_take_photo_action(); + capture.setup_preview_action(); + capture.setup_toggle_camera(); + + capture.$template.find(".fc-stream-container").show(); + capture.$template.find(".fc-preview-container").hide(); + capture.video = capture.$template.find("video")[0]; + capture.video.srcObject = capture.stream; + capture.video.load(); + + var playPromise = capture.video.play(); + if (playPromise && typeof playPromise.then === "function") { + return playPromise.then(function () { + logCaptureDetails(capture, stream, constraints); + }); + } + + logCaptureDetails(capture, stream, constraints); + return undefined; + }); + }); + } + + function installCaptureOverride() { + if ( + !frappe.ui || + !frappe.ui.Capture || + frappe.ui.Capture.__av_tools_capture_override_installed + ) { + return Boolean(frappe.ui && frappe.ui.Capture); + } + + var proto = frappe.ui.Capture.prototype; + var originalShow = proto.show; + var originalRenderStream = proto.render_stream; + var originalSetupTakePhotoAction = proto.setup_take_photo_action; + + proto.show = function () { + var me = this; + + if (frappe.is_mobile()) { + var cachedSettings = + me.__av_tools_capture_settings || + (captureSettingsCache && normalizeCaptureSettings(captureSettingsCache)); + + if (cachedSettings) { + me.__av_tools_capture_settings = cachedSettings; + } + + if (cachedSettings && shouldForceAppWebCapture(cachedSettings)) { + me.build_dialog(); + me.show_for_desktop(); + return undefined; + } + } + + return originalShow.call(me); + }; + + proto.render_stream = function () { + var me = this; + + return getCaptureSettings(me).then(function (settings) { + if (!settings.enabled) { + return originalRenderStream.call(me); + } + + return renderStreamWithConstraints(me, buildConstraints(me, settings), settings); + }); + }; + + proto.setup_take_photo_action = function () { + var me = this; + var settings = + me.__av_tools_capture_settings || normalizeCaptureSettings(defaultCaptureSettings); + + if (!settings.enabled) { + return originalSetupTakePhotoAction.call(this); + } + + this.dialog.set_primary_action(__("Take Photo"), function () { + captureStillImage(me).then(function (dataUrl) { + me.images.push(dataUrl); + me.setup_preview_action(); + me.update_count(); + }); + }); + }; + + proto.setup_capture_action = function () { + var me = this; + + this.dialog.set_secondary_action_label(__("Capture")); + this.dialog.set_secondary_action(function () { + var settings = + me.__av_tools_capture_settings || + normalizeCaptureSettings(defaultCaptureSettings); + + if (frappe.is_mobile() && !shouldUseWebCaptureOnMobile(settings)) { + me.show_for_mobile(); + return; + } + + me.render_stream(); + }); + }; + + frappe.ui.Capture.__av_tools_capture_override_installed = true; + return true; + } + + function retryInstall() { + if (installCaptureOverride()) { + warmCaptureSettingsCache(); + return; + } + + var attempts = 0; + var interval = setInterval(function () { + attempts += 1; + + if (installCaptureOverride() || attempts > 50) { + if (frappe.ui && frappe.ui.Capture) { + warmCaptureSettingsCache(); + } + clearInterval(interval); + } + }, 200); + } + + $(retryInstall); + $(document).on("app_ready startup", retryInstall); +})(); From 15a25685133b7dff1b1dc469d7d58d9bd3e7df8c Mon Sep 17 00:00:00 2001 From: Emanuel Kagombora Date: Fri, 7 Aug 2026 12:21:16 +0300 Subject: [PATCH 2/2] feat(av_tools): simplify camera capture override for desk and mobile --- .../av_tools_settings/av_tools_settings.json | 9 - .../test_av_tools_settings.py | 4 - av_tools/av_tools_hooks/capture.py | 18 +- av_tools/public/js/capture_override.js | 368 ++++-------------- 4 files changed, 86 insertions(+), 313 deletions(-) diff --git a/av_tools/av_tools/doctype/av_tools_settings/av_tools_settings.json b/av_tools/av_tools/doctype/av_tools_settings/av_tools_settings.json index 9b24ad7..6eb63f7 100644 --- a/av_tools/av_tools/doctype/av_tools_settings/av_tools_settings.json +++ b/av_tools/av_tools/doctype/av_tools_settings/av_tools_settings.json @@ -30,7 +30,6 @@ "enable_validate_item_remaining_qty", "camera_capture_section", "enable_camera_capture_override", - "force_web_capture_on_mobile", "camera_capture_column_break", "camera_capture_ideal_width", "camera_capture_ideal_height", @@ -194,14 +193,6 @@ "fieldtype": "Check", "label": "Enable Camera Capture Override" }, - { - "default": "0", - "depends_on": "eval:doc.enable_camera_capture_override == 1", - "description": "When enabled, mobile users will use the browser camera dialog instead of the native device capture flow.", - "fieldname": "force_web_capture_on_mobile", - "fieldtype": "Check", - "label": "Force Web Capture On Mobile" - }, { "depends_on": "eval:doc.enable_camera_capture_override == 1", "fieldname": "camera_capture_column_break", diff --git a/av_tools/av_tools/doctype/av_tools_settings/test_av_tools_settings.py b/av_tools/av_tools/doctype/av_tools_settings/test_av_tools_settings.py index 65424a4..3d60095 100644 --- a/av_tools/av_tools/doctype/av_tools_settings/test_av_tools_settings.py +++ b/av_tools/av_tools/doctype/av_tools_settings/test_av_tools_settings.py @@ -34,7 +34,6 @@ class TestAVToolsSettings(AccountsTestMixin, FrappeTestCase): "role_to_reopen_material_request": "", "override_sales_invoice_qty": 0, "enable_camera_capture_override": 0, - "force_web_capture_on_mobile": 0, "camera_capture_ideal_width": 1920, "camera_capture_ideal_height": 1080, "camera_capture_min_width": 0, @@ -342,7 +341,6 @@ def make_item_details_args(self): class TestAVToolsCaptureSettings(FrappeTestCase): settings_defaults: ClassVar[dict[str, object]] = { "enable_camera_capture_override": 0, - "force_web_capture_on_mobile": 0, "camera_capture_ideal_width": 1920, "camera_capture_ideal_height": 1080, "camera_capture_min_width": 0, @@ -367,7 +365,6 @@ def set_settings(self, **overrides): def test_get_capture_settings_uses_single_doctype_values(self): self.set_settings( enable_camera_capture_override=1, - force_web_capture_on_mobile=1, camera_capture_ideal_width=2560, camera_capture_ideal_height=1440, camera_capture_min_width=1280, @@ -377,7 +374,6 @@ def test_get_capture_settings_uses_single_doctype_values(self): settings = get_capture_settings() self.assertTrue(settings["enabled"]) - self.assertTrue(settings["force_web_capture_on_mobile"]) self.assertEqual(settings["ideal_width"], 2560) self.assertEqual(settings["ideal_height"], 1440) self.assertEqual(settings["min_width"], 1280) diff --git a/av_tools/av_tools_hooks/capture.py b/av_tools/av_tools_hooks/capture.py index 9c184a8..efc3efd 100644 --- a/av_tools/av_tools_hooks/capture.py +++ b/av_tools/av_tools_hooks/capture.py @@ -4,7 +4,6 @@ SETTINGS_DOCTYPE = "AV Tools Settings" DEFAULT_CAPTURE_SETTINGS = { "enabled": False, - "force_web_capture_on_mobile": False, "ideal_width": 1920, "ideal_height": 1080, "min_width": 0, @@ -28,11 +27,18 @@ def _get_capture_settings(): return settings settings["enabled"] = bool(cint(values.get("enable_camera_capture_override"))) - settings["force_web_capture_on_mobile"] = bool(cint(values.get("force_web_capture_on_mobile"))) - settings["ideal_width"] = _sanitize_positive_int(values.get("camera_capture_ideal_width"), 1920) - settings["ideal_height"] = _sanitize_positive_int(values.get("camera_capture_ideal_height"), 1080) - settings["min_width"] = _sanitize_positive_int(values.get("camera_capture_min_width"), 0) - settings["min_height"] = _sanitize_positive_int(values.get("camera_capture_min_height"), 0) + settings["ideal_width"] = _sanitize_positive_int( + values.get("camera_capture_ideal_width"), DEFAULT_CAPTURE_SETTINGS["ideal_width"] + ) + settings["ideal_height"] = _sanitize_positive_int( + values.get("camera_capture_ideal_height"), DEFAULT_CAPTURE_SETTINGS["ideal_height"] + ) + settings["min_width"] = _sanitize_positive_int( + values.get("camera_capture_min_width"), DEFAULT_CAPTURE_SETTINGS["min_width"] + ) + settings["min_height"] = _sanitize_positive_int( + values.get("camera_capture_min_height"), DEFAULT_CAPTURE_SETTINGS["min_height"] + ) return settings diff --git a/av_tools/public/js/capture_override.js b/av_tools/public/js/capture_override.js index d93df2a..66a50c7 100644 --- a/av_tools/public/js/capture_override.js +++ b/av_tools/public/js/capture_override.js @@ -1,16 +1,13 @@ (function () { - frappe.provide("av_tools.capture_override"); - var defaultCaptureSettings = { enabled: 0, - force_web_capture_on_mobile: 0, ideal_width: 1920, ideal_height: 1080, min_width: 0, min_height: 0, }; - var captureSettingsCache = null; - var captureSettingsPromise = null; + var captureSettings = null; + var captureSettingsRequest = null; function parsePositiveInt(value, fallback) { var parsed = parseInt(value, 10); @@ -20,281 +17,109 @@ function normalizeCaptureSettings(settings) { return { enabled: Boolean(Number(settings && settings.enabled)), - force_web_capture_on_mobile: Boolean( - Number(settings && settings.force_web_capture_on_mobile) + ideal_width: parsePositiveInt( + settings && settings.ideal_width, + defaultCaptureSettings.ideal_width + ), + ideal_height: parsePositiveInt( + settings && settings.ideal_height, + defaultCaptureSettings.ideal_height + ), + min_width: parsePositiveInt( + settings && settings.min_width, + defaultCaptureSettings.min_width + ), + min_height: parsePositiveInt( + settings && settings.min_height, + defaultCaptureSettings.min_height ), - ideal_width: parsePositiveInt(settings && settings.ideal_width, 1920), - ideal_height: parsePositiveInt(settings && settings.ideal_height, 1080), - min_width: parsePositiveInt(settings && settings.min_width, 0), - min_height: parsePositiveInt(settings && settings.min_height, 0), }; } - function requestCaptureSettings() { - if (captureSettingsPromise) { - return captureSettingsPromise; + function loadCaptureSettings() { + if (captureSettings) { + return Promise.resolve(captureSettings); } - captureSettingsPromise = new Promise(function (resolve) { + if (captureSettingsRequest) { + return captureSettingsRequest; + } + + captureSettingsRequest = new Promise(function (resolve) { frappe.call({ method: "av_tools.av_tools_hooks.capture.get_capture_settings", callback: function (response) { - captureSettingsCache = normalizeCaptureSettings( + captureSettings = normalizeCaptureSettings( response.message || defaultCaptureSettings ); - resolve(captureSettingsCache); + resolve(captureSettings); }, error: function () { - captureSettingsCache = normalizeCaptureSettings(defaultCaptureSettings); - resolve(captureSettingsCache); + captureSettings = normalizeCaptureSettings(defaultCaptureSettings); + resolve(captureSettings); }, }); }).finally(function () { - captureSettingsPromise = null; - }); - - return captureSettingsPromise; - } - - function getCachedCaptureSettings(capture) { - if (capture && capture.__av_tools_capture_settings) { - return Promise.resolve(capture.__av_tools_capture_settings); - } - - if (captureSettingsCache) { - var settings = normalizeCaptureSettings(captureSettingsCache); - if (capture) { - capture.__av_tools_capture_settings = settings; - } - return Promise.resolve(settings); - } - - return Promise.resolve(null); - } - - function getCaptureSettings(capture, forceRefresh) { - if (!forceRefresh) { - return getCachedCaptureSettings(capture).then(function (settings) { - if (settings) { - return settings; - } - return requestCaptureSettings().then(function (freshSettings) { - if (capture) { - capture.__av_tools_capture_settings = freshSettings; - } - return freshSettings; - }); - }); - } - - return requestCaptureSettings().then(function (settings) { - if (capture) { - capture.__av_tools_capture_settings = settings; - } - return settings; - }); - } - - function warmCaptureSettingsCache() { - requestCaptureSettings().catch(function () { - return null; + captureSettingsRequest = null; }); - } - - function shouldForceAppWebCapture(settings) { - return settings.enabled && settings.force_web_capture_on_mobile; - } - function shouldUseWebCaptureOnMobile(settings) { - var sysdefaults = (frappe.boot && frappe.boot.sysdefaults) || {}; - return ( - shouldForceAppWebCapture(settings) || - Boolean(Number(sysdefaults.force_web_capture_mode_for_uploads)) - ); + return captureSettingsRequest; } - function buildTrackConstraints(capture, settings) { - var constraints = { + function buildConstraints(capture, settings) { + var video = { facingMode: { ideal: capture.facing_mode, }, }; if (settings.min_width || settings.ideal_width) { - constraints.width = {}; + video.width = {}; if (settings.min_width) { - constraints.width.min = settings.min_width; + video.width.min = settings.min_width; } if (settings.ideal_width) { - constraints.width.ideal = settings.ideal_width; + video.width.ideal = settings.ideal_width; } } if (settings.min_height || settings.ideal_height) { - constraints.height = {}; + video.height = {}; if (settings.min_height) { - constraints.height.min = settings.min_height; + video.height.min = settings.min_height; } if (settings.ideal_height) { - constraints.height.ideal = settings.ideal_height; + video.height.ideal = settings.ideal_height; } } - if (settings.ideal_width || settings.ideal_height) { - constraints.advanced = [ - { - width: settings.ideal_width || undefined, - height: settings.ideal_height || undefined, - }, - ]; - } - - return constraints; - } - - function buildConstraints(capture, settings) { return { - video: buildTrackConstraints(capture, settings), + video: video, audio: false, }; } - function applyTrackConstraints(track, capture, settings) { - if (!track || typeof track.applyConstraints !== "function") { - return Promise.resolve(); - } - - return track - .applyConstraints(buildTrackConstraints(capture, settings)) - .catch(function (error) { - console.warn("[av_tools] Failed to apply camera constraints", error); - }); - } - - function blobToDataUrl(blob) { - return new Promise(function (resolve, reject) { - var reader = new FileReader(); - reader.onload = function () { - resolve(reader.result); - }; - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - } - - function bitmapToPngDataUrl(bitmap) { - var canvas = document.createElement("canvas"); - canvas.width = bitmap.width; - canvas.height = bitmap.height; - canvas.getContext("2d").drawImage(bitmap, 0, 0); - - if (typeof bitmap.close === "function") { - bitmap.close(); - } - - return canvas.toDataURL("image/png"); - } - - function blobToPngDataUrl(blob) { - if (typeof createImageBitmap === "function") { - return createImageBitmap(blob).then(bitmapToPngDataUrl); - } - - return blobToDataUrl(blob).then(function (dataUrl) { - return new Promise(function (resolve, reject) { - var image = new Image(); - image.onload = function () { - var canvas = document.createElement("canvas"); - canvas.width = image.naturalWidth || image.width; - canvas.height = image.naturalHeight || image.height; - canvas.getContext("2d").drawImage(image, 0, 0); - resolve(canvas.toDataURL("image/png")); - }; - image.onerror = reject; - image.src = dataUrl; - }); - }); - } - - function captureStillImage(capture) { - var fallbackImage = frappe._.get_data_uri(capture.video); - var track = - capture.stream && capture.stream.getVideoTracks && capture.stream.getVideoTracks()[0]; - - if (!track || typeof ImageCapture !== "function") { - return Promise.resolve(fallbackImage); - } - - try { - var imageCapture = new ImageCapture(track); - if (typeof imageCapture.takePhoto !== "function") { - return Promise.resolve(fallbackImage); - } - - return imageCapture - .takePhoto() - .then(blobToPngDataUrl) - .catch(function (error) { - console.warn("[av_tools] Falling back to stream-frame capture", error); - return fallbackImage; - }); - } catch (error) { - console.warn("[av_tools] Falling back to stream-frame capture", error); - return Promise.resolve(fallbackImage); - } - } - - function logCaptureDetails(capture, stream, constraints) { - if (!(frappe.boot && frappe.boot.developer_mode)) { - return; - } - - try { - var track = stream.getVideoTracks()[0]; - var actualSettings = track && track.getSettings ? track.getSettings() : {}; - var capabilities = track && track.getCapabilities ? track.getCapabilities() : {}; - console.info("[av_tools] Camera capture override active", { - requested_constraints: constraints, - actual_settings: actualSettings, - capabilities: capabilities, - video_dimensions: { - width: capture.video && capture.video.videoWidth, - height: capture.video && capture.video.videoHeight, - }, - }); - } catch (error) { - console.warn("[av_tools] Failed to log camera capture details", error); - } - } - - function renderStreamWithConstraints(capture, constraints, settings) { + function renderStreamWithConstraints(capture, constraints) { return navigator.mediaDevices.getUserMedia(constraints).then(function (stream) { capture.stream = stream; - var track = stream.getVideoTracks && stream.getVideoTracks()[0]; - - return applyTrackConstraints(track, capture, settings).then(function () { - capture.dialog.custom_actions.empty(); - capture.dialog.get_primary_btn().off("click"); - capture.setup_take_photo_action(); - capture.setup_preview_action(); - capture.setup_toggle_camera(); - - capture.$template.find(".fc-stream-container").show(); - capture.$template.find(".fc-preview-container").hide(); - capture.video = capture.$template.find("video")[0]; - capture.video.srcObject = capture.stream; - capture.video.load(); - - var playPromise = capture.video.play(); - if (playPromise && typeof playPromise.then === "function") { - return playPromise.then(function () { - logCaptureDetails(capture, stream, constraints); - }); - } + capture.dialog.custom_actions.empty(); + capture.dialog.get_primary_btn().off("click"); + capture.setup_take_photo_action(); + capture.setup_preview_action(); + capture.setup_toggle_camera(); + + capture.$template.find(".fc-stream-container").show(); + capture.$template.find(".fc-preview-container").hide(); + capture.video = capture.$template.find("video")[0]; + capture.video.srcObject = capture.stream; + capture.video.load(); + + var playPromise = capture.video.play(); + if (playPromise && typeof playPromise.then === "function") { + return playPromise; + } - logCaptureDetails(capture, stream, constraints); - return undefined; - }); + return undefined; }); } @@ -310,75 +135,34 @@ var proto = frappe.ui.Capture.prototype; var originalShow = proto.show; var originalRenderStream = proto.render_stream; - var originalSetupTakePhotoAction = proto.setup_take_photo_action; proto.show = function () { - var me = this; - - if (frappe.is_mobile()) { - var cachedSettings = - me.__av_tools_capture_settings || - (captureSettingsCache && normalizeCaptureSettings(captureSettingsCache)); + if (!frappe.is_mobile()) { + return originalShow.call(this); + } - if (cachedSettings) { - me.__av_tools_capture_settings = cachedSettings; - } + if (!captureSettings) { + loadCaptureSettings(); + return originalShow.call(this); + } - if (cachedSettings && shouldForceAppWebCapture(cachedSettings)) { - me.build_dialog(); - me.show_for_desktop(); - return undefined; - } + if (!captureSettings.enabled) { + return originalShow.call(this); } - return originalShow.call(me); + this.build_dialog(); + return this.show_for_desktop(); }; proto.render_stream = function () { var me = this; - return getCaptureSettings(me).then(function (settings) { + return loadCaptureSettings().then(function (settings) { if (!settings.enabled) { return originalRenderStream.call(me); } - return renderStreamWithConstraints(me, buildConstraints(me, settings), settings); - }); - }; - - proto.setup_take_photo_action = function () { - var me = this; - var settings = - me.__av_tools_capture_settings || normalizeCaptureSettings(defaultCaptureSettings); - - if (!settings.enabled) { - return originalSetupTakePhotoAction.call(this); - } - - this.dialog.set_primary_action(__("Take Photo"), function () { - captureStillImage(me).then(function (dataUrl) { - me.images.push(dataUrl); - me.setup_preview_action(); - me.update_count(); - }); - }); - }; - - proto.setup_capture_action = function () { - var me = this; - - this.dialog.set_secondary_action_label(__("Capture")); - this.dialog.set_secondary_action(function () { - var settings = - me.__av_tools_capture_settings || - normalizeCaptureSettings(defaultCaptureSettings); - - if (frappe.is_mobile() && !shouldUseWebCaptureOnMobile(settings)) { - me.show_for_mobile(); - return; - } - - me.render_stream(); + return renderStreamWithConstraints(me, buildConstraints(me, settings)); }); }; @@ -387,19 +171,15 @@ } function retryInstall() { - if (installCaptureOverride()) { - warmCaptureSettingsCache(); - return; - } + loadCaptureSettings(); + + if (installCaptureOverride()) return; var attempts = 0; var interval = setInterval(function () { attempts += 1; if (installCaptureOverride() || attempts > 50) { - if (frappe.ui && frappe.ui.Capture) { - warmCaptureSettingsCache(); - } clearInterval(interval); } }, 200);