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.
Files changed (41) hide show
  1. capturekit/__init__.py +164 -0
  2. capturekit/_backends/__init__.py +0 -0
  3. capturekit/_backends/base.py +89 -0
  4. capturekit/_backends/linux/__init__.py +1 -0
  5. capturekit/_backends/linux/_xlib.py +655 -0
  6. capturekit/_backends/linux/api.py +160 -0
  7. capturekit/_backends/linux/pulse.py +464 -0
  8. capturekit/_backends/linux/x11.py +720 -0
  9. capturekit/_backends/windows/__init__.py +0 -0
  10. capturekit/_backends/windows/_com.py +350 -0
  11. capturekit/_backends/windows/_d3d11.py +307 -0
  12. capturekit/_backends/windows/_win32.py +420 -0
  13. capturekit/_backends/windows/_winrt.py +162 -0
  14. capturekit/_backends/windows/api.py +214 -0
  15. capturekit/_backends/windows/cursor.py +66 -0
  16. capturekit/_backends/windows/dxgi_duplication.py +310 -0
  17. capturekit/_backends/windows/wasapi.py +570 -0
  18. capturekit/_backends/windows/wgc.py +398 -0
  19. capturekit/_pipeline/__init__.py +1 -0
  20. capturekit/_pipeline/aligner.py +177 -0
  21. capturekit/_pipeline/clock.py +47 -0
  22. capturekit/_pipeline/frame_queue.py +120 -0
  23. capturekit/_pipeline/mixer.py +80 -0
  24. capturekit/_pipeline/timeline.py +133 -0
  25. capturekit/_platform.py +24 -0
  26. capturekit/_version.py +1 -0
  27. capturekit/_worker.py +100 -0
  28. capturekit/capabilities.py +79 -0
  29. capturekit/capture.py +141 -0
  30. capturekit/config.py +85 -0
  31. capturekit/encoding/__init__.py +5 -0
  32. capturekit/encoding/ffmpeg.py +356 -0
  33. capturekit/encoding/pipes.py +212 -0
  34. capturekit/errors.py +109 -0
  35. capturekit/py.typed +0 -0
  36. capturekit/recording.py +940 -0
  37. capturekit/sources.py +124 -0
  38. capturekit/types.py +211 -0
  39. capturekit-0.1.0.dist-info/METADATA +112 -0
  40. capturekit-0.1.0.dist-info/RECORD +41 -0
  41. capturekit-0.1.0.dist-info/WHEEL +4 -0
@@ -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)