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
+139
View File
@@ -0,0 +1,139 @@
import type NodeCG from "@nodecg/types";
import type {
Ad,
AdState,
BreakSlide,
BundleConfig,
ChoreoConfig,
ObsStatus,
SchedulerState,
TickerConfig,
TickerMessage,
} from "../types";
import { getStrings } from "../i18n";
export interface Replicants {
tickerMessages: NodeCG.ServerReplicant<TickerMessage[]>;
tickerConfig: NodeCG.ServerReplicant<TickerConfig>;
ads: NodeCG.ServerReplicant<Ad[]>;
adState: NodeCG.ServerReplicant<AdState>;
choreoConfig: NodeCG.ServerReplicant<ChoreoConfig>;
schedulerState: NodeCG.ServerReplicant<SchedulerState>;
breakSlide: NodeCG.ServerReplicant<BreakSlide>;
obsStatus: NodeCG.ServerReplicant<ObsStatus>;
}
export function declareReplicants(nodecg: NodeCG.ServerAPI): Replicants {
// First-run seeds follow the configured locale, and `branding` in the
// bundle config can pre-fill the identity-flavored ones.
const config = nodecg.bundleConfig as unknown as BundleConfig;
const seed = getStrings(config?.locale).seed;
const branding = config?.branding ?? {};
// Only fill in defaults when nothing is persisted, so operator edits
// survive NodeCG restarts.
const rep = <T>(name: string, defaultValue: T): NodeCG.ServerReplicant<T> => {
const r = nodecg.Replicant(name) as unknown as NodeCG.ServerReplicant<T>;
if (r.value === undefined) r.value = defaultValue;
return r;
};
const ads = rep<Ad[]>("ads", [
{
id: "sample-qr",
rowText: seed.sampleAdRow,
columnText: seed.sampleAdColumn,
columnImageUrl: null,
qrUrl: branding.sampleQrUrl ?? "https://example.com",
enabled: true,
},
]);
// Migrate ads persisted by older versions: the { text } shape from before
// the rowText/columnText split, and rows without columnImageUrl.
if (
ads.value?.some(
(a) =>
(a as any).rowText === undefined ||
(a as any).columnImageUrl === undefined,
)
) {
ads.value = ads.value.map((a) => {
const legacy = a as any;
return {
id: a.id,
rowText:
legacy.rowText ??
(typeof legacy.text === "string" ? legacy.text : ""),
columnText: legacy.columnText ?? "",
columnImageUrl: legacy.columnImageUrl ?? null,
qrUrl: a.qrUrl,
enabled: a.enabled,
};
});
}
const slideDefaults: BreakSlide = {
heading: branding.breakHeading ?? seed.breakHeading,
nextActName: "",
nextActFrom: "",
logoInverted: false,
logoName: null,
logoHeightPx: 240,
};
const breakSlide = rep<BreakSlide>("breakSlide", slideDefaults);
// Migrate slides persisted before the logo controls existed: fill in any
// missing fields from the defaults, keeping whatever the operator set.
if (
breakSlide.value &&
Object.keys(slideDefaults).some(
(k) => (breakSlide.value as any)[k] === undefined,
)
) {
breakSlide.value = { ...slideDefaults, ...breakSlide.value };
}
return {
ads,
breakSlide,
tickerMessages: rep<TickerMessage[]>("tickerMessages", []),
tickerConfig: rep<TickerConfig>("tickerConfig", {
loopBlurb: branding.tickerBlurb ?? seed.loopBlurb,
separator: "♦",
defaultTtlSeconds: 300,
speedPxPerSec: 120,
}),
adState: rep<AdState>("adState", {
visible: false,
corner: "tr",
adId: null,
}),
choreoConfig: rep<ChoreoConfig>("choreoConfig", {
normalAdIntervalMinutes: 15,
adDurationSeconds: 20,
breakGapSeconds: 45,
breakAdDurationSeconds: 15,
breakAdsPerBurst: 2,
}),
schedulerState: rep<SchedulerState>("schedulerState", {
mode: "normal",
phase: "live",
lastCorner: "bl",
lastAdIndex: -1,
burstRemaining: 0,
nextTransitionAt: null,
}),
obsStatus: rep<ObsStatus>("obsStatus", {
connected: false,
currentScene: null,
lastError: null,
attempts: 0,
}),
};
}