diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 5e1d0fb1..665cd03c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -72,7 +72,7 @@ jobs: - run: | mk python-release owner=vkottler \ - repo=runtimepy version=4.4.0 + repo=runtimepy version=4.4.1 if: | matrix.python-version == '3.11' && matrix.system == 'ubuntu-latest' diff --git a/.gitignore b/.gitignore index ce1e313e..6c548dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ mklocal docs compile_commands.json src +*.webm +*.log diff --git a/README.md b/README.md index be9ddcf8..9e5c885f 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ===================================== generator=datazen version=3.1.4 - hash=72952b0b2e4342ce27726da3c02bc379 + hash=696c64ad3b4f9680a238a934819b4c89 ===================================== --> -# runtimepy ([4.4.0](https://pypi.org/project/runtimepy/)) +# runtimepy ([4.4.1](https://pypi.org/project/runtimepy/)) [![python](https://img.shields.io/pypi/pyversions/runtimepy.svg)](https://pypi.org/project/runtimepy/) ![Build Status](https://github.com/vkottler/runtimepy/workflows/Python%20Package/badge.svg) diff --git a/config b/config index cc472ae7..117336ef 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit cc472ae7257da9c95a2059d7d9134ef880eeb43b +Subproject commit 117336ef886174ba499b37eae7c7e17aaa892047 diff --git a/local/arbiter/compress_and_gif.sh b/local/arbiter/compress_and_gif.sh new file mode 100755 index 00000000..734fb73d --- /dev/null +++ b/local/arbiter/compress_and_gif.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +set -e + +SCALE_ARGS=() + +create_gif() { + set -x + ffmpeg -y -i "$1" "${SCALE_ARGS[@]}" "${1%.*}.gif" + set +x +} + +# https://askubuntu.com/questions/1440589/how-to-compress-videos-properly-with-webm +COMPRESS_ARGS=(-c:v libvpx-vp9 -b:v 0 -crf 48 -deadline best) +COMPRESS_ARGS+=(-row-mt 1 -b:a 96k -ac 2) + +compress_and_gif() { + # Two pass compression. + COMPRESSED="compressed-$1" + + set -x + ffmpeg -i "$1" "${COMPRESS_ARGS[@]}" \ + -pass 1 -an -f null /dev/null + ffmpeg -y -i "$1" "${COMPRESS_ARGS[@]}" \ + -pass 2 -c:a libopus "$COMPRESSED" + set +x + + # Create GIF. + create_gif "$COMPRESSED" +} + +# Scale gif down. +SCALE_ARGS+=(-vf scale="832:-1") +create_gif "$1" + +# compress_and_gif "$1" diff --git a/local/variables/package.yaml b/local/variables/package.yaml index 5d2a1043..9e0b1807 100644 --- a/local/variables/package.yaml +++ b/local/variables/package.yaml @@ -1,5 +1,5 @@ --- major: 4 minor: 4 -patch: 0 +patch: 1 entry: runtimepy diff --git a/pyproject.toml b/pyproject.toml index c9e4e6c0..54192888 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta:__legacy__" [project] name = "runtimepy" -version = "4.4.0" +version = "4.4.1" description = "A framework for implementing Python services." readme = "README.md" requires-python = ">=3.11" diff --git a/runtimepy/__init__.py b/runtimepy/__init__.py index 2ef57ebd..18d9aa7a 100644 --- a/runtimepy/__init__.py +++ b/runtimepy/__init__.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.4 -# hash=ba303dd05d632963067eb7020aeb9f0e +# hash=28647a8e43d3a46f378e9bb06c1c6d30 # ===================================== """ @@ -10,7 +10,7 @@ DESCRIPTION = "A framework for implementing Python services." PKG_NAME = "runtimepy" -VERSION = "4.4.0" +VERSION = "4.4.1" # runtimepy-specific content. METRICS_NAME = "metrics" diff --git a/runtimepy/data/browser.yaml b/runtimepy/data/browser.yaml new file mode 100644 index 00000000..dc30bac0 --- /dev/null +++ b/runtimepy/data/browser.yaml @@ -0,0 +1,4 @@ +--- +config: + # Attempt to open a browser automatically. + xdg_open_http: true diff --git a/runtimepy/data/css/main.css b/runtimepy/data/css/main.css index d2adf719..294d1f00 100644 --- a/runtimepy/data/css/main.css +++ b/runtimepy/data/css/main.css @@ -27,7 +27,7 @@ body > :first-child { background-color: var(--bs-secondary-bg-subtle); } -#ui-plot { +.click-plot { cursor: pointer; } diff --git a/runtimepy/data/js/classes/PlotDrawer.js b/runtimepy/data/js/classes/PlotDrawer.js index 33db0ddb..45049c0d 100644 --- a/runtimepy/data/js/classes/PlotDrawer.js +++ b/runtimepy/data/js/classes/PlotDrawer.js @@ -12,8 +12,12 @@ class PlotDrawer { /* Point managers for individual channels. */ this.channels = {}; - /* need to make this an N entity for multiple channels */ + /* Line objects by channel name. */ this.lines = {}; + + /* Keep track of x-axis bounds for each channel. */ + this.oldestTimestamps = {}; + this.newestTimestamps = {}; } update() { this.wglp.update(); } @@ -25,12 +29,16 @@ class PlotDrawer { this.newLine(name); } else { delete this.lines[name]; + delete this.oldestTimestamps[name]; + delete this.newestTimestamps[name]; + this.channels[name].buffer.reset(); } this.updateLines(); } handlePoints(points) { + /* Handle ingesting new point data. */ for (const key in points) { if (key in this.states && this.states[key]) { /* Add point manager and create line for plotted channel. */ @@ -38,7 +46,34 @@ class PlotDrawer { this.channels[key] = new PointManager(); } if (key in this.lines) { - this.channels[key].handlePoints(points[key], this.lines[key]); + let result = this.channels[key].handlePoints(points[key]); + + /* Update timestamp tracking. */ + this.oldestTimestamps[key] = result[0]; + this.newestTimestamps[key] = result[1]; + } + } + } + + /* Compute x-axis bounds (min and max timestamps). */ + let minTimestamp = null; + let maxTimestamp = null; + for (const key in this.oldestTimestamps) { + let oldest = this.oldestTimestamps[key]; + let newest = this.newestTimestamps[key]; + if (minTimestamp == null || oldest < minTimestamp) { + minTimestamp = oldest; + } + if (maxTimestamp == null || newest > maxTimestamp) { + maxTimestamp = newest; + } + } + + /* Re-draw all lines. */ + if (minTimestamp != null && maxTimestamp != null) { + for (const key in this.channels) { + if (key in this.lines) { + this.channels[key].draw(this.lines[key], minTimestamp, maxTimestamp); } } } diff --git a/runtimepy/data/js/classes/PointBuffer.js b/runtimepy/data/js/classes/PointBuffer.js index bd4efe04..45acff14 100644 --- a/runtimepy/data/js/classes/PointBuffer.js +++ b/runtimepy/data/js/classes/PointBuffer.js @@ -11,6 +11,8 @@ class PointBuffer { this.head = 0; this.tail = 0; this.elements = 0; + this.oldestTimestamp = null; + this.newestTimestamp = null; } updateCapacity(capacity) { @@ -30,7 +32,7 @@ class PointBuffer { this.timestamps = newTimestamps; } - ingest(points, line) { + ingest(points) { for (let point of points) { /* Store point. */ this.values[this.tail] = point[0]; @@ -48,16 +50,18 @@ class PointBuffer { } } - this.draw(line); + /* Update tracking of oldest and newest point timestamps. */ + this.oldestTimestamp = this.timestamps[this.head]; + this.newestTimestamp = this.timestamps[this.newestIdx()]; } - normalizeTimestamps(newestIdx, oldestIdx) { + normalizeTimestamps(newestIdx, oldestIdx, oldestTimestamp, newestTimestamp) { /* * Determine slope+offset so each timestamp can be mapped to (-1,1) * domain. */ - let oldestTimestamp = this.timestamps[oldestIdx]; - let slope = 2 / (this.timestamps[newestIdx] - oldestTimestamp); + + let slope = 2 / (newestTimestamp - oldestTimestamp); /* Build array of plot-able timestamp X values. */ let times = []; @@ -113,7 +117,15 @@ class PointBuffer { incrIndex(val) { return (val + 1) % this.capacity; } - draw(line) { + newestIdx() { + let result = this.tail - 1; + if (result < 0) { + result = this.capacity - 1; + } + return result; + } + + draw(line, oldestTimestamp, newestTimestamp) { /* Need at least two points to draw a line. */ if (this.elements < 2) { return; @@ -121,13 +133,12 @@ class PointBuffer { /* Find indices for oldest and newest points. */ let oldestIdx = this.head; - let newestIdx = this.tail - 1; - if (newestIdx < 0) { - newestIdx = this.capacity - 1; - } /* Build arrays of plot-able (normalized) timestamps and values. */ - let times = this.normalizeTimestamps(newestIdx, oldestIdx); + let newestIdx = this.newestIdx(); + + let times = this.normalizeTimestamps(newestIdx, oldestIdx, oldestTimestamp, + newestTimestamp); let values = this.normalizeValues(newestIdx, oldestIdx); /* Set points. */ diff --git a/runtimepy/data/js/classes/PointManager.js b/runtimepy/data/js/classes/PointManager.js index fa434afe..354382c6 100644 --- a/runtimepy/data/js/classes/PointManager.js +++ b/runtimepy/data/js/classes/PointManager.js @@ -3,11 +3,17 @@ class PointManager { this.color = new WebglPlotBundle.ColorRGBA(Math.random(), Math.random(), Math.random(), 1); - /* How should capacity be controlled? */ - this.buffer = new PointBuffer(256); + /* How should capacity be controlled? Try to find UI element (probably + * needs to be passed in). */ + this.buffer = new PointBuffer(512); } - draw(line) { this.buffer.draw(line); } + draw(line, minTimestamp, maxTimestamp) { + this.buffer.draw(line, minTimestamp, maxTimestamp); + } - handlePoints(points, line) { this.buffer.ingest(points, line); } + handlePoints(points) { + this.buffer.ingest(points); + return [ this.buffer.oldestTimestamp, this.buffer.newestTimestamp ]; + } } diff --git a/runtimepy/data/server_dev.yaml b/runtimepy/data/server_dev.yaml index d230cee3..d82cb4d4 100644 --- a/runtimepy/data/server_dev.yaml +++ b/runtimepy/data/server_dev.yaml @@ -1,6 +1,7 @@ --- includes: - package://runtimepy/dummy_load.yaml + - package://runtimepy/browser.yaml config: localhost: true @@ -15,5 +16,8 @@ config: # This is the default. # html_method: runtimepy.net.server.app.channel_environments + # For a simple demo. + xdg_fragment: "wave1,hide-tabs,hide-channels/wave1:sin,cos" + port_overrides: runtimepy_http_server: 8000 diff --git a/runtimepy/net/server/app/__init__.py b/runtimepy/net/server/app/__init__.py index 8465828f..cae60028 100644 --- a/runtimepy/net/server/app/__init__.py +++ b/runtimepy/net/server/app/__init__.py @@ -3,15 +3,47 @@ """ # built-in +from contextlib import suppress from importlib import import_module as _import_module +from typing import Any # internal from runtimepy.net.arbiter.info import AppInfo from runtimepy.net.server import RuntimepyServerConnection from runtimepy.net.server.app.create import config_param, create_app +from runtimepy.subprocess import spawn_exec from runtimepy.util import import_str_and_item +async def launch_browser(app: AppInfo) -> None: + """ + Attempts to launch browser windows/tabs if any 'http_server' server ports + are configured. + """ + + # Launch browser based on config option. + if config_param(app, "xdg_open_http", False): + + port: Any + for port in app.config["root"]["ports"]: # type: ignore + if "http_server" in port["name"]: + # URI parameters. + hostname = config_param(app, "xdg_host", "localhost") + + # Assemble URI. + uri = f"http://{hostname}:{port['port']}/" + + # Add a fragment if one was specified. + fragment = config_param(app, "xdg_fragment", "") + if fragment: + uri += "#" + fragment + + with suppress(FileNotFoundError): + await app.stack.enter_async_context( + spawn_exec("xdg-open", uri) + ) + + async def setup(app: AppInfo) -> int: """Perform server application setup steps.""" @@ -27,4 +59,6 @@ async def setup(app: AppInfo) -> int: app, getattr(_import_module(module), method) ) + await launch_browser(app) + return 0 diff --git a/runtimepy/net/server/app/env/tab/html.py b/runtimepy/net/server/app/env/tab/html.py index c190b563..a4d2bf1b 100644 --- a/runtimepy/net/server/app/env/tab/html.py +++ b/runtimepy/net/server/app/env/tab/html.py @@ -255,5 +255,5 @@ def compose(self, parent: Element) -> None: tag="canvas", id=self.get_id("plot"), parent=div(parent=container, class_str="w-100 h-100 border-start"), - class_str="w-100 h-100", + class_str="w-100 h-100 click-plot", ) diff --git a/tests/commands/test_arbiter.py b/tests/commands/test_arbiter.py index 18810ff5..8cbd3ead 100644 --- a/tests/commands/test_arbiter.py +++ b/tests/commands/test_arbiter.py @@ -46,5 +46,6 @@ def test_arbiter_command_advanced(): args = base + [str(resource("connection_arbiter", f"{entry}.yaml"))] if not is_windows(): args.append("dummy_load") + args.append("browser") assert runtimepy_main(args) == 0