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