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>
This commit is contained in:
Thibault Saunier 2024-11-27 13:42:02 -03:00 committed by GStreamer Marge Bot
parent 4b74819671
commit 61159bd992
35 changed files with 4577 additions and 0 deletions

View file

@ -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

File diff suppressed because it is too large Load diff

View 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"

View file

@ -0,0 +1,82 @@
# gst-dots-viewer
![](static/images/gst-dots-viewer.jpeg)
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:
[![](static/images/gst-dots-viewer-video.jpeg){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.

View file

@ -0,0 +1,5 @@
use static_files::resource_dir;
fn main() -> std::io::Result<()> {
resource_dir("./static").build()
}

View 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))

View 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

View 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"
}
}
}
}

View 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"
}
}

View 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:?}"),
}
}
}

View 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
}

View file

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

View 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>

File diff suppressed because one or more lines are too long

View 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 = '&times;';
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();
}

View 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)

File diff suppressed because one or more lines are too long

View 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>

View file

@ -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

View file

@ -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+)')