init: initial commit

- ticker / QR ad overlay / break slide graphics (1920×1080) with all
  geometry derived from one shared module (src/shared/obs-geometry.mjs)
- OBS scene-collection generator (scripts/generate-obs-scenes.mjs) and a
  read-only in-dashboard setup verifier, both driven by the same module
- en/sr dashboard i18n selected via bundle config; configschema.json with
  neutral first-run defaults seeded from optional branding config
- install.sh / systemd user service / update.sh for venue deployment
- reference OBS scene collection + profile from a real venue as fixtures
This commit is contained in:
2026-07-15 17:43:05 +02:00
commit 4bb64a8ca3
71 changed files with 18186 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
/**
* Parcel build script
* See https://parceljs.org/features/parcel-api/ for more info
*/
// Native
import { fileURLToPath } from 'url';
import { argv } from 'process';
// Packages
import { glob } from 'glob';
import { Parcel } from '@parcel/core';
// Ours
import pjson from '../package.json' with { type: 'json' };
const buildAll = argv.includes('--all');
const buildExtension = argv.includes('--extension') || buildAll;
const buildDashboard = argv.includes('--dashboard') || buildAll;
const buildGraphics = argv.includes('--graphics') || buildAll;
const bundlers = new Set();
const commonBrowserTargetProps = {
engines: {
browsers: ['last 5 Chrome versions'],
},
context: 'browser',
};
if (buildDashboard) {
bundlers.add(
new Parcel({
entries: glob.sync('src/dashboard/**/*.html'),
targets: {
default: {
...commonBrowserTargetProps,
distDir: 'dashboard',
publicUrl: `/bundles/${pjson.name}/dashboard`,
},
},
defaultConfig: '@parcel/config-default',
additionalReporters: [
{
packageName: '@parcel/reporter-cli',
resolveFrom: fileURLToPath(import.meta.url),
},
],
}),
);
}
if (buildGraphics) {
bundlers.add(
new Parcel({
entries: glob.sync('src/graphics/**/*.html'),
targets: {
default: {
...commonBrowserTargetProps,
distDir: 'graphics',
publicUrl: `/bundles/${pjson.name}/graphics`,
},
},
defaultConfig: '@parcel/config-default',
additionalReporters: [
{
packageName: '@parcel/reporter-cli',
resolveFrom: fileURLToPath(import.meta.url),
},
],
}),
);
}
if (buildExtension) {
bundlers.add(
new Parcel({
entries: 'src/extension/index.ts',
targets: {
default: {
context: 'node',
distDir: 'extension',
},
},
defaultConfig: '@parcel/config-default',
additionalReporters: [
{
packageName: '@parcel/reporter-cli',
resolveFrom: fileURLToPath(import.meta.url),
},
],
}),
);
}
try {
if (argv.includes('--watch')) {
const watchPromises = [];
for (const bundler of bundlers.values()) {
watchPromises.push(
bundler.watch((err) => {
if (err) {
// fatal error
throw err;
}
}),
);
}
await Promise.all(watchPromises);
} else {
const buildPromises = [];
for (const bundler of bundlers.values()) {
buildPromises.push(bundler.run());
}
await Promise.all(buildPromises);
}
console.log('Bundle build completed successfully');
} catch (_) {
// the reporter-cli package will handle printing errors to the user
process.exit(1);
}
+19
View File
@@ -0,0 +1,19 @@
const timers = new Map();
/**
* A standard debounce, but uses a string `name` as the key instead of the callback.
*/
export default function (name, callback, duration = 500) {
const existing = timers.get(name);
if (existing) {
clearTimeout(existing);
}
timers.set(
name,
setTimeout(() => {
timers.delete(name);
callback();
}, duration),
);
}
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env node
/**
* Generate a ready-to-import OBS scene collection for this bundle.
*
* The output mirrors the proven nested-scene architecture:
* - a program scene (placeholder + ticker) and a break scene
* (break slide + ticker) shared by all six driven scenes,
* - six driven scenes, each stacking AdOverlay (bottom, full-size,
* identical everywhere) under the nested program/break scene,
* - Move + Stinger transitions, which obs-websocket cannot create.
*
* All geometry comes from src/shared/obs-geometry.mjs — the same module the
* graphics render from — so OBS transforms can never drift from the pixels.
*
* Usage:
* node scripts/generate-obs-scenes.mjs [options]
*
* Options:
* --config <path> bundle cfg JSON to read scene/transition/source
* names from (e.g. ../../cfg/<bundle>.json)
* --out <path> output file (default obs/generated/<name>.json)
* --name <name> scene collection name (default "VenueGfx")
* --nodecg-url <url> NodeCG base URL (default http://localhost:9090)
* --bundle <name> bundle name for graphics URLs (default: package.json)
* --stinger <path> absolute path to the stinger video (default: a
* placeholder you MUST replace in OBS)
* --move-duration <ms> Move transition duration (default 700)
* --transition-point <ms> stinger transition point (default 1200)
* --ticker-offset-y <px> ticker Y offset in the nested scenes (default 0)
*
* Import in OBS: Scene Collection → Import, pick the file, then switch to it.
* Afterwards: replace the "Program Placeholder" source inside the program
* scene with your real capture, and point the Stinger transition at a real
* video file if you passed no --stinger.
*/
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
CANVAS,
DEFAULT_MOVE_DURATION_MS,
DEFAULT_SCENE_NAMES,
DEFAULT_SOURCE_NAMES,
DEFAULT_STINGER_POINT_MS,
DEFAULT_TRANSITION_NAMES,
MOVE_TRANSITION_KIND,
MOVE_TRANSITION_SETTINGS,
STINGER_TRANSITION_KIND,
TICKER_OFFSET_Y,
expectedSceneModel,
graphicUrls,
stingerSettings,
} from "../src/shared/obs-geometry.mjs";
const bundleRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
// Matches the OBS version the reference fixture was saved by (32.x); OBS
// uses it to decide which config migrations to run on load.
const PREV_VER = 536936450;
const STINGER_PLACEHOLDER = "/CHANGE/ME/stinger.webm";
// ── CLI ──────────────────────────────────────────────────────────────────
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith("--")) continue;
const key = a.slice(2);
const value =
i + 1 < argv.length && !argv[i + 1].startsWith("--") ? argv[++i] : true;
args[key] = value;
}
return args;
}
const args = parseArgs(process.argv.slice(2));
if (args.help || args.h) {
console.log(
readFileSync(fileURLToPath(import.meta.url), "utf8")
.split("*/")[0]
.split("\n")
.filter((l) => l.startsWith(" *"))
.map((l) => l.slice(3))
.join("\n"),
);
process.exit(0);
}
const cfg = args.config
? JSON.parse(readFileSync(resolve(args.config), "utf8"))
: {};
const pjson = JSON.parse(readFileSync(join(bundleRoot, "package.json"), "utf8"));
const bundleName = args.bundle ?? pjson.name;
const collectionName = args.name ?? "VenueGfx";
const nodecgUrl = args["nodecg-url"] ?? "http://localhost:9090";
const stingerPath = args.stinger ?? STINGER_PLACEHOLDER;
const moveDuration = Number(args["move-duration"] ?? DEFAULT_MOVE_DURATION_MS);
const transitionPoint = Number(
args["transition-point"] ?? DEFAULT_STINGER_POINT_MS,
);
const tickerOffsetY = Number(args["ticker-offset-y"] ?? TICKER_OFFSET_Y);
const scenes = { ...DEFAULT_SCENE_NAMES, ...(cfg.obs?.scenes ?? {}) };
const transitions = { ...DEFAULT_TRANSITION_NAMES, ...(cfg.obs?.transitions ?? {}) };
const sources = { ...DEFAULT_SOURCE_NAMES, ...(cfg.obs?.sources ?? {}) };
const urls = graphicUrls(nodecgUrl, bundleName);
const model = expectedSceneModel({ scenes, sources, tickerOffsetY });
// ── Source builders (shapes match what OBS 32 itself saves) ─────────────
const uuids = new Map();
const uuidFor = (name) => {
if (!uuids.has(name)) uuids.set(name, randomUUID());
return uuids.get(name);
};
const SOURCE_COMMON = {
prev_ver: PREV_VER,
sync: 0,
flags: 0,
volume: 1.0,
balance: 0.5,
enabled: true,
muted: false,
"push-to-mute": false,
"push-to-mute-delay": 0,
"push-to-talk": false,
"push-to-talk-delay": 0,
hotkeys: {},
deinterlace_mode: 0,
deinterlace_field_order: 0,
monitoring_type: 0,
private_settings: {},
};
function browserSource(name, url) {
return {
...SOURCE_COMMON,
name,
uuid: uuidFor(name),
id: "browser_source",
versioned_id: "browser_source",
settings: { url, width: CANVAS.w, height: CANVAS.h },
mixers: 255,
};
}
function colorSource(name) {
return {
...SOURCE_COMMON,
name,
uuid: uuidFor(name),
id: "color_source",
versioned_id: "color_source_v3",
// opaque dark gray so an untouched placeholder is visible but calm
settings: { color: 4280492319, width: CANVAS.w, height: CANVAS.h },
mixers: 0,
};
}
/** Scene-item transform boilerplate, canonical top-left alignment. */
function sceneItem(expectedItem, id) {
const { pos, scale } = expectedItem;
const halfH = CANVAS.h / 2;
return {
name: expectedItem.source,
source_uuid: uuidFor(expectedItem.source),
visible: true,
locked: false,
rot: 0.0,
scale_ref: { x: CANVAS.w, y: CANVAS.h },
align: 5, // top-left
bounds_type: 0,
bounds_align: 0,
bounds_crop: false,
crop_left: 0,
crop_top: 0,
crop_right: 0,
crop_bottom: 0,
id,
group_item_backup: false,
pos: { x: pos.x, y: pos.y },
pos_rel: { x: (pos.x - CANVAS.w / 2) / halfH, y: (pos.y - halfH) / halfH },
scale: { x: scale, y: scale },
scale_rel: { x: scale, y: scale },
bounds: { x: 0.0, y: 0.0 },
bounds_rel: { x: 0.0, y: 0.0 },
scale_filter: "disable",
blend_method: "default",
blend_type: "normal",
show_transition: { duration: 300 },
hide_transition: { duration: 300 },
private_settings: {},
};
}
function scene(name, expectedItems) {
return {
...SOURCE_COMMON,
name,
uuid: uuidFor(name),
id: "scene",
versioned_id: "scene",
settings: {
id_counter: expectedItems.length,
custom_size: false,
items: expectedItems.map((item, i) => sceneItem(item, i + 1)),
},
mixers: 0,
canvas_uuid: "6c69626f-6273-4c00-9d88-c5136d61696e", // fixed "libobs main" canvas
};
}
// ── Assemble the collection ──────────────────────────────────────────────
const allSources = [
browserSource(sources.ticker, urls.ticker),
browserSource(sources.adOverlay, urls.ad),
browserSource(sources.breakSlide, urls.break),
colorSource(sources.programPlaceholder),
...Object.entries(model.nested).map(([name, items]) => scene(name, items)),
...Object.entries(model.driven).map(([name, items]) => scene(name, items)),
];
const collection = {
canvases: [],
current_program_scene: scenes.main,
current_scene: scenes.main,
current_transition: transitions.default,
groups: [],
modules: {},
name: collectionName,
preview_locked: false,
quick_transitions: [
{ name: "Cut", duration: 300, hotkeys: [], id: 1, fade_to_black: false },
{ name: "Fade", duration: 300, hotkeys: [], id: 2, fade_to_black: false },
{ name: "Fade", duration: 300, hotkeys: [], id: 3, fade_to_black: true },
],
resolution: { x: CANVAS.w, y: CANVAS.h },
saved_projectors: [],
scaling_enabled: false,
scaling_level: 0,
scaling_off_x: 0.0,
scaling_off_y: 0.0,
scene_order: [
...Object.keys(model.driven).map((name) => ({ name })),
...Object.keys(model.nested).map((name) => ({ name })),
],
sources: allSources,
transition_duration: moveDuration,
transitions: [
{
name: transitions.default,
id: MOVE_TRANSITION_KIND,
settings: { ...MOVE_TRANSITION_SETTINGS },
},
{
name: transitions.break,
id: STINGER_TRANSITION_KIND,
settings: stingerSettings(stingerPath, transitionPoint),
},
],
version: 2,
"virtual-camera": { type2: 3 },
};
// ── Write ────────────────────────────────────────────────────────────────
const outPath = resolve(
args.out ?? join(bundleRoot, "obs", "generated", `${collectionName}.json`),
);
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, JSON.stringify(collection, null, 2) + "\n");
console.log(`Scene collection written to ${outPath}`);
console.log(`
Next steps in OBS:
1. Scene Collection → Import → pick the file above, then switch to it.
2. Open the "${sources.programScene}" scene and replace the
"${sources.programPlaceholder}" color source with your real capture
(camera, window capture, …). Keep it 1920×1080 at (0,0).`);
if (stingerPath === STINGER_PLACEHOLDER) {
console.log(` 3. ⚠ The "${transitions.break}" transition points at the placeholder
${STINGER_PLACEHOLDER} — set a real video in the transition dock
(or re-run with --stinger /absolute/path/to/stinger.webm).`);
}
console.log(`
Profile checklist (Settings → Video / Output — not part of the collection):
- Base and Output resolution ${CANVAS.w}×${CANVAS.h}
- 60 FPS (the ticker scrolls; 30 looks choppy)
- encoder/output to taste — the graphics don't care
Requires OBS ≥ 31 and exeldro's Move plugin (for the "${transitions.default}" transition).`);
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Install and enable the systemd user service for a NodeCG install created
# by install.sh.
#
# Usage: ./install-service.sh [install-dir] (default: ~/venue-gfx)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UNIT_NAME=nodecg-venue-gfx.service
TEMPLATE="$SCRIPT_DIR/../deploy/$UNIT_NAME"
install_dir="${1:-$HOME/venue-gfx}"
install_dir="$(cd "${install_dir/#\~/$HOME}" && pwd)"
[[ -f $install_dir/index.js ]] || {
echo "$install_dir does not look like a NodeCG install (no index.js)" >&2
exit 1
}
mkdir -p "$HOME/.config/systemd/user"
sed "s|__INSTALL_DIR__|$install_dir|" "$TEMPLATE" \
> "$HOME/.config/systemd/user/$UNIT_NAME"
systemctl --user daemon-reload
systemctl --user enable --now "$UNIT_NAME"
systemctl --user status "$UNIT_NAME" --no-pager || true
cat <<EOF
Service installed and started. Useful commands:
systemctl --user status $UNIT_NAME
systemctl --user restart $UNIT_NAME
journalctl --user -u $UNIT_NAME -f
⚠ To start on boot without anyone logging in, lingering must be enabled once:
loginctl enable-linger $USER
EOF
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# Bootstrap a complete NodeCG + nodecg-venue-gfx install on a venue machine.
#
# Creates a standalone NodeCG installation (the nodecg npm package extracted
# as the install root — the layout NodeCG itself calls "standalone"; running
# it as a node_modules dependency is still flagged experimental upstream),
# clones this bundle into bundles/, builds it, writes the config files, and
# generates an importable OBS scene collection.
#
# Usage:
# ./install.sh [--dir ~/venue-gfx] [--port 9090] [--obs-password SECRET]
# [--repo <git-url>] [--local <path-to-checkout>] [--yes]
#
# --local uses an existing checkout (copied, not cloned) instead of --repo;
# when run from inside a checkout, --local defaults to that checkout.
# --yes accepts all defaults without prompting.
set -euo pipefail
BUNDLE=nodecg-venue-gfx
DEFAULT_REPO="https://git.dvdrw.dev/kckljajicevo/nodecg-venue-gfx.git"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
dir=""
port=""
obs_password=""
repo=""
local_src=""
assume_yes=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dir) dir="$2"; shift 2 ;;
--port) port="$2"; shift 2 ;;
--obs-password) obs_password="$2"; shift 2 ;;
--repo) repo="$2"; shift 2 ;;
--local) local_src="$2"; shift 2 ;;
--yes|-y) assume_yes=1; shift ;;
--help|-h) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 1 ;;
esac
done
# ── prerequisites ─────────────────────────────────────────────────────────
command -v node >/dev/null || { echo "node not found — install Node.js 20+" >&2; exit 1; }
command -v npm >/dev/null || { echo "npm not found" >&2; exit 1; }
node_major=$(node -p 'process.versions.node.split(".")[0]')
if (( node_major < 20 )); then
echo "Node.js $(node -v) is too old — need 20+" >&2
exit 1
fi
# When run from inside a checkout of the bundle, default to copying it.
if [[ -z $local_src && -z $repo && -f "$SCRIPT_DIR/../package.json" ]]; then
if node -p 'require(process.argv[1]).name' "$SCRIPT_DIR/../package.json" 2>/dev/null | grep -qx "$BUNDLE"; then
local_src="$(cd "$SCRIPT_DIR/.." && pwd)"
fi
fi
ask() { # ask <prompt> <default-var-name> <default-value>
local prompt="$1" var="$2" def="$3" answer
if [[ -n ${!var} ]]; then return; fi
if (( assume_yes )); then printf -v "$var" '%s' "$def"; return; fi
read -r -p "$prompt [$def]: " answer
printf -v "$var" '%s' "${answer:-$def}"
}
ask "Install directory" dir "$HOME/venue-gfx"
ask "NodeCG port" port "9090"
ask "OBS websocket password (empty = auth disabled)" obs_password ""
if [[ -z $local_src ]]; then
ask "Bundle git URL" repo "$DEFAULT_REPO"
fi
dir="${dir/#\~/$HOME}"
echo
echo "Installing into $dir (NodeCG on port $port)"
# ── NodeCG itself (standalone layout: install root = the nodecg package) ──
mkdir -p "$dir"
cd "$dir"
if [[ -f package.json ]] && node -p 'require("./package.json").name' | grep -qx nodecg; then
echo "NodeCG already present — skipping"
else
echo "Downloading NodeCG…"
tarball=$(npm pack nodecg@^2 --silent | tail -n1)
tar xzf "$tarball" --strip-components=1
rm -f "$tarball"
echo "Installing NodeCG dependencies…"
npm install --omit=dev --no-audit --no-fund
fi
mkdir -p bundles cfg obs
# ── the bundle ────────────────────────────────────────────────────────────
if [[ ! -d bundles/$BUNDLE ]]; then
if [[ -n $local_src ]]; then
echo "Copying bundle from $local_src"
mkdir -p "bundles/$BUNDLE"
(cd "$local_src" && git archive HEAD 2>/dev/null || tar cf - --exclude node_modules --exclude .git .) \
| tar xf - -C "bundles/$BUNDLE"
else
echo "Cloning $repo"
git clone "$repo" "bundles/$BUNDLE"
fi
fi
echo "Building the bundle…"
(cd "bundles/$BUNDLE" && npm ci --no-audit --no-fund && npm run build)
# ── config ────────────────────────────────────────────────────────────────
if [[ ! -f cfg/nodecg.json ]]; then
printf '{\n\t"port": %s\n}\n' "$port" > cfg/nodecg.json
echo "Wrote cfg/nodecg.json"
fi
if [[ ! -f cfg/$BUNDLE.json ]]; then
node - "$obs_password" > "cfg/$BUNDLE.json" <<'EOF'
const password = process.argv[2] ?? "";
process.stdout.write(JSON.stringify({
locale: "en",
obs: { enabled: true, url: "ws://127.0.0.1:4455", password },
}, null, "\t") + "\n");
EOF
echo "Wrote cfg/$BUNDLE.json"
fi
# ── OBS scene collection ──────────────────────────────────────────────────
node "bundles/$BUNDLE/scripts/generate-obs-scenes.mjs" \
--config "cfg/$BUNDLE.json" \
--nodecg-url "http://localhost:$port" \
--name VenueGfx \
--out obs/VenueGfx.json
# ── done ──────────────────────────────────────────────────────────────────
cat <<EOF
──────────────────────────────────────────────────────────────────────
Install complete. Start NodeCG with:
cd $dir && node index.js
Dashboard: http://localhost:$port
OBS setup (see bundles/$BUNDLE/docs/OBS-SETUP.md for details):
1. Install exeldro's Move plugin: https://obsproject.com/forum/resources/move.913/
2. Tools → WebSocket Server Settings: enable, port 4455$( [[ -n $obs_password ]] && echo ", set the password you chose" || echo ", disable authentication" )
3. Scene Collection → Import → $dir/obs/VenueGfx.json, then switch to it
4. Replace the "Program Placeholder" source with your real capture
5. Point the Stinger transition at a real video file
6. Settings → Video: 1920×1080 base + output, 60 FPS
7. Dashboard → "OBS setup check" panel → Run checks, fix anything red
To run NodeCG as a service that survives reboots:
bundles/$BUNDLE/scripts/install-service.sh $dir
──────────────────────────────────────────────────────────────────────
EOF
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Update the bundle inside a NodeCG install created by install.sh:
# pull, reinstall deps, rebuild, and restart the service if one is running.
#
# Usage: ./update.sh [install-dir] (default: the install this script is in,
# falling back to ~/venue-gfx)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUNDLE=nodecg-venue-gfx
UNIT_NAME=nodecg-venue-gfx.service
# scripts/ lives at <install>/bundles/<bundle>/scripts — walk up.
candidate="$(cd "$SCRIPT_DIR/../../.." && pwd)"
install_dir="${1:-$candidate}"
[[ -f $install_dir/index.js ]] || install_dir="$HOME/venue-gfx"
[[ -f $install_dir/index.js ]] || {
echo "no NodeCG install found (tried $candidate and ~/venue-gfx)" >&2
exit 1
}
bundle_dir="$install_dir/bundles/$BUNDLE"
echo "Updating $bundle_dir"
git -C "$bundle_dir" pull --ff-only
(cd "$bundle_dir" && npm ci --no-audit --no-fund && npm run build)
if systemctl --user is-active --quiet "$UNIT_NAME" 2>/dev/null; then
echo "Restarting $UNIT_NAME"
systemctl --user restart "$UNIT_NAME"
else
echo "Done. Restart NodeCG to load the new extension code."
fi