#!/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 bundle cfg JSON to read scene/transition/source * names from (e.g. ../../cfg/.json) * --out output file (default obs/generated/.json) * --name scene collection name (default "VenueGfx") * --nodecg-url NodeCG base URL (default http://localhost:9090) * --bundle bundle name for graphics URLs (default: package.json) * --stinger absolute path to the stinger video (default: a * placeholder you MUST replace in OBS) * --move-duration Move transition duration (default 700) * --transition-point stinger transition point (default 1200) * --ticker-offset-y 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).`);