S1: Einrichtungs-Assistent, Twitch-Auth, Datenbank und Chat-Aufnahme
Zugangsdaten kommen jetzt aus der App statt aus einer .env: ein Einrichtungs-Assistent auf http://localhost:4792/setup nimmt Client-ID, Secret und OBS-Passwort entgegen, der OAuth-Rueckruf laeuft auf denselben Server. Gespeichert wird verschlueselt (AES-256-GCM) in einem Volume, bewusst nicht in der Datenbank - die wird auf die Windows-Seite gesichert. Siehe ADR-0005. Technikbasis vereinfacht: Node 24 bringt node:sqlite, WebSocket, fetch und die Ausfuehrung von TypeScript mit. Das Projekt hat damit null Laufzeitabhaengigkeiten und keinen Build-Schritt; pnpm-Workspaces und die Paketmanifeste sind entfallen, Importe laufen ueber die imports-Map der Wurzel-package.json. erasableSyntaxOnly verhindert Syntax, die das Type Stripping stillschweigend entfernen wuerde. Siehe Nachtrag 2 in ADR-0001. Enthalten: - packages/core: Config, Layout, Logger mit Secret-Maskierung, Retry, YAML - packages/db: Migrationen, Repositories, alle SQL-Anweisungen an einem Ort - packages/twitch: Secret-Speicher, OAuth, Helix mit Token-Bucket, IRC - apps/ingest: Daemon, Einrichtungsseite, Spike-Erkennung - 20 Tests (IRC-Parser, Spike-Detektor, Datenbankschicht) Der Chat verbindet sich anonym, solange nichts hinterlegt ist - damit laesst sich die Datenaufnahme pruefen, bevor die Twitch-Apps existieren. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
1 parent
4ceb61f095
commit
d07c18441b
82 files changed
+2775
-838
No files matched your search
+17
-12
@@ -1,8 +1,9 @@
|
||||
# Scheibe S0 aus docs/07-mvp-plan.md: Toolchain-Image.
|
||||
# Scheiben S0 und S1 aus docs/07-mvp-plan.md.
|
||||
#
|
||||
# Bewusst noch ohne `pnpm install` - es gibt noch keine Abhaengigkeiten und
|
||||
# keine Lockfile. Der Entrypoint kommt mit reinem Node aus. Sobald in S1
|
||||
# echter Code dazukommt, wird hier eine deps-Stufe ergaenzt.
|
||||
# Das Projekt hat KEINE npm-Abhaengigkeiten: Node 24 bringt SQLite
|
||||
# (node:sqlite), WebSocket und fetch mit und fuehrt TypeScript direkt aus
|
||||
# (Type Stripping). Deshalb gibt es weder eine deps-Stufe noch einen
|
||||
# Build-Schritt. Siehe ADR-0001 und ADR-0004.
|
||||
|
||||
# ---------- whisper.cpp bauen ----------
|
||||
FROM debian:bookworm-slim AS whisper
|
||||
@@ -18,11 +19,10 @@ RUN cmake -B build -DCMAKE_BUILD_TYPE=Release -DWHISPER_BUILD_TESTS=OFF \
|
||||
&& cmake --build build --config Release -j"$(nproc)" \
|
||||
&& mkdir -p /out \
|
||||
&& cp build/bin/whisper-cli /out/ \
|
||||
&& (cp build/src/libwhisper.so* /out/ 2>/dev/null || true) \
|
||||
&& (cp build/ggml/src/libggml*.so /out/ 2>/dev/null || true)
|
||||
&& find build -name 'libwhisper.so*' -o -name 'libggml*.so' | xargs -r cp -P -t /out/
|
||||
|
||||
# ---------- Laufzeit ----------
|
||||
FROM node:22-bookworm-slim
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg fonts-dejavu-core ca-certificates curl \
|
||||
@@ -34,10 +34,13 @@ RUN ln -s /usr/local/lib/whisper/whisper-cli /usr/local/bin/whisper-cli \
|
||||
&& ldconfig
|
||||
|
||||
WORKDIR /app
|
||||
COPY docker/ ./docker/
|
||||
# package.json zuerst: sie enthaelt die "imports"-Map, ueber die #core/... aufgeloest wird
|
||||
COPY package.json ./
|
||||
COPY packages/ ./packages/
|
||||
COPY apps/ ./apps/
|
||||
COPY docker/ ./docker/
|
||||
|
||||
# Die Datenbank und die Sprachmodelle liegen in benannten Volumes, nicht im
|
||||
# Datenbank und Sprachmodelle liegen in benannten Volumes, nicht im
|
||||
# Bind-Mount - siehe docs/decisions/ADR-0004-docker.md
|
||||
ENV WHISPER_BIN=/usr/local/bin/whisper-cli \
|
||||
MODEL_DIR=/models \
|
||||
@@ -46,9 +49,11 @@ ENV WHISPER_BIN=/usr/local/bin/whisper-cli \
|
||||
CLIPS_DIR=/app/data/clips \
|
||||
REPLAY_DIR=/replay \
|
||||
CONFIG_DIR=/app/config \
|
||||
NODE_ENV=production
|
||||
NODE_ENV=production \
|
||||
NODE_OPTIONS=--disable-warning=ExperimentalWarning \
|
||||
BIND_HOST=0.0.0.0
|
||||
|
||||
EXPOSE 4780 4790 4791 4792
|
||||
EXPOSE 4790 4791 4792
|
||||
|
||||
ENTRYPOINT ["node", "/app/docker/entrypoint.js"]
|
||||
ENTRYPOINT ["node", "/app/docker/entrypoint.ts"]
|
||||
CMD ["help"]
|
||||
@@ -1,353 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Einstiegspunkt des Container-Images. Scheibe S0 aus docs/07-mvp-plan.md.
|
||||
//
|
||||
// Bewusst ohne npm-Abhaengigkeiten: dieses Skript muss laufen, bevor
|
||||
// irgendetwas anderes installiert oder gebaut ist.
|
||||
|
||||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { parseYaml } from './lib/yaml.js';
|
||||
|
||||
const ENV = {
|
||||
whisper: process.env.WHISPER_BIN || 'whisper-cli',
|
||||
modelDir: process.env.MODEL_DIR || '/models',
|
||||
dbDir: process.env.DB_DIR || '/db',
|
||||
workDir: process.env.WORK_DIR || '/work',
|
||||
clipsDir: process.env.CLIPS_DIR || '/app/data/clips',
|
||||
replayDir: process.env.REPLAY_DIR || '/replay',
|
||||
configDir: process.env.CONFIG_DIR || '/app/config',
|
||||
obsHost: process.env.OBS_WS_HOST || 'host.docker.internal',
|
||||
obsPort: Number(process.env.OBS_WS_PORT || 4455),
|
||||
};
|
||||
|
||||
const MODELS = {
|
||||
'ggml-small.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
|
||||
'ggml-medium.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin',
|
||||
'ggml-base.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Ausgabe
|
||||
|
||||
const C = process.stdout.isTTY
|
||||
? { ok: '\x1b[32m', warn: '\x1b[33m', bad: '\x1b[31m', dim: '\x1b[90m', off: '\x1b[0m' }
|
||||
: { ok: '', warn: '', bad: '', dim: '', off: '' };
|
||||
|
||||
let hardFailures = 0;
|
||||
let warnings = 0;
|
||||
|
||||
function report(state, label, detail = '') {
|
||||
const mark = { ok: `${C.ok} OK ${C.off}`, warn: `${C.warn} WARN ${C.off}`, bad: `${C.bad} FEHLT${C.off}` }[state];
|
||||
if (state === 'bad') hardFailures++;
|
||||
if (state === 'warn') warnings++;
|
||||
console.log(`${mark} ${label.padEnd(34)} ${C.dim}${detail}${C.off}`);
|
||||
}
|
||||
|
||||
function section(title) {
|
||||
console.log(`\n${title}`);
|
||||
console.log('-'.repeat(Math.max(title.length, 40)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Helfer
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
|
||||
}
|
||||
|
||||
function runAsync(cmd, args, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { maxBuffer: 32 * 1024 * 1024, ...opts }, (err, stdout, stderr) =>
|
||||
err ? reject(Object.assign(err, { stdout, stderr })) : resolve({ stdout, stderr })
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function canWrite(dir) {
|
||||
const probe = path.join(dir, `.probe-${process.pid}`);
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(probe, 'x');
|
||||
await fs.unlink(probe);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function tcpProbe(host, port, timeout = 2500) {
|
||||
return new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
const done = (result) => { sock.destroy(); resolve(result); };
|
||||
sock.setTimeout(timeout);
|
||||
sock.once('connect', () => done(true));
|
||||
sock.once('timeout', () => done(false));
|
||||
sock.once('error', () => done(false));
|
||||
sock.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
async function readLayout() {
|
||||
const file = path.join(ENV.configDir, 'layout.yaml');
|
||||
const text = await fs.readFile(file, 'utf8');
|
||||
return { file, layout: parseYaml(text) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- check-env
|
||||
|
||||
async function checkEnv() {
|
||||
console.log('\nabdulive-toolkit - Selbsttest der Umgebung');
|
||||
|
||||
section('Laufzeit');
|
||||
const major = Number(process.versions.node.split('.')[0]);
|
||||
report(major >= 22 ? 'ok' : 'bad', 'Node.js', `v${process.versions.node}`);
|
||||
|
||||
section('Medienwerkzeuge');
|
||||
try {
|
||||
const v = run('ffmpeg', ['-version']).split('\n')[0];
|
||||
report('ok', 'ffmpeg', v.replace('ffmpeg version ', '').slice(0, 40));
|
||||
const cfg = run('ffmpeg', ['-hide_banner', '-buildconf']);
|
||||
report(cfg.includes('--enable-libx264') ? 'ok' : 'bad', ' libx264 (H.264-Encoder)');
|
||||
report(cfg.includes('--enable-libass') ? 'ok' : 'bad', ' libass (Untertitel einbrennen)');
|
||||
} catch {
|
||||
report('bad', 'ffmpeg', 'nicht im PATH');
|
||||
}
|
||||
try {
|
||||
run('ffprobe', ['-version']);
|
||||
report('ok', 'ffprobe');
|
||||
} catch {
|
||||
report('bad', 'ffprobe', 'nicht im PATH');
|
||||
}
|
||||
try {
|
||||
const out = run(ENV.whisper, ['--help'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
report('ok', 'whisper-cli', out.split('\n')[0].slice(0, 40) || ENV.whisper);
|
||||
} catch (err) {
|
||||
// whisper-cli beendet sich bei --help teils mit Code != 0, Ausgabe zaehlt
|
||||
const text = `${err.stdout || ''}${err.stderr || ''}`;
|
||||
report(text.includes('usage') || text.includes('whisper') ? 'ok' : 'bad', 'whisper-cli',
|
||||
text.includes('whisper') ? '' : 'nicht lauffaehig');
|
||||
}
|
||||
|
||||
section('Sprachmodell');
|
||||
let anyModel = false;
|
||||
for (const name of Object.keys(MODELS)) {
|
||||
try {
|
||||
const st = await fs.stat(path.join(ENV.modelDir, name));
|
||||
report('ok', name, `${(st.size / 1024 / 1024).toFixed(0)} MB`);
|
||||
anyModel = true;
|
||||
} catch { /* nicht vorhanden, ist in Ordnung */ }
|
||||
}
|
||||
if (!anyModel) report('warn', 'kein Modell vorhanden', 'holen mit: fetch-model ggml-small.bin');
|
||||
|
||||
section('Verzeichnisse');
|
||||
for (const [label, dir] of [
|
||||
['Datenbank (Volume)', ENV.dbDir],
|
||||
['Arbeitsverzeichnis (Volume)', ENV.workDir],
|
||||
['Sprachmodelle (Volume)', ENV.modelDir],
|
||||
['Clips (Bind-Mount)', ENV.clipsDir],
|
||||
]) {
|
||||
report(await canWrite(dir) ? 'ok' : 'bad', label, `${dir} beschreibbar`);
|
||||
}
|
||||
for (const [label, dir, hard] of [
|
||||
['OBS-Wiedergabepuffer', ENV.replayDir, false],
|
||||
['Konfiguration', ENV.configDir, true],
|
||||
]) {
|
||||
try {
|
||||
const n = (await fs.readdir(dir)).length;
|
||||
report('ok', label, `${dir} lesbar, ${n} Eintraege`);
|
||||
} catch {
|
||||
report(hard ? 'bad' : 'warn', label, `${dir} nicht lesbar - Mount gesetzt?`);
|
||||
}
|
||||
}
|
||||
|
||||
section('Layout (Facecam-Ausschnitt)');
|
||||
try {
|
||||
const { layout } = await readLayout();
|
||||
const prof = layout.profiles?.default;
|
||||
if (!prof) throw new Error('Profil "default" fehlt');
|
||||
const { cam, game } = prof;
|
||||
const sum = cam.band.h + game.band.h;
|
||||
report(sum === layout.target.height ? 'ok' : 'bad', 'Baender ergeben Zielhoehe',
|
||||
`${cam.band.h} + ${game.band.h} = ${sum} (soll ${layout.target.height})`);
|
||||
for (const [name, c] of [['Facecam', cam.crop], ['Gameplay', game.crop]]) {
|
||||
const inside = c.x >= 0 && c.y >= 0 &&
|
||||
c.x + c.w <= layout.source.width && c.y + c.h <= layout.source.height;
|
||||
report(inside ? 'ok' : 'bad', `${name}-Ausschnitt im Bild`,
|
||||
`${c.w}x${c.h} bei ${c.x},${c.y}`);
|
||||
}
|
||||
for (const [name, c, b] of [['Facecam', cam.crop, cam.band], ['Gameplay', game.crop, game.band]]) {
|
||||
const dev = Math.abs((c.w / c.h) / (b.w / b.h) - 1) * 100;
|
||||
report(dev < 1 ? 'ok' : 'warn', `${name}-Seitenverhaeltnis`,
|
||||
`Abweichung ${dev.toFixed(2)} % (>1 % verzerrt sichtbar)`);
|
||||
}
|
||||
} catch (err) {
|
||||
report('bad', 'config/layout.yaml', err.message);
|
||||
}
|
||||
|
||||
section('OBS');
|
||||
const obsUp = await tcpProbe(ENV.obsHost, ENV.obsPort);
|
||||
report(obsUp ? 'ok' : 'warn', 'obs-websocket erreichbar',
|
||||
`${ENV.obsHost}:${ENV.obsPort}${obsUp ? '' : ' - OBS laeuft nicht oder WebSocket-Server ist aus'}`);
|
||||
|
||||
section('Zugangsdaten (.env)');
|
||||
for (const key of ['TWITCH_CLIENT_ID', 'TWITCH_CLIENT_SECRET', 'OBS_WS_PASSWORD']) {
|
||||
report(process.env[key] ? 'ok' : 'warn', key, process.env[key] ? 'gesetzt' : 'fehlt');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (hardFailures > 0) {
|
||||
console.log(`${C.bad}${hardFailures} harte Fehler${C.off}, ${warnings} Warnungen. Das Image ist noch nicht einsatzbereit.`);
|
||||
process.exitCode = 1;
|
||||
} else if (warnings > 0) {
|
||||
console.log(`${C.ok}Keine harten Fehler${C.off}, ${warnings} Warnungen - die sind fuer S0 in Ordnung.`);
|
||||
} else {
|
||||
console.log(`${C.ok}Alles in Ordnung.${C.off}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- fetch-model
|
||||
|
||||
async function fetchModel(name = 'ggml-small.bin') {
|
||||
const url = MODELS[name];
|
||||
if (!url) {
|
||||
console.error(`Unbekanntes Modell "${name}". Bekannt: ${Object.keys(MODELS).join(', ')}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
await fs.mkdir(ENV.modelDir, { recursive: true });
|
||||
const target = path.join(ENV.modelDir, name);
|
||||
try {
|
||||
const st = await fs.stat(target);
|
||||
console.log(`${name} liegt schon da (${(st.size / 1024 / 1024).toFixed(0)} MB).`);
|
||||
return;
|
||||
} catch { /* nicht da, also laden */ }
|
||||
|
||||
console.log(`Lade ${name} ...`);
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new Error(`Download fehlgeschlagen: HTTP ${res.status}`);
|
||||
const total = Number(res.headers.get('content-length') || 0);
|
||||
let seen = 0;
|
||||
let lastPrint = 0;
|
||||
const tmp = `${target}.part`;
|
||||
const out = createWriteStream(tmp);
|
||||
const body = res.body.getReader ? res.body : res.body;
|
||||
await pipeline(
|
||||
(async function* () {
|
||||
for await (const chunk of body) {
|
||||
seen += chunk.length;
|
||||
const now = Date.now();
|
||||
if (now - lastPrint > 1000) {
|
||||
lastPrint = now;
|
||||
const pct = total ? ` (${((seen / total) * 100).toFixed(0)} %)` : '';
|
||||
process.stdout.write(`\r ${(seen / 1024 / 1024).toFixed(0)} MB${pct} `);
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
})(),
|
||||
out
|
||||
);
|
||||
await fs.rename(tmp, target);
|
||||
console.log(`\rFertig: ${target} (${(seen / 1024 / 1024).toFixed(0)} MB)`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- calibrate
|
||||
|
||||
function buildFilter(prof) {
|
||||
const { cam, game } = prof;
|
||||
return [
|
||||
`[0:v]crop=${cam.crop.w}:${cam.crop.h}:${cam.crop.x}:${cam.crop.y},scale=${cam.band.w}:${cam.band.h},setsar=1[cam]`,
|
||||
`[0:v]crop=${game.crop.w}:${game.crop.h}:${game.crop.x}:${game.crop.y},scale=${game.band.w}:${game.band.h},setsar=1[game]`,
|
||||
`[cam][game]vstack=inputs=2[v]`,
|
||||
].join(';');
|
||||
}
|
||||
|
||||
async function calibrate(input, profileName = 'default') {
|
||||
const { layout } = await readLayout();
|
||||
const prof = layout.profiles?.[profileName];
|
||||
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
|
||||
|
||||
if (!input) {
|
||||
const files = (await fs.readdir(ENV.replayDir).catch(() => []))
|
||||
.filter((f) => /\.(mp4|mkv|mov|flv|png|jpg)$/i.test(f))
|
||||
.sort();
|
||||
input = files.length ? path.join(ENV.replayDir, files[files.length - 1]) : null;
|
||||
if (!input) throw new Error(`Keine Datei angegeben und nichts in ${ENV.replayDir} gefunden.`);
|
||||
console.log(`Neueste Datei aus ${ENV.replayDir}: ${path.basename(input)}`);
|
||||
}
|
||||
|
||||
const outDir = path.join(ENV.clipsDir, '_calibration');
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
|
||||
const isImage = /\.(png|jpe?g)$/i.test(input);
|
||||
const seek = isImage ? [] : ['-ss', '5'];
|
||||
|
||||
// 1. Referenzbild aus der Quelle, unveraendert
|
||||
const frame = path.join(outDir, 'frame.png');
|
||||
await runAsync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
|
||||
...seek, '-i', input, '-frames:v', '1', frame]);
|
||||
|
||||
// 2. Vorschau der 9:16-Komposition mit den Werten aus layout.yaml
|
||||
const preview = path.join(outDir, `preview-${profileName}.png`);
|
||||
await runAsync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
|
||||
'-i', frame, '-filter_complex', buildFilter(prof), '-map', '[v]',
|
||||
'-frames:v', '1', preview]);
|
||||
|
||||
// 3. Kontrollbild: Ausschnitte im Originalbild markiert
|
||||
const marked = path.join(outDir, 'ausschnitte.png');
|
||||
const box = (c, color) =>
|
||||
`drawbox=x=${c.x}:y=${c.y}:w=${c.w}:h=${c.h}:color=${color}:t=4`;
|
||||
await runAsync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
|
||||
'-i', frame, '-vf',
|
||||
`${box(prof.cam.crop, 'yellow')},${box(prof.game.crop, 'cyan')}`,
|
||||
'-frames:v', '1', marked]);
|
||||
|
||||
console.log('\nGeschrieben:');
|
||||
console.log(` ${frame} Einzelbild aus der Quelle`);
|
||||
console.log(` ${marked} Ausschnitte markiert (gelb = Cam, cyan = Gameplay)`);
|
||||
console.log(` ${preview} fertige 9:16-Vorschau`);
|
||||
console.log('\nSitzt der Ausschnitt nicht, die Werte in config/layout.yaml anpassen');
|
||||
console.log('und den Befehl erneut ausfuehren.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- help
|
||||
|
||||
function help() {
|
||||
console.log(`
|
||||
abdulive-toolkit - Container-Einstiegspunkt
|
||||
|
||||
check-env Umgebung pruefen (Werkzeuge, Volumes, Layout, OBS)
|
||||
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
|
||||
calibrate [datei] [profil] Facecam-Ausschnitt pruefen, Vorschau rendern
|
||||
help Diese Uebersicht
|
||||
|
||||
Beispiele:
|
||||
docker compose run --rm toolkit check-env
|
||||
docker compose run --rm toolkit fetch-model ggml-small.bin
|
||||
docker compose run --rm toolkit calibrate
|
||||
|
||||
Stand: Scheibe S0 aus docs/07-mvp-plan.md. Ingest, Clipping und Untertitel
|
||||
folgen in S1 bis S4.
|
||||
`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- main
|
||||
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
try {
|
||||
switch (cmd) {
|
||||
case 'check-env': await checkEnv(); break;
|
||||
case 'fetch-model': await fetchModel(args[0]); break;
|
||||
case 'calibrate': await calibrate(args[0], args[1]); break;
|
||||
case 'help': case undefined: help(); break;
|
||||
default:
|
||||
console.error(`Unbekannter Befehl "${cmd}".`);
|
||||
help();
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`\n${C.bad}Fehler:${C.off} ${err.message}`);
|
||||
if (err.stderr) console.error(String(err.stderr).trim().split('\n').slice(-5).join('\n'));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env node
|
||||
// Einstiegspunkt des Container-Images.
|
||||
|
||||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { loadLayout, paths, type LayoutProfile, type Rect } from '#core/config.ts';
|
||||
import { openDb } from '#db/index.ts';
|
||||
import { secretStatus } from '#twitch/secrets.ts';
|
||||
|
||||
const WHISPER = process.env.WHISPER_BIN || 'whisper-cli';
|
||||
const OBS_HOST = process.env.OBS_WS_HOST || 'host.docker.internal';
|
||||
const OBS_PORT = Number(process.env.OBS_WS_PORT || 4455);
|
||||
|
||||
const MODELS: Record<string, string> = {
|
||||
'ggml-small.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
|
||||
'ggml-medium.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin',
|
||||
'ggml-base.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin',
|
||||
};
|
||||
|
||||
const C = process.stdout.isTTY
|
||||
? { ok: '\x1b[32m', warn: '\x1b[33m', bad: '\x1b[31m', dim: '\x1b[90m', off: '\x1b[0m' }
|
||||
: { ok: '', warn: '', bad: '', dim: '', off: '' };
|
||||
|
||||
let hardFailures = 0;
|
||||
let warnings = 0;
|
||||
|
||||
function report(state: 'ok' | 'warn' | 'bad', label: string, detail = ''): void {
|
||||
const mark = { ok: `${C.ok} OK ${C.off}`, warn: `${C.warn} WARN ${C.off}`, bad: `${C.bad} FEHLT${C.off}` }[state];
|
||||
if (state === 'bad') hardFailures++;
|
||||
if (state === 'warn') warnings++;
|
||||
console.log(`${mark} ${label.padEnd(34)} ${C.dim}${detail}${C.off}`);
|
||||
}
|
||||
|
||||
function section(title: string): void {
|
||||
console.log(`\n${title}`);
|
||||
console.log('-'.repeat(Math.max(title.length, 40)));
|
||||
}
|
||||
|
||||
function run(cmd: string, args: string[]): string {
|
||||
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
}
|
||||
|
||||
function runAsync(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) =>
|
||||
err ? reject(Object.assign(err, { stdout, stderr })) : resolve({ stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function canWrite(dir: string): Promise<boolean> {
|
||||
const probe = path.join(dir, `.probe-${process.pid}`);
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(probe, 'x');
|
||||
await fs.unlink(probe);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function tcpProbe(host: string, port: number, timeout = 2500): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
const done = (r: boolean) => { sock.destroy(); resolve(r); };
|
||||
sock.setTimeout(timeout);
|
||||
sock.once('connect', () => done(true));
|
||||
sock.once('timeout', () => done(false));
|
||||
sock.once('error', () => done(false));
|
||||
sock.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- check-env
|
||||
|
||||
async function checkEnv(): Promise<void> {
|
||||
console.log('\nabdulive-toolkit - Selbsttest der Umgebung');
|
||||
|
||||
section('Laufzeit');
|
||||
const major = Number(process.versions.node.split('.')[0]);
|
||||
report(major >= 24 ? 'ok' : 'bad', 'Node.js', `v${process.versions.node}`);
|
||||
try {
|
||||
const { DatabaseSync } = await import('node:sqlite');
|
||||
new DatabaseSync(':memory:').close();
|
||||
report('ok', 'node:sqlite (eingebaut)');
|
||||
} catch (err) {
|
||||
report('bad', 'node:sqlite', (err as Error).message.slice(0, 50));
|
||||
}
|
||||
report(typeof WebSocket !== 'undefined' ? 'ok' : 'bad', 'WebSocket (eingebaut)');
|
||||
|
||||
section('Medienwerkzeuge');
|
||||
try {
|
||||
const v = run('ffmpeg', ['-version']).split('\n')[0] ?? '';
|
||||
report('ok', 'ffmpeg', v.replace('ffmpeg version ', '').slice(0, 40));
|
||||
const cfg = run('ffmpeg', ['-hide_banner', '-buildconf']);
|
||||
report(cfg.includes('--enable-libx264') ? 'ok' : 'bad', ' libx264 (H.264-Encoder)');
|
||||
report(cfg.includes('--enable-libass') ? 'ok' : 'bad', ' libass (Untertitel einbrennen)');
|
||||
} catch { report('bad', 'ffmpeg', 'nicht im PATH'); }
|
||||
try { run('ffprobe', ['-version']); report('ok', 'ffprobe'); }
|
||||
catch { report('bad', 'ffprobe', 'nicht im PATH'); }
|
||||
try {
|
||||
run(WHISPER, ['--help']);
|
||||
report('ok', 'whisper-cli', WHISPER);
|
||||
} catch (err) {
|
||||
const text = `${(err as { stdout?: string }).stdout ?? ''}${(err as { stderr?: string }).stderr ?? ''}`;
|
||||
report(text.includes('whisper') || text.includes('usage') ? 'ok' : 'bad', 'whisper-cli',
|
||||
text.includes('whisper') ? WHISPER : 'nicht lauffaehig');
|
||||
}
|
||||
|
||||
section('Sprachmodell');
|
||||
let anyModel = false;
|
||||
for (const name of Object.keys(MODELS)) {
|
||||
try {
|
||||
const st = await fs.stat(path.join(paths.models, name));
|
||||
report('ok', name, `${(st.size / 1024 / 1024).toFixed(0)} MB`);
|
||||
anyModel = true;
|
||||
} catch { /* nicht vorhanden */ }
|
||||
}
|
||||
if (!anyModel) report('warn', 'kein Modell vorhanden', 'holen mit: fetch-model ggml-small.bin');
|
||||
|
||||
section('Verzeichnisse');
|
||||
for (const [label, dir] of [
|
||||
['Datenbank (Volume)', paths.db],
|
||||
['Arbeitsverzeichnis (Volume)', paths.work],
|
||||
['Sprachmodelle (Volume)', paths.models],
|
||||
['Clips (Bind-Mount)', paths.clips],
|
||||
] as const) {
|
||||
report(await canWrite(dir) ? 'ok' : 'bad', label, `${dir} beschreibbar`);
|
||||
}
|
||||
for (const [label, dir, hard] of [
|
||||
['OBS-Wiedergabepuffer', paths.replay, false],
|
||||
['Konfiguration', paths.config, true],
|
||||
] as const) {
|
||||
try {
|
||||
const n = (await fs.readdir(dir)).length;
|
||||
report('ok', label, `${dir} lesbar, ${n} Eintraege`);
|
||||
} catch { report(hard ? 'bad' : 'warn', label, `${dir} nicht lesbar - Mount gesetzt?`); }
|
||||
}
|
||||
|
||||
section('Datenbank');
|
||||
try {
|
||||
const db = openDb();
|
||||
const n = (db.prepare('SELECT COUNT(*) AS n FROM schema_migrations').get() as { n: number }).n;
|
||||
report('ok', 'Schema', `${n} Migration(en) angewendet`);
|
||||
} catch (err) {
|
||||
report('bad', 'Schema', (err as Error).message.slice(0, 60));
|
||||
}
|
||||
|
||||
section('Layout (Facecam-Ausschnitt)');
|
||||
try {
|
||||
const layout = loadLayout();
|
||||
const prof = layout.profiles?.['default'];
|
||||
if (!prof) throw new Error('Profil "default" fehlt');
|
||||
const sum = prof.cam.band.h + prof.game.band.h;
|
||||
report(sum === layout.target.height ? 'ok' : 'bad', 'Baender ergeben Zielhoehe',
|
||||
`${prof.cam.band.h} + ${prof.game.band.h} = ${sum} (soll ${layout.target.height})`);
|
||||
for (const [name, c] of [['Facecam', prof.cam.crop], ['Gameplay', prof.game.crop]] as const) {
|
||||
const inside = c.x >= 0 && c.y >= 0 &&
|
||||
c.x + c.w <= layout.source.width && c.y + c.h <= layout.source.height;
|
||||
report(inside ? 'ok' : 'bad', `${name}-Ausschnitt im Bild`, `${c.w}x${c.h} bei ${c.x},${c.y}`);
|
||||
}
|
||||
for (const [name, c, b] of [
|
||||
['Facecam', prof.cam.crop, prof.cam.band], ['Gameplay', prof.game.crop, prof.game.band],
|
||||
] as const) {
|
||||
const dev = Math.abs((c.w / c.h) / (b.w / b.h) - 1) * 100;
|
||||
report(dev < 1 ? 'ok' : 'warn', `${name}-Seitenverhaeltnis`,
|
||||
`Abweichung ${dev.toFixed(2)} % (>1 % verzerrt sichtbar)`);
|
||||
}
|
||||
} catch (err) {
|
||||
report('bad', 'config/layout.yaml', (err as Error).message);
|
||||
}
|
||||
|
||||
section('OBS');
|
||||
const obsUp = await tcpProbe(OBS_HOST, OBS_PORT);
|
||||
report(obsUp ? 'ok' : 'warn', 'obs-websocket erreichbar',
|
||||
`${OBS_HOST}:${OBS_PORT}${obsUp ? '' : ' - OBS laeuft nicht oder WebSocket-Server ist aus'}`);
|
||||
|
||||
section('Zugangsdaten');
|
||||
// Meldet nur, OB etwas hinterlegt ist - nie was. Siehe ADR-0005.
|
||||
const st = secretStatus();
|
||||
for (const role of ['broadcaster', 'bot'] as const) {
|
||||
const a = st.apps[role];
|
||||
report(a.clientId && a.hasSecret ? 'ok' : 'warn', `${role}: App-Daten`,
|
||||
a.clientId ? (a.hasSecret ? 'Client-ID und Secret hinterlegt' : 'Secret fehlt') : 'nichts hinterlegt');
|
||||
report(st.tokens[role].connected ? 'ok' : 'warn', `${role}: Twitch verbunden`,
|
||||
st.tokens[role].connected ? `als ${st.tokens[role].login}` : 'Einrichtung unter /setup');
|
||||
}
|
||||
report(st.obs ? 'ok' : 'warn', 'OBS-Passwort', st.obs ? 'hinterlegt' : 'Einrichtung unter /setup');
|
||||
|
||||
console.log('');
|
||||
if (hardFailures > 0) {
|
||||
console.log(`${C.bad}${hardFailures} harte Fehler${C.off}, ${warnings} Warnungen. Noch nicht einsatzbereit.`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`${C.ok}Keine harten Fehler${C.off}, ${warnings} Warnungen.` +
|
||||
(warnings ? ' Warnungen ohne Einrichtung sind normal.' : ''));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- fetch-model
|
||||
|
||||
async function fetchModel(name = 'ggml-small.bin'): Promise<void> {
|
||||
const url = MODELS[name];
|
||||
if (!url) {
|
||||
console.error(`Unbekanntes Modell "${name}". Bekannt: ${Object.keys(MODELS).join(', ')}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
await fs.mkdir(paths.models, { recursive: true });
|
||||
const target = path.join(paths.models, name);
|
||||
try {
|
||||
const st = await fs.stat(target);
|
||||
console.log(`${name} liegt schon da (${(st.size / 1024 / 1024).toFixed(0)} MB).`);
|
||||
return;
|
||||
} catch { /* laden */ }
|
||||
|
||||
console.log(`Lade ${name} ...`);
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok || !res.body) throw new Error(`Download fehlgeschlagen: HTTP ${res.status}`);
|
||||
const total = Number(res.headers.get('content-length') || 0);
|
||||
let seen = 0;
|
||||
let lastPrint = 0;
|
||||
const tmp = `${target}.part`;
|
||||
|
||||
await pipeline(
|
||||
(async function* () {
|
||||
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
||||
seen += chunk.length;
|
||||
const now = Date.now();
|
||||
if (now - lastPrint > 1000) {
|
||||
lastPrint = now;
|
||||
const pct = total ? ` (${((seen / total) * 100).toFixed(0)} %)` : '';
|
||||
process.stdout.write(`\r ${(seen / 1024 / 1024).toFixed(0)} MB${pct} `);
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
})(),
|
||||
createWriteStream(tmp)
|
||||
);
|
||||
await fs.rename(tmp, target);
|
||||
console.log(`\rFertig: ${target} (${(seen / 1024 / 1024).toFixed(0)} MB)`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- calibrate
|
||||
|
||||
function buildFilter(prof: LayoutProfile): string {
|
||||
const c = (r: Rect) => `crop=${r.w}:${r.h}:${r.x}:${r.y}`;
|
||||
return [
|
||||
`[0:v]${c(prof.cam.crop)},scale=${prof.cam.band.w}:${prof.cam.band.h},setsar=1[cam]`,
|
||||
`[0:v]${c(prof.game.crop)},scale=${prof.game.band.w}:${prof.game.band.h},setsar=1[game]`,
|
||||
'[cam][game]vstack=inputs=2[v]',
|
||||
].join(';');
|
||||
}
|
||||
|
||||
async function calibrate(input?: string, profileName = 'default'): Promise<void> {
|
||||
const layout = loadLayout();
|
||||
const prof = layout.profiles?.[profileName];
|
||||
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
|
||||
|
||||
if (!input) {
|
||||
const files = (await fs.readdir(paths.replay).catch(() => [] as string[]))
|
||||
.filter((f) => /\.(mp4|mkv|mov|flv|png|jpe?g)$/i.test(f)).sort();
|
||||
const last = files[files.length - 1];
|
||||
if (!last) throw new Error(`Keine Datei angegeben und nichts in ${paths.replay} gefunden.`);
|
||||
input = path.join(paths.replay, last);
|
||||
console.log(`Neueste Datei aus ${paths.replay}: ${last}`);
|
||||
}
|
||||
|
||||
const outDir = path.join(paths.clips, '_calibration');
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
const seek = /\.(png|jpe?g)$/i.test(input) ? [] : ['-ss', '5'];
|
||||
const quiet = ['-y', '-hide_banner', '-loglevel', 'error'];
|
||||
|
||||
const frame = path.join(outDir, 'frame.png');
|
||||
await runAsync('ffmpeg', [...quiet, ...seek, '-i', input, '-frames:v', '1', frame]);
|
||||
|
||||
const preview = path.join(outDir, `preview-${profileName}.png`);
|
||||
await runAsync('ffmpeg', [...quiet, '-i', frame, '-filter_complex', buildFilter(prof),
|
||||
'-map', '[v]', '-frames:v', '1', preview]);
|
||||
|
||||
const marked = path.join(outDir, 'ausschnitte.png');
|
||||
const box = (r: Rect, color: string) => `drawbox=x=${r.x}:y=${r.y}:w=${r.w}:h=${r.h}:color=${color}:t=4`;
|
||||
await runAsync('ffmpeg', [...quiet, '-i', frame, '-vf',
|
||||
`${box(prof.cam.crop, 'yellow')},${box(prof.game.crop, 'cyan')}`, '-frames:v', '1', marked]);
|
||||
|
||||
console.log('\nGeschrieben:');
|
||||
console.log(` ${frame} Einzelbild aus der Quelle`);
|
||||
console.log(` ${marked} Ausschnitte markiert (gelb = Cam, cyan = Gameplay)`);
|
||||
console.log(` ${preview} fertige 9:16-Vorschau`);
|
||||
console.log('\nSitzt der Ausschnitt nicht, die Werte in config/layout.yaml anpassen');
|
||||
console.log('und den Befehl erneut ausfuehren.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- help
|
||||
|
||||
function help(): void {
|
||||
console.log(`
|
||||
abdulive-toolkit - Container-Einstiegspunkt
|
||||
|
||||
ingest Daemon starten (Chat, Zuschauerzahlen, Einrichtungsseite)
|
||||
check-env Umgebung pruefen
|
||||
db:migrate Migrationen anwenden
|
||||
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
|
||||
calibrate [datei] [profil] Facecam-Ausschnitt pruefen, Vorschau rendern
|
||||
help Diese Uebersicht
|
||||
|
||||
Einrichtung nach dem Start: http://localhost:4792/setup
|
||||
|
||||
Stand: Scheiben S0 und S1 aus docs/07-mvp-plan.md. OBS-Anbindung, Clipping
|
||||
und Untertitel folgen in S2 bis S4.
|
||||
`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- main
|
||||
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
try {
|
||||
switch (cmd) {
|
||||
case 'ingest': {
|
||||
const { runIngest } = await import('../apps/ingest/src/index.ts');
|
||||
await runIngest();
|
||||
break;
|
||||
}
|
||||
case 'check-env': await checkEnv(); break;
|
||||
case 'db:migrate': openDb(); console.log('Migrationen sind aktuell.'); break;
|
||||
case 'fetch-model': await fetchModel(args[0]); break;
|
||||
case 'calibrate': await calibrate(args[0], args[1]); break;
|
||||
case 'help': case undefined: help(); break;
|
||||
default:
|
||||
console.error(`Unbekannter Befehl "${cmd}".`);
|
||||
help();
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`\n${C.bad}Fehler:${C.off} ${(err as Error).message}`);
|
||||
const stderr = (err as { stderr?: string }).stderr;
|
||||
if (stderr) console.error(String(stderr).trim().split('\n').slice(-5).join('\n'));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// Minimaler YAML-Leser fuer unsere eigenen Konfigurationsdateien.
|
||||
//
|
||||
// Deckt genau das ab, was config/*.yaml benutzt: verschachtelte Maps ueber
|
||||
// Einrueckung, Skalare, Inline-Maps `{a: 1, b: 2}`, Listen aus Skalaren und
|
||||
// Blockskalare (`>` / `|`). Bewusst kein vollstaendiges YAML - sobald in S1
|
||||
// echte Abhaengigkeiten dazukommen, wird das durch `yaml` aus npm ersetzt.
|
||||
|
||||
function scalar(raw) {
|
||||
const s = raw.trim();
|
||||
if (s === '' || s === '~' || s === 'null') return null;
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
if (/^-?\d+$/.test(s)) return Number.parseInt(s, 10);
|
||||
if (/^-?\d*\.\d+$/.test(s)) return Number.parseFloat(s);
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function flowMap(raw) {
|
||||
const out = {};
|
||||
for (const part of raw.slice(1, -1).split(',')) {
|
||||
if (!part.trim()) continue;
|
||||
const i = part.indexOf(':');
|
||||
if (i === -1) continue;
|
||||
out[part.slice(0, i).trim()] = scalar(part.slice(i + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i];
|
||||
if (quote) {
|
||||
if (c === quote) quote = null;
|
||||
} else if (c === '"' || c === "'") {
|
||||
quote = c;
|
||||
} else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
|
||||
return line.slice(0, i);
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
export function parseYaml(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const root = {};
|
||||
// Stapel aus [Einrueckung, Zielobjekt]
|
||||
const stack = [[-1, root]];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const rawLine = lines[i];
|
||||
const line = stripComment(rawLine);
|
||||
if (!line.trim()) { i++; continue; }
|
||||
|
||||
const indent = line.length - line.trimStart().length;
|
||||
const body = line.trim();
|
||||
|
||||
while (stack.length > 1 && indent <= stack[stack.length - 1][0]) stack.pop();
|
||||
const parent = stack[stack.length - 1][1];
|
||||
|
||||
// Listeneintrag
|
||||
if (body.startsWith('- ')) {
|
||||
const key = stack[stack.length - 1][2];
|
||||
if (key && Array.isArray(parent[key])) parent[key].push(scalar(body.slice(2)));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const colon = body.indexOf(':');
|
||||
if (colon === -1) { i++; continue; }
|
||||
const key = body.slice(0, colon).trim();
|
||||
const rest = body.slice(colon + 1).trim();
|
||||
|
||||
if (rest === '') {
|
||||
// Verschachtelte Map, leere Liste oder leerer Wert - anhand der
|
||||
// naechsten inhaltstragenden Zeile entscheiden
|
||||
let j = i + 1;
|
||||
while (j < lines.length && !stripComment(lines[j]).trim()) j++;
|
||||
const next = j < lines.length ? stripComment(lines[j]) : '';
|
||||
const nextIndent = next ? next.length - next.trimStart().length : -1;
|
||||
if (next && nextIndent > indent && next.trim().startsWith('- ')) {
|
||||
parent[key] = [];
|
||||
stack.push([indent, parent, key]);
|
||||
} else if (next && nextIndent > indent) {
|
||||
parent[key] = {};
|
||||
stack.push([indent, parent[key]]);
|
||||
} else {
|
||||
parent[key] = null;
|
||||
}
|
||||
} else if (rest === '>' || rest === '|' || rest === '>-' || rest === '|-') {
|
||||
// Blockskalar: alle staerker eingerueckten Zeilen einsammeln
|
||||
const parts = [];
|
||||
let j = i + 1;
|
||||
for (; j < lines.length; j++) {
|
||||
if (!lines[j].trim()) { parts.push(''); continue; }
|
||||
const ind = lines[j].length - lines[j].trimStart().length;
|
||||
if (ind <= indent) break;
|
||||
parts.push(lines[j].trim());
|
||||
}
|
||||
parent[key] = rest.startsWith('>') ? parts.join(' ').trim() : parts.join('\n');
|
||||
i = j;
|
||||
continue;
|
||||
} else if (rest.startsWith('{')) {
|
||||
parent[key] = flowMap(rest);
|
||||
} else if (rest.startsWith('[')) {
|
||||
parent[key] = rest.slice(1, -1).split(',').map(scalar).filter((v) => v !== null);
|
||||
} else {
|
||||
parent[key] = scalar(rest);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
Reference in new issue
Block a user