diff --git a/plugins/python-codecs/.gitignore b/plugins/python-codecs/.gitignore new file mode 100644 index 000000000..c3f88796f --- /dev/null +++ b/plugins/python-codecs/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +out/ +node_modules/ +dist/ +.venv diff --git a/plugins/python-codecs/.npmignore b/plugins/python-codecs/.npmignore new file mode 100644 index 000000000..72f2e2832 --- /dev/null +++ b/plugins/python-codecs/.npmignore @@ -0,0 +1,9 @@ +.DS_Store +out/ +node_modules/ +*.map +fs +src +.vscode +dist/*.js +.venv diff --git a/plugins/python-codecs/.vscode/launch.json b/plugins/python-codecs/.vscode/launch.json new file mode 100644 index 000000000..e6fa82af0 --- /dev/null +++ b/plugins/python-codecs/.vscode/launch.json @@ -0,0 +1,41 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Scrypted Debugger", + "type": "python", + "request": "attach", + "connect": { + "host": "${config:scrypted.debugHost}", + "port": 10081 + }, + "justMyCode": false, + "preLaunchTask": "scrypted: deploy+debug", + "pathMappings": [ + { + "localRoot": "/Volumes/Dev/scrypted/server/python/", + "remoteRoot": "/Volumes/Dev/scrypted/server/python/", + }, + { + "localRoot": "${workspaceFolder}/src", + "remoteRoot": "${config:scrypted.pythonRemoteRoot}" + }, + + ] + }, + { + "name": "Python: Test", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/src/test.py", + "console": "internalConsole", + "justMyCode": true, + "env": { + "GST_PLUGIN_PATH": "/opt/homebrew/lib/gstreamer-1.0" + } + } + ] +} \ No newline at end of file diff --git a/plugins/python-codecs/.vscode/settings.json b/plugins/python-codecs/.vscode/settings.json new file mode 100644 index 000000000..d71b6ca7d --- /dev/null +++ b/plugins/python-codecs/.vscode/settings.json @@ -0,0 +1,19 @@ + +{ + // docker installation + // "scrypted.debugHost": "koushik-thin", + // "scrypted.serverRoot": "/server", + + // pi local installation + // "scrypted.debugHost": "192.168.2.119", + // "scrypted.serverRoot": "/home/pi/.scrypted", + + // local checkout + "scrypted.debugHost": "127.0.0.1", + "scrypted.serverRoot": "/Users/koush/.scrypted", + + "scrypted.pythonRemoteRoot": "${config:scrypted.serverRoot}/volume/plugin.zip", + "python.analysis.extraPaths": [ + "./node_modules/@scrypted/sdk/types/scrypted_python" + ] +} \ No newline at end of file diff --git a/plugins/python-codecs/.vscode/tasks.json b/plugins/python-codecs/.vscode/tasks.json new file mode 100644 index 000000000..4d922a539 --- /dev/null +++ b/plugins/python-codecs/.vscode/tasks.json @@ -0,0 +1,20 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "scrypted: deploy+debug", + "type": "shell", + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "command": "npm run scrypted-vscode-launch ${config:scrypted.debugHost}", + }, + ] +} diff --git a/plugins/python-codecs/README.md b/plugins/python-codecs/README.md new file mode 100644 index 000000000..7f0c8d01c --- /dev/null +++ b/plugins/python-codecs/README.md @@ -0,0 +1,3 @@ +# Python Codecs for Scrypted + +Adds encoding and decoding capabilites to Scrypted via Gstreamer and Libav. diff --git a/plugins/python-codecs/package-lock.json b/plugins/python-codecs/package-lock.json new file mode 100644 index 000000000..cef703b92 --- /dev/null +++ b/plugins/python-codecs/package-lock.json @@ -0,0 +1,57 @@ +{ + "name": "@scrypted/python-codecs", + "version": "0.0.23", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@scrypted/python-codecs", + "version": "0.0.23", + "devDependencies": { + "@scrypted/sdk": "file:../../sdk" + } + }, + "../../sdk": { + "name": "@scrypted/sdk", + "version": "0.2.85", + "dev": true, + "license": "ISC", + "dependencies": { + "@babel/preset-typescript": "^7.18.6", + "adm-zip": "^0.4.13", + "axios": "^0.21.4", + "babel-loader": "^9.1.0", + "babel-plugin-const-enum": "^1.1.0", + "esbuild": "^0.15.9", + "ncp": "^2.0.0", + "raw-loader": "^4.0.2", + "rimraf": "^3.0.2", + "tmp": "^0.2.1", + "ts-loader": "^9.4.2", + "typescript": "^4.9.4", + "webpack": "^5.75.0", + "webpack-bundle-analyzer": "^4.5.0" + }, + "bin": { + "scrypted-changelog": "bin/scrypted-changelog.js", + "scrypted-debug": "bin/scrypted-debug.js", + "scrypted-deploy": "bin/scrypted-deploy.js", + "scrypted-deploy-debug": "bin/scrypted-deploy-debug.js", + "scrypted-package-json": "bin/scrypted-package-json.js", + "scrypted-setup-project": "bin/scrypted-setup-project.js", + "scrypted-webpack": "bin/scrypted-webpack.js" + }, + "devDependencies": { + "@types/node": "^18.11.18", + "@types/stringify-object": "^4.0.0", + "stringify-object": "^3.3.0", + "ts-node": "^10.4.0", + "typedoc": "^0.23.21" + } + }, + "node_modules/@scrypted/sdk": { + "resolved": "../../sdk", + "link": true + } + } +} diff --git a/plugins/python-codecs/package.json b/plugins/python-codecs/package.json new file mode 100644 index 000000000..4cf97e2bf --- /dev/null +++ b/plugins/python-codecs/package.json @@ -0,0 +1,34 @@ +{ + "name": "@scrypted/python-codecs", + "description": "Scrypted Python Codecs", + "keywords": [ + "scrypted", + "plugin", + "codecs" + ], + "scripts": { + "scrypted-setup-project": "scrypted-setup-project", + "prescrypted-setup-project": "scrypted-package-json", + "build": "scrypted-webpack", + "prepublishOnly": "NODE_ENV=production scrypted-webpack", + "prescrypted-vscode-launch": "scrypted-webpack", + "scrypted-vscode-launch": "scrypted-deploy-debug", + "scrypted-deploy-debug": "scrypted-deploy-debug", + "scrypted-debug": "scrypted-debug", + "scrypted-deploy": "scrypted-deploy", + "scrypted-readme": "scrypted-readme", + "scrypted-package-json": "scrypted-package-json" + }, + "scrypted": { + "name": "Python Codecs", + "runtime": "python", + "type": "API", + "interfaces": [ + "VideoFrameGenerator" + ] + }, + "devDependencies": { + "@scrypted/sdk": "file:../../sdk" + }, + "version": "0.0.23" +} diff --git a/plugins/python-codecs/src/gstreamer.py b/plugins/python-codecs/src/gstreamer.py new file mode 100644 index 000000000..1e84273ef --- /dev/null +++ b/plugins/python-codecs/src/gstreamer.py @@ -0,0 +1,124 @@ +import concurrent.futures +import threading +import asyncio +from queue import Queue + +try: + import gi + gi.require_version('Gst', '1.0') + gi.require_version('GstBase', '1.0') + + from gi.repository import GLib, GObject, Gst + GObject.threads_init() + Gst.init(None) +except: + pass + +class Callback: + def __init__(self, callback) -> None: + self.loop = asyncio.get_event_loop() + self.callback = callback + +def createPipelineIterator(pipeline: str): + pipeline = '{pipeline} ! appsink name=appsink emit-signals=true sync=false'.format(pipeline=pipeline) + gst = Gst.parse_launch(pipeline) + bus = gst.get_bus() + + def on_bus_message(bus, message): + t = str(message.type) + print(t) + if t == str(Gst.MessageType.EOS): + finish() + elif t == str(Gst.MessageType.WARNING): + err, debug = message.parse_warning() + print('Warning: %s: %s\n' % (err, debug)) + elif t == str(Gst.MessageType.ERROR): + err, debug = message.parse_error() + print('Error: %s: %s\n' % (err, debug)) + finish() + + def stopGst(): + bus.remove_signal_watch() + bus.disconnect(watchId) + gst.set_state(Gst.State.NULL) + + def finish(): + nonlocal hasFinished + hasFinished = True + callback = Callback(None) + callbackQueue.put(callback) + if not asyncFuture.done(): + asyncFuture.set_result(None) + if not finished.done(): + finished.set_result(None) + + watchId = bus.connect('message', on_bus_message) + bus.add_signal_watch() + + finished = concurrent.futures.Future() + finished.add_done_callback(lambda _: stopGst()) + hasFinished = False + + appsink = gst.get_by_name('appsink') + callbackQueue = Queue() + asyncFuture = asyncio.Future() + + async def gen(): + try: + while True: + nonlocal asyncFuture + asyncFuture = asyncio.Future() + yieldFuture = asyncio.Future() + async def asyncCallback(sample): + asyncFuture.set_result(sample) + await yieldFuture + callbackQueue.put(Callback(asyncCallback)) + sample = await asyncFuture + if not sample: + break + yield sample + yieldFuture.set_result(None) + finally: + finish() + + + def on_new_sample(sink, preroll): + nonlocal hasFinished + + sample = sink.emit('pull-preroll' if preroll else 'pull-sample') + + callback: Callback = callbackQueue.get() + if not callback.callback or hasFinished: + hasFinished = True + if callback.callback: + asyncio.run_coroutine_threadsafe(callback.callback(None), loop = callback.loop) + return Gst.FlowReturn.OK + + future = asyncio.run_coroutine_threadsafe(callback.callback(sample), loop = callback.loop) + future.result() + return Gst.FlowReturn.OK + + appsink.connect('new-preroll', on_new_sample, True) + appsink.connect('new-sample', on_new_sample, False) + + gst.set_state(Gst.State.PLAYING) + return gst, gen + +def mainThread(): + async def asyncMain(): + gst, gen = createPipelineIterator('rtspsrc location=rtsp://localhost:59668/18cc179a814fd5b3 ! rtph264depay ! h264parse ! vtdec_hw ! videoconvert ! video/x-raw') + i = 0 + async for sample in gen(): + print('sample') + i = i + 1 + if i == 10: + break + + loop = asyncio.new_event_loop() + asyncio.ensure_future(asyncMain(), loop = loop) + loop.run_forever() + +if __name__ == "__main__": + threading.Thread(target = mainThread).start() + mainLoop = GLib.MainLoop() + mainLoop.run() diff --git a/plugins/python-codecs/src/main.py b/plugins/python-codecs/src/main.py new file mode 100644 index 000000000..0330ff114 --- /dev/null +++ b/plugins/python-codecs/src/main.py @@ -0,0 +1,48 @@ +import scrypted_sdk +from typing import Any +from urllib.parse import urlparse + +def optional_chain(root, *keys): + result = root + for k in keys: + if isinstance(result, dict): + result = result.get(k, None) + else: + result = getattr(result, k, None) + if result is None: + break + return result + +class PythonCodecs(scrypted_sdk.ScryptedDeviceBase, scrypted_sdk.VideoFrameGenerator): + def __init__(self, nativeId = None): + super().__init__(nativeId) + + async def generateVideoFrames(self, mediaObject: scrypted_sdk.MediaObject, options: scrypted_sdk.VideoFrameGeneratorOptions = None, filter: Any = None) -> scrypted_sdk.VideoFrame: + ffmpegInput: scrypted_sdk.FFmpegInput = await scrypted_sdk.mediaManager.convertMediaObjectToJSON(mediaObject, scrypted_sdk.ScryptedMimeTypes.FFmpegInput.value) + container = ffmpegInput.get('container', None) + videosrc = ffmpegInput.get('url') + videoCodec = optional_chain(ffmpegInput, 'mediaStreamOptions', 'video', 'codec') + + if videosrc.startswith('tcp://'): + parsed_url = urlparse(videosrc) + videosrc = 'tcpclientsrc port=%s host=%s' % ( + parsed_url.port, parsed_url.hostname) + if container == 'mpegts': + videosrc += ' ! tsdemux' + elif container == 'sdp': + videosrc += ' ! sdpdemux' + else: + raise Exception('unknown container %s' % container) + elif videosrc.startswith('rtsp'): + videosrc = 'rtspsrc buffer-mode=0 location=%s protocols=tcp latency=0 is-live=false' % videosrc + if videoCodec == 'h264': + videosrc += ' ! rtph264depay ! h264parse' + + try: + while True: + yield 1 + finally: + print('done!') + +def create_scrypted_plugin(): + return PythonCodecs() diff --git a/plugins/python-codecs/src/requirements.txt b/plugins/python-codecs/src/requirements.txt new file mode 100644 index 000000000..0d931b08d --- /dev/null +++ b/plugins/python-codecs/src/requirements.txt @@ -0,0 +1,5 @@ +# plugin +PyGObject>=3.30.4; sys_platform != 'win32' +# libav doesnt work on arm7 +av>=10.0.0; sys_platform != 'linux' or platform_machine == 'x86_64' or platform_machine == 'aarch64' +pyvips \ No newline at end of file