mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-19 18:27:29 -05:00
pyweb camera support (#1901)
* add media module * add Device class to media * add camera test example * add snap, download and other convenience methods * load devices automagically * add draw method to canvas * add docstring for download * add docstrings to draw method * add docstrings to snap * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * load devices as soon as the page loads * solve conflict * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove display calls listing devices in camera example * fix typos and other small errors * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix typo in docstring * fix error message typo * replace setAttribute on JS properties with accessors * remove debug statement * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add docstrings * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add docstrings to camera example * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
95
pyscript.core/src/stdlib/pyweb/media.py
Normal file
95
pyscript.core/src/stdlib/pyweb/media.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from pyodide.ffi import to_js
|
||||||
|
from pyscript import window
|
||||||
|
|
||||||
|
|
||||||
|
class Device:
|
||||||
|
"""Device represents a media input or output device, such as a microphone,
|
||||||
|
camera, or headset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, device):
|
||||||
|
self._js = device
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self):
|
||||||
|
return self._js.deviceId
|
||||||
|
|
||||||
|
@property
|
||||||
|
def group(self):
|
||||||
|
return self._js.groupId
|
||||||
|
|
||||||
|
@property
|
||||||
|
def kind(self):
|
||||||
|
return self._js.kind
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self):
|
||||||
|
return self._js.label
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return getattr(self, key)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def load(cls, audio=False, video=True):
|
||||||
|
"""Load the device stream."""
|
||||||
|
options = window.Object.new()
|
||||||
|
options.audio = audio
|
||||||
|
if isinstance(video, bool):
|
||||||
|
options.video = video
|
||||||
|
else:
|
||||||
|
# TODO: Think this can be simplified but need to check it on the pyodide side
|
||||||
|
|
||||||
|
# TODO: this is pyodide specific. shouldn't be!
|
||||||
|
options.video = window.Object.new()
|
||||||
|
for k in video:
|
||||||
|
setattr(
|
||||||
|
options.video,
|
||||||
|
k,
|
||||||
|
to_js(video[k], dict_converter=window.Object.fromEntries),
|
||||||
|
)
|
||||||
|
|
||||||
|
stream = await window.navigator.mediaDevices.getUserMedia(options)
|
||||||
|
return stream
|
||||||
|
|
||||||
|
async def get_stream(self):
|
||||||
|
key = self.kind.replace("input", "").replace("output", "")
|
||||||
|
options = {key: {"deviceId": {"exact": self.id}}}
|
||||||
|
|
||||||
|
return await self.load(**options)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_devices() -> list[dict]:
|
||||||
|
"""
|
||||||
|
Return the list of the currently available media input and output devices,
|
||||||
|
such as microphones, cameras, headsets, and so forth.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
list(dict) - list of dictionaries representing the available media devices.
|
||||||
|
Each dictionary has the following keys:
|
||||||
|
* deviceId: a string that is an identifier for the represented device
|
||||||
|
that is persisted across sessions. It is un-guessable by other
|
||||||
|
applications and unique to the origin of the calling application.
|
||||||
|
It is reset when the user clears cookies (for Private Browsing, a
|
||||||
|
different identifier is used that is not persisted across sessions).
|
||||||
|
|
||||||
|
* groupId: a string that is a group identifier. Two devices have the same
|
||||||
|
group identifier if they belong to the same physical device — for
|
||||||
|
example a monitor with both a built-in camera and a microphone.
|
||||||
|
|
||||||
|
* kind: an enumerated value that is either "videoinput", "audioinput"
|
||||||
|
or "audiooutput".
|
||||||
|
|
||||||
|
* label: a string describing this device (for example "External USB
|
||||||
|
Webcam").
|
||||||
|
|
||||||
|
Note: the returned list will omit any devices that are blocked by the document
|
||||||
|
Permission Policy: microphone, camera, speaker-selection (for output devices),
|
||||||
|
and so on. Access to particular non-default devices is also gated by the
|
||||||
|
Permissions API, and the list will omit devices for which the user has not
|
||||||
|
granted explicit permission.
|
||||||
|
"""
|
||||||
|
# https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices
|
||||||
|
return [
|
||||||
|
Device(obj) for obj in await window.navigator.mediaDevices.enumerateDevices()
|
||||||
|
]
|
||||||
@@ -204,6 +204,91 @@ class Element(BaseElement):
|
|||||||
def show_me(self):
|
def show_me(self):
|
||||||
self._js.scrollIntoView()
|
self._js.scrollIntoView()
|
||||||
|
|
||||||
|
def snap(
|
||||||
|
self,
|
||||||
|
to: BaseElement | str = None,
|
||||||
|
width: int | None = None,
|
||||||
|
height: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Captures a snapshot of a video element. (Only available for video elements)
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
* to: element where to save the snapshot of the video frame to
|
||||||
|
* width: width of the image
|
||||||
|
* height: height of the image
|
||||||
|
|
||||||
|
Output:
|
||||||
|
(Element) canvas element where the video frame snapshot was drawn into
|
||||||
|
"""
|
||||||
|
if self._js.tagName != "VIDEO":
|
||||||
|
raise AttributeError("Snap method is only available for video Elements")
|
||||||
|
|
||||||
|
if to is None:
|
||||||
|
canvas = self.create("canvas")
|
||||||
|
if width is None:
|
||||||
|
width = self._js.width
|
||||||
|
if height is None:
|
||||||
|
height = self._js.height
|
||||||
|
canvas._js.width = width
|
||||||
|
canvas._js.height = height
|
||||||
|
|
||||||
|
elif isistance(to, Element):
|
||||||
|
if to._js.tagName != "CANVAS":
|
||||||
|
raise TypeError("Element to snap to must a canvas.")
|
||||||
|
canvas = to
|
||||||
|
elif getattr(to, "tagName", "") == "CANVAS":
|
||||||
|
canvas = Element(to)
|
||||||
|
elif isinstance(to, str):
|
||||||
|
canvas = pydom[to][0]
|
||||||
|
if canvas._js.tagName != "CANVAS":
|
||||||
|
raise TypeError("Element to snap to must a be canvas.")
|
||||||
|
|
||||||
|
canvas.draw(self, width, height)
|
||||||
|
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
def download(self, filename: str = "snapped.png") -> None:
|
||||||
|
"""Download the current element (only available for canvas elements) with the filename
|
||||||
|
provided in input.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
* filename (str): name of the file being downloaded
|
||||||
|
|
||||||
|
Output:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
if self._js.tagName != "CANVAS":
|
||||||
|
raise AttributeError(
|
||||||
|
"The download method is only available for canvas Elements"
|
||||||
|
)
|
||||||
|
|
||||||
|
link = self.create("a")
|
||||||
|
link._js.download = filename
|
||||||
|
link._js.href = self._js.toDataURL()
|
||||||
|
link._js.click()
|
||||||
|
|
||||||
|
def draw(self, what, width, height):
|
||||||
|
"""Draw `what` on the current element (only available for canvas elements).
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
* what (canvas image source): An element to draw into the context. The specification permits any canvas
|
||||||
|
image source, specifically, an HTMLImageElement, an SVGImageElement, an HTMLVideoElement,
|
||||||
|
an HTMLCanvasElement, an ImageBitmap, an OffscreenCanvas, or a VideoFrame.
|
||||||
|
"""
|
||||||
|
if self._js.tagName != "CANVAS":
|
||||||
|
raise AttributeError(
|
||||||
|
"The draw method is only available for canvas Elements"
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(what, Element):
|
||||||
|
what = what._js
|
||||||
|
|
||||||
|
# https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
|
||||||
|
self._js.getContext("2d").drawImage(what, 0, 0, width, height)
|
||||||
|
|
||||||
|
|
||||||
class OptionsProxy:
|
class OptionsProxy:
|
||||||
"""This class represents the options of a select element. It
|
"""This class represents the options of a select element. It
|
||||||
|
|||||||
24
pyscript.core/test/camera.html
Normal file
24
pyscript.core/test/camera.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PyScript Media Example</title>
|
||||||
|
<link rel="stylesheet" href="../dist/core.css">
|
||||||
|
<script type="module" src="../dist/core.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="py" src="camera.py" async></script>
|
||||||
|
|
||||||
|
<label for="cars">Choose a device:</label>
|
||||||
|
|
||||||
|
<select name="devices" id="devices"></select>
|
||||||
|
|
||||||
|
<button id="pick-device">Select the device</button>
|
||||||
|
<button id="snap">Snap</button>
|
||||||
|
|
||||||
|
<div id="result"></div>
|
||||||
|
|
||||||
|
<video id="video" width="600" height="400" autoplay></video>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
pyscript.core/test/camera.py
Normal file
32
pyscript.core/test/camera.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from pyodide.ffi import create_proxy
|
||||||
|
from pyscript import display, document, when, window
|
||||||
|
from pyweb import media, pydom
|
||||||
|
|
||||||
|
devicesSelect = pydom["#devices"][0]
|
||||||
|
video = pydom["video"][0]
|
||||||
|
devices = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_media_devices(event=None):
|
||||||
|
"""List the available media devices."""
|
||||||
|
global devices
|
||||||
|
for i, device in enumerate(await media.list_devices()):
|
||||||
|
devices[device.id] = device
|
||||||
|
label = f"{i} - ({device.kind}) {device.label} [{device.id}]"
|
||||||
|
devicesSelect.options.add(value=device.id, html=label)
|
||||||
|
|
||||||
|
|
||||||
|
@when("click", "#pick-device")
|
||||||
|
async def connect_to_device(e):
|
||||||
|
"""Connect to the selected device."""
|
||||||
|
device = devices[devicesSelect.value]
|
||||||
|
video._js.srcObject = await device.get_stream()
|
||||||
|
|
||||||
|
|
||||||
|
@when("click", "#snap")
|
||||||
|
async def camera_click(e):
|
||||||
|
"""Take a picture and download it."""
|
||||||
|
video.snap().download()
|
||||||
|
|
||||||
|
|
||||||
|
await list_media_devices()
|
||||||
1
pyscript.core/types/stdlib/pyscript.d.ts
vendored
1
pyscript.core/types/stdlib/pyscript.d.ts
vendored
@@ -7,6 +7,7 @@ declare namespace _default {
|
|||||||
"util.py": string;
|
"util.py": string;
|
||||||
};
|
};
|
||||||
let pyweb: {
|
let pyweb: {
|
||||||
|
"media.py": string;
|
||||||
"pydom.py": string;
|
"pydom.py": string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user