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