Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Datum-Uhrzeit>/`, darin je Quelle `<Name>_000.mkv`, `<Name>_001.mkv` … und eine Begleitdatei `<Name>.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 <Ordner>` (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 `<Datum-Uhrzeit>-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).

Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<date-time>/` with `<name>_000.mkv`, `<name>_001.mkv` … per source and a companion file `<name>.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 <folder>` (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, `<date-time>-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).

Expand Down Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions companion/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand Down
5 changes: 5 additions & 0 deletions companion/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
}
Expand Down
7 changes: 6 additions & 1 deletion companion/src/feedbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
2 changes: 2 additions & 0 deletions companion/src/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
11 changes: 9 additions & 2 deletions companion/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -162,6 +162,13 @@ export const checks = {
loudness_paused: (s: ScopesState) => s.audio?.paused === true,
true_peak_above: (s: ScopesState, o: Record<string, unknown>) => 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<string, unknown>) => {
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<string, unknown>) => {
const list = s.recording?.sources ?? []
const want = String(o.source ?? '').trim()
Expand Down
7 changes: 7 additions & 0 deletions companion/test/module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<string, unknown>][]) {
const r = validateCommand(buildCommand(id, o))
Expand Down
Loading
Loading