@norskvideo/ctl-dev-kit 0.2.107 → 0.2.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/conventions/DESIGN.family.md +73 -0
- package/conventions/check-drift.ts +100 -0
- package/conventions/sync-drift.ts +21 -1
- package/create-demo/create-demo.ts +191 -0
- package/create-demo/template/core/CLAUDE.md.tmpl +158 -0
- package/create-demo/template/core/README.md.tmpl +74 -0
- package/create-demo/template/core/ctl/install.sh +27 -0
- package/create-demo/template/core/ctl/lib.sh +146 -0
- package/create-demo/template/core/ctl/mode.sh +19 -0
- package/create-demo/template/core/ctl/push.sh +16 -0
- package/create-demo/template/core/ctl/site.example.env +30 -0
- package/create-demo/template/core/ctl/studio.example.env +12 -0
- package/create-demo/template/core/gitignore +18 -0
- package/create-demo/template/core/scripts/build.sh +30 -0
- package/create-demo/template/core/scripts/test-feed.sh +44 -0
- package/create-demo/template/dashboard/dashboard/index.html +13 -0
- package/create-demo/template/dashboard/dashboard/package.json +22 -0
- package/create-demo/template/dashboard/dashboard/src/App.tsx +61 -0
- package/create-demo/template/dashboard/dashboard/src/api/types.ts +38 -0
- package/create-demo/template/dashboard/dashboard/src/components/ImagePreview.tsx +23 -0
- package/create-demo/template/dashboard/dashboard/src/components/StatusPill.tsx +65 -0
- package/create-demo/template/dashboard/dashboard/src/hooks/useAlerts.ts +98 -0
- package/create-demo/template/dashboard/dashboard/src/hooks/useDiscovery.ts +88 -0
- package/create-demo/template/dashboard/dashboard/src/hooks/useEnv.ts +53 -0
- package/create-demo/template/dashboard/dashboard/src/hooks/useLiveComponent.ts +249 -0
- package/create-demo/template/dashboard/dashboard/src/lib/workflow.ts +11 -0
- package/create-demo/template/dashboard/dashboard/src/main.tsx +12 -0
- package/create-demo/template/dashboard/dashboard/src/styles.css +84 -0
- package/create-demo/template/dashboard/dashboard/src/vite-env.d.ts +1 -0
- package/create-demo/template/dashboard/dashboard/tsconfig.json +18 -0
- package/create-demo/template/dashboard/dashboard/vite.config.ts +35 -0
- package/create-product/cli.ts +39 -1
- package/create-product/features/components.ts +10 -1
- package/package.json +1 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# __DEMO__
|
|
2
|
+
|
|
3
|
+
<!-- The customer's handover document. Replace every TODO before handover; the
|
|
4
|
+
CLAUDE.md "Handover" list says what this needs to cover. -->
|
|
5
|
+
|
|
6
|
+
TODO: one paragraph on what this demo does and who it is for.
|
|
7
|
+
|
|
8
|
+
## What is in it
|
|
9
|
+
|
|
10
|
+
| Path | What it is |
|
|
11
|
+
| ---------- | ------------------------------------------------------------------------------------------ |
|
|
12
|
+
| `workdir/` | The Norsk Studio working directory: the workflow, and what Studio serves and loads with it |
|
|
13
|
+
| `ctl/` | The scripts that install it into norsk-ctl and update it |
|
|
14
|
+
| `scripts/` | Building it, and a test feed |
|
|
15
|
+
<!-- if:dashboard -->
|
|
16
|
+
| `dashboard/` | The operator dashboard |
|
|
17
|
+
<!-- end:dashboard -->
|
|
18
|
+
<!-- if:components -->
|
|
19
|
+
| `components/` | This demo's own Studio components |
|
|
20
|
+
<!-- end:components -->
|
|
21
|
+
|
|
22
|
+
## Installing
|
|
23
|
+
|
|
24
|
+
It runs under norsk-ctl, on this machine or another one over ssh.
|
|
25
|
+
|
|
26
|
+
1. Install norsk-ctl on the machine, and have the norsk-studio licence there.
|
|
27
|
+
2. Copy `ctl/site.example.env` to `ctl/site.env` and fill it in: where it runs, where the working
|
|
28
|
+
dir goes, the licence path, the ports to publish.
|
|
29
|
+
3. Copy `ctl/studio.example.env` to `ctl/studio.env` and fill in the site's settings.
|
|
30
|
+
4. Run `ctl/install.sh`. It prints the Studio URL when it is up.
|
|
31
|
+
|
|
32
|
+
Building needs bun on the machine you run the scripts from.
|
|
33
|
+
|
|
34
|
+
## Using it
|
|
35
|
+
|
|
36
|
+
TODO: the URLs, and what the operator does on each page.
|
|
37
|
+
|
|
38
|
+
- `scripts/test-feed.sh up` sends a test picture into the SRT input; `down` stops it.
|
|
39
|
+
|
|
40
|
+
## The workflow
|
|
41
|
+
|
|
42
|
+
TODO: each block in `workdir/studio-save-files/__DEMO__/__DEMO__.yaml`, in signal
|
|
43
|
+
order: what it is for and the settings that matter.
|
|
44
|
+
|
|
45
|
+
## Changing it
|
|
46
|
+
|
|
47
|
+
- Edit the workflow YAML, then `ctl/push.sh`. It rebuilds, copies the working dir over and relaunches.
|
|
48
|
+
- To change it in Studio's editor, `ctl/mode.sh editor`; `ctl/mode.sh document` puts it back to
|
|
49
|
+
running on start. Saving from the editor drops the YAML's comments, so copy changes back by hand.
|
|
50
|
+
- Site settings (ports, passphrases, destinations) live in `ctl/studio.env`, as
|
|
51
|
+
`<COMPONENT_ID>_<FIELD>=value`.
|
|
52
|
+
<!-- if:dashboard -->
|
|
53
|
+
- The dashboard is in `dashboard/`; `src/lib/workflow.ts` names the components it shows.
|
|
54
|
+
<!-- end:dashboard -->
|
|
55
|
+
<!-- if:components -->
|
|
56
|
+
- Custom components are in `components/src/library/`, one directory each.
|
|
57
|
+
<!-- end:components -->
|
|
58
|
+
|
|
59
|
+
## Limits
|
|
60
|
+
|
|
61
|
+
TODO: what this demo does not do, and what a production build would add.
|
|
62
|
+
|
|
63
|
+
There is no access control beyond norsk-ctl's own sign-in.
|
|
64
|
+
|
|
65
|
+
## Versions
|
|
66
|
+
|
|
67
|
+
TODO: the norsk-ctl, Studio and Norsk versions this was shown on
|
|
68
|
+
(`norsk-ctl instance describe __DEMO__`).
|
|
69
|
+
|
|
70
|
+
## Taking it out of norsk-ctl
|
|
71
|
+
|
|
72
|
+
`norsk-ctl instance export __DEMO__ --to <dir>` writes a docker compose bundle that runs
|
|
73
|
+
without norsk-ctl, with a `HANDOVER.md` of its own. Run it under its own project name, and put
|
|
74
|
+
a reverse proxy in front of Studio: the bundle has none.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# First install of the demo into norsk-ctl: here, or on DEMO_HOST over ssh
|
|
3
|
+
# (see ctl/site.env). Builds, stages workdir/ at WORKDIR, and launches it as
|
|
4
|
+
# instance __DEMO__ from the studio-dev template. ctl/push.sh updates it after.
|
|
5
|
+
#
|
|
6
|
+
# ctl/install.sh [--no-build]
|
|
7
|
+
source "$(dirname "$0")/lib.sh"
|
|
8
|
+
load_site
|
|
9
|
+
|
|
10
|
+
if ctl instance exists "$DEMO" >/dev/null 2>&1; then
|
|
11
|
+
die "instance $DEMO already exists: ctl/push.sh updates it"
|
|
12
|
+
fi
|
|
13
|
+
if [[ "${1:-}" != --no-build ]]; then "$ROOT/scripts/build.sh"; fi
|
|
14
|
+
|
|
15
|
+
ensure_template
|
|
16
|
+
sync_workdir
|
|
17
|
+
write_overrides "$(current_mode)"
|
|
18
|
+
|
|
19
|
+
ports=()
|
|
20
|
+
for port in $HOST_PORTS; do ports+=(--host-ports "$port"); done
|
|
21
|
+
ctl instance launch-template "$DEMO" --template studio-dev \
|
|
22
|
+
--param "INSTANCE_NAME=$DEMO" --param GLOBAL_MOQ_PREVIEW_WEBTRANSPORTPORT=auto \
|
|
23
|
+
--working-directory "$WORKDIR" --overrides "$WORKDIR/ctl-overrides.yml" \
|
|
24
|
+
--network-mode "$NETWORK_MODE" "${ports[@]}" >/dev/null
|
|
25
|
+
|
|
26
|
+
wait_ready
|
|
27
|
+
print_urls
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shared by install.sh, push.sh and mode.sh: the site's settings, how to reach
|
|
3
|
+
# the machine the demo runs on, and the overrides file norsk-ctl launches
|
|
4
|
+
# Studio with. Sourced, not run.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
DEMO=__DEMO__
|
|
8
|
+
CTL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
+
ROOT="$(dirname "$CTL_DIR")"
|
|
10
|
+
|
|
11
|
+
die() {
|
|
12
|
+
echo "error: $*" >&2
|
|
13
|
+
exit 1
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# ctl/site.env, with the defaults for anything it leaves unset.
|
|
17
|
+
load_site() {
|
|
18
|
+
[[ -f "$CTL_DIR/site.env" ]] || die "no ctl/site.env: copy ctl/site.example.env to it and fill it in"
|
|
19
|
+
set -a
|
|
20
|
+
# shellcheck source=/dev/null
|
|
21
|
+
source "$CTL_DIR/site.env"
|
|
22
|
+
set +a
|
|
23
|
+
DEMO_HOST="${DEMO_HOST:-}"
|
|
24
|
+
WORKDIR="${WORKDIR:-/srv/$DEMO}"
|
|
25
|
+
NETWORK_MODE="${NETWORK_MODE:-docker}"
|
|
26
|
+
RUN_AS="${RUN_AS-norsk}"
|
|
27
|
+
NORSK_CTL="${NORSK_CTL:-norsk-ctl}"
|
|
28
|
+
HOST_PORTS="${HOST_PORTS:-}"
|
|
29
|
+
LICENSE_FILE="${LICENSE_FILE:-}"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
quote() {
|
|
33
|
+
local out=() arg
|
|
34
|
+
for arg in "$@"; do out+=("$(printf '%q' "$arg")"); done
|
|
35
|
+
echo "${out[*]}"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Run a shell command line on the target: over ssh when DEMO_HOST is set, here
|
|
39
|
+
# otherwise. stdin passes through either way.
|
|
40
|
+
on_target() {
|
|
41
|
+
if [[ -n "$DEMO_HOST" ]]; then ssh "$DEMO_HOST" "$*"; else bash -c "$*"; fi
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# "sudo " when the working dir belongs to another user (RUN_AS), else nothing.
|
|
45
|
+
target_sudo() {
|
|
46
|
+
if [[ -n "$RUN_AS" ]]; then echo "sudo "; fi
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# norsk-ctl on the target, as the user its daemon runs as.
|
|
50
|
+
ctl() {
|
|
51
|
+
local cmd
|
|
52
|
+
cmd="$NORSK_CTL $(quote "$@")"
|
|
53
|
+
if [[ -n "$RUN_AS" ]]; then cmd="sudo -u $(quote "$RUN_AS") -H $cmd"; fi
|
|
54
|
+
on_target "$cmd"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
current_mode() {
|
|
58
|
+
cat "$CTL_DIR/.mode" 2>/dev/null || echo document
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
yaml_quote() {
|
|
62
|
+
local s="${1//\'/\'\'}"
|
|
63
|
+
printf "'%s'" "$s"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# The compose override norsk-ctl merges over the studio service, for a mode:
|
|
67
|
+
# document Studio runs acme.yaml on start (the demo as shown)
|
|
68
|
+
# editor Studio opens on the editor with nothing running (for changing it)
|
|
69
|
+
# NODE_CONFIG names every library, the custom components included, because a
|
|
70
|
+
# norsk-ctl older than the library fix lets this list replace the one it
|
|
71
|
+
# writes. ctl/studio.env lines are Studio environment overrides, <ID>_<FIELD>.
|
|
72
|
+
overrides_yaml() {
|
|
73
|
+
local mode="$1" libs server line
|
|
74
|
+
[[ "$mode" == document || "$mode" == editor ]] || die "unknown mode '$mode': document or editor"
|
|
75
|
+
libs='"@norskvideo/norsk-studio-built-ins","@norskvideo/norsk-studio-beta"'
|
|
76
|
+
if [[ -d "$ROOT/components" ]]; then libs="$libs,\"@$DEMO/library\""; fi
|
|
77
|
+
server="\"library\":[$libs]"
|
|
78
|
+
if [[ "$mode" == document ]]; then server="\"document\":\"$DEMO.yaml\",$server"; fi
|
|
79
|
+
echo "services:"
|
|
80
|
+
echo " studio:"
|
|
81
|
+
echo " environment:"
|
|
82
|
+
echo " NODE_CONFIG: $(yaml_quote "{\"server\":{$server}}")"
|
|
83
|
+
if [[ -f "$CTL_DIR/studio.env" ]]; then
|
|
84
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
85
|
+
[[ -z "${line// /}" || "$line" == \#* ]] && continue
|
|
86
|
+
echo " ${line%%=*}: $(yaml_quote "${line#*=}")"
|
|
87
|
+
done <"$CTL_DIR/studio.env"
|
|
88
|
+
fi
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
write_overrides() {
|
|
92
|
+
overrides_yaml "$1" | on_target "$(target_sudo)tee $(quote "$WORKDIR/ctl-overrides.yml") >/dev/null"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Copy workdir/ to WORKDIR on the target, owned by RUN_AS. Nothing already
|
|
96
|
+
# there is deleted (Studio keeps its own files in it), except inside the two
|
|
97
|
+
# build outputs, where a stale file would still be served or loaded.
|
|
98
|
+
sync_workdir() {
|
|
99
|
+
local dest="$WORKDIR" rsync=(rsync) opts=(-a) sub
|
|
100
|
+
on_target "$(target_sudo)mkdir -p $(quote "$WORKDIR")"
|
|
101
|
+
if [[ -n "$RUN_AS" ]]; then
|
|
102
|
+
opts+=("--chown=$RUN_AS:$RUN_AS")
|
|
103
|
+
if [[ -n "$DEMO_HOST" ]]; then opts+=(--rsync-path="sudo rsync"); else rsync=(sudo rsync); fi
|
|
104
|
+
fi
|
|
105
|
+
if [[ -n "$DEMO_HOST" ]]; then dest="$DEMO_HOST:$WORKDIR"; fi
|
|
106
|
+
"${rsync[@]}" "${opts[@]}" --exclude logs --exclude .norsk --exclude moq-certs --exclude ctl-overrides.yml \
|
|
107
|
+
"$ROOT/workdir/" "$dest/"
|
|
108
|
+
for sub in "studio-save-files/$DEMO/dashboards" components; do
|
|
109
|
+
if [[ -d "$ROOT/workdir/$sub" ]]; then
|
|
110
|
+
"${rsync[@]}" "${opts[@]}" --delete "$ROOT/workdir/$sub/" "$dest/$sub/"
|
|
111
|
+
fi
|
|
112
|
+
done
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# The studio-dev product template, added from the licence when it is missing.
|
|
116
|
+
ensure_template() {
|
|
117
|
+
if ctl template list 2>/dev/null | grep -q "name: studio-dev"; then return; fi
|
|
118
|
+
[[ -n "$LICENSE_FILE" ]] || die "norsk-ctl has no studio-dev template: set LICENSE_FILE in ctl/site.env"
|
|
119
|
+
ctl product add --license-file "$LICENSE_FILE" --product norsk-studio >/dev/null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe_field() {
|
|
123
|
+
ctl instance describe "$DEMO" -o json 2>/dev/null | bun -e '
|
|
124
|
+
const text = await Bun.stdin.text();
|
|
125
|
+
let v = JSON.parse(text.slice(text.indexOf("{")));
|
|
126
|
+
for (const key of process.argv[1].split(".")) v = v?.[key];
|
|
127
|
+
console.log(v ?? "");
|
|
128
|
+
' "$1"
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
wait_ready() {
|
|
132
|
+
local i
|
|
133
|
+
for ((i = 0; i < 90; i++)); do
|
|
134
|
+
if [[ "$(describe_field runtimeScreen.ready 2>/dev/null)" == true ]]; then return 0; fi
|
|
135
|
+
sleep 2
|
|
136
|
+
done
|
|
137
|
+
die "$DEMO did not come up within 3 minutes: see norsk-ctl instance describe $DEMO"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
print_urls() {
|
|
141
|
+
local studio
|
|
142
|
+
studio="$(describe_field runtimeScreen.href)"
|
|
143
|
+
echo "Studio: $studio"
|
|
144
|
+
if [[ -d "$ROOT/dashboard" ]]; then echo "Dashboard: ${studio}dashboard/$DEMO/"; fi
|
|
145
|
+
echo "Mode: $(current_mode)"
|
|
146
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Switch the installed demo between its two modes, and relaunch it:
|
|
3
|
+
# document Studio runs the workflow on start: the demo as it is shown.
|
|
4
|
+
# The editor's Stop button is hidden in this mode.
|
|
5
|
+
# editor Studio opens on the editor with nothing running, to change it.
|
|
6
|
+
#
|
|
7
|
+
# ctl/mode.sh document|editor
|
|
8
|
+
source "$(dirname "$0")/lib.sh"
|
|
9
|
+
load_site
|
|
10
|
+
|
|
11
|
+
mode="${1:-}"
|
|
12
|
+
overrides_yaml "$mode" >/dev/null
|
|
13
|
+
ctl instance exists "$DEMO" >/dev/null 2>&1 || die "no instance $DEMO yet: run ctl/install.sh first"
|
|
14
|
+
|
|
15
|
+
echo "$mode" >"$CTL_DIR/.mode"
|
|
16
|
+
write_overrides "$mode"
|
|
17
|
+
ctl instance relaunch "$DEMO" >/dev/null
|
|
18
|
+
wait_ready
|
|
19
|
+
print_urls
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Push a change to the installed demo: build, sync workdir/ to WORKDIR, rewrite
|
|
3
|
+
# the overrides from ctl/studio.env, relaunch, and wait until Studio is up.
|
|
4
|
+
#
|
|
5
|
+
# ctl/push.sh [--no-build]
|
|
6
|
+
source "$(dirname "$0")/lib.sh"
|
|
7
|
+
load_site
|
|
8
|
+
|
|
9
|
+
ctl instance exists "$DEMO" >/dev/null 2>&1 || die "no instance $DEMO yet: run ctl/install.sh first"
|
|
10
|
+
if [[ "${1:-}" != --no-build ]]; then "$ROOT/scripts/build.sh"; fi
|
|
11
|
+
|
|
12
|
+
sync_workdir
|
|
13
|
+
write_overrides "$(current_mode)"
|
|
14
|
+
ctl instance relaunch "$DEMO" >/dev/null
|
|
15
|
+
wait_ready
|
|
16
|
+
print_urls
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Where and how this demo runs under norsk-ctl. Copy to ctl/site.env (which
|
|
2
|
+
# is gitignored) and fill it in; ctl/install.sh, push.sh and mode.sh read it.
|
|
3
|
+
|
|
4
|
+
# The machine norsk-ctl runs on: user@host over ssh, or empty for this one.
|
|
5
|
+
DEMO_HOST=
|
|
6
|
+
|
|
7
|
+
# Where the working dir is staged on that machine. It must be a path the
|
|
8
|
+
# norsk-ctl user can reach: a directory under someone's home usually is not.
|
|
9
|
+
WORKDIR=/srv/__DEMO__
|
|
10
|
+
|
|
11
|
+
# The user the norsk-ctl daemon runs as: the working dir is owned by it and
|
|
12
|
+
# norsk-ctl is run as it (via sudo). Empty runs both as the ssh/local user.
|
|
13
|
+
RUN_AS=norsk
|
|
14
|
+
|
|
15
|
+
# The norsk-ctl command on that machine.
|
|
16
|
+
NORSK_CTL=norsk-ctl
|
|
17
|
+
|
|
18
|
+
# A path on that machine to the norsk-studio licence, used once to add the
|
|
19
|
+
# studio-dev product template if norsk-ctl does not have it yet.
|
|
20
|
+
LICENSE_FILE=
|
|
21
|
+
|
|
22
|
+
# docker (norsk-ctl's default) or hybrid. hybrid puts media on the host
|
|
23
|
+
# network: needed when a viewer receives WebRTC media (WHEP, WHIP) directly.
|
|
24
|
+
NETWORK_MODE=docker
|
|
25
|
+
|
|
26
|
+
# Ingest ports to publish on the host, space separated, HOST[:CONTAINER][/udp].
|
|
27
|
+
# Everything else is allocated by norsk-ctl; pin a port only when a sender or
|
|
28
|
+
# a firewall outside the box must agree on it. 5001/udp is the SRT input
|
|
29
|
+
# (srt_in in the workflow); change both together.
|
|
30
|
+
HOST_PORTS="5001/udp"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Site settings for the workflow. Copy to ctl/studio.env (which is gitignored).
|
|
2
|
+
#
|
|
3
|
+
# Each line is a Studio environment override, <COMPONENT_ID>_<FIELD>=value:
|
|
4
|
+
# it replaces that field of that component in the workflow YAML at launch, for
|
|
5
|
+
# any field the component declares overridable. This is where ports,
|
|
6
|
+
# passphrases and destinations go, so the YAML carries no site's values and no
|
|
7
|
+
# secrets. ctl/push.sh writes these into the overrides norsk-ctl launches with.
|
|
8
|
+
#
|
|
9
|
+
# A port changed here must be changed in HOST_PORTS (ctl/site.env) too.
|
|
10
|
+
|
|
11
|
+
# SRT_IN_PORT=5001
|
|
12
|
+
# SRT_IN_PASSPHRASE=
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# The site's settings: copied from the .example files, never committed.
|
|
2
|
+
ctl/site.env
|
|
3
|
+
ctl/studio.env
|
|
4
|
+
ctl/.mode
|
|
5
|
+
|
|
6
|
+
node_modules/
|
|
7
|
+
|
|
8
|
+
# Build output, rebuilt by scripts/build.sh.
|
|
9
|
+
dashboard/dist/
|
|
10
|
+
components/lib/
|
|
11
|
+
components/src/**/_gen/
|
|
12
|
+
workdir/studio-save-files/*/dashboards/
|
|
13
|
+
workdir/components/
|
|
14
|
+
|
|
15
|
+
# What Studio writes into its working dir while it runs.
|
|
16
|
+
workdir/logs/
|
|
17
|
+
workdir/.norsk/
|
|
18
|
+
workdir/moq-certs/
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build what this demo adds to Studio, into workdir/ where Studio finds it:
|
|
3
|
+
# dashboard/ -> workdir/studio-save-files/__DEMO__/dashboards/ served at /dashboard/__DEMO__/
|
|
4
|
+
# components/ -> workdir/components/@__DEMO__/library/ loaded as a Studio library
|
|
5
|
+
# A demo without either has nothing to build.
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
root="$(cd "$(dirname "$0")/.." && pwd)"
|
|
8
|
+
|
|
9
|
+
install_to() {
|
|
10
|
+
local from="$1" dest="$2"
|
|
11
|
+
rm -rf "$dest"
|
|
12
|
+
mkdir -p "$dest"
|
|
13
|
+
cp -r "$from/." "$dest/"
|
|
14
|
+
echo "installed ${dest#"$root"/}"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if [[ -d "$root/dashboard" ]]; then
|
|
18
|
+
# vite 8 needs util.parseEnv, which bun 1.1 does not have.
|
|
19
|
+
if ! bun -e 'if (typeof require("node:util").parseEnv !== "function") process.exit(1)' 2>/dev/null; then
|
|
20
|
+
echo "error: bun $(bun --version) is too old to build the dashboard (vite 8 needs util.parseEnv): run bun upgrade" >&2
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
(cd "$root/dashboard" && bun install >/dev/null && bun run build)
|
|
24
|
+
install_to "$root/dashboard/dist" "$root/workdir/studio-save-files/__DEMO__/dashboards"
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
if [[ -d "$root/components" ]]; then
|
|
28
|
+
(cd "$root/components" && bun install >/dev/null && bun run build)
|
|
29
|
+
install_to "$root/components/lib/library" "$root/workdir/components/@__DEMO__/library"
|
|
30
|
+
fi
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# A labelled test picture with a tone, sent into the SRT input (srt_in), so the
|
|
3
|
+
# demo has something to show without a camera. Runs ffmpeg in docker.
|
|
4
|
+
#
|
|
5
|
+
# scripts/test-feed.sh up|down
|
|
6
|
+
#
|
|
7
|
+
# FEED_HOST is where the demo runs (default this machine); the port is
|
|
8
|
+
# SRT_IN_PORT from ctl/studio.env, else the workflow's 5001.
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
root="$(cd "$(dirname "$0")/.." && pwd)"
|
|
11
|
+
|
|
12
|
+
# A setting from ctl/studio.env, or nothing: an unset one is not an error.
|
|
13
|
+
studio_env() { grep -E "^$1=" "$root/ctl/studio.env" 2>/dev/null | cut -d= -f2- || true; }
|
|
14
|
+
|
|
15
|
+
host="${FEED_HOST:-127.0.0.1}"
|
|
16
|
+
port="$(studio_env SRT_IN_PORT)"
|
|
17
|
+
port="${port:-5001}"
|
|
18
|
+
passphrase="$(studio_env SRT_IN_PASSPHRASE)"
|
|
19
|
+
image="${FFMPEG_IMAGE:-linuxserver/ffmpeg:8.0.1}"
|
|
20
|
+
name="__DEMO__-test-feed"
|
|
21
|
+
|
|
22
|
+
case "${1:-up}" in
|
|
23
|
+
up)
|
|
24
|
+
url="srt://${host}:${port}?mode=caller&latency=200000"
|
|
25
|
+
if [[ -n "$passphrase" ]]; then url="${url}&passphrase=${passphrase}"; fi
|
|
26
|
+
docker rm -f "$name" >/dev/null 2>&1 || true
|
|
27
|
+
docker run -d --restart unless-stopped --name "$name" --network host "$image" \
|
|
28
|
+
-hide_banner -loglevel warning -re \
|
|
29
|
+
-f lavfi -i "testsrc2=size=1280x720:rate=25" \
|
|
30
|
+
-f lavfi -i "sine=frequency=440:sample_rate=48000" \
|
|
31
|
+
-vf "drawtext=text='__DEMO__ %{localtime\:%T}':fontcolor=white:fontsize=64:x=40:y=40" \
|
|
32
|
+
-c:v libx264 -preset veryfast -tune zerolatency -g 50 -b:v 3M -pix_fmt yuv420p \
|
|
33
|
+
-c:a aac -b:a 128k -ac 2 \
|
|
34
|
+
-f mpegts "$url" >/dev/null
|
|
35
|
+
echo "test feed -> ${host}:${port}"
|
|
36
|
+
;;
|
|
37
|
+
down)
|
|
38
|
+
docker rm -f "$name" >/dev/null 2>&1 && echo "test feed stopped" || true
|
|
39
|
+
;;
|
|
40
|
+
*)
|
|
41
|
+
echo "usage: $0 up|down" >&2
|
|
42
|
+
exit 2
|
|
43
|
+
;;
|
|
44
|
+
esac
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<link rel="icon" href="data:," />
|
|
5
|
+
<meta charset="utf-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
<title>__DEMO__</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__DEMO__-dashboard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc --noEmit && vite build",
|
|
9
|
+
"typecheck": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"react": "^19.0.0",
|
|
13
|
+
"react-dom": "^19.0.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/react": "^19.0.10",
|
|
17
|
+
"@types/react-dom": "^19.0.3",
|
|
18
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
19
|
+
"typescript": "^5.7.2",
|
|
20
|
+
"vite": "^8.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { EnvResponse, WorkflowComponentDescriptor } from "./api/types";
|
|
2
|
+
import { ImagePreview } from "./components/ImagePreview";
|
|
3
|
+
import { StatusPill } from "./components/StatusPill";
|
|
4
|
+
import { labelFromAlerts, toneFromAlerts, useAlerts } from "./hooks/useAlerts";
|
|
5
|
+
import { useDiscovery } from "./hooks/useDiscovery";
|
|
6
|
+
import { useEnv } from "./hooks/useEnv";
|
|
7
|
+
import { useLiveComponent } from "./hooks/useLiveComponent";
|
|
8
|
+
import { WORKFLOW } from "./lib/workflow";
|
|
9
|
+
|
|
10
|
+
// The starter page: the program preview, and every running component with its
|
|
11
|
+
// health and live state. Replace it with what the operator actually needs; the
|
|
12
|
+
// hooks are the parts to keep.
|
|
13
|
+
export function App() {
|
|
14
|
+
const { env, error } = useEnv();
|
|
15
|
+
const { components, loading } = useDiscovery(env);
|
|
16
|
+
|
|
17
|
+
if (error) return <main className="page">Cannot reach Studio: {error}</main>;
|
|
18
|
+
return (
|
|
19
|
+
<main className="page">
|
|
20
|
+
<header>
|
|
21
|
+
<h1>__DEMO__</h1>
|
|
22
|
+
</header>
|
|
23
|
+
<ImagePreview componentId={WORKFLOW.preview.id} env={env} />
|
|
24
|
+
<section>
|
|
25
|
+
<h2>Components</h2>
|
|
26
|
+
{loading ? (
|
|
27
|
+
<p>Waiting for the workflow to start</p>
|
|
28
|
+
) : (
|
|
29
|
+
<ul className="components">
|
|
30
|
+
{components.map((c) => (
|
|
31
|
+
<ComponentRow key={c.componentId} component={c} env={env} />
|
|
32
|
+
))}
|
|
33
|
+
</ul>
|
|
34
|
+
)}
|
|
35
|
+
</section>
|
|
36
|
+
</main>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ComponentRow({ component, env }: { component: WorkflowComponentDescriptor; env: EnvResponse | null }) {
|
|
41
|
+
const { alerts, loaded } = useAlerts(component.componentId, env);
|
|
42
|
+
return (
|
|
43
|
+
<li>
|
|
44
|
+
<span className="component-name">{component.displayName ?? component.componentId}</span>
|
|
45
|
+
<span className="component-type">{component.componentType}</span>
|
|
46
|
+
<StatusPill tone={toneFromAlerts(alerts, loaded)} label={labelFromAlerts(alerts, loaded, { live: "OK" })} />
|
|
47
|
+
{component.capabilities.hasState && <LiveState componentId={component.componentId} env={env} />}
|
|
48
|
+
</li>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function LiveState({ componentId, env }: { componentId: string; env: EnvResponse | null }) {
|
|
53
|
+
const { state } = useLiveComponent<unknown>({ componentId, env });
|
|
54
|
+
if (state === null) return null;
|
|
55
|
+
return (
|
|
56
|
+
<details className="component-state">
|
|
57
|
+
<summary>State</summary>
|
|
58
|
+
<pre>{JSON.stringify(state, null, 2)}</pre>
|
|
59
|
+
</details>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// What the dashboard reads from Studio. The discovery response is the
|
|
2
|
+
// workflow's `<apiBasePath>/components` endpoint, which returns each running
|
|
3
|
+
// component's type and capabilities only: its config and state arrive over the
|
|
4
|
+
// component's websocket (see useLiveComponent).
|
|
5
|
+
|
|
6
|
+
export interface EnvResponse {
|
|
7
|
+
studioUrlPrefix: string;
|
|
8
|
+
apiBasePath: string;
|
|
9
|
+
wsBasePath: string;
|
|
10
|
+
note?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ComponentCapabilities {
|
|
14
|
+
hasHttp: boolean;
|
|
15
|
+
hasWebSocket: boolean;
|
|
16
|
+
hasViews: boolean;
|
|
17
|
+
hasState: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorkflowComponentDescriptor {
|
|
21
|
+
componentId: string;
|
|
22
|
+
componentType: string;
|
|
23
|
+
displayName?: string;
|
|
24
|
+
capabilities: ComponentCapabilities;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ComponentsResponse {
|
|
28
|
+
components: WorkflowComponentDescriptor[];
|
|
29
|
+
totalComponents: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `output.preview`'s state. `url` depends on the preview mode: a JPEG URL in
|
|
33
|
+
// image mode, a WHEP endpoint in webrtc mode, a WebTransport base URL in moq.
|
|
34
|
+
export interface PreviewState {
|
|
35
|
+
url?: string;
|
|
36
|
+
previewMode?: string;
|
|
37
|
+
[k: string]: unknown;
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import type { EnvResponse, PreviewState } from "../api/types";
|
|
3
|
+
import { useLiveComponent } from "../hooks/useLiveComponent";
|
|
4
|
+
|
|
5
|
+
// The program as a still from an `output.preview` in image mode. Its state
|
|
6
|
+
// carries the first JPEG's URL, and each newer one arrives as a `url-published`
|
|
7
|
+
// event, about once a second. No player and no media ports, so it works in
|
|
8
|
+
// norsk-ctl's docker network mode. For live video, see the CLAUDE.md
|
|
9
|
+
// "Dashboard" notes.
|
|
10
|
+
export function ImagePreview({ componentId, env }: { componentId: string; env: EnvResponse | null }) {
|
|
11
|
+
const [published, setPublished] = useState<string | null>(null);
|
|
12
|
+
const onEvent = useCallback((evt: unknown) => {
|
|
13
|
+
const e = evt as { type?: string; url?: unknown };
|
|
14
|
+
if (e.type === "url-published" && typeof e.url === "string") setPublished(e.url);
|
|
15
|
+
}, []);
|
|
16
|
+
const { state, connected } = useLiveComponent<PreviewState>({ componentId, env, onEvent });
|
|
17
|
+
|
|
18
|
+
const url = published ?? (typeof state?.url === "string" && state.url.length > 0 ? state.url : null);
|
|
19
|
+
if (!url) {
|
|
20
|
+
return <div className="preview preview-empty">{connected ? "Waiting for a picture" : "Connecting"}</div>;
|
|
21
|
+
}
|
|
22
|
+
return <img className="preview" src={url} alt="Program preview" />;
|
|
23
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A status pill. Six tones aligned with what Studio's per-component /alerts
|
|
2
|
+
// endpoint reports.
|
|
3
|
+
// A component's tone is derived from its live alerts list:
|
|
4
|
+
//
|
|
5
|
+
// live — no alerts (Studio considers this component healthy)
|
|
6
|
+
// ready — warning-level alerts only (e.g. "no active connections")
|
|
7
|
+
// waiting — expected upstream not yet reached (config-derived)
|
|
8
|
+
// idle — configured but off / disabled (config-derived)
|
|
9
|
+
// error — any error-level alert (e.g. "receiving no data")
|
|
10
|
+
// unknown — Studio's state doesn't expose the signal we'd need
|
|
11
|
+
//
|
|
12
|
+
// Card headers may add a matching border-left accent via `toneAccentColor`.
|
|
13
|
+
|
|
14
|
+
export type StatusTone = "live" | "ready" | "waiting" | "idle" | "error" | "unknown";
|
|
15
|
+
|
|
16
|
+
interface StatusPillProps {
|
|
17
|
+
tone: StatusTone;
|
|
18
|
+
label: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PALETTE: Record<StatusTone, { background: string; color: string; border: string }> = {
|
|
22
|
+
live: {
|
|
23
|
+
background: "rgba(34, 197, 94, 0.20)",
|
|
24
|
+
color: "rgb(134, 239, 172)",
|
|
25
|
+
border: "1px solid rgba(34, 197, 94, 0.55)",
|
|
26
|
+
},
|
|
27
|
+
ready: {
|
|
28
|
+
background: "rgba(59, 130, 246, 0.20)",
|
|
29
|
+
color: "rgb(147, 197, 253)",
|
|
30
|
+
border: "1px solid rgba(59, 130, 246, 0.55)",
|
|
31
|
+
},
|
|
32
|
+
waiting: {
|
|
33
|
+
background: "rgba(234, 179, 8, 0.20)",
|
|
34
|
+
color: "rgb(253, 224, 71)",
|
|
35
|
+
border: "1px solid rgba(234, 179, 8, 0.55)",
|
|
36
|
+
},
|
|
37
|
+
idle: {
|
|
38
|
+
background: "rgba(107, 114, 128, 0.20)",
|
|
39
|
+
color: "rgb(209, 213, 219)",
|
|
40
|
+
border: "1px solid rgba(107, 114, 128, 0.55)",
|
|
41
|
+
},
|
|
42
|
+
error: {
|
|
43
|
+
background: "rgba(239, 68, 68, 0.20)",
|
|
44
|
+
color: "rgb(252, 165, 165)",
|
|
45
|
+
border: "1px solid rgba(239, 68, 68, 0.55)",
|
|
46
|
+
},
|
|
47
|
+
unknown: {
|
|
48
|
+
background: "rgba(107, 114, 128, 0.08)",
|
|
49
|
+
color: "rgb(156, 163, 175)",
|
|
50
|
+
border: "1px dashed rgba(107, 114, 128, 0.55)",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function StatusPill({ tone, label }: StatusPillProps) {
|
|
55
|
+
return (
|
|
56
|
+
<span className="status-pill" style={PALETTE[tone]} data-tone={tone}>
|
|
57
|
+
{label}
|
|
58
|
+
</span>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// For card border-left accents matching a section's aggregate status.
|
|
63
|
+
export function toneAccentColor(tone: StatusTone): string {
|
|
64
|
+
return PALETTE[tone].border.replace("1px solid ", "").replace("1px dashed ", "");
|
|
65
|
+
}
|