diff --git a/.gitignore b/.gitignore index aaef126..8d26df3 100644 --- a/.gitignore +++ b/.gitignore @@ -120,4 +120,8 @@ test_suite.tar.gz invent.min.tar.gz static/default.css static/default.min.css -static/*.zip \ No newline at end of file +static/*.zip + +# All temp files +/temp +temp/ \ No newline at end of file diff --git a/examples/open_cv_playground/config.json b/examples/open_cv_playground/config.json new file mode 100644 index 0000000..22f69ba --- /dev/null +++ b/examples/open_cv_playground/config.json @@ -0,0 +1,5 @@ +{ + "files": { + "/static/invent.min.tar.gz": "./*" + } +} \ No newline at end of file diff --git a/examples/open_cv_playground/index.html b/examples/open_cv_playground/index.html new file mode 100644 index 0000000..d123bd5 --- /dev/null +++ b/examples/open_cv_playground/index.html @@ -0,0 +1,59 @@ + + + + + + Test Card + + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/examples/open_cv_playground/main.py b/examples/open_cv_playground/main.py new file mode 100644 index 0000000..b677d29 --- /dev/null +++ b/examples/open_cv_playground/main.py @@ -0,0 +1,167 @@ +import asyncio + +import invent +from invent.tools import create_opencv_donkey +from invent.ui import * + +# Datastore ############################################################################ + +await invent.setup() + +# Code ################################################################################# + +# Pre-define some webcam variations +preview_webcam = Webcam( + photo_output="download", +) + +opencv_webcam = Webcam( + photo_output="preview", + preview_layout="side-by-side", + mode="photo", +) + + +opencv_status = Label( + text="Donkey starting...", +) + +default_code = ( + "# Available variables: image_bgr, image_rgb, grey, cv2, np\n" + "# Set result_image (or result) to a numpy ndarray.\n" + "# Example: start with the current frame and modify it however you like.\n\n" + "result_image = image_bgr.copy()\n" +) + +opencv_code_editor = CodeEditor( + code=default_code, + language="python", + min_height="280px", +) + +opencv_worker = None + + +async def ensure_worker(): + """Start the Donkey worker and bootstrap OpenCV when needed.""" + global opencv_worker + if opencv_worker is not None and opencv_worker.ready: + opencv_status.text = "Donkey ready." + return + opencv_status.text = "Starting Donkey worker..." + try: + opencv_worker = await create_opencv_donkey( + result_key="opencv.worker.status" + ) + opencv_status.text = ( + "Donkey ready. Capture a photo and run your code." + ) + except Exception as exc: + opencv_status.text = f"Failed to start donkey worker: {exc}" + + +def _latest_capture_data_url(): + capture = opencv_webcam.latest_capture(media_type="photo") + if capture is None: + return None + return capture.get("data_url") + + +async def run_worker_code(): + if opencv_worker is None or not opencv_worker.ready: + opencv_status.text = "Donkey is not ready. Press 'Start Donkey' first." + return + + data_url = _latest_capture_data_url() + if not data_url: + opencv_status.text = "Capture a photo first, then run an action." + return + + code = opencv_code_editor.code or "" + if not code.strip(): + opencv_status.text = "Write some OpenCV code first." + return + + opencv_status.text = "Running code..." + try: + result = await opencv_worker.run_code(code, data_url) + except Exception as exc: + opencv_status.text = f"Worker error: {exc}" + return + + if result is None: + opencv_status.text = "Worker returned no result." + return + + getter = getattr(result, "get", None) + if callable(getter): + ok = getter("ok") + processed_data_url = getter("data_url") + else: + ok = False + processed_data_url = None + + if ok: + if processed_data_url: + opencv_webcam.show_image(processed_data_url) + opencv_status.text = "Done. Custom OpenCV code executed." + return + + opencv_status.text = ( + f"Worker returned no displayable result ({type(result).__name__})." + ) + + +async def handle_opencv_controls(message): + button_name = getattr(message.source, "name", "") + + if button_name == "run_code_button": + await run_worker_code() + + +invent.subscribe( + handle_opencv_controls, + to_channel="opencv-controls", + when_subject=["press"], +) + + +# Lazy boot so the worker starts automatically when the page loads. +asyncio.create_task(ensure_worker()) + +# User Interface ####################################################################### + +app = invent.App( + name="Theme Testcard", + pages=[ + Page( + id="testcard", + children=[ + Label(text="# Invent Test Card"), + Label( + text="This is a test card for the Invent framework. It includes all the different widgets and components in the framework, so that we can see how they look with different themes applied." + ), + Label(text="## Standard webcam"), + preview_webcam, + Label(text="## OpenCV webcam playground"), + Label( + text=( + "Take a photo, write your OpenCV code, and then press **Run Code**." + ) + ), + opencv_webcam, + Button( + text="Run Code", + name="run_code_button", + channel="opencv-controls", + ), + opencv_code_editor, + opencv_status, + ], + ), + ], +) + +# GO! ################################################################################## + +invent.go() diff --git a/src/invent/themes/default.css b/src/invent/themes/default.css index bb2c709..e325def 100644 --- a/src/invent/themes/default.css +++ b/src/invent/themes/default.css @@ -2699,7 +2699,7 @@ figure.invent-avatar:focus-visible { .invent-webcam-shutter-container, .shutter-container { justify-self: center; - text-align: center; + text-align: center; } .invent-webcam-mode-btn, @@ -2773,6 +2773,73 @@ figure.invent-avatar:focus-visible { z-index: 1; } +/* Media row: transparent by default so video box stacks normally in the + * flex-column webcam container. Activated side-by-side with modifier class. */ +.invent-webcam-media-row { + display: contents; +} + +/* Capture preview in stacked mode: full-width img below the video. */ +.invent-webcam-preview-col { + width: 100%; +} + +.invent-webcam-preview-col .invent-webcam-capture-preview { + border-radius: 12px; + display: block; + height: auto; + width: 100%; +} + +.invent-webcam-preview-col .invent-webcam-capture-preview.hidden { + display: none; +} + +/* Side-by-side mode: media row becomes a flex row, both columns equal size. */ +.invent-webcam--side-by-side { + min-height: 0; +} + +.invent-webcam--side-by-side .invent-webcam-media-row { + display: flex; + gap: var(--spacing-lg); + align-items: flex-start; +} + +.invent-webcam--side-by-side .invent-webcam-media-row .invent-webcam-box { + flex: 1; + min-width: 0; + min-height: 0; + width: auto; +} + +.invent-webcam--side-by-side .invent-webcam-preview-col { + aspect-ratio: 4 / 3; + background: var(--webcam-bg); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-width: 0; + overflow: hidden; + width: auto; +} + +.invent-webcam--side-by-side .invent-webcam-preview-col .invent-webcam-capture-preview { + border-radius: 0; + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +@media (max-width: 600px) { + .invent-webcam--side-by-side .invent-webcam-media-row { + flex-direction: column; + } +} + @keyframes invent-webcam-recording-ring { 0% { border: var(--border-width) solid var(--webcam-text-primary); @@ -2841,4 +2908,4 @@ figure.invent-avatar:focus-visible { .shutter-container { justify-self: center; } -} \ No newline at end of file +} diff --git a/src/invent/tools/__init__.py b/src/invent/tools/__init__.py index e69de29..35cf01d 100644 --- a/src/invent/tools/__init__.py +++ b/src/invent/tools/__init__.py @@ -0,0 +1,19 @@ +from .device import ( + DONKEY_BUSY, + DONKEY_CREATING, + DONKEY_ERROR, + DONKEY_KILLED, + DONKEY_READY, + OpenCVDonkey, + create_opencv_donkey, +) + +__all__ = [ + "DONKEY_BUSY", + "DONKEY_CREATING", + "DONKEY_ERROR", + "DONKEY_KILLED", + "DONKEY_READY", + "OpenCVDonkey", + "create_opencv_donkey", +] diff --git a/src/invent/tools/device.py b/src/invent/tools/device.py index 937caad..910cc95 100644 --- a/src/invent/tools/device.py +++ b/src/invent/tools/device.py @@ -1,6 +1,200 @@ +import invent +import asyncio +import json +from pyscript import document, js_import, window +from pyscript.ffi import to_js + +# Datastore flags for Donkey worker status. +DONKEY_CREATING = "_DEVICE_DONKEY_CREATING" +DONKEY_READY = "_DEVICE_DONKEY_READY" +DONKEY_BUSY = "_DEVICE_DONKEY_BUSY" +DONKEY_ERROR = "_DEVICE_DONKEY_ERROR" +DONKEY_KILLED = "_DEVICE_DONKEY_KILLED" + + """ -Vibrate -Microphone -Flashlight -Camera +NOTE: I am using execute("from _opencv_worker import *") which +pulls everything into the worker's global scope, which is the only +reliable way to define persistent callables in a donkey worker that I +could find. Both + - process() feeds large multiline strings line-by-line which + prevents this sort of data from using it, and + - execute() alone seems to scope defs locally and discard them + after the call returns. """ + +# The OpenCV worker code written to the worker's virtual filesystem as a +# proper Python module. +_OPENCV_WORKER_MODULE = r""" +import base64 +import cv2 +import numpy as np + + +def _decode_data_url(data_url): + if not data_url or "," not in data_url: + raise ValueError("Expected an image data URL") + payload = data_url.split(",", 1)[1] + binary = base64.b64decode(payload) + buf = np.frombuffer(binary, dtype=np.uint8) + image = cv2.imdecode(buf, cv2.IMREAD_COLOR) + if image is None: + raise ValueError("Could not decode input image") + return image + + +def _encode_png_data_url(image): + ok, encoded = cv2.imencode(".png", image) + if not ok: + raise ValueError("Could not encode processed image") + payload = base64.b64encode(encoded.tobytes()).decode("ascii") + return "data:image/png;base64," + payload + + +def worker_run_user_code(user_code, data_url): + image_bgr = _decode_data_url(data_url) + image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) + grey = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY) + + namespace = { + "cv2": cv2, + "np": np, + "image_bgr": image_bgr, + "image_rgb": image_rgb, + "grey": grey, + "result_image": None, + "result": None, + } + + exec(user_code, namespace, namespace) + + result = namespace.get("result_image") + if result is None: + result = namespace.get("result") + if result is None: + result = image_bgr + + if not isinstance(result, np.ndarray): + raise ValueError( + "Your code must assign a numpy ndarray to result_image or result" + ) + + if result.ndim == 2: + result = cv2.cvtColor(result, cv2.COLOR_GRAY2BGR) + elif result.ndim == 3 and result.shape[2] == 3: + pass + else: + raise ValueError("Unsupported result_image shape") + + return { + "ok": True, + "kind": "user_code", + "data_url": _encode_png_data_url(result), + } +""" + + +_PYSCRIPT_CORE = "https://pyscript.net/releases/2026.3.1/core.js" + + +def _ensure_terminal_div(terminal_id="donkey-terminal"): + """Create a hidden terminal container in the DOM if it doesn't exist yet.""" + if document.getElementById(terminal_id) is None: + div = document.createElement("div") + div.id = terminal_id + div.style.display = "none" + document.body.appendChild(div) + return f"#{terminal_id}" + + +class OpenCVDonkey: + """Thin wrapper around a PyScript donkey used for OpenCV processing.""" + + def __init__(self, donkey, result_key=None): + self._donkey = donkey + self._result_key = result_key + self._ready = False + + @property + def ready(self): + return self._ready + + def _set_status(self, status): + if self._result_key: + invent.datastore[self._result_key] = status + + async def initialize(self): + self._set_status(DONKEY_BUSY) + # Write the module to the worker's virtual filesystem as a + # .py file, then import it into global scope with execute(). + await self._donkey.execute( + f"open('_opencv_worker.py', 'w').write({_OPENCV_WORKER_MODULE!r})" + ) + await self._donkey.execute("from _opencv_worker import *") + self._ready = True + self._set_status(DONKEY_READY) + + async def run_code(self, code, data_url): + if not self._ready: + raise RuntimeError("Donkey is not ready yet") + self._set_status(DONKEY_BUSY) + try: + payload = await self._donkey.evaluate( + "__import__('json').dumps(worker_run_user_code(" + f"{code!r}, {data_url!r}" + "))" + ) + result = json.loads(payload) + self._set_status(DONKEY_READY) + return result + except Exception as exc: + self._set_status(f"{DONKEY_ERROR}: {exc}") + raise + + async def kill(self): + await self._donkey.kill() + self._ready = False + self._set_status(DONKEY_KILLED) + + +async def create_opencv_donkey(result_key=None, *, packages=None): + """ + Create and initialise an OpenCV donkey worker. + + Parameters: + + result_key : str | None + Datastore key for status updates, e.g. "opencv.worker.status". + packages : list[str] | None + Extra packages to install in the worker + """ + if packages is None: + packages = [] + + base_packages = ["opencv-python", "numpy"] + all_packages = base_packages + [ + p for p in packages if p not in base_packages + ] + + if result_key: + invent.datastore[result_key] = DONKEY_CREATING + + # Import donkey directly from PyScript + (core,) = await js_import(_PYSCRIPT_CORE) + # Create the hidden terminal div + terminal_selector = _ensure_terminal_div() + + options = to_js( + { + "type": "py", + "persistent": True, + "terminal": terminal_selector, + "config": {"packages": all_packages}, + } + ) + + donkey = await core.donkey(options) + + worker = OpenCVDonkey(donkey, result_key=result_key) + await worker.initialize() + return worker diff --git a/src/invent/ui/core/property.py b/src/invent/ui/core/property.py index c60989f..b17d447 100644 --- a/src/invent/ui/core/property.py +++ b/src/invent/ui/core/property.py @@ -406,11 +406,15 @@ def validate(self, value): length = len(value) if self.min_length and length < self.min_length: raise ValidationError( - _("The length of the value is less than min_value allowed.") + _( + "The length of the value is less than min_value allowed." + ) ) if self.max_length and length > self.max_length: raise ValidationError( - _("The length of the value is more than max_value allowed.") + _( + "The length of the value is more than max_value allowed." + ) ) return value diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index ed297fe..9e691af 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -19,23 +19,33 @@ limitations under the License. """ -from invent.i18n import _ +import asyncio import time + +from invent.i18n import _ from invent.ui.core import ( Widget, ChoiceProperty, BooleanProperty, Event, ) -from pyscript.web import div, video, button, canvas +from pyscript import window +from pyscript.web import div, video, button, canvas, img from pyscript.ffi import create_proxy class Webcam(Widget): """ - A webcam widget with photo capture and video recording capabilities. + A webcam widget with photo capture and video recording. """ + photo_output = ChoiceProperty( + _("How captured photos are handled: downloaded, previewed, or both."), + default_value="download", + choices=["download", "preview", "both"], + group="behavior", + ) + mode = ChoiceProperty( _("Webcam mode: photo, video, or both."), default_value="both", @@ -49,9 +59,17 @@ class Webcam(Widget): group="style", ) + preview_layout = ChoiceProperty( + _("Layout of the capture preview relative to the live video feed."), + default_value="stacked", + choices=["stacked", "side-by-side"], + group="style", + ) + photo_captured = Event( _("Sent when a photo is captured."), webcam=_("The Webcam widget that captured the photo."), + capture=_("The captured photo metadata."), ) video_recorded = Event( @@ -59,46 +77,98 @@ class Webcam(Widget): webcam=_("The Webcam widget that recorded the video."), ) - @classmethod - def icon(cls): - return '' # noqa + def __init__(self, *args, **kwargs): + self._captures = [] + self._capture_counter = 0 + super().__init__(*args, **kwargs) + + # Capture management + def _store_capture(self, capture): + capture = dict(capture) + capture.setdefault("type", "photo") + capture.setdefault("timestamp", self._timestamp()) + self._capture_counter += 1 + capture.setdefault( + "id", + f"{capture['type']}-{self._timestamp()}-{self._capture_counter}", + ) + self._captures.append(capture) - def trigger(self): - """ - Trigger the current action (capture photo or start/stop recording). - """ - if hasattr(self, "_shutter_btn"): - self._shutter_btn.click() + preview_enabled = self.photo_output in ("preview", "both") + + if capture["type"] == "photo" and preview_enabled: + self._show_capture_preview(capture) + + return capture + + def captures(self, media_type=None): + if media_type is None: + return list(self._captures) + return [c for c in self._captures if c.get("type") == media_type] + + def latest_capture(self, media_type=None): + captures = self.captures(media_type=media_type) + return captures[-1] if captures else None + + # Preview helpers + def _show_capture_preview(self, capture): + if not hasattr(self, "_capture_preview"): + return + data_url = capture.get("data_url") + if not data_url: + return + self._capture_preview.src = data_url + self._capture_preview.classes.remove("hidden") + + def _hide_capture_preview(self): + if hasattr(self, "_capture_preview"): + self._capture_preview.classes.add("hidden") + + def _refresh_capture_preview(self): + preview_enabled = self.photo_output in ("preview", "both") + if not preview_enabled: + self._hide_capture_preview() + return + latest_photo = self.latest_capture(media_type="photo") + if latest_photo: + self._show_capture_preview(latest_photo) + else: + self._hide_capture_preview() + def show_image(self, data_url): + """Display any image in the preview panel, replacing the current content.""" + if not hasattr(self, "_capture_preview"): + return + self._capture_preview.src = data_url + self._capture_preview.classes.remove("hidden") + + # Mode switching def set_mode(self, mode): - """ - Set the active webcam mode when switching is enabled. - """ + """Set the active webcam mode when switching is enabled.""" if self.mode != "both": return if mode in ["photo", "video"]: self._active_mode = mode + mode_label = ( + "Video Mode" + if self._current_mode() == "video" + else "Photo Mode" + ) if hasattr(self, "_mode_buttons"): self._update_mode_buttons() if hasattr(self, "_mode_indicator"): - self._mode_indicator._dom_element.textContent = ( - self._mode_label() - ) + self._mode_indicator._dom_element.textContent = mode_label if hasattr(self, "_shutter_btn"): self._set_shutter_text() def _current_mode(self): - """ - Return the active mode used for behavior and UI labels. - """ if self.mode == "both": return getattr(self, "_active_mode", "photo") return self.mode + # Photo capture def capture_photo(self): - """ - Capture a photo from the current video stream. - """ + """Capture a photo from the current video stream.""" if hasattr(self, "_canvas") and hasattr(self, "_video_elem"): video_el = self._video_elem._dom_element canvas_el = self._canvas._dom_element @@ -109,35 +179,43 @@ def capture_photo(self): canvas_el.height = height ctx = canvas_el.getContext("2d") - ctx.drawImage( - video_el, - 0, - 0, - width, - height, + ctx.drawImage(video_el, 0, 0, width, height) + + capture = self._store_capture( + { + "type": "photo", + "timestamp": self._timestamp(), + "data_url": self._canvas._dom_element.toDataURL( + "image/jpeg" + ), + } ) - # Trigger download - self._download_canvas_as_image() - self.publish(self.photo_captured, webcam=self) + download_enabled = self.photo_output in ( + "download", + "both", + ) + preview_enabled = self.photo_output in ("preview", "both") + + if download_enabled: + self._download_canvas_as_image(capture) + + if not preview_enabled: + self._hide_capture_preview() + + self.publish(self.photo_captured, webcam=self, capture=capture) + + # Status helpers def _set_status(self, text): - """ - Update the status indicator text. - """ if not hasattr(self, "_status_elem"): return self._status_elem._dom_element.textContent = text def _timestamp(self): - """ - Return a millisecond timestamp for generated filenames. - """ return int(time.time() * 1000) + # Video recording def start_recording(self): - """ - Start recording video from the webcam. - """ if hasattr(self, "_recorder"): if not self._recording and self._recorder.state == "inactive": self._recorded_chunks = [] @@ -148,9 +226,6 @@ def start_recording(self): self._set_status("Recording...") def stop_recording(self): - """ - Stop recording and trigger download of the video file. - """ if ( hasattr(self, "_recorder") and self._recording @@ -162,24 +237,21 @@ def stop_recording(self): self._set_shutter_text() self._set_status("Saving video...") + # Callbacks def on_mode_changed(self): - """ - Apply all mode-dependent configuration to the already-rendered DOM. - The framework calls this after the widget's properties are set, - so self.mode is reliable here (unlike during render()). - """ if not hasattr(self, "_controls"): - # render() hasn't run yet; nothing to configure return - # Set active mode self._active_mode = "photo" if self.mode == "both" else self.mode + mode_label = ( + "Video Mode" if self._current_mode() == "video" else "Photo Mode" + ) - # Rebuild controls: remove any existing modes container, then - # prepend one only when mode='both'. controls_el = self._controls._dom_element - # Remove any previously inserted modes container - if hasattr(self, "_modes_container"): + if ( + hasattr(self, "_modes_container") + and self._modes_container is not None + ): try: controls_el.removeChild(self._modes_container._dom_element) except Exception: @@ -196,30 +268,31 @@ def on_mode_changed(self): else: self._mode_buttons = [] - # Update shutter label and mode indicator text self._set_shutter_text() if hasattr(self, "_mode_indicator"): - self._mode_indicator._dom_element.textContent = self._mode_label() + self._mode_indicator._dom_element.textContent = mode_label - # Apply show_mode_indicator if hasattr(self, "_indicators"): if self.show_mode_indicator: self._indicators.classes.remove("hidden") else: self._indicators.classes.add("hidden") - def _mode_label(self): - """ - Return the display label for the current mode. - """ - if self._current_mode() == "video": - return "Video Mode" - return "Photo Mode" + def on_photo_output_changed(self): + if not hasattr(self, "_capture_preview"): + return + self._refresh_capture_preview() + + def on_preview_layout_changed(self): + if not hasattr(self, "element"): + return + if self.preview_layout == "side-by-side": + self.element.classes.add("invent-webcam--side-by-side") + else: + self.element.classes.remove("invent-webcam--side-by-side") + # UI helpers def _update_mode_buttons(self): - """ - Update the visual state of mode buttons based on current mode. - """ for btn_info in self._mode_buttons: btn = btn_info["element"] btn_mode = btn_info["mode"] @@ -231,9 +304,6 @@ def _update_mode_buttons(self): btn.classes.remove("active") def _set_shutter_text(self): - """ - Keep shutter text aligned with current mode and recording state. - """ if not hasattr(self, "_shutter_btn"): return is_recording = getattr(self, "_recording", False) @@ -243,24 +313,23 @@ def _set_shutter_text(self): text = "Take" self._shutter_btn._dom_element.textContent = text - def _download_canvas_as_image(self): - """ - Download the canvas content as an image file. - """ + # Download helper + def _download_canvas_as_image(self, capture=None): try: - from pyscript import window + capture = capture or self.latest_capture(media_type="photo") + if capture and capture.get("data_url"): + data_url = capture["data_url"] + else: + data_url = self._canvas._dom_element.toDataURL("image/jpeg") link = window.document.createElement("a") - link.href = self._canvas._dom_element.toDataURL("image/jpeg") + link.href = data_url link.download = f"photo-{self._timestamp()}.jpg" link.click() except Exception as e: print(f"Error downloading photo: {e}") def _on_shutter_click(self, event): - """ - Handle shutter button clicks. - """ if self._current_mode() == "photo": self.capture_photo() else: @@ -269,10 +338,9 @@ def _on_shutter_click(self, event): else: self.start_recording() + # Webcam stream def _setup_webcam_stream(self): try: - from pyscript import window - navigator = window.navigator if not navigator.mediaDevices: print("Camera not supported in this browser") @@ -305,20 +373,13 @@ async def get_stream(): print(f"Camera access denied or error: {e}") self._set_status("Camera access denied") - # Handle promise - import asyncio - asyncio.create_task(get_stream()) except Exception as e: print(f"Error setting up webcam: {e}") def _setup_recorder(self, stream): - """ - Set up the MediaRecorder for video recording. - """ try: - from pyscript import window def on_dataavailable(event): if event.data.size > 0: @@ -353,11 +414,8 @@ def on_stop(event): except Exception as e: print(f"Error setting up recorder: {e}") + # Determine Camera Mode (video or photo) def _build_mode_buttons(self): - """ - Build and return the mode toggle container for mode='both'. - Populates self._mode_buttons as a side effect. - """ self._mode_buttons = [] def build_mode_button(mode_name): @@ -386,15 +444,13 @@ def build_mode_button(mode_name): def render(self): """ - Render the webcam widget with controls. - Mode-dependent configuration is applied in on_mode_changed, - which the framework calls after properties are set. + Render the webcam widget. """ - # Hidden canvas for photo capture + # hidden canvas for photo capture self._canvas = canvas() self._canvas.classes.add("invent-webcam-canvas-hidden") - # Video element for preview + # live video element self._video_elem = video() self._video_elem.id = f"{self.id}-video" self._video_elem.autoplay = True @@ -404,10 +460,8 @@ def render(self): def on_video_ready(event): video_el = self._video_elem._dom_element canvas_el = self._canvas._dom_element - width = video_el.videoWidth or 1280 - height = video_el.videoHeight or 720 - canvas_el.width = width - canvas_el.height = height + canvas_el.width = video_el.videoWidth or 1280 + canvas_el.height = video_el.videoHeight or 720 self._video_elem._dom_element.addEventListener( "loadedmetadata", create_proxy(on_video_ready) @@ -417,7 +471,7 @@ def on_video_ready(event): video_container.classes.add("invent-webcam-box") video_container.classes.add("webcam-box") - # Shutter button + # shutter button self._shutter_btn = button("Take") self._shutter_btn.id = f"{self.id}-shutter" self._shutter_btn.classes.add("invent-webcam-shutter") @@ -430,14 +484,12 @@ def on_video_ready(event): shutter_container.classes.add("invent-webcam-shutter-container") shutter_container.classes.add("shutter-container") - # Controls container: starts with just the shutter; - # on_mode_changed inserts mode toggle buttons when mode='both'. self._controls = div(shutter_container) self._controls.classes.add("invent-webcam-actions") self._controls.classes.add("actions") self._shutter_container = shutter_container - # Status indicators + # status indicators self._status_elem = div("Initializing camera...") self._status_elem.id = f"{self.id}-status" self._status_elem.classes.add("invent-webcam-status") @@ -451,18 +503,32 @@ def on_video_ready(event): self._indicators.classes.add("invent-webcam-indicators") self._indicators.classes.add("indicators") - # Main container + # capture preview image + self._capture_preview = img() + self._capture_preview.id = f"{self.id}-capture-preview" + self._capture_preview.classes.add("invent-webcam-capture-preview") + self._capture_preview.classes.add("capture-preview") + self._capture_preview.classes.add("hidden") + + # Wrap video and preview in a media row + self._preview_col = div(self._capture_preview) + self._preview_col.classes.add("invent-webcam-preview-col") + + self._media_row = div(video_container, self._preview_col) + self._media_row.classes.add("invent-webcam-media-row") + element = div( self._canvas, - video_container, + self._media_row, self._controls, self._indicators, id=self.id, ) + element.classes.add("invent-webcam") element.classes.add("webcam-container") - # Initialize the webcam stream + # Start the camera stream! self._setup_webcam_stream() return element