diff --git a/README.de.md b/README.de.md index 8fe18fa..cbb732f 100644 --- a/README.de.md +++ b/README.de.md @@ -276,7 +276,8 @@ Jede Quelle lässt sich lokal mitschneiden, um einen Kamera-Abgleich nachträgli - **Dateien:** Matroska oder MOV in Segmenten (Standard 5 min, einstellbar); stürzt etwas ab, fehlt höchstens das gerade geschriebene Segment. Je Aufnahme ein Unterordner `/`, darin je Quelle `_000.mkv`, `_001.mkv` … und eine Begleitdatei `.json`: Quelleneinstellungen (Transfer, Matrix, Gamut, CST/LUT), Wanduhr- und PTP-Zeit des ersten Bildes, Timecode der Quelle, gemessene Latenz, Segmente mit Startzeit, Marker und Ereignisse (für die Paint-Änderungen des späteren geschlossenen Abgleichs). - **Zielordner:** nur freigegebene Ordner – in der Desktop-App *Filme/Videos › LZ Scopes* und unter **Einstellungen → Aufnahme → Ordner wählen …** gewählte, mit `npm start` die mit `--record-dir ` (mehrfach) oder `LZS_RECORD_DIRS` angegebenen. Die Seite nennt einen Ordner nur beim Namen, nie mit Pfad; Aufnahmen startet nur die lokale App gleicher Herkunft oder ein Client mit Steuer-Token. - **Platz:** Vor dem Start muss Platz für zwei Segmente plus 1 GB frei sein; die Quellenkarte zeigt die restlichen Minuten, unter 1 GB endet die Aufnahme. Richtwerte: ProRes 422 HQ 1080p50 ≈ 2,7 GB je Minute, H.264 mit 10 Mbit/s ≈ 75 MB. -- **Steuer-API und Companion:** `record`, `record.mark`, `record.event` ([docs/control-api.de.md](docs/control-api.de.md)); Companion-Aktionen *Record* und *Marker*, Feedback *Recording*, Variablen `recording` und `recording_time`. +- **Ringpuffer** („die letzten 60 s sichern“): **Ring** in der Quellenkarte hält für Bridge-Quellen die letzten Sekunden auf der Platte (Segmente von 2 s, reihum überschrieben, im Ordner `.lzs-ring` des Aufnahme-Ordners). **Ring sichern** in der Kopfleiste (Taste `P`) schreibt alle Ringe mit derselben Zeitmarke in eine eigene Aufnahme `-ring/`, ohne Neukodierung, ab dem letzten Keyframe vor „jetzt − Länge“, optional mit Nachlauf; das Fenster jeder Quelle ist um ihre gemessene Latenz verschoben, die Begleitdatei trägt das Fenster und die Marker darin. Länge und Nachlauf unter **Einstellungen → Aufnahme**. +- **Steuer-API und Companion:** `record`, `record.mark`, `record.event`, `ring`, `ring.save` ([docs/control-api.de.md](docs/control-api.de.md)); Companion-Aktionen *Record*, *Marker*, *Ring buffer* und *Save ring buffers*, Feedbacks *Recording* und *Ring buffer running*, Variablen `recording` und `recording_time`. Hintergrund und Messwerte: [docs/research/aufzeichnung.md](docs/research/aufzeichnung.md). @@ -330,7 +331,7 @@ Siehe [Issues](https://github.com/larszu/lz-scopes/issues) und die Recherchen in - Kein AJA. NDI nur mit installierter NDI-Runtime, ohne Ton, mit echten Quellen im Netz ungeprüft. DeckLink nur über den eigenen Helfer (ffmpegs eigener DeckLink-Weg ist „nonfree“ und nicht weitergebbar); der Helfer ist in der Desktop-App enthalten, lief aber noch nie mit Hardware, der Windows-Helfer ist nur kompiliert. - Audio: Browser liefern über `getUserMedia` höchstens 2 Kanäle; Mehrkanal kommt nur über die Bridge. Ungeprüft: Mehrkanal-Interfaces und Dante mit echter Hardware, die Bridge unter Windows (Ersatzweg für `pipe:3`, DirectShow-Ton), der A/V-Versatz gegen eine echte Kamera. - Browser-Quellen (Kamera, Datei) liefern immer 8 bit und durchlaufen das Farbmanagement des Browsers. -- Aufzeichnung: geprüft mit lavfi-Testbildern (ProRes, HEVC über VideoToolbox), Netzwerk-Kopie über UDP/MPEG-TS und MediaRecorder in der Desktop-App. Ungeprüft: NVENC, Quick Sync und VAAPI, DeckLink- und NDI-Aufnahme mit echter Hardware, die Bridge unter Windows (Segmentliste per Datei, Stopp ohne SIGINT). NDI kommt über den Helfer dekodiert an und wird daher neu kodiert, nicht kopiert. Im RTP-Eigenempfang und mit getrenntem Tonprozess zeichnet die Bridge nur das Bild auf. +- Aufzeichnung: geprüft mit lavfi-Testbildern (ProRes, HEVC über VideoToolbox), Netzwerk-Kopie über UDP/MPEG-TS und MediaRecorder in der Desktop-App. Ungeprüft: NVENC, Quick Sync und VAAPI, DeckLink- und NDI-Aufnahme mit echter Hardware, die Bridge unter Windows (Segmentliste per Datei, Stopp ohne SIGINT). NDI kommt über den Helfer dekodiert an und wird daher neu kodiert, nicht kopiert. Im RTP-Eigenempfang und mit getrenntem Tonprozess zeichnet die Bridge nur das Bild auf. Den Ringpuffer gibt es nur für Bridge-Quellen. ## Tests diff --git a/README.md b/README.md index 48c472f..a844482 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,8 @@ Every source can be recorded locally, to check a camera match afterwards (before - **Files:** Matroska or MOV in segments (5 min by default, adjustable); a crash costs at most the segment being written. Each take gets a folder `/` with `_000.mkv`, `_001.mkv` … per source and a companion file `.json`: source settings (transfer, matrix, gamut, CST/LUT), wall clock and PTP time of the first picture, the source's time code, measured latency, segments with start times, markers and events (for the paint changes of the later closed-loop match). - **Target folders:** released folders only – in the desktop app *Movies/Videos › LZ Scopes* and folders picked under **Settings → Recording → Choose folder …**, with `npm start` those given with `--record-dir ` (repeatable) or `LZS_RECORD_DIRS`. The page names a folder only by name, never by path; only the local app of the same origin or a client with the control token can start a recording. - **Space:** starting needs room for two segments plus 1 GB; the source card shows the minutes left, below 1 GB the recording stops. Rough figures: ProRes 422 HQ 1080p50 ≈ 2.7 GB per minute, H.264 at 10 Mbit/s ≈ 75 MB. -- **Control API and Companion:** `record`, `record.mark`, `record.event` ([docs/control-api.md](docs/control-api.md)); Companion actions *Record* and *Marker*, feedback *Recording*, variables `recording` and `recording_time`. +- **Ring buffer** ("save the last 60 s"): **Ring** in the source card keeps the last seconds of a bridge source on disk (2-s segments overwritten in turn, in the `.lzs-ring` folder of the recording folder). **Save ring** in the header (key `P`) writes every ring with the same time mark into a take of its own, `-ring/`, without re-encoding, from the last key frame before "now − length", optionally with a post-roll; each source's window is shifted by its measured latency, and the companion file carries the window and the markers inside it. Length and post-roll under **Settings → Recording**. +- **Control API and Companion:** `record`, `record.mark`, `record.event`, `ring`, `ring.save` ([docs/control-api.md](docs/control-api.md)); Companion actions *Record*, *Marker*, *Ring buffer* and *Save ring buffers*, feedbacks *Recording* and *Ring buffer running*, variables `recording` and `recording_time`. Background and measurements (German): [docs/research/aufzeichnung.md](docs/research/aufzeichnung.md). @@ -221,7 +222,7 @@ view.setSource(src); - Output windows render above 8 bit where the browser allows it (pattern window: float16 canvas with exact 10-bit codes, `R` switches levels 0–100 % / codes 1:1 for a legal-range monitor; scope windows: RGBA16F WebGL buffer). The window shows what the pipeline delivers; whether the display link carries 10 bit is not known – use the *10-bit ramp* banding test or a capture. A 10-bit stream (`codec=hevc10|hevc422|v210|prores`, v210 bit-exact) goes through the bridge. See [docs/research/10bit-ausgabe.md](docs/research/10bit-ausgabe.md). - No AJA input. NDI only with the NDI runtime installed, no sound, untested with real network sources. DeckLink only through our own helper (ffmpeg's own DeckLink device is "nonfree" and cannot be redistributed); it ships with the desktop app but has never run with hardware, and the Windows helper has only been compiled. Audio from the browser is limited to 2 channels (more only through the bridge). Not tested yet: multichannel interfaces and Dante on real hardware, the bridge on Windows, the A/V offset against a real camera. - Browser sources (camera, file) are always 8 bit and pass through the browser's colour management. -- Recording: tested with lavfi test patterns (ProRes, HEVC via VideoToolbox), network copy over UDP/MPEG-TS and MediaRecorder in the desktop app. Not tested: NVENC, Quick Sync and VAAPI, DeckLink and NDI recording with real hardware, the bridge on Windows (segment list through a file, stop without SIGINT). NDI arrives decoded from the helper and is therefore re-encoded, not copied. With the own RTP reception and with a separate sound process the bridge records the picture only. +- Recording: tested with lavfi test patterns (ProRes, HEVC via VideoToolbox), network copy over UDP/MPEG-TS and MediaRecorder in the desktop app. Not tested: NVENC, Quick Sync and VAAPI, DeckLink and NDI recording with real hardware, the bridge on Windows (segment list through a file, stop without SIGINT). NDI arrives decoded from the helper and is therefore re-encoded, not copied. With the own RTP reception and with a separate sound process the bridge records the picture only. The ring buffer exists for bridge sources only. ## Scope help diff --git a/companion/src/actions.ts b/companion/src/actions.ts index bde03fd..be1d90a 100644 --- a/companion/src/actions.ts +++ b/companion/src/actions.ts @@ -99,6 +99,19 @@ export function updateActions(self: ModuleInstance): void { options: [{ type: 'dropdown', id: 'mode', label: 'Mode', default: 'toggle', choices: MODES }, sourceOption(self, true)], callback: run('record'), }, + ring: { + name: 'Ring buffer on/off (one source or all)', + options: [{ type: 'dropdown', id: 'mode', label: 'Mode', default: 'toggle', choices: MODES }, sourceOption(self, true)], + callback: run('ring'), + }, + ring_save: { + name: 'Save ring buffers (all sources, same time mark)', + options: [ + { type: 'number', id: 'pre', label: 'Seconds before (0 = as set in the app)', default: 0, min: 0, max: 600 }, + { type: 'textinput', id: 'post', label: 'Post-roll seconds (empty = as set in the app)', default: '' }, + ], + callback: run('ring_save'), + }, record_mark: { name: 'Marker in the running recordings', options: [{ type: 'textinput', id: 'label', label: 'Label (empty = M1, M2 …)', default: '' }, sourceOption(self, true)], diff --git a/companion/src/commands.ts b/companion/src/commands.ts index 285d3cf..3cf89b1 100644 --- a/companion/src/commands.ts +++ b/companion/src/commands.ts @@ -59,6 +59,11 @@ export function buildCommand(actionId: string, o: Options): Command { return clean({ cmd: 'generator', mode: o.mode ?? 'toggle', signal: text(o.signal), freq, level, force: level !== undefined && level > -6 ? o.force === true || undefined : undefined }) } case 'record': return clean({ cmd: 'record', mode: o.mode ?? 'toggle', source: ref(o.source) }) + case 'ring': return clean({ cmd: 'ring', mode: o.mode ?? 'toggle', source: ref(o.source) }) + case 'ring_save': { + const num = (v: unknown) => (v === undefined || v === '' || Number(v) <= 0 ? undefined : Number(v)) + return clean({ cmd: 'ring.save', pre: num(o.pre), post: o.post === undefined || o.post === '' ? undefined : Number(o.post) }) + } case 'record_mark': return clean({ cmd: 'record.mark', label: text(o.label), source: ref(o.source) }) case 'setting': return clean({ cmd: 'setting', key: text(o.key), value: settingValue(o.value) }) } diff --git a/companion/src/feedbacks.ts b/companion/src/feedbacks.ts index b66a88b..6c1e3c5 100644 --- a/companion/src/feedbacks.ts +++ b/companion/src/feedbacks.ts @@ -71,9 +71,14 @@ export function updateFeedbacks(self: ModuleInstance): void { options: [{ type: 'textinput', id: 'source', label: 'Source (number, name or id; empty = any)', default: '' }], callback: (fb) => checks.recording(s(), opts(fb.options)), }, + ring_active: { + type: 'boolean', name: 'Ring buffer running', defaultStyle: { bgcolor: AMBER, color: BLACK }, + options: [{ type: 'textinput', id: 'source', label: 'Source (number, name or id; empty = any)', default: '' }], + callback: (fb) => checks.ring_active(s(), opts(fb.options)), + }, ident_problem: { type: 'boolean', name: 'Ident finding (swapped, missing, polarity)', defaultStyle: { bgcolor: RED, color: WHITE }, options: [], callback: () => checks.ident_problem(s()) }, } self.setFeedbackDefinitions(defs) } -export const FEEDBACK_IDS = ['connected', 'qc_active', 'frozen', 'source_active', 'clip_above', 'ymax_above', 'ymin_below', 'layout_active', 'scene_active', 'output_open', 'streaming', 'maximized', 'playing', 'generator_running', 'loudness_paused', 'true_peak_above', 'ident_problem', 'recording'] +export const FEEDBACK_IDS = ['connected', 'qc_active', 'frozen', 'source_active', 'clip_above', 'ymax_above', 'ymin_below', 'layout_active', 'scene_active', 'output_open', 'streaming', 'maximized', 'playing', 'generator_running', 'loudness_paused', 'true_peak_above', 'ident_problem', 'recording', 'ring_active'] diff --git a/companion/src/presets.ts b/companion/src/presets.ts index 3871151..1e806d3 100644 --- a/companion/src/presets.ts +++ b/companion/src/presets.ts @@ -35,6 +35,8 @@ export function updatePresets(self: ModuleInstance): void { [{ feedbackId: 'frozen', options: {}, style: { bgcolor: AMBER, color: BLACK } }]) p.record_all = button('Recording', 'Record all', 'REC\\n$(lz-scopes:recording_time)', 'record', { mode: 'toggle', source: '' }, [{ feedbackId: 'recording', options: { source: '' }, style: { bgcolor: RED, color: WHITE } }], '14') + p.ring_save = button('Recording', 'Save ring buffers', 'SAVE\\nRING', 'ring_save', { pre: 0, post: '' }, + [{ feedbackId: 'ring_active', options: { source: '' }, style: { bgcolor: AMBER, color: BLACK } }]) p.record_mark = button('Recording', 'Marker', 'MARK', 'record_mark', { label: '', source: '' }) p.roi_clear = button('Measure', 'Clear measuring frame', 'ROI\\nclear', 'roi_clear', { source: '' }) p.clip = button('Measure', 'Clipping display', 'Clip\\n$(lz-scopes:clip) %', 'freeze', { mode: 'toggle' }, diff --git a/companion/src/state.ts b/companion/src/state.ts index cf4a929..bb42c5a 100644 --- a/companion/src/state.ts +++ b/companion/src/state.ts @@ -30,7 +30,7 @@ export interface ScopesState { } | null generator?: { running: boolean; signal: string; level: number; freq: number; channels: number } | null /** local recordings: running count and which sources */ - recording?: { active: number; sources: { name: string; id: string; seconds: number; take: string | null }[] } | null + recording?: { active: number; sources: { name: string; id: string; seconds: number; take: string | null }[]; ring?: { name: string; id: string; pre: number | null; buffered: number }[] } | null /** QC log: active events, total, latest event */ qc?: { active: number; total: number; last: string } | null } @@ -60,7 +60,7 @@ export function normalizeState(raw: unknown): ScopesState { paused: r.audio.paused === true, identProblems: arr(r.audio.identProblems), } : null, generator: r.generator && typeof r.generator === 'object' ? { ...r.generator, running: r.generator.running === true } : null, - recording: r.recording && typeof r.recording === 'object' ? { active: Number(r.recording.active) || 0, sources: arr(r.recording.sources) } : null, + recording: r.recording && typeof r.recording === 'object' ? { active: Number(r.recording.active) || 0, sources: arr(r.recording.sources), ring: arr(r.recording.ring) } : null, qc: r.qc && typeof r.qc === 'object' ? { active: Number(r.qc.active) || 0, total: Number(r.qc.total) || 0, last: String(r.qc.last ?? '') } : null, } } @@ -162,6 +162,13 @@ export const checks = { loudness_paused: (s: ScopesState) => s.audio?.paused === true, true_peak_above: (s: ScopesState, o: Record) => s.audio?.maxTP != null && s.audio.maxTP > Number(o.threshold ?? -1), ident_problem: (s: ScopesState) => (s.audio?.identProblems.length ?? 0) > 0, + ring_active: (s: ScopesState, o: Record) => { + const list = s.recording?.ring ?? [] + const want = String(o.source ?? '').trim() + if (!want || want === '0') return list.length > 0 + const src = s.sources.find((x) => String(x.index) === want || x.id === want || same(x.name, want)) + return list.some((x) => (src ? x.id === src.id : same(x.name, want))) + }, recording: (s: ScopesState, o: Record) => { const list = s.recording?.sources ?? [] const want = String(o.source ?? '').trim() diff --git a/companion/test/module.test.ts b/companion/test/module.test.ts index 9da1973..900b59d 100644 --- a/companion/test/module.test.ts +++ b/companion/test/module.test.ts @@ -84,6 +84,12 @@ test('recording: variables and feedback per source', () => { assert.ok(!checks.recording(s, { source: '1' })) assert.ok(!checks.recording(EMPTY_STATE, { source: '' })) assert.equal(variableValues(EMPTY_STATE, true).recording_time, '') + const r = normalizeState({ sources: s.sources, recording: { active: 0, sources: [], ring: [{ name: 'Kamera 1', id: 's1', pre: 60, buffered: 42 }] } }) + assert.ok(checks.ring_active(r, { source: '' })) + assert.ok(checks.ring_active(r, { source: '1' })) + assert.ok(!checks.ring_active(r, { source: '2' })) + assert.deepEqual(buildCommand('ring_save', { pre: 0, post: '' }), { cmd: 'ring.save' }) + assert.deepEqual(buildCommand('ring_save', { pre: 30, post: '5' }), { cmd: 'ring.save', pre: 30, post: 5 }) }) test('choices key changes only with the lists', () => { @@ -113,6 +119,7 @@ test('commands pass the bridge validation', async (t) => { ['audio_reset', { source: '' }], ['audio_pause', { mode: 'on', source: '2' }], ['generator', { mode: 'on', signal: 'ebu-ident', freq: 0, level: '-18' }], ['generator', { mode: 'off', signal: '', level: '' }], ['generator', { mode: 'on', level: '-3', force: true }], + ['ring', { mode: 'on', source: '' }], ['ring_save', { pre: 0, post: '' }], ['ring_save', { pre: 30, post: '5' }], ['record', { mode: 'toggle', source: '' }], ['record', { mode: 'on', source: '2' }], ['record_mark', { label: 'Take 1', source: '' }], ] as [string, Record][]) { const r = validateCommand(buildCommand(id, o)) diff --git a/docs/control-api.de.md b/docs/control-api.de.md index 947e866..55f90c2 100644 --- a/docs/control-api.de.md +++ b/docs/control-api.de.md @@ -46,6 +46,8 @@ Jeder Befehl ist ein JSON-Objekt mit `cmd`. Panels, Quellen, Vorlagen und Szenen | `audio.pause` | `mode` `toggle`\|`on`\|`off`, `source` optional: I und LRA anhalten/fortsetzen (Tech 3341) | | `generator` | `mode` `toggle`\|`on`\|`off`, `signal` optional (`sine`, `ebu-ident`, `glits`, `blits`, `ebu-multi`, `ident-lr`, `pink`, `pink-band`, `white`, `sweep`, `steps`, `polarity`, `avsync`, …), `freq` 10–20000 Hz, `level` −90–0 dBFS; über −6 dBFS nur mit `"force": true` | | `record` | `mode` `toggle`\|`on`\|`off`, `source` optional (ohne = alle aufnehmbaren Quellen in eine gemeinsame Aufnahme, wie „Alle aufnehmen“; `toggle` beendet alle, sobald eine läuft) | +| `ring` | `mode` `toggle`\|`on`\|`off`, `source` optional (ohne = alle Bridge-Quellen): Ringpuffer der Quelle | +| `ring.save` | `pre` (s, optional), `post` (s, optional; Nachlauf): alle Ringpuffer mit derselben Zeitmarke sichern; ohne Angabe die Werte aus Einstellungen → Aufnahme | | `record.mark` | `label` optional (ohne = M1, M2 …), `source` optional (ohne = alle laufenden Aufnahmen): Marker in die Begleitdatei | | `record.event` | `kind` (Buchstaben, Ziffern, `_ . -`), `data` (beliebiger JSON-Wert bis 16 KB), `source` optional: Ereignis in die Begleitdatei, z. B. Paint-Änderungen | | `setting` | `key` (`theme`, `scheme`, `sidebar`, `display`, `hdrPreview`, `unit`, `tint`, `precision`, `falseColour`, `skinLuma`, `skinHue`, `zebra`, `stage`, `deRef`, `lowLatency`), `value`: eine der Auswahlen, eine Zahl im Bereich, `true`/`false` oder `[lo, hi]` für `skinLuma`. Dieselbe Beschreibung baut das Einstellungen-Fenster und die ⚙-Zeilen der Panels (src/ui/schema.ts); `state.settings` listet Schlüssel, Werte, Auswahlen und Bereiche | @@ -69,7 +71,7 @@ Antwort: `{ ok, result?, error?, state }`. `error` ist ein englischer Text, der "audio": { "source": "Kamera 1", "momentary": -22.8, "shortTerm": -23.1, "integrated": -23.0, "lra": 4.2, "maxTP": -2.1, "paused": false, "seconds": 312, "avOffsetMs": 12.5, "ident": "EBU-Stereo-Ident (R 49)", "identProblems": [] }, "generator": { "running": false, "signal": "sine", "level": -18, "freq": 1000, "channels": 2 }, - "recording": { "active": 1, "sources": [{ "name": "Kamera 1", "id": "s1", "seconds": 312, "take": "20261010-213005" }] }, + "recording": { "active": 1, "sources": [{ "name": "Kamera 1", "id": "s1", "seconds": 312, "take": "20261010-213005" }], "ring": [{ "name": "Kamera 2", "id": "s2", "pre": 60, "buffered": 60 }] }, "settings": [{ "key": "unit", "kind": "select", "label": "Skala", "value": "percent", "options": ["percent", "bit8", "bit10", "nits"] }, …], "sources": [], "layouts": [], "presets": [], "panels": [], "scenes": [], "patterns": [] } @@ -105,6 +107,8 @@ curl -s -H "$J" -d '{"cmd":"transport","op":"next"}' $B curl -s -H "$J" -d '{"cmd":"record","mode":"on"}' $B # alle Quellen aufnehmen curl -s -H "$J" -d '{"cmd":"record.mark","label":"Gamma +2"}' $B curl -s -H "$J" -d '{"cmd":"record","mode":"off"}' $B +curl -s -H "$J" -d '{"cmd":"ring","mode":"on"}' $B # Ringpuffer für alle Bridge-Quellen +curl -s -H "$J" -d '{"cmd":"ring.save","post":5}' $B # letzte Sekunden + 5 s Nachlauf sichern # mit Token von einem anderen Rechner curl -s -H "$J" -H 'Authorization: Bearer geheim' -d '{"cmd":"freeze"}' http://studio.local:4192/api/control ``` @@ -113,7 +117,7 @@ Im Browser öffnet `output.open` ein Fenster nur, wenn Pop-ups für die Seite er ## Companion-Modul -`companion/` enthält das Modul `companion-module-lz-scopes` (API `@companion-module/base` ~1.14.1, wie das Modul in lz-camera-bridge) mit Aktionen für die Befehle oben (außer `record.event`), Feedbacks (Aufnahme läuft, Verbunden, Eingefroren, Quelle aktiv, Clipping/Y′ über Schwelle, Layout, Szene, Ausgabe offen, Stream läuft, Panel maximiert, Videodatei läuft, Tongenerator läuft, I/LRA angehalten, Max True Peak über Schwelle, Ident-Befund), Variablen (auch Lautheit M/S/I/LRA, True Peak, A/V-Versatz, Ident, laufende Aufnahmen und Aufnahmedauer) und Presets. +`companion/` enthält das Modul `companion-module-lz-scopes` (API `@companion-module/base` ~1.14.1, wie das Modul in lz-camera-bridge) mit Aktionen für die Befehle oben (außer `record.event`), Feedbacks (Aufnahme läuft, Ringpuffer läuft, Verbunden, Eingefroren, Quelle aktiv, Clipping/Y′ über Schwelle, Layout, Szene, Ausgabe offen, Stream läuft, Panel maximiert, Videodatei läuft, Tongenerator läuft, I/LRA angehalten, Max True Peak über Schwelle, Ident-Befund), Variablen (auch Lautheit M/S/I/LRA, True Peak, A/V-Versatz, Ident, laufende Aufnahmen und Aufnahmedauer) und Presets. ```bash cd companion diff --git a/docs/control-api.md b/docs/control-api.md index 75fda1a..b4c81c5 100644 --- a/docs/control-api.md +++ b/docs/control-api.md @@ -46,6 +46,8 @@ Every command is a JSON object with `cmd`. Panels, sources, presets and scenes c | `audio.pause` | `mode` `toggle`\|`on`\|`off`, `source` optional: pause/resume I and LRA (Tech 3341) | | `generator` | `mode` `toggle`\|`on`\|`off`, `signal` optional (`sine`, `ebu-ident`, `glits`, `blits`, `ebu-multi`, `ident-lr`, `pink`, `pink-band`, `white`, `sweep`, `steps`, `polarity`, `avsync`, …), `freq` 10–20000 Hz, `level` −90–0 dBFS; above −6 dBFS only with `"force": true` | | `record` | `mode` `toggle`\|`on`\|`off`, `source` optional (none = every recordable source into one take, like "Record all"; `toggle` stops all as soon as one runs) | +| `ring` | `mode` `toggle`\|`on`\|`off`, `source` optional (none = all bridge sources): ring buffer of the source | +| `ring.save` | `pre` (s, optional), `post` (s, optional; post-roll): save every ring buffer with the same time mark; without them the values from Settings → Recording | | `record.mark` | `label` optional (none = M1, M2 …), `source` optional (none = all running recordings): marker in the companion file | | `record.event` | `kind` (letters, digits, `_ . -`), `data` (any JSON value up to 16 KB), `source` optional: event in the companion file, e.g. paint changes | | `setting` | `key` (`theme`, `scheme`, `sidebar`, `display`, `hdrPreview`, `unit`, `tint`, `precision`, `falseColour`, `skinLuma`, `skinHue`, `zebra`, `stage`, `deRef`, `lowLatency`), `value`: one of the choices, a number in range, `true`/`false`, or `[lo, hi]` for `skinLuma`. The same description renders the settings window and the panel ⚙ rows (src/ui/schema.ts); `state.settings` lists keys, values, choices and ranges | @@ -69,7 +71,7 @@ Answer: `{ ok, result?, error?, state }`. `error` is an English text naming the "audio": { "source": "Camera 1", "momentary": -22.8, "shortTerm": -23.1, "integrated": -23.0, "lra": 4.2, "maxTP": -2.1, "paused": false, "seconds": 312, "avOffsetMs": 12.5, "ident": "EBU stereo ident (R 49)", "identProblems": [] }, "generator": { "running": false, "signal": "sine", "level": -18, "freq": 1000, "channels": 2 }, - "recording": { "active": 1, "sources": [{ "name": "Camera 1", "id": "s1", "seconds": 312, "take": "20261010-213005" }] }, + "recording": { "active": 1, "sources": [{ "name": "Camera 1", "id": "s1", "seconds": 312, "take": "20261010-213005" }], "ring": [{ "name": "Camera 2", "id": "s2", "pre": 60, "buffered": 60 }] }, "settings": [{ "key": "unit", "kind": "select", "label": "Scale", "value": "percent", "options": ["percent", "bit8", "bit10", "nits"] }, …], "sources": [], "layouts": [], "presets": [], "panels": [], "scenes": [], "patterns": [] } @@ -105,6 +107,8 @@ curl -s -H "$J" -d '{"cmd":"transport","op":"next"}' $B curl -s -H "$J" -d '{"cmd":"record","mode":"on"}' $B # record all sources curl -s -H "$J" -d '{"cmd":"record.mark","label":"Gamma +2"}' $B curl -s -H "$J" -d '{"cmd":"record","mode":"off"}' $B +curl -s -H "$J" -d '{"cmd":"ring","mode":"on"}' $B # ring buffer for all bridge sources +curl -s -H "$J" -d '{"cmd":"ring.save","post":5}' $B # save the last seconds + 5 s post-roll # with a token from another computer curl -s -H "$J" -H 'Authorization: Bearer secret' -d '{"cmd":"freeze"}' http://studio.local:4192/api/control ``` @@ -113,7 +117,7 @@ In the browser, `output.open` opens a window only if pop-ups are allowed for the ## Companion module -`companion/` contains the module `companion-module-lz-scopes` (API `@companion-module/base` ~1.14.1, like the module in lz-camera-bridge) with actions for the commands above (except `record.event`), feedbacks (recording, connected, frozen, source active, clipping/Y′ above threshold, layout, scene, output open, stream running, panel maximised, video file playing, tone generator running, I/LRA paused, max true peak above threshold, ident result), variables (including loudness M/S/I/LRA, true peak, A/V offset, ident, running recordings and recording time) and presets. +`companion/` contains the module `companion-module-lz-scopes` (API `@companion-module/base` ~1.14.1, like the module in lz-camera-bridge) with actions for the commands above (except `record.event`), feedbacks (recording, ring buffer running, connected, frozen, source active, clipping/Y′ above threshold, layout, scene, output open, stream running, panel maximised, video file playing, tone generator running, I/LRA paused, max true peak above threshold, ident result), variables (including loudness M/S/I/LRA, true peak, A/V offset, ident, running recordings and recording time) and presets. ```bash cd companion diff --git a/docs/research/aufzeichnung.md b/docs/research/aufzeichnung.md index 4730a0a..d2e373b 100644 --- a/docs/research/aufzeichnung.md +++ b/docs/research/aufzeichnung.md @@ -26,6 +26,17 @@ Quelle ──► ffmpeg der Quelle ──► Ausgang 1: Analysebild (rawvideo/H. Der HEVC-Encoder wird je ffmpeg einmal mit einer kurzen Testkodierung ausgewählt (`pickHevcEncoder`): ein gelisteter Hardware-Encoder kann ohne GPU oder Treiber trotzdem scheitern. - **Browser-Quellen** (`src/record/browser.ts`): MediaRecorder (WebM VP9/VP8 bzw. MP4), Teile je Segmentlänge über `ws://…/record-upload`. Der Testbild-Generator zeichnet in eine Canvas außerhalb des Dokuments; `canvas.captureStream()` liefert dafür keine Bilder (Chromium erfasst nur gemalte Canvas). Die Quelle füttert deshalb einen `MediaStreamTrackGenerator` Bild für Bild mit `VideoFrame`s. +## Ringpuffer + +`server/ring.mjs`, dritter Ausgang desselben ffmpeg (nur Bridge-Quellen): + +- `-f segment -segment_time 2 -segment_wrap N -reset_timestamps 1`, Matroska, in `/.lzs-ring/-/ring_%03d.mkv`. N = ⌈(Länge + Nachlauf + 10 s) / 2⌉ + 2: die 10 s Reserve halten die älteste benötigte Datei vom Überschreiben fern, solange sie kopiert wird. Jeder ffmpeg-Start (Aufnahme an/aus) bekommt eigene Dateinamen; Dateien früherer Starts fallen mit der Zeit aus dem Fenster und werden gelöscht. Beim Abschalten löscht die Bridge den Ordner. +- Jedes Segment beginnt mit einem Keyframe: Kopie schneidet der Segment-Muxer an Keyframes, ProRes/DNxHR/FFV1 sind intra, HEVC bekommt `-force_key_frames expr:gte(t,n_forced*2)`. +- Die Segmentzeile gibt jedem Segment seine Wanduhrzeit (wie oben). Eine neue Zeile für Datei *k* macht den alten Eintrag für *k* und für *k + 1* (wird als nächstes überschrieben) ungültig. +- **Sichern** (`ring.save`, Taste `P`): ein Druck schickt allen Quellen dieselbe Zeitmarke `at` (Wanduhr der App; die Bridge nimmt ihre eigene, wenn beide mehr als 5 s auseinanderliegen). Fenster je Quelle in Ankunftszeit: [at − Länge + Latenz, at + Nachlauf + Latenz]. Die Bridge wartet, bis das Segment mit dem Fensterende fertig ist, kopiert die beteiligten Segmente beiseite (`copyFile` mit `COPYFILE_FICLONE`: auf APFS/Btrfs ein Klon ohne Datenkopie), sucht per ffprobe den letzten Keyframe vor dem Fensteranfang im ersten Segment und fügt mit dem concat-Demuxer zusammen (`inpoint` = dieser Keyframe, `outpoint` = Fensterende, `-c copy`). +- Ergebnis: `-ring/_ring.mkv` und `_ring.json` mit `kind: "ring"`, `window` (Zeitmarke, Länge, Nachlauf, Fenster in Wanduhr, `complete` = Ring reichte zurück bis zum Fensteranfang), Startzeit des ersten Bildes (= Keyframe) und den Markern im Fenster. +- Platz: vor dem Start Platz für das ganze Fenster plus 1 GB. Richtwerte für 60 s: ProRes 422 HQ 1080p50 ≈ 3,2 GB mit Reserve, H.264 10 Mbit/s ≈ 90 MB. + ## Zeitbezug - Die Segmentzeile kommt, wenn ffmpeg das nächste Segment beginnt, also wenn das Bild am Segmentende ankam. Wanduhr des Segmentanfangs = Zeitpunkt der Zeile − (Ende − Anfang). Der erste Wert ist der Start der Aufnahme (`start.utcMs`), dazu die PTP-Zeit desselben Augenblicks aus dem PTP-Monitor der Bridge (`start.ptp`, `start.clock` = `ptp` oder `system`). @@ -69,12 +80,15 @@ Zugangsdaten werden aus der URL entfernt. `t` ist in Sekunden ab dem ersten Bild | `test:testsrc` → HEVC 10 bit | `hevc_videotoolbox`, Main 10, `yuv420p10le` | | ffmpeg mit SIGKILL mitten im 4. Segment (MOV) | Segmente 1–3 vollständig lesbar, Bildzahl = Dauer × 25 | | Desktop-App: Testbild-Generator per MediaRecorder | WebM, ffprobe liest es | +| Ring 6 s + 2 s Nachlauf, `test:testsrc` → ProRes | 201 Bilder (8 s × 25 + 1), Beginn 9 ms vor dem Fensteranfang, Ring-Ordner danach leer | +| Kopie H.264, Keyframe jede Sekunde, Fenster ab 1,3 s | Datei beginnt am Keyframe 1,0 s, 100 Bilder bis 5,0 s | -Tests: `test/record.test.ts` (Zielordner, Argumente, Segmentliste, Begleitdatei, Platzprüfung, Steuerbefehle; mit dem mitgelieferten ffmpeg: Absturz kostet höchstens ein Segment, Bridge-Aufnahme mit lückenlosen Segmenten), `e2e/record.spec.ts` (Knopf der Quellenkarte, `record`/`record.mark` über die Steuer-API, Browser-Quelle). +Tests: `test/record.test.ts` (Zielordner, Argumente, Segmentliste, Begleitdatei, Platzprüfung, Steuerbefehle; mit dem mitgelieferten ffmpeg: Absturz kostet höchstens ein Segment, Bridge-Aufnahme mit lückenlosen Segmenten), `test/ring.test.ts` (Fenster mit Latenz, Segmentauswahl, Keyframe-Suche, concat-Liste, Überschreiben; mit ffmpeg: Kopie ab Keyframe, Bridge-Ring mit Nachlauf), `e2e/record.spec.ts` (Knopf der Quellenkarte, `record`/`record.mark`/`ring`/`ring.save` über die Steuer-API, Browser-Quelle). ## Ungeprüft - NVENC, Quick Sync, AMF und VAAPI (keine Hardware am Prüfplatz); die Auswahl per Testkodierung fällt dort auf libx265 bzw. ProRes zurück. - DeckLink- und NDI-Aufnahme mit echter Hardware bzw. echten Quellen im Netz. NDI kommt über den Helfer dekodiert an und wird neu kodiert; ein Kopieren von NDI|HX gibt es nicht. - Die Bridge unter Windows (Segmentliste per Datei, Stopp ohne SIGINT: das letzte MOV-Segment kann dort fehlen, Matroska bleibt lesbar). +- Ringpuffer für Browser-Quellen (MediaRecorder) gibt es nicht. - RTP-Eigenempfang und getrennter Tonprozess (`LZS_AUDIO_SPLIT`, Windows-Ersatzweg): Aufnahme nur mit Bild, weil der Ton in einem anderen Prozess liegt. diff --git a/docs/research/kamera-abgleich.md b/docs/research/kamera-abgleich.md index dca71fa..1ac4f46 100644 --- a/docs/research/kamera-abgleich.md +++ b/docs/research/kamera-abgleich.md @@ -57,7 +57,7 @@ Kameras sehen die Szene aus verschiedenen Winkeln. Verglichen werden deshalb **V ### 5. Lokale Aufzeichnung der Quellen (mittel) -Umgesetzt ist die Aufzeichnung in der Bridge und im Browser mit Begleitdatei: [aufzeichnung.md](aufzeichnung.md). +Umgesetzt sind die Aufzeichnung in der Bridge und im Browser mit Begleitdatei und der Ringpuffer: [aufzeichnung.md](aufzeichnung.md). Zweck: Abgleich nachträglich prüfen (vorher/nachher, Drift über die Show), Kameras ohne Live-Zugriff vergleichen, Belege für Kunden. diff --git a/e2e/record.spec.ts b/e2e/record.spec.ts index cbf297c..7142285 100644 --- a/e2e/record.spec.ts +++ b/e2e/record.spec.ts @@ -71,3 +71,22 @@ test('Aufnahme-Knopf der Quellenkarte und „alle aufnehmen“ über die Steuer- await until(() => { const n = statSync(join(take2, webm)).size; const done = n > 1000 && n === last; last = n; return done; }, 15_000, 'WebM vollständig'); expect(frames(join(take2, webm))).toBeGreaterThan(10); }); + +test('Ringpuffer: Ring in der Quellenkarte, Sichern über die Steuer-API, Kopfleiste zeigt „Ring sichern“', async () => { + const { page } = a; + const st = (await a.state()).sources.find((x) => x.kind === 'stream' && x.status === 'live')!; + await expect(page.locator('#ring-save')).toBeHidden(); + await page.locator(`button[data-ring="${st.id}"]`).click(); + await expect(page.locator(`[data-ringstat="${st.id}"]`)).toContainText('Ring 60 s', { timeout: 30_000 }); + await expect(page.locator('#ring-save')).toBeVisible(); + // a few seconds into the ring, then save: the file holds what the ring has (from its start) + await page.waitForTimeout(7000); + const before = new Set(readdirSync(dir)); + expect((await a.control({ cmd: 'ring.save', post: 1 })).ok).toBe(true); + const take = await until(() => readdirSync(dir).find((d) => !before.has(d) && d.endsWith('-ring')), 30_000, 'Ring-Aufnahme'); + const mkv = await until(() => readdirSync(join(dir, take)).find((f) => f.endsWith('_ring.mkv')), 30_000, 'Ring-Datei'); + await until(() => readdirSync(join(dir, take)).some((f) => f.endsWith('_ring.json')), 15_000, 'Begleitdatei des Rings'); + expect(frames(join(dir, take, mkv))).toBeGreaterThan(20); + expect((await a.control({ cmd: 'ring', mode: 'off' })).ok).toBe(true); + await expect(page.locator('#ring-save')).toBeHidden({ timeout: 15_000 }); +}); diff --git a/server/control.mjs b/server/control.mjs index 938537a..9ad4601 100644 --- a/server/control.mjs +++ b/server/control.mjs @@ -47,6 +47,8 @@ export const COMMANDS = { 'audio.pause': 'Pause/resume I and LRA (Tech 3341): mode toggle|on|off, source (optional; none = all sources with sound)', 'generator': 'Tone generator: mode toggle|on|off, signal (optional), freq (Hz, optional), level (dBFS, optional; above −6 only with force: true)', 'record': 'Local recording: mode toggle|on|off, source (optional; none = all recordable sources, as "record all")', + 'ring': 'Ring buffer on/off: mode toggle|on|off, source (optional; none = all bridge sources)', + 'ring.save': 'Save the ring buffers of all sources with one time mark: pre (s, optional), post (s, optional)', 'record.mark': 'Marker in the running recordings: label (optional), source (optional; none = all)', 'record.event': 'Event in the companion file of the running recordings: kind (letters, digits, _ . -), data (JSON value), source (optional)', 'setting': `Set a global setting: key (${SETTING_KEYS.join(', ')}), value (choice, number, true/false, or [lo, hi] for skinLuma); state.settings lists keys, values and choices`, @@ -165,6 +167,14 @@ export function validateCommand(raw) { check(optRef('source')); break; case 'record': check(optMode()); check(optRef('source')); break; + case 'ring': check(optMode()); check(optRef('source')); break; + case 'ring.save': + for (const k of ['pre', 'post']) { + if (c[k] === undefined || c[k] === '') continue; + const v = Number(c[k]); + if (!Number.isFinite(v) || v < 0 || v > 600) errors.push(`${k}: 0 … 600 s`); else out[k] = v; + } + break; case 'record.mark': check(optRef('source')); if (c.label !== undefined && c.label !== '') { if (!isStr(c.label) || c.label.length > 200) errors.push('label: text up to 200 characters'); else out.label = c.label; } diff --git a/server/helper-input.mjs b/server/helper-input.mjs index 0d3eafe..e34af1b 100644 --- a/server/helper-input.mjs +++ b/server/helper-input.mjs @@ -269,7 +269,7 @@ export function startHelperStream(ws, { bin, args, label, params, ctx }) { self.stdin.end(); }); const stats = setInterval(() => { - if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'stats', sent, dropped: dropped + helperDropped, ...(status ? toMsg(status) : {}), ...(helperStats ? { helper: helperStats } : {}), ...(phase?.report() ? { phase: phase.report() } : {}), ...(recorder?.active ? { rec: recorder.status() } : {}) })); + if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'stats', sent, dropped: dropped + helperDropped, ...(status ? toMsg(status) : {}), ...(helperStats ? { helper: helperStats } : {}), ...(phase?.report() ? { phase: phase.report() } : {}), ...(recorder?.active ? { rec: recorder.status() } : {}), ...(recorder?.ringActive ? { ring: recorder.ringStatus() } : {}) })); }, 1000); ws.on('close', () => { closed = true; clearInterval(stats); recorder?.ended('closed'); stopFf(); helper.kill('SIGTERM'); }); } diff --git a/server/index.mjs b/server/index.mjs index f457b6e..f0b6456 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -642,7 +642,7 @@ async function startStream(ws, params) { // local recording (server/recorder.mjs): a second output of this ffmpeg; toggling restarts it let current = null, restarting = false, recAudio = null; const recorder = video ? new StreamRecorder({ - url, ffmpeg, allowed: ws.recordAllowed === true, roots: () => RECORD_ROOTS, clock: clockNow, + url, ffmpeg, ffprobe: ffprobeCandidates([ffmpeg])[0] ?? '', allowed: ws.recordAllowed === true, roots: () => RECORD_ROOTS, clock: clockNow, send: (m) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(m)); }, restart: () => restart(), media: () => ({ width: info.width, height: info.height, fps: info.fps, codec: info.codec, audioMap: recAudio }), @@ -809,6 +809,7 @@ async function startStream(ws, params) { if (packetizer) Object.assign(st, { audioSent: packetizer.packets, audioDropped: 0, audioGaps: 0, audioSplit: split, pts: !!pts }); if (own) st.rtp = own.report(); if (recorder?.active) st.rec = recorder.status(); + if (recorder?.ringActive) st.ring = recorder.ringStatus(); ws.send(JSON.stringify(st)); }, 1000); own?.on('error', (e) => { stderr = (stderr + `\nOwn RTP reception: ${e.message}`).slice(-2000); }); @@ -1091,7 +1092,7 @@ function startNdi(ws, params, url) { /** Recorder factory for the helper inputs (DeckLink, NDI): encoded recording, no sound. */ function helperRecorder(ws, url, ffmpeg) { return ({ restart, media }) => new StreamRecorder({ - url, ffmpeg, allowed: ws.recordAllowed === true, roots: () => RECORD_ROOTS, clock: clockNow, restart, media, + url, ffmpeg, ffprobe: ffprobeCandidates([ffmpeg])[0] ?? '', allowed: ws.recordAllowed === true, roots: () => RECORD_ROOTS, clock: clockNow, restart, media, send: (m) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(m)); }, }); } diff --git a/server/recorder.mjs b/server/recorder.mjs index 4ff40a1..e438631 100644 --- a/server/recorder.mjs +++ b/server/recorder.mjs @@ -7,14 +7,17 @@ // output (ffmpeg cannot add an output to a running process); the analysis picture pauses for // the length of a restart. Segment numbers continue across restarts (segment_start_number). -import { createWriteStream, statSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { createWriteStream, existsSync, statSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { bmsg, toMsg } from './messages.mjs'; import { LineSplitter, RECORD_CODECS, RECORD_CONTAINERS, Sidecar, ensureDir, escapePattern, estimateRate, freeBytes, minutesLeft, - parseSegmentLine, pickHevcEncoder, recordMode, resolveTarget, segmentOutputArgs, spaceProblem, stripCredentials, tailFile, + parseSegmentLine, pickHevcEncoder, recordMode, resolveTarget, safeName, segmentOutputArgs, spaceProblem, stripCredentials, tailFile, writeJsonAtomic, } from './record.mjs'; +import { RING_MARGIN, RING_SEGMENT, clearRing, dropOverwritten, joinSegments, ringWrap, saveWindow, selectSegments } from './ring.mjs'; /** Settings a client may store in the companion file (copied as given, size-limited). */ function cleanSettings(s) { @@ -36,10 +39,16 @@ export class StreamRecorder { this.rec = null; /** sessions whose ffmpeg is still running (finished when it exits) */ this.closing = new Set(); + /** ring buffer (server/ring.mjs) or null; `gen` counts ffmpeg launches (file names per launch) */ + this.ring = null; + this.gen = 0; + /** ring folders to delete once their ffmpeg has exited */ + this.oldRings = []; this.timer = setInterval(() => this.check(), 5000); } get mode() { return recordMode(this.o.url); } get active() { return !!this.rec; } + get ringActive() { return !!this.ring; } /** Client message (already parsed). Returns true when it was a recording message. */ async handle(m) { @@ -47,9 +56,125 @@ export class StreamRecorder { if (m.type === 'record') { await (m.on ? this.start(m) : this.stop('user')); return true; } if (m.type === 'mark') { this.mark(m.label); return true; } if (m.type === 'event') { this.event(m.kind, m.data); return true; } + if (m.type === 'ring') { await (m.on ? this.startRing(m) : this.stopRing()); return true; } + if (m.type === 'ring.save') { await this.saveRing(m); return true; } return false; } + ringError(e) { this.o.send({ type: 'ring', state: 'error', ...toMsg(e) }); } + + /** Codec of the ring and the recording for raw sources; HEVC resolved to a working encoder. */ + async pickCodec(mode, want) { + let codec = RECORD_CODECS.includes(want) ? want : 'prores'; + let encoder = '', hardware = false, note = null; + if (mode === 'encode' && codec === 'hevc') { + const pick = await pickHevcEncoder(this.o.ffmpeg, this.platform); + if (pick.encoder) { encoder = pick.encoder; hardware = pick.hardware; if (!hardware) note = bmsg('record.hevcSoftware', 'No hardware HEVC encoder – libx265 (software)'); } + else { codec = 'prores'; note = bmsg('record.hevcNone', 'No HEVC encoder works here – ProRes 422 HQ instead'); } + } + return { codec, encoder, hardware, note }; + } + + async startRing(m) { + if (this.ring) return this.o.send(this.ringStatus()); + if (!this.o.allowed) return this.ringError(bmsg('record.denied', 'Recording only from the local app (same origin) or with the control token')); + const mode = this.mode; + if (!mode) return this.ringError(bmsg('record.unsupported', 'This source cannot be recorded in the bridge')); + const roots = this.o.roots(); + const root = roots.find((r) => r.name === m.root) ?? (m.root ? null : roots[0]); + if (!root) return this.ringError(roots.length ? bmsg('record.badTarget', 'Recording folder not released') : bmsg('record.noRoot', 'No recording folder released – start the bridge with --record-dir ')); + const pre = Math.min(600, Math.max(4, Math.round(Number(m.pre) || 60))); + const post = Math.min(120, Math.max(0, Math.round(Number(m.post) || 0))); + const { codec, encoder, hardware, note } = await this.pickCodec(mode, m.codec); + const media = this.o.media(); + const rate = estimateRate(mode, codec, media.width, media.height, media.fps); + const dir = join(root.dir, '.lzs-ring', `${safeName(m.name)}-${randomBytes(4).toString('hex')}`); + try { await ensureDir(dir); } catch (e) { return this.ringError(bmsg('record.mkdir', `Folder cannot be created: ${e.message}`, { reason: e.message })); } + const space = spaceProblem(freeBytes(dir), rate, (pre + post + RING_MARGIN) / 2); + if (space) { await clearRing(dir); return this.ringError(space); } + this.ring = { root, dir, pre, post, wrap: ringWrap(pre, post), mode, codec, encoder, hardware, note, rate, entries: [], marks: [], name: String(m.name ?? ''), settings: cleanSettings(m.settings), since: Date.now() }; + this.o.send(this.ringStatus()); + this.o.restart(); + } + + async stopRing() { + const g = this.ring; + if (!g) return this.o.send({ type: 'ring', state: 'stopped' }); + this.ring = null; + this.oldRings.push(g.dir); + this.o.send({ type: 'ring', state: 'stopped' }); + this.o.restart(); + } + + /** A segment of the ring is complete. */ + ringLine(g, gen, line, at) { + const seg = parseSegmentLine(line); + if (!seg || this.ring !== g) return; + const file = basename(seg.file); + g.entries = dropOverwritten(g.entries, file, g.wrap, gen); + g.entries.push({ file, startMs: at - (seg.end - seg.start) * 1000, endMs: at, gen, start: seg.start, end: seg.end }); + // files of earlier launches age out of the window: delete them + const keepFrom = at - (g.pre + g.post + RING_MARGIN + 4) * 1000; + for (const e of g.entries) if (e.endMs < keepFrom && e.gen !== gen) rm(join(g.dir, e.file), { force: true }).catch(() => {}); + g.entries = g.entries.filter((e) => e.endMs >= keepFrom || e.gen === gen); + } + + /** + * Save the ring: last `pre` s before `at` plus `post` s after it, shifted by this source's + * latency, into //_ring.mkv with a companion file. + */ + async saveRing(m) { + const g = this.ring; + if (!g) return this.ringError(bmsg('ring.off', 'Ring buffer not running for this source')); + const now = Date.now(); + const at = Number.isFinite(Number(m.at)) && Math.abs(Number(m.at) - now) < 5000 ? Number(m.at) : now; + const pre = Math.min(g.pre, Math.max(1, Number(m.pre) || g.pre)); + const post = Math.min(g.post, Math.max(0, Number(m.post ?? g.post) || 0)); + const latencyMs = Number.isFinite(Number(m.latencyMs)) ? Number(m.latencyMs) : 0; + const { from, to } = saveWindow(at, pre, post, latencyMs); + this.o.send({ type: 'ring', state: 'saving', take: m.take, post }); + // post-roll: wait until the segment covering the end is complete + const deadline = to + (RING_SEGMENT + 8) * 1000; + while (!g.entries.some((e) => e.endMs >= to) && Date.now() < deadline && this.ring === g) await new Promise((r) => setTimeout(r, 100)); + const sel = selectSegments(g.entries.filter((e) => existsSync(join(g.dir, e.file))), from, to); + if (!sel) return this.ringError(bmsg('ring.empty', 'Ring buffer has no pictures yet')); + const target = resolveTarget(this.o.roots(), g.root.name, m.take, m.name ?? g.name); + if (!target) return this.ringError(bmsg('record.badTarget', 'Recording folder not released')); + try { await ensureDir(target.dir); } catch (e) { return this.ringError(bmsg('record.mkdir', `Folder cannot be created: ${e.message}`, { reason: e.message })); } + let base = `${target.base}_ring`, k = 2; + while (existsSync(join(target.dir, `${base}.mkv`))) base = `${target.base}_ring-${k++}`; + const out = join(target.dir, `${base}.mkv`); + const r = await joinSegments({ ffmpeg: this.o.ffmpeg, ffprobe: this.o.ffprobe ?? '', dir: g.dir, sel, out }); + if (!r.ok) return this.ringError(bmsg('ring.joinFailed', `Ring buffer could not be saved: ${r.error}`, { reason: r.error })); + const first = sel.items[0]; + const startUtc = Math.round(first.startMs + r.startOffset * 1000); + const media = this.o.media(); + const sc = new Sidecar({ + requestedUtcMs: at, take: m.take, kind: 'ring', + source: { name: String(m.name ?? g.name).slice(0, 120), url: stripCredentials(this.o.url) }, + settings: cleanSettings(m.settings) ?? g.settings, latencyMs, + window: { atUtcMs: at, preSeconds: pre, postSeconds: post, fromUtcMs: Math.round(from), toUtcMs: Math.round(to), complete: sel.complete }, + video: { mode: g.mode, codec: g.mode === 'copy' ? `copy (${media.codec ?? '?'})` : g.codec, encoder: g.encoder || null, hardware: g.hardware, container: 'mkv', width: media.width, height: media.height, fps: media.fps, audio: !!media.audioMap }, + }); + const c = this.o.clock?.(); + sc.setStart(startUtc, c ? c.seconds - (Date.now() - startUtc) / 1000 : null, c?.ref ?? null); + sc.addSegment({ file: `${base}.mkv`, start: 0, end: (Math.min(to, sel.items[sel.items.length - 1].endMs) - startUtc) / 1000, utcMs: startUtc }); + for (const mk of g.marks) if (mk.utcMs >= startUtc && mk.utcMs <= to) sc.data.markers.push({ ...mk, t: (mk.utcMs - startUtc) / 1000 }); + sc.finish('ring', Math.round(to)); + await writeJsonAtomic(join(target.dir, `${base}.json`), sc.toJSON()).catch(() => {}); + this.o.send({ type: 'ring', state: 'saved', take: m.take, file: `${base}.mkv`, startUtcMs: startUtc, seconds: Math.round((to - startUtc) / 100) / 10, complete: sel.complete }); + } + + ringStatus() { + const g = this.ring; + if (!g) return { type: 'ring', state: 'stopped' }; + const have = g.entries.length ? (Math.max(...g.entries.map((e) => e.endMs)) - Math.min(...g.entries.map((e) => e.startMs))) / 1000 : 0; + return { + type: 'ring', state: 'running', pre: g.pre, post: g.post, buffered: Math.round(Math.min(have, g.pre + g.post + RING_MARGIN)), mode: g.mode, codec: g.codec, encoder: g.encoder || null, + ...(g.note ? { note: g.note.message, noteCode: g.note.code, noteParams: g.note.params } : {}), + }; + } + error(e) { this.o.send({ type: 'record', state: 'error', ...toMsg(e) }); } async start(m) { @@ -105,6 +230,7 @@ export class StreamRecorder { } mark(label) { + if (this.ring) this.ring.marks.push({ utcMs: Date.now(), label: String(label ?? '').slice(0, 200) }); const r = this.rec; if (!r) return; const mk = r.sidecar.mark(label, Date.now()); @@ -130,6 +256,26 @@ export class StreamRecorder { * `fd` = first free stdio index (pipe:fd) on POSIX; Windows uses a list file. */ outputs(fd = 4) { + const rec = this.recOutput(fd); + const ring = this.ringOutput(fd + rec.pipes); + return { args: [...rec.args, ...ring.args], pipes: rec.pipes + ring.pipes, sessions: [...rec.sessions, ...ring.sessions] }; + } + + ringOutput(fd) { + const g = this.ring; + if (!g) return { args: [], pipes: 0, sessions: [] }; + const gen = ++this.gen; + const media = this.o.media(); + const listFile = this.platform === 'win32' ? join(g.dir, `ring${gen}.csv`) : null; + const args = segmentOutputArgs({ + mode: g.mode, codec: g.codec, encoder: g.encoder, container: 'mkv', audioMap: media.audioMap, sourceCodec: media.codec, + width: media.width, height: media.height, fps: media.fps, segmentSeconds: RING_SEGMENT, wrap: g.wrap, gopSeconds: RING_SEGMENT, + list: listFile ?? `pipe:${fd}`, pattern: join(escapePattern(g.dir), `ring${gen}_%03d.mkv`), + }); + return { args, pipes: listFile ? 0 : 1, sessions: [{ ring: g, gen, fd: listFile ? null : fd, listFile }] }; + } + + recOutput(fd) { const r = this.rec; if (!r) return { args: [], pipes: 0, sessions: [] }; const media = this.o.media(); @@ -151,14 +297,16 @@ export class StreamRecorder { attach(proc, out) { const tails = []; for (const s of out.sessions) { - s.r.attached = true; - const split = new LineSplitter((line) => this.segmentLine(s.r, line, Date.now())); + if (s.r) s.r.attached = true; + const split = new LineSplitter((line) => (s.ring ? this.ringLine(s.ring, s.gen, line, Date.now()) : this.segmentLine(s.r, line, Date.now()))); if (s.fd !== null) proc.stdio[s.fd]?.on('data', (d) => split.push(String(d))); else tails.push(tailFile(s.listFile, (t) => split.push(t))); } proc.once('close', async () => { await Promise.all(tails.map((t) => t.stop())); - for (const s of out.sessions) if (this.closing.has(s.r) && this.rec !== s.r) await this.finish(s.r); + for (const s of out.sessions) if (s.r && this.closing.has(s.r) && this.rec !== s.r) await this.finish(s.r); + // ring folders of a stopped ring: the ffmpeg that wrote them is gone now + for (const s of out.sessions) if (s.ring && this.ring !== s.ring && this.oldRings.includes(s.ring.dir)) { this.oldRings = this.oldRings.filter((d) => d !== s.ring.dir); await clearRing(s.ring.dir); } }); } @@ -191,6 +339,7 @@ export class StreamRecorder { /** The stream ended (source gone, client closed): end the recording with it. */ async ended(reason = 'end') { clearInterval(this.timer); + if (this.ring) { this.oldRings.push(this.ring.dir); if (!this.gen) await clearRing(this.ring.dir); this.ring = null; } const r = this.rec; if (r) { this.rec = null; r.reason = reason; r.stopAt = Date.now(); this.closing.add(r); } // the ffmpeg 'close' handler of attach() finishes it; nothing ran → finish now diff --git a/server/ring.mjs b/server/ring.mjs new file mode 100644 index 0000000..9010f77 --- /dev/null +++ b/server/ring.mjs @@ -0,0 +1,133 @@ +// Ring buffer of one bridge source ("save the last 60 s", #140 step 5, docs/research/aufzeichnung.md). +// +// A third output of the source's ffmpeg writes segments of about 2 s in turn into a fixed set +// of files (segment muxer, `segment_wrap`), always on disk, never in RAM. "Save" copies the +// segments of the wanted window aside and joins them without re-encoding (concat demuxer, +// `-c copy`), starting at the last key frame before "now − length", optionally with a +// post-roll. All sources of one press get the same time mark; the window is shifted by the +// latency of each source so that all files start at the same moment of the scene. + +import { spawn } from 'node:child_process'; +import { constants, existsSync } from 'node:fs'; +import { copyFile, mkdir, readdir, rm, rmdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** Length of one ring segment in seconds. */ +export const RING_SEGMENT = 2; +/** Extra seconds kept beyond length + post-roll, so the oldest needed file is not overwritten while it is copied. */ +export const RING_MARGIN = 10; + +/** Number of files in the ring for a window of `pre` + `post` seconds. */ +export function ringWrap(pre, post, seg = RING_SEGMENT, margin = RING_MARGIN) { + return Math.ceil((Math.max(1, pre) + Math.max(0, post) + margin) / seg) + 2; +} + +/** + * Window of one save in arrival time of this source (wall clock ms): a frame that arrives at + * wall time w shows the scene at w − latency, so the scene window [at − pre, at + post] is + * [at − pre + latency, at + post + latency] here. + */ +export function saveWindow(at, pre, post, latencyMs = 0) { + const l = Number.isFinite(latencyMs) ? Math.max(0, latencyMs) : 0; + return { from: at - pre * 1000 + l, to: at + post * 1000 + l }; +} + +/** + * Ring entries ({ file, startMs, endMs, gen }, wall clock) needed for [from, to], oldest first, + * with the offsets into the first and last file (seconds). Each segment begins with a key frame + * (copy: the muxer cuts at key frames; encode: intra codecs or forced key frames). + * Returns null when the ring does not reach back to `from` (window starts before the oldest entry). + */ +export function selectSegments(entries, from, to) { + const list = [...entries].sort((a, b) => a.startMs - b.startMs).filter((e) => e.endMs > from && e.startMs < to); + if (!list.length) return null; + const first = list[0], last = list[list.length - 1]; + return { + items: list, + inpoint: Math.max(0, (from - first.startMs) / 1000), + outpoint: Math.max(0, (to - last.startMs) / 1000), + complete: first.startMs <= from + 1 && last.endMs >= to - 1, + }; +} + +/** Last key frame at or before `t` (seconds) in a sorted list of key frame times; the first one if none. */ +export function lastKeyframeBefore(keys, t) { + let best = keys.length ? keys[0] : 0; + for (const k of keys) { if (k <= t + 1e-6) best = k; else break; } + return best; +} + +/** concat demuxer list: `file '…'` with inpoint/outpoint (ffmpeg-formats, concat: quote ' as '\''). */ +export function concatList(items) { + const q = (p) => `'${String(p).replace(/'/g, "'\\''")}'`; + return items.map((it) => [`file ${q(it.path)}`, ...(it.inpoint ? [`inpoint ${it.inpoint.toFixed(6)}`] : []), ...(it.outpoint !== undefined ? [`outpoint ${it.outpoint.toFixed(6)}`] : [])].join('\n')).join('\n') + '\n'; +} + +/** Ring entries of the files a new segment line makes stale (the file just reported is rewritten next round). */ +export function dropOverwritten(entries, file, wrap, gen) { + const m = /^(.*_)(\d+)(\.\w+)$/.exec(file); + const next = m ? `${m[1]}${String((Number(m[2]) + 1) % wrap).padStart(m[2].length, '0')}${m[3]}` : ''; + return entries.filter((e) => !(e.gen === gen && (e.file === file || e.file === next))); +} + +function run(bin, args, timeoutMs = 120000) { + return new Promise((ok) => { + let err = '', out = ''; + const p = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); + const timer = setTimeout(() => p.kill('SIGKILL'), timeoutMs); + p.stdout.on('data', (d) => { out += d; }); + p.stderr.on('data', (d) => { err = (err + d).slice(-4000); }); + p.on('error', (e) => { clearTimeout(timer); ok({ code: -1, out, err: e.message }); }); + p.on('close', (code) => { clearTimeout(timer); ok({ code, out, err }); }); + }); +} + +/** Key frame times (s) of the video in one file (ffprobe), sorted. */ +export async function keyframes(ffprobe, file) { + if (!ffprobe) return []; + const r = await run(ffprobe, ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'packet=pts_time,flags', '-of', 'csv=p=0', file], 30000); + return r.out.split('\n').map((l) => l.split(',')).filter(([t, f]) => f?.includes('K') && t !== 'N/A').map(([t]) => Number(t)).filter(Number.isFinite).sort((a, b) => a - b); +} + +/** + * Join the selected ring segments into `out` without re-encoding. The files are copied aside + * first (APFS/Btrfs: clone, instant), so the ring may go on overwriting meanwhile. + * → { ok, startOffset (s, the key frame the file begins with), error? } + */ +export async function joinSegments({ ffmpeg, ffprobe, dir, sel, out }) { + const stage = join(dir, `save-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + await mkdir(stage, { recursive: true }); + try { + const items = []; + for (let i = 0; i < sel.items.length; i++) { + const src = join(dir, sel.items[i].file); + if (!existsSync(src)) continue; + const dst = join(stage, `${String(i).padStart(4, '0')}_${sel.items[i].file}`); + await copyFile(src, dst, constants.COPYFILE_FICLONE); + items.push({ path: dst, entry: sel.items[i] }); + } + if (!items.length) return { ok: false, error: 'no segments' }; + // the file begins at the last key frame before the window start ("ab dem letzten Keyframe vor jetzt − Länge") + const keys = await keyframes(ffprobe, items[0].path); + const start = keys.length ? lastKeyframeBefore(keys, sel.inpoint) : 0; + const list = items.map((it, i) => ({ + path: it.path, + ...(i === 0 && start > 0 ? { inpoint: start } : {}), + ...(i === items.length - 1 && it.entry === sel.items[sel.items.length - 1] && sel.outpoint < (it.entry.endMs - it.entry.startMs) / 1000 ? { outpoint: sel.outpoint } : {}), + })); + const listFile = join(stage, 'list.txt'); + await writeFile(listFile, concatList(list)); + const r = await run(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-f', 'concat', '-safe', '0', '-i', listFile, '-map', '0', '-c', 'copy', out]); + return r.code === 0 ? { ok: true, startOffset: start, files: items.length } : { ok: false, error: r.err.trim().split('\n').pop() || `ffmpeg ${r.code}` }; + } finally { + await rm(stage, { recursive: true, force: true }).catch(() => {}); + } +} + +/** Delete the ring folder of a source (its own segment files only). */ +export async function clearRing(dir) { + try { + for (const f of await readdir(dir)) if (/^ring\d*_\d+\.mkv$|^ring\d*\.csv$/.test(f)) await rm(join(dir, f), { force: true }); + await rmdir(dir).catch(() => {}); + } catch { /* gone */ } +} diff --git a/src/i18n/de/bridge.ts b/src/i18n/de/bridge.ts index 0a22d15..1098f43 100644 --- a/src/i18n/de/bridge.ts +++ b/src/i18n/de/bridge.ts @@ -83,6 +83,9 @@ export default { 'bridge.record.stoppedDiskFull': 'Aufnahme beendet: Platte voll', 'bridge.record.hevcSoftware': 'Kein HEVC-Hardware-Encoder – libx265 (Software)', 'bridge.record.hevcNone': 'Kein HEVC-Encoder funktioniert hier – stattdessen ProRes 422 HQ', + 'bridge.ring.off': 'Ringpuffer für diese Quelle nicht aktiv', + 'bridge.ring.empty': 'Ringpuffer hat noch keine Bilder', + 'bridge.ring.joinFailed': 'Ringpuffer ließ sich nicht sichern: {reason}', 'bridge.record.write': 'Schreiben nicht möglich: {reason}', 'bridge.folder.notReleased': 'Ordner nicht freigegeben – Bridge mit --watch-dir starten', 'bridge.folder.unreadable': '{file}: kein lesbares Bild', diff --git a/src/i18n/de/menu.ts b/src/i18n/de/menu.ts index f8d1e67..11b7d77 100644 --- a/src/i18n/de/menu.ts +++ b/src/i18n/de/menu.ts @@ -88,6 +88,7 @@ export default { 'keys.g.record': 'Aufnahme', 'keys.record': 'Alle Quellen aufnehmen / alle Aufnahmen beenden', 'keys.mark': 'Marker in die laufenden Aufnahmen', + 'keys.ringSave': 'Ringpuffer aller Quellen sichern (gleiche Zeitmarke)', 'keys.frame': 'ein Bild zurück / vor', 'keys.second': 'eine Sekunde zurück / vor', 'keys.shuttle': 'Shuttle rückwärts / Stopp / vorwärts', diff --git a/src/i18n/de/record.ts b/src/i18n/de/record.ts index 3ca4f63..de9348a 100644 --- a/src/i18n/de/record.ts +++ b/src/i18n/de/record.ts @@ -30,5 +30,19 @@ export default { 'record.codecHint': 'Gilt für Testbilder, Capture-Geräte, DeckLink und NDI. Netzwerkquellen (RTSP, SRT, RTMP, UDP …) werden unverändert ohne Neukodierung geschrieben. HEVC nutzt VideoToolbox, NVENC oder Quick Sync und weicht sonst auf libx265 aus. FFV1 behält das Quellformat exakt, auch Sub-Black und Super-White.', 'record.container': 'Container', 'record.segment': 'Segmentlänge', + 'ring.toggle': 'Ring', + 'ring.toggleTitle': 'Ringpuffer: die letzten {pre} s auf der Platte halten, um sie nachträglich zu sichern (P, Kopfleiste)', + 'ring.status': 'Ring {pre} s · {have} s im Puffer', + 'ring.save': 'Ring sichern', + 'ring.saveTitle': 'Ringpuffer aller Quellen mit derselben Zeitmarke sichern (P)', + 'ring.saving': 'Sichere die Ringpuffer von {n} Quellen (Nachlauf {post} s) …', + 'ring.savingOne': 'sichert …', + 'ring.saved': 'Ringpuffer gesichert: {n} Dateien in {take}', + 'ring.savedSome': 'Ringpuffer gesichert: {n} Dateien in {take}, {failed} fehlgeschlagen', + 'ring.failed': 'Ringpuffer {source}: {error}', + 'ring.none': 'Kein Ringpuffer aktiv – in der Quellenkarte einschalten (Ring)', + 'ring.length': 'Ringpuffer-Länge', + 'ring.post': 'Nachlauf', + 'ring.hint': 'Bridge-Quellen mit eingeschaltetem Ring halten so viele Sekunden auf der Platte (Segmente von 2 s, reihum überschrieben). Sichern (P) schreibt das Fenster bis jetzt plus Nachlauf in eine eigene Aufnahme, ab dem letzten Keyframe vor dem Fensteranfang, ohne Neukodierung. Alle Quellen bekommen dieselbe Zeitmarke, verschoben um ihre gemessene Latenz.', 'record.segmentHint': 'Nach dieser Zeit beginnt eine neue Datei; stürzt App oder Rechner ab, fehlt höchstens das gerade geschriebene Segment.', } satisfies Translation; diff --git a/src/i18n/en/bridge.ts b/src/i18n/en/bridge.ts index 021b012..5e75875 100644 --- a/src/i18n/en/bridge.ts +++ b/src/i18n/en/bridge.ts @@ -84,6 +84,9 @@ export default { 'bridge.record.stoppedDiskFull': 'Recording stopped: disk full', 'bridge.record.hevcSoftware': 'No hardware HEVC encoder – libx265 (software)', 'bridge.record.hevcNone': 'No HEVC encoder works here – ProRes 422 HQ instead', + 'bridge.ring.off': 'Ring buffer not running for this source', + 'bridge.ring.empty': 'Ring buffer has no pictures yet', + 'bridge.ring.joinFailed': 'Ring buffer could not be saved: {reason}', 'bridge.record.write': 'Cannot write: {reason}', 'bridge.folder.notReleased': 'Folder not released – start the bridge with --watch-dir ', 'bridge.folder.unreadable': '{file}: no readable picture', diff --git a/src/i18n/en/menu.ts b/src/i18n/en/menu.ts index 286f95e..e57495f 100644 --- a/src/i18n/en/menu.ts +++ b/src/i18n/en/menu.ts @@ -87,6 +87,7 @@ export default { 'keys.g.record': 'Recording', 'keys.record': 'Record all sources / stop all recordings', 'keys.mark': 'Marker in the running recordings', + 'keys.ringSave': 'Save the ring buffers of all sources (same time mark)', 'keys.frame': 'one frame back / forward', 'keys.second': 'one second back / forward', 'keys.shuttle': 'Shuttle reverse / stop / forward', diff --git a/src/i18n/en/record.ts b/src/i18n/en/record.ts index 9052f17..9cd364f 100644 --- a/src/i18n/en/record.ts +++ b/src/i18n/en/record.ts @@ -29,5 +29,19 @@ export default { 'record.codecHint': 'Applies to test patterns, capture devices, DeckLink and NDI. Network sources (RTSP, SRT, RTMP, UDP …) are written unchanged without re-encoding. HEVC uses VideoToolbox, NVENC or Quick Sync and falls back to libx265. FFV1 keeps the source format exactly, including sub-black and super-white.', 'record.container': 'Container', 'record.segment': 'Segment length', + 'ring.toggle': 'Ring', + 'ring.toggleTitle': 'Ring buffer: keep the last {pre} s on disk, to save them afterwards (P, header)', + 'ring.status': 'ring {pre} s · {have} s held', + 'ring.save': 'Save ring', + 'ring.saveTitle': 'Save the ring buffers of all sources with the same time mark (P)', + 'ring.saving': 'Saving the ring buffers of {n} sources (post-roll {post} s) …', + 'ring.savingOne': 'saving …', + 'ring.saved': 'Ring buffers saved: {n} files in {take}', + 'ring.savedSome': 'Ring buffers saved: {n} files in {take}, {failed} failed', + 'ring.failed': 'Ring buffer {source}: {error}', + 'ring.none': 'No ring buffer running – switch it on in the source card (Ring)', + 'ring.length': 'Ring buffer length', + 'ring.post': 'Post-roll', + 'ring.hint': 'Bridge sources with Ring switched on keep this many seconds on disk (segments of 2 s, overwritten in turn). Save (P) writes the window ending now plus the post-roll into a take of its own, starting at the last key frame before the window, without re-encoding. All sources get the same time mark, shifted by their measured latency.', 'record.segmentHint': 'A new file starts after this time; if the app or the computer crashes, at most the segment being written is lost.', } as const satisfies Messages; diff --git a/src/main.ts b/src/main.ts index 2aaa33b..eed3030 100644 --- a/src/main.ts +++ b/src/main.ts @@ -56,7 +56,7 @@ import { Source, type AudioInput, type SourceKind, type SourceSettings } from '. import { bridgeFfmpegText, fetchBridgeHealth, pushFfmpegText, sourceFfmpegText, type BridgeHealth } from './ffmpegInfo'; import { SOURCE_ITEMS, mountMenu, refreshMenu, registerMenuCommand, type MenuActions, type MenuState } from './menu/appMenu'; import { openSettings, refreshSettings, registerSettingsSection } from './menu/settings'; -import { initRecording, isRecording, markAll, eventAll, onBridgeRecord, recordAll, recordAllButton, recordRow, recordSection, recordingState, sourceStopped, startRecording, stopRecording } from './record/record'; +import { initRecording, isRecording, markAll, eventAll, onBridgeRecord, recordAll, recordAllButton, recordRow, recordSection, recordingState, ringOn, saveRingButton, saveRings, setRing, sourceStopped, startRecording, stopRecording } from './record/record'; import { aboutSection, keysSection } from './menu/pages'; import { LANG_NAMES, LANGS, lang, langPref, num, setLangPref, systemLang, t, type LangPref } from './i18n'; import { ShadingControl, SIM_URL } from './shading/ui'; @@ -167,7 +167,7 @@ const freezeBtn = button(t('main.freeze'), () => toggleFreeze(), { title: t('mai const sideToggle = iconButton('◧', t('main.side.aria'), () => toggleSidebar(), { title: t('main.side.toggle'), attrs: { id: 'toggle-side' } }); /** Header overflow (narrow windows): the same layout presets and scale as the header groups. */ const moreMenu = popover({ label: '⋯', title: t('ui.more'), heading: t('ui.moreTitle'), cls: 'more-body', content: () => [field(t('ui.layouts'), layoutControl()), settingRow(SET.unit), - row(button(t('record.all'), () => recordAll(), { title: t('record.allTitle') }))] }); + row(button(t('record.all'), () => recordAll(), { title: t('record.allTitle') }), button(t('ring.save'), () => saveRings(), { title: t('ring.saveTitle') }))] }); moreMenu.id = 'bar-more'; const side = h('aside', { class: 'side', id: 'side' }, h('h2', {}, t('main.sources')), @@ -190,6 +190,7 @@ app.replaceChildren( h('span', { class: 'fps', id: 'fps', title: t('main.fpsTitle') }), gpuWarn, recordAllButton, + saveRingButton, freezeBtn, iconButton('⚙', t('common.settings'), () => openSettings(), { title: t('main.settingsTitle'), attrs: { id: 'settings-btn' } }), iconButton('⛶', t('main.full'), () => toggleFullscreen(), { title: t('main.fullTitle'), attrs: { id: 'full' } })), @@ -1933,6 +1934,17 @@ function execute(c: Command): unknown { recordAll(on); return recordingState(); } + case 'ring': { + const list = c.source !== undefined ? [need(findSource(c.source), `Source ${c.source} not found`)] : sources.filter((s) => s.kind === 'stream'); + const on = c.mode === 'toggle' ? !list.some(ringOn) : c.mode === 'on'; + for (const s of list) setRing(s, on); + return recordingState(); + } + case 'ring.save': { + const n = saveRings({ pre: typeof c.pre === 'number' ? c.pre : undefined, post: typeof c.post === 'number' ? c.post : undefined }); + if (!n) throw new Error('No ring buffer running'); + return { saving: n }; + } case 'record.mark': { const list = c.source !== undefined ? [need(findSource(c.source), `Source ${c.source} not found`)] : undefined; return { marked: markAll(typeof c.label === 'string' ? c.label : '', list) }; @@ -2050,6 +2062,7 @@ document.addEventListener('keydown', (e) => { else if (e.key === 'b' || e.key === 'B') toggleSidebar(); else if (e.key === 'r' || e.key === 'R') recordAll(); else if (e.key === 'm' || e.key === 'M') markAll(); + else if (e.key === 'p' || e.key === 'P') saveRings(); else if (e.key === 'c' || e.key === 'C') setStage(STAGES[(STAGES.indexOf(state.stage ?? 'signal') + 1) % STAGES.length]); else if (e.key === 'Escape') { if (narrowQuery.matches && drawerOpen) { toggleSidebar(false); $('#toggle-side').focus(); } else if (dock.api.hasMaximizedGroup()) dock.exitMaximized(); else { sources.forEach((s) => { s.probe = null; s.roi = null; s.faceMode = 'off'; }); refreshHeads(); } } }); @@ -2181,7 +2194,7 @@ sources.forEach((s) => { if (s.kind === 'stream' && s.url) s.connectStream(s.url Source.onResolve = (s, msg) => resolvePlayback.onResolve(s, msg); Source.onStop = (s) => { resolvePlayback.onStop(s); sourceStopped(s); }; Source.onRecord = (s, msg) => onBridgeRecord(s, msg); -initRecording({ sources: () => sources, bridgeUrl, changed: () => renderSources(), hud: alertHud, latencyOf: (s) => s.latency.summary()?.total.mean ?? null }); +initRecording({ sources: () => sources, bridgeUrl, changed: () => renderSources(), hud: alertHud, latencyOf: (s) => s.latency.summary()?.total.mean ?? null, save }); // running DaVinci Resolve on the bridge machine: one click to connect mountResolveLive($('#resolve-live'), { diff --git a/src/menu/shortcuts.ts b/src/menu/shortcuts.ts index 8c9bd28..2602c55 100644 --- a/src/menu/shortcuts.ts +++ b/src/menu/shortcuts.ts @@ -16,6 +16,7 @@ export const SHORTCUTS: Shortcut[] = [ { group: t('keys.g.view'), keys: ['C'], what: t('keys.stage') }, { group: t('keys.g.record'), keys: ['R'], what: t('keys.record') }, { group: t('keys.g.record'), keys: ['M'], what: t('keys.mark') }, + { group: t('keys.g.record'), keys: ['P'], what: t('keys.ringSave') }, { group: t('keys.g.video'), keys: ['Left', 'Right'], what: t('keys.frame') }, { group: t('keys.g.video'), keys: ['Shift+Left', 'Shift+Right'], what: t('keys.second') }, { group: t('keys.g.video'), keys: ['J', 'K', 'L'], what: t('keys.shuttle') }, diff --git a/src/record/record.ts b/src/record/record.ts index 1fdac3a..1c8034c 100644 --- a/src/record/record.ts +++ b/src/record/record.ts @@ -12,7 +12,9 @@ import type { SettingsSection } from '../menu/settings'; import { BrowserRecorder } from './browser'; export type RecordCodec = 'prores' | 'dnxhr' | 'hevc' | 'ffv1'; -export interface RecordPrefs { root: string; codec: RecordCodec; container: 'mkv' | 'mov'; segmentMin: number } +export interface RecordPrefs { root: string; codec: RecordCodec; container: 'mkv' | 'mov'; segmentMin: number; ringPre: number; ringPost: number } +/** Ring buffer of a bridge source (server/ring.mjs) as the bridge reports it. */ +export interface RingState { state: 'starting' | 'running' | 'saving' | 'error'; pre?: number; post?: number; buffered?: number; message?: string; note?: string } export interface RecState { state: 'starting' | 'recording' | 'stopped' | 'error'; take?: string; file?: string; seconds?: number; bytes?: number; segments?: number; @@ -21,7 +23,7 @@ export interface RecState { } const KEY = 'lz-scopes.record'; -const DEFAULTS: RecordPrefs = { root: '', codec: 'prores', container: 'mkv', segmentMin: 5 }; +const DEFAULTS: RecordPrefs = { root: '', codec: 'prores', container: 'mkv', segmentMin: 5, ringPre: 60, ringPost: 0 }; export function loadPrefs(): RecordPrefs { try { return { ...DEFAULTS, ...JSON.parse(localStorage.getItem(KEY) ?? '{}') }; } catch { return { ...DEFAULTS }; } @@ -43,7 +45,7 @@ export function recordKind(s: Pick & { isVide return null; } -interface Ctx { sources: () => Source[]; bridgeUrl: () => string; changed: () => void; hud: (text: string) => void; latencyOf: (s: Source) => number | null } +interface Ctx { sources: () => Source[]; bridgeUrl: () => string; changed: () => void; hud: (text: string) => void; latencyOf: (s: Source) => number | null; save: () => void } let ctx: Ctx | null = null; const recs = new Map(); const browser = new Map; stream: MediaStream; own: boolean }>(); @@ -64,6 +66,7 @@ export async function refreshRoots() { /** A recording message of the bridge for this source (Source.onRecord). */ export function onBridgeRecord(s: Source, m: Record) { + if (m.type === 'ring') return onBridgeRing(s, m); const prev = recs.get(s.id); const st = m.state as RecState['state'] | 'mark'; if (st === 'mark') return; @@ -185,6 +188,7 @@ export function eventAll(kind: string, data: unknown, list?: Source[]) { export function sourceStopped(s: Source) { if (browser.has(s.id)) stopRecording(s); else recs.delete(s.id); + rings.delete(s.id); } const pad = (n: number) => String(n).padStart(2, '0'); @@ -218,7 +222,10 @@ export function recordRow(s: Source): HTMLElement | null { const r = recs.get(s.id); if (!kind && !r) return null; const on = isRecording(s); + const ring = ringOn(s); return row( + ...(kind === 'bridge' || s.settings.ring ? [button(t('ring.toggle'), () => setRing(s, !s.settings.ring), { small: true, pressed: !!s.settings.ring, title: t('ring.toggleTitle', { pre: prefs.ringPre }), attrs: { 'data-ring': s.id } }), + h('span', { class: ['rec-status', rings.get(s.id)?.state === 'error' ? 'bad' : ''], 'data-ringstat': s.id }, ring || rings.has(s.id) ? ringText(s) : '')] : []), button(h('span', { class: 'rec-label' }, on ? t('record.stop') : t('record.rec')), () => toggleRecording(s), { small: true, pressed: on, title: kind === 'browser' ? t('record.titleBrowser') : t('record.title'), attrs: { class: ['btn', 'mini', 'rec', on ? 'on' : ''], 'data-rec': s.id } }), h('span', { class: ['rec-status', r?.state === 'error' ? 'bad' : ''], 'data-recstat': s.id, role: r?.state === 'error' ? 'alert' : null }, statusText(s)), @@ -231,8 +238,87 @@ export const recordAllButton = button(t('record.all'), () => { if (!r.started && !r.stopped) ctx?.hud(t('record.nothing')); }, { title: t('record.allTitle'), pressed: false, attrs: { id: 'rec-all', class: 'btn rec' } }); +// ---- ring buffer ("save the last 60 s"): bridge sources only + +const rings = new Map(); +/** saves of one press: take → sources still saving and files written */ +const saves = new Map(); + +function onBridgeRing(s: Source, m: Record) { + const st = m.state as string; + const note = m.noteCode ? bridgeMessage({ message: String(m.note), code: String(m.noteCode), params: m.noteParams as Record }) : undefined; + if (st === 'running') rings.set(s.id, { state: rings.get(s.id)?.state === 'saving' ? 'saving' : 'running', pre: Number(m.pre), post: Number(m.post), buffered: Number(m.buffered), note }); + else if (st === 'stopped') rings.delete(s.id); + else if (st === 'saving') { const r = rings.get(s.id); if (r) r.state = 'saving'; } + else if (st === 'saved' || st === 'error') { + const r = rings.get(s.id); + if (r) r.state = 'running'; + if (st === 'error' && !r) rings.set(s.id, { state: 'error', message: bridgeMessage(m as never) }); + const take = String(m.take ?? ''); + const sv = saves.get(take); + if (sv) { + sv.left--; if (st === 'saved') sv.files++; else sv.failed++; + if (sv.left <= 0) { saves.delete(take); ctx?.hud(sv.failed ? t('ring.savedSome', { n: sv.files, failed: sv.failed, take }) : t('ring.saved', { n: sv.files, take })); } + } else if (st === 'error') ctx?.hud(t('ring.failed', { source: s.name, error: bridgeMessage(m as never) })); + } + ctx?.changed(); +} + +export const ringState = (s: Source) => rings.get(s.id) ?? null; +export const ringOn = (s: Source) => { const r = rings.get(s.id); return !!r && r.state !== 'error'; }; +export const anyRing = () => !!ctx?.sources().some(ringOn); + +/** Ring buffer on/off for one source; the choice is kept with the source and applied when it is live. */ +export function setRing(s: Source, on: boolean) { + s.settings.ring = on; + ctx?.save(); + if (on && !ringOn(s) && recordKind(s) === 'bridge') { + if (s.sendBridge({ type: 'ring', on: true, root: prefs.root, pre: prefs.ringPre, post: prefs.ringPost, codec: prefs.codec, name: s.name, settings: settingsOf(s) })) rings.set(s.id, { state: 'starting' }); + } else if (!on) { if (ringOn(s)) s.sendBridge({ type: 'ring', on: false }); rings.delete(s.id); } + ctx?.changed(); +} + +/** + * "Save": all running rings with the same time mark; each source's window is shifted by its + * latency on the bridge. `pre`/`post` default to the settings (at most what the ring holds). + */ +export function saveRings(o: { pre?: number; post?: number; list?: Source[] } = {}) { + if (!ctx) return 0; + const list = (o.list ?? ctx.sources()).filter((s) => ringOn(s) && rings.get(s.id)?.state !== 'saving'); + if (!list.length) { ctx.hud(t('ring.none')); return 0; } + const take = `${takeName()}-ring`, at = Date.now(); + saves.set(take, { left: list.length, files: 0, failed: 0 }); + for (const s of list) { + s.sendBridge({ type: 'ring.save', take, at, pre: o.pre ?? prefs.ringPre, post: o.post ?? prefs.ringPost, latencyMs: ctx.latencyOf(s) ?? 0, name: s.name, settings: settingsOf(s) }); + const r = rings.get(s.id); if (r) r.state = 'saving'; + } + ctx.hud(t('ring.saving', { n: list.length, post: o.post ?? prefs.ringPost })); + ctx.changed(); + return list.length; +} + +function ringText(s: Source) { + const r = rings.get(s.id); + if (!r) return ''; + if (r.state === 'error') return r.message ?? ''; + if (r.state === 'starting') return t('record.starting'); + if (r.state === 'saving') return t('ring.savingOne'); + return [t('ring.status', { pre: r.pre ?? prefs.ringPre, have: Math.min(r.buffered ?? 0, r.pre ?? 0) }), r.note].filter(Boolean).join(' · '); +} + +/** Header button "save ring buffer" (shown while a ring runs). */ +export const saveRingButton = button(t('ring.save'), () => saveRings(), { title: t('ring.saveTitle'), attrs: { id: 'ring-save', hidden: true } }); + function tick() { if (!ctx) return; + for (const s of ctx.sources()) { + // a source with the ring switched on gets it back when it is live again (reconnect, app start) + if (s.settings.ring && !rings.has(s.id) && recordKind(s) === 'bridge') setRing(s, true); + if (!recordKind(s) && rings.has(s.id) && s.status !== 'live') rings.delete(s.id); + const el = document.querySelector(`[data-ringstat="${s.id}"]`); + if (el) el.textContent = ringText(s); + } + saveRingButton.hidden = !anyRing(); for (const s of ctx.sources()) { const el = document.querySelector(`[data-recstat="${s.id}"]`); if (el) el.textContent = statusText(s); @@ -247,7 +333,8 @@ function tick() { /** State for the control API (Companion feedbacks). */ export function recordingState() { const list = (ctx?.sources() ?? []).filter(isRecording); - return { active: list.length, sources: list.map((s) => ({ name: s.name, id: s.id, seconds: Math.round((Date.now() - (recs.get(s.id)?.since ?? Date.now())) / 1000), take: recs.get(s.id)?.take ?? null })) }; + const ringList = (ctx?.sources() ?? []).filter(ringOn); + return { active: list.length, ring: ringList.map((s) => ({ name: s.name, id: s.id, pre: rings.get(s.id)?.pre ?? null, buffered: rings.get(s.id)?.buffered ?? 0 })), sources: list.map((s) => ({ name: s.name, id: s.id, seconds: Math.round((Date.now() - (recs.get(s.id)?.since ?? Date.now())) / 1000), take: recs.get(s.id)?.take ?? null })) }; } /** Settings → Recording. */ @@ -265,6 +352,9 @@ export function recordSection(order: number, chooseFolder?: () => Promise<{ name field(t('record.container'), select(prefs.container, [['mkv', 'Matroska (.mkv)'], ['mov', 'QuickTime (.mov)']], (v) => { prefs.container = v as 'mkv' | 'mov'; savePrefs(); })), field(t('record.segment'), numberInput(prefs.segmentMin, (v) => { prefs.segmentMin = v; savePrefs(); }, { min: 1, max: 60, clamp: true, size: 's' }), 'min'), hint(t('record.segmentHint')), + field(t('ring.length'), numberInput(prefs.ringPre, (v) => { prefs.ringPre = v; savePrefs(); }, { min: 4, max: 600, clamp: true, size: 'm' }), 's'), + field(t('ring.post'), numberInput(prefs.ringPost, (v) => { prefs.ringPost = v; savePrefs(); }, { min: 0, max: 120, clamp: true, size: 'm' }), 's'), + hint(t('ring.hint')), ); }; return { id: 'record', label: t('record.settings'), order, render: () => { render(); refreshRoots().then(render); return [body]; } }; diff --git a/src/sources.ts b/src/sources.ts index c86ca21..9f708b3 100644 --- a/src/sources.ts +++ b/src/sources.ts @@ -60,6 +60,8 @@ export interface SourceSettings { lowLatency?: boolean; /** this source's own low-latency settings; missing fields follow the global ones */ ll?: Partial; + /** bridge: keep a ring buffer for "save the last … s" (src/record/record.ts) */ + ring?: boolean; } export interface StreamInfo { @@ -496,8 +498,9 @@ export class Source { this.rtpStats = msg.rtp ?? null; this.phase = msg.phase ?? null; if (msg.rec) Source.onRecord?.(this, msg.rec); + if (msg.ring) Source.onRecord?.(this, msg.ring); if (msg.message) this.set(this.status === 'live' ? 'live' : 'connecting', bridgeMessage(msg)); - } else if (msg.type === 'record') { + } else if (msg.type === 'record' || msg.type === 'ring') { // local recording in the bridge (server/recorder.mjs) Source.onRecord?.(this, msg); } else if (msg.type === 'resolve') { diff --git a/src/style.css b/src/style.css index afb6445..8135d05 100644 --- a/src/style.css +++ b/src/style.css @@ -30,8 +30,8 @@ body { background: var(--surface-0); overflow: hidden; } } @container bar (max-width: 640px) { .bar { gap: 6px; } - /* recording stays in the ⋯ menu, the source cards and on R; the header shows it only while it runs */ - #rec-all:not(.on) { display: none; } + /* recording stays in the ⋯ menu, the source cards and on R / P; the header shows it only while it runs */ + #rec-all:not(.on), #ring-save { display: none; } .brand .product, .fps { display: none; } .brand { padding-right: 0; } } diff --git a/test/ring.test.ts b/test/ring.test.ts new file mode 100644 index 0000000..1a1a180 --- /dev/null +++ b/test/ring.test.ts @@ -0,0 +1,133 @@ +// Ring buffer (#140 step 5): window, segment selection, key frame search and joining with the +// shipped ffmpeg (copy from the last key frame before the window start, post-roll, latency). +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { + concatList, dropOverwritten, joinSegments, keyframes, lastKeyframeBefore, ringWrap, saveWindow, selectSegments, +// @ts-expect-error plain JS module +} from '../server/ring.mjs'; +import { validateCommand } from '../server/control.mjs'; +import { shippedFfmpeg } from './shippedFfmpeg'; + +const ffmpeg = shippedFfmpeg ?? ''; +const ffprobe = ffmpeg.replace(/ffmpeg(\.exe)?$/, (m) => m.replace('ffmpeg', 'ffprobe')); +const tmp: string[] = []; +const tempDir = () => { const d = mkdtempSync(join(tmpdir(), 'lzs-ring-')); tmp.push(d); return d; }; +afterAll(() => { for (const d of tmp) rmSync(d, { recursive: true, force: true }); }); + +type Entry = { file: string; startMs: number; endMs: number; gen: number }; +const seg = (i: number, gen = 1, len = 2000, t0 = 0): Entry => ({ file: `ring${gen}_${String(i).padStart(3, '0')}.mkv`, startMs: t0 + i * len, endMs: t0 + (i + 1) * len, gen }); + +describe('ring buffer: pure parts', () => { + it('number of files: window + post-roll + margin, two spare', () => { + expect(ringWrap(60, 0)).toBe(37); + expect(ringWrap(60, 10)).toBe(42); + }); + it('save window follows the latency of the source', () => { + expect(saveWindow(100_000, 60, 5, 0)).toEqual({ from: 40_000, to: 105_000 }); + // a camera 120 ms late shows the same moment 120 ms later in arrival time + expect(saveWindow(100_000, 60, 5, 120)).toEqual({ from: 40_120, to: 105_120 }); + expect(saveWindow(100_000, 1, 0, NaN)).toEqual({ from: 99_000, to: 100_000 }); + }); + it('selects the segments of the window with offsets into first and last file', () => { + const entries = [seg(3), seg(0), seg(1), seg(2), seg(4)]; + const s = selectSegments(entries, 1300, 7500); + expect(s.items.map((e: Entry) => e.file)).toEqual(['ring1_000.mkv', 'ring1_001.mkv', 'ring1_002.mkv', 'ring1_003.mkv']); + expect(s.inpoint).toBeCloseTo(1.3); + expect(s.outpoint).toBeCloseTo(1.5); + expect(s.complete).toBe(true); + // window older than the ring: what is there, marked incomplete + expect(selectSegments(entries, -5000, 3000).complete).toBe(false); + expect(selectSegments(entries, 20_000, 30_000)).toBeNull(); + }); + it('key frame search: last one at or before t, the first one otherwise', () => { + expect(lastKeyframeBefore([0, 1, 2, 3], 1.3)).toBe(1); + expect(lastKeyframeBefore([0, 1, 2, 3], 2)).toBe(2); + expect(lastKeyframeBefore([0.04, 1], 0)).toBe(0.04); + expect(lastKeyframeBefore([], 5)).toBe(0); + }); + it('concat list quotes paths and carries in/out points', () => { + expect(concatList([{ path: "/a/it's/x.mkv", inpoint: 1.25 }, { path: '/a/y.mkv', outpoint: 0.5 }])) + .toBe("file '/a/it'\\''s/x.mkv'\ninpoint 1.250000\nfile '/a/y.mkv'\noutpoint 0.500000\n"); + }); + it('a new segment line makes the same file and the next one stale (wrap)', () => { + const e = [seg(0), seg(1), seg(2), seg(3), seg(0, 2)]; + expect(dropOverwritten(e, 'ring1_003.mkv', 4, 1).map((x: Entry) => x.file)).toEqual(['ring1_001.mkv', 'ring1_002.mkv', 'ring2_000.mkv']); + }); + it('control API: ring and ring.save', () => { + expect(validateCommand({ cmd: 'ring', mode: 'on' })).toEqual({ ok: true, command: { cmd: 'ring', mode: 'on' } }); + expect(validateCommand({ cmd: 'ring.save', pre: 30, post: 5 })).toEqual({ ok: true, command: { cmd: 'ring.save', pre: 30, post: 5 } }); + expect(validateCommand({ cmd: 'ring.save', pre: -1 }).ok).toBe(false); + }); +}); + +const probe = (file: string) => { + const j = JSON.parse(execFileSync(ffprobe, ['-v', 'error', '-select_streams', 'v:0', '-count_frames', '-show_entries', 'stream=codec_name,nb_read_frames', '-of', 'json', file]).toString()); + return { codec: j.streams[0].codec_name as string, frames: Number(j.streams[0].nb_read_frames) }; +}; + +describe.skipIf(!shippedFfmpeg || !existsSync(ffprobe))('ring buffer with the shipped ffmpeg', () => { + it('copy: joined from the last key frame before the window start, cut at the post-roll end', async () => { + const dir = tempDir(); + // 6 s H.264, key frame every second, no B-frames, 2-s segments like the ring + const r = spawnSync(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=25:duration=6', + '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '25', '-bf', '0', '-f', 'segment', '-segment_time', '2', '-reset_timestamps', '1', join(dir, 'ring1_%03d.mkv')]); + expect(r.status).toBe(0); + expect(await keyframes(ffprobe, join(dir, 'ring1_000.mkv'))).toEqual([0, 1]); + const sel = selectSegments([seg(0), seg(1), seg(2)], 1300, 5000); + const out = join(dir, 'out.mkv'); + const res = await joinSegments({ ffmpeg, ffprobe, dir, sel, out }); + expect(res).toMatchObject({ ok: true, startOffset: 1, files: 3 }); + // from the key frame at 1.0 s to 5.0 s: 4 s at 25 fps + expect(probe(out)).toEqual({ codec: 'h264', frames: 100 }); + // the stage folder is gone, the ring files stay + expect(readdirSync(dir).sort()).toEqual(['out.mkv', 'ring1_000.mkv', 'ring1_001.mkv', 'ring1_002.mkv']); + }, 30_000); + + it('bridge: ring of a test pattern (ProRes), save with post-roll, companion file of the window', async () => { + const dir = tempDir(); + // @ts-expect-error plain JS module + const { startBridge } = await import('../server/index.mjs'); + const { default: WebSocket } = await import('ws'); + const bridge = await startBridge({ port: 0, recordDirs: [dir] }); + const ws = new WebSocket(`ws://127.0.0.1:${bridge.port}/stream?url=test:testsrc&width=160`); + const msgs: Record[] = []; + try { + ws.on('message', (d: Buffer, bin: boolean) => { if (!bin) msgs.push(JSON.parse(String(d))); }); + await new Promise((ok) => ws.once('open', ok)); + await until(() => msgs.some((m) => m.type === 'info')); + ws.send(JSON.stringify({ type: 'ring', on: true, pre: 4, post: 1, codec: 'prores', name: 'Cam' })); + await new Promise((r) => setTimeout(r, 8000)); + ws.send(JSON.stringify({ type: 'mark', label: 'in' })); + ws.send(JSON.stringify({ type: 'ring.save', take: 't1', at: Date.now(), name: 'Cam', latencyMs: 0 })); + await until(() => msgs.some((m) => m.type === 'ring' && (m.state === 'saved' || m.state === 'error')), 20_000); + ws.send(JSON.stringify({ type: 'ring', on: false })); + await until(() => msgs.some((m) => m.type === 'ring' && m.state === 'stopped'), 10_000); + await new Promise((r) => setTimeout(r, 1500)); + } finally { ws.close(); bridge.close(); } + const saved = msgs.find((m) => m.type === 'ring' && m.state === 'saved'); + expect(saved).toMatchObject({ file: 'Cam_ring.mkv', complete: true }); + const v = probe(join(dir, 't1', 'Cam_ring.mkv')); + expect(v.codec).toBe('prores'); + // 4 s before + 1 s post-roll at 25 fps = 125 frames (intra codec: starts at the window, ±1 frame); a slow + // CI runner encodes 1080p ProRes below real time and fills the same wall-clock window with fewer frames + expect(v.frames).toBeLessThanOrEqual(127); + expect(v.frames).toBeGreaterThan(40); + const j = JSON.parse(readFileSync(join(dir, 't1', 'Cam_ring.json'), 'utf8')); + expect(j).toMatchObject({ kind: 'ring', window: { preSeconds: 4, postSeconds: 1, complete: true }, video: { codec: 'prores' } }); + expect(j.markers.map((m: { label: string }) => m.label)).toEqual(['in']); + // the ring folder is removed after the ring stops + expect(readdirSync(join(dir, '.lzs-ring'))).toEqual([]); + }, 60_000); +}); + +async function until(f: () => boolean, ms = 10_000) { + const t0 = Date.now(); + while (!f()) { + if (Date.now() - t0 > ms) throw new Error('timeout'); + await new Promise((r) => setTimeout(r, 50)); + } +}