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:
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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).`);
|
||||
Executable
+38
@@ -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
|
||||
Executable
+166
@@ -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
|
||||
Executable
+34
@@ -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
|
||||
Reference in New Issue
Block a user