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
+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).`);