4bb64a8ca3
- 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
301 lines
10 KiB
JavaScript
301 lines
10 KiB
JavaScript
#!/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).`);
|