Overlay: fuenf Browser-Sources fuer OBS

Einblendungen, Chat, Statusleiste, Effekte, Pausenbild. Kein Build, kein
Framework - OBS laedt die Dateien direkt, und jede Zwischenstufe waere ein
Ding, das mitten im Stream kaputtgehen kann.

Server-Sent Events statt WebSocket, entgegen der Skizze in chat-control.md:
Node bringt einen WebSocket-*Client* mit, aber keinen Server, `EventSource`
verbindet sich von allein neu, und die Richtung stimmt - das Overlay hoert
nur zu. Gegen die tote Browser-Source nach einem Neustart stehen drei Dinge:
der eingebaute Reconnect, ein Herzschlag als echtes Ereignis (ein
SSE-Kommentar haelt die Verbindung offen, ist im Browser aber unsichtbar),
und ein Zustands-Schnappschuss beim Verbinden.

Abos, Raids, Bits und Loeschungen kommen ueber IRC-USERNOTICE und CLEARCHAT
herein; das wurde bisher weggeworfen. Follows fehlen weiter, die gibt es nur
ueber EventSub.

GIFs im Chat: Twitch schickt dafuer einen Tag `gifs`, den wir nicht kannten.

  gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif
  PRIVMSG #abdulive_ :[Idk GIF by Respective]

Der Nachrichtentext ist nur ein Platzhalter, der Bereich markiert ihn.
Ausgewertet wird er jetzt; ohne das stand woertlich "[Idk GIF by Respective]"
im Bild. Gefunden wurde der Tag durch einen Rohmitschnitt - in 3094
Nachrichten aus acht grossen Kanaelen war kein einziges GIF, weil die Adresse
eben nicht im Text steht.

Zwei Fehler, die beim Durchklicken im Browser aufgefallen sind:

- Die Aufraeumfunktion im Chat zaehlte Zeilen, die erst nach der
  Ausblend-Animation aus dem DOM verschwinden, und lief in eine
  Endlosschleife. In OBS waere die Quelle beim vierzehnten Chatbeitrag
  eingefroren.
- Der Bereichsparser der GIFs war zu nachsichtig: `-1-4` zerfaellt beim
  Trennen in ['', '1', '4'], und `Number('')` ist 0 - der Bereich saehe
  gueltig aus und wuerde den falschen Textteil ersetzen.

Auf der Uebersichtsseite steht fuer jede Einblendung, jede Chat-Variante und
jeden Effekt ein Knopf. Ohne den liesse sich eine Raid-Einblendung nur
pruefen, indem man auf einen echten Raid wartet. Was einen Zustand setzt, ist
waehrend eines Streams gesperrt; "Stand zuruecksetzen" ausdruecklich nicht,
sonst kaeme man aus einem erfundenen Zustand nicht mehr heraus.

build-package.ts nimmt jetzt auch html/css/js mit - sonst liefert das Paket
404 statt Overlay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-18 00:13:28 +02:00
1 parent 3b606e36fb
commit bc2c8f6cbe
24 files changed
+3221 -8

No files matched your search

+48 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseLine, toChatMessage } from './irc.ts';
import { parseLine, toChatMessage, parseGifs } from './irc.ts';
const LINE =
'@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' +
@@ -57,3 +57,50 @@ test('PING wird als solches erkannt', () => {
assert.equal(m?.command, 'PING');
assert.equal(m?.params[0], 'tmi.twitch.tv');
});
// ---------------------------------------------------------------- GIFs
test('der gifs-Tag wird zerlegt', () => {
// Echte Zeile aus #abdulive_, mitgeschnitten am 2026-09-17.
const g = parseGifs(
'0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/Pnd7qLFuGCOntZcNKB/giphy.gif?cid=095d&ct=g');
assert.equal(g.length, 1);
assert.equal(g[0]!.id, 'Pnd7qLFuGCOntZcNKB');
assert.equal(g[0]!.from, 0);
assert.equal(g[0]!.to, 22);
assert.match(g[0]!.url, /^https:\/\/media0\.giphy\.com\//);
assert.ok(g[0]!.url.includes('&ct=g'), 'die Adresse darf nicht am & abgeschnitten werden');
});
test('der Bereich deckt genau den Platzhalter ab', () => {
// Wenn das nicht stimmt, bleibt ein Rest der eckigen Klammer im Bild stehen.
const text = '[Idk GIF by Respective]';
const g = parseGifs('0-22|abc|https://x/y.gif')[0]!;
assert.equal([...text].slice(g.from, g.to + 1).join(''), text);
});
test('eine Nachricht ohne GIF hat eine leere Liste', () => {
const m = parseLine('@id=1;user-id=7;emotes= :a!a@a PRIVMSG #k :moin');
assert.deepEqual(toChatMessage(m!)!.gifs, []);
});
test('eine Nachricht mit GIF traegt es bis in die ChatMessage', () => {
const m = parseLine(
'@id=1;user-id=7;emotes=;gifs=0-4|xy|https://media.giphy.com/a.gif ' +
':a!a@a PRIVMSG #k :[GIF]');
assert.equal(toChatMessage(m!)!.gifs[0]!.url, 'https://media.giphy.com/a.gif');
});
test('kein https, kein Bild', () => {
// Der Tag kommt von Twitch, nicht vom Chatter - trotzdem landet die Adresse
// als Bild im Stream, und dort gilt kein Vertrauensvorschuss.
assert.deepEqual(parseGifs('0-4|xy|http://media.giphy.com/a.gif'), []);
assert.deepEqual(parseGifs('0-4|xy|javascript:alert(1)'), []);
});
test('unvollstaendige oder unsinnige Tags werden verworfen', () => {
for (const kaputt of ['', '0-4', '0-4|nurid', 'abc|x|https://y/z.gif', '-1-4|x|https://y/z.gif',
'9-4|x|https://y/z.gif']) {
assert.deepEqual(parseGifs(kaputt), [], `"${kaputt}" haette verworfen gehoert`);
}
});