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,23 @@
|
|||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build
|
||||||
|
- run: npx tsc --noEmit -p src/dashboard/tsconfig.json
|
||||||
|
- run: npx tsc --noEmit -p src/graphics/tsconfig.json
|
||||||
|
- run: npx tsc --noEmit -p src/extension/tsconfig.json
|
||||||
|
- run: node scripts/generate-obs-scenes.mjs --out /tmp/smoke.json
|
||||||
|
- run: node -e "JSON.parse(require('fs').readFileSync('/tmp/smoke.json'))"
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Parcel build outputs (rebuilt from src/) — anchored so src/dashboard etc. stay tracked
|
||||||
|
/dashboard/
|
||||||
|
/graphics/
|
||||||
|
/extension/
|
||||||
|
.parcel-cache/
|
||||||
|
|
||||||
|
# generated OBS scene collections (re-run npm run obs:scenes)
|
||||||
|
/obs/generated/
|
||||||
|
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "@parcel/config-default",
|
||||||
|
"validators": {
|
||||||
|
"*.{ts,tsx}": ["@parcel/validator-typescript"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"@tailwindcss/postcss": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thanks for taking a look!
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
The bundle develops inside a NodeCG install:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir venue-dev && cd venue-dev
|
||||||
|
npm pack nodecg@^2 && tar xzf nodecg-*.tgz --strip-components=1 && rm nodecg-*.tgz
|
||||||
|
npm install --omit=dev
|
||||||
|
mkdir -p bundles cfg && cd bundles
|
||||||
|
git clone https://git.dvdrw.dev/kckljajicevo/nodecg-venue-gfx.git && cd nodecg-venue-gfx
|
||||||
|
npm ci
|
||||||
|
npm run dev # watch + auto-restarting NodeCG on :9090
|
||||||
|
```
|
||||||
|
|
||||||
|
(`scripts/install.sh --local .` automates the same thing.)
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- `dashboard/`, `graphics/`, `extension/` are build outputs — never edit or
|
||||||
|
commit them; sources live in `src/`.
|
||||||
|
- Geometry shared with OBS lives only in `src/shared/obs-geometry.mjs`
|
||||||
|
(plain JS + `.d.mts` declarations — keep both in sync).
|
||||||
|
- UI strings live in `src/i18n/` — no literals in panel components. New
|
||||||
|
locales: copy `en.ts`, register in `index.ts`, extend the enum in
|
||||||
|
`configschema.json`.
|
||||||
|
- `npm run build` and the TypeScript checks must stay green:
|
||||||
|
`npx tsc --noEmit -p src/{dashboard,graphics,extension}/tsconfig.json`.
|
||||||
|
- Changes to the generated OBS collection shape should be validated by a real
|
||||||
|
import into OBS ≥ 31.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPL-3.0 — contributions are accepted under the same license.
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# nodecg-venue-gfx
|
||||||
|
|
||||||
|
Broadcast graphics for a venue screen, as a [NodeCG](https://nodecg.dev) 2
|
||||||
|
bundle: an always-on scrolling ticker with operator-added TTL messages,
|
||||||
|
corner-alternating ads that shrink the live program into a corner, a break
|
||||||
|
announcement slide, and automated + manual OBS scene choreography over
|
||||||
|
obs-websocket. Built for festivals and concerts; generic enough for any
|
||||||
|
single-screen show.
|
||||||
|
|
||||||
|
- All on-screen text is edited live from the dashboard
|
||||||
|
- Support for multiple locales
|
||||||
|
- OBS setup is generated. One script emits an importable scene collection, and a
|
||||||
|
dashboard panel verifies a live OBS against the exact geometry the graphics
|
||||||
|
render from.
|
||||||
|
- If OBS dies mid-show, the scheduler keeps running and re-applies the right
|
||||||
|
scene the moment OBS comes back. If NodeCG dies, restart it and it comes back
|
||||||
|
in live mode with all content intact.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
**Fresh venue machine** (installs NodeCG + this bundle + config + OBS scene
|
||||||
|
collection in one go):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSLO https://git.dvdrw.dev/kckljajicevo/nodecg-venue-gfx/raw/branch/master/scripts/install.sh
|
||||||
|
bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Into an existing NodeCG install:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd <nodecg>/bundles
|
||||||
|
git clone https://git.dvdrw.dev/kckljajicevo/nodecg-venue-gfx.git
|
||||||
|
cd nodecg-venue-gfx
|
||||||
|
npm ci && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
1. Write `cfg/nodecg-venue-gfx.json` (see
|
||||||
|
[docs/examples/cfg.example.json](docs/examples/cfg.example.json)).
|
||||||
|
2. Set up OBS: `npm run obs:scenes` -> import -> done. Details in
|
||||||
|
[docs/OBS-SETUP.md](docs/OBS-SETUP.md).
|
||||||
|
3. Start NodeCG (`node index.js` from the install root) and open the
|
||||||
|
dashboard at <http://localhost:9090>.
|
||||||
|
|
||||||
|
To run as a service that survives reboots: `scripts/install-service.sh`
|
||||||
|
(systemd user unit). To update later: `scripts/update.sh`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`cfg/nodecg-venue-gfx.json` in the NodeCG install, validated against
|
||||||
|
[configschema.json](configschema.json). Everything is optional:
|
||||||
|
|
||||||
|
| Key | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `locale` | `"en"` | Dashboard language (`"en"` or `"sr"`) |
|
||||||
|
| `branding.tickerBlurb` | `"Welcome ♪"` | First-run seed for the recurring ticker blurb |
|
||||||
|
| `branding.breakHeading` | `"Stay tuned — there's more!"` | First-run seed for the break-slide heading |
|
||||||
|
| `branding.sampleQrUrl` | `"https://example.com"` | First-run seed for the sample ad's QR URL |
|
||||||
|
| `obs.enabled` | `false` | Connect to OBS and drive scenes |
|
||||||
|
| `obs.url` | `"ws://127.0.0.1:4455"` | obs-websocket URL |
|
||||||
|
| `obs.password` | `""` | obs-websocket password |
|
||||||
|
| `obs.scenes.*` | `MAIN`, `MAIN_AD_TR`, … | Names of the six driven scenes |
|
||||||
|
| `obs.transitions.*` | `Move`, `Stinger` | Names of the two transitions |
|
||||||
|
| `obs.sources.*` | `TickerOverlay`, … | Names of the shared sources/nested scenes (for the generator + verifier) |
|
||||||
|
|
||||||
|
`branding.*` values only seed the very first run — after that, everything is
|
||||||
|
edited from the dashboard and persists in NodeCG's database. Set
|
||||||
|
`obs.enabled: false` to develop without OBS; everything else keeps working.
|
||||||
|
|
||||||
|
## Dashboard panels
|
||||||
|
|
||||||
|
- **Ticker messages** — ticker settings (loop blurb, separator, default TTL,
|
||||||
|
speed) and message management; each active message shows a depleting fill
|
||||||
|
for its remaining time, with renew/remove/inline-edit.
|
||||||
|
- **Ads** — CRUD for ads (strip text, column text, column image from the
|
||||||
|
Assets dialog, QR URL, enabled), with a live QR preview per row.
|
||||||
|
- **Choreography / OBS** — connection status, mode/phase badges, countdown to
|
||||||
|
the next automatic transition, timing configuration, break-slide content,
|
||||||
|
and manual controls: show ad now, skip ad, break toggle.
|
||||||
|
- **OBS setup check** — read-only verification of the live OBS against the
|
||||||
|
expected scenes/sources/transforms/transitions, with concrete fixes.
|
||||||
|
|
||||||
|
Images (ad backgrounds, column images, break background, act logos) are
|
||||||
|
uploaded through NodeCG's standard **Assets** dialog.
|
||||||
|
|
||||||
|
## Graphics (1920×1080)
|
||||||
|
|
||||||
|
| URL | Default OBS source | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `graphics/ticker.html` | `TickerOverlay` | Bottom 96 px scrolling ticker |
|
||||||
|
| `graphics/ad.html` | `AdOverlay` | QR + headline in the gamma-shaped cutout |
|
||||||
|
| `graphics/break.html` | `BreakSlide` | Break announcement slide (ambient motion) |
|
||||||
|
|
||||||
|
The ticker uses a segment-queue marquee: loop content is snapshotted per
|
||||||
|
revolution, so adding/removing messages mid-scroll never causes visual jumps.
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
- **Colors**: edit the `THEME LAB` block in
|
||||||
|
[`src/graphics/index.css`](src/graphics/index.css) — every brand color the
|
||||||
|
graphics use is a token there.
|
||||||
|
- **Fonts**: the `FONT LAB` block right above it (Margarine is bundled as the
|
||||||
|
default; drop a `.ttf`/`.woff2` into `src/graphics/fonts/` to swap).
|
||||||
|
- **Geometry** (canvas size, the 70% shrink, ad cutout, OBS transforms):
|
||||||
|
[`src/shared/obs-geometry.mjs`](src/shared/obs-geometry.mjs) — one module
|
||||||
|
shared by the graphics, the scene-collection generator, and the verify
|
||||||
|
panel, so they can never disagree. v1 is fixed at 1920×1080.
|
||||||
|
|
||||||
|
## Adding a language
|
||||||
|
|
||||||
|
1. Copy `src/i18n/en.ts` to `src/i18n/<code>.ts` and translate (the `Strings`
|
||||||
|
type keeps you honest).
|
||||||
|
2. Register it in `src/i18n/index.ts` (`locales`).
|
||||||
|
3. Add the code to the `locale` enum in `configschema.json`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev # parcel watch + nodemon restarting NodeCG
|
||||||
|
npm run build # one-shot production build
|
||||||
|
npm run obs:scenes # regenerate the OBS scene collection
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev mode assumes the bundle lives inside a NodeCG install
|
||||||
|
(`bundles/nodecg-venue-gfx`); `nodemon` runs `node ../../index.js`.
|
||||||
|
|
||||||
|
Rules of thumb:
|
||||||
|
|
||||||
|
- **Graphics/dashboard** changes: rebuild (or let the watcher do it), then
|
||||||
|
refresh the page / OBS browser source. No server restart.
|
||||||
|
- **Extension** (`src/extension/`), **types**, or **package.json** changes:
|
||||||
|
rebuild **and restart NodeCG** — plain `node index.js` never rebuilds
|
||||||
|
anything.
|
||||||
|
- Graphics are fixed 1920×1080 canvases — judge them in OBS or a fullscreen
|
||||||
|
1080p tab; smaller windows show a crop.
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
- The scheduler is a state machine over `(mode: normal|break, phase: live|ad)`
|
||||||
|
with exactly one pending timer; OBS calls are fire-and-forget, so an
|
||||||
|
unreachable OBS never stalls the choreography.
|
||||||
|
- Corner alternation and ad round-robin persist across restarts
|
||||||
|
(`schedulerState` replicant). On startup the bundle always returns to
|
||||||
|
(normal, live) + the main scene.
|
||||||
|
- Replicant defaults are only applied when nothing is persisted, so operator
|
||||||
|
edits survive restarts.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[AGPL-3.0](LICENSE)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"locale": {
|
||||||
|
"description": "Dashboard language",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["en", "sr"],
|
||||||
|
"default": "en"
|
||||||
|
},
|
||||||
|
"branding": {
|
||||||
|
"description": "First-run replicant seeds — only applied when nothing is persisted yet; everything is editable from the dashboard afterwards",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"tickerBlurb": {
|
||||||
|
"description": "Recurring ticker blurb shown at every loop seam",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"breakHeading": {
|
||||||
|
"description": "Default heading on the break slide",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sampleQrUrl": {
|
||||||
|
"description": "URL encoded in the sample ad's QR code",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"obs": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"description": "Connect to OBS and drive scene switches",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"description": "obs-websocket URL",
|
||||||
|
"type": "string",
|
||||||
|
"default": "ws://127.0.0.1:4455"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"description": "obs-websocket password (empty when authentication is disabled)",
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"scenes": {
|
||||||
|
"description": "Names of the six scenes NodeCG switches between",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"main": { "type": "string", "default": "MAIN" },
|
||||||
|
"mainAdTr": { "type": "string", "default": "MAIN_AD_TR" },
|
||||||
|
"mainAdBl": { "type": "string", "default": "MAIN_AD_BL" },
|
||||||
|
"break": { "type": "string", "default": "BREAK" },
|
||||||
|
"breakAdTr": { "type": "string", "default": "BREAK_AD_TR" },
|
||||||
|
"breakAdBl": { "type": "string", "default": "BREAK_AD_BL" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transitions": {
|
||||||
|
"description": "Names of the transitions in the OBS transition dock",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"default": {
|
||||||
|
"description": "Selected before ad shrink/hop switches (the Move transition)",
|
||||||
|
"type": "string",
|
||||||
|
"default": "Move"
|
||||||
|
},
|
||||||
|
"break": {
|
||||||
|
"description": "Selected before MAIN↔BREAK toggles (the stinger)",
|
||||||
|
"type": "string",
|
||||||
|
"default": "Stinger"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"description": "Names of the shared sources/nested scenes, used by the OBS setup verifier and the scene-collection generator",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"adOverlay": { "type": "string", "default": "AdOverlay" },
|
||||||
|
"ticker": { "type": "string", "default": "TickerOverlay" },
|
||||||
|
"breakSlide": { "type": "string", "default": "BreakSlide" },
|
||||||
|
"programScene": { "type": "string", "default": "PROGRAM" },
|
||||||
|
"breakScene": { "type": "string", "default": "BREAK_SLIDE" },
|
||||||
|
"programPlaceholder": { "type": "string", "default": "Program Placeholder" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# systemd user service for the NodeCG install driving the venue graphics.
|
||||||
|
# Installed by scripts/install-service.sh (which fills in the install dir);
|
||||||
|
# runs as a *user* service so it shares the session with OBS and never needs
|
||||||
|
# root. Remember `loginctl enable-linger <user>` for start-on-boot without a
|
||||||
|
# login.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=NodeCG venue graphics (nodecg-venue-gfx)
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=__INSTALL_DIR__
|
||||||
|
ExecStart=/usr/bin/env node index.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# OBS setup
|
||||||
|
|
||||||
|
Target: one 1920×1080 venue screen driven by OBS (no stream required).
|
||||||
|
NodeCG switches scenes over obs-websocket; exeldro's **Move** plugin animates
|
||||||
|
the program shrink between scenes; a **Stinger** covers the MAIN<->BREAK
|
||||||
|
toggle.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- OBS Studio **31 or newer** (the generated scene collection uses the current
|
||||||
|
collection format).
|
||||||
|
- **Move plugin** by exeldro: <https://obsproject.com/forum/resources/move.913/>
|
||||||
|
- **WebSocket server**: Tools → WebSocket Server Settings → Enable, port
|
||||||
|
`4455`, set a password. Put the same password in `cfg/nodecg-venue-gfx.json`
|
||||||
|
(`obs.password`).
|
||||||
|
|
||||||
|
## The fast path: import the generated scene collection
|
||||||
|
|
||||||
|
From the bundle directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run obs:scenes # writes obs/generated/VenueGfx.json
|
||||||
|
# options: node scripts/generate-obs-scenes.mjs --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in OBS: **Scene Collection → Import**, pick the file, switch to the new
|
||||||
|
collection, and finish up:
|
||||||
|
|
||||||
|
1. Open the `PROGRAM` scene and replace the **Program Placeholder** color
|
||||||
|
source with your real feed (camera, capture card, window capture, …),
|
||||||
|
1920×1080 at (0, 0). Everything else references this scene, so this is the
|
||||||
|
only place the feed lives.
|
||||||
|
2. Point the **Stinger** transition at a real video file (transition dock →
|
||||||
|
double-click Stinger). If you passed `--stinger /abs/path.webm` to the
|
||||||
|
generator this is already done.
|
||||||
|
3. Settings → Video: Base and Output **1920×1080**, **60 FPS** (the ticker
|
||||||
|
scrolls; 30 looks choppy). Encoder/output settings are yours to pick —
|
||||||
|
the graphics don't care.
|
||||||
|
4. Open the dashboard's **OBS setup check** panel and hit *Run checks* —
|
||||||
|
fix anything red, re-run until it's green (warnings are informational).
|
||||||
|
|
||||||
|
The generator reads scene/transition/source names from the bundle cfg
|
||||||
|
(`--config ../../cfg/nodecg-venue-gfx.json`), so a renamed setup stays
|
||||||
|
consistent across the graphics, the generated collection, and the verifier —
|
||||||
|
they all share one geometry module (`src/shared/obs-geometry.mjs`).
|
||||||
|
|
||||||
|
## How the scenes are built (what the generator emits)
|
||||||
|
|
||||||
|
Two **nested scenes** hold the content:
|
||||||
|
|
||||||
|
| Scene | Items (bottom → top) |
|
||||||
|
| ------------- | ----------------------------------------------- |
|
||||||
|
| `PROGRAM` | Program Placeholder (your feed), `TickerOverlay` |
|
||||||
|
| `BREAK_SLIDE` | `BreakSlide` browser source, `TickerOverlay` |
|
||||||
|
|
||||||
|
The ticker lives *inside* the content scene on purpose: when the program
|
||||||
|
shrinks to 70%, the ticker shrinks with it, and the ad graphic owns the full
|
||||||
|
L-shaped gap down to the screen edges.
|
||||||
|
|
||||||
|
Six **driven scenes** are what NodeCG switches between. Each stacks, bottom →
|
||||||
|
top: `AdOverlay` (fullscreen, identical transform everywhere) → the nested
|
||||||
|
content scene:
|
||||||
|
|
||||||
|
| Scene | Content scene | Content transform |
|
||||||
|
| ------------- | ------------- | ----------------- |
|
||||||
|
| `MAIN` | `PROGRAM` | (0, 0) ×1.0 |
|
||||||
|
| `MAIN_AD_TR` | `PROGRAM` | (576, 0) ×0.7 |
|
||||||
|
| `MAIN_AD_BL` | `PROGRAM` | (0, 324) ×0.7 |
|
||||||
|
| `BREAK` | `BREAK_SLIDE` | (0, 0) ×1.0 |
|
||||||
|
| `BREAK_AD_TR` | `BREAK_SLIDE` | (576, 0) ×0.7 |
|
||||||
|
| `BREAK_AD_BL` | `BREAK_SLIDE` | (0, 324) ×0.7 |
|
||||||
|
|
||||||
|
Why `AdOverlay` sits at the bottom of **every** scene: under the fullscreen
|
||||||
|
content it's invisible, but because it exists on both sides of every ad
|
||||||
|
transition with an identical transform, Move leaves it perfectly static — the
|
||||||
|
shrinking program physically *uncovers* the ad that was already painted
|
||||||
|
underneath. The ad graphic only paints the gamma cutout, which the
|
||||||
|
corner-parked program never overlaps.
|
||||||
|
|
||||||
|
### Transitions
|
||||||
|
|
||||||
|
- **Move** (`obs.transitions.default`, default name `Move`) — all ad
|
||||||
|
switches: the shrink and the corner-to-corner hops. Duration 700–800 ms.
|
||||||
|
With `AdOverlay` matched in every scene, only the content scene ever moves.
|
||||||
|
- **Stinger** (`obs.transitions.break`, default name `Stinger`) — only for
|
||||||
|
MAIN↔BREAK. Set the Transition Point to the frame where the video fully
|
||||||
|
covers the screen (the generator defaults to 1200 ms).
|
||||||
|
|
||||||
|
NodeCG selects the right transition over the websocket before every switch,
|
||||||
|
so whichever is "active" in the dock doesn't matter. If a named transition is
|
||||||
|
missing, the bundle logs a warning and switches with whatever is active —
|
||||||
|
nothing breaks. Do **not** set per-scene Transition Overrides on `BREAK`: the
|
||||||
|
bundle already picks the stinger only for the break toggle; an override would
|
||||||
|
also play it on every return from a break-ad burst.
|
||||||
|
|
||||||
|
obs-websocket cannot *create* transitions — that's exactly why the generator
|
||||||
|
exists. It also can't read settings of inactive transitions, which is why the
|
||||||
|
verify panel reports those as "skip: spot-check in OBS".
|
||||||
|
|
||||||
|
## Manual setup (appendix)
|
||||||
|
|
||||||
|
If you'd rather build the collection by hand, recreate the tables above:
|
||||||
|
|
||||||
|
1. Create the three **browser sources** once (Add → Browser), then reference
|
||||||
|
the *same instances* everywhere else via Add → **Add Existing** — Move
|
||||||
|
matches sources by name, duplicates would break the animation:
|
||||||
|
- `TickerOverlay` → `http://localhost:9090/bundles/nodecg-venue-gfx/graphics/ticker.html`, 1920×1080
|
||||||
|
- `AdOverlay` → `…/graphics/ad.html`, 1920×1080
|
||||||
|
- `BreakSlide` → `…/graphics/break.html`, 1920×1080
|
||||||
|
|
||||||
|
For all three: **uncheck "Shutdown source when not visible"** (keeps the
|
||||||
|
ticker scrolling and replicant connections alive across switches).
|
||||||
|
2. Build the two nested scenes, then the six driven scenes per the tables.
|
||||||
|
Set transforms precisely via right-click → Transform → Edit Transform; at
|
||||||
|
×0.7 the content is 1344×756. Keep every transform top-left aligned.
|
||||||
|
3. Create the two transitions in the Scene Transitions dock.
|
||||||
|
4. Run the **OBS setup check** panel until green — it compares the live OBS
|
||||||
|
state against the same geometry the graphics render from, normalizing
|
||||||
|
alignment and allowing ±5 px, so a hand-built setup verifies fine.
|
||||||
|
|
||||||
|
All names (scenes, transitions, sources) are configurable in
|
||||||
|
`cfg/nodecg-venue-gfx.json`; the verifier and generator follow the cfg.
|
||||||
|
|
||||||
|
## What NodeCG does at runtime
|
||||||
|
|
||||||
|
The bundle only ever selects a transition (`SetCurrentSceneTransition`) and
|
||||||
|
switches scenes (`SetCurrentProgramScene`) — all animation lives in OBS/Move,
|
||||||
|
which is the reliable path (remotely toggling Move Source filters has a known
|
||||||
|
settings-refresh bug over websocket).
|
||||||
|
|
||||||
|
- Every `normalAdIntervalMinutes`: `MAIN` → `MAIN_AD_TR`/`_BL` (alternating
|
||||||
|
corners) for `adDurationSeconds`, then back.
|
||||||
|
- Break toggled on: → `BREAK`; every `breakGapSeconds` a burst of
|
||||||
|
`breakAdsPerBurst` ads, each `breakAdDurationSeconds` long, hopping
|
||||||
|
diagonally between corners.
|
||||||
|
- Manual override buttons live on the "Choreography / OBS" dashboard panel.
|
||||||
|
- If OBS restarts, NodeCG reconnects and immediately re-applies the scene the
|
||||||
|
state machine expects.
|
||||||
|
|
||||||
|
If during break ads the *camera* should shrink into the corner instead of the
|
||||||
|
break slide, put `PROGRAM` instead of `BREAK_SLIDE` into
|
||||||
|
`BREAK_AD_TR`/`BREAK_AD_BL` — an OBS-only change, no code involved.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"locale": "en",
|
||||||
|
"branding": {
|
||||||
|
"tickerBlurb": "My Venue ♪",
|
||||||
|
"breakHeading": "Stay tuned — there's more!",
|
||||||
|
"sampleQrUrl": "https://example.com/my-venue"
|
||||||
|
},
|
||||||
|
"obs": {
|
||||||
|
"enabled": true,
|
||||||
|
"url": "ws://127.0.0.1:4455",
|
||||||
|
"password": "changeme"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"watch": ["src/extension/*", "src/types/*"],
|
||||||
|
"exec": "npm run build:extension && node ../../index.js",
|
||||||
|
"ext": "js,ts,json,yaml",
|
||||||
|
"delay": 500,
|
||||||
|
"ignoreRoot": [
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
".nyc_output",
|
||||||
|
".parcel-cache",
|
||||||
|
"coverage",
|
||||||
|
"extension",
|
||||||
|
"graphics",
|
||||||
|
"dashboard"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+12729
File diff suppressed because it is too large
Load Diff
+182
@@ -0,0 +1,182 @@
|
|||||||
|
{
|
||||||
|
"name": "nodecg-venue-gfx",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Venue-screen broadcast graphics for NodeCG: message ticker, QR ad overlays, break slide, and automated OBS scene choreography",
|
||||||
|
"homepage": "https://git.dvdrw.dev/kckljajicevo/nodecg-venue-gfx",
|
||||||
|
"author": {
|
||||||
|
"name": "dvdrw",
|
||||||
|
"email": "admin@dvdrw.dev",
|
||||||
|
"url": "https://dvdrw.dev"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dashboard",
|
||||||
|
"graphics",
|
||||||
|
"extension.js",
|
||||||
|
"extension"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"nodecg-bundle"
|
||||||
|
],
|
||||||
|
"nodecg": {
|
||||||
|
"compatibleRange": "^2.0.0",
|
||||||
|
"assetCategories": [
|
||||||
|
{
|
||||||
|
"name": "ad-bg-tr",
|
||||||
|
"title": "Ad background — top right",
|
||||||
|
"allowedTypes": [
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"webp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ad-bg-bl",
|
||||||
|
"title": "Ad background — bottom left",
|
||||||
|
"allowedTypes": [
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"webp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ad-column",
|
||||||
|
"title": "Ad — column image",
|
||||||
|
"allowedTypes": [
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"webp",
|
||||||
|
"gif"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "break-bg",
|
||||||
|
"title": "Break — background image",
|
||||||
|
"allowedTypes": [
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"webp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "break-logo",
|
||||||
|
"title": "Break — act logo",
|
||||||
|
"allowedTypes": [
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"webp",
|
||||||
|
"svg"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dashboardPanels": [
|
||||||
|
{
|
||||||
|
"name": "ticker-panel",
|
||||||
|
"title": "Ticker messages",
|
||||||
|
"width": 3,
|
||||||
|
"file": "ticker-panel.html",
|
||||||
|
"headerColor": "#525F78"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ads-panel",
|
||||||
|
"title": "Ads",
|
||||||
|
"width": 3,
|
||||||
|
"file": "ads-panel.html",
|
||||||
|
"headerColor": "#525F78"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "choreo-panel",
|
||||||
|
"title": "Choreography / OBS",
|
||||||
|
"width": 3,
|
||||||
|
"file": "choreo-panel.html",
|
||||||
|
"headerColor": "#8B2635"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "obs-check-panel",
|
||||||
|
"title": "OBS setup check",
|
||||||
|
"width": 3,
|
||||||
|
"file": "obs-check-panel.html",
|
||||||
|
"headerColor": "#8B2635"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"graphics": [
|
||||||
|
{
|
||||||
|
"file": "ticker.html",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"singleInstance": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "ad.html",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"singleInstance": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "break.html",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"singleInstance": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.5%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node scripts/build.mjs --all",
|
||||||
|
"build:extension": "node scripts/build.mjs --extension",
|
||||||
|
"watch": "node scripts/build.mjs --all --watch",
|
||||||
|
"watch:browser": "node scripts/build.mjs --dashboard --graphics --watch",
|
||||||
|
"obs:scenes": "node scripts/generate-obs-scenes.mjs",
|
||||||
|
"dev": "concurrently --kill-others \"npm run watch:browser\" \"nodemon\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.0.4",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"obs-websocket-js": "^5.0.6",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"tailwindcss": "^4.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.1.3",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@nodecg/types": "^2.8.0",
|
||||||
|
"@parcel/core": "^2.13.3",
|
||||||
|
"@parcel/config-default": "^2.13.3",
|
||||||
|
"@parcel/reporter-cli": "^2.13.3",
|
||||||
|
"@parcel/validator-typescript": "^2.13.3",
|
||||||
|
"glob": "^10.2.7",
|
||||||
|
"nodemon": "^3",
|
||||||
|
"concurrently": "^9"
|
||||||
|
},
|
||||||
|
"license": "AGPL-3.0",
|
||||||
|
"trustedDependencies": [
|
||||||
|
"@parcel/watcher",
|
||||||
|
"@swc/core",
|
||||||
|
"lmdb",
|
||||||
|
"msgpackr-extract"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.dvdrw.dev/kckljajicevo/nodecg-venue-gfx.git"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Parcel build script
|
||||||
|
* See https://parceljs.org/features/parcel-api/ for more info
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Native
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { argv } from 'process';
|
||||||
|
|
||||||
|
// Packages
|
||||||
|
import { glob } from 'glob';
|
||||||
|
import { Parcel } from '@parcel/core';
|
||||||
|
|
||||||
|
// Ours
|
||||||
|
import pjson from '../package.json' with { type: 'json' };
|
||||||
|
|
||||||
|
const buildAll = argv.includes('--all');
|
||||||
|
const buildExtension = argv.includes('--extension') || buildAll;
|
||||||
|
const buildDashboard = argv.includes('--dashboard') || buildAll;
|
||||||
|
const buildGraphics = argv.includes('--graphics') || buildAll;
|
||||||
|
|
||||||
|
const bundlers = new Set();
|
||||||
|
const commonBrowserTargetProps = {
|
||||||
|
engines: {
|
||||||
|
browsers: ['last 5 Chrome versions'],
|
||||||
|
},
|
||||||
|
context: 'browser',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (buildDashboard) {
|
||||||
|
bundlers.add(
|
||||||
|
new Parcel({
|
||||||
|
entries: glob.sync('src/dashboard/**/*.html'),
|
||||||
|
targets: {
|
||||||
|
default: {
|
||||||
|
...commonBrowserTargetProps,
|
||||||
|
distDir: 'dashboard',
|
||||||
|
publicUrl: `/bundles/${pjson.name}/dashboard`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: '@parcel/config-default',
|
||||||
|
additionalReporters: [
|
||||||
|
{
|
||||||
|
packageName: '@parcel/reporter-cli',
|
||||||
|
resolveFrom: fileURLToPath(import.meta.url),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buildGraphics) {
|
||||||
|
bundlers.add(
|
||||||
|
new Parcel({
|
||||||
|
entries: glob.sync('src/graphics/**/*.html'),
|
||||||
|
targets: {
|
||||||
|
default: {
|
||||||
|
...commonBrowserTargetProps,
|
||||||
|
distDir: 'graphics',
|
||||||
|
publicUrl: `/bundles/${pjson.name}/graphics`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: '@parcel/config-default',
|
||||||
|
additionalReporters: [
|
||||||
|
{
|
||||||
|
packageName: '@parcel/reporter-cli',
|
||||||
|
resolveFrom: fileURLToPath(import.meta.url),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buildExtension) {
|
||||||
|
bundlers.add(
|
||||||
|
new Parcel({
|
||||||
|
entries: 'src/extension/index.ts',
|
||||||
|
targets: {
|
||||||
|
default: {
|
||||||
|
context: 'node',
|
||||||
|
distDir: 'extension',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: '@parcel/config-default',
|
||||||
|
additionalReporters: [
|
||||||
|
{
|
||||||
|
packageName: '@parcel/reporter-cli',
|
||||||
|
resolveFrom: fileURLToPath(import.meta.url),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (argv.includes('--watch')) {
|
||||||
|
const watchPromises = [];
|
||||||
|
for (const bundler of bundlers.values()) {
|
||||||
|
watchPromises.push(
|
||||||
|
bundler.watch((err) => {
|
||||||
|
if (err) {
|
||||||
|
// fatal error
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(watchPromises);
|
||||||
|
} else {
|
||||||
|
const buildPromises = [];
|
||||||
|
for (const bundler of bundlers.values()) {
|
||||||
|
buildPromises.push(bundler.run());
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(buildPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Bundle build completed successfully');
|
||||||
|
} catch (_) {
|
||||||
|
// the reporter-cli package will handle printing errors to the user
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const timers = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A standard debounce, but uses a string `name` as the key instead of the callback.
|
||||||
|
*/
|
||||||
|
export default function (name, callback, duration = 500) {
|
||||||
|
const existing = timers.get(name);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
timers.set(
|
||||||
|
name,
|
||||||
|
setTimeout(() => {
|
||||||
|
timers.delete(name);
|
||||||
|
callback();
|
||||||
|
}, duration),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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).`);
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install and enable the systemd user service for a NodeCG install created
|
||||||
|
# by install.sh.
|
||||||
|
#
|
||||||
|
# Usage: ./install-service.sh [install-dir] (default: ~/venue-gfx)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
UNIT_NAME=nodecg-venue-gfx.service
|
||||||
|
TEMPLATE="$SCRIPT_DIR/../deploy/$UNIT_NAME"
|
||||||
|
|
||||||
|
install_dir="${1:-$HOME/venue-gfx}"
|
||||||
|
install_dir="$(cd "${install_dir/#\~/$HOME}" && pwd)"
|
||||||
|
|
||||||
|
[[ -f $install_dir/index.js ]] || {
|
||||||
|
echo "$install_dir does not look like a NodeCG install (no index.js)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$HOME/.config/systemd/user"
|
||||||
|
sed "s|__INSTALL_DIR__|$install_dir|" "$TEMPLATE" \
|
||||||
|
> "$HOME/.config/systemd/user/$UNIT_NAME"
|
||||||
|
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now "$UNIT_NAME"
|
||||||
|
systemctl --user status "$UNIT_NAME" --no-pager || true
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Service installed and started. Useful commands:
|
||||||
|
systemctl --user status $UNIT_NAME
|
||||||
|
systemctl --user restart $UNIT_NAME
|
||||||
|
journalctl --user -u $UNIT_NAME -f
|
||||||
|
|
||||||
|
⚠ To start on boot without anyone logging in, lingering must be enabled once:
|
||||||
|
loginctl enable-linger $USER
|
||||||
|
EOF
|
||||||
Executable
+166
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bootstrap a complete NodeCG + nodecg-venue-gfx install on a venue machine.
|
||||||
|
#
|
||||||
|
# Creates a standalone NodeCG installation (the nodecg npm package extracted
|
||||||
|
# as the install root — the layout NodeCG itself calls "standalone"; running
|
||||||
|
# it as a node_modules dependency is still flagged experimental upstream),
|
||||||
|
# clones this bundle into bundles/, builds it, writes the config files, and
|
||||||
|
# generates an importable OBS scene collection.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./install.sh [--dir ~/venue-gfx] [--port 9090] [--obs-password SECRET]
|
||||||
|
# [--repo <git-url>] [--local <path-to-checkout>] [--yes]
|
||||||
|
#
|
||||||
|
# --local uses an existing checkout (copied, not cloned) instead of --repo;
|
||||||
|
# when run from inside a checkout, --local defaults to that checkout.
|
||||||
|
# --yes accepts all defaults without prompting.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUNDLE=nodecg-venue-gfx
|
||||||
|
DEFAULT_REPO="https://git.dvdrw.dev/kckljajicevo/nodecg-venue-gfx.git"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
dir=""
|
||||||
|
port=""
|
||||||
|
obs_password=""
|
||||||
|
repo=""
|
||||||
|
local_src=""
|
||||||
|
assume_yes=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dir) dir="$2"; shift 2 ;;
|
||||||
|
--port) port="$2"; shift 2 ;;
|
||||||
|
--obs-password) obs_password="$2"; shift 2 ;;
|
||||||
|
--repo) repo="$2"; shift 2 ;;
|
||||||
|
--local) local_src="$2"; shift 2 ;;
|
||||||
|
--yes|-y) assume_yes=1; shift ;;
|
||||||
|
--help|-h) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
*) echo "unknown option: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── prerequisites ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
command -v node >/dev/null || { echo "node not found — install Node.js 20+" >&2; exit 1; }
|
||||||
|
command -v npm >/dev/null || { echo "npm not found" >&2; exit 1; }
|
||||||
|
|
||||||
|
node_major=$(node -p 'process.versions.node.split(".")[0]')
|
||||||
|
if (( node_major < 20 )); then
|
||||||
|
echo "Node.js $(node -v) is too old — need 20+" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# When run from inside a checkout of the bundle, default to copying it.
|
||||||
|
if [[ -z $local_src && -z $repo && -f "$SCRIPT_DIR/../package.json" ]]; then
|
||||||
|
if node -p 'require(process.argv[1]).name' "$SCRIPT_DIR/../package.json" 2>/dev/null | grep -qx "$BUNDLE"; then
|
||||||
|
local_src="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
ask() { # ask <prompt> <default-var-name> <default-value>
|
||||||
|
local prompt="$1" var="$2" def="$3" answer
|
||||||
|
if [[ -n ${!var} ]]; then return; fi
|
||||||
|
if (( assume_yes )); then printf -v "$var" '%s' "$def"; return; fi
|
||||||
|
read -r -p "$prompt [$def]: " answer
|
||||||
|
printf -v "$var" '%s' "${answer:-$def}"
|
||||||
|
}
|
||||||
|
|
||||||
|
ask "Install directory" dir "$HOME/venue-gfx"
|
||||||
|
ask "NodeCG port" port "9090"
|
||||||
|
ask "OBS websocket password (empty = auth disabled)" obs_password ""
|
||||||
|
if [[ -z $local_src ]]; then
|
||||||
|
ask "Bundle git URL" repo "$DEFAULT_REPO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
dir="${dir/#\~/$HOME}"
|
||||||
|
echo
|
||||||
|
echo "Installing into $dir (NodeCG on port $port)"
|
||||||
|
|
||||||
|
# ── NodeCG itself (standalone layout: install root = the nodecg package) ──
|
||||||
|
|
||||||
|
mkdir -p "$dir"
|
||||||
|
cd "$dir"
|
||||||
|
|
||||||
|
if [[ -f package.json ]] && node -p 'require("./package.json").name' | grep -qx nodecg; then
|
||||||
|
echo "NodeCG already present — skipping"
|
||||||
|
else
|
||||||
|
echo "Downloading NodeCG…"
|
||||||
|
tarball=$(npm pack nodecg@^2 --silent | tail -n1)
|
||||||
|
tar xzf "$tarball" --strip-components=1
|
||||||
|
rm -f "$tarball"
|
||||||
|
echo "Installing NodeCG dependencies…"
|
||||||
|
npm install --omit=dev --no-audit --no-fund
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p bundles cfg obs
|
||||||
|
|
||||||
|
# ── the bundle ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if [[ ! -d bundles/$BUNDLE ]]; then
|
||||||
|
if [[ -n $local_src ]]; then
|
||||||
|
echo "Copying bundle from $local_src…"
|
||||||
|
mkdir -p "bundles/$BUNDLE"
|
||||||
|
(cd "$local_src" && git archive HEAD 2>/dev/null || tar cf - --exclude node_modules --exclude .git .) \
|
||||||
|
| tar xf - -C "bundles/$BUNDLE"
|
||||||
|
else
|
||||||
|
echo "Cloning $repo…"
|
||||||
|
git clone "$repo" "bundles/$BUNDLE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building the bundle…"
|
||||||
|
(cd "bundles/$BUNDLE" && npm ci --no-audit --no-fund && npm run build)
|
||||||
|
|
||||||
|
# ── config ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if [[ ! -f cfg/nodecg.json ]]; then
|
||||||
|
printf '{\n\t"port": %s\n}\n' "$port" > cfg/nodecg.json
|
||||||
|
echo "Wrote cfg/nodecg.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f cfg/$BUNDLE.json ]]; then
|
||||||
|
node - "$obs_password" > "cfg/$BUNDLE.json" <<'EOF'
|
||||||
|
const password = process.argv[2] ?? "";
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
locale: "en",
|
||||||
|
obs: { enabled: true, url: "ws://127.0.0.1:4455", password },
|
||||||
|
}, null, "\t") + "\n");
|
||||||
|
EOF
|
||||||
|
echo "Wrote cfg/$BUNDLE.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── OBS scene collection ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
node "bundles/$BUNDLE/scripts/generate-obs-scenes.mjs" \
|
||||||
|
--config "cfg/$BUNDLE.json" \
|
||||||
|
--nodecg-url "http://localhost:$port" \
|
||||||
|
--name VenueGfx \
|
||||||
|
--out obs/VenueGfx.json
|
||||||
|
|
||||||
|
# ── done ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────────────────────────
|
||||||
|
Install complete. Start NodeCG with:
|
||||||
|
|
||||||
|
cd $dir && node index.js
|
||||||
|
|
||||||
|
Dashboard: http://localhost:$port
|
||||||
|
|
||||||
|
OBS setup (see bundles/$BUNDLE/docs/OBS-SETUP.md for details):
|
||||||
|
1. Install exeldro's Move plugin: https://obsproject.com/forum/resources/move.913/
|
||||||
|
2. Tools → WebSocket Server Settings: enable, port 4455$( [[ -n $obs_password ]] && echo ", set the password you chose" || echo ", disable authentication" )
|
||||||
|
3. Scene Collection → Import → $dir/obs/VenueGfx.json, then switch to it
|
||||||
|
4. Replace the "Program Placeholder" source with your real capture
|
||||||
|
5. Point the Stinger transition at a real video file
|
||||||
|
6. Settings → Video: 1920×1080 base + output, 60 FPS
|
||||||
|
7. Dashboard → "OBS setup check" panel → Run checks, fix anything red
|
||||||
|
|
||||||
|
To run NodeCG as a service that survives reboots:
|
||||||
|
bundles/$BUNDLE/scripts/install-service.sh $dir
|
||||||
|
──────────────────────────────────────────────────────────────────────
|
||||||
|
EOF
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Update the bundle inside a NodeCG install created by install.sh:
|
||||||
|
# pull, reinstall deps, rebuild, and restart the service if one is running.
|
||||||
|
#
|
||||||
|
# Usage: ./update.sh [install-dir] (default: the install this script is in,
|
||||||
|
# falling back to ~/venue-gfx)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BUNDLE=nodecg-venue-gfx
|
||||||
|
UNIT_NAME=nodecg-venue-gfx.service
|
||||||
|
|
||||||
|
# scripts/ lives at <install>/bundles/<bundle>/scripts — walk up.
|
||||||
|
candidate="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||||
|
install_dir="${1:-$candidate}"
|
||||||
|
[[ -f $install_dir/index.js ]] || install_dir="$HOME/venue-gfx"
|
||||||
|
[[ -f $install_dir/index.js ]] || {
|
||||||
|
echo "no NodeCG install found (tried $candidate and ~/venue-gfx)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_dir="$install_dir/bundles/$BUNDLE"
|
||||||
|
|
||||||
|
echo "Updating $bundle_dir…"
|
||||||
|
git -C "$bundle_dir" pull --ff-only
|
||||||
|
(cd "$bundle_dir" && npm ci --no-audit --no-fund && npm run build)
|
||||||
|
|
||||||
|
if systemctl --user is-active --quiet "$UNIT_NAME" 2>/dev/null; then
|
||||||
|
echo "Restarting $UNIT_NAME…"
|
||||||
|
systemctl --user restart "$UNIT_NAME"
|
||||||
|
else
|
||||||
|
echo "Done. Restart NodeCG to load the new extension code."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type { Ad, AssetFile } from "../types";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
import { useReplicant, useReplicantValue } from "./lib";
|
||||||
|
|
||||||
|
const adsReplicant = nodecg.Replicant(
|
||||||
|
"ads",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<Ad[]>;
|
||||||
|
|
||||||
|
// Pool of column images, uploaded via the dashboard Assets dialog
|
||||||
|
// ("Ad — column image"); each ad picks one from the dropdown.
|
||||||
|
const columnImagesReplicant = nodecg.Replicant(
|
||||||
|
"assets:ad-column",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<AssetFile[]>;
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none";
|
||||||
|
|
||||||
|
function QrPreview({ url }: { url: string }) {
|
||||||
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
QRCode.toDataURL(url, { width: 96, margin: 1 })
|
||||||
|
.then((d) => {
|
||||||
|
if (alive) setDataUrl(d);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setDataUrl(null);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
if (!dataUrl) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-24 w-24 shrink-0 items-center justify-center rounded border border-dashed border-gray-300 text-xs text-gray-400">
|
||||||
|
QR?
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <img className="h-24 w-24 shrink-0 rounded" src={dataUrl} alt="" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdsPanel() {
|
||||||
|
// Seeded once from the replicant; this panel is the only writer of `ads`,
|
||||||
|
// so later echoes would only fight the operator's cursor.
|
||||||
|
const [ads, setAds] = useState<Ad[] | null>(null);
|
||||||
|
const columnImages = useReplicantValue<AssetFile[]>(columnImagesReplicant) ?? [];
|
||||||
|
|
||||||
|
useReplicant(adsReplicant, (value) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
setAds((prev) => prev ?? value.map((a) => ({ ...a })));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const write = (next: Ad[]) => {
|
||||||
|
setAds(next);
|
||||||
|
adsReplicant.value = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchAd = (id: string, patch: Partial<Ad>) =>
|
||||||
|
write(ads!.map((a) => (a.id === id ? { ...a, ...patch } : a)));
|
||||||
|
|
||||||
|
const addAd = () =>
|
||||||
|
write([
|
||||||
|
...ads!,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
rowText: "",
|
||||||
|
columnText: "",
|
||||||
|
columnImageUrl: null,
|
||||||
|
qrUrl: "https://www.instagram.com/",
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const removeAd = (id: string) => write(ads!.filter((a) => a.id !== id));
|
||||||
|
|
||||||
|
if (!ads) return <div className="p-3 text-sm text-gray-500">{t.common.loading}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
{ads.length === 0 && (
|
||||||
|
<div className="text-sm italic text-gray-400">{t.ads.none}</div>
|
||||||
|
)}
|
||||||
|
{ads.map((ad) => (
|
||||||
|
<div
|
||||||
|
key={ad.id}
|
||||||
|
className={`flex gap-3 rounded border p-2 ${
|
||||||
|
ad.enabled ? "border-green-400 bg-green-50" : "border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<QrPreview url={ad.qrUrl} />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
placeholder={t.ads.rowTextPlaceholder}
|
||||||
|
value={ad.rowText}
|
||||||
|
onChange={(e) => patchAd(ad.id, { rowText: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
placeholder={t.ads.columnTextPlaceholder}
|
||||||
|
value={ad.columnText}
|
||||||
|
onChange={(e) => patchAd(ad.id, { columnText: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="https://…"
|
||||||
|
value={ad.qrUrl}
|
||||||
|
onChange={(e) => patchAd(ad.id, { qrUrl: e.target.value })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
title={t.ads.columnImageTitle}
|
||||||
|
value={ad.columnImageUrl ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchAd(ad.id, { columnImageUrl: e.target.value || null })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">{t.ads.noColumnImage}</option>
|
||||||
|
{ad.columnImageUrl &&
|
||||||
|
!columnImages.some((f) => f.url === ad.columnImageUrl) && (
|
||||||
|
<option value={ad.columnImageUrl}>
|
||||||
|
{t.ads.missingImage} {ad.columnImageUrl.split("/").pop()}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{columnImages.map((f) => (
|
||||||
|
<option key={f.url} value={f.url}>
|
||||||
|
{f.name}.{f.ext.replace(/^\./, "")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="flex items-center gap-1.5 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ad.enabled}
|
||||||
|
onChange={(e) => patchAd(ad.id, { enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t.ads.enabled}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="rounded px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => removeAd(ad.id)}
|
||||||
|
>
|
||||||
|
{t.ads.delete}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
onClick={addAd}
|
||||||
|
>
|
||||||
|
{t.ads.addAd}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type {
|
||||||
|
AssetFile,
|
||||||
|
BreakSlide,
|
||||||
|
ChoreoConfig,
|
||||||
|
ObsStatus,
|
||||||
|
SchedulerState,
|
||||||
|
} from "../types";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
import { parseNum, useReplicantForm, useReplicantValue } from "./lib";
|
||||||
|
|
||||||
|
const obsStatusReplicant = nodecg.Replicant(
|
||||||
|
"obsStatus",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<ObsStatus>;
|
||||||
|
|
||||||
|
const schedulerStateReplicant = nodecg.Replicant(
|
||||||
|
"schedulerState",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<SchedulerState>;
|
||||||
|
|
||||||
|
const choreoConfigReplicant = nodecg.Replicant(
|
||||||
|
"choreoConfig",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<ChoreoConfig>;
|
||||||
|
|
||||||
|
const breakSlideReplicant = nodecg.Replicant(
|
||||||
|
"breakSlide",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<BreakSlide>;
|
||||||
|
|
||||||
|
const logoAssetsReplicant = nodecg.Replicant(
|
||||||
|
"assets:break-logo",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<AssetFile[]>;
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none";
|
||||||
|
const sectionTitleCls = "text-xs font-bold uppercase tracking-wide text-gray-500";
|
||||||
|
|
||||||
|
const formatCountdown = (ms: number) => {
|
||||||
|
const total = Math.max(0, Math.ceil(ms / 1000));
|
||||||
|
const m = Math.floor(total / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function NumField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onValid,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onValid: (n: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseNum(e.target.value);
|
||||||
|
if (n !== null && n > 0) onValid(n);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChoreoPanel() {
|
||||||
|
const status = useReplicantValue<ObsStatus>(obsStatusReplicant);
|
||||||
|
const sched = useReplicantValue<SchedulerState>(schedulerStateReplicant);
|
||||||
|
const [cfg, updateCfg] = useReplicantForm<ChoreoConfig>(choreoConfigReplicant);
|
||||||
|
const [slide, updateSlide] = useReplicantForm<BreakSlide>(breakSlideReplicant);
|
||||||
|
const logoFiles = useReplicantValue<AssetFile[]>(logoAssetsReplicant);
|
||||||
|
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isBreak = sched?.mode === "break";
|
||||||
|
const isAd = sched?.phase === "ad";
|
||||||
|
const countdown =
|
||||||
|
sched?.nextTransitionAt != null ? sched.nextTransitionAt - now : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-3">
|
||||||
|
<section className="flex flex-col gap-1">
|
||||||
|
<h2 className={sectionTitleCls}>{t.choreo.obsSection}</h2>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3 w-3 rounded-full ${
|
||||||
|
status?.connected ? "bg-green-500" : "bg-red-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{status?.connected ? (
|
||||||
|
<span>
|
||||||
|
{t.choreo.connectedScene}{" "}
|
||||||
|
<b className="font-mono">{status.currentScene ?? "?"}</b>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
{t.choreo.notConnected}
|
||||||
|
{status?.attempts ? t.choreo.attempt(status.attempts) : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{status?.lastError && !status.connected && (
|
||||||
|
<div className="truncate text-xs text-red-600" title={status.lastError}>
|
||||||
|
{status.lastError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h2 className={sectionTitleCls}>{t.choreo.stateSection}</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded px-2 py-0.5 text-xs font-bold uppercase ${
|
||||||
|
isBreak ? "bg-purple-600 text-white" : "bg-green-600 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isBreak ? t.choreo.modeBreak : t.choreo.modeLive}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded px-2 py-0.5 text-xs font-bold uppercase ${
|
||||||
|
isAd ? "bg-amber-500 text-white" : "bg-gray-300 text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isAd ? t.choreo.phaseAd : t.choreo.phaseLive}
|
||||||
|
</span>
|
||||||
|
{countdown !== null && (
|
||||||
|
<span className="ml-auto text-sm tabular-nums text-gray-600">
|
||||||
|
{t.choreo.nextTransitionIn} <b>{formatCountdown(countdown)}</b>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="flex-1 rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||||
|
disabled={isAd}
|
||||||
|
onClick={() => nodecg.sendMessage("ad:showNow")}
|
||||||
|
>
|
||||||
|
{t.choreo.showAdNow}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex-1 rounded bg-gray-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||||
|
disabled={!isAd}
|
||||||
|
onClick={() => nodecg.sendMessage("ad:skip")}
|
||||||
|
>
|
||||||
|
{t.choreo.skipAd}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`rounded px-3 py-3 text-base font-bold text-white ${
|
||||||
|
isBreak
|
||||||
|
? "bg-purple-700 hover:bg-purple-800"
|
||||||
|
: "bg-red-600 hover:bg-red-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => nodecg.sendMessage("break:toggle")}
|
||||||
|
>
|
||||||
|
{isBreak ? t.choreo.breakOff : t.choreo.breakOn}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h2 className={sectionTitleCls}>{t.choreo.breakSlideSection}</h2>
|
||||||
|
{slide ? (
|
||||||
|
<>
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
{t.choreo.headingLabel}
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={slide.heading}
|
||||||
|
onChange={(e) => updateSlide({ heading: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
{t.choreo.nextActNameLabel}
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={slide.nextActName}
|
||||||
|
onChange={(e) => updateSlide({ nextActName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
{t.choreo.nextActFromLabel}
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={slide.nextActFrom}
|
||||||
|
onChange={(e) => updateSlide({ nextActFrom: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
{t.choreo.logoLabel}
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
value={slide.logoName ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSlide({ logoName: e.target.value || null })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">{t.choreo.logoAuto}</option>
|
||||||
|
{(logoFiles ?? []).map((f) => (
|
||||||
|
<option key={f.base} value={f.base}>
|
||||||
|
{f.base}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
{t.choreo.logoSizeLabel(slide.logoHeightPx ?? 240)}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="w-full"
|
||||||
|
min={80}
|
||||||
|
max={480}
|
||||||
|
step={10}
|
||||||
|
value={slide.logoHeightPx ?? 240}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSlide({ logoHeightPx: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4"
|
||||||
|
checked={slide.logoInverted ?? false}
|
||||||
|
onChange={(e) => updateSlide({ logoInverted: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t.choreo.invertLogo}
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500">{t.common.loading}</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h2 className={sectionTitleCls}>{t.choreo.timingSection}</h2>
|
||||||
|
{cfg ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<NumField
|
||||||
|
label={t.choreo.adIntervalLabel}
|
||||||
|
value={cfg.normalAdIntervalMinutes}
|
||||||
|
onValid={(n) => updateCfg({ normalAdIntervalMinutes: n })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t.choreo.adDurationLabel}
|
||||||
|
value={cfg.adDurationSeconds}
|
||||||
|
onValid={(n) => updateCfg({ adDurationSeconds: n })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t.choreo.breakGapLabel}
|
||||||
|
value={cfg.breakGapSeconds}
|
||||||
|
onValid={(n) => updateCfg({ breakGapSeconds: n })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t.choreo.breakAdDurationLabel}
|
||||||
|
value={cfg.breakAdDurationSeconds}
|
||||||
|
onValid={(n) => updateCfg({ breakAdDurationSeconds: n })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t.choreo.breakAdsPerBurstLabel}
|
||||||
|
value={cfg.breakAdsPerBurst}
|
||||||
|
onValid={(n) => updateCfg({ breakAdsPerBurst: n })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500">{t.common.loading}</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type { ObsCheckResult, ObsCheckStatus, ObsVerifyReport } from "../types";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
import { useReplicantValue } from "./lib";
|
||||||
|
|
||||||
|
const reportReplicant = nodecg.Replicant(
|
||||||
|
"obsVerifyReport",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<ObsVerifyReport>;
|
||||||
|
|
||||||
|
const STATUS_STYLE: Record<ObsCheckStatus, { chip: string; row: string }> = {
|
||||||
|
pass: { chip: "bg-green-600 text-white", row: "border-green-200" },
|
||||||
|
warn: { chip: "bg-amber-500 text-white", row: "border-amber-300 bg-amber-50" },
|
||||||
|
fail: { chip: "bg-red-600 text-white", row: "border-red-300 bg-red-50" },
|
||||||
|
skip: { chip: "bg-gray-400 text-white", row: "border-gray-200" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ORDER: ObsCheckStatus[] = ["fail", "warn", "skip", "pass"];
|
||||||
|
|
||||||
|
function CheckRow({ check }: { check: ObsCheckResult }) {
|
||||||
|
const style = STATUS_STYLE[check.status];
|
||||||
|
return (
|
||||||
|
<div className={`rounded border px-2 py-1.5 ${style.row}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase ${style.chip}`}
|
||||||
|
>
|
||||||
|
{t.obsCheck.status[check.status]}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={check.label}>
|
||||||
|
{check.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{check.detail && (
|
||||||
|
<div className="mt-0.5 break-words text-xs text-gray-600">{check.detail}</div>
|
||||||
|
)}
|
||||||
|
{check.fix && (
|
||||||
|
<div className="mt-0.5 break-words text-xs text-gray-800">
|
||||||
|
<b>{t.obsCheck.fixPrefix}</b> {check.fix}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObsCheckPanel() {
|
||||||
|
const report = useReplicantValue<ObsVerifyReport>(reportReplicant);
|
||||||
|
|
||||||
|
const checks = [...(report?.checks ?? [])].sort(
|
||||||
|
(a, b) => ORDER.indexOf(a.status) - ORDER.indexOf(b.status),
|
||||||
|
);
|
||||||
|
const counts = { pass: 0, warn: 0, fail: 0, skip: 0 };
|
||||||
|
for (const c of checks) counts[c.status]++;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||||
|
disabled={report?.running ?? false}
|
||||||
|
onClick={() => nodecg.sendMessage("obs:verify")}
|
||||||
|
>
|
||||||
|
{report?.running ? t.obsCheck.running : t.obsCheck.run}
|
||||||
|
</button>
|
||||||
|
{report?.ranAt != null && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{counts.fail > 0 || counts.warn > 0
|
||||||
|
? `${counts.pass}✓ ${counts.warn}⚠ ${counts.fail}✗`
|
||||||
|
: t.obsCheck.allGood}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report?.ranAt == null && !report?.running && (
|
||||||
|
<div className="text-sm italic text-gray-400">{t.obsCheck.intro}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{checks.map((c) => (
|
||||||
|
<CheckRow key={c.id} check={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type { TickerConfig, TickerMessage } from "../types";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
import { parseNum, useReplicantForm, useReplicantValue } from "./lib";
|
||||||
|
|
||||||
|
const messagesReplicant = nodecg.Replicant(
|
||||||
|
"tickerMessages",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<TickerMessage[]>;
|
||||||
|
|
||||||
|
const configReplicant = nodecg.Replicant(
|
||||||
|
"tickerConfig",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<TickerConfig>;
|
||||||
|
|
||||||
|
const fillColor = (frac: number) =>
|
||||||
|
frac > 0.5 ? "#bbf7d0" : frac > 0.2 ? "#fde68a" : "#fecaca";
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none";
|
||||||
|
const btnCls =
|
||||||
|
"rounded bg-gray-200 px-2 py-1 text-xs font-medium hover:bg-gray-300";
|
||||||
|
|
||||||
|
export function TickerPanel() {
|
||||||
|
const [cfg, updateCfg] = useReplicantForm<TickerConfig>(configReplicant);
|
||||||
|
const messages = useReplicantValue<TickerMessage[]>(messagesReplicant) ?? [];
|
||||||
|
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
const [newText, setNewText] = useState("");
|
||||||
|
const [newTtl, setNewTtl] = useState("");
|
||||||
|
const [newPermanent, setNewPermanent] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => setNow(Date.now()), 250);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addMessage = () => {
|
||||||
|
const text = newText.trim();
|
||||||
|
if (!text || !cfg) return;
|
||||||
|
const ttl = parseNum(newTtl);
|
||||||
|
messagesReplicant.value = [
|
||||||
|
...messages,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
text,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
ttlSeconds: newPermanent
|
||||||
|
? null
|
||||||
|
: ttl !== null && ttl > 0
|
||||||
|
? ttl
|
||||||
|
: cfg.defaultTtlSeconds,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setNewText("");
|
||||||
|
setNewTtl("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mutations read messagesReplicant.value (not the React copy) so that e.g.
|
||||||
|
// a text edit committed by the same click that hits "Renew" isn't lost.
|
||||||
|
const refresh = (id: string) => {
|
||||||
|
messagesReplicant.value = (messagesReplicant.value ?? []).map((m) =>
|
||||||
|
m.id === id ? { ...m, addedAt: Date.now() } : m,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (id: string) => {
|
||||||
|
messagesReplicant.value = (messagesReplicant.value ?? []).filter(
|
||||||
|
(m) => m.id !== id,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline text editing of a running message; local state until committed so
|
||||||
|
// replicant echoes can't fight the cursor. Saving keeps addedAt/TTL as-is.
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editText, setEditText] = useState("");
|
||||||
|
|
||||||
|
const startEdit = (m: TickerMessage) => {
|
||||||
|
setEditingId(m.id);
|
||||||
|
setEditText(m.text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitEdit = () => {
|
||||||
|
const text = editText.trim();
|
||||||
|
if (editingId && text) {
|
||||||
|
messagesReplicant.value = (messagesReplicant.value ?? []).map((m) =>
|
||||||
|
m.id === editingId ? { ...m, text } : m,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cfg) return <div className="p-3 text-sm text-gray-500">{t.common.loading}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-3">
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
||||||
|
{t.ticker.settings}
|
||||||
|
</h2>
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
{t.ticker.loopBlurbLabel}
|
||||||
|
<input
|
||||||
|
className={`${inputCls} w-full`}
|
||||||
|
value={cfg.loopBlurb}
|
||||||
|
onChange={(e) => updateCfg({ loopBlurb: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
{t.ticker.separatorLabel}
|
||||||
|
<input
|
||||||
|
className={`${inputCls} w-full`}
|
||||||
|
value={cfg.separator}
|
||||||
|
onChange={(e) => updateCfg({ separator: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
{t.ticker.defaultTtlLabel}
|
||||||
|
<input
|
||||||
|
className={`${inputCls} w-full`}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={cfg.defaultTtlSeconds}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseNum(e.target.value);
|
||||||
|
if (n !== null && n > 0) updateCfg({ defaultTtlSeconds: n });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
{t.ticker.speedLabel}
|
||||||
|
<input
|
||||||
|
className={`${inputCls} w-full`}
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
value={cfg.speedPxPerSec}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseNum(e.target.value);
|
||||||
|
if (n !== null && n > 0) updateCfg({ speedPxPerSec: n });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
||||||
|
{t.ticker.newMessage}
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className={`${inputCls} min-w-0 flex-1`}
|
||||||
|
placeholder={t.ticker.messagePlaceholder}
|
||||||
|
value={newText}
|
||||||
|
onChange={(e) => setNewText(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && addMessage()}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={`${inputCls} w-20 shrink-0 disabled:bg-gray-100 disabled:text-gray-400`}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder={`${cfg.defaultTtlSeconds}s`}
|
||||||
|
title={t.ticker.ttlTitle}
|
||||||
|
value={newTtl}
|
||||||
|
disabled={newPermanent}
|
||||||
|
onChange={(e) => setNewTtl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="flex shrink-0 items-center gap-1 text-xs text-gray-600"
|
||||||
|
title={t.ticker.permanentTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newPermanent}
|
||||||
|
onChange={(e) => setNewPermanent(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t.ticker.permanent}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="shrink-0 rounded bg-blue-600 px-3 py-1 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
onClick={addMessage}
|
||||||
|
>
|
||||||
|
{t.ticker.add}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-1.5">
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
||||||
|
{t.ticker.activeMessages} ({messages.length})
|
||||||
|
</h2>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-sm italic text-gray-400">
|
||||||
|
{t.ticker.noMessages}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((m) => {
|
||||||
|
const permanent = m.ttlSeconds === null;
|
||||||
|
const remainingMs = permanent
|
||||||
|
? 0
|
||||||
|
: Math.max(0, m.addedAt + m.ttlSeconds! * 1000 - now);
|
||||||
|
const frac = permanent
|
||||||
|
? 0
|
||||||
|
: Math.min(1, remainingMs / (m.ttlSeconds! * 1000));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className={`relative overflow-hidden rounded border ${
|
||||||
|
permanent ? "border-blue-300 bg-blue-50" : "border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!permanent && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0"
|
||||||
|
style={{
|
||||||
|
width: `${frac * 100}%`,
|
||||||
|
background: fillColor(frac),
|
||||||
|
transition: "width 250ms linear",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="relative flex items-center gap-2 px-2 py-1.5">
|
||||||
|
{editingId === m.id ? (
|
||||||
|
<input
|
||||||
|
className={`${inputCls} min-w-0 flex-1 bg-white`}
|
||||||
|
value={editText}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
onBlur={commitEdit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commitEdit();
|
||||||
|
if (e.key === "Escape") setEditingId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 cursor-text truncate text-sm"
|
||||||
|
title={t.ticker.clickToEdit}
|
||||||
|
onClick={() => startEdit(m)}
|
||||||
|
>
|
||||||
|
{m.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 text-xs tabular-nums text-gray-600">
|
||||||
|
{permanent ? "∞" : `${Math.ceil(remainingMs / 1000)}s`}
|
||||||
|
</span>
|
||||||
|
{editingId !== m.id && (
|
||||||
|
<button className={btnCls} onClick={() => startEdit(m)}>
|
||||||
|
{t.ticker.edit}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!permanent && (
|
||||||
|
<button className={btnCls} onClick={() => refresh(m.id)}>
|
||||||
|
{t.ticker.refresh}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`${btnCls} text-red-700`}
|
||||||
|
onClick={() => remove(m.id)}
|
||||||
|
>
|
||||||
|
{t.ticker.remove}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { AdsPanel } from "./AdsPanel";
|
||||||
|
import { locale } from "./i18n";
|
||||||
|
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<AdsPanel />);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link href="./panel.css" type="text/css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./ads-panel-bootstrap.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { ChoreoPanel } from "./ChoreoPanel";
|
||||||
|
import { locale } from "./i18n";
|
||||||
|
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<ChoreoPanel />);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link href="./panel.css" type="text/css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./choreo-panel-bootstrap.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { BundleConfig } from "../types";
|
||||||
|
import { getStrings } from "../i18n";
|
||||||
|
|
||||||
|
// bundleConfig is available synchronously in browser contexts, so the locale
|
||||||
|
// is fixed per page load — no hook or provider needed.
|
||||||
|
export const locale =
|
||||||
|
(nodecg.bundleConfig as unknown as BundleConfig)?.locale ?? "en";
|
||||||
|
|
||||||
|
export const t = getStrings(locale);
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import NodeCG from "@nodecg/types";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const useReplicant = <T = any>(
|
||||||
|
replicant: NodeCG.ServerReplicant<T>,
|
||||||
|
fn: (newValue: T | undefined, oldValue: T | undefined) => void | (() => void),
|
||||||
|
deps: any[] = [],
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
let cleanup: (() => void) | void;
|
||||||
|
|
||||||
|
const handler = (newValue: T | undefined, oldValue: T | undefined) => {
|
||||||
|
if (cleanup) cleanup(); // Run previous cleanup before setting new one
|
||||||
|
cleanup = fn(newValue, oldValue); // Store new cleanup function (if provided)
|
||||||
|
};
|
||||||
|
|
||||||
|
replicant.on("change", handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
replicant.off("change", handler);
|
||||||
|
if (cleanup) cleanup();
|
||||||
|
};
|
||||||
|
}, [...deps]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicant value as React state. Values are cloned on every change because
|
||||||
|
* NodeCG patches the same object in place, which React reference-equality
|
||||||
|
* would otherwise miss.
|
||||||
|
*/
|
||||||
|
export const useReplicantValue = <T = any>(
|
||||||
|
replicant: NodeCG.ServerReplicant<T>,
|
||||||
|
): T | undefined => {
|
||||||
|
const [value, setValue] = useState<T | undefined>(undefined);
|
||||||
|
useReplicant<T>(replicant, (newValue) => {
|
||||||
|
setValue(
|
||||||
|
newValue === undefined ? undefined : JSON.parse(JSON.stringify(newValue)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable form state backed by a replicant: local state is seeded from the
|
||||||
|
* replicant's first change event only, so later echoes of our own writes
|
||||||
|
* can't fight the operator's cursor mid-typing. Single-operator tradeoff.
|
||||||
|
*/
|
||||||
|
export const useReplicantForm = <T>(
|
||||||
|
replicant: NodeCG.ServerReplicant<T>,
|
||||||
|
): [T | null, (patch: Partial<T>) => void] => {
|
||||||
|
const [value, setValue] = useState<T | null>(null);
|
||||||
|
|
||||||
|
useReplicant(replicant, (newValue) => {
|
||||||
|
if (newValue !== undefined) {
|
||||||
|
setValue((prev) => prev ?? JSON.parse(JSON.stringify(newValue)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = (patch: Partial<T>) => {
|
||||||
|
setValue((prev) => {
|
||||||
|
if (prev === null) return prev;
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
replicant.value = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return [value, update];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parse a numeric input; returns null (caller should ignore) when invalid. */
|
||||||
|
export const parseNum = (raw: string): number | null => {
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { ObsCheckPanel } from "./ObsCheckPanel";
|
||||||
|
import { locale } from "./i18n";
|
||||||
|
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<ObsCheckPanel />);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link href="./panel.css" type="text/css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./obs-check-panel-bootstrap.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { TickerPanel } from "./TickerPanel";
|
||||||
|
import { locale } from "./i18n";
|
||||||
|
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<TickerPanel />);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link href="./panel.css" type="text/css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./ticker-panel-bootstrap.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.build.json",
|
||||||
|
"include": ["**/*.tsx", "**/*.ts", "../types/**/*.ts", "../../node_modules/@nodecg/types/types/augment-window.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type { BundleConfig } from "../types";
|
||||||
|
|
||||||
|
import { declareReplicants } from "./replicants";
|
||||||
|
import { ObsController, resolveObsConfig } from "./obs";
|
||||||
|
import { registerObsVerify } from "./obsVerify";
|
||||||
|
import { Scheduler } from "./scheduler";
|
||||||
|
import { startTickerSweeper } from "./ticker";
|
||||||
|
|
||||||
|
module.exports = function (nodecg: NodeCG.ServerAPI) {
|
||||||
|
const replicants = declareReplicants(nodecg);
|
||||||
|
|
||||||
|
startTickerSweeper(nodecg, replicants);
|
||||||
|
|
||||||
|
const obsConfig = resolveObsConfig(
|
||||||
|
nodecg.bundleConfig as unknown as BundleConfig,
|
||||||
|
);
|
||||||
|
const obs = new ObsController(nodecg, obsConfig, replicants.obsStatus);
|
||||||
|
const scheduler = new Scheduler(nodecg, replicants, obs);
|
||||||
|
|
||||||
|
registerObsVerify(nodecg, obs, obsConfig);
|
||||||
|
|
||||||
|
obs.onReady = () => scheduler.applyDesiredScene();
|
||||||
|
|
||||||
|
scheduler.start();
|
||||||
|
obs.start();
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { OBSWebSocket } from "obs-websocket-js";
|
||||||
|
import type { OBSRequestTypes, OBSResponseTypes } from "obs-websocket-js";
|
||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type {
|
||||||
|
BundleConfig,
|
||||||
|
ObsConnectionConfig,
|
||||||
|
ObsSceneNames,
|
||||||
|
ObsStatus,
|
||||||
|
ObsTransitionNames,
|
||||||
|
} from "../types";
|
||||||
|
import {
|
||||||
|
DEFAULT_SCENE_NAMES,
|
||||||
|
DEFAULT_SOURCE_NAMES,
|
||||||
|
DEFAULT_TRANSITION_NAMES,
|
||||||
|
} from "../shared/obs-geometry.mjs";
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: ObsConnectionConfig = {
|
||||||
|
enabled: false,
|
||||||
|
url: "ws://127.0.0.1:4455",
|
||||||
|
password: "",
|
||||||
|
scenes: DEFAULT_SCENE_NAMES,
|
||||||
|
transitions: DEFAULT_TRANSITION_NAMES,
|
||||||
|
sources: DEFAULT_SOURCE_NAMES,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BACKOFF_MS = [1000, 2000, 5000, 10000];
|
||||||
|
|
||||||
|
export function resolveObsConfig(
|
||||||
|
bundleConfig: BundleConfig | undefined,
|
||||||
|
): ObsConnectionConfig {
|
||||||
|
const obs = bundleConfig?.obs ?? {};
|
||||||
|
return {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...obs,
|
||||||
|
scenes: { ...DEFAULT_CONFIG.scenes, ...(obs.scenes ?? {}) },
|
||||||
|
transitions: { ...DEFAULT_CONFIG.transitions, ...(obs.transitions ?? {}) },
|
||||||
|
sources: { ...DEFAULT_CONFIG.sources, ...(obs.sources ?? {}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ObsController {
|
||||||
|
private obs = new OBSWebSocket();
|
||||||
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
private attempts = 0;
|
||||||
|
|
||||||
|
/** Called after every successful (re)connect so the scheduler can reconcile OBS. */
|
||||||
|
onReady: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private nodecg: NodeCG.ServerAPI,
|
||||||
|
private cfg: ObsConnectionConfig,
|
||||||
|
private status: NodeCG.ServerReplicant<ObsStatus>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get scenes(): ObsSceneNames {
|
||||||
|
return this.cfg.scenes;
|
||||||
|
}
|
||||||
|
|
||||||
|
get transitions(): ObsTransitionNames {
|
||||||
|
return this.cfg.transitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.cfg.enabled) {
|
||||||
|
this.nodecg.log.info("OBS: disabled in bundle config, running without OBS");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.obs.on("ConnectionClosed", () => {
|
||||||
|
if (this.status.value?.connected) {
|
||||||
|
this.nodecg.log.warn("OBS: connection closed");
|
||||||
|
}
|
||||||
|
this.patchStatus({ connected: false, currentScene: null });
|
||||||
|
this.scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track manual scene switches made by the operator in OBS itself.
|
||||||
|
this.obs.on("CurrentProgramSceneChanged", ({ sceneName }) => {
|
||||||
|
this.patchStatus({ currentScene: sceneName });
|
||||||
|
});
|
||||||
|
|
||||||
|
void this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget scene switch; never throws so timer-driven callers can't wedge.
|
||||||
|
* When `transition` is given it is selected first (best-effort — a missing
|
||||||
|
* transition logs a warning and the switch proceeds with whatever is active).
|
||||||
|
* Returns false when disabled, disconnected, or the switch fails.
|
||||||
|
*/
|
||||||
|
async setScene(name: string, transition?: string): Promise<boolean> {
|
||||||
|
if (!this.cfg.enabled) return false;
|
||||||
|
if (!this.status.value?.connected) {
|
||||||
|
this.nodecg.log.debug(`OBS: not connected, skipped switch to "${name}"`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (transition) {
|
||||||
|
try {
|
||||||
|
await this.obs.call("SetCurrentSceneTransition", {
|
||||||
|
transitionName: transition,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
this.nodecg.log.warn(
|
||||||
|
`OBS: transition "${transition}" not selectable (${e?.message ?? e}), using current`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.obs.call("SetCurrentProgramScene", { sceneName: name });
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.nodecg.log.error(`OBS: failed to switch to "${name}": ${e?.message ?? e}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed passthrough for read-only queries (the setup verifier). Throws
|
||||||
|
* when OBS is disabled or disconnected — callers turn that into a skip.
|
||||||
|
*/
|
||||||
|
async request<T extends keyof OBSRequestTypes>(
|
||||||
|
type: T,
|
||||||
|
params?: OBSRequestTypes[T],
|
||||||
|
): Promise<OBSResponseTypes[T]> {
|
||||||
|
if (!this.cfg.enabled) throw new Error("OBS is disabled in bundle config");
|
||||||
|
if (!this.status.value?.connected) throw new Error("OBS is not connected");
|
||||||
|
return this.obs.call(type, params as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchStatus(patch: Partial<ObsStatus>) {
|
||||||
|
this.status.value = { ...this.status.value!, ...patch };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connect() {
|
||||||
|
this.attempts += 1;
|
||||||
|
this.patchStatus({ attempts: this.attempts });
|
||||||
|
try {
|
||||||
|
await this.obs.connect(this.cfg.url, this.cfg.password || undefined, {
|
||||||
|
rpcVersion: 1,
|
||||||
|
});
|
||||||
|
this.attempts = 0;
|
||||||
|
this.patchStatus({ connected: true, lastError: null, attempts: 0 });
|
||||||
|
this.nodecg.log.info("OBS: connected");
|
||||||
|
try {
|
||||||
|
const { currentProgramSceneName } = await this.obs.call(
|
||||||
|
"GetCurrentProgramScene",
|
||||||
|
);
|
||||||
|
this.patchStatus({ currentScene: currentProgramSceneName });
|
||||||
|
} catch {
|
||||||
|
// non-fatal; CurrentProgramSceneChanged will catch us up
|
||||||
|
}
|
||||||
|
this.onReady?.();
|
||||||
|
} catch (e: any) {
|
||||||
|
const message = e?.message ?? String(e);
|
||||||
|
this.patchStatus({ connected: false, lastError: message });
|
||||||
|
this.nodecg.log.warn(`OBS: connect attempt #${this.attempts} failed: ${message}`);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect() {
|
||||||
|
if (this.reconnectTimer) return;
|
||||||
|
const delay =
|
||||||
|
BACKOFF_MS[Math.min(Math.max(this.attempts - 1, 0), BACKOFF_MS.length - 1)];
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
void this.connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type {
|
||||||
|
ObsCheckResult,
|
||||||
|
ObsConnectionConfig,
|
||||||
|
ObsVerifyReport,
|
||||||
|
} from "../types";
|
||||||
|
import type { ObsController } from "./obs";
|
||||||
|
import {
|
||||||
|
CANVAS,
|
||||||
|
MOVE_TRANSITION_KIND,
|
||||||
|
STINGER_TRANSITION_KIND,
|
||||||
|
expectedSceneModel,
|
||||||
|
graphicUrls,
|
||||||
|
type ExpectedItem,
|
||||||
|
} from "../shared/obs-geometry.mjs";
|
||||||
|
|
||||||
|
// Hand-tweaked setups (center alignment, eyeballed positions) should still
|
||||||
|
// pass: transforms are compared as effective top-left with these tolerances.
|
||||||
|
// ±5 px is invisible at 1080p yet still catches every real mistake (wrong
|
||||||
|
// corner, unscaled program, missing shrink) by hundreds of pixels.
|
||||||
|
const POS_TOLERANCE_PX = 5;
|
||||||
|
const SCALE_TOLERANCE = 0.01;
|
||||||
|
|
||||||
|
const STINGER_PLACEHOLDER_MARKER = "/CHANGE/ME";
|
||||||
|
|
||||||
|
interface SceneItemInfo {
|
||||||
|
sceneItemId: number;
|
||||||
|
sceneItemIndex: number;
|
||||||
|
sourceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EffectiveTransform {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
scaleX: number;
|
||||||
|
scaleY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an OBS scene-item transform to its effective top-left corner.
|
||||||
|
* OBS `alignment` is a bitfield (1=left, 2=right, 4=top, 8=bottom; 0=center
|
||||||
|
* on both axes) anchoring `position`, so raw positionX/Y values are only
|
||||||
|
* comparable after removing the anchor.
|
||||||
|
*/
|
||||||
|
function effectiveTopLeft(t: Record<string, unknown>): EffectiveTransform {
|
||||||
|
const alignment = Number(t.alignment);
|
||||||
|
const scaleX = Number(t.scaleX);
|
||||||
|
const scaleY = Number(t.scaleY);
|
||||||
|
const w = Number(t.sourceWidth) * scaleX;
|
||||||
|
const h = Number(t.sourceHeight) * scaleY;
|
||||||
|
const x =
|
||||||
|
Number(t.positionX) - (alignment & 1 ? 0 : alignment & 2 ? w : w / 2);
|
||||||
|
const y =
|
||||||
|
Number(t.positionY) - (alignment & 4 ? 0 : alignment & 8 ? h : h / 2);
|
||||||
|
return { x, y, scaleX, scaleY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformMatches(
|
||||||
|
actual: EffectiveTransform,
|
||||||
|
expected: ExpectedItem,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
Math.abs(actual.x - expected.pos.x) <= POS_TOLERANCE_PX &&
|
||||||
|
Math.abs(actual.y - expected.pos.y) <= POS_TOLERANCE_PX &&
|
||||||
|
Math.abs(actual.scaleX - expected.scale) <= SCALE_TOLERANCE &&
|
||||||
|
Math.abs(actual.scaleY - expected.scale) <= SCALE_TOLERANCE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (t: EffectiveTransform) =>
|
||||||
|
`top-left (${t.x.toFixed(0)}, ${t.y.toFixed(0)}) scale ${t.scaleX.toFixed(3)}×${t.scaleY.toFixed(3)}`;
|
||||||
|
|
||||||
|
export function registerObsVerify(
|
||||||
|
nodecg: NodeCG.ServerAPI,
|
||||||
|
obs: ObsController,
|
||||||
|
cfg: ObsConnectionConfig,
|
||||||
|
) {
|
||||||
|
const report = nodecg.Replicant("obsVerifyReport", {
|
||||||
|
persistent: false,
|
||||||
|
defaultValue: { ranAt: null, running: false, checks: [] },
|
||||||
|
}) as unknown as NodeCG.ServerReplicant<ObsVerifyReport>;
|
||||||
|
|
||||||
|
nodecg.listenFor("obs:verify", async () => {
|
||||||
|
report.value = { ranAt: null, running: true, checks: [] };
|
||||||
|
const checks = await runChecks(nodecg, obs, cfg);
|
||||||
|
report.value = { ranAt: Date.now(), running: false, checks };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runChecks(
|
||||||
|
nodecg: NodeCG.ServerAPI,
|
||||||
|
obs: ObsController,
|
||||||
|
cfg: ObsConnectionConfig,
|
||||||
|
): Promise<ObsCheckResult[]> {
|
||||||
|
const checks: ObsCheckResult[] = [];
|
||||||
|
const model = expectedSceneModel({
|
||||||
|
scenes: cfg.scenes,
|
||||||
|
sources: cfg.sources,
|
||||||
|
});
|
||||||
|
const allScenes = { ...model.nested, ...model.driven };
|
||||||
|
|
||||||
|
// ── connection ─────────────────────────────────────────────────────────
|
||||||
|
if (!cfg.enabled) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "connection",
|
||||||
|
label: "OBS connection",
|
||||||
|
status: "skip",
|
||||||
|
detail: "OBS is disabled in the bundle config (obs.enabled: false).",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const version = await obs.request("GetVersion");
|
||||||
|
checks.push({
|
||||||
|
id: "connection",
|
||||||
|
label: "OBS connection",
|
||||||
|
status: "pass",
|
||||||
|
detail: `OBS ${version.obsVersion}, obs-websocket ${version.obsWebSocketVersion}`,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "connection",
|
||||||
|
label: "OBS connection",
|
||||||
|
status: "fail",
|
||||||
|
detail: e?.message ?? String(e),
|
||||||
|
fix: "Start OBS, enable Tools → WebSocket Server Settings, and match url/password in the bundle config.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── canvas ─────────────────────────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
const v = await obs.request("GetVideoSettings");
|
||||||
|
const ok =
|
||||||
|
v.baseWidth === CANVAS.w &&
|
||||||
|
v.baseHeight === CANVAS.h &&
|
||||||
|
v.outputWidth === CANVAS.w &&
|
||||||
|
v.outputHeight === CANVAS.h;
|
||||||
|
checks.push({
|
||||||
|
id: "canvas",
|
||||||
|
label: `Canvas ${CANVAS.w}×${CANVAS.h}`,
|
||||||
|
status: ok ? "pass" : "fail",
|
||||||
|
detail: `base ${v.baseWidth}×${v.baseHeight}, output ${v.outputWidth}×${v.outputHeight}`,
|
||||||
|
fix: ok
|
||||||
|
? undefined
|
||||||
|
: `Settings → Video: set Base and Output resolution to ${CANVAS.w}×${CANVAS.h}.`,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
checks.push({
|
||||||
|
id: "canvas",
|
||||||
|
label: `Canvas ${CANVAS.w}×${CANVAS.h}`,
|
||||||
|
status: "skip",
|
||||||
|
detail: e?.message ?? String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── scenes exist ───────────────────────────────────────────────────────
|
||||||
|
let existingScenes = new Set<string>();
|
||||||
|
try {
|
||||||
|
const { scenes } = await obs.request("GetSceneList");
|
||||||
|
existingScenes = new Set(scenes.map((s: any) => String(s.sceneName)));
|
||||||
|
const missing = Object.keys(allScenes).filter(
|
||||||
|
(name) => !existingScenes.has(name),
|
||||||
|
);
|
||||||
|
checks.push({
|
||||||
|
id: "scenes",
|
||||||
|
label: "All scenes exist",
|
||||||
|
status: missing.length === 0 ? "pass" : "fail",
|
||||||
|
detail:
|
||||||
|
missing.length === 0
|
||||||
|
? `${Object.keys(allScenes).length} scenes present`
|
||||||
|
: `missing: ${missing.join(", ")}`,
|
||||||
|
fix:
|
||||||
|
missing.length === 0
|
||||||
|
? undefined
|
||||||
|
: "Import the generated scene collection (npm run obs:scenes) or create the missing scenes manually.",
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
checks.push({
|
||||||
|
id: "scenes",
|
||||||
|
label: "All scenes exist",
|
||||||
|
status: "skip",
|
||||||
|
detail: e?.message ?? String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── browser sources ────────────────────────────────────────────────────
|
||||||
|
// Compare URL by pathname only: OBS may legitimately reach NodeCG via a
|
||||||
|
// different host than the one this extension binds to.
|
||||||
|
const urls = graphicUrls("http://localhost", nodecg.bundleName);
|
||||||
|
const browserSources: Array<{ name: string; expectedUrl: string }> = [
|
||||||
|
{ name: cfg.sources.ticker, expectedUrl: urls.ticker },
|
||||||
|
{ name: cfg.sources.adOverlay, expectedUrl: urls.ad },
|
||||||
|
{ name: cfg.sources.breakSlide, expectedUrl: urls.break },
|
||||||
|
];
|
||||||
|
for (const { name, expectedUrl } of browserSources) {
|
||||||
|
const expectedPath = new URL(expectedUrl).pathname;
|
||||||
|
try {
|
||||||
|
const { inputKind, inputSettings } = await obs.request(
|
||||||
|
"GetInputSettings",
|
||||||
|
{ inputName: name },
|
||||||
|
);
|
||||||
|
const problems: string[] = [];
|
||||||
|
if (inputKind !== "browser_source") {
|
||||||
|
problems.push(`kind is ${inputKind}, expected browser_source`);
|
||||||
|
}
|
||||||
|
const url = String(inputSettings.url ?? "");
|
||||||
|
let path = "";
|
||||||
|
try {
|
||||||
|
path = new URL(url).pathname;
|
||||||
|
} catch {
|
||||||
|
/* not a URL */
|
||||||
|
}
|
||||||
|
if (path !== expectedPath) {
|
||||||
|
problems.push(`URL is "${url}", expected path ${expectedPath}`);
|
||||||
|
}
|
||||||
|
const w = Number(inputSettings.width ?? 0);
|
||||||
|
const h = Number(inputSettings.height ?? 0);
|
||||||
|
if (w !== CANVAS.w || h !== CANVAS.h) {
|
||||||
|
problems.push(`size is ${w}×${h}, expected ${CANVAS.w}×${CANVAS.h}`);
|
||||||
|
}
|
||||||
|
if (inputSettings.shutdown === true) {
|
||||||
|
problems.push(`"Shutdown source when not visible" is checked`);
|
||||||
|
}
|
||||||
|
checks.push({
|
||||||
|
id: `source:${name}`,
|
||||||
|
label: `Browser source "${name}"`,
|
||||||
|
status: problems.length === 0 ? "pass" : "fail",
|
||||||
|
detail: problems.length === 0 ? url : problems.join("; "),
|
||||||
|
fix:
|
||||||
|
problems.length === 0
|
||||||
|
? undefined
|
||||||
|
: `Open the source properties: URL …${expectedPath}, size ${CANVAS.w}×${CANVAS.h}, "Shutdown source when not visible" unchecked.`,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
checks.push({
|
||||||
|
id: `source:${name}`,
|
||||||
|
label: `Browser source "${name}"`,
|
||||||
|
status: "fail",
|
||||||
|
detail: e?.message ?? String(e),
|
||||||
|
fix: `Create a browser source named exactly "${name}" (…${expectedPath}, ${CANVAS.w}×${CANVAS.h}).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── per-scene items + transforms ───────────────────────────────────────
|
||||||
|
const adOverlayTransforms: string[] = [];
|
||||||
|
for (const [sceneName, expectedItems] of Object.entries(allScenes)) {
|
||||||
|
if (!existingScenes.has(sceneName)) continue; // already failed above
|
||||||
|
try {
|
||||||
|
const { sceneItems } = await obs.request("GetSceneItemList", {
|
||||||
|
sceneName,
|
||||||
|
});
|
||||||
|
const items = (sceneItems as unknown as SceneItemInfo[])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sceneItemIndex - b.sceneItemIndex);
|
||||||
|
const problems: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Expected items must appear in order from the bottom of the stack;
|
||||||
|
// anything else in the scene is the user's business (warn only).
|
||||||
|
const expectedNames = expectedItems.map((it) => it.source);
|
||||||
|
const strictByName = new Map(
|
||||||
|
expectedItems.filter((it) => it.strict).map((it) => [it.source, it]),
|
||||||
|
);
|
||||||
|
const presentNames = items.map((it) => it.sourceName);
|
||||||
|
const extras = presentNames.filter((n) => !expectedNames.includes(n));
|
||||||
|
|
||||||
|
for (const expected of expectedItems) {
|
||||||
|
if (expected.strict && !presentNames.includes(expected.source)) {
|
||||||
|
problems.push(`missing item "${expected.source}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!bottomItemOk(items, expectedItems)) {
|
||||||
|
problems.push(
|
||||||
|
`"${expectedItems[0].source}" must be the bottom item (found: ${presentNames.join(" → ") || "none"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (extras.length > 0) {
|
||||||
|
warnings.push(`extra items (fine if intentional): ${extras.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// transforms of strict items
|
||||||
|
for (const item of items) {
|
||||||
|
const expected = strictByName.get(item.sourceName);
|
||||||
|
if (!expected) continue;
|
||||||
|
const { sceneItemTransform } = await obs.request(
|
||||||
|
"GetSceneItemTransform",
|
||||||
|
{ sceneName, sceneItemId: item.sceneItemId },
|
||||||
|
);
|
||||||
|
const actual = effectiveTopLeft(
|
||||||
|
sceneItemTransform as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
if (!transformMatches(actual, expected)) {
|
||||||
|
const isTicker = item.sourceName === cfg.sources.ticker;
|
||||||
|
const msg = `"${item.sourceName}" is at ${fmt(actual)}, expected (${expected.pos.x}, ${expected.pos.y}) scale ${expected.scale}`;
|
||||||
|
// A deliberately offset ticker (venue screens that crop the bottom
|
||||||
|
// edge) is a tweak, not a mistake.
|
||||||
|
(isTicker ? warnings : problems).push(msg);
|
||||||
|
}
|
||||||
|
if (item.sourceName === cfg.sources.adOverlay) {
|
||||||
|
adOverlayTransforms.push(`${sceneName}: ${fmt(actual)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: `scene:${sceneName}`,
|
||||||
|
label: `Scene "${sceneName}"`,
|
||||||
|
status:
|
||||||
|
problems.length > 0 ? "fail" : warnings.length > 0 ? "warn" : "pass",
|
||||||
|
detail:
|
||||||
|
[...problems, ...warnings].join("; ") ||
|
||||||
|
`items: ${presentNames.join(" → ")}`,
|
||||||
|
fix:
|
||||||
|
problems.length > 0
|
||||||
|
? "Re-import the generated scene collection, or fix the item stack/transforms to match docs/OBS-SETUP.md."
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
checks.push({
|
||||||
|
id: `scene:${sceneName}`,
|
||||||
|
label: `Scene "${sceneName}"`,
|
||||||
|
status: "skip",
|
||||||
|
detail: e?.message ?? String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdOverlay must sit at the exact same spot in every scene, or Move will
|
||||||
|
// animate it during transitions instead of leaving it static.
|
||||||
|
const uniqueAdTransforms = new Set(
|
||||||
|
adOverlayTransforms.map((s) => s.split(": ")[1]),
|
||||||
|
);
|
||||||
|
if (adOverlayTransforms.length > 0) {
|
||||||
|
checks.push({
|
||||||
|
id: "ad-overlay-identical",
|
||||||
|
label: `"${cfg.sources.adOverlay}" identical in all scenes`,
|
||||||
|
status: uniqueAdTransforms.size === 1 ? "pass" : "fail",
|
||||||
|
detail:
|
||||||
|
uniqueAdTransforms.size === 1
|
||||||
|
? `${adOverlayTransforms.length} scenes, all ${[...uniqueAdTransforms][0]}`
|
||||||
|
: adOverlayTransforms.join("; "),
|
||||||
|
fix:
|
||||||
|
uniqueAdTransforms.size === 1
|
||||||
|
? undefined
|
||||||
|
: "Reset the ad overlay to (0, 0) scale 1.0 in every scene (Transform → Reset).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── transitions ────────────────────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
const { transitions, currentSceneTransitionName } = await obs.request(
|
||||||
|
"GetSceneTransitionList",
|
||||||
|
);
|
||||||
|
const byName = new Map(
|
||||||
|
(transitions as any[]).map((t) => [String(t.transitionName), t]),
|
||||||
|
);
|
||||||
|
const expectations: Array<{ name: string; kind: string; role: string }> = [
|
||||||
|
{
|
||||||
|
name: cfg.transitions.default,
|
||||||
|
kind: MOVE_TRANSITION_KIND,
|
||||||
|
role: "ad switches (Move plugin)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: cfg.transitions.break,
|
||||||
|
kind: STINGER_TRANSITION_KIND,
|
||||||
|
role: "MAIN↔BREAK (stinger)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const { name, kind, role } of expectations) {
|
||||||
|
const found = byName.get(name);
|
||||||
|
const ok = found && found.transitionKind === kind;
|
||||||
|
checks.push({
|
||||||
|
id: `transition:${name}`,
|
||||||
|
label: `Transition "${name}" — ${role}`,
|
||||||
|
status: ok ? "pass" : "fail",
|
||||||
|
detail: found
|
||||||
|
? `kind ${found.transitionKind}${ok ? "" : `, expected ${kind}`}`
|
||||||
|
: `no transition named "${name}" in the transition dock`,
|
||||||
|
fix: ok
|
||||||
|
? undefined
|
||||||
|
: kind === MOVE_TRANSITION_KIND
|
||||||
|
? `Install exeldro's Move plugin and add a "${name}" transition (obs-websocket cannot create transitions).`
|
||||||
|
: `Add a Stinger transition named "${name}" pointing at your stinger video (obs-websocket cannot create transitions).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings of non-active transitions are not readable over
|
||||||
|
// obs-websocket; opportunistically inspect the active one.
|
||||||
|
const current = byName.get(String(currentSceneTransitionName));
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
[cfg.transitions.default, cfg.transitions.break].includes(
|
||||||
|
String(currentSceneTransitionName),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const { transitionSettings } = await obs.request(
|
||||||
|
"GetCurrentSceneTransition",
|
||||||
|
);
|
||||||
|
const path = String((transitionSettings as any)?.path ?? "");
|
||||||
|
if (path.includes(STINGER_PLACEHOLDER_MARKER)) {
|
||||||
|
checks.push({
|
||||||
|
id: "stinger-path",
|
||||||
|
label: "Stinger video path",
|
||||||
|
status: "fail",
|
||||||
|
detail: `still the generator placeholder: ${path}`,
|
||||||
|
fix: "Point the Stinger transition at a real video file (transition dock → Properties).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
id: "transition-settings",
|
||||||
|
label: "Transition settings (duration, stinger path)",
|
||||||
|
status: "skip",
|
||||||
|
detail:
|
||||||
|
"Not readable for inactive transitions over obs-websocket — spot-check them in OBS.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
checks.push({
|
||||||
|
id: "transitions",
|
||||||
|
label: "Transitions",
|
||||||
|
status: "skip",
|
||||||
|
detail: e?.message ?? String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first expected item (the ad overlay) must be at the very bottom. */
|
||||||
|
function bottomItemOk(
|
||||||
|
items: SceneItemInfo[],
|
||||||
|
expectedItems: ExpectedItem[],
|
||||||
|
): boolean {
|
||||||
|
if (items.length === 0) return false;
|
||||||
|
return items[0].sourceName === expectedItems[0].source;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type { Ad, ChoreoConfig, Corner, SchedulerState } from "../types";
|
||||||
|
import type { ObsController } from "./obs";
|
||||||
|
import type { Replicants } from "./replicants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State machine over (mode, phase). Exactly one pending timer at a time;
|
||||||
|
* timers are wall-clock-driven and OBS calls are fire-and-forget, so an
|
||||||
|
* unreachable OBS never wedges the choreography.
|
||||||
|
*/
|
||||||
|
export class Scheduler {
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private nodecg: NodeCG.ServerAPI,
|
||||||
|
private r: Replicants,
|
||||||
|
private obs: ObsController,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
// Always come back up in (normal, live) + MAIN, whatever we were doing
|
||||||
|
// when we went down.
|
||||||
|
this.patchState({ mode: "normal" });
|
||||||
|
this.toLive();
|
||||||
|
|
||||||
|
this.nodecg.listenFor("ad:showNow", () => {
|
||||||
|
if (this.st.phase === "live") this.toAd();
|
||||||
|
});
|
||||||
|
this.nodecg.listenFor("ad:skip", () => {
|
||||||
|
if (this.st.phase === "ad") this.toLive();
|
||||||
|
});
|
||||||
|
this.nodecg.listenFor("break:toggle", () => {
|
||||||
|
this.patchState({ mode: this.st.mode === "normal" ? "break" : "normal" });
|
||||||
|
// MAIN↔BREAK is the one switch that plays the stinger
|
||||||
|
this.toLive(this.obs.transitions.break);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile OBS with the state machine, e.g. after an OBS (re)connect. */
|
||||||
|
applyDesiredScene() {
|
||||||
|
const st = this.st;
|
||||||
|
const sc = this.obs.scenes;
|
||||||
|
const scene =
|
||||||
|
st.phase === "live"
|
||||||
|
? st.mode === "normal"
|
||||||
|
? sc.main
|
||||||
|
: sc.break
|
||||||
|
: this.adScene(st.mode, st.lastCorner);
|
||||||
|
void this.obs.setScene(scene, this.obs.transitions.default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get st(): SchedulerState {
|
||||||
|
return this.r.schedulerState.value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get cfg(): ChoreoConfig {
|
||||||
|
return this.r.choreoConfig.value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchState(patch: Partial<SchedulerState>) {
|
||||||
|
this.r.schedulerState.value = { ...this.st, ...patch };
|
||||||
|
}
|
||||||
|
|
||||||
|
private arm(ms: number, fn: () => void) {
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(fn, ms);
|
||||||
|
this.patchState({ nextTransitionAt: Date.now() + ms });
|
||||||
|
}
|
||||||
|
|
||||||
|
private adScene(mode: SchedulerState["mode"], corner: Corner): string {
|
||||||
|
const sc = this.obs.scenes;
|
||||||
|
return mode === "normal"
|
||||||
|
? corner === "tr"
|
||||||
|
? sc.mainAdTr
|
||||||
|
: sc.mainAdBl
|
||||||
|
: corner === "tr"
|
||||||
|
? sc.breakAdTr
|
||||||
|
: sc.breakAdBl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round-robin over currently enabled ads; lastAdIndex persists across restarts. */
|
||||||
|
private nextEnabledAd(): { ad: Ad; index: number } | null {
|
||||||
|
const enabled = (this.r.ads.value ?? []).filter((a) => a.enabled);
|
||||||
|
if (enabled.length === 0) return null;
|
||||||
|
const index = (Math.max(this.st.lastAdIndex, -1) + 1) % enabled.length;
|
||||||
|
return { ad: enabled[index], index };
|
||||||
|
}
|
||||||
|
|
||||||
|
private toLive(transition = this.obs.transitions.default) {
|
||||||
|
const mode = this.st.mode;
|
||||||
|
this.r.adState.value = { ...this.r.adState.value!, visible: false };
|
||||||
|
this.patchState({ phase: "live", burstRemaining: 0 });
|
||||||
|
if (mode === "normal") {
|
||||||
|
void this.obs.setScene(this.obs.scenes.main, transition);
|
||||||
|
this.arm(this.cfg.normalAdIntervalMinutes * 60_000, () => this.toAd());
|
||||||
|
} else {
|
||||||
|
void this.obs.setScene(this.obs.scenes.break, transition);
|
||||||
|
this.arm(this.cfg.breakGapSeconds * 1000, () => this.toAd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toAd() {
|
||||||
|
const pick = this.nextEnabledAd();
|
||||||
|
if (!pick) {
|
||||||
|
this.nodecg.log.warn("scheduler: no enabled ads, skipping ad slot");
|
||||||
|
this.toLive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mode = this.st.mode;
|
||||||
|
const corner: Corner = this.st.lastCorner === "tr" ? "bl" : "tr";
|
||||||
|
this.patchState({
|
||||||
|
phase: "ad",
|
||||||
|
lastCorner: corner,
|
||||||
|
lastAdIndex: pick.index,
|
||||||
|
burstRemaining: mode === "break" ? this.cfg.breakAdsPerBurst : 1,
|
||||||
|
});
|
||||||
|
this.r.adState.value = { visible: true, corner, adId: pick.ad.id };
|
||||||
|
void this.obs.setScene(
|
||||||
|
this.adScene(mode, corner),
|
||||||
|
this.obs.transitions.default,
|
||||||
|
);
|
||||||
|
const duration =
|
||||||
|
mode === "normal" ? this.cfg.adDurationSeconds : this.cfg.breakAdDurationSeconds;
|
||||||
|
this.arm(duration * 1000, () => this.onAdTimer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A direct AD_TR→AD_BL switch makes Move fly the slide diagonally across
|
||||||
|
// the screen, leaving a visual hole behind it. Instead each burst hop goes
|
||||||
|
// through the fullscreen base scene: expand, swap the ad while covered,
|
||||||
|
// shrink out to the opposite corner.
|
||||||
|
private static readonly BURST_HOP_MS = 1000;
|
||||||
|
|
||||||
|
private onAdTimer() {
|
||||||
|
const st = this.st;
|
||||||
|
if (st.mode === "break" && st.burstRemaining > 1) {
|
||||||
|
this.r.adState.value = { ...this.r.adState.value!, visible: false };
|
||||||
|
void this.obs.setScene(this.obs.scenes.break, this.obs.transitions.default);
|
||||||
|
this.arm(Scheduler.BURST_HOP_MS, () => this.nextBurstAd());
|
||||||
|
} else {
|
||||||
|
this.toLive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextBurstAd() {
|
||||||
|
const st = this.st;
|
||||||
|
const pick = this.nextEnabledAd();
|
||||||
|
if (!pick) {
|
||||||
|
this.toLive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const corner: Corner = st.lastCorner === "tr" ? "bl" : "tr";
|
||||||
|
this.patchState({
|
||||||
|
lastCorner: corner,
|
||||||
|
lastAdIndex: pick.index,
|
||||||
|
burstRemaining: st.burstRemaining - 1,
|
||||||
|
});
|
||||||
|
this.r.adState.value = { visible: true, corner, adId: pick.ad.id };
|
||||||
|
void this.obs.setScene(
|
||||||
|
this.adScene("break", corner),
|
||||||
|
this.obs.transitions.default,
|
||||||
|
);
|
||||||
|
this.arm(this.cfg.breakAdDurationSeconds * 1000, () => this.onAdTimer());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type { Replicants } from "./replicants";
|
||||||
|
|
||||||
|
/** Sweep expired ticker messages once a second; write back only on change. */
|
||||||
|
export function startTickerSweeper(nodecg: NodeCG.ServerAPI, r: Replicants) {
|
||||||
|
setInterval(() => {
|
||||||
|
const messages = r.tickerMessages.value;
|
||||||
|
if (!messages || messages.length === 0) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const alive = messages.filter(
|
||||||
|
(m) => m.ttlSeconds === null || m.addedAt + m.ttlSeconds * 1000 > now,
|
||||||
|
);
|
||||||
|
if (alive.length !== messages.length) {
|
||||||
|
r.tickerMessages.value = alive;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.build.json",
|
||||||
|
"include": ["**/*.ts", "../types/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type { Ad as AdType, AdState, AssetFile, Corner } from "../types";
|
||||||
|
import { LAYOUTS, QR_CARD } from "../shared/obs-geometry.mjs";
|
||||||
|
import { useReplicantValue } from "./lib";
|
||||||
|
|
||||||
|
const adsReplicant = nodecg.Replicant(
|
||||||
|
"ads",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<AdType[]>;
|
||||||
|
|
||||||
|
const adStateReplicant = nodecg.Replicant(
|
||||||
|
"adState",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<AdState>;
|
||||||
|
|
||||||
|
// Background images are uploaded through the dashboard "Assets" dialog,
|
||||||
|
// one category per corner layout; the first file in each category is used.
|
||||||
|
const backgroundReplicants: Record<
|
||||||
|
Corner,
|
||||||
|
NodeCG.ServerReplicant<AssetFile[]>
|
||||||
|
> = {
|
||||||
|
tr: nodecg.Replicant("assets:ad-bg-tr") as unknown as NodeCG.ServerReplicant<
|
||||||
|
AssetFile[]
|
||||||
|
>,
|
||||||
|
bl: nodecg.Replicant("assets:ad-bg-bl") as unknown as NodeCG.ServerReplicant<
|
||||||
|
AssetFile[]
|
||||||
|
>,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout geometry (rects, clip paths, QR card size) lives in
|
||||||
|
// src/shared/obs-geometry.mjs so the OBS scene generator and verify panel
|
||||||
|
// can never drift from what the graphic actually renders.
|
||||||
|
const QR_IMG = QR_CARD - 16; // minus the card's p-2 padding
|
||||||
|
|
||||||
|
const rectStyle = (r: Rect): React.CSSProperties => ({
|
||||||
|
position: "absolute",
|
||||||
|
left: r.x,
|
||||||
|
top: r.y,
|
||||||
|
width: r.w,
|
||||||
|
height: r.h,
|
||||||
|
});
|
||||||
|
|
||||||
|
function PanningBackground({
|
||||||
|
url,
|
||||||
|
clipPath,
|
||||||
|
}: {
|
||||||
|
url: string | null;
|
||||||
|
clipPath: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0" style={{ clipPath }}>
|
||||||
|
{/* gradient always underneath so a missing/still-loading image never
|
||||||
|
leaves bare cutout */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-ad-bg-start to-ad-bg-end" />
|
||||||
|
{url && (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
className="absolute left-0 top-0 h-full w-full object-cover"
|
||||||
|
style={{ animation: "ad-pan 20s linear infinite alternate" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* scrim so text stays readable on busy images — delete if unwanted */}
|
||||||
|
<div className="absolute inset-0 bg-black/25" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QrCard({ url, size }: { url: string; size: number }) {
|
||||||
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
// Generous quiet zone (margin) matters for phones scanning a distant screen.
|
||||||
|
QRCode.toDataURL(url, { width: 512, margin: 2 })
|
||||||
|
.then((d) => {
|
||||||
|
if (alive) setDataUrl(d);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setDataUrl(null);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
if (!dataUrl) return null;
|
||||||
|
return (
|
||||||
|
<div className="rounded bg-white p-2 shadow-2xl">
|
||||||
|
<img src={dataUrl} width={size} height={size} alt="" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Ad() {
|
||||||
|
const ads = useReplicantValue<AdType[]>(adsReplicant);
|
||||||
|
const state = useReplicantValue<AdState>(adStateReplicant);
|
||||||
|
const bgTr = useReplicantValue<AssetFile[]>(backgroundReplicants.tr);
|
||||||
|
const bgBl = useReplicantValue<AssetFile[]>(backgroundReplicants.bl);
|
||||||
|
|
||||||
|
// Hold the last shown ad so content stays put while fading out.
|
||||||
|
const [current, setCurrent] = useState<AdType | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (state?.visible && state.adId) {
|
||||||
|
const ad = (ads ?? []).find((a) => a.id === state.adId);
|
||||||
|
if (ad) setCurrent(ad);
|
||||||
|
}
|
||||||
|
}, [state, ads]);
|
||||||
|
|
||||||
|
const visible = !!state?.visible && !!current;
|
||||||
|
|
||||||
|
// No fade/zoom of our own: the program moving aside is what reveals the ad,
|
||||||
|
// so the content must simply be there, fully opaque, the whole time. After
|
||||||
|
// hide we keep rendering for 1s so the program expands back over a
|
||||||
|
// still-visible ad instead of it blinking out mid-transition.
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setShown(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(() => setShown(false), 1000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const corner = state?.corner ?? "tr";
|
||||||
|
const layout = LAYOUTS[corner];
|
||||||
|
const backgroundUrl = (corner === "tr" ? bgTr : bgBl)?.[0]?.url ?? null;
|
||||||
|
|
||||||
|
if (!current || !shown) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-[1080px] w-[1920px] overflow-hidden">
|
||||||
|
<PanningBackground url={backgroundUrl} clipPath={layout.clipPath} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={rectStyle(layout.row)}
|
||||||
|
className="flex items-center justify-center px-16"
|
||||||
|
>
|
||||||
|
<span className="font-display text-center text-6xl font-bold leading-tight text-white drop-shadow-[0_3px_12px_rgba(0,0,0,0.9)]">
|
||||||
|
{current.rowText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* two vertical bands: the image gets the top 55% (or the whole column
|
||||||
|
when there's no text), the text centers itself in what remains */}
|
||||||
|
<div
|
||||||
|
style={rectStyle(layout.column)}
|
||||||
|
className="flex flex-col items-center gap-8 px-10"
|
||||||
|
>
|
||||||
|
{current.columnImageUrl && (
|
||||||
|
<div
|
||||||
|
className={`flex w-full items-center justify-center ${
|
||||||
|
current.columnText ? "h-[55%] shrink-0" : "min-h-0 flex-1"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={current.columnImageUrl}
|
||||||
|
alt=""
|
||||||
|
className={`${
|
||||||
|
current.columnText ? "max-h-full" : "max-h-[85%]"
|
||||||
|
} ${corner === "tr" ? "mt-32" : ""} max-w-full object-contain drop-shadow-2xl`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{current.columnText && (
|
||||||
|
<div className="flex min-h-0 w-full flex-1 items-center justify-center">
|
||||||
|
<span className="font-display text-center text-5xl font-bold leading-snug text-white drop-shadow-[0_3px_12px_rgba(0,0,0,0.9)]">
|
||||||
|
{current.columnText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={rectStyle(layout.corner)}
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<QrCard url={current.qrUrl} size={QR_IMG} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type { AssetFile, BreakSlide } from "../types";
|
||||||
|
import { useReplicantValue } from "./lib";
|
||||||
|
|
||||||
|
const breakSlideReplicant = nodecg.Replicant(
|
||||||
|
"breakSlide",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<BreakSlide>;
|
||||||
|
|
||||||
|
// Optional background image (dashboard Assets → "Pauza — pozadinska slika");
|
||||||
|
// when present it replaces the drifting blobs.
|
||||||
|
const backgroundReplicant = nodecg.Replicant(
|
||||||
|
"assets:break-bg",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<AssetFile[]>;
|
||||||
|
|
||||||
|
// Optional band logo (dashboard Assets → "Pauza — logo benda"), shown between
|
||||||
|
// the heading and the act name.
|
||||||
|
const logoReplicant = nodecg.Replicant(
|
||||||
|
"assets:break-logo",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<AssetFile[]>;
|
||||||
|
|
||||||
|
interface Blob {
|
||||||
|
size: number;
|
||||||
|
left: string;
|
||||||
|
top: string;
|
||||||
|
color: string;
|
||||||
|
animation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Huge blurred radial blobs on slow drift loops — GPU-cheap ambient motion.
|
||||||
|
// Colors come from the THEME LAB block in index.css.
|
||||||
|
const BLOBS: Blob[] = [
|
||||||
|
{
|
||||||
|
size: 900,
|
||||||
|
left: "-10%",
|
||||||
|
top: "-20%",
|
||||||
|
color: "var(--color-break-blob-1)",
|
||||||
|
animation: "drift-a 90s ease-in-out infinite alternate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
size: 800,
|
||||||
|
left: "55%",
|
||||||
|
top: "40%",
|
||||||
|
color: "var(--color-break-blob-2)",
|
||||||
|
animation: "drift-b 110s ease-in-out infinite alternate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
size: 700,
|
||||||
|
left: "25%",
|
||||||
|
top: "55%",
|
||||||
|
color: "var(--color-break-blob-3)",
|
||||||
|
animation: "drift-c 70s ease-in-out infinite alternate",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Break() {
|
||||||
|
const slide = useReplicantValue<BreakSlide>(breakSlideReplicant);
|
||||||
|
const backgroundUrl =
|
||||||
|
useReplicantValue<AssetFile[]>(backgroundReplicant)?.[0]?.url ?? null;
|
||||||
|
// The slide names which uploaded file to use; fall back to the first one if
|
||||||
|
// nothing is picked or the picked file was deleted.
|
||||||
|
const logoFiles = useReplicantValue<AssetFile[]>(logoReplicant);
|
||||||
|
const logoUrl =
|
||||||
|
(logoFiles?.find((f) => f.base === slide?.logoName) ?? logoFiles?.[0])
|
||||||
|
?.url ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative h-[1080px] w-[1920px] overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, var(--color-break-bg-a) 0%, var(--color-break-bg-b) 55%, var(--color-break-bg-a) 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backgroundUrl ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={backgroundUrl}
|
||||||
|
alt=""
|
||||||
|
className="absolute left-0 top-0 h-full w-full object-cover"
|
||||||
|
style={{ animation: "break-bounce 30s ease-in-out infinite alternate" }}
|
||||||
|
/>
|
||||||
|
{/* scrim keeps the announcement text readable — tune to taste */}
|
||||||
|
<div className="absolute inset-0 bg-black/40" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
BLOBS.map((blob, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute rounded-full"
|
||||||
|
style={{
|
||||||
|
width: blob.size,
|
||||||
|
height: blob.size,
|
||||||
|
left: blob.left,
|
||||||
|
top: blob.top,
|
||||||
|
background: blob.color,
|
||||||
|
filter: "blur(120px)",
|
||||||
|
animation: blob.animation,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Text stays well inside the edges: this same source is shown shrunk to
|
||||||
|
70% in the BREAK_AD_* scenes, so extremes would crowd the ad cutout.
|
||||||
|
*/}
|
||||||
|
<div
|
||||||
|
className="font-display absolute inset-0 flex flex-col items-center justify-center gap-10 px-[15%] text-center"
|
||||||
|
style={{ animation: "breathe 8s ease-in-out infinite" }}
|
||||||
|
>
|
||||||
|
<div className="text-6xl font-medium tracking-wide text-white/80">
|
||||||
|
{slide?.heading}
|
||||||
|
</div>
|
||||||
|
{logoUrl && (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt=""
|
||||||
|
className="max-w-[45%] object-contain"
|
||||||
|
style={{
|
||||||
|
height: slide?.logoHeightPx ?? 240,
|
||||||
|
// invert must come before the shadow so the shadow itself
|
||||||
|
// doesn't flip to white
|
||||||
|
filter: `${slide?.logoInverted ? "invert(1) " : ""}drop-shadow(0 4px 24px rgba(0,0,0,0.6))`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="text-9xl font-extrabold leading-tight text-white drop-shadow-[0_4px_24px_rgba(0,0,0,0.6)]">
|
||||||
|
{slide?.nextActName}
|
||||||
|
</div>
|
||||||
|
<div className="text-5xl font-light tracking-wider text-break-accent">
|
||||||
|
{slide?.nextActFrom}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
|
||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type { TickerConfig, TickerMessage } from "../types";
|
||||||
|
import { useReplicant } from "./lib";
|
||||||
|
|
||||||
|
const messagesReplicant = nodecg.Replicant(
|
||||||
|
"tickerMessages",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<TickerMessage[]>;
|
||||||
|
|
||||||
|
const configReplicant = nodecg.Replicant(
|
||||||
|
"tickerConfig",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<TickerConfig>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A segment is an immutable snapshot of the loop content:
|
||||||
|
* blurb ♦ msg1 ♦ msg2 ♦ … Segments scroll off as a unit, and new segments
|
||||||
|
* are built from the *current* replicant values only when appended, so
|
||||||
|
* mid-scroll changes never cause jumps — content updates enter at loop
|
||||||
|
* boundaries, each seam marked by the blurb.
|
||||||
|
*/
|
||||||
|
interface Segment {
|
||||||
|
id: number;
|
||||||
|
blurb: string;
|
||||||
|
separator: string;
|
||||||
|
texts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SEGMENTS = 40;
|
||||||
|
const TOPUP_MARGIN_PX = 400;
|
||||||
|
|
||||||
|
let nextSegmentId = 1;
|
||||||
|
|
||||||
|
export function Ticker() {
|
||||||
|
const [, forceRender] = useState(0);
|
||||||
|
const segmentsRef = useRef<Segment[]>([]);
|
||||||
|
const widthsRef = useRef(new Map<number, number>());
|
||||||
|
const offsetRef = useRef(0);
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const latest = useRef({
|
||||||
|
blurb: "",
|
||||||
|
separator: "♦",
|
||||||
|
speed: 120,
|
||||||
|
texts: [] as string[],
|
||||||
|
});
|
||||||
|
|
||||||
|
useReplicant(configReplicant, (cfg) => {
|
||||||
|
if (!cfg) return;
|
||||||
|
latest.current.blurb = cfg.loopBlurb;
|
||||||
|
latest.current.separator = cfg.separator;
|
||||||
|
latest.current.speed = cfg.speedPxPerSec;
|
||||||
|
});
|
||||||
|
|
||||||
|
useReplicant(messagesReplicant, (msgs) => {
|
||||||
|
latest.current.texts = (msgs ?? []).map((m) => m.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let raf: number;
|
||||||
|
let last = performance.now();
|
||||||
|
|
||||||
|
const frame = (now: number) => {
|
||||||
|
const dt = Math.min((now - last) / 1000, 0.1);
|
||||||
|
last = now;
|
||||||
|
const track = trackRef.current;
|
||||||
|
const segments = segmentsRef.current;
|
||||||
|
|
||||||
|
if (track) {
|
||||||
|
offsetRef.current += latest.current.speed * dt;
|
||||||
|
|
||||||
|
// Retire fully-scrolled-off segments and rebase the offset.
|
||||||
|
let dirty = false;
|
||||||
|
while (segments.length > 0) {
|
||||||
|
const w = widthsRef.current.get(segments[0].id);
|
||||||
|
if (w !== undefined && offsetRef.current >= w) {
|
||||||
|
offsetRef.current -= w;
|
||||||
|
widthsRef.current.delete(segments[0].id);
|
||||||
|
segments.shift();
|
||||||
|
dirty = true;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length === 0) offsetRef.current = 0;
|
||||||
|
|
||||||
|
// Top up the queue from current replicant values until the track
|
||||||
|
// extends past the viewport. Each append is committed with flushSync
|
||||||
|
// and measured immediately: rAF-paced React renders otherwise land
|
||||||
|
// *after* the paint, so the transform and the DOM would disagree for
|
||||||
|
// a frame and the track would visibly jump at every seam.
|
||||||
|
const { blurb, separator, texts } = latest.current;
|
||||||
|
if (blurb.trim() !== "" || texts.length > 0) {
|
||||||
|
let total = segments.reduce(
|
||||||
|
(sum, s) => sum + (widthsRef.current.get(s.id) ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
while (
|
||||||
|
segments.length < MAX_SEGMENTS &&
|
||||||
|
total - offsetRef.current < window.innerWidth + TOPUP_MARGIN_PX
|
||||||
|
) {
|
||||||
|
const seg: Segment = {
|
||||||
|
id: nextSegmentId++,
|
||||||
|
blurb,
|
||||||
|
separator,
|
||||||
|
texts: [...texts],
|
||||||
|
};
|
||||||
|
segments.push(seg);
|
||||||
|
flushSync(() => forceRender((n) => n + 1));
|
||||||
|
dirty = false;
|
||||||
|
const el = track.querySelector<HTMLElement>(
|
||||||
|
`[data-seg-id="${seg.id}"]`,
|
||||||
|
);
|
||||||
|
const w = Math.max(el?.offsetWidth ?? 1, 1);
|
||||||
|
widthsRef.current.set(seg.id, w);
|
||||||
|
total += w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit any retirements that weren't already flushed by an append.
|
||||||
|
if (dirty) flushSync(() => forceRender((n) => n + 1));
|
||||||
|
|
||||||
|
track.style.transform = `translateX(${-offsetRef.current}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't start until fonts are loaded — segment widths are measured once
|
||||||
|
// at append time, and a late font swap would invalidate all of them.
|
||||||
|
let cancelled = false;
|
||||||
|
void document.fonts.ready.then(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
last = performance.now();
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="font-ticker fixed bottom-0 left-0 h-24 w-[1920px] overflow-hidden bg-gradient-to-t from-ticker-start to-ticker-end border-t-08 border-ticker-border">
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className="absolute left-0 top-0 flex h-full items-center whitespace-nowrap will-change-transform"
|
||||||
|
>
|
||||||
|
{segmentsRef.current.map((seg) => (
|
||||||
|
<div
|
||||||
|
key={seg.id}
|
||||||
|
data-seg-id={seg.id}
|
||||||
|
className="flex h-full shrink-0 items-center"
|
||||||
|
>
|
||||||
|
<span className="px-8 text-[40px] font-bold text-ticker-accent drop-shadow-[0_2px_4px_rgba(0,0,0,0.8)]">
|
||||||
|
{seg.blurb}
|
||||||
|
</span>
|
||||||
|
<span className="text-[32px] text-white/40">{seg.separator}</span>
|
||||||
|
{seg.texts.map((text, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<span className="px-8 text-[40px] text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.8)]">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
<span className="text-[32px] text-white/40">
|
||||||
|
{seg.separator}
|
||||||
|
</span>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { Ad } from "./Ad";
|
||||||
|
|
||||||
|
document.documentElement.lang =
|
||||||
|
(nodecg.bundleConfig as { locale?: string })?.locale ?? "en";
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<Ad />);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link href="./index.css" type="text/css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./ad-bootstrap.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { Break } from "./Break";
|
||||||
|
|
||||||
|
document.documentElement.lang =
|
||||||
|
(nodecg.bundleConfig as { locale?: string })?.locale ?? "en";
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<Break />);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link href="./index.css" type="text/css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./break-bootstrap.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
@@ -0,0 +1,141 @@
|
|||||||
|
/* @import url('https://fonts.googleapis.com/css2?family=Margarine&family=Schoolbell&display=swap'); */
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Margarine";
|
||||||
|
src: url("./fonts/Margarine-Regular.ttf") format("truetype");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FONT LAB ────────────────────────────────────────────────────────────
|
||||||
|
Two roles, applied everywhere via the `font-ticker` / `font-display`
|
||||||
|
utility classes. Swap candidates by editing the two variables below,
|
||||||
|
save (watch rebuild), refresh the graphic.
|
||||||
|
|
||||||
|
- ticker → the scrolling marquee text
|
||||||
|
- display → big statements: ad row/column text, break slide
|
||||||
|
|
||||||
|
Candidates to try (must be installed on the OBS machine, or bundled
|
||||||
|
below; the stack falls through to the next name if one is missing):
|
||||||
|
grotesque: "Inter", "Archivo", "Manrope", "Roboto"
|
||||||
|
condensed: "Oswald", "Archivo Narrow", "Roboto Condensed"
|
||||||
|
poster: "Anton", "Archivo Black", "Bebas Neue", Impact
|
||||||
|
|
||||||
|
To bundle a font file (works offline, recommended for the venue):
|
||||||
|
drop the .woff2/.ttf into src/graphics/fonts/ and uncomment:
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "MojFont";
|
||||||
|
src: url("./fonts/MojFont.woff2") format("woff2");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
Google Fonts also works if the OBS machine has internet — add e.g.
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Anton&display=block");
|
||||||
|
directly under the tailwindcss import above (keep all @import lines at
|
||||||
|
the very top of this file). Latin-extended fonts cover č ć đ š ž.
|
||||||
|
──────────────────────────────────────────────────────────────────────── */
|
||||||
|
/* ── THEME LAB ───────────────────────────────────────────────────────────
|
||||||
|
Every brand color the graphics use, in one place. Edit, save (watch
|
||||||
|
rebuild), refresh the graphic — nothing else references raw colors.
|
||||||
|
|
||||||
|
- ticker-* the scrolling marquee: bottom→top gradient, top border,
|
||||||
|
and the accent used for the recurring blurb text
|
||||||
|
- ad-bg-* fallback gradient behind ad backgrounds while the image
|
||||||
|
loads (or when none is uploaded)
|
||||||
|
- break-bg-* break-slide backdrop gradient (a/b = edges/center)
|
||||||
|
- break-accent the "where they're from" line on the break slide
|
||||||
|
- break-blob-* the three drifting ambient blobs shown when no background
|
||||||
|
image is uploaded (keep them translucent)
|
||||||
|
──────────────────────────────────────────────────────────────────────── */
|
||||||
|
@theme {
|
||||||
|
--font-ticker: "Margarine", system-ui, sans-serif;
|
||||||
|
--font-display: "Margarine", system-ui, sans-serif;
|
||||||
|
|
||||||
|
--color-ticker-start: var(--color-cyan-800);
|
||||||
|
--color-ticker-end: var(--color-teal-500);
|
||||||
|
--color-ticker-border: var(--color-blue-400);
|
||||||
|
--color-ticker-accent: var(--color-amber-200);
|
||||||
|
|
||||||
|
--color-ad-bg-start: #0b1020;
|
||||||
|
--color-ad-bg-end: #1a2140;
|
||||||
|
|
||||||
|
--color-break-bg-a: #0a0e1a;
|
||||||
|
--color-break-bg-b: #171030;
|
||||||
|
--color-break-accent: color-mix(in srgb, var(--color-amber-300) 90%, transparent);
|
||||||
|
--color-break-blob-1: rgba(99, 102, 241, 0.35);
|
||||||
|
--color-break-blob-2: rgba(168, 85, 247, 0.3);
|
||||||
|
--color-break-blob-3: rgba(245, 158, 11, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Break slide ambient motion */
|
||||||
|
@keyframes drift-a {
|
||||||
|
from {
|
||||||
|
transform: translate(-120px, -80px) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(180px, 140px) scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift-b {
|
||||||
|
from {
|
||||||
|
transform: translate(120px, 60px) scale(1.1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(-160px, -110px) scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift-c {
|
||||||
|
from {
|
||||||
|
transform: translate(0, 120px) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(100px, -140px) scale(1.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ad background: slow pan; the 1.15 scale gives the ±3% translate room to
|
||||||
|
move without ever exposing an edge (7.5% margin > 3% × 1.15 travel) */
|
||||||
|
@keyframes ad-pan {
|
||||||
|
from {
|
||||||
|
transform: scale(1.15) translate(-3%, -2%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1.15) translate(3%, 2%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Break background: full edge-to-edge sweep that visibly reverses. At 1.15
|
||||||
|
scale the hidden margin is 7.5% of the screen per side, i.e. ~6.5% of the
|
||||||
|
element's own size — ±6%/±4% stays just inside that, so each bounce lands
|
||||||
|
right at the image edge without exposing it */
|
||||||
|
@keyframes break-bounce {
|
||||||
|
from {
|
||||||
|
transform: scale(1.15) translate(-6%, -4%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1.15) translate(6%, 4%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes breathe {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.045);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import NodeCG from "@nodecg/types";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const useReplicant = <T = any>(
|
||||||
|
replicant: NodeCG.ServerReplicant<T>,
|
||||||
|
fn: (newValue: T | undefined, oldValue: T | undefined) => void | (() => void),
|
||||||
|
deps: any[] = [],
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
let cleanup: (() => void) | void;
|
||||||
|
|
||||||
|
const handler = (newValue: T | undefined, oldValue: T | undefined) => {
|
||||||
|
if (cleanup) cleanup(); // Run previous cleanup before setting new one
|
||||||
|
cleanup = fn(newValue, oldValue); // Store new cleanup function (if provided)
|
||||||
|
};
|
||||||
|
|
||||||
|
replicant.on("change", handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
replicant.off("change", handler);
|
||||||
|
if (cleanup) cleanup();
|
||||||
|
};
|
||||||
|
}, [...deps]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicant value as React state. Values are cloned on every change because
|
||||||
|
* NodeCG patches the same object in place, which React reference-equality
|
||||||
|
* would otherwise miss.
|
||||||
|
*/
|
||||||
|
export const useReplicantValue = <T = any>(
|
||||||
|
replicant: NodeCG.ServerReplicant<T>,
|
||||||
|
): T | undefined => {
|
||||||
|
const [value, setValue] = useState<T | undefined>(undefined);
|
||||||
|
useReplicant<T>(replicant, (newValue) => {
|
||||||
|
setValue(
|
||||||
|
newValue === undefined ? undefined : JSON.parse(JSON.stringify(newValue)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { Ticker } from "./Ticker";
|
||||||
|
|
||||||
|
document.documentElement.lang =
|
||||||
|
(nodecg.bundleConfig as { locale?: string })?.locale ?? "en";
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<Ticker />);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link href="./index.css" type="text/css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./ticker-bootstrap.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.build.json",
|
||||||
|
"include": ["**/*.tsx", "**/*.ts", "../types/**/*.ts", "../../node_modules/@nodecg/types/types/augment-window.d.ts"]
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* English strings — the source of truth for the `Strings` shape. Other
|
||||||
|
* locales (sr.ts, …) must satisfy this type; `getStrings` in index.ts falls
|
||||||
|
* back to English for unknown locales.
|
||||||
|
*
|
||||||
|
* Keep this module dependency-free: it is imported by the dashboard (browser)
|
||||||
|
* and by the extension (node) for first-run replicant seeds.
|
||||||
|
*/
|
||||||
|
export const en = {
|
||||||
|
common: {
|
||||||
|
loading: "Loading…",
|
||||||
|
},
|
||||||
|
|
||||||
|
ticker: {
|
||||||
|
settings: "Settings",
|
||||||
|
loopBlurbLabel: "Recurring blurb (at every seam)",
|
||||||
|
separatorLabel: "Separator",
|
||||||
|
defaultTtlLabel: "Default duration (s)",
|
||||||
|
speedLabel: "Speed (px/s)",
|
||||||
|
newMessage: "New message",
|
||||||
|
messagePlaceholder: "Message text…",
|
||||||
|
ttlTitle: "Duration in seconds (empty = default)",
|
||||||
|
permanentTitle: "Message without expiry — removed only manually",
|
||||||
|
permanent: "Permanent",
|
||||||
|
add: "Add",
|
||||||
|
activeMessages: "Active messages",
|
||||||
|
noMessages: "No active messages — only the recurring blurb is shown.",
|
||||||
|
clickToEdit: "Click to edit the text",
|
||||||
|
edit: "Edit",
|
||||||
|
refresh: "Renew",
|
||||||
|
remove: "Remove",
|
||||||
|
},
|
||||||
|
|
||||||
|
ads: {
|
||||||
|
none: "No ads.",
|
||||||
|
rowTextPlaceholder: "Text — strip (wide part)…",
|
||||||
|
columnTextPlaceholder: "Text — column (narrow part)…",
|
||||||
|
columnImageTitle: "Column image (upload images via the Assets dialog)",
|
||||||
|
noColumnImage: "No column image",
|
||||||
|
missingImage: "(missing)",
|
||||||
|
enabled: "Enabled",
|
||||||
|
delete: "Delete",
|
||||||
|
addAd: "+ New ad",
|
||||||
|
},
|
||||||
|
|
||||||
|
choreo: {
|
||||||
|
obsSection: "OBS",
|
||||||
|
connectedScene: "Connected — scene:",
|
||||||
|
notConnected: "Not connected",
|
||||||
|
attempt: (n: number) => ` (attempt #${n})`,
|
||||||
|
stateSection: "State",
|
||||||
|
modeBreak: "Break",
|
||||||
|
modeLive: "Program",
|
||||||
|
phaseAd: "Ad",
|
||||||
|
phaseLive: "Live",
|
||||||
|
nextTransitionIn: "next transition in",
|
||||||
|
showAdNow: "Show ad now",
|
||||||
|
skipAd: "Skip ad",
|
||||||
|
breakOn: "BREAK: START",
|
||||||
|
breakOff: "BREAK: END",
|
||||||
|
breakSlideSection: "Announcement (break slide)",
|
||||||
|
headingLabel: "Heading",
|
||||||
|
nextActNameLabel: "Next act",
|
||||||
|
nextActFromLabel: "Where they're from",
|
||||||
|
logoLabel: "Act logo",
|
||||||
|
logoAuto: "Automatic (first image)",
|
||||||
|
logoSizeLabel: (px: number) => `Logo size (${px}px)`,
|
||||||
|
invertLogo: "Invert logo colors (for a dark logo on the dark backdrop)",
|
||||||
|
timingSection: "Timing",
|
||||||
|
adIntervalLabel: "Ad interval (min)",
|
||||||
|
adDurationLabel: "Ad duration (s)",
|
||||||
|
breakGapLabel: "Break: gap (s)",
|
||||||
|
breakAdDurationLabel: "Break: ad duration (s)",
|
||||||
|
breakAdsPerBurstLabel: "Break: ads per burst",
|
||||||
|
},
|
||||||
|
|
||||||
|
obsCheck: {
|
||||||
|
run: "Run checks",
|
||||||
|
running: "Checking…",
|
||||||
|
allGood: "everything checks out ✓",
|
||||||
|
intro:
|
||||||
|
"Verifies the live OBS setup against what the graphics expect: scenes, sources, transforms, transitions.",
|
||||||
|
fixPrefix: "Fix:",
|
||||||
|
status: {
|
||||||
|
pass: "pass",
|
||||||
|
warn: "warn",
|
||||||
|
fail: "fail",
|
||||||
|
skip: "skip",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/** First-run replicant defaults — only used until the operator edits them. */
|
||||||
|
seed: {
|
||||||
|
loopBlurb: "Welcome ♪",
|
||||||
|
sampleAdRow: "Follow us online!",
|
||||||
|
sampleAdColumn: "Scan the QR code 📱",
|
||||||
|
breakHeading: "Stay tuned — there's more!",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Strings = typeof en;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { en, type Strings } from "./en";
|
||||||
|
import { sr } from "./sr";
|
||||||
|
|
||||||
|
export type { Strings };
|
||||||
|
|
||||||
|
export const locales = { en, sr } satisfies Record<string, Strings>;
|
||||||
|
|
||||||
|
export type Locale = keyof typeof locales;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a locale code (usually `bundleConfig.locale`) to its strings,
|
||||||
|
* falling back to English for unknown/missing codes.
|
||||||
|
*/
|
||||||
|
export function getStrings(locale?: string): Strings {
|
||||||
|
return (locales as Record<string, Strings | undefined>)[locale ?? ""] ?? en;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Strings } from "./en";
|
||||||
|
|
||||||
|
/** Serbian (latinica). */
|
||||||
|
export const sr: Strings = {
|
||||||
|
common: {
|
||||||
|
loading: "Učitavanje…",
|
||||||
|
},
|
||||||
|
|
||||||
|
ticker: {
|
||||||
|
settings: "Podešavanja",
|
||||||
|
loopBlurbLabel: "Stalna poruka (na svakom prelazu)",
|
||||||
|
separatorLabel: "Separator",
|
||||||
|
defaultTtlLabel: "Podraz. trajanje (s)",
|
||||||
|
speedLabel: "Brzina (px/s)",
|
||||||
|
newMessage: "Nova poruka",
|
||||||
|
messagePlaceholder: "Tekst poruke…",
|
||||||
|
ttlTitle: "Trajanje u sekundama (prazno = podrazumevano)",
|
||||||
|
permanentTitle: "Poruka bez isteka — uklanja se samo ručno",
|
||||||
|
permanent: "Stalna",
|
||||||
|
add: "Dodaj",
|
||||||
|
activeMessages: "Aktivne poruke",
|
||||||
|
noMessages: "Nema aktivnih poruka — prikazuje se samo stalna poruka.",
|
||||||
|
clickToEdit: "Klikni za izmenu teksta",
|
||||||
|
edit: "Izmeni",
|
||||||
|
refresh: "Obnovi",
|
||||||
|
remove: "Ukloni",
|
||||||
|
},
|
||||||
|
|
||||||
|
ads: {
|
||||||
|
none: "Nema reklama.",
|
||||||
|
rowTextPlaceholder: "Tekst — traka (široki deo)…",
|
||||||
|
columnTextPlaceholder: "Tekst — kolona (uski deo)…",
|
||||||
|
columnImageTitle: "Slika u koloni (dodaj slike kroz Assets dijalog)",
|
||||||
|
noColumnImage: "Bez slike u koloni",
|
||||||
|
missingImage: "(nedostaje)",
|
||||||
|
enabled: "Uključena",
|
||||||
|
delete: "Obriši",
|
||||||
|
addAd: "+ Nova reklama",
|
||||||
|
},
|
||||||
|
|
||||||
|
choreo: {
|
||||||
|
obsSection: "OBS",
|
||||||
|
connectedScene: "Povezan — scena:",
|
||||||
|
notConnected: "Nije povezan",
|
||||||
|
attempt: (n: number) => ` (pokušaj #${n})`,
|
||||||
|
stateSection: "Stanje",
|
||||||
|
modeBreak: "Pauza",
|
||||||
|
modeLive: "Program",
|
||||||
|
phaseAd: "Reklama",
|
||||||
|
phaseLive: "Uživo",
|
||||||
|
nextTransitionIn: "sledeći prelaz za",
|
||||||
|
showAdNow: "Prikaži reklamu sada",
|
||||||
|
skipAd: "Preskoči reklamu",
|
||||||
|
breakOn: "PAUZA: UKLJUČI",
|
||||||
|
breakOff: "PAUZA: ISKLJUČI",
|
||||||
|
breakSlideSection: "Najava (slajd pauze)",
|
||||||
|
headingLabel: "Naslov",
|
||||||
|
nextActNameLabel: "Sledeći nastup",
|
||||||
|
nextActFromLabel: "Odakle su",
|
||||||
|
logoLabel: "Logo benda",
|
||||||
|
logoAuto: "Automatski (prva slika)",
|
||||||
|
logoSizeLabel: (px: number) => `Veličina logoa (${px}px)`,
|
||||||
|
invertLogo: "Invertuj boje logoa (za taman logo na tamnoj pozadini)",
|
||||||
|
timingSection: "Tajming",
|
||||||
|
adIntervalLabel: "Interval reklama (min)",
|
||||||
|
adDurationLabel: "Trajanje reklame (s)",
|
||||||
|
breakGapLabel: "Pauza: razmak (s)",
|
||||||
|
breakAdDurationLabel: "Pauza: trajanje reklame (s)",
|
||||||
|
breakAdsPerBurstLabel: "Pauza: reklama po turi",
|
||||||
|
},
|
||||||
|
|
||||||
|
obsCheck: {
|
||||||
|
run: "Pokreni provere",
|
||||||
|
running: "Proveravam…",
|
||||||
|
allGood: "sve je u redu ✓",
|
||||||
|
intro:
|
||||||
|
"Proverava OBS podešavanje u odnosu na ono što grafika očekuje: scene, izvore, transformacije, prelaze.",
|
||||||
|
fixPrefix: "Rešenje:",
|
||||||
|
status: {
|
||||||
|
pass: "ok",
|
||||||
|
warn: "pažnja",
|
||||||
|
fail: "greška",
|
||||||
|
skip: "presk.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
seed: {
|
||||||
|
loopBlurb: "Dobro došli ♪",
|
||||||
|
sampleAdRow: "Zapratite nas na mrežama!",
|
||||||
|
sampleAdColumn: "Skenirajte QR kod 📱",
|
||||||
|
breakHeading: "Čekajte, ima još!",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Hand-written declarations for obs-geometry.mjs — keep the two in sync.
|
||||||
|
* The implementation stays plain JS so `scripts/generate-obs-scenes.mjs`
|
||||||
|
* can import it with bare `node`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Corner = "tr" | "bl";
|
||||||
|
|
||||||
|
export interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Layout {
|
||||||
|
/** wide strip along the shrunken program's bottom/top edge */
|
||||||
|
row: Rect;
|
||||||
|
/** tall column beside the program */
|
||||||
|
column: Rect;
|
||||||
|
/** square-ish corner where row and column meet — the QR lives here */
|
||||||
|
corner: Rect;
|
||||||
|
/** gamma-shaped cutout (row ∪ column ∪ corner) for clipping the background */
|
||||||
|
clipPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Size {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemTransform {
|
||||||
|
pos: Point;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneNames {
|
||||||
|
main: string;
|
||||||
|
mainAdTr: string;
|
||||||
|
mainAdBl: string;
|
||||||
|
break: string;
|
||||||
|
breakAdTr: string;
|
||||||
|
breakAdBl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransitionNames {
|
||||||
|
default: string;
|
||||||
|
break: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceNames {
|
||||||
|
adOverlay: string;
|
||||||
|
ticker: string;
|
||||||
|
breakSlide: string;
|
||||||
|
programScene: string;
|
||||||
|
breakScene: string;
|
||||||
|
programPlaceholder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpectedItem {
|
||||||
|
source: string;
|
||||||
|
pos: Point;
|
||||||
|
scale: number;
|
||||||
|
/** false → user-customized slot; only presence is checked, not name/transform */
|
||||||
|
strict: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneModel {
|
||||||
|
/** shared nested scenes (program, break slide); items bottom → top */
|
||||||
|
nested: Record<string, ExpectedItem[]>;
|
||||||
|
/** the six scenes NodeCG switches between; items bottom → top */
|
||||||
|
driven: Record<string, ExpectedItem[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare const CANVAS: Size;
|
||||||
|
export declare const SHRINK: number;
|
||||||
|
export declare const PROGRAM: Size;
|
||||||
|
export declare const COLUMN_W: number;
|
||||||
|
export declare const ROW_H: number;
|
||||||
|
export declare const QR_CARD: number;
|
||||||
|
export declare const TICKER_OFFSET_Y: number;
|
||||||
|
export declare const LAYOUTS: Record<Corner, Layout>;
|
||||||
|
export declare function programTransform(corner: Corner): ItemTransform;
|
||||||
|
|
||||||
|
export declare const DEFAULT_SCENE_NAMES: SceneNames;
|
||||||
|
export declare const DEFAULT_TRANSITION_NAMES: TransitionNames;
|
||||||
|
export declare const DEFAULT_SOURCE_NAMES: SourceNames;
|
||||||
|
export declare function graphicUrls(
|
||||||
|
baseUrl: string,
|
||||||
|
bundleName: string,
|
||||||
|
): { ticker: string; ad: string; break: string };
|
||||||
|
|
||||||
|
export declare const MOVE_TRANSITION_KIND: string;
|
||||||
|
export declare const STINGER_TRANSITION_KIND: string;
|
||||||
|
export declare const DEFAULT_MOVE_DURATION_MS: number;
|
||||||
|
export declare const DEFAULT_STINGER_POINT_MS: number;
|
||||||
|
export declare const MOVE_TRANSITION_SETTINGS: Record<string, unknown>;
|
||||||
|
export declare function stingerSettings(
|
||||||
|
path: string,
|
||||||
|
transitionPointMs?: number,
|
||||||
|
): { preload: boolean; path: string; transition_point: number };
|
||||||
|
|
||||||
|
export declare function expectedSceneModel(options?: {
|
||||||
|
scenes?: SceneNames;
|
||||||
|
sources?: SourceNames;
|
||||||
|
tickerOffsetY?: number;
|
||||||
|
}): SceneModel;
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Single source of truth for everything the browser graphics and OBS must
|
||||||
|
* agree on: canvas size, the ad-corner shrink geometry, default scene /
|
||||||
|
* source / transition names, and the transition definitions OBS needs.
|
||||||
|
*
|
||||||
|
* Three very different consumers share this file, which is why it is plain
|
||||||
|
* ESM JavaScript with a hand-written declaration file (obs-geometry.d.mts):
|
||||||
|
* - src/graphics/Ad.tsx Parcel browser build
|
||||||
|
* - src/extension/obsVerify.ts Parcel node build
|
||||||
|
* - scripts/generate-obs-scenes.mjs bare `node`, no toolchain
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CANVAS = { w: 1920, h: 1080 };
|
||||||
|
|
||||||
|
/** The program shrinks to this factor whenever an ad corner is shown. */
|
||||||
|
export const SHRINK = 0.7;
|
||||||
|
|
||||||
|
/** Shrunken program size (1344×756 at 1080p). */
|
||||||
|
export const PROGRAM = { w: CANVAS.w * SHRINK, h: CANVAS.h * SHRINK };
|
||||||
|
|
||||||
|
/** Tall gap beside the shrunken program (576 wide at 1080p). */
|
||||||
|
export const COLUMN_W = CANVAS.w - PROGRAM.w;
|
||||||
|
|
||||||
|
/** Strip above/below the shrunken program (324 tall at 1080p). */
|
||||||
|
export const ROW_H = CANVAS.h - PROGRAM.h;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR card: the corner rect is 576×324 in both layouts; fill its height minus
|
||||||
|
* ~24px breathing room. Centered in the corner rect, i.e. x-centered to the
|
||||||
|
* column's width and y-centered to the row's height.
|
||||||
|
*/
|
||||||
|
export const QR_CARD = 276;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Y offset of the ticker browser source inside the program/break scene.
|
||||||
|
* Slightly negative values pull the ticker's bottom edge up when the venue
|
||||||
|
* screen crops the bottom of the canvas.
|
||||||
|
*/
|
||||||
|
export const TICKER_OFFSET_Y = 0;
|
||||||
|
|
||||||
|
const px = (n) => `${n}px`;
|
||||||
|
|
||||||
|
// The program shrinks to 70% (1344×756) and carries the ticker with it, so
|
||||||
|
// the ad space owns the full L-shaped gap down to the screen edges — the two
|
||||||
|
// layouts are exact mirror images of each other.
|
||||||
|
export const LAYOUTS = {
|
||||||
|
// program top-right → cutout is left column + bottom strip, QR bottom-left
|
||||||
|
tr: {
|
||||||
|
column: { x: 0, y: 0, w: COLUMN_W, h: PROGRAM.h },
|
||||||
|
corner: { x: 0, y: PROGRAM.h, w: COLUMN_W, h: ROW_H },
|
||||||
|
row: { x: COLUMN_W, y: PROGRAM.h, w: PROGRAM.w, h: ROW_H },
|
||||||
|
clipPath: `polygon(0px 0px, ${px(COLUMN_W)} 0px, ${px(COLUMN_W)} ${px(PROGRAM.h)}, ${px(CANVAS.w)} ${px(PROGRAM.h)}, ${px(CANVAS.w)} ${px(CANVAS.h)}, 0px ${px(CANVAS.h)})`,
|
||||||
|
},
|
||||||
|
// program bottom-left → cutout is top strip + right column, QR top-right
|
||||||
|
bl: {
|
||||||
|
row: { x: 0, y: 0, w: PROGRAM.w, h: ROW_H },
|
||||||
|
corner: { x: PROGRAM.w, y: 0, w: COLUMN_W, h: ROW_H },
|
||||||
|
column: { x: PROGRAM.w, y: ROW_H, w: COLUMN_W, h: PROGRAM.h },
|
||||||
|
clipPath: `polygon(0px 0px, ${px(CANVAS.w)} 0px, ${px(CANVAS.w)} ${px(CANVAS.h)}, ${px(PROGRAM.w)} ${px(CANVAS.h)}, ${px(PROGRAM.w)} ${px(ROW_H)}, 0px ${px(ROW_H)})`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical scene-item transform for the nested program/break scene in an
|
||||||
|
* ad-corner scene: top-left aligned, exact position, uniform SHRINK scale.
|
||||||
|
*/
|
||||||
|
export function programTransform(corner) {
|
||||||
|
return corner === "tr"
|
||||||
|
? { pos: { x: COLUMN_W, y: 0 }, scale: SHRINK }
|
||||||
|
: { pos: { x: 0, y: ROW_H }, scale: SHRINK };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SCENE_NAMES = {
|
||||||
|
main: "MAIN",
|
||||||
|
mainAdTr: "MAIN_AD_TR",
|
||||||
|
mainAdBl: "MAIN_AD_BL",
|
||||||
|
break: "BREAK",
|
||||||
|
breakAdTr: "BREAK_AD_TR",
|
||||||
|
breakAdBl: "BREAK_AD_BL",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TRANSITION_NAMES = {
|
||||||
|
default: "Move",
|
||||||
|
break: "Stinger",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared sources are created once and re-added by name in every scene
|
||||||
|
* ("Add Existing") — the Move transition matches items across scenes by
|
||||||
|
* source name, so the names must be identical everywhere.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_SOURCE_NAMES = {
|
||||||
|
adOverlay: "AdOverlay",
|
||||||
|
ticker: "TickerOverlay",
|
||||||
|
breakSlide: "BreakSlide",
|
||||||
|
/** nested scene holding the real program feed + the ticker */
|
||||||
|
programScene: "PROGRAM",
|
||||||
|
/** nested scene holding the break slide + the ticker */
|
||||||
|
breakScene: "BREAK_SLIDE",
|
||||||
|
/** stand-in the user replaces with their capture inside the program scene */
|
||||||
|
programPlaceholder: "Program Placeholder",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Graphics page URLs for the three browser sources. */
|
||||||
|
export function graphicUrls(baseUrl, bundleName) {
|
||||||
|
const base = baseUrl.replace(/\/+$/, "");
|
||||||
|
return {
|
||||||
|
ticker: `${base}/bundles/${bundleName}/graphics/ticker.html`,
|
||||||
|
ad: `${base}/bundles/${bundleName}/graphics/ad.html`,
|
||||||
|
break: `${base}/bundles/${bundleName}/graphics/break.html`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transitions ──────────────────────────────────────────────────────────
|
||||||
|
// obs-websocket cannot create transitions, so these definitions exist only
|
||||||
|
// for the scene-collection generator (and as `kind` checks in the verifier).
|
||||||
|
// The settings blocks are copied verbatim from a working OBS 32 setup with
|
||||||
|
// Move plugin 3.x — they are plugin-version-dependent and undocumented, so
|
||||||
|
// change them only against a live OBS that proves the new shape.
|
||||||
|
|
||||||
|
export const MOVE_TRANSITION_KIND = "move_transition";
|
||||||
|
export const STINGER_TRANSITION_KIND = "obs_stinger_transition";
|
||||||
|
|
||||||
|
export const DEFAULT_MOVE_DURATION_MS = 700;
|
||||||
|
export const DEFAULT_STINGER_POINT_MS = 1200;
|
||||||
|
|
||||||
|
export const MOVE_TRANSITION_SETTINGS = {
|
||||||
|
transition_match: "Move",
|
||||||
|
transition_in: "None",
|
||||||
|
transition_out: "None",
|
||||||
|
name_part_match: false,
|
||||||
|
easing_in: 0,
|
||||||
|
easing_out: 0,
|
||||||
|
easing_function_match: 7,
|
||||||
|
position_in: 1,
|
||||||
|
curve_in: 0.0,
|
||||||
|
nested_scenes: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function stingerSettings(path, transitionPointMs = DEFAULT_STINGER_POINT_MS) {
|
||||||
|
return { preload: true, path, transition_point: transitionPointMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expected scene model ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declarative model of the scenes NodeCG drives, consumed by both the
|
||||||
|
* scene-collection generator and the verify panel so the two can never
|
||||||
|
* disagree. Items are listed bottom → top. `strict: false` marks a slot the
|
||||||
|
* user customizes (e.g. the capture source replacing the placeholder): the
|
||||||
|
* verifier only requires *something* to be there, ignoring name/transform.
|
||||||
|
*/
|
||||||
|
export function expectedSceneModel({
|
||||||
|
scenes = DEFAULT_SCENE_NAMES,
|
||||||
|
sources = DEFAULT_SOURCE_NAMES,
|
||||||
|
tickerOffsetY = TICKER_OFFSET_Y,
|
||||||
|
} = {}) {
|
||||||
|
const full = { pos: { x: 0, y: 0 }, scale: 1 };
|
||||||
|
const item = (source, t = full, strict = true) => ({
|
||||||
|
source,
|
||||||
|
pos: t.pos,
|
||||||
|
scale: t.scale,
|
||||||
|
strict,
|
||||||
|
});
|
||||||
|
const tickerItem = item(sources.ticker, {
|
||||||
|
pos: { x: 0, y: tickerOffsetY },
|
||||||
|
scale: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const drivenScene = (content, transform) => [
|
||||||
|
// AdOverlay sits at the very bottom, full-size and identical in all six
|
||||||
|
// scenes, so Move leaves it static and the shrinking program uncovers
|
||||||
|
// the ad content in the gamma cutout.
|
||||||
|
item(sources.adOverlay),
|
||||||
|
item(content, transform),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
nested: {
|
||||||
|
[sources.programScene]: [
|
||||||
|
item(sources.programPlaceholder, full, false),
|
||||||
|
tickerItem,
|
||||||
|
],
|
||||||
|
[sources.breakScene]: [item(sources.breakSlide), tickerItem],
|
||||||
|
},
|
||||||
|
driven: {
|
||||||
|
[scenes.main]: drivenScene(sources.programScene),
|
||||||
|
[scenes.mainAdTr]: drivenScene(sources.programScene, programTransform("tr")),
|
||||||
|
[scenes.mainAdBl]: drivenScene(sources.programScene, programTransform("bl")),
|
||||||
|
[scenes.break]: drivenScene(sources.breakScene),
|
||||||
|
[scenes.breakAdTr]: drivenScene(sources.breakScene, programTransform("tr")),
|
||||||
|
[scenes.breakAdBl]: drivenScene(sources.breakScene, programTransform("bl")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+20
@@ -0,0 +1,20 @@
|
|||||||
|
export type Corner = "tr" | "bl";
|
||||||
|
|
||||||
|
export interface Ad {
|
||||||
|
id: string;
|
||||||
|
/** headline shown in the wide strip */
|
||||||
|
rowText: string;
|
||||||
|
/** text shown in the tall column */
|
||||||
|
columnText: string;
|
||||||
|
/** asset URL of the image shown in the column, null = no image */
|
||||||
|
columnImageUrl: string | null;
|
||||||
|
/** URL encoded into the QR code */
|
||||||
|
qrUrl: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdState {
|
||||||
|
visible: boolean;
|
||||||
|
corner: Corner;
|
||||||
|
adId: string | null;
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/** One file in a NodeCG `assets:<category>` replicant. */
|
||||||
|
export interface AssetFile {
|
||||||
|
base: string;
|
||||||
|
category: string;
|
||||||
|
ext: string;
|
||||||
|
name: string;
|
||||||
|
sum: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
Vendored
+57
@@ -0,0 +1,57 @@
|
|||||||
|
export interface ObsSceneNames {
|
||||||
|
main: string;
|
||||||
|
mainAdTr: string;
|
||||||
|
mainAdBl: string;
|
||||||
|
break: string;
|
||||||
|
breakAdTr: string;
|
||||||
|
breakAdBl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObsTransitionNames {
|
||||||
|
/** transition selected before ad shrink/hop switches (the Move transition) */
|
||||||
|
default: string;
|
||||||
|
/** transition selected before MAIN↔BREAK toggles (the stinger) */
|
||||||
|
break: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names of the shared OBS sources/nested scenes, for the setup verifier and
|
||||||
|
* scene generator. Defaults live in src/shared/obs-geometry.mjs
|
||||||
|
* (DEFAULT_SOURCE_NAMES); override here when an existing OBS setup uses
|
||||||
|
* different names.
|
||||||
|
*/
|
||||||
|
export interface ObsSourceNames {
|
||||||
|
adOverlay: string;
|
||||||
|
ticker: string;
|
||||||
|
breakSlide: string;
|
||||||
|
programScene: string;
|
||||||
|
breakScene: string;
|
||||||
|
programPlaceholder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObsConnectionConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
url: string;
|
||||||
|
password: string;
|
||||||
|
scenes: ObsSceneNames;
|
||||||
|
transitions: ObsTransitionNames;
|
||||||
|
sources: ObsSourceNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First-run replicant seeds — only applied when nothing is persisted yet. */
|
||||||
|
export interface BrandingConfig {
|
||||||
|
tickerBlurb?: string;
|
||||||
|
breakHeading?: string;
|
||||||
|
sampleQrUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleConfig {
|
||||||
|
/** dashboard language; defaults to "en" */
|
||||||
|
locale?: string;
|
||||||
|
branding?: BrandingConfig;
|
||||||
|
obs?: Partial<ObsConnectionConfig> & {
|
||||||
|
scenes?: Partial<ObsSceneNames>;
|
||||||
|
transitions?: Partial<ObsTransitionNames>;
|
||||||
|
sources?: Partial<ObsSourceNames>;
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Corner } from "./ads";
|
||||||
|
|
||||||
|
export interface ChoreoConfig {
|
||||||
|
normalAdIntervalMinutes: number;
|
||||||
|
adDurationSeconds: number;
|
||||||
|
/** pause between ad bursts while in break mode */
|
||||||
|
breakGapSeconds: number;
|
||||||
|
breakAdDurationSeconds: number;
|
||||||
|
breakAdsPerBurst: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulerState {
|
||||||
|
mode: "normal" | "break";
|
||||||
|
phase: "live" | "ad";
|
||||||
|
lastCorner: Corner;
|
||||||
|
lastAdIndex: number;
|
||||||
|
burstRemaining: number;
|
||||||
|
/** epoch ms of the next automatic transition, for dashboard countdown */
|
||||||
|
nextTransitionAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreakSlide {
|
||||||
|
heading: string;
|
||||||
|
nextActName: string;
|
||||||
|
nextActFrom: string;
|
||||||
|
/** invert the band logo's colors (dark logo on the dark background) */
|
||||||
|
logoInverted: boolean;
|
||||||
|
/** `base` of the assets:break-logo file to show; null = first uploaded */
|
||||||
|
logoName: string | null;
|
||||||
|
/** rendered logo height in px (width follows the aspect ratio) */
|
||||||
|
logoHeightPx: number;
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
export * from "./ticker";
|
||||||
|
export * from "./ads";
|
||||||
|
export * from "./assets";
|
||||||
|
export * from "./choreo";
|
||||||
|
export * from "./obs";
|
||||||
|
export * from "./bundleConfig";
|
||||||
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
export interface ObsStatus {
|
||||||
|
connected: boolean;
|
||||||
|
currentScene: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObsCheckStatus = "pass" | "warn" | "fail" | "skip";
|
||||||
|
|
||||||
|
export interface ObsCheckResult {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: ObsCheckStatus;
|
||||||
|
/** what was actually found (shown under the label) */
|
||||||
|
detail?: string;
|
||||||
|
/** human instructions for making the check pass */
|
||||||
|
fix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObsVerifyReport {
|
||||||
|
/** epoch ms of the last run, null before the first run */
|
||||||
|
ranAt: number | null;
|
||||||
|
running: boolean;
|
||||||
|
checks: ObsCheckResult[];
|
||||||
|
}
|
||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
export interface TickerMessage {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
/** epoch ms when the message was added (or last refreshed) */
|
||||||
|
addedAt: number;
|
||||||
|
/** null = permanent message, never swept */
|
||||||
|
ttlSeconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TickerConfig {
|
||||||
|
/** fixed blurb shown at every loop point */
|
||||||
|
loopBlurb: string;
|
||||||
|
separator: string;
|
||||||
|
defaultTtlSeconds: number;
|
||||||
|
speedPxPerSec: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Required for parcel
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react",
|
||||||
|
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
// Output related
|
||||||
|
"target": "es2021",
|
||||||
|
"outDir": "./build",
|
||||||
|
"module": "commonjs",
|
||||||
|
|
||||||
|
// Module resolution
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
// @nodecg/types 2.8 exposes its typings only via the package "exports"
|
||||||
|
// map, which classic node resolution ignores — point at them directly.
|
||||||
|
"paths": {
|
||||||
|
"@nodecg/types": ["./node_modules/@nodecg/types/types/index.d.ts"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Type checking
|
||||||
|
"strict": true,
|
||||||
|
|
||||||
|
// Extra output
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"inlineSourceMap": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "node_modules/@nodecg/types/types/augment-window.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.build.json"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user