Files
nodecg-venue-gfx/scripts/generate-obs-scenes.mjs
T
dvdrw 4bb64a8ca3 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
2026-07-15 19:30:32 +02:00

301 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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).`);