devtools: Add dots-viewer set of tools
This adds `gstdump` and `gst-dots-viewer` server, see the README for more details about what those tools do. Part-of: <https://gitlab.freedesktop.org/gstreamer/gstreamer/-/merge_requests/7999>
|
@ -175,6 +175,9 @@ def is_library_target_and_not_plugin(target, filename):
|
||||||
|
|
||||||
|
|
||||||
def is_binary_target_and_in_path(target, filename, bindir):
|
def is_binary_target_and_in_path(target, filename, bindir):
|
||||||
|
if target['name'] in ['gst-dots-viewer', 'gstdump']:
|
||||||
|
return True
|
||||||
|
|
||||||
if target['type'] != 'executable':
|
if target['type'] != 'executable':
|
||||||
return False
|
return False
|
||||||
# Check if this file installed by this target is installed to bindir
|
# Check if this file installed by this target is installed to bindir
|
||||||
|
|
2345
subprojects/gst-devtools/dots-viewer/Cargo.lock
generated
Normal file
41
subprojects/gst-devtools/dots-viewer/Cargo.toml
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
[package]
|
||||||
|
name = "gst-dots-viewer"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
default-run = "gst-dots-viewer"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
actix = "0.13"
|
||||||
|
actix-web = "4.0"
|
||||||
|
actix-web-actors = "4.0"
|
||||||
|
actix-files = "0.6"
|
||||||
|
notify = "6.0"
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
dirs = "5.0.1"
|
||||||
|
serde_json = "1.0"
|
||||||
|
once_cell = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
actix-web-static-files = "4.0"
|
||||||
|
static-files = "0.2.1"
|
||||||
|
glob = "0.3"
|
||||||
|
single-instance = "0.3.3"
|
||||||
|
opener = "0.7.1"
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
static-files = "0.2.1"
|
||||||
|
|
||||||
|
# Binary target for gstdots
|
||||||
|
[[bin]]
|
||||||
|
name = "gst-dots-viewer"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
# Binary target for gstdump
|
||||||
|
[[bin]]
|
||||||
|
name = "gstdump"
|
||||||
|
path = "src/gstdump.rs"
|
82
subprojects/gst-devtools/dots-viewer/README.md
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
# gst-dots-viewer
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Simple web server that watches a directory for GStreamer `*.dot` files in a local path and
|
||||||
|
serves them as a web page allowing you to browse them easily. See
|
||||||
|
`gst-dots-viewer --help` for more information.
|
||||||
|
|
||||||
|
## How to use it
|
||||||
|
|
||||||
|
This tool uses the `GST_DEBUG_DUMP_DOT_DIR` environment variable to locate the dot
|
||||||
|
files generated by GStreamer and defaults to `$XDG_CACHE_DIR/gstreamer-dots/` if it is not set.
|
||||||
|
|
||||||
|
You can run it with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
Then you can open your browser at `http://localhost:3000` and wait for the graphs to appear as you use your
|
||||||
|
GStreamer application. The web page is updated every time a new `.dot` file is placed
|
||||||
|
in the path pointed by the folder watched by the `gst-dots-viewer` server.
|
||||||
|
|
||||||
|
## The `gstdump` utility
|
||||||
|
|
||||||
|
In order to simplify generating the dot files when developing GStreamer applications,
|
||||||
|
we provide the `gstdump` tool that can be used to **remove** old `.dot`
|
||||||
|
files and setup the [`pipeline-snapshot`](tracer-pipeline-snapshot) tracer with the following parameters:
|
||||||
|
|
||||||
|
- `xdg-cache=true`: Use the default 'cache' directory to store `.dot` files,
|
||||||
|
the same as what `gst-dots-viewer` uses by default
|
||||||
|
- `folder-mode=numbered`: Use folders to store the `.dot` files, with
|
||||||
|
incrementing number each time pipelines are dumped
|
||||||
|
|
||||||
|
If you have already configured the `pipeline-snapshot` tracer using the
|
||||||
|
`GST_TRACER` environment variable, `gstdump` will not override it.
|
||||||
|
|
||||||
|
`gstdump` also sets `GST_DEBUG_DUMP_DOT_DIR` to the path where `gst-dots-viewer` expects them
|
||||||
|
so pipelines that are 'manually' dumped by the application are also dumped.
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
Demo of the `gstdump`, gst-dots-viewer used in combination with the [tracer-pipeline-snapshot](tracer-pipeline-snapshot)
|
||||||
|
|
||||||
|
### Video:
|
||||||
|
|
||||||
|
[{width=70%}](https://youtu.be/-cHME_eNKbc "GStreamer dot files viewer")
|
||||||
|
|
||||||
|
### Start gst-dots
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
# Starts the `gst-dots-viewer` server with default parameters
|
||||||
|
# You can open it in your browser at http://localhost:3000
|
||||||
|
$ gst-dots-viewer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start the GStreamer pipeline with `pipeline-snapshot` and `gstdump`
|
||||||
|
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
# This runs the pipeline with `gstdump` which sets up:
|
||||||
|
#
|
||||||
|
# - the `pipeline-snapshot` tracer with the following parameters:
|
||||||
|
# - xdg-cache=true: Use the default 'cache' directory to store `.dot` files,
|
||||||
|
# the same as what `gst-dots-viewer` uses by default
|
||||||
|
# - folder-mode=numbered: Use folders to store the `.dot` files, with
|
||||||
|
# incrementing number each time pipelines are dumped
|
||||||
|
# - `GST_DEBUG_DUMP_DOT_DIR` path so pipelines that are 'manually' dumped by
|
||||||
|
# `gst-launch-1.0` are also dumped.
|
||||||
|
|
||||||
|
gstdump gst-launch-1.0 videotestsrc ! webrtcsink run-signalling-server=true0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dump pipelines manually thanks to the `pipeline-snapshot` tracer
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
kill -SIGUSR1 $(pgrep gst-launch-1.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each time the pipeline is dumped, the `gst-dots-viewer` server will refresh
|
||||||
|
the page to display the new pipelines.
|
5
subprojects/gst-devtools/dots-viewer/build.rs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
use static_files::resource_dir;
|
||||||
|
|
||||||
|
fn main() -> std::io::Result<()> {
|
||||||
|
resource_dir("./static").build()
|
||||||
|
}
|
204
subprojects/gst-devtools/dots-viewer/cargo_wrapper.py
Normal file
|
@ -0,0 +1,204 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import shlex
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from pathlib import Path as P
|
||||||
|
|
||||||
|
PARSER = ArgumentParser()
|
||||||
|
PARSER.add_argument("command", choices=["build", "test"])
|
||||||
|
PARSER.add_argument("build_dir", type=P)
|
||||||
|
PARSER.add_argument("src_dir", type=P)
|
||||||
|
PARSER.add_argument("root_dir", type=P)
|
||||||
|
PARSER.add_argument("target", choices=["release", "debug"])
|
||||||
|
PARSER.add_argument("prefix", type=P)
|
||||||
|
PARSER.add_argument("libdir", type=P)
|
||||||
|
PARSER.add_argument("--version", default=None)
|
||||||
|
PARSER.add_argument("--bin", default=None, type=P)
|
||||||
|
PARSER.add_argument("--features", nargs="+", default=[])
|
||||||
|
PARSER.add_argument("--packages", nargs="+", default=[])
|
||||||
|
PARSER.add_argument("--examples", nargs="+", default=[])
|
||||||
|
PARSER.add_argument("--lib-suffixes", nargs="+", default=[])
|
||||||
|
PARSER.add_argument("--exe-suffix")
|
||||||
|
PARSER.add_argument("--depfile")
|
||||||
|
PARSER.add_argument("--disable-doc", action="store_true", default=False)
|
||||||
|
|
||||||
|
|
||||||
|
def shlex_join(args):
|
||||||
|
if hasattr(shlex, "join"):
|
||||||
|
return shlex.join(args)
|
||||||
|
return " ".join([shlex.quote(arg) for arg in args])
|
||||||
|
|
||||||
|
|
||||||
|
def generate_depfile_for(fpath):
|
||||||
|
file_stem = fpath.parent / fpath.stem
|
||||||
|
depfile_content = ""
|
||||||
|
with open(f"{file_stem}.d", "r") as depfile:
|
||||||
|
for line in depfile.readlines():
|
||||||
|
if line.startswith(str(file_stem)):
|
||||||
|
# We can't blindly split on `:` because on Windows that's part
|
||||||
|
# of the drive letter. Lucky for us, the format of the dep file
|
||||||
|
# is one of:
|
||||||
|
#
|
||||||
|
# /path/to/output: /path/to/src1 /path/to/src2
|
||||||
|
# /path/to/output:
|
||||||
|
#
|
||||||
|
# So we parse these two cases specifically
|
||||||
|
if line.endswith(":"):
|
||||||
|
output = line[:-1]
|
||||||
|
srcs = ""
|
||||||
|
else:
|
||||||
|
output, srcs = line.split(": ", maxsplit=2)
|
||||||
|
|
||||||
|
all_deps = []
|
||||||
|
for src in srcs.split(" "):
|
||||||
|
all_deps.append(src)
|
||||||
|
src = P(src)
|
||||||
|
if src.name == "lib.rs":
|
||||||
|
# `rustc` doesn't take `Cargo.toml` into account
|
||||||
|
# but we need to
|
||||||
|
cargo_toml = src.parent.parent / "Cargo.toml"
|
||||||
|
if cargo_toml.exists():
|
||||||
|
all_deps.append(str(cargo_toml))
|
||||||
|
|
||||||
|
depfile_content += f"{output}: {' '.join(all_deps)}\n"
|
||||||
|
|
||||||
|
return depfile_content
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
opts = PARSER.parse_args()
|
||||||
|
logdir = opts.root_dir / "meson-logs"
|
||||||
|
logfile_path = logdir / f"{opts.src_dir.name}-cargo-wrapper.log"
|
||||||
|
logfile = open(logfile_path, mode="w", buffering=1)
|
||||||
|
|
||||||
|
print(opts, file=logfile)
|
||||||
|
cargo_target_dir = opts.build_dir / "target"
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
if "PKG_CONFIG_PATH" in env:
|
||||||
|
pkg_config_path = env["PKG_CONFIG_PATH"].split(os.pathsep)
|
||||||
|
else:
|
||||||
|
pkg_config_path = []
|
||||||
|
pkg_config_path.append(str(opts.root_dir / "meson-uninstalled"))
|
||||||
|
env["PKG_CONFIG_PATH"] = os.pathsep.join(pkg_config_path)
|
||||||
|
|
||||||
|
if "NASM" in env:
|
||||||
|
env["PATH"] = os.pathsep.join([os.path.dirname(env["NASM"]), env["PATH"]])
|
||||||
|
|
||||||
|
rustc_target = None
|
||||||
|
if "RUSTC" in env:
|
||||||
|
rustc_cmdline = shlex.split(env["RUSTC"])
|
||||||
|
# grab target from RUSTFLAGS
|
||||||
|
rust_flags = rustc_cmdline[1:] + shlex.split(env.get("RUSTFLAGS", ""))
|
||||||
|
if "--target" in rust_flags:
|
||||||
|
rustc_target_idx = rust_flags.index("--target")
|
||||||
|
_ = rust_flags.pop(rustc_target_idx) # drop '--target'
|
||||||
|
rustc_target = rust_flags.pop(rustc_target_idx)
|
||||||
|
env["RUSTFLAGS"] = shlex_join(rust_flags)
|
||||||
|
env["RUSTC"] = rustc_cmdline[0]
|
||||||
|
|
||||||
|
features = opts.features
|
||||||
|
if opts.command == "build":
|
||||||
|
cargo_cmd = ["cargo"]
|
||||||
|
if opts.bin or opts.examples:
|
||||||
|
cargo_cmd += ["build"]
|
||||||
|
else:
|
||||||
|
cargo_cmd += ["cbuild"]
|
||||||
|
if not opts.disable_doc:
|
||||||
|
features += ["doc"]
|
||||||
|
if opts.target == "release":
|
||||||
|
cargo_cmd.append("--release")
|
||||||
|
elif opts.command == "test":
|
||||||
|
# cargo test
|
||||||
|
cargo_cmd = ["cargo", "ctest", "--no-fail-fast", "--color=always"]
|
||||||
|
else:
|
||||||
|
print("Unknown command:", opts.command, file=logfile)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if rustc_target:
|
||||||
|
cargo_cmd += ["--target", rustc_target]
|
||||||
|
if features:
|
||||||
|
cargo_cmd += ["--features", ",".join(features)]
|
||||||
|
cargo_cmd += ["--target-dir", cargo_target_dir]
|
||||||
|
cargo_cmd += ["--manifest-path", opts.src_dir / "Cargo.toml"]
|
||||||
|
if opts.bin:
|
||||||
|
cargo_cmd.extend(["--bin", opts.bin.name])
|
||||||
|
else:
|
||||||
|
if not opts.examples:
|
||||||
|
cargo_cmd.extend(
|
||||||
|
["--prefix", opts.prefix, "--libdir", opts.prefix / opts.libdir]
|
||||||
|
)
|
||||||
|
for p in opts.packages:
|
||||||
|
cargo_cmd.extend(["-p", p])
|
||||||
|
for e in opts.examples:
|
||||||
|
cargo_cmd.extend(["--example", e])
|
||||||
|
|
||||||
|
def run(cargo_cmd, env):
|
||||||
|
print(cargo_cmd, env, file=logfile)
|
||||||
|
try:
|
||||||
|
subprocess.run(cargo_cmd, env=env, cwd=opts.src_dir, check=True)
|
||||||
|
except subprocess.SubprocessError:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
run(cargo_cmd, env)
|
||||||
|
|
||||||
|
if opts.command == "build":
|
||||||
|
target_dir = cargo_target_dir / "**" / opts.target
|
||||||
|
if opts.bin:
|
||||||
|
exe = glob.glob(
|
||||||
|
str(target_dir / opts.bin) + opts.exe_suffix, recursive=True
|
||||||
|
)[0]
|
||||||
|
shutil.copy2(exe, opts.build_dir)
|
||||||
|
depfile_content = generate_depfile_for(P(exe))
|
||||||
|
else:
|
||||||
|
# Copy so files to build dir
|
||||||
|
depfile_content = ""
|
||||||
|
for suffix in opts.lib_suffixes:
|
||||||
|
for f in glob.glob(str(target_dir / f"*.{suffix}"), recursive=True):
|
||||||
|
libfile = P(f)
|
||||||
|
|
||||||
|
depfile_content += generate_depfile_for(libfile)
|
||||||
|
|
||||||
|
copied_file = opts.build_dir / libfile.name
|
||||||
|
try:
|
||||||
|
if copied_file.stat().st_mtime == libfile.stat().st_mtime:
|
||||||
|
print(f"{copied_file} has not changed.", file=logfile)
|
||||||
|
continue
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"Copying {copied_file}", file=logfile)
|
||||||
|
shutil.copy2(f, opts.build_dir)
|
||||||
|
# Copy examples to builddir
|
||||||
|
for example in opts.examples:
|
||||||
|
example_glob = str(target_dir / "examples" / example) + opts.exe_suffix
|
||||||
|
exe = glob.glob(example_glob, recursive=True)[0]
|
||||||
|
shutil.copy2(exe, opts.build_dir)
|
||||||
|
depfile_content += generate_depfile_for(P(exe))
|
||||||
|
|
||||||
|
with open(opts.depfile, "w") as depfile:
|
||||||
|
depfile.write(depfile_content)
|
||||||
|
|
||||||
|
# Copy generated pkg-config files
|
||||||
|
for f in glob.glob(str(target_dir / "*.pc"), recursive=True):
|
||||||
|
shutil.copy(f, opts.build_dir)
|
||||||
|
|
||||||
|
# Move -uninstalled.pc to meson-uninstalled
|
||||||
|
uninstalled = opts.build_dir / "meson-uninstalled"
|
||||||
|
os.makedirs(uninstalled, exist_ok=True)
|
||||||
|
|
||||||
|
for f in opts.build_dir.glob("*-uninstalled.pc"):
|
||||||
|
# move() does not allow us to update the file so remove it if it already exists
|
||||||
|
dest = uninstalled / P(f).name
|
||||||
|
if dest.exists():
|
||||||
|
dest.unlink()
|
||||||
|
# move() takes paths from Python3.9 on
|
||||||
|
if (sys.version_info.major >= 3) and (sys.version_info.minor >= 9):
|
||||||
|
shutil.move(f, uninstalled)
|
||||||
|
else:
|
||||||
|
shutil.move(str(f), str(uninstalled))
|
58
subprojects/gst-devtools/dots-viewer/meson.build
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
if not add_languages('rust', required: get_option('dots_viewer'))
|
||||||
|
subdir_done()
|
||||||
|
endif
|
||||||
|
|
||||||
|
rustc = meson.get_compiler('rust')
|
||||||
|
|
||||||
|
cargo = find_program('cargo', version:'>=1.40', required: get_option('dots_viewer'))
|
||||||
|
if not cargo.found()
|
||||||
|
subdir_done()
|
||||||
|
endif
|
||||||
|
|
||||||
|
cargo_wrapper = find_program('cargo_wrapper.py')
|
||||||
|
extra_env = {'RUSTC': ' '.join(rustc.cmd_array())}
|
||||||
|
|
||||||
|
system = host_machine.system()
|
||||||
|
exe_suffix = ''
|
||||||
|
if system == 'windows'
|
||||||
|
exe_suffix = '.exe'
|
||||||
|
endif
|
||||||
|
|
||||||
|
if get_option('debug')
|
||||||
|
target = 'debug'
|
||||||
|
else
|
||||||
|
target = 'release'
|
||||||
|
endif
|
||||||
|
|
||||||
|
# Extra env to pass to cargo
|
||||||
|
if get_option('default_library') == 'static'
|
||||||
|
extra_env += {
|
||||||
|
# Tell the pkg-config crate to think of all libraries as static
|
||||||
|
'PKG_CONFIG_ALL_STATIC': '1',
|
||||||
|
# Tell the system-deps crate to process linker flag for static deps
|
||||||
|
'SYSTEM_DEPS_LINK': 'static'
|
||||||
|
}
|
||||||
|
endif
|
||||||
|
|
||||||
|
foreach binname: ['gst-dots-viewer', 'gstdump']
|
||||||
|
custom_target(binname,
|
||||||
|
build_by_default: true,
|
||||||
|
output: binname + exe_suffix,
|
||||||
|
console: true,
|
||||||
|
install: true,
|
||||||
|
install_dir: get_option('bindir'),
|
||||||
|
depfile: binname + '.dep',
|
||||||
|
env: extra_env,
|
||||||
|
command: [cargo_wrapper,
|
||||||
|
'build',
|
||||||
|
meson.current_build_dir(),
|
||||||
|
meson.current_source_dir(),
|
||||||
|
meson.global_build_root(),
|
||||||
|
target,
|
||||||
|
get_option('prefix'),
|
||||||
|
get_option('libdir'),
|
||||||
|
'--depfile', '@DEPFILE@',
|
||||||
|
'--bin', binname,
|
||||||
|
'--exe-suffix', exe_suffix,
|
||||||
|
])
|
||||||
|
endforeach
|
32
subprojects/gst-devtools/dots-viewer/package-lock.json
generated
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "gst-dots",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "gst-dots",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@viz-js/viz": "^3.4.0",
|
||||||
|
"fuse.js": "^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@viz-js/viz": {
|
||||||
|
"version": "3.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@viz-js/viz/-/viz-3.11.0.tgz",
|
||||||
|
"integrity": "sha512-3zoKLQUqShIhTPvBAIIgJUf5wO9aY0q+Ftzw1u26KkJX1OJjT7Z5VUqgML2GIzXJYFgjqS6a2VREMwrgChuubA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fuse.js": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
15
subprojects/gst-devtools/dots-viewer/package.json
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "gst-dots",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "GStreamer dot files viewer",
|
||||||
|
"main": "static/js/gstdots.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "Thibault Saunier <tsaunier@igalia.com>",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@viz-js/viz": "^3.4.0",
|
||||||
|
"fuse.js": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
81
subprojects/gst-devtools/dots-viewer/src/gstdump.rs
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
use dirs::cache_dir;
|
||||||
|
use glob::glob;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Determine the directory to use for dumping GStreamer pipelines
|
||||||
|
let gstdot_path = env::var("GST_DEBUG_DUMP_DOT_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
let mut path = cache_dir().expect("Failed to find cache directory");
|
||||||
|
path.push("gstreamer-dots");
|
||||||
|
path
|
||||||
|
});
|
||||||
|
|
||||||
|
let args: Vec<String> = env::args().skip(1).collect();
|
||||||
|
let delete = args.first().map_or(true, |arg| {
|
||||||
|
if ["--help", "-h"].contains(&arg.as_str()) {
|
||||||
|
eprintln!("Usage: gstdump [-n | --no-delete] [command]");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
!["-n", "--no-delete"].contains(&arg.as_str())
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure the directory exists
|
||||||
|
fs::create_dir_all(&gstdot_path).expect("Failed to create dot directory");
|
||||||
|
|
||||||
|
println!("Dumping GStreamer pipelines into {:?}", gstdot_path);
|
||||||
|
let command_idx = if delete {
|
||||||
|
// Build the glob pattern and remove existing .dot files
|
||||||
|
let pattern = gstdot_path.join("**/*.dot").to_string_lossy().into_owned();
|
||||||
|
println!("Removing existing .dot files matching {pattern}");
|
||||||
|
for entry in glob(&pattern).expect("Failed to read glob pattern") {
|
||||||
|
match entry {
|
||||||
|
Ok(path) => {
|
||||||
|
if path.is_file() {
|
||||||
|
fs::remove_file(path).expect("Failed to remove file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Error reading file: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set the environment variable to use the determined directory
|
||||||
|
env::set_var("GST_DEBUG_DUMP_DOT_DIR", &gstdot_path);
|
||||||
|
env::set_var(
|
||||||
|
"GST_TRACERS",
|
||||||
|
env::var("GST_TRACERS").map_or_else(
|
||||||
|
|_| "pipeline-snapshot(xdg-cache=true,folder-mode=numbered)".to_string(),
|
||||||
|
|tracers| {
|
||||||
|
if !tracers.contains("pipeline-snapshot") {
|
||||||
|
println!("pipeline-snapshot already enabled");
|
||||||
|
|
||||||
|
tracers
|
||||||
|
} else {
|
||||||
|
format!("{tracers},pipeline-snapshot(xdg-cache=true,folder-mode=numbered)")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run the command provided in arguments
|
||||||
|
eprintln!("Running {:?}", &args[command_idx..]);
|
||||||
|
if args.len() >= command_idx {
|
||||||
|
let output = Command::new(&args[command_idx])
|
||||||
|
.args(&args[command_idx + 1..])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(_status) => (),
|
||||||
|
Err(e) => eprintln!("Error: {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
466
subprojects/gst-devtools/dots-viewer/src/main.rs
Normal file
|
@ -0,0 +1,466 @@
|
||||||
|
use actix::Addr;
|
||||||
|
use actix::AsyncContext;
|
||||||
|
use actix::Message;
|
||||||
|
use actix::{Actor, Handler, StreamHandler};
|
||||||
|
use actix_web::{web, App, Error, HttpRequest, HttpResponse, HttpServer};
|
||||||
|
use actix_web_actors::ws;
|
||||||
|
use actix_web_static_files::ResourceFiles;
|
||||||
|
use anyhow::Context;
|
||||||
|
use clap::{ArgAction, Parser};
|
||||||
|
use notify::Watcher;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use serde_json::json;
|
||||||
|
use single_instance::SingleInstance;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
use tokio::runtime;
|
||||||
|
use tracing::debug;
|
||||||
|
use tracing::error;
|
||||||
|
use tracing::info;
|
||||||
|
use tracing::instrument;
|
||||||
|
use tracing::{event, Level};
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
|
||||||
|
|
||||||
|
pub static RUNTIME: Lazy<runtime::Runtime> = Lazy::new(|| {
|
||||||
|
runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.worker_threads(1)
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Simple web server that watches a directory for GStreamer `*.dot` files in a local path and
|
||||||
|
/// serves them as a web page allowing you to browse them easily.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
/// Server address
|
||||||
|
#[arg(short, long, default_value = "0.0.0.0", action = ArgAction::Set)]
|
||||||
|
address: String,
|
||||||
|
|
||||||
|
/// Server port
|
||||||
|
#[arg(short, long, default_value_t = 3000, action = ArgAction::Set)]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// Folder to monitor for new .dot files
|
||||||
|
#[arg(short, long, action = ArgAction::Set)]
|
||||||
|
dotdir: Option<String>,
|
||||||
|
|
||||||
|
/// local .dot file to open, can be used to view a specific `.dot` file
|
||||||
|
#[arg()]
|
||||||
|
dot_file: Option<String>,
|
||||||
|
|
||||||
|
/// Opens the served page in the default web browser
|
||||||
|
#[arg(short, long)]
|
||||||
|
open: bool,
|
||||||
|
|
||||||
|
#[arg(short, long)]
|
||||||
|
verbose: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GstDots {
|
||||||
|
gstdot_path: std::path::PathBuf,
|
||||||
|
clients: Arc<Mutex<Vec<Addr<WebSocket>>>>,
|
||||||
|
dot_watcher: Mutex<Option<notify::RecommendedWatcher>>,
|
||||||
|
args: Args,
|
||||||
|
id: String,
|
||||||
|
http_address: String,
|
||||||
|
instance: SingleInstance,
|
||||||
|
exit_on_socket_close: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for GstDots {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("GstDots")
|
||||||
|
.field("gstdot_path", &self.gstdot_path)
|
||||||
|
.field("clients", &self.clients)
|
||||||
|
.field("dot_watcher", &self.dot_watcher)
|
||||||
|
.field("args", &self.args)
|
||||||
|
.field("id", &self.id)
|
||||||
|
.field("http_address", &self.http_address)
|
||||||
|
.field("exit_on_socket_close", &self.exit_on_socket_close)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GstDots {
|
||||||
|
fn new(args: Args) -> Arc<Self> {
|
||||||
|
let gstdot_path = args
|
||||||
|
.dotdir
|
||||||
|
.as_ref()
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let mut path = dirs::cache_dir().expect("Failed to find cache directory");
|
||||||
|
path.push("gstreamer-dots");
|
||||||
|
path
|
||||||
|
});
|
||||||
|
std::fs::create_dir_all(&gstdot_path).expect("Failed to create dot directory");
|
||||||
|
|
||||||
|
let exit_on_socket_close = args.dot_file.as_ref().is_some() || args.open;
|
||||||
|
|
||||||
|
let id = format!("gstdots-{}-{}", args.address, args.port);
|
||||||
|
let instance = SingleInstance::new(&id).unwrap();
|
||||||
|
info!("Instance {id} is single: {}", instance.is_single());
|
||||||
|
let app = Arc::new(Self {
|
||||||
|
gstdot_path: gstdot_path.clone(),
|
||||||
|
id,
|
||||||
|
http_address: format!("http://{}:{}", args.address, args.port),
|
||||||
|
args,
|
||||||
|
clients: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
dot_watcher: Default::default(),
|
||||||
|
exit_on_socket_close,
|
||||||
|
instance,
|
||||||
|
});
|
||||||
|
app.watch_dot_files();
|
||||||
|
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_dot_path(&self, dot_path: &Path) -> String {
|
||||||
|
dot_path
|
||||||
|
.strip_prefix(&self.gstdot_path)
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dot_path_for_file(&self, path: &std::path::Path) -> std::path::PathBuf {
|
||||||
|
let file_name = path.file_name().unwrap();
|
||||||
|
|
||||||
|
self.gstdot_path.join(file_name).with_extension("dot")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn modify_time(&self, path: &std::path::Path) -> u128 {
|
||||||
|
self.dot_path_for_file(path)
|
||||||
|
.metadata()
|
||||||
|
.map(|m| m.modified().unwrap_or(std::time::UNIX_EPOCH))
|
||||||
|
.unwrap_or(std::time::UNIX_EPOCH)
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_dot_files(path: &PathBuf, entries: &mut Vec<(PathBuf, SystemTime)>) {
|
||||||
|
if let Ok(read_dir) = std::fs::read_dir(path) {
|
||||||
|
for entry in read_dir.flatten() {
|
||||||
|
let dot_path = entry.path();
|
||||||
|
if dot_path.is_dir() {
|
||||||
|
// Recursively call this function if the path is a directory
|
||||||
|
Self::collect_dot_files(&dot_path, entries);
|
||||||
|
} else {
|
||||||
|
// Process only `.dot` files
|
||||||
|
if dot_path.extension().and_then(|e| e.to_str()) == Some("dot") {
|
||||||
|
if let Ok(metadata) = dot_path.metadata() {
|
||||||
|
if let Ok(modified) = metadata.modified() {
|
||||||
|
entries.push((dot_path, modified));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_dots(&self, client: Addr<WebSocket>) {
|
||||||
|
event!(Level::DEBUG, "Listing dot files in {:?}", self.gstdot_path);
|
||||||
|
let mut entries: Vec<(PathBuf, SystemTime)> = Vec::new();
|
||||||
|
|
||||||
|
let start_path = PathBuf::from(&self.gstdot_path);
|
||||||
|
Self::collect_dot_files(&start_path, &mut entries);
|
||||||
|
|
||||||
|
entries.sort_by(|a, b| a.1.cmp(&b.1));
|
||||||
|
|
||||||
|
for (dot_path, _) in entries {
|
||||||
|
let content = match std::fs::read_to_string(&dot_path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
event!(Level::ERROR, "===>Error reading file: {dot_path:?}: {e:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if content.is_empty() {
|
||||||
|
event!(Level::ERROR, "===>Empty file: {:?}", dot_path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = self.relative_dot_path(&dot_path);
|
||||||
|
debug!("Sending `{name}` to client: {client:?}");
|
||||||
|
client.do_send(TextMessage(
|
||||||
|
json!({
|
||||||
|
"type": "NewDot",
|
||||||
|
"name": name,
|
||||||
|
"content": content,
|
||||||
|
"creation_time": self.modify_time(&dot_path),
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn watch_dot_files(self: &Arc<Self>) {
|
||||||
|
let app_clone = self.clone();
|
||||||
|
let mut dot_watcher =
|
||||||
|
notify::recommended_watcher(move |event: Result<notify::Event, notify::Error>| {
|
||||||
|
match event {
|
||||||
|
Ok(event) => {
|
||||||
|
let wanted = event.paths .iter().any(|p| p.extension().map(|e| e == "dot").unwrap_or(false));
|
||||||
|
if wanted
|
||||||
|
{
|
||||||
|
match event.kind {
|
||||||
|
notify::event::EventKind::Modify(notify::event::ModifyKind::Name(_)) => {
|
||||||
|
for path in event.paths.iter() {
|
||||||
|
debug!("File created: {:?}", path);
|
||||||
|
if path.extension().map(|e| e == "dot").unwrap_or(false) {
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
let clients = app_clone.clients.lock().unwrap();
|
||||||
|
let clients = clients.clone();
|
||||||
|
|
||||||
|
for client in clients.iter() {
|
||||||
|
let name = app_clone.relative_dot_path(&path);
|
||||||
|
event!(Level::DEBUG, "Sending {name} to client: {client:?}");
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(content) => client.do_send(TextMessage(
|
||||||
|
json!({
|
||||||
|
"type": "NewDot",
|
||||||
|
"name": name,
|
||||||
|
"content": content,
|
||||||
|
"creation_time": app_clone.modify_time(&event.paths[0]),
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)),
|
||||||
|
Err(err) => error!("Could not read file {path:?}: {err:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notify::event::EventKind::Remove(_) => {
|
||||||
|
debug!("File removed: {:?}", event.paths);
|
||||||
|
for path in event.paths.iter() {
|
||||||
|
debug!("File removed: {:?}", path);
|
||||||
|
if path.extension().map(|e| e == "dot").unwrap_or(false) {
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
let clients = app_clone.clients.lock().unwrap();
|
||||||
|
let clients = clients.clone();
|
||||||
|
|
||||||
|
for client in clients.iter() {
|
||||||
|
debug!("Sending to client: {:?}", client);
|
||||||
|
client.do_send(TextMessage(
|
||||||
|
json!({
|
||||||
|
"type": "DotRemoved",
|
||||||
|
"name": path.file_name().unwrap().to_str().unwrap(),
|
||||||
|
"creation_time": app_clone.modify_time(&event.paths[0]),
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => event!(Level::ERROR, "watch error: {:?}", err),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("Could not create dot_watcher");
|
||||||
|
|
||||||
|
info!("Watching dot files in {:?}", self.gstdot_path);
|
||||||
|
dot_watcher
|
||||||
|
.watch(self.gstdot_path.as_path(), notify::RecursiveMode::Recursive)
|
||||||
|
.unwrap();
|
||||||
|
*self.dot_watcher.lock().unwrap() = Some(dot_watcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "trace")]
|
||||||
|
fn add_client(&self, client: Addr<WebSocket>) {
|
||||||
|
let mut clients = self.clients.lock().unwrap();
|
||||||
|
|
||||||
|
info!("Client added: {:?}", client);
|
||||||
|
clients.push(client.clone());
|
||||||
|
drop(clients);
|
||||||
|
|
||||||
|
self.list_dots(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "trace")]
|
||||||
|
fn remove_client(&self, addr: &Addr<WebSocket>) {
|
||||||
|
info!("Client removed: {:?}", addr);
|
||||||
|
let mut clients = self.clients.lock().unwrap();
|
||||||
|
clients.retain(|a| a != addr);
|
||||||
|
|
||||||
|
if self.exit_on_socket_close && clients.is_empty() {
|
||||||
|
info!("No more clients, exiting");
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(&self) -> anyhow::Result<bool> {
|
||||||
|
if self.args.dot_file.is_some() || self.args.open {
|
||||||
|
let gstdot_path = self
|
||||||
|
.args
|
||||||
|
.dotdir
|
||||||
|
.as_ref()
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let mut path = dirs::cache_dir().expect("Failed to find cache directory");
|
||||||
|
path.push("gstreamer-dots");
|
||||||
|
path
|
||||||
|
});
|
||||||
|
|
||||||
|
let dot_address = if let Some(dot_file) = self.args.dot_file.as_ref() {
|
||||||
|
let dot_path = PathBuf::from(&dot_file);
|
||||||
|
let dot_name = dot_path.file_name().unwrap();
|
||||||
|
let gstdot_path = gstdot_path.join(dot_name);
|
||||||
|
info!("Copying {dot_path:?} to {gstdot_path:?}");
|
||||||
|
std::fs::copy(&dot_path, gstdot_path).expect("Failed to copy .dot file");
|
||||||
|
format!(
|
||||||
|
"{}?pipeline={}",
|
||||||
|
self.http_address,
|
||||||
|
dot_name.to_str().unwrap()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
self.http_address.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Openning {dot_address}");
|
||||||
|
opener::open_browser(dot_address)?;
|
||||||
|
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An instance already running but not asked to open anything, let starting the
|
||||||
|
// new instance fail
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_on_running_instance(&self) -> anyhow::Result<bool> {
|
||||||
|
if !self.instance.is_single() {
|
||||||
|
info!("Server already running, trying to open dot file");
|
||||||
|
self.open()
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(self: &Arc<Self>) -> anyhow::Result<()> {
|
||||||
|
// Check if another instance is already running
|
||||||
|
// If so and user specified a dot file, open it in the running single
|
||||||
|
// and exit
|
||||||
|
if self.open_on_running_instance()? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_data = web::Data::new(self.clone());
|
||||||
|
let address = format!("{}:{}", self.args.address, self.args.port);
|
||||||
|
info!("Starting server on http://{}", address);
|
||||||
|
|
||||||
|
if self.args.dot_file.is_some() || self.args.open {
|
||||||
|
let self_clone = self.clone();
|
||||||
|
RUNTIME.spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
|
||||||
|
match self_clone.open() {
|
||||||
|
Ok(true) => break,
|
||||||
|
Err(err) => {
|
||||||
|
error!("Error opening dot file: {:?}", err);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
let generated = generate();
|
||||||
|
App::new()
|
||||||
|
.app_data(app_data.clone())
|
||||||
|
.route("/ws/", web::get().to(ws_index))
|
||||||
|
})
|
||||||
|
.bind(&address)
|
||||||
|
.context("Couldn't bind adresss")?
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
.context("Couldn't run server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct WebSocket {
|
||||||
|
app: Arc<GstDots>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Message)]
|
||||||
|
#[rtype(result = "()")] // Indicates that no response is expected
|
||||||
|
pub struct TextMessage(pub String);
|
||||||
|
|
||||||
|
impl Actor for WebSocket {
|
||||||
|
type Context = ws::WebsocketContext<Self>;
|
||||||
|
|
||||||
|
fn started(&mut self, ctx: &mut Self::Context) {
|
||||||
|
self.app.add_client(ctx.address());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stopping(&mut self, ctx: &mut Self::Context) -> actix::Running {
|
||||||
|
self.app.remove_client(&ctx.address());
|
||||||
|
actix::Running::Stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handler<TextMessage> for WebSocket {
|
||||||
|
type Result = ();
|
||||||
|
|
||||||
|
fn handle(&mut self, msg: TextMessage, ctx: &mut Self::Context) {
|
||||||
|
// Send the text message to the WebSocket client
|
||||||
|
ctx.text(msg.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for WebSocket {
|
||||||
|
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, _ctx: &mut Self::Context) {
|
||||||
|
if let Ok(ws::Message::Text(text)) = msg {
|
||||||
|
debug!("Message received: {:?}", text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ws_index(
|
||||||
|
req: HttpRequest,
|
||||||
|
stream: web::Payload,
|
||||||
|
data: web::Data<Arc<GstDots>>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let app = data.get_ref().clone();
|
||||||
|
|
||||||
|
ws::start(WebSocket { app }, &req, stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.compact()
|
||||||
|
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE)
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::filter::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
|
tracing_subscriber::filter::EnvFilter::new(format!(
|
||||||
|
"warn{}",
|
||||||
|
if args.verbose {
|
||||||
|
",gst_dots_viewer=trace"
|
||||||
|
} else {
|
||||||
|
",gst_dots_viewer=info"
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let gstdots = GstDots::new(args);
|
||||||
|
gstdots.run().await
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2015 Mountainstorm
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* this element needs tooltip positioning to work */
|
||||||
|
.graphviz-svg {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stop tooltips wrapping */
|
||||||
|
.graphviz-svg .tooltip-inner {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stop people selecting text on nodes */
|
||||||
|
.graphviz-svg text {
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-khtml-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 6.7 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 7 KiB |
After Width: | Height: | Size: 8 KiB |
After Width: | Height: | Size: 8.8 KiB |
After Width: | Height: | Size: 9.7 KiB |
BIN
subprojects/gst-devtools/dots-viewer/static/images/favicon.png
Normal file
After Width: | Height: | Size: 231 B |
After Width: | Height: | Size: 130 KiB |
After Width: | Height: | Size: 419 KiB |
157
subprojects/gst-devtools/dots-viewer/static/index.html
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>GstDots</title>
|
||||||
|
<link rel="icon" type="image/png" href="/images/favicon-16.png" sizes="16x16">
|
||||||
|
<link rel="icon" type="image/png" href="/images/favicon-32.png" sizes="32x32">
|
||||||
|
<link rel="icon" type="image/png" href="/images/favicon-57.png" sizes="57x57">
|
||||||
|
<link rel="icon" type="image/png" href="/images/favicon-64.png" sizes="64x64">
|
||||||
|
<link rel="icon" type="image/png" href="/images/favicon-76.png" sizes="76x76">
|
||||||
|
<link rel="icon" type="image/png" href="/images/favicon-96.png" sizes="96x96">
|
||||||
|
<link rel="icon" type="image/png" href="/images/favicon-128.png" sizes="128x128">
|
||||||
|
<link rel="icon" type="image/png" href="/images/favicon-192.png" sizes="192x192">
|
||||||
|
<link rel="icon" type="image/png" href="/images/favicon-228.png" sizes="228x228">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Lato', sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions {
|
||||||
|
color: #fcfcfc;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
bottom: 0px;
|
||||||
|
left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
width: -webkit-fill-available;
|
||||||
|
width: -moz-available;
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.internalframe {
|
||||||
|
flex-grow: 1;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: rgb(0,0,0);
|
||||||
|
background-color: rgba(0,0,0, 0.9);
|
||||||
|
overflow-x: hidden;
|
||||||
|
transition: 0.5s;
|
||||||
|
visibility: "visible";
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-content {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay a {
|
||||||
|
padding: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 36px;
|
||||||
|
color: #818181;
|
||||||
|
display: block;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay a:hover, .overlay a:focus {
|
||||||
|
color: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay .closebtn {
|
||||||
|
top: 20px;
|
||||||
|
right: 45px;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-height: 250000px) {
|
||||||
|
.overlay a {font-size: 20px}
|
||||||
|
.overlay .closebtn {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 20px;
|
||||||
|
top: 15px;
|
||||||
|
right: 35px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] { display: none; }
|
||||||
|
.wrap-collabsible { margin: 1.2rem 0; }
|
||||||
|
.lbl-toggle { display: block; font-weight: bold; font-family: monospace; font-size: 1.2rem; text-transform: uppercase; text-align: center; padding: 1rem; color: #DDD; background: #0069ff; cursor: pointer; border-radius: 7px; transition: all 0.25s ease-out; }
|
||||||
|
.lbl-toggle:hover { color: #FFF; }
|
||||||
|
.lbl-toggle::before { content: ' '; display: inline-block; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 5px solid currentColor; vertical-align: middle; margin-right: .7rem; transform: translateY(-2px); transition: transform .2s ease-out; }
|
||||||
|
.toggle:checked+.lbl-toggle::before { transform: rotate(90deg) translateX(-3px); }
|
||||||
|
.collapsible-content { max-height: 0px; overflow: hidden; transition: max-height .25s ease-in-out; }
|
||||||
|
.toggle:checked + .lbl-toggle + .collapsible-content { max-height: 999999999px; }
|
||||||
|
.toggle:checked+.lbl-toggle { border-bottom-right-radius: 0; border-bottom-left-radius: 0; }
|
||||||
|
.collapsible-content .content-inner { background: rgba(0, 105, 255, .2); border-bottom: 1px solid rgba(0, 105, 255, .45); border-bottom-left-radius: 7px; border-bottom-right-radius: 7px; padding: .5rem 1rem; }
|
||||||
|
.collapsible-content p { margin-bottom: 0; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script src="/js/gstdots.js" type="module"> </script>
|
||||||
|
<script type="module">
|
||||||
|
import {updateFromUrl, connectWs, connectSearch, removePipelineOverlay} from '/js/gstdots.js';
|
||||||
|
|
||||||
|
window.addEventListener('popstate', function(event) {
|
||||||
|
updateFromUrl(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
connectWs();
|
||||||
|
connectSearch();
|
||||||
|
updateFromUrl(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keyup', function(e) {
|
||||||
|
if (e.key == "Escape") {
|
||||||
|
removePipelineOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === '/' && event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA') {
|
||||||
|
event.preventDefault(); // Prevent the default action to avoid typing '/' into an input or textarea
|
||||||
|
document.getElementById('search').focus(); // Focus the input element with ID 'search'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
for (let iframe of document.getElementsByClassName("internalframe")) {
|
||||||
|
iframe.addEventListener('keyup', function(e) {
|
||||||
|
if (e.key == "Escape") {
|
||||||
|
removePipelineOverlay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>GStreamer Pipeline graphs</h1>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input type="text", id="search", placeholder="Search for pipeline">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pipelines"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
339
subprojects/gst-devtools/dots-viewer/static/js/gstdots.js
Normal file
|
@ -0,0 +1,339 @@
|
||||||
|
import { instance } from "/js/viz-standalone.mjs";
|
||||||
|
import Fuse from '/js/fuse.min.mjs'
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
async function createOverlayElement(img, fname) {
|
||||||
|
let overlay = document.getElementById("overlay");
|
||||||
|
if (overlay || img.creating_svg) {
|
||||||
|
console.warn(`Overlay already exists`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayDiv = document.createElement('div');
|
||||||
|
overlayDiv.id = "overlay";
|
||||||
|
overlayDiv.className = 'overlay';
|
||||||
|
|
||||||
|
document.getElementById('pipelines').appendChild(overlayDiv);
|
||||||
|
await generateSvg(img);
|
||||||
|
|
||||||
|
const closeButton = document.createElement('a');
|
||||||
|
closeButton.href = 'javascript:void(0)';
|
||||||
|
closeButton.className = 'closebtn';
|
||||||
|
closeButton.innerHTML = '×';
|
||||||
|
closeButton.onclick = (event) => {
|
||||||
|
removePipelineOverlay();
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
overlayDiv.appendChild(closeButton);
|
||||||
|
|
||||||
|
const contentDiv = document.createElement('div');
|
||||||
|
contentDiv.className = 'overlay-content';
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.loading = 'lazy';
|
||||||
|
iframe.src = '/overlay.html?svg=' + img.src + '&title=' + fname;
|
||||||
|
iframe.className = 'internalframe';
|
||||||
|
contentDiv.appendChild(iframe);
|
||||||
|
|
||||||
|
overlayDiv.appendChild(contentDiv);
|
||||||
|
|
||||||
|
return overlayDiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dotId(dot_info) {
|
||||||
|
return `${dot_info.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSvg(img) {
|
||||||
|
if (img.src != "" || img.creating_svg) {
|
||||||
|
console.debug('Image already generated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.creating_svg = true;
|
||||||
|
try {
|
||||||
|
let viz = await instance();
|
||||||
|
const svg = viz.renderSVGElement(img.dot_info.content);
|
||||||
|
img.src = URL.createObjectURL(new Blob([svg.outerHTML], { type: 'image/svg+xml' }));
|
||||||
|
img.creating_svg = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Fail rendering SVG for '${img.dot_info.content}' failed: ${error}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewDotDiv(pipelines_div, dot_info) {
|
||||||
|
let path = dot_info.name;
|
||||||
|
let dirname = path.split('/');
|
||||||
|
let parent_div = pipelines_div;
|
||||||
|
if (dirname.length > 1) {
|
||||||
|
dirname = `/${dirname.slice(0, -1).join('/')}/`;
|
||||||
|
} else {
|
||||||
|
dirname = "/";
|
||||||
|
}
|
||||||
|
let div_id = `content-${dirname}`;
|
||||||
|
parent_div = document.getElementById(div_id);
|
||||||
|
|
||||||
|
if (!parent_div) {
|
||||||
|
parent_div = document.createElement("div");
|
||||||
|
parent_div.id = `dir-${dirname}`;
|
||||||
|
parent_div.className = "wrap-collabsible";
|
||||||
|
parent_div.innerHTML = `<input id="collapsible-${dirname}" class="toggle" type="checkbox">
|
||||||
|
<label for="collapsible-${dirname}" class="lbl-toggle">${dirname}</label>
|
||||||
|
<div class="collapsible-content" id="content-${dirname}">
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (pipelines_div.firstChild) {
|
||||||
|
pipelines_div.insertBefore(parent_div, pipelines_div.firstChild);
|
||||||
|
} else {
|
||||||
|
pipelines_div.appendChild(parent_div);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent_div = document.getElementById(`content-${dirname}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let div = document.createElement("div");
|
||||||
|
div.id = dotId(dot_info);
|
||||||
|
div.className = "content-inner pipelineDiv";
|
||||||
|
div.setAttribute("data_score", "0");
|
||||||
|
div.setAttribute("creation_time", dot_info.creation_time);
|
||||||
|
|
||||||
|
let title = document.createElement("h2");
|
||||||
|
title.textContent = dot_info.name.replace(".dot", "");
|
||||||
|
|
||||||
|
let img = document.createElement("img");
|
||||||
|
img.alt = "image";
|
||||||
|
img.className = "preview";
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.dot_info = dot_info;
|
||||||
|
const observer = new IntersectionObserver((entries, observer) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
console.debug(`Image ${div.id} is visible`);
|
||||||
|
generateSvg(img);
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
rootMargin: '100px'
|
||||||
|
});
|
||||||
|
observer.observe(img);
|
||||||
|
|
||||||
|
div.appendChild(title);
|
||||||
|
div.appendChild(img);
|
||||||
|
|
||||||
|
div.onclick = function () {
|
||||||
|
createOverlayElement(img, title.textContent).then(_ => {
|
||||||
|
setUrlVariable('pipeline', div.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (parent_div.firstChild) {
|
||||||
|
parent_div.insertBefore(div, parent_div.firstChild);
|
||||||
|
} else {
|
||||||
|
parent_div.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSearch();
|
||||||
|
updateFromUrl(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsetUrlVariable(key) {
|
||||||
|
let url = new URL(window.location.href);
|
||||||
|
let searchParams = new URLSearchParams(url.search);
|
||||||
|
searchParams.delete(key);
|
||||||
|
url.search = searchParams.toString();
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUrlVariable(key, value) {
|
||||||
|
let url = new URL(window.location.href);
|
||||||
|
let searchParams = new URLSearchParams(url.search);
|
||||||
|
searchParams.set(key, value);
|
||||||
|
url.search = searchParams.toString();
|
||||||
|
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateFromUrl(noHistoryUpdate) {
|
||||||
|
if (window.location.search) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const pipeline = url.searchParams.get('pipeline');
|
||||||
|
if (pipeline) {
|
||||||
|
console.log(`Creating overlay for ${pipeline}`);
|
||||||
|
let div = document.getElementById(pipeline);
|
||||||
|
if (!div) {
|
||||||
|
console.info(`Pipeline ${pipeline} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let img = div.querySelector('img');
|
||||||
|
let title = div.querySelector('h2');
|
||||||
|
createOverlayElement(img, title.textContent).then(_ => {
|
||||||
|
console.debug(`Overlay created for ${pipeline}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
removePipelineOverlay(noHistoryUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectWs() {
|
||||||
|
console.assert(ws === null, "Websocket already exists");
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const port = window.location.port;
|
||||||
|
const wsUrl = `${protocol}//${host}:${port}/ws/`;
|
||||||
|
let pipelines_div = document.getElementById("pipelines");
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
console.info(`Websocklet ${ws} created with ${wsUrl}`);
|
||||||
|
ws.onopen = () => {
|
||||||
|
let pipelines_div = document.getElementById("pipelines");
|
||||||
|
|
||||||
|
console.log(`WebSocket connected, removing all children from ${pipelines_div}`);
|
||||||
|
while (pipelines_div.firstChild) {
|
||||||
|
console.debug(`Removing ${pipelines_div.firstChild}`);
|
||||||
|
pipelines_div.removeChild(pipelines_div.firstChild);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = function (event) {
|
||||||
|
console.debug(`Received message: ${event.data}`)
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(event.data);
|
||||||
|
if (obj.type == "NewDot") {
|
||||||
|
if (document.getElementById(dotId(obj))) {
|
||||||
|
console.warn(`Pipeline ${obj.name} already exists`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createNewDotDiv(pipelines_div, obj, ws).then(() => {
|
||||||
|
updateSearch();
|
||||||
|
});
|
||||||
|
} else if (obj.type == "DotRemoved") {
|
||||||
|
let dot_id = dotId(obj);
|
||||||
|
let dot_div = document.getElementById(dot_id);
|
||||||
|
if (dot_div) {
|
||||||
|
console.info(`Removing dot_div ${dot_id}`);
|
||||||
|
dot_div.remove();
|
||||||
|
} else {
|
||||||
|
console.error(`dot_div ${dot_id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSearch();
|
||||||
|
} else {
|
||||||
|
console.warn(`Unknown message type: ${obj.type}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error: ${e}`);
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
console.log('WebSocket disconnected', event.reason);
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
setTimeout(connectWs, 10000);
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSearch() {
|
||||||
|
const input = document.getElementById('search');
|
||||||
|
const allDivs = document.querySelectorAll('.pipelineDiv');
|
||||||
|
|
||||||
|
if (document.querySelectorAll('.toggle').length == 1) {
|
||||||
|
// If the is only 1 folder, expand it
|
||||||
|
let toggle = document.querySelector('.toggle')
|
||||||
|
if (toggle && !toggle.auto_checked) {
|
||||||
|
toggle.checked = true;
|
||||||
|
toggle.auto_checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.value === "") {
|
||||||
|
let divs = Array.from(allDivs).map(div => {
|
||||||
|
div.style.display = '';
|
||||||
|
div.setAttribute("data_score", "0");
|
||||||
|
|
||||||
|
return div;
|
||||||
|
});
|
||||||
|
|
||||||
|
divs.sort((a, b) => {
|
||||||
|
const scoreA = parseInt(a.getAttribute('creation_time'), 0);
|
||||||
|
const scoreB = parseInt(b.getAttribute('creation_time'), 0);
|
||||||
|
|
||||||
|
console.debug('Comparing', scoreA, scoreB, " = ", scoreB - scoreA);
|
||||||
|
return scoreB - scoreA;
|
||||||
|
});
|
||||||
|
|
||||||
|
divs.forEach(div => {
|
||||||
|
console.debug(`Moving ${div.id} in {div.parentNode}}`);
|
||||||
|
div.parentNode.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
includeScore: true,
|
||||||
|
threshold: 0.6,
|
||||||
|
keys: ['title']
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const list = Array.from(allDivs).map(div => ({
|
||||||
|
id: div.getAttribute('id'), // Assuming each div has an ID
|
||||||
|
title: div.querySelector('h2').textContent
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fuse = new Fuse(list, options);
|
||||||
|
const results = fuse.search(input.value);
|
||||||
|
|
||||||
|
allDivs.forEach(div => div.style.display = 'none');
|
||||||
|
let divs = results.map(result => {
|
||||||
|
let div = document.getElementById(result.item.id);
|
||||||
|
div.style.display = '';
|
||||||
|
div.setAttribute('data_score', result.score);
|
||||||
|
|
||||||
|
return div;
|
||||||
|
});
|
||||||
|
|
||||||
|
divs.sort((a, b) => {
|
||||||
|
const scoreA = parseFloat(a.getAttribute('data_score'), 0);
|
||||||
|
const scoreB = parseFloat(b.getAttribute('data_score'), 0);
|
||||||
|
|
||||||
|
return scoreA - scoreB; // For ascending order. Use (scoreB - scoreA) for descending.
|
||||||
|
});
|
||||||
|
|
||||||
|
divs.forEach(div => {
|
||||||
|
div.parentNode.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectSearch() {
|
||||||
|
const input = document.getElementById('search');
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
updateSearch();
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePipelineOverlay(noHistoryUpdate) {
|
||||||
|
let overlay = document.getElementById("overlay");
|
||||||
|
if (!overlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
overlay.parentNode.removeChild(overlay);
|
||||||
|
if (!noHistoryUpdate) {
|
||||||
|
unsetUrlVariable('pipeline');
|
||||||
|
}
|
||||||
|
updateSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
593
subprojects/gst-devtools/dots-viewer/static/js/jquery.graphviz.svg.js
Executable file
|
@ -0,0 +1,593 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2015 Mountainstorm
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
+function ($) {
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// Cross Browser starts/endsWith support
|
||||||
|
// =====================================
|
||||||
|
String.prototype.startsWith = function (prefix) {
|
||||||
|
return this.indexOf(prefix) == 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
String.prototype.endsWith = function (suffix) {
|
||||||
|
return this.indexOf(suffix, this.length - suffix.length) !== -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GRAPHVIZSVG PUBLIC CLASS DEFINITION
|
||||||
|
// ===================================
|
||||||
|
|
||||||
|
var GraphvizSvg = function (element, options) {
|
||||||
|
this.type = null
|
||||||
|
this.options = null
|
||||||
|
this.enabled = null
|
||||||
|
this.$element = null
|
||||||
|
|
||||||
|
this.init('graphviz.svg', element, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.VERSION = '1.0.1'
|
||||||
|
|
||||||
|
GraphvizSvg.GVPT_2_PX = 32.5 // used to ease removal of extra space
|
||||||
|
|
||||||
|
GraphvizSvg.DEFAULTS = {
|
||||||
|
url: null,
|
||||||
|
svg: null,
|
||||||
|
shrink: '0.10pt',
|
||||||
|
tooltips: {
|
||||||
|
init: function ($graph) {
|
||||||
|
var $a = $(this)
|
||||||
|
$a.tooltip({
|
||||||
|
container: $graph,
|
||||||
|
placement: 'auto left',
|
||||||
|
animation: false,
|
||||||
|
viewport: null
|
||||||
|
}).on('hide.bs.tooltip', function () {
|
||||||
|
// keep them visible even if you acidentally mouse over
|
||||||
|
if ($a.attr('data-tooltip-keepvisible')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
show: function () {
|
||||||
|
var $a = $(this)
|
||||||
|
$a.attr('data-tooltip-keepvisible', true)
|
||||||
|
$a.tooltip('show')
|
||||||
|
},
|
||||||
|
hide: function () {
|
||||||
|
var $a = $(this)
|
||||||
|
$a.removeAttr('data-tooltip-keepvisible')
|
||||||
|
$a.tooltip('hide')
|
||||||
|
},
|
||||||
|
update: function () {
|
||||||
|
var $this = $(this)
|
||||||
|
if ($this.attr('data-tooltip-keepvisible')) {
|
||||||
|
$this.tooltip('show')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
zoom: true,
|
||||||
|
highlight: {
|
||||||
|
selected: function (col, bg) {
|
||||||
|
return col
|
||||||
|
},
|
||||||
|
unselected: function (col, bg) {
|
||||||
|
return jQuery.Color(col).transition(bg, 0.9)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ready: null
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.onMouseUpdate = function (e) {
|
||||||
|
this.mousePosition = {
|
||||||
|
x: e.pageX,
|
||||||
|
y: e.pageY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.init = function (type, element, options) {
|
||||||
|
this.enabled = true
|
||||||
|
this.type = type
|
||||||
|
this.$element = $(element)
|
||||||
|
this.options = this.getOptions(options)
|
||||||
|
|
||||||
|
if (options.url) {
|
||||||
|
var that = this
|
||||||
|
$.get(options.url, null, function (data) {
|
||||||
|
var svg = $("svg", data)
|
||||||
|
that.$element.html(document.adoptNode(svg[0]))
|
||||||
|
that.setup()
|
||||||
|
}, "xml")
|
||||||
|
} else {
|
||||||
|
if (options.svg) {
|
||||||
|
this.$element.html(options.svg)
|
||||||
|
}
|
||||||
|
this.setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', this.onMouseUpdate.bind(this), false);
|
||||||
|
document.addEventListener('mouseenter', this.onMouseUpdate.bind(this), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.getDefaults = function () {
|
||||||
|
return GraphvizSvg.DEFAULTS
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.getOptions = function (options) {
|
||||||
|
options = $.extend({}, this.getDefaults(), this.$element.data(), options)
|
||||||
|
|
||||||
|
if (options.shrink) {
|
||||||
|
if (typeof options.shrink != 'object') {
|
||||||
|
options.shrink = {
|
||||||
|
x: options.shrink,
|
||||||
|
y: options.shrink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.shrink.x = this.convertToPx(options.shrink.x)
|
||||||
|
options.shrink.y = this.convertToPx(options.shrink.y)
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.setup = function () {
|
||||||
|
var options = this.options
|
||||||
|
|
||||||
|
// save key elements in the graph for easy access
|
||||||
|
var $svg = $(this.$element.children('svg'))
|
||||||
|
var $graph = $svg.children('g:first')
|
||||||
|
this.$svg = $svg
|
||||||
|
this.$graph = $graph
|
||||||
|
this.$background = $graph.children('polygon:first') // might not exist
|
||||||
|
this.$nodes = $graph.children('.node')
|
||||||
|
this.$edges = $graph.children('.edge')
|
||||||
|
this._nodesByName = {}
|
||||||
|
this._edgesByName = {}
|
||||||
|
|
||||||
|
// add top level class and copy background color to element
|
||||||
|
this.$element.addClass('graphviz-svg')
|
||||||
|
if (this.$background.length) {
|
||||||
|
this.$element.css('background', this.$background.attr('fill'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup all the nodes and edges
|
||||||
|
var that = this
|
||||||
|
this.$nodes.each(function () {that.setupNodesEdges($(this), true)})
|
||||||
|
this.$edges.each(function () {that.setupNodesEdges($(this), false)})
|
||||||
|
|
||||||
|
// remove the graph title element
|
||||||
|
var $title = this.$graph.children('title')
|
||||||
|
this.$graph.attr('data-name', $title.text())
|
||||||
|
$title.remove()
|
||||||
|
|
||||||
|
if (options.zoom) {
|
||||||
|
this.setupZoom()
|
||||||
|
}
|
||||||
|
|
||||||
|
// tell people we're done
|
||||||
|
if (options.ready) {
|
||||||
|
options.ready.call(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.setupNodesEdges = function ($el, isNode) {
|
||||||
|
var that = this
|
||||||
|
var options = this.options
|
||||||
|
|
||||||
|
// save the colors of the paths, ellipses and polygons
|
||||||
|
$el.find('polygon, ellipse, path').each(function () {
|
||||||
|
var $this = $(this)
|
||||||
|
// save original colors
|
||||||
|
$this.data('graphviz.svg.color', {
|
||||||
|
fill: $this.attr('fill'),
|
||||||
|
stroke: $this.attr('stroke')
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
// shrink it if it's a node
|
||||||
|
if (isNode && options.shrink) {
|
||||||
|
that.scaleNode($this)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// save the node name and check if theres a comment above; save it
|
||||||
|
var $title = $el.children('title')
|
||||||
|
if ($title[0]) {
|
||||||
|
// remove any compass points:
|
||||||
|
var title = $title.text().replace(/:[snew][ew]?/g, '')
|
||||||
|
$el.attr('data-name', title)
|
||||||
|
$title.remove()
|
||||||
|
if (isNode) {
|
||||||
|
this._nodesByName[title] = $el[0]
|
||||||
|
} else {
|
||||||
|
this._edgesByName[title] = $el[0]
|
||||||
|
}
|
||||||
|
// without a title we can't tell if its a user comment or not
|
||||||
|
var previousSibling = $el[0].previousSibling
|
||||||
|
while (previousSibling && previousSibling.nodeType != 8) {
|
||||||
|
previousSibling = previousSibling.previousSibling
|
||||||
|
}
|
||||||
|
if (previousSibling != null && previousSibling.nodeType == 8) {
|
||||||
|
var htmlDecode = function (input) {
|
||||||
|
var e = document.createElement('div')
|
||||||
|
e.innerHTML = input
|
||||||
|
return e.childNodes[0].nodeValue
|
||||||
|
}
|
||||||
|
var value = htmlDecode(previousSibling.nodeValue.trim())
|
||||||
|
if (value != title) {
|
||||||
|
// user added comment
|
||||||
|
$el.attr('data-comment', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove namespace from a[xlink:title]
|
||||||
|
$el.children('a').filter(function () {return $(this).attr('xlink:title')}).each(function () {
|
||||||
|
var $a = $(this)
|
||||||
|
$a.attr('title', $a.attr('xlink:title'))
|
||||||
|
$a.removeAttr('xlink:title')
|
||||||
|
if (options.tooltips) {
|
||||||
|
options.tooltips.init.call(this, that.$element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.setupZoom = function () {
|
||||||
|
var that = this
|
||||||
|
var $element = this.$element
|
||||||
|
var $svg = this.$svg
|
||||||
|
this.zoom = {width: $svg.attr('width'), height: $svg.attr('height'), percentage: null}
|
||||||
|
this.scaleView(100.0)
|
||||||
|
$element.mousewheel(function (evt) {
|
||||||
|
if (evt.ctrlKey) {
|
||||||
|
var percentage = that.zoom.percentage
|
||||||
|
percentage += evt.deltaY * evt.deltaFactor
|
||||||
|
if (percentage < 100.0) {
|
||||||
|
percentage = 100.0
|
||||||
|
}
|
||||||
|
// get pointer offset in view
|
||||||
|
// ratio offset within svg
|
||||||
|
var dx = evt.pageX - $svg.offset().left
|
||||||
|
var dy = evt.pageY - $svg.offset().top
|
||||||
|
var rx = dx / $svg.width()
|
||||||
|
var ry = dy / $svg.height()
|
||||||
|
|
||||||
|
// offset within frame ($element)
|
||||||
|
var px = evt.pageX - $element.offset().left
|
||||||
|
var py = evt.pageY - $element.offset().top
|
||||||
|
|
||||||
|
that.scaleView(percentage)
|
||||||
|
// scroll so pointer is still in same place
|
||||||
|
$element.scrollLeft((rx * $svg.width()) + 0.5 - px)
|
||||||
|
$element.scrollTop((ry * $svg.height()) + 0.5 - py)
|
||||||
|
return false // stop propogation
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$element.on("keydown", function (evt) {
|
||||||
|
console.log(evt)
|
||||||
|
if (evt.shiftKey) {
|
||||||
|
$element.css('cursor', 'move')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$element.on("keyup", function (evt) {
|
||||||
|
console.log(evt)
|
||||||
|
if (evt.shiftKey) {
|
||||||
|
$element.css('cursor', 'auto')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
$element
|
||||||
|
.on('mousemove', function (e) {
|
||||||
|
var $svg = this.$svg
|
||||||
|
$element.css({'transform-origin': ((e.pageX - $(this).offset().left) / $(this).width()) * 100 + '% ' + ((e.pageY - $(this).offset().top) / $(this).height()) * 100 + '%'});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.scaleView = function (percentage) {
|
||||||
|
var that = this
|
||||||
|
var $svg = this.$svg
|
||||||
|
$svg.attr('width', percentage + '%')
|
||||||
|
$svg.attr('height', percentage + '%')
|
||||||
|
this.zoom.percentage = percentage
|
||||||
|
// now callback to update tooltip position
|
||||||
|
var $everything = this.$nodes.add(this.$edges)
|
||||||
|
$everything.children('a[title]').each(function () {
|
||||||
|
that.options.tooltips.update.call(this)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.scaleInView = function (percentage) {
|
||||||
|
var that = this
|
||||||
|
var $svg = this.$svg
|
||||||
|
var $element = this.$element
|
||||||
|
|
||||||
|
// get pointer offset in view
|
||||||
|
// ratio offset within svg
|
||||||
|
var dx = this.mousePosition.x - $svg.offset().left
|
||||||
|
var dy = this.mousePosition.y - $svg.offset().top
|
||||||
|
var rx = dx / $svg.width()
|
||||||
|
var ry = dy / $svg.height()
|
||||||
|
|
||||||
|
// offset within frame ($element)
|
||||||
|
var px = this.mousePosition.x - $element.offset().left
|
||||||
|
var py = this.mousePosition.y - $element.offset().top
|
||||||
|
|
||||||
|
$svg.attr('width', percentage + '%')
|
||||||
|
$svg.attr('height', percentage + '%')
|
||||||
|
this.zoom.percentage = percentage
|
||||||
|
// now callback to update tooltip position
|
||||||
|
var $everything = this.$nodes.add(this.$edges)
|
||||||
|
$everything.children('a[title]').each(function () {
|
||||||
|
that.options.tooltips.update.call(this)
|
||||||
|
})
|
||||||
|
|
||||||
|
// scroll so pointer is still in same place
|
||||||
|
$element.scrollLeft((rx * $svg.width()) + 0.5 - px)
|
||||||
|
$element.scrollTop((ry * $svg.height()) + 0.5 - py)
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.scaleNode = function ($node) {
|
||||||
|
var dx = this.options.shrink.x
|
||||||
|
var dy = this.options.shrink.y
|
||||||
|
var tagName = $node.prop('tagName')
|
||||||
|
if (tagName == 'ellipse') {
|
||||||
|
$node.attr('rx', parseFloat($node.attr('rx')) - dx)
|
||||||
|
$node.attr('ry', parseFloat($node.attr('ry')) - dy)
|
||||||
|
} else if (tagName == 'polygon') {
|
||||||
|
// this is more complex - we need to scale it manually
|
||||||
|
var bbox = $node[0].getBBox()
|
||||||
|
var cx = bbox.x + (bbox.width / 2)
|
||||||
|
var cy = bbox.y + (bbox.height / 2)
|
||||||
|
var pts = $node.attr('points').split(' ')
|
||||||
|
var points = '' // new value
|
||||||
|
for (var i in pts) {
|
||||||
|
var xy = pts[i].split(',')
|
||||||
|
var ox = parseFloat(xy[0])
|
||||||
|
var oy = parseFloat(xy[1])
|
||||||
|
points += (((cx - ox) / (bbox.width / 2) * dx) + ox) +
|
||||||
|
',' +
|
||||||
|
(((cy - oy) / (bbox.height / 2) * dy) + oy) +
|
||||||
|
' '
|
||||||
|
}
|
||||||
|
$node.attr('points', points)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.convertToPx = function (val) {
|
||||||
|
var retval = val
|
||||||
|
if (typeof val == 'string') {
|
||||||
|
var end = val.length
|
||||||
|
var factor = 1.0
|
||||||
|
if (val.endsWith('px')) {
|
||||||
|
end -= 2
|
||||||
|
} else if (val.endsWith('pt')) {
|
||||||
|
end -= 2
|
||||||
|
factor = GraphvizSvg.GVPT_2_PX
|
||||||
|
}
|
||||||
|
retval = parseFloat(val.substring(0, end)) * factor
|
||||||
|
}
|
||||||
|
return retval
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.findEdge = function (nodeName, testEdge, $retval) {
|
||||||
|
var retval = []
|
||||||
|
for (var name in this._edgesByName) {
|
||||||
|
var match = testEdge(nodeName, name)
|
||||||
|
if (match) {
|
||||||
|
if ($retval) {
|
||||||
|
$retval.push(this._edgesByName[name])
|
||||||
|
}
|
||||||
|
retval.push(match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return retval
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.findLinked = function (node, includeEdges, testEdge, $retval) {
|
||||||
|
var that = this
|
||||||
|
var $node = $(node)
|
||||||
|
var $edges = null
|
||||||
|
if (includeEdges) {
|
||||||
|
$edges = $retval
|
||||||
|
}
|
||||||
|
var names = this.findEdge($node.attr('data-name'), testEdge, $edges)
|
||||||
|
for (var i in names) {
|
||||||
|
var n = this._nodesByName[names[i]]
|
||||||
|
if (!$retval.is(n)) {
|
||||||
|
$retval.push(n)
|
||||||
|
that.findLinked(n, includeEdges, testEdge, $retval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.colorElement = function ($el, getColor) {
|
||||||
|
var bg = this.$element.css('background')
|
||||||
|
$el.find('polygon, ellipse, path').each(function () {
|
||||||
|
var $this = $(this)
|
||||||
|
var color = $this.data('graphviz.svg.color')
|
||||||
|
if (color.fill && $this.prop('tagName') != 'path') {
|
||||||
|
$this.attr('fill', getColor(color.fill, bg)) // don't set fill if it's a path
|
||||||
|
}
|
||||||
|
if (color.stroke) {
|
||||||
|
$this.attr('stroke', getColor(color.stroke, bg))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.restoreElement = function ($el) {
|
||||||
|
$el.find('polygon, ellipse, path').each(function () {
|
||||||
|
var $this = $(this)
|
||||||
|
var color = $this.data('graphviz.svg.color')
|
||||||
|
if (color.fill) {
|
||||||
|
$this.attr('fill', color.fill) // don't set fill if it's a path
|
||||||
|
}
|
||||||
|
if (color.stroke) {
|
||||||
|
$this.attr('stroke', color.stroke)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// methods users can actually call
|
||||||
|
GraphvizSvg.prototype.nodes = function () {
|
||||||
|
return this.$nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.edges = function () {
|
||||||
|
return this.$edges
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.nodesByName = function () {
|
||||||
|
return this._nodesByName
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.edgesByName = function () {
|
||||||
|
return this._edgesByName
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.linkedTo = function (node, includeEdges) {
|
||||||
|
var $retval = $()
|
||||||
|
this.findLinked(node, includeEdges, function (nodeName, edgeName) {
|
||||||
|
var other = null;
|
||||||
|
var match = '->' + nodeName
|
||||||
|
if (edgeName.endsWith(match)) {
|
||||||
|
other = edgeName.substring(0, edgeName.length - match.length);
|
||||||
|
}
|
||||||
|
return other;
|
||||||
|
}, $retval)
|
||||||
|
return $retval
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.linkedFrom = function (node, includeEdges) {
|
||||||
|
var $retval = $()
|
||||||
|
this.findLinked(node, includeEdges, function (nodeName, edgeName) {
|
||||||
|
var other = null;
|
||||||
|
var match = nodeName + '->'
|
||||||
|
if (edgeName.startsWith(match)) {
|
||||||
|
other = edgeName.substring(match.length);
|
||||||
|
}
|
||||||
|
return other;
|
||||||
|
}, $retval)
|
||||||
|
return $retval
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.linked = function (node, includeEdges) {
|
||||||
|
var $retval = $()
|
||||||
|
this.findLinked(node, includeEdges, function (nodeName, edgeName) {
|
||||||
|
return '^' + name + '--(.*)$'
|
||||||
|
}, $retval)
|
||||||
|
this.findLinked(node, includeEdges, function (nodeName, edgeName) {
|
||||||
|
return '^(.*)--' + name + '$'
|
||||||
|
}, $retval)
|
||||||
|
return $retval
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.tooltip = function ($elements, show) {
|
||||||
|
var that = this
|
||||||
|
var options = this.options
|
||||||
|
$elements.each(function () {
|
||||||
|
$(this).children('a[title]').each(function () {
|
||||||
|
if (show) {
|
||||||
|
options.tooltips.show.call(this)
|
||||||
|
} else {
|
||||||
|
options.tooltips.hide.call(this)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.bringToFront = function ($elements) {
|
||||||
|
$elements.detach().appendTo(this.$graph)
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.sendToBack = function ($elements) {
|
||||||
|
if (this.$background.length) {
|
||||||
|
$element.insertAfter(this.$background)
|
||||||
|
} else {
|
||||||
|
$elements.detach().prependTo(this.$graph)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.highlight = function ($nodesEdges, tooltips) {
|
||||||
|
var that = this
|
||||||
|
var options = this.options
|
||||||
|
var $everything = this.$nodes.add(this.$edges)
|
||||||
|
if ($nodesEdges && $nodesEdges.length > 0) {
|
||||||
|
// create set of all other elements and dim them
|
||||||
|
$everything.not($nodesEdges).each(function () {
|
||||||
|
that.colorElement($(this), options.highlight.unselected)
|
||||||
|
that.tooltip($(this))
|
||||||
|
})
|
||||||
|
$nodesEdges.each(function () {
|
||||||
|
that.colorElement($(this), options.highlight.selected)
|
||||||
|
})
|
||||||
|
if (tooltips) {
|
||||||
|
this.tooltip($nodesEdges, true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$everything.each(function () {
|
||||||
|
that.restoreElement($(this))
|
||||||
|
})
|
||||||
|
this.tooltip($everything)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphvizSvg.prototype.destroy = function () {
|
||||||
|
var that = this
|
||||||
|
this.hide(function () {
|
||||||
|
that.$element.off('.' + that.type).removeData(that.type)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// GRAPHVIZSVG PLUGIN DEFINITION
|
||||||
|
// =============================
|
||||||
|
|
||||||
|
function Plugin(option) {
|
||||||
|
return this.each(function () {
|
||||||
|
var $this = $(this)
|
||||||
|
var data = $this.data('graphviz.svg')
|
||||||
|
var options = typeof option == 'object' && option
|
||||||
|
|
||||||
|
if (!data && /destroy/.test(option)) return
|
||||||
|
if (!data) $this.data('graphviz.svg', (data = new GraphvizSvg(this, options)))
|
||||||
|
if (typeof option == 'string') data[option]()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var old = $.fn.graphviz
|
||||||
|
|
||||||
|
$.fn.graphviz = Plugin
|
||||||
|
$.fn.graphviz.Constructor = GraphvizSvg
|
||||||
|
|
||||||
|
|
||||||
|
// GRAPHVIZ NO CONFLICT
|
||||||
|
// ====================
|
||||||
|
|
||||||
|
$.fn.graphviz.noConflict = function () {
|
||||||
|
$.fn.graphviz = old
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
}(jQuery)
|
91
subprojects/gst-devtools/dots-viewer/static/overlay.html
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
<!--
|
||||||
|
Copyright (c) 2015 Mountainstorm
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.4/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="/css/graphviz.svg.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.floating-rectangle {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
padding: 10px;
|
||||||
|
z-index: 1000;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<body>
|
||||||
|
<h1 style="text-align: center" id="title"> {{ TITLE }}</h1>
|
||||||
|
|
||||||
|
<div id="graph" style="width: 100%; height: 100%; overflow: scroll;"></div>
|
||||||
|
<div class="floating-rectangle" id="instructions">
|
||||||
|
Click node to highlight<br/>Shift-Ctrl-scroll or w/s to zoom<br/>Esc to unhighlight
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script type="text/javascript" src="https://code.jquery.com/jquery-2.1.3.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.rawgit.com/jquery/jquery-mousewheel/master/jquery.mousewheel.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.rawgit.com/jquery/jquery-color/master/jquery.color.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.4/js/bootstrap.min.js"></script>
|
||||||
|
<script type="text/javascript" src="/js/jquery.graphviz.svg.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
let url = new URL(window.location.href);
|
||||||
|
let searchParams = new URLSearchParams(url.search);
|
||||||
|
document.getElementById("title").innerHTML = searchParams.get("title");
|
||||||
|
|
||||||
|
$(document).ready(function(){
|
||||||
|
$("#graph").graphviz({
|
||||||
|
url: searchParams.get("svg"),
|
||||||
|
ready: function() {
|
||||||
|
var gv = this;
|
||||||
|
gv.nodes().click(function () {
|
||||||
|
var $set = $();
|
||||||
|
$set.push(this);
|
||||||
|
$set = $set.add(gv.linkedFrom(this, true));
|
||||||
|
$set = $set.add(gv.linkedTo(this, true));
|
||||||
|
gv.highlight($set, true);
|
||||||
|
gv.bringToFront($set);
|
||||||
|
});
|
||||||
|
$(document).keydown(function (evt) {
|
||||||
|
if (evt.key == "Escape") {
|
||||||
|
gv.highlight();
|
||||||
|
} else if (evt.key == "w") {
|
||||||
|
gv.scaleInView((gv.zoom.percentage + 100));
|
||||||
|
} else if (evt.key == "s") {
|
||||||
|
gv.scaleInView((gv.zoom.percentage - 100) || 100);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
|
@ -185,6 +185,10 @@ if not get_option('validate').disabled()
|
||||||
subdir('validate')
|
subdir('validate')
|
||||||
endif
|
endif
|
||||||
|
|
||||||
|
if not get_option('dots_viewer').disabled()
|
||||||
|
subdir('dots-viewer')
|
||||||
|
endif
|
||||||
|
|
||||||
if not get_option('debug_viewer').disabled()
|
if not get_option('debug_viewer').disabled()
|
||||||
subdir('debug-viewer')
|
subdir('debug-viewer')
|
||||||
endif
|
endif
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
option('validate', type : 'feature', value : 'auto',
|
option('validate', type : 'feature', value : 'auto',
|
||||||
description : 'Build GstValidate')
|
description : 'Build GstValidate')
|
||||||
|
option('dots_viewer', type : 'feature', value : 'auto',
|
||||||
|
description : 'Build gst-dots-viewer')
|
||||||
option('cairo', type : 'feature', value : 'auto', description : 'Build GstValidateVideo')
|
option('cairo', type : 'feature', value : 'auto', description : 'Build GstValidateVideo')
|
||||||
option('debug_viewer', type : 'feature', value : 'disabled',
|
option('debug_viewer', type : 'feature', value : 'disabled',
|
||||||
description : 'Build GstDebugViewer (GPLv3+)')
|
description : 'Build GstDebugViewer (GPLv3+)')
|
||||||
|
|