capturekit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- capturekit/__init__.py +164 -0
- capturekit/_backends/__init__.py +0 -0
- capturekit/_backends/base.py +89 -0
- capturekit/_backends/linux/__init__.py +1 -0
- capturekit/_backends/linux/_xlib.py +655 -0
- capturekit/_backends/linux/api.py +160 -0
- capturekit/_backends/linux/pulse.py +464 -0
- capturekit/_backends/linux/x11.py +720 -0
- capturekit/_backends/windows/__init__.py +0 -0
- capturekit/_backends/windows/_com.py +350 -0
- capturekit/_backends/windows/_d3d11.py +307 -0
- capturekit/_backends/windows/_win32.py +420 -0
- capturekit/_backends/windows/_winrt.py +162 -0
- capturekit/_backends/windows/api.py +214 -0
- capturekit/_backends/windows/cursor.py +66 -0
- capturekit/_backends/windows/dxgi_duplication.py +310 -0
- capturekit/_backends/windows/wasapi.py +570 -0
- capturekit/_backends/windows/wgc.py +398 -0
- capturekit/_pipeline/__init__.py +1 -0
- capturekit/_pipeline/aligner.py +177 -0
- capturekit/_pipeline/clock.py +47 -0
- capturekit/_pipeline/frame_queue.py +120 -0
- capturekit/_pipeline/mixer.py +80 -0
- capturekit/_pipeline/timeline.py +133 -0
- capturekit/_platform.py +24 -0
- capturekit/_version.py +1 -0
- capturekit/_worker.py +100 -0
- capturekit/capabilities.py +79 -0
- capturekit/capture.py +141 -0
- capturekit/config.py +85 -0
- capturekit/encoding/__init__.py +5 -0
- capturekit/encoding/ffmpeg.py +356 -0
- capturekit/encoding/pipes.py +212 -0
- capturekit/errors.py +109 -0
- capturekit/py.typed +0 -0
- capturekit/recording.py +940 -0
- capturekit/sources.py +124 -0
- capturekit/types.py +211 -0
- capturekit-0.1.0.dist-info/METADATA +112 -0
- capturekit-0.1.0.dist-info/RECORD +41 -0
- capturekit-0.1.0.dist-info/WHEEL +4 -0
capturekit/recording.py
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
"""Recording: synchronised video + audio capture, optionally encoded by FFmpeg.
|
|
2
|
+
|
|
3
|
+
Thread layout (see docs/architecture.md):
|
|
4
|
+
|
|
5
|
+
* video thread – owns the video backend, paces CFR slots, fills FrameQueue
|
|
6
|
+
* consumer – writes frames to FFmpeg stdin and/or dispatches callbacks
|
|
7
|
+
* audio threads – one per source (backend-owned); align + fan out samples
|
|
8
|
+
* audio clock – heartbeat that fills silence when sources deliver nothing
|
|
9
|
+
* track writers – one per FFmpeg audio input pipe
|
|
10
|
+
* dispatcher – runs user callbacks (never on capture threads)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import enum
|
|
16
|
+
import functools
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import queue
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Callable, Sequence
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from types import TracebackType
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from . import _platform
|
|
30
|
+
from ._backends.base import AudioBackend, AudioPacket, VideoBackend, blit_clear
|
|
31
|
+
from ._pipeline.aligner import AudioAligner, Samples
|
|
32
|
+
from ._pipeline.clock import NS_PER_S, now_ns, sleep_until
|
|
33
|
+
from ._pipeline.frame_queue import BufferPool, DupItem, FrameItem, FrameQueue
|
|
34
|
+
from ._pipeline.mixer import AudioMixer
|
|
35
|
+
from ._pipeline.timeline import Timeline
|
|
36
|
+
from .config import AudioMixMode, RecordingConfig, TargetLostPolicy
|
|
37
|
+
from .encoding import ffmpeg as ff
|
|
38
|
+
from .encoding.pipes import OutputPipe, create_pipe
|
|
39
|
+
from .errors import (
|
|
40
|
+
CaptureTimeoutError,
|
|
41
|
+
EncoderError,
|
|
42
|
+
RecorderStateError,
|
|
43
|
+
TargetLostError,
|
|
44
|
+
)
|
|
45
|
+
from .sources import AudioSourceSpec, VideoSource, audio_source_label
|
|
46
|
+
from .types import AudioChunk, Frame
|
|
47
|
+
|
|
48
|
+
log = logging.getLogger("capturekit.recorder")
|
|
49
|
+
|
|
50
|
+
AUDIO_CHANNELS = 2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RecorderState(enum.Enum):
|
|
54
|
+
IDLE = "idle"
|
|
55
|
+
STARTING = "starting"
|
|
56
|
+
RECORDING = "recording"
|
|
57
|
+
PAUSED = "paused"
|
|
58
|
+
STOPPING = "stopping"
|
|
59
|
+
STOPPED = "stopped"
|
|
60
|
+
FAILED = "failed"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _Accum:
|
|
64
|
+
__slots__ = ("count", "max", "total")
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self.count = 0
|
|
68
|
+
self.total = 0.0
|
|
69
|
+
self.max = 0.0
|
|
70
|
+
|
|
71
|
+
def add(self, v: float) -> None:
|
|
72
|
+
self.count += 1
|
|
73
|
+
self.total += v
|
|
74
|
+
if v > self.max:
|
|
75
|
+
self.max = v
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def avg(self) -> float:
|
|
79
|
+
return self.total / self.count if self.count else 0.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class VideoStats:
|
|
84
|
+
backend: str
|
|
85
|
+
encoder: str | None
|
|
86
|
+
fps: float
|
|
87
|
+
output_size: tuple[int, int]
|
|
88
|
+
content_size: tuple[int, int]
|
|
89
|
+
slots: int #: frames in the output (= duration * fps)
|
|
90
|
+
frames_new: int #: slots filled with newly captured content
|
|
91
|
+
frames_duplicated: int #: slots repeating the previous image (no change)
|
|
92
|
+
frames_dropped: int #: captured frames discarded because the encoder lagged
|
|
93
|
+
frames_late: int #: slots skipped because the pacer woke up late
|
|
94
|
+
frames_encoded: int #: frames written to the encoder (incl. duplicates)
|
|
95
|
+
queue_depth: int
|
|
96
|
+
queue_depth_max: int
|
|
97
|
+
capture_ms_avg: float
|
|
98
|
+
capture_ms_max: float
|
|
99
|
+
pacing_jitter_ms_avg: float
|
|
100
|
+
pacing_jitter_ms_max: float
|
|
101
|
+
encoder_write_ms_avg: float
|
|
102
|
+
encoder_write_ms_max: float
|
|
103
|
+
target_lost: bool
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class AudioTrackStats:
|
|
108
|
+
name: str
|
|
109
|
+
device: str
|
|
110
|
+
packets: int
|
|
111
|
+
duration_s: float
|
|
112
|
+
silence_inserted_s: float
|
|
113
|
+
trimmed_s: float
|
|
114
|
+
drift_corrected_samples: int
|
|
115
|
+
discontinuities: int
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class RecordingStats:
|
|
120
|
+
state: RecorderState
|
|
121
|
+
output: str | None
|
|
122
|
+
media_duration_s: float
|
|
123
|
+
wall_elapsed_s: float
|
|
124
|
+
video: VideoStats | None
|
|
125
|
+
audio: tuple[AudioTrackStats, ...]
|
|
126
|
+
callbacks_dropped: int
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class RecordingResult:
|
|
131
|
+
output: str | None
|
|
132
|
+
stats: RecordingStats
|
|
133
|
+
error: BaseException | None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- helpers -------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _Dispatcher:
|
|
140
|
+
"""Runs user callbacks on one thread; frame callbacks are dropped (and
|
|
141
|
+
counted) when the consumer is slow, lifecycle callbacks never are."""
|
|
142
|
+
|
|
143
|
+
def __init__(self) -> None:
|
|
144
|
+
self._q: queue.Queue[tuple[Callable[..., None], tuple[object, ...]] | None] = queue.Queue()
|
|
145
|
+
self.dropped = 0
|
|
146
|
+
self._thread = threading.Thread(target=self._run, name="capturekit-callbacks", daemon=True)
|
|
147
|
+
self._thread.start()
|
|
148
|
+
|
|
149
|
+
def _run(self) -> None:
|
|
150
|
+
while True:
|
|
151
|
+
item = self._q.get()
|
|
152
|
+
if item is None:
|
|
153
|
+
return
|
|
154
|
+
fn, args = item
|
|
155
|
+
try:
|
|
156
|
+
fn(*args)
|
|
157
|
+
except Exception:
|
|
158
|
+
log.exception("exception in user callback %r", fn)
|
|
159
|
+
|
|
160
|
+
def post(self, fn: Callable[..., None], *args: object, droppable: bool = False) -> None:
|
|
161
|
+
if droppable and self._q.qsize() > 64:
|
|
162
|
+
self.dropped += 1
|
|
163
|
+
return
|
|
164
|
+
self._q.put((fn, args))
|
|
165
|
+
|
|
166
|
+
def close(self, timeout_s: float = 5.0) -> None:
|
|
167
|
+
self._q.put(None)
|
|
168
|
+
if threading.current_thread() is not self._thread:
|
|
169
|
+
self._thread.join(timeout_s)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _TrackWriter:
|
|
173
|
+
"""Streams one audio track into an FFmpeg input pipe."""
|
|
174
|
+
|
|
175
|
+
MAX_QUEUED_S = 30.0
|
|
176
|
+
|
|
177
|
+
def __init__(self, label: str, sample_rate: int, on_error: Callable[[BaseException], None]):
|
|
178
|
+
self.pipe: OutputPipe = create_pipe(label)
|
|
179
|
+
self._q: queue.Queue[bytes | None] = queue.Queue()
|
|
180
|
+
self._queued = 0
|
|
181
|
+
self._limit = int(self.MAX_QUEUED_S * sample_rate * AUDIO_CHANNELS * 4)
|
|
182
|
+
self._lock = threading.Lock()
|
|
183
|
+
self._on_error = on_error
|
|
184
|
+
self._failed = False
|
|
185
|
+
self._aborted = False
|
|
186
|
+
self._thread = threading.Thread(
|
|
187
|
+
target=self._run, name=f"capturekit-track-{label}", daemon=True
|
|
188
|
+
)
|
|
189
|
+
self._thread.start()
|
|
190
|
+
|
|
191
|
+
def put(self, block: Samples) -> None:
|
|
192
|
+
if self._failed:
|
|
193
|
+
return
|
|
194
|
+
data = np.ascontiguousarray(block, dtype=np.float32).tobytes()
|
|
195
|
+
with self._lock:
|
|
196
|
+
self._queued += len(data)
|
|
197
|
+
over = self._queued > self._limit
|
|
198
|
+
if over:
|
|
199
|
+
self._failed = True
|
|
200
|
+
self._on_error(
|
|
201
|
+
EncoderError(
|
|
202
|
+
f"FFmpeg is not consuming audio (track backlog > {self.MAX_QUEUED_S:.0f}s)"
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
return
|
|
206
|
+
self._q.put(data)
|
|
207
|
+
|
|
208
|
+
def _run(self) -> None:
|
|
209
|
+
try:
|
|
210
|
+
self.pipe.connect()
|
|
211
|
+
while True:
|
|
212
|
+
data = self._q.get()
|
|
213
|
+
if data is None:
|
|
214
|
+
break
|
|
215
|
+
with self._lock:
|
|
216
|
+
self._queued -= len(data)
|
|
217
|
+
if not self._failed:
|
|
218
|
+
self.pipe.write(data)
|
|
219
|
+
except BaseException as exc:
|
|
220
|
+
self._failed = True
|
|
221
|
+
if not self._aborted: # after abort() the cause is reported by FFmpeg's exit
|
|
222
|
+
self._on_error(exc)
|
|
223
|
+
finally:
|
|
224
|
+
self.pipe.close()
|
|
225
|
+
|
|
226
|
+
def finish(self) -> None:
|
|
227
|
+
self._q.put(None)
|
|
228
|
+
|
|
229
|
+
def abort(self) -> None:
|
|
230
|
+
"""Stop writing; unblocks connect() if FFmpeg never opened the pipe."""
|
|
231
|
+
self._aborted = True
|
|
232
|
+
self._failed = True
|
|
233
|
+
self.pipe.abort()
|
|
234
|
+
|
|
235
|
+
def join(self, timeout_s: float) -> None:
|
|
236
|
+
self._thread.join(timeout_s)
|
|
237
|
+
if self._thread.is_alive():
|
|
238
|
+
self.abort()
|
|
239
|
+
self._thread.join(2)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class _AudioInput:
|
|
243
|
+
def __init__(
|
|
244
|
+
self, index: int, spec: AudioSourceSpec, backend: AudioBackend, aligner: AudioAligner
|
|
245
|
+
) -> None:
|
|
246
|
+
self.index = index
|
|
247
|
+
self.spec = spec
|
|
248
|
+
self.name = audio_source_label(spec)
|
|
249
|
+
self.backend = backend
|
|
250
|
+
self.aligner = aligner
|
|
251
|
+
self.lock = threading.Lock()
|
|
252
|
+
self.writer: _TrackWriter | None = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# --- recorder ------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class Recorder:
|
|
259
|
+
"""Records a video source and/or audio sources.
|
|
260
|
+
|
|
261
|
+
``output=None`` captures without writing a file; use ``on_video_frame`` /
|
|
262
|
+
``on_audio`` to receive data. Callbacks run on a dedicated dispatcher
|
|
263
|
+
thread; in recorder callbacks ``timestamp_ns`` is *media time* (ns since
|
|
264
|
+
the start of the recording, pauses excluded) so video and audio callbacks
|
|
265
|
+
share one timeline.
|
|
266
|
+
|
|
267
|
+
Example::
|
|
268
|
+
|
|
269
|
+
rec = Recorder(WindowSource(win), [ProcessAudioSource.for_window(win)],
|
|
270
|
+
"out.mkv", RecordingConfig(fps=60))
|
|
271
|
+
rec.start(); ...; result = rec.stop()
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def __init__(
|
|
275
|
+
self,
|
|
276
|
+
video: VideoSource | None = None,
|
|
277
|
+
audio: Sequence[AudioSourceSpec] = (),
|
|
278
|
+
output: str | os.PathLike[str] | None = None,
|
|
279
|
+
config: RecordingConfig | None = None,
|
|
280
|
+
*,
|
|
281
|
+
on_error: Callable[[BaseException], None] | None = None,
|
|
282
|
+
on_state_change: Callable[[RecorderState], None] | None = None,
|
|
283
|
+
on_video_frame: Callable[[Frame], None] | None = None,
|
|
284
|
+
on_audio: Callable[[AudioChunk], None] | None = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
if video is None and not audio:
|
|
287
|
+
raise ValueError("a recording needs a video source and/or audio sources")
|
|
288
|
+
self.video_source = video
|
|
289
|
+
self.audio_sources = tuple(audio)
|
|
290
|
+
self.output = str(Path(output).resolve()) if output is not None else None
|
|
291
|
+
self.config = config or RecordingConfig()
|
|
292
|
+
self._on_error = on_error
|
|
293
|
+
self._on_state = on_state_change
|
|
294
|
+
self._on_frame = on_video_frame
|
|
295
|
+
self._on_audio = on_audio
|
|
296
|
+
|
|
297
|
+
self._state = RecorderState.IDLE
|
|
298
|
+
self._state_lock = threading.RLock()
|
|
299
|
+
self._timeline = Timeline()
|
|
300
|
+
self._stop_evt = threading.Event()
|
|
301
|
+
self._stop_requested = threading.Event()
|
|
302
|
+
self._audio_closed = False
|
|
303
|
+
self._done = threading.Event()
|
|
304
|
+
self._error: BaseException | None = None
|
|
305
|
+
self._fatal = False
|
|
306
|
+
self._dispatcher: _Dispatcher | None = None
|
|
307
|
+
self._ffmpeg: ff.FFmpegProcess | None = None
|
|
308
|
+
self._encoder: str | None = None
|
|
309
|
+
self._start_wall = 0
|
|
310
|
+
self._end_media_ns: int | None = None
|
|
311
|
+
self._shutdown_started = False
|
|
312
|
+
self._output_preexisted = True
|
|
313
|
+
|
|
314
|
+
# video
|
|
315
|
+
self._video_thread: threading.Thread | None = None
|
|
316
|
+
self._consumer_thread: threading.Thread | None = None
|
|
317
|
+
self._video_ready = threading.Event()
|
|
318
|
+
self._video_error: BaseException | None = None
|
|
319
|
+
self._video_backend_name = ""
|
|
320
|
+
self._out_size = (0, 0)
|
|
321
|
+
self._content_size = (0, 0)
|
|
322
|
+
self._queue = FrameQueue(self.config.queue_frames)
|
|
323
|
+
self._pool: BufferPool | None = None
|
|
324
|
+
self._slots = 0
|
|
325
|
+
self._new = self._dup = self._dropped = self._late = self._encoded = 0
|
|
326
|
+
self._capture_ms = _Accum()
|
|
327
|
+
self._jitter_ms = _Accum()
|
|
328
|
+
self._write_ms = _Accum()
|
|
329
|
+
self._target_lost = False
|
|
330
|
+
|
|
331
|
+
# audio
|
|
332
|
+
self._inputs: list[_AudioInput] = []
|
|
333
|
+
self._mix_writer: _TrackWriter | None = None
|
|
334
|
+
self._mixer: AudioMixer | None = None
|
|
335
|
+
self._clock_thread: threading.Thread | None = None
|
|
336
|
+
|
|
337
|
+
# -- public API ------------------------------------------------------------
|
|
338
|
+
@property
|
|
339
|
+
def state(self) -> RecorderState:
|
|
340
|
+
return self._state
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def error(self) -> BaseException | None:
|
|
344
|
+
return self._error
|
|
345
|
+
|
|
346
|
+
def start(self) -> Recorder:
|
|
347
|
+
with self._state_lock:
|
|
348
|
+
if self._state is not RecorderState.IDLE:
|
|
349
|
+
raise RecorderStateError(f"cannot start from state {self._state.value}")
|
|
350
|
+
self._set_state(RecorderState.STARTING)
|
|
351
|
+
try:
|
|
352
|
+
self._start()
|
|
353
|
+
except BaseException as exc:
|
|
354
|
+
with self._state_lock:
|
|
355
|
+
self._error, self._fatal = exc, True
|
|
356
|
+
self._shutdown(time.monotonic() + 60)
|
|
357
|
+
self._remove_partial_output()
|
|
358
|
+
raise
|
|
359
|
+
# stop() or a worker failure during STARTING only *requests* a stop;
|
|
360
|
+
# the starting thread performs it once initialisation has finished.
|
|
361
|
+
with self._state_lock:
|
|
362
|
+
aborted = self._stop_requested.is_set()
|
|
363
|
+
if aborted:
|
|
364
|
+
self._set_state(RecorderState.STOPPING)
|
|
365
|
+
if aborted:
|
|
366
|
+
self._shutdown(time.monotonic() + 60)
|
|
367
|
+
if self._fatal and self._error is not None:
|
|
368
|
+
self._remove_partial_output()
|
|
369
|
+
raise self._error
|
|
370
|
+
return self
|
|
371
|
+
|
|
372
|
+
def _remove_partial_output(self) -> None:
|
|
373
|
+
"""Delete an output file created by a start that failed (never a file
|
|
374
|
+
that existed before start; overwrite=False guarantees it was new)."""
|
|
375
|
+
if self.output is None or self._ffmpeg is None or self._output_preexisted:
|
|
376
|
+
return
|
|
377
|
+
try:
|
|
378
|
+
Path(self.output).unlink(missing_ok=True)
|
|
379
|
+
except OSError as exc:
|
|
380
|
+
log.warning("could not remove partial output %s: %s", self.output, exc)
|
|
381
|
+
|
|
382
|
+
def pause(self) -> None:
|
|
383
|
+
with self._state_lock:
|
|
384
|
+
if self._state is not RecorderState.RECORDING:
|
|
385
|
+
raise RecorderStateError(f"cannot pause from state {self._state.value}")
|
|
386
|
+
self._timeline.pause(now_ns())
|
|
387
|
+
self._set_state(RecorderState.PAUSED)
|
|
388
|
+
|
|
389
|
+
def resume(self) -> None:
|
|
390
|
+
with self._state_lock:
|
|
391
|
+
if self._state is not RecorderState.PAUSED:
|
|
392
|
+
raise RecorderStateError(f"cannot resume from state {self._state.value}")
|
|
393
|
+
self._timeline.resume(now_ns())
|
|
394
|
+
self._set_state(RecorderState.RECORDING)
|
|
395
|
+
|
|
396
|
+
def stop(self, timeout: float = 60.0) -> RecordingResult:
|
|
397
|
+
"""Stop, finalise the output and return the result.
|
|
398
|
+
|
|
399
|
+
Raises the first fatal error (encoder/backend failure). A lost target
|
|
400
|
+
under ``TargetLostPolicy.STOP`` is reported in ``result.error`` and via
|
|
401
|
+
``on_error`` but does not raise.
|
|
402
|
+
"""
|
|
403
|
+
deadline = time.monotonic() + timeout
|
|
404
|
+
with self._state_lock:
|
|
405
|
+
if self._state is RecorderState.IDLE:
|
|
406
|
+
raise RecorderStateError("recorder was never started")
|
|
407
|
+
self._stop_requested.set()
|
|
408
|
+
run_here = self._state in (RecorderState.RECORDING, RecorderState.PAUSED)
|
|
409
|
+
if run_here:
|
|
410
|
+
self._set_state(RecorderState.STOPPING)
|
|
411
|
+
if run_here:
|
|
412
|
+
self._shutdown(deadline)
|
|
413
|
+
if not self._done.wait(max(0.0, deadline - time.monotonic())):
|
|
414
|
+
raise CaptureTimeoutError("recorder did not stop in time")
|
|
415
|
+
if self._fatal and self._error is not None:
|
|
416
|
+
raise self._error
|
|
417
|
+
return RecordingResult(self.output, self.stats, self._error)
|
|
418
|
+
|
|
419
|
+
def wait(self, timeout: float | None = None) -> bool:
|
|
420
|
+
"""Block until the recorder has stopped (e.g. after the target was lost)."""
|
|
421
|
+
return self._done.wait(timeout)
|
|
422
|
+
|
|
423
|
+
def __enter__(self) -> Recorder:
|
|
424
|
+
return self.start()
|
|
425
|
+
|
|
426
|
+
def __exit__(
|
|
427
|
+
self, et: type[BaseException] | None, ev: BaseException | None, tb: TracebackType | None
|
|
428
|
+
) -> None:
|
|
429
|
+
if self._state not in (RecorderState.IDLE,):
|
|
430
|
+
try:
|
|
431
|
+
self.stop()
|
|
432
|
+
except BaseException:
|
|
433
|
+
if et is None:
|
|
434
|
+
raise
|
|
435
|
+
# The body's exception propagates; never drop ours silently.
|
|
436
|
+
log.exception("Recorder.stop() failed while handling another exception")
|
|
437
|
+
|
|
438
|
+
@property
|
|
439
|
+
def stats(self) -> RecordingStats:
|
|
440
|
+
now = now_ns()
|
|
441
|
+
fps = self.config.fps
|
|
442
|
+
media = (
|
|
443
|
+
self._end_media_ns
|
|
444
|
+
if self._end_media_ns is not None
|
|
445
|
+
else self._timeline.media_now(now)
|
|
446
|
+
if self._timeline.started
|
|
447
|
+
else 0
|
|
448
|
+
)
|
|
449
|
+
video = None
|
|
450
|
+
if self.video_source is not None:
|
|
451
|
+
video = VideoStats(
|
|
452
|
+
backend=self._video_backend_name,
|
|
453
|
+
encoder=self._encoder,
|
|
454
|
+
fps=fps,
|
|
455
|
+
output_size=self._out_size,
|
|
456
|
+
content_size=self._content_size,
|
|
457
|
+
slots=self._slots,
|
|
458
|
+
frames_new=self._new,
|
|
459
|
+
frames_duplicated=self._dup,
|
|
460
|
+
frames_dropped=self._dropped,
|
|
461
|
+
frames_late=self._late,
|
|
462
|
+
frames_encoded=self._encoded,
|
|
463
|
+
queue_depth=self._queue.depth,
|
|
464
|
+
queue_depth_max=self._queue.max_depth,
|
|
465
|
+
capture_ms_avg=self._capture_ms.avg,
|
|
466
|
+
capture_ms_max=self._capture_ms.max,
|
|
467
|
+
pacing_jitter_ms_avg=self._jitter_ms.avg,
|
|
468
|
+
pacing_jitter_ms_max=self._jitter_ms.max,
|
|
469
|
+
encoder_write_ms_avg=self._write_ms.avg,
|
|
470
|
+
encoder_write_ms_max=self._write_ms.max,
|
|
471
|
+
target_lost=self._target_lost,
|
|
472
|
+
)
|
|
473
|
+
rate = self.config.audio_sample_rate
|
|
474
|
+
tracks = tuple(
|
|
475
|
+
AudioTrackStats(
|
|
476
|
+
name=i.name,
|
|
477
|
+
device=getattr(i.backend, "device_name", ""),
|
|
478
|
+
packets=i.aligner.stats.packets,
|
|
479
|
+
duration_s=i.aligner.written / rate,
|
|
480
|
+
silence_inserted_s=i.aligner.stats.silence_inserted / rate,
|
|
481
|
+
trimmed_s=i.aligner.stats.samples_trimmed / rate,
|
|
482
|
+
drift_corrected_samples=i.aligner.stats.drift_corrected,
|
|
483
|
+
discontinuities=i.aligner.stats.discontinuities,
|
|
484
|
+
)
|
|
485
|
+
for i in self._inputs
|
|
486
|
+
)
|
|
487
|
+
return RecordingStats(
|
|
488
|
+
state=self._state,
|
|
489
|
+
output=self.output,
|
|
490
|
+
media_duration_s=media / NS_PER_S,
|
|
491
|
+
wall_elapsed_s=(now - self._start_wall) / NS_PER_S if self._start_wall else 0.0,
|
|
492
|
+
video=video,
|
|
493
|
+
audio=tracks,
|
|
494
|
+
callbacks_dropped=self._dispatcher.dropped if self._dispatcher else 0,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# -- internals ---------------------------------------------------------------
|
|
498
|
+
def _set_state(self, state: RecorderState) -> None:
|
|
499
|
+
self._state = state
|
|
500
|
+
if self._on_state is not None and self._dispatcher is not None:
|
|
501
|
+
self._dispatcher.post(self._on_state, state)
|
|
502
|
+
|
|
503
|
+
def _report_error(self, exc: BaseException, fatal: bool) -> None:
|
|
504
|
+
with self._state_lock:
|
|
505
|
+
first = self._error is None
|
|
506
|
+
if first:
|
|
507
|
+
self._error = exc
|
|
508
|
+
self._fatal = fatal
|
|
509
|
+
if first and self._on_error is not None and self._dispatcher is not None:
|
|
510
|
+
self._dispatcher.post(self._on_error, exc)
|
|
511
|
+
if not first:
|
|
512
|
+
log.debug("subsequent error ignored: %r", exc)
|
|
513
|
+
|
|
514
|
+
def _fail(self, exc: BaseException, fatal: bool = True) -> None:
|
|
515
|
+
"""Called from worker threads: report and stop asynchronously."""
|
|
516
|
+
self._report_error(exc, fatal)
|
|
517
|
+
with self._state_lock:
|
|
518
|
+
self._stop_requested.set()
|
|
519
|
+
if self._state not in (RecorderState.RECORDING, RecorderState.PAUSED):
|
|
520
|
+
return # STARTING: start() performs the shutdown; else already stopping
|
|
521
|
+
self._set_state(RecorderState.STOPPING)
|
|
522
|
+
threading.Thread(
|
|
523
|
+
target=self._shutdown,
|
|
524
|
+
args=(time.monotonic() + 120,),
|
|
525
|
+
name="capturekit-shutdown",
|
|
526
|
+
daemon=True,
|
|
527
|
+
).start()
|
|
528
|
+
|
|
529
|
+
def _on_audio_error(self, exc: BaseException) -> None:
|
|
530
|
+
if isinstance(exc, TargetLostError) and (
|
|
531
|
+
self.config.on_target_lost is TargetLostPolicy.CONTINUE
|
|
532
|
+
):
|
|
533
|
+
# e.g. headset unplugged: keep recording; the clock heartbeat fills
|
|
534
|
+
# this track with silence.
|
|
535
|
+
self._report_error(exc, fatal=False)
|
|
536
|
+
return
|
|
537
|
+
self._fail(exc, fatal=not isinstance(exc, TargetLostError))
|
|
538
|
+
|
|
539
|
+
def _start(self) -> None:
|
|
540
|
+
cfg = self.config
|
|
541
|
+
self._dispatcher = _Dispatcher()
|
|
542
|
+
if self._on_state is not None:
|
|
543
|
+
self._dispatcher.post(self._on_state, RecorderState.STARTING)
|
|
544
|
+
if self.output is not None:
|
|
545
|
+
out = Path(self.output)
|
|
546
|
+
if not out.parent.is_dir():
|
|
547
|
+
raise FileNotFoundError(f"output directory {out.parent} does not exist")
|
|
548
|
+
self._output_preexisted = out.exists()
|
|
549
|
+
if self._output_preexisted and not cfg.overwrite:
|
|
550
|
+
raise FileExistsError(f"{out} exists (set RecordingConfig.overwrite=True)")
|
|
551
|
+
|
|
552
|
+
if self.video_source is not None:
|
|
553
|
+
self._video_thread = threading.Thread(
|
|
554
|
+
target=self._video_main, name="capturekit-video", daemon=True
|
|
555
|
+
)
|
|
556
|
+
self._video_thread.start()
|
|
557
|
+
if not self._video_ready.wait(cfg.start_timeout_s + 5):
|
|
558
|
+
raise CaptureTimeoutError("video source did not start in time")
|
|
559
|
+
if self._video_error is not None:
|
|
560
|
+
raise self._video_error
|
|
561
|
+
if self._stop_requested.is_set():
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
api = _platform.backend()
|
|
565
|
+
rate = cfg.audio_sample_rate
|
|
566
|
+
for i, spec in enumerate(self.audio_sources):
|
|
567
|
+
backend = api.open_audio(spec, rate, AUDIO_CHANNELS)
|
|
568
|
+
self._inputs.append(
|
|
569
|
+
_AudioInput(i, spec, backend, AudioAligner(self._timeline, rate, AUDIO_CHANNELS))
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
audio_inputs: list[ff.AudioInput] = []
|
|
573
|
+
if self.output is not None:
|
|
574
|
+
mode = cfg.audio_mix
|
|
575
|
+
if mode in (AudioMixMode.SEPARATE, AudioMixMode.BOTH) or len(self._inputs) == 1:
|
|
576
|
+
for inp in self._inputs:
|
|
577
|
+
inp.writer = _TrackWriter(f"a{inp.index}", rate, self._fail)
|
|
578
|
+
audio_inputs.append(
|
|
579
|
+
ff.AudioInput(inp.writer.pipe.path, rate, AUDIO_CHANNELS, inp.name)
|
|
580
|
+
)
|
|
581
|
+
if mode in (AudioMixMode.MIXED, AudioMixMode.BOTH) and len(self._inputs) > 1:
|
|
582
|
+
self._mix_writer = _TrackWriter("mix", rate, self._fail)
|
|
583
|
+
audio_inputs.append(
|
|
584
|
+
ff.AudioInput(self._mix_writer.pipe.path, rate, AUDIO_CHANNELS, "mix")
|
|
585
|
+
)
|
|
586
|
+
if (
|
|
587
|
+
len(self._inputs) > 1
|
|
588
|
+
and (cfg.audio_mix is not AudioMixMode.SEPARATE or self.output is None)
|
|
589
|
+
and (self._mix_writer is not None or self._on_audio is not None)
|
|
590
|
+
):
|
|
591
|
+
sink = self._mix_writer.put if self._mix_writer is not None else (lambda _b: None)
|
|
592
|
+
self._mixer = AudioMixer(len(self._inputs), AUDIO_CHANNELS, sink)
|
|
593
|
+
|
|
594
|
+
if self._stop_requested.is_set():
|
|
595
|
+
return
|
|
596
|
+
# Audio sources start before FFmpeg so a source that cannot open fails
|
|
597
|
+
# before any output file exists; packets that arrive before the
|
|
598
|
+
# timeline starts are discarded by the aligners.
|
|
599
|
+
for inp in self._inputs:
|
|
600
|
+
inp.backend.start(functools.partial(self._on_packet, inp), self._on_audio_error)
|
|
601
|
+
if self._inputs:
|
|
602
|
+
self._clock_thread = threading.Thread(
|
|
603
|
+
target=self._audio_clock_main, name="capturekit-audio-clock", daemon=True
|
|
604
|
+
)
|
|
605
|
+
self._clock_thread.start()
|
|
606
|
+
if self._stop_requested.is_set():
|
|
607
|
+
return
|
|
608
|
+
if self.output is not None:
|
|
609
|
+
exe = ff.find_ffmpeg(cfg.encoder.ffmpeg_path)
|
|
610
|
+
if self.video_source is not None:
|
|
611
|
+
self._encoder = ff.select_encoder(exe, cfg.encoder, self._out_size)
|
|
612
|
+
cmd = ff.build_command(
|
|
613
|
+
exe,
|
|
614
|
+
output=self.output,
|
|
615
|
+
container=ff.container_for(self.output, cfg.container),
|
|
616
|
+
encoder=self._encoder,
|
|
617
|
+
cfg=cfg.encoder,
|
|
618
|
+
video_size=self._out_size if self.video_source is not None else None,
|
|
619
|
+
fps=cfg.fps,
|
|
620
|
+
audio_inputs=audio_inputs,
|
|
621
|
+
overwrite=cfg.overwrite,
|
|
622
|
+
)
|
|
623
|
+
log.debug("ffmpeg command: %s", cmd)
|
|
624
|
+
self._ffmpeg = ff.FFmpegProcess(cmd)
|
|
625
|
+
|
|
626
|
+
if self.video_source is not None:
|
|
627
|
+
self._consumer_thread = threading.Thread(
|
|
628
|
+
target=self._consumer_main, name="capturekit-encoder", daemon=True
|
|
629
|
+
)
|
|
630
|
+
self._consumer_thread.start()
|
|
631
|
+
|
|
632
|
+
with self._state_lock:
|
|
633
|
+
self._start_wall = now_ns()
|
|
634
|
+
self._timeline.start(self._start_wall)
|
|
635
|
+
if self._state is RecorderState.STARTING:
|
|
636
|
+
self._set_state(RecorderState.RECORDING)
|
|
637
|
+
|
|
638
|
+
# -- video -----------------------------------------------------------------
|
|
639
|
+
def _video_main(self) -> None:
|
|
640
|
+
cfg = self.config
|
|
641
|
+
api = _platform.backend()
|
|
642
|
+
backend: VideoBackend | None = None
|
|
643
|
+
try:
|
|
644
|
+
with api.thread_context():
|
|
645
|
+
backend = api.open_video(self.video_source)
|
|
646
|
+
try:
|
|
647
|
+
backend.start()
|
|
648
|
+
self._video_backend_name = backend.info.name
|
|
649
|
+
first = backend.acquire(None, cfg.start_timeout_s)
|
|
650
|
+
if first is None:
|
|
651
|
+
raise CaptureTimeoutError(
|
|
652
|
+
f"no frame from the video source within {cfg.start_timeout_s:.1f}s"
|
|
653
|
+
)
|
|
654
|
+
fh, fw = first.data.shape[:2]
|
|
655
|
+
ow, oh = cfg.output_size or (fw, fh)
|
|
656
|
+
ow, oh = max(2, ow - ow % 2), max(2, oh - oh % 2)
|
|
657
|
+
self._out_size = (ow, oh)
|
|
658
|
+
self._content_size = first.content_size
|
|
659
|
+
self._pool = BufferPool((oh, ow, 4), cfg.queue_frames + 3)
|
|
660
|
+
first_buf = self._pool.acquire()
|
|
661
|
+
assert first_buf is not None
|
|
662
|
+
ch, cw = min(fh, oh), min(fw, ow)
|
|
663
|
+
first_buf[:ch, :cw] = first.data[:ch, :cw]
|
|
664
|
+
blit_clear(first_buf, cw, ch)
|
|
665
|
+
except BaseException as exc:
|
|
666
|
+
self._video_error = exc
|
|
667
|
+
return
|
|
668
|
+
finally:
|
|
669
|
+
self._video_ready.set()
|
|
670
|
+
self._pace(backend, first_buf)
|
|
671
|
+
except BaseException as exc:
|
|
672
|
+
self._fail(exc)
|
|
673
|
+
finally:
|
|
674
|
+
if backend is not None:
|
|
675
|
+
try:
|
|
676
|
+
backend.close()
|
|
677
|
+
except Exception:
|
|
678
|
+
log.exception("closing video backend failed")
|
|
679
|
+
self._queue.close()
|
|
680
|
+
|
|
681
|
+
def _pace(self, backend: VideoBackend, first_buf: np.ndarray) -> None:
|
|
682
|
+
assert self._pool is not None
|
|
683
|
+
tl = self._timeline
|
|
684
|
+
period = NS_PER_S / self.config.fps
|
|
685
|
+
pool, q = self._pool, self._queue
|
|
686
|
+
pending_first: np.ndarray | None = first_buf
|
|
687
|
+
n = 0
|
|
688
|
+
while not self._stop_evt.is_set():
|
|
689
|
+
if not tl.started or tl.paused:
|
|
690
|
+
tl.wait_running(0.05)
|
|
691
|
+
continue
|
|
692
|
+
deadline = tl.wall_for_media(int(n * period))
|
|
693
|
+
if deadline is None:
|
|
694
|
+
continue
|
|
695
|
+
if not sleep_until(deadline, self._stop_evt):
|
|
696
|
+
break
|
|
697
|
+
now = now_ns()
|
|
698
|
+
if tl.is_paused(): # paused while we slept
|
|
699
|
+
continue
|
|
700
|
+
self._jitter_ms.add(max(0, now - deadline) / 1e6)
|
|
701
|
+
current = int(tl.media_now(now) // period)
|
|
702
|
+
if current > n and pending_first is None:
|
|
703
|
+
q.put_dup(current - n)
|
|
704
|
+
self._late += current - n
|
|
705
|
+
n = current
|
|
706
|
+
if self._target_lost:
|
|
707
|
+
if pending_first is not None:
|
|
708
|
+
q.put_frame(FrameItem(pending_first, now))
|
|
709
|
+
pending_first = None
|
|
710
|
+
else:
|
|
711
|
+
q.put_dup(1)
|
|
712
|
+
self._dup += 1
|
|
713
|
+
n += 1
|
|
714
|
+
self._slots = n
|
|
715
|
+
continue
|
|
716
|
+
buf = pool.acquire()
|
|
717
|
+
if buf is None:
|
|
718
|
+
q.put_dup(1)
|
|
719
|
+
self._dropped += 1
|
|
720
|
+
else:
|
|
721
|
+
t0 = now_ns()
|
|
722
|
+
try:
|
|
723
|
+
res = backend.acquire(buf, 0)
|
|
724
|
+
except TargetLostError as exc:
|
|
725
|
+
pool.release(buf)
|
|
726
|
+
lost_policy = self.config.on_target_lost
|
|
727
|
+
self._target_lost = True
|
|
728
|
+
if lost_policy is TargetLostPolicy.STOP:
|
|
729
|
+
self._fail(exc, fatal=False)
|
|
730
|
+
if pending_first is not None:
|
|
731
|
+
q.put_frame(FrameItem(pending_first, now))
|
|
732
|
+
n += 1
|
|
733
|
+
self._slots = n
|
|
734
|
+
break
|
|
735
|
+
self._report_error(exc, fatal=False)
|
|
736
|
+
res = None
|
|
737
|
+
buf = None
|
|
738
|
+
self._capture_ms.add((now_ns() - t0) / 1e6)
|
|
739
|
+
if res is not None:
|
|
740
|
+
assert buf is not None
|
|
741
|
+
self._content_size = res.content_size
|
|
742
|
+
if pending_first is not None:
|
|
743
|
+
pool.release(pending_first)
|
|
744
|
+
pending_first = None
|
|
745
|
+
if q.put_frame(FrameItem(buf, res.timestamp_ns)):
|
|
746
|
+
self._new += 1
|
|
747
|
+
else:
|
|
748
|
+
pool.release(buf)
|
|
749
|
+
q.put_dup(1)
|
|
750
|
+
self._dropped += 1
|
|
751
|
+
else:
|
|
752
|
+
if buf is not None:
|
|
753
|
+
pool.release(buf)
|
|
754
|
+
if pending_first is not None:
|
|
755
|
+
if q.put_frame(FrameItem(pending_first, now)):
|
|
756
|
+
self._new += 1
|
|
757
|
+
pending_first = None
|
|
758
|
+
else:
|
|
759
|
+
q.put_dup(1)
|
|
760
|
+
self._dup += 1
|
|
761
|
+
n += 1
|
|
762
|
+
self._slots = n
|
|
763
|
+
|
|
764
|
+
def _consumer_main(self) -> None:
|
|
765
|
+
q, pool = self._queue, self._pool
|
|
766
|
+
assert pool is not None
|
|
767
|
+
last: np.ndarray | None = None
|
|
768
|
+
slot = 0
|
|
769
|
+
period = NS_PER_S / self.config.fps
|
|
770
|
+
ffm = self._ffmpeg
|
|
771
|
+
try:
|
|
772
|
+
while True:
|
|
773
|
+
item = q.get()
|
|
774
|
+
if item is None:
|
|
775
|
+
break
|
|
776
|
+
if isinstance(item, FrameItem):
|
|
777
|
+
if ffm is not None:
|
|
778
|
+
t0 = now_ns()
|
|
779
|
+
ffm.write(memoryview(item.buffer).cast("B"))
|
|
780
|
+
self._write_ms.add((now_ns() - t0) / 1e6)
|
|
781
|
+
self._encoded += 1
|
|
782
|
+
if self._on_frame is not None and self._dispatcher is not None:
|
|
783
|
+
f = Frame(item.buffer.copy(), int(slot * period), slot, self._content_size)
|
|
784
|
+
self._dispatcher.post(self._on_frame, f, droppable=True)
|
|
785
|
+
if last is not None:
|
|
786
|
+
pool.release(last)
|
|
787
|
+
last = item.buffer
|
|
788
|
+
slot += 1
|
|
789
|
+
elif isinstance(item, DupItem):
|
|
790
|
+
if last is None:
|
|
791
|
+
continue
|
|
792
|
+
for _ in range(item.count):
|
|
793
|
+
if ffm is not None:
|
|
794
|
+
t0 = now_ns()
|
|
795
|
+
ffm.write(memoryview(last).cast("B"))
|
|
796
|
+
self._write_ms.add((now_ns() - t0) / 1e6)
|
|
797
|
+
self._encoded += 1
|
|
798
|
+
slot += item.count
|
|
799
|
+
except BaseException as exc:
|
|
800
|
+
self._fail(exc)
|
|
801
|
+
# Keep draining so the video thread never blocks on a full pool.
|
|
802
|
+
while q.get() is not None:
|
|
803
|
+
pass
|
|
804
|
+
finally:
|
|
805
|
+
if ffm is not None:
|
|
806
|
+
ffm.close_stdin()
|
|
807
|
+
|
|
808
|
+
# -- audio -------------------------------------------------------------------
|
|
809
|
+
def _on_packet(self, inp: _AudioInput, pkt: AudioPacket) -> None:
|
|
810
|
+
if self._audio_closed:
|
|
811
|
+
return # a source thread outlived stop(); its data is past the end
|
|
812
|
+
with inp.lock:
|
|
813
|
+
blocks = inp.aligner.push(pkt.data, pkt.timestamp_ns, pkt.discontinuity)
|
|
814
|
+
self._emit_audio(inp, blocks)
|
|
815
|
+
|
|
816
|
+
def _emit_audio(self, inp: _AudioInput, blocks: list[Samples]) -> None:
|
|
817
|
+
# The aligner has already advanced `written` past all of `blocks`.
|
|
818
|
+
pos = inp.aligner.written - sum(len(b) for b in blocks)
|
|
819
|
+
for b in blocks:
|
|
820
|
+
if not len(b):
|
|
821
|
+
continue
|
|
822
|
+
start, pos = pos, pos + len(b)
|
|
823
|
+
b.flags.writeable = False # shared by writer, mixer and callbacks
|
|
824
|
+
if inp.writer is not None:
|
|
825
|
+
inp.writer.put(b)
|
|
826
|
+
if self._mixer is not None:
|
|
827
|
+
self._mixer.push(inp.index, b)
|
|
828
|
+
if self._on_audio is not None and self._dispatcher is not None:
|
|
829
|
+
chunk = AudioChunk(
|
|
830
|
+
b,
|
|
831
|
+
start * NS_PER_S // self.config.audio_sample_rate,
|
|
832
|
+
self.config.audio_sample_rate,
|
|
833
|
+
inp.name,
|
|
834
|
+
)
|
|
835
|
+
self._dispatcher.post(self._on_audio, chunk, droppable=True)
|
|
836
|
+
|
|
837
|
+
def _audio_clock_main(self) -> None:
|
|
838
|
+
while not self._stop_evt.wait(0.02):
|
|
839
|
+
now = now_ns()
|
|
840
|
+
for inp in self._inputs:
|
|
841
|
+
with inp.lock:
|
|
842
|
+
self._emit_audio(inp, inp.aligner.heartbeat(now))
|
|
843
|
+
|
|
844
|
+
# -- shutdown -------------------------------------------------------------------
|
|
845
|
+
def _shutdown(self, deadline: float) -> None:
|
|
846
|
+
with self._state_lock:
|
|
847
|
+
already = self._shutdown_started
|
|
848
|
+
self._shutdown_started = True
|
|
849
|
+
if already:
|
|
850
|
+
self._done.wait(max(0.0, deadline - time.monotonic()))
|
|
851
|
+
return
|
|
852
|
+
try:
|
|
853
|
+
self._shutdown_inner(deadline)
|
|
854
|
+
finally:
|
|
855
|
+
with self._state_lock:
|
|
856
|
+
self._set_state(
|
|
857
|
+
RecorderState.FAILED if self._fatal and self._error else RecorderState.STOPPED
|
|
858
|
+
)
|
|
859
|
+
# Set before closing the dispatcher: a user callback blocked in
|
|
860
|
+
# stop() must be released before we join the dispatcher thread.
|
|
861
|
+
self._done.set()
|
|
862
|
+
if self._dispatcher is not None:
|
|
863
|
+
self._dispatcher.close()
|
|
864
|
+
|
|
865
|
+
def _step(self, what: str, fn: Callable[[], object]) -> None:
|
|
866
|
+
"""Run one shutdown stage; record failures but always continue."""
|
|
867
|
+
try:
|
|
868
|
+
fn()
|
|
869
|
+
except BaseException as exc:
|
|
870
|
+
log.exception("shutdown stage %r failed", what)
|
|
871
|
+
self._report_error(exc, fatal=True)
|
|
872
|
+
|
|
873
|
+
@staticmethod
|
|
874
|
+
def _left(deadline: float, cap: float) -> float:
|
|
875
|
+
return max(0.1, min(cap, deadline - time.monotonic()))
|
|
876
|
+
|
|
877
|
+
def _shutdown_inner(self, deadline: float) -> None:
|
|
878
|
+
end_wall = now_ns()
|
|
879
|
+
final_media = self._timeline.stop(end_wall) if self._timeline.started else 0
|
|
880
|
+
self._stop_evt.set()
|
|
881
|
+
self._stop_requested.set()
|
|
882
|
+
vt = self._video_thread
|
|
883
|
+
if vt is not None and vt is not threading.current_thread():
|
|
884
|
+
vt.join(self._left(deadline, 15))
|
|
885
|
+
self._queue.close()
|
|
886
|
+
if self.video_source is not None and self._timeline.started:
|
|
887
|
+
end_media = int(self._slots * NS_PER_S / self.config.fps)
|
|
888
|
+
else:
|
|
889
|
+
end_media = final_media
|
|
890
|
+
self._end_media_ns = end_media
|
|
891
|
+
|
|
892
|
+
for inp in self._inputs:
|
|
893
|
+
self._step(f"stop audio {inp.name}", inp.backend.stop)
|
|
894
|
+
self._audio_closed = True
|
|
895
|
+
if self._clock_thread is not None:
|
|
896
|
+
self._clock_thread.join(self._left(deadline, 5))
|
|
897
|
+
|
|
898
|
+
def finish_audio() -> None:
|
|
899
|
+
if self._timeline.started:
|
|
900
|
+
for inp in self._inputs:
|
|
901
|
+
with inp.lock:
|
|
902
|
+
self._emit_audio(inp, inp.aligner.finish(end_media))
|
|
903
|
+
if self._mixer is not None:
|
|
904
|
+
for inp in self._inputs:
|
|
905
|
+
self._mixer.finish_input(inp.index)
|
|
906
|
+
|
|
907
|
+
self._step("finish audio", finish_audio)
|
|
908
|
+
writers = [i.writer for i in self._inputs if i.writer is not None]
|
|
909
|
+
if self._mix_writer is not None:
|
|
910
|
+
writers.append(self._mix_writer)
|
|
911
|
+
ffm = self._ffmpeg
|
|
912
|
+
try:
|
|
913
|
+
ffmpeg_gone = ffm is None or ffm.proc.poll() is not None
|
|
914
|
+
for w in writers:
|
|
915
|
+
if ffmpeg_gone:
|
|
916
|
+
w.abort() # FFmpeg never started or already exited
|
|
917
|
+
w.finish()
|
|
918
|
+
ct = self._consumer_thread
|
|
919
|
+
if ct is not None:
|
|
920
|
+
ct.join(self._left(deadline, 30))
|
|
921
|
+
if ct.is_alive() and ffm is not None:
|
|
922
|
+
ffm.kill()
|
|
923
|
+
ct.join(5)
|
|
924
|
+
for w in writers:
|
|
925
|
+
w.join(self._left(deadline, 30))
|
|
926
|
+
finally:
|
|
927
|
+
if ffm is not None:
|
|
928
|
+
self._step("wait for FFmpeg", lambda: self._finish_ffmpeg(ffm, deadline))
|
|
929
|
+
|
|
930
|
+
def _finish_ffmpeg(self, ffm: ff.FFmpegProcess, deadline: float) -> None:
|
|
931
|
+
rc = ffm.wait(self._left(deadline, 60))
|
|
932
|
+
if rc != 0:
|
|
933
|
+
self._report_error(
|
|
934
|
+
EncoderError(
|
|
935
|
+
"FFmpeg exited with an error", stderr_tail=ffm.stderr_tail, returncode=rc
|
|
936
|
+
),
|
|
937
|
+
fatal=True,
|
|
938
|
+
)
|
|
939
|
+
elif ffm.stderr_tail:
|
|
940
|
+
log.debug("ffmpeg output:\n%s", ffm.stderr_tail)
|