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,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).`);
|
||||
Reference in New Issue
Block a user