homebridge-plugin-utils 1.35.0 → 2.1.0
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.
- package/README.md +130 -2
- package/build/eslint-plugin/README.md +164 -0
- package/build/eslint-plugin/config.mjs +308 -0
- package/build/eslint-plugin/index.mjs +7 -0
- package/build/eslint-plugin/plugin.mjs +46 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
- package/build/eslint-plugin/rules/comment-style.mjs +190 -0
- package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
- package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
- package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
- package/build/eslint-plugin/test-setup.mjs +21 -0
- package/build/fs-ops.mjs +184 -0
- package/build/tsconfig.json +18 -3
- package/dist/backpressure.d.ts +94 -45
- package/dist/backpressure.js +229 -93
- package/dist/backpressure.js.map +1 -1
- package/dist/cli/index.d.ts +166 -0
- package/dist/cli/index.js +551 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/clock-double.d.ts +93 -0
- package/dist/clock-double.js +141 -0
- package/dist/clock-double.js.map +1 -0
- package/dist/clock.d.ts +39 -0
- package/dist/clock.js +34 -0
- package/dist/clock.js.map +1 -0
- package/dist/disposable-stack.d.ts +59 -0
- package/dist/disposable-stack.js +155 -0
- package/dist/disposable-stack.js.map +1 -0
- package/dist/docChrome.d.ts +260 -0
- package/dist/docChrome.js +361 -0
- package/dist/docChrome.js.map +1 -0
- package/dist/eslint-plugin/config.d.mts +193 -0
- package/dist/eslint-plugin/index.d.mts +2 -0
- package/dist/eslint-plugin/plugin.d.mts +87 -0
- package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
- package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
- package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
- package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
- package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
- package/dist/featureOptions-docs.d.ts +96 -0
- package/dist/featureOptions-docs.js +193 -0
- package/dist/featureOptions-docs.js.map +1 -0
- package/dist/featureOptions.d.ts +674 -0
- package/dist/featureOptions.js +870 -0
- package/dist/featureOptions.js.map +1 -0
- package/dist/ffmpeg/codecs.d.ts +256 -72
- package/dist/ffmpeg/codecs.js +477 -262
- package/dist/ffmpeg/codecs.js.map +1 -1
- package/dist/ffmpeg/dgram-util.d.ts +46 -0
- package/dist/ffmpeg/dgram-util.js +38 -0
- package/dist/ffmpeg/dgram-util.js.map +1 -0
- package/dist/ffmpeg/exec.d.ts +83 -64
- package/dist/ffmpeg/exec.js +77 -86
- package/dist/ffmpeg/exec.js.map +1 -1
- package/dist/ffmpeg/fmp4.d.ts +49 -2
- package/dist/ffmpeg/fmp4.js +47 -11
- package/dist/ffmpeg/fmp4.js.map +1 -1
- package/dist/ffmpeg/hap-enums.d.ts +214 -0
- package/dist/ffmpeg/hap-enums.js +92 -0
- package/dist/ffmpeg/hap-enums.js.map +1 -0
- package/dist/ffmpeg/index.d.ts +16 -9
- package/dist/ffmpeg/index.js +6 -0
- package/dist/ffmpeg/index.js.map +1 -1
- package/dist/ffmpeg/mp4-assembler.d.ts +161 -0
- package/dist/ffmpeg/mp4-assembler.js +424 -0
- package/dist/ffmpeg/mp4-assembler.js.map +1 -0
- package/dist/ffmpeg/mp4-parser.d.ts +94 -0
- package/dist/ffmpeg/mp4-parser.js +130 -0
- package/dist/ffmpeg/mp4-parser.js.map +1 -0
- package/dist/ffmpeg/options.d.ts +62 -149
- package/dist/ffmpeg/options.js +608 -499
- package/dist/ffmpeg/options.js.map +1 -1
- package/dist/ffmpeg/process.d.ts +142 -96
- package/dist/ffmpeg/process.js +406 -278
- package/dist/ffmpeg/process.js.map +1 -1
- package/dist/ffmpeg/record.d.ts +325 -186
- package/dist/ffmpeg/record.js +418 -565
- package/dist/ffmpeg/record.js.map +1 -1
- package/dist/ffmpeg/recording-process-double.d.ts +157 -0
- package/dist/ffmpeg/recording-process-double.js +190 -0
- package/dist/ffmpeg/recording-process-double.js.map +1 -0
- package/dist/ffmpeg/rtp-parser.d.ts +70 -0
- package/dist/ffmpeg/rtp-parser.js +77 -0
- package/dist/ffmpeg/rtp-parser.js.map +1 -0
- package/dist/ffmpeg/rtp.d.ts +198 -141
- package/dist/ffmpeg/rtp.js +474 -251
- package/dist/ffmpeg/rtp.js.map +1 -1
- package/dist/ffmpeg/settings.d.ts +5 -2
- package/dist/ffmpeg/settings.js +20 -5
- package/dist/ffmpeg/settings.js.map +1 -1
- package/dist/ffmpeg/stream.d.ts +57 -107
- package/dist/ffmpeg/stream.js +121 -150
- package/dist/ffmpeg/stream.js.map +1 -1
- package/dist/formatters.d.ts +106 -0
- package/dist/formatters.js +174 -0
- package/dist/formatters.js.map +1 -0
- package/dist/homebridge-enums.d.ts +30 -0
- package/dist/homebridge-enums.js +17 -0
- package/dist/homebridge-enums.js.map +1 -0
- package/dist/index.d.ts +13 -6
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/logclient/auth.d.ts +114 -0
- package/dist/logclient/auth.js +199 -0
- package/dist/logclient/auth.js.map +1 -0
- package/dist/logclient/cli-run.d.ts +76 -0
- package/dist/logclient/cli-run.js +639 -0
- package/dist/logclient/cli-run.js.map +1 -0
- package/dist/logclient/cli.d.ts +3 -0
- package/dist/logclient/cli.js +97 -0
- package/dist/logclient/cli.js.map +1 -0
- package/dist/logclient/client.d.ts +145 -0
- package/dist/logclient/client.js +600 -0
- package/dist/logclient/client.js.map +1 -0
- package/dist/logclient/config.d.ts +173 -0
- package/dist/logclient/config.js +199 -0
- package/dist/logclient/config.js.map +1 -0
- package/dist/logclient/endpoints.d.ts +54 -0
- package/dist/logclient/endpoints.js +73 -0
- package/dist/logclient/endpoints.js.map +1 -0
- package/dist/logclient/filter.d.ts +45 -0
- package/dist/logclient/filter.js +51 -0
- package/dist/logclient/filter.js.map +1 -0
- package/dist/logclient/frame.d.ts +93 -0
- package/dist/logclient/frame.js +203 -0
- package/dist/logclient/frame.js.map +1 -0
- package/dist/logclient/index.d.ts +31 -0
- package/dist/logclient/index.js +12 -0
- package/dist/logclient/index.js.map +1 -0
- package/dist/logclient/parser.d.ts +211 -0
- package/dist/logclient/parser.js +393 -0
- package/dist/logclient/parser.js.map +1 -0
- package/dist/logclient/rest.d.ts +41 -0
- package/dist/logclient/rest.js +111 -0
- package/dist/logclient/rest.js.map +1 -0
- package/dist/logclient/settings.d.ts +15 -0
- package/dist/logclient/settings.js +64 -0
- package/dist/logclient/settings.js.map +1 -0
- package/dist/logclient/socket-double.d.ts +201 -0
- package/dist/logclient/socket-double.js +384 -0
- package/dist/logclient/socket-double.js.map +1 -0
- package/dist/logclient/socket.d.ts +257 -0
- package/dist/logclient/socket.js +620 -0
- package/dist/logclient/socket.js.map +1 -0
- package/dist/logclient/stitch.d.ts +83 -0
- package/dist/logclient/stitch.js +146 -0
- package/dist/logclient/stitch.js.map +1 -0
- package/dist/logclient/time-expression.d.ts +42 -0
- package/dist/logclient/time-expression.js +181 -0
- package/dist/logclient/time-expression.js.map +1 -0
- package/dist/logclient/time-window.d.ts +38 -0
- package/dist/logclient/time-window.js +53 -0
- package/dist/logclient/time-window.js.map +1 -0
- package/dist/logclient/types.d.ts +107 -0
- package/dist/logclient/types.js +6 -0
- package/dist/logclient/types.js.map +1 -0
- package/dist/mqttClient.d.ts +287 -0
- package/dist/mqttClient.js +433 -0
- package/dist/mqttClient.js.map +1 -0
- package/dist/service.d.ts +64 -15
- package/dist/service.js +93 -66
- package/dist/service.js.map +1 -1
- package/dist/ui/featureOptions.js +870 -0
- package/dist/ui/featureOptions.js.map +1 -0
- package/dist/ui/formatters.js +174 -0
- package/dist/ui/formatters.js.map +1 -0
- package/dist/ui/pluginConfigSession.mjs +141 -0
- package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
- package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
- package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
- package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
- package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
- package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
- package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
- package/dist/ui/webUi-featureOptions/state.mjs +319 -0
- package/dist/ui/webUi-featureOptions/store.mjs +181 -0
- package/dist/ui/webUi-featureOptions/utils.mjs +213 -0
- package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
- package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
- package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
- package/dist/ui/webUi-featureOptions/views/nav.mjs +341 -0
- package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
- package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
- package/dist/ui/webUi-featureOptions.mjs +702 -0
- package/dist/ui/webUi.mjs +225 -88
- package/dist/util.d.ts +679 -45
- package/dist/util.js +830 -77
- package/dist/util.js.map +1 -1
- package/package.json +33 -15
- package/build/eslint-rules.mjs +0 -511
- package/dist/featureoptions.d.ts +0 -264
- package/dist/featureoptions.js +0 -480
- package/dist/featureoptions.js.map +0 -1
- package/dist/mqttclient.d.ts +0 -178
- package/dist/mqttclient.js +0 -310
- package/dist/mqttclient.js.map +0 -1
- package/dist/ui/featureoptions.js +0 -480
- package/dist/ui/featureoptions.js.map +0 -1
- package/dist/ui/webUi-featureoptions.mjs +0 -3765
package/dist/ffmpeg/process.js
CHANGED
|
@@ -3,365 +3,493 @@
|
|
|
3
3
|
* ffmpeg/process.ts: Base class to provide FFmpeg process control and capability introspection.
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
|
-
* FFmpeg process management
|
|
6
|
+
* FFmpeg process management with AbortSignal-based lifecycle.
|
|
7
7
|
*
|
|
8
|
-
* This module defines the `FfmpegProcess` class,
|
|
9
|
-
*
|
|
8
|
+
* This module defines the `FfmpegProcess` base class, the foundation every other FFmpeg class in `homebridge-plugin-utils` builds on. Construction spawns the child; the
|
|
9
|
+
* composed {@link AbortSignal} exposed as `this.signal` is the single source of truth for the process's lifetime. Every teardown path - external `abort()`, parent
|
|
10
|
+
* signal aborting, natural exit, spawn failure, optional startup timeout - converges on the same signal.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* The class is an {@link AsyncDisposable} so callers can manage the process with `await using` for scope-bound lifetimes. It is intentionally not an `EventEmitter`:
|
|
13
|
+
* ready/exit are {@link Promise}s, stderr accumulates to {@link FfmpegProcess.stderrLog}, and fine-grained termination hooks are registered against `this.signal`.
|
|
13
14
|
*
|
|
14
15
|
* Key features:
|
|
15
16
|
*
|
|
16
|
-
* -
|
|
17
|
-
* -
|
|
18
|
-
* -
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
* Intended for developers needing direct, reliable control over FFmpeg process lifecycles with detailed runtime insights, especially in plugin or media automation
|
|
22
|
-
* contexts.
|
|
17
|
+
* - Spawn-on-construction. No `start()` step, no configure-then-run.
|
|
18
|
+
* - {@link AbortSignal}-driven teardown. Node's native `spawn({ signal, killSignal })` owns the kill path; no manual SIGKILL fallback timer.
|
|
19
|
+
* - `ready` promise resolves when FFmpeg produces its first stderr byte (the earliest reliable "we are actually running" signal).
|
|
20
|
+
* - `exited` promise resolves with the child's exit code and signal once it terminates. Rejects with `signal.reason` only when the child never started (e.g., `ENOENT`).
|
|
21
|
+
* - Reason-based teardown logging: `"failed"` dumps stderr at ERROR, `"timeout"` logs at WARN, other reasons log at DEBUG.
|
|
23
22
|
*
|
|
24
23
|
* @module
|
|
25
24
|
*/
|
|
26
|
-
import {
|
|
25
|
+
import { HbpuAbortError, composeSignals, isHbpuAbortError, isHbpuAbortReason, isTimeoutReason, markHandled, onAbort } from "../util.js";
|
|
27
26
|
import { EOL } from "node:os";
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// Matches non-printable control characters for stripping from FFmpeg stderr output. Compiled once at module scope rather than per data event.
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
// Matches non-printable control characters that #onStderrData replaces with EOL so control sequences become line boundaries rather than vanishing. Compiled once at
|
|
29
|
+
// module scope rather than per data event.
|
|
32
30
|
const NON_PRINTABLE_CHARS = /\p{C}+/gu;
|
|
31
|
+
// Shape-level type guard for exit-info-like cause values. The class attaches exit information to `HbpuAbortError`'s `cause` field for `"closed"` and `"failed"`
|
|
32
|
+
// reasons, but `cause` is typed `unknown` at the error boundary, so any consumer that wants to read exit info (including our own teardown logger) must narrow.
|
|
33
|
+
//
|
|
34
|
+
// The predicate's return type is intentionally narrower than the public `FfmpegProcessExitInfo`. The public type declares `exitSignal: Nullable<NodeJS.Signals>` - a
|
|
35
|
+
// compile-time union of ~30 specific strings. Runtime membership cannot be verified without a platform-dependent signal whitelist that would drift from Node itself,
|
|
36
|
+
// so the guard only checks `exitSignal` is a string (or null). The mismatch between "what we can produce strictly" and "what we can validate at the read side" is
|
|
37
|
+
// bridged by `satisfies FfmpegProcessExitInfo` at the production site (see `#onExit`), which gives us strict types for callers of `exited` without asking the
|
|
38
|
+
// read-side guard to lie about what it can actually check.
|
|
39
|
+
//
|
|
40
|
+
// `describeCause` only uses `exitSignal` for string concatenation, so losing the `NodeJS.Signals` narrowing here costs nothing. If a future consumer needs the strict
|
|
41
|
+
// union, they should either enforce it at the produce side (as `#onExit` does) or accept the looser shape at the read side.
|
|
42
|
+
function isExitInfoShape(value) {
|
|
43
|
+
if ((typeof value !== "object") || (value === null)) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const candidate = value;
|
|
47
|
+
const exitCodeOk = (candidate["exitCode"] === null) || (typeof candidate["exitCode"] === "number");
|
|
48
|
+
const exitSignalOk = (candidate["exitSignal"] === null) || (typeof candidate["exitSignal"] === "string");
|
|
49
|
+
return exitCodeOk && exitSignalOk;
|
|
50
|
+
}
|
|
51
|
+
// Render a `"failed"` abort reason's `cause` for human consumption. Recognized cause shapes, in dispatch order:
|
|
52
|
+
//
|
|
53
|
+
// - `FfmpegProcessExitInfo`: produced by `#onExit` for natural non-zero exits. Rendered as "exit code N" and/or "signal S".
|
|
54
|
+
// - `Error`: produced by `#onSpawnError` from Node's ENOENT/EACCES/etc. Rendered as the error's message.
|
|
55
|
+
// - anything else (including `null`, primitives, arbitrary user-supplied cause values): rendered as "unknown".
|
|
56
|
+
//
|
|
57
|
+
// `cause` is `unknown` by design; this function is the single narrowing point so the teardown logger never touches a field it has not validated. Pure and free-standing
|
|
58
|
+
// because it is stateless - no reason to attach it to the class.
|
|
59
|
+
function describeCause(cause) {
|
|
60
|
+
if (isExitInfoShape(cause)) {
|
|
61
|
+
const parts = [];
|
|
62
|
+
if (cause.exitCode !== null) {
|
|
63
|
+
parts.push("exit code " + cause.exitCode.toString());
|
|
64
|
+
}
|
|
65
|
+
if (cause.exitSignal !== null) {
|
|
66
|
+
parts.push("signal " + cause.exitSignal);
|
|
67
|
+
}
|
|
68
|
+
return (parts.length > 0) ? parts.join(", ") : "no exit information";
|
|
69
|
+
}
|
|
70
|
+
if (cause instanceof Error) {
|
|
71
|
+
return cause.message;
|
|
72
|
+
}
|
|
73
|
+
return "unknown";
|
|
74
|
+
}
|
|
33
75
|
/**
|
|
34
|
-
* Base class providing FFmpeg process management
|
|
76
|
+
* Base class providing FFmpeg process management with signal-driven lifecycle.
|
|
35
77
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
78
|
+
* Construction spawns the child immediately. The composed `this.signal` is the single source of truth for the process's lifetime: external `abort()`, a parent signal
|
|
79
|
+
* firing, a non-zero exit, a spawn failure, and (optionally) a startup-timeout all converge on the same signal. Subclasses register additional `"abort"` listeners on
|
|
80
|
+
* `this.signal` rather than overriding `abort()` or `[Symbol.asyncDispose]`.
|
|
38
81
|
*
|
|
39
82
|
* @example
|
|
40
83
|
*
|
|
41
84
|
* ```ts
|
|
42
|
-
* //
|
|
43
|
-
*
|
|
44
|
-
* process.start();
|
|
85
|
+
* // Scope-bound: the child is guaranteed to be torn down when the block exits, regardless of success or exception.
|
|
86
|
+
* await using proc = new FfmpegProcess(options, { args: [ "-i", "input.mp4", "-f", "null", "-" ] });
|
|
45
87
|
*
|
|
46
|
-
*
|
|
47
|
-
* const
|
|
48
|
-
*
|
|
49
|
-
*
|
|
88
|
+
* await proc.ready;
|
|
89
|
+
* const { exitCode } = await proc.exited;
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
50
93
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
94
|
+
* ```ts
|
|
95
|
+
* // Signal-driven: parent controls teardown through its own AbortController.
|
|
96
|
+
* const controller = new AbortController();
|
|
97
|
+
* const proc = new FfmpegProcess(options, { args, signal: controller.signal });
|
|
98
|
+
*
|
|
99
|
+
* // Later, from anywhere with the controller:
|
|
100
|
+
* controller.abort();
|
|
53
101
|
* ```
|
|
54
102
|
*
|
|
55
103
|
* @see {@link https://ffmpeg.org/documentation.html | FFmpeg Documentation}
|
|
56
104
|
*
|
|
57
|
-
* @see {@link https://nodejs.org/api/child_process.html | Node.js child_process}
|
|
105
|
+
* @see {@link https://nodejs.org/api/child_process.html#child_processspawncommand-args-options | Node.js child_process.spawn}
|
|
58
106
|
*
|
|
59
107
|
* @see FfmpegOptions
|
|
60
108
|
*
|
|
61
109
|
* @category FFmpeg
|
|
62
110
|
*/
|
|
63
|
-
export class FfmpegProcess
|
|
111
|
+
export class FfmpegProcess {
|
|
64
112
|
/**
|
|
65
|
-
*
|
|
113
|
+
* The composed abort signal representing this process's lifetime. Aborts exactly once when the child exits, the parent signal fires, or `abort()` is called; the
|
|
114
|
+
* reason encoded on `signal.reason` names the cause (see {@link HbpuAbortReason}). Subclasses and external callers attach `"abort"` listeners to this signal when they
|
|
115
|
+
* need scope-bound teardown hooks of their own.
|
|
66
116
|
*/
|
|
67
|
-
|
|
117
|
+
signal;
|
|
68
118
|
/**
|
|
69
|
-
*
|
|
119
|
+
* Resolves when FFmpeg has produced its first stderr byte - the earliest point at which we can reliably say the child is running. Rejects with `this.signal.reason`
|
|
120
|
+
* when the process aborts before becoming ready (external abort, spawn failure, startup timeout, early natural exit).
|
|
70
121
|
*/
|
|
71
|
-
|
|
122
|
+
ready;
|
|
72
123
|
/**
|
|
73
|
-
*
|
|
124
|
+
* Resolves with the child's exit code and signal once the process terminates. Rejects with `this.signal.reason` only when the child never started (e.g., the FFmpeg
|
|
125
|
+
* binary could not be located); in every other case it resolves with the actual exit information, even when the abort reason is `"failed"`.
|
|
74
126
|
*/
|
|
75
|
-
|
|
127
|
+
exited;
|
|
76
128
|
/**
|
|
77
|
-
*
|
|
129
|
+
* Writable standard input stream for the FFmpeg process.
|
|
78
130
|
*/
|
|
79
|
-
|
|
131
|
+
stdin;
|
|
132
|
+
/**
|
|
133
|
+
* Readable standard output stream. Subclasses that consume this stream internally narrow the public type to `never` via `declare`.
|
|
134
|
+
*/
|
|
135
|
+
stdout;
|
|
136
|
+
/**
|
|
137
|
+
* Readable standard error stream. Primarily useful to callers who want to observe stderr in addition to the accumulated {@link FfmpegProcess.stderrLog}; most callers
|
|
138
|
+
* should prefer `stderrLog` since the class already buffers lines for them.
|
|
139
|
+
*/
|
|
140
|
+
stderr;
|
|
80
141
|
/**
|
|
81
|
-
* FFmpeg process configuration
|
|
142
|
+
* The FFmpeg options instance this process was constructed with. Exposed for subclasses that need access to the shared configuration (codec support, logger, etc.).
|
|
82
143
|
*/
|
|
83
144
|
options;
|
|
84
145
|
/**
|
|
85
|
-
*
|
|
146
|
+
* Logger instance resolved from `options.log`. Shared by all log output in this class and subclasses.
|
|
86
147
|
*/
|
|
87
|
-
|
|
88
|
-
_hasError;
|
|
89
|
-
_isStarted;
|
|
148
|
+
log;
|
|
90
149
|
/**
|
|
91
|
-
*
|
|
150
|
+
* The resolved FFmpeg command-line arguments this process was spawned with. Retained so teardown logging can include the original command in error reports.
|
|
92
151
|
*/
|
|
93
|
-
|
|
94
|
-
ffmpegTimeout;
|
|
95
|
-
isLogging;
|
|
96
|
-
stderrBuffer;
|
|
152
|
+
args;
|
|
97
153
|
/**
|
|
98
|
-
*
|
|
154
|
+
* Protected alias for {@link FfmpegProcess.stdin}. Subclasses that publicly narrow `stdin` to `never` still consume the underlying stream through this typed path.
|
|
99
155
|
*/
|
|
100
|
-
|
|
101
|
-
// Create a new FFmpeg process instance.
|
|
102
|
-
constructor(options, commandLineArgs, callback) {
|
|
103
|
-
// Initialize our parent.
|
|
104
|
-
super();
|
|
105
|
-
this.callback = null;
|
|
106
|
-
this.commandLineArgs = [];
|
|
107
|
-
this._hasError = false;
|
|
108
|
-
this._isEnded = false;
|
|
109
|
-
this._isStarted = false;
|
|
110
|
-
this.isLogging = false;
|
|
111
|
-
this.log = options.log;
|
|
112
|
-
this.options = options;
|
|
113
|
-
this.process = null;
|
|
114
|
-
this.stderrBuffer = "";
|
|
115
|
-
this._stderrLog = [];
|
|
116
|
-
// Toggle FFmpeg logging, if configured.
|
|
117
|
-
this.isVerbose = this.options.codecSupport.verbose;
|
|
118
|
-
// If we've specified a command line or a callback, let's save them.
|
|
119
|
-
if (commandLineArgs) {
|
|
120
|
-
this.commandLineArgs = commandLineArgs;
|
|
121
|
-
}
|
|
122
|
-
if (callback) {
|
|
123
|
-
this.callback = callback;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
// Prepare and start our FFmpeg process.
|
|
127
|
-
prepareProcess(commandLineArgs, callback) {
|
|
128
|
-
// If we've specified a new command line or callback, let's save them.
|
|
129
|
-
if (commandLineArgs) {
|
|
130
|
-
this.commandLineArgs = commandLineArgs;
|
|
131
|
-
}
|
|
132
|
-
// Save the callback, if we have one.
|
|
133
|
-
if (callback) {
|
|
134
|
-
this.callback = callback;
|
|
135
|
-
}
|
|
136
|
-
// See if we should display ffmpeg command output.
|
|
137
|
-
this.isLogging = false;
|
|
138
|
-
// Track if we've started or ended FFmpeg.
|
|
139
|
-
this._isStarted = false;
|
|
140
|
-
this._isEnded = false;
|
|
141
|
-
// If we've got a loglevel specified, ensure we display it.
|
|
142
|
-
if (this.commandLineArgs.includes("-loglevel")) {
|
|
143
|
-
this.isLogging = true;
|
|
144
|
-
}
|
|
145
|
-
// Inform the user, if we've been asked to do so.
|
|
146
|
-
if (this.isLogging || this.isVerbose || this.options.debug) {
|
|
147
|
-
this.log.info("FFmpeg command (version: %s): %s %s", this.options.codecSupport.ffmpegVersion, this.options.codecSupport.ffmpegExec, this.commandLineArgs.join(" "));
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
this.log.debug("FFmpeg command (version: %s): %s %s", this.options.codecSupport.ffmpegVersion, this.options.codecSupport.ffmpegExec, this.commandLineArgs.join(" "));
|
|
151
|
-
}
|
|
152
|
-
return true;
|
|
153
|
-
}
|
|
156
|
+
_stdin;
|
|
154
157
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
* Protected alias for {@link FfmpegProcess.stdout}. Subclasses that publicly narrow `stdout` to `never` (because an internal consumer such as `Mp4SegmentAssembler`
|
|
159
|
+
* owns the stream) still consume the underlying stream through this typed path.
|
|
160
|
+
*/
|
|
161
|
+
_stdout;
|
|
162
|
+
/**
|
|
163
|
+
* Protected alias for {@link FfmpegProcess.stderr}. Provided for symmetry with `_stdin` / `_stdout`.
|
|
164
|
+
*/
|
|
165
|
+
_stderr;
|
|
166
|
+
// The private AbortController whose signal is fed into the composed `this.signal`. When a parent signal is provided, `AbortSignal.any` composes this controller's
|
|
167
|
+
// signal with the parent's; otherwise `this.signal` is this controller's signal directly.
|
|
168
|
+
#controller;
|
|
169
|
+
// The underlying Node ChildProcess. Held privately so subclasses route through `_stdin` / `_stdout` / `_stderr` rather than reaching into the raw object.
|
|
170
|
+
#process;
|
|
171
|
+
// Accumulated stderr lines. Exposed publicly as a readonly view via `stderrLog`. Preserved across teardown for post-mortem inspection.
|
|
172
|
+
#stderrLog = [];
|
|
173
|
+
// Pending partial stderr line (text read from the stream that has not yet terminated with a line break).
|
|
174
|
+
#stderrBuffer = "";
|
|
175
|
+
// Resolver pair for `ready`. First stderr byte resolves; teardown rejects if still pending.
|
|
176
|
+
#readyResolvers;
|
|
177
|
+
// Resolver pair for `exited`. "exit" event resolves with the actual info; "error" rejects when the child never spawned.
|
|
178
|
+
#exitedResolvers;
|
|
179
|
+
// Set `true` once we have observed first stderr output. Used by the optional startup-timeout path to decide whether the timeout fired meaningfully.
|
|
180
|
+
#firstStderr = false;
|
|
181
|
+
// Cached decision - should stderr lines be mirrored to `log.info`? Computed once from `args`, `verbose`, and `debug` during construction and immutable thereafter.
|
|
182
|
+
// Cached rather than recomputed because the stderr hot path consults it per chunk and the inputs never change.
|
|
183
|
+
#liveLog;
|
|
184
|
+
/**
|
|
185
|
+
* Construct and spawn a new FFmpeg process.
|
|
160
186
|
*
|
|
161
|
-
*
|
|
187
|
+
* Spawning happens synchronously as part of construction: by the time the constructor returns, the child is already running (or has already scheduled its `"error"`
|
|
188
|
+
* event for a spawn failure). There is no separate `start()` step.
|
|
162
189
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* ```
|
|
190
|
+
* @param options - Shared {@link FfmpegOptions} configuration (codec support, logger, debug flag, name).
|
|
191
|
+
* @param init - Optional init options. See {@link FfmpegProcessInit}.
|
|
166
192
|
*/
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
this.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
this.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
//
|
|
201
|
-
this
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
this.
|
|
229
|
-
|
|
230
|
-
if (this.isLogging || this.isVerbose || this.options.debug) {
|
|
231
|
-
this.log.info(line);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
// Handle our process termination.
|
|
236
|
-
this.process?.once("exit", (exitCode, signal) => {
|
|
237
|
-
// Clear out our canary.
|
|
238
|
-
if (this.ffmpegTimeout) {
|
|
239
|
-
clearTimeout(this.ffmpegTimeout);
|
|
240
|
-
}
|
|
241
|
-
this._isStarted = false;
|
|
242
|
-
this._isEnded = true;
|
|
243
|
-
// Some utilities to streamline things.
|
|
244
|
-
const logPrefix = "FFmpeg process ended ";
|
|
245
|
-
// FFmpeg ended normally and our canary didn't need to enforce FFmpeg's extinction.
|
|
246
|
-
if (this.ffmpegTimeout && (exitCode === 0)) {
|
|
247
|
-
this.log.debug(logPrefix + "(Normal).");
|
|
248
|
-
}
|
|
249
|
-
else if (((exitCode === null) || (exitCode === 255)) && this.process?.killed) {
|
|
250
|
-
// FFmpeg has ended. Let's figure out if it's because we killed it or whether it died of natural causes.
|
|
251
|
-
this.log.debug(logPrefix + (signal === "SIGKILL" ? "(Killed)." : "(Expected)."));
|
|
252
|
-
}
|
|
253
|
-
else {
|
|
254
|
-
// Flag that we've run into an FFmpeg error.
|
|
255
|
-
this._hasError = true;
|
|
256
|
-
// Flush out any remaining output in our error buffer.
|
|
257
|
-
if (this.stderrBuffer.length) {
|
|
258
|
-
this._stderrLog.push(this.stderrBuffer + "\n");
|
|
259
|
-
this.stderrBuffer = "";
|
|
260
|
-
}
|
|
261
|
-
// Inform the user.
|
|
262
|
-
this.logFfmpegError(exitCode, signal);
|
|
263
|
-
// Execute our error handler, if one is provided.
|
|
264
|
-
if (errorHandler) {
|
|
265
|
-
void errorHandler(util.format(this.options.name() + ": " + logPrefix + " unexpectedly with exit code %s and signal %s.", exitCode, signal));
|
|
193
|
+
constructor(options, init = {}) {
|
|
194
|
+
const { args = [], signal: parentSignal, startupTimeout } = init;
|
|
195
|
+
this.options = options;
|
|
196
|
+
this.log = options.log;
|
|
197
|
+
// Freeze a copy of the caller's args so the `readonly` type is enforced at runtime as well as in TypeScript. Without the copy, a caller mutating their own array
|
|
198
|
+
// post-construction would silently mutate our stored value - visible in the teardown log, invisible during review.
|
|
199
|
+
this.args = Object.freeze([...args]);
|
|
200
|
+
// Resolve the logging decision once, at construction time. The live-log path runs on every stderr chunk; recomputing `this.args.includes("-loglevel")` each time
|
|
201
|
+
// would be wasteful. Verbose and debug are configuration, not signal state, so they are also resolved here.
|
|
202
|
+
this.#liveLog = this.args.includes("-loglevel") || this.#verbose || options.debug;
|
|
203
|
+
// Compose our lifetime. The internal controller lets us abort the process from within the class; the optional parent signal lets a caller tear us down as part of
|
|
204
|
+
// their own lifecycle. `composeSignals` handles the "one or both" case uniformly so we never hand-roll the `AbortSignal.any` branching.
|
|
205
|
+
this.#controller = new AbortController();
|
|
206
|
+
this.signal = composeSignals(parentSignal, this.#controller.signal);
|
|
207
|
+
// Log the command once at construction. The policy: verbose/debug/loglevel-in-args configurations surface the command at info; every other configuration logs it
|
|
208
|
+
// at debug. Consumers that need this in structured form read `args` directly. We select the level dynamically so there is a single format string to maintain -
|
|
209
|
+
// parallel if/else branches drift as they age.
|
|
210
|
+
const commandLogLevel = this.#liveLog ? "info" : "debug";
|
|
211
|
+
this.log[commandLogLevel]("FFmpeg command (version: %s): %s %s.", this.#ffmpegVersion, this.#ffmpegExec, this.args.join(" "));
|
|
212
|
+
// Wire up the ready/exited resolvers before spawning. Node emits spawn-failure `"error"` events on the next microtask; the resolvers must be in place by then so
|
|
213
|
+
// the error path can settle them. We bind via typed locals so `PromiseWithResolvers<T>` is expressed as a type annotation rather than a call-site generic argument.
|
|
214
|
+
const readyResolvers = Promise.withResolvers();
|
|
215
|
+
const exitedResolvers = Promise.withResolvers();
|
|
216
|
+
this.#readyResolvers = readyResolvers;
|
|
217
|
+
this.#exitedResolvers = exitedResolvers;
|
|
218
|
+
// Mark both promises as observed. Callers that await `ready` or `exited` still receive the rejection through their own chain - `markHandled` only opts the promise
|
|
219
|
+
// out of Node's unhandled-rejection tracker for the case where nobody awaits one of them (e.g., a session that aborts before the process becomes ready and the
|
|
220
|
+
// caller never asked for the ready outcome).
|
|
221
|
+
this.ready = markHandled(readyResolvers.promise);
|
|
222
|
+
this.exited = markHandled(exitedResolvers.promise);
|
|
223
|
+
// Spawn immediately. Node's spawn accepts `{ signal, killSignal }` natively: when the signal aborts, Node kills the child with `killSignal`, letting the kernel
|
|
224
|
+
// own the kill path. Pairing the signal with `SIGKILL` is the right choice for FFmpeg specifically because it is a media pipeline, not a service we want to
|
|
225
|
+
// negotiate orderly shutdown with - when we are done, we are done. We spawn from `this.args` (the frozen copy) rather than the caller's live reference so the
|
|
226
|
+
// class has a single source of truth for its command line.
|
|
227
|
+
this.#process = spawn(this.#ffmpegExec, this.args, { killSignal: "SIGKILL", signal: this.signal });
|
|
228
|
+
// Cache references to the child's streams. `ChildProcessWithoutNullStreams` guarantees these are non-null, and the cached references stay live even after the child
|
|
229
|
+
// exits so subclasses can still inspect them.
|
|
230
|
+
this.stdin = this._stdin = this.#process.stdin;
|
|
231
|
+
this.stdout = this._stdout = this.#process.stdout;
|
|
232
|
+
this.stderr = this._stderr = this.#process.stderr;
|
|
233
|
+
// Attach listeners to the ChildProcess and its streams. Node's `EventEmitter.on` / `.once` do not accept the `{ signal }` option that `EventTarget.addEventListener`
|
|
234
|
+
// does - that helper lives on the module-level `events.on` / `events.once` wrappers, which are oriented around promises and async iterators rather than callbacks.
|
|
235
|
+
// For the base class's single-producer-single-consumer callbacks, manual attachment is fine: the underlying ChildProcess and its pipes are destroyed when the signal
|
|
236
|
+
// aborts (Node's `spawn({ signal, killSignal })` owns the kill path), so post-abort events simply never fire and there is no runaway-listener risk.
|
|
237
|
+
this.#process.once("error", (error) => this.#onSpawnError(error));
|
|
238
|
+
this.#process.once("exit", (exitCode, exitSignal) => this.#onExit(exitCode, exitSignal));
|
|
239
|
+
this.#process.stdin.on("error", (error) => this.#onStdinError(error));
|
|
240
|
+
this.#process.stderr.on("data", (chunk) => this.#onStderrData(chunk));
|
|
241
|
+
// Single teardown convergence point. `onAbort` registers the one-shot teardown listener for the normal abort path AND runs it synchronously when the signal is
|
|
242
|
+
// already aborted at construction time - typically because the caller passed a pre-aborted parent signal. `#onSpawnError` will later settle `exited` when Node's
|
|
243
|
+
// pre-aborted spawn emits its `"error"` event on the next microtask.
|
|
244
|
+
onAbort(this.signal, () => this.#teardown());
|
|
245
|
+
// Optional startup watchdog. `AbortSignal.timeout` fires an abort after the configured window; we observe that abort and convert it into our own `"timeout"`
|
|
246
|
+
// reason when the process has not yet emitted its first stderr byte. The `{ signal: this.signal }` option on `addEventListener` ties the listener's lifetime to
|
|
247
|
+
// the process signal, so the listener auto-unregisters (and the timeout signal becomes GC-eligible) the moment the process aborts for any other reason - no
|
|
248
|
+
// manual `clearTimeout` bookkeeping, and the listener cannot fire after the process is already torn down. The `#firstStderr` guard prevents firing after a
|
|
249
|
+
// successful start; this is a startup watchdog, not a general stall detector.
|
|
250
|
+
if (startupTimeout !== undefined) {
|
|
251
|
+
const timeoutSignal = AbortSignal.timeout(startupTimeout);
|
|
252
|
+
timeoutSignal.addEventListener("abort", () => {
|
|
253
|
+
// Only act if we are still waiting for first output and the process has not aborted for another reason.
|
|
254
|
+
if (!this.aborted && !this.#firstStderr) {
|
|
255
|
+
this.#controller.abort(new HbpuAbortError("timeout"));
|
|
266
256
|
}
|
|
267
|
-
}
|
|
268
|
-
// Cleanup after ourselves. We intentionally preserve _stderrLog so callers can inspect it for post-mortem diagnostics after the process exits.
|
|
269
|
-
this.process?.stdin.off("error", errorListener);
|
|
270
|
-
this.process?.stderr.off("data", dataListener);
|
|
271
|
-
this.process = null;
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
// Stop the FFmpeg process and complete any cleanup activities.
|
|
275
|
-
stopProcess() {
|
|
276
|
-
// Check to make sure we aren't using stdin for data before telling FFmpeg we're done.
|
|
277
|
-
if (!this.commandLineArgs.includes("pipe:0")) {
|
|
278
|
-
this.process?.stdin.end("q");
|
|
257
|
+
}, { once: true, signal: this.signal });
|
|
279
258
|
}
|
|
280
|
-
// Close our input and output.
|
|
281
|
-
this.process?.stdin.destroy();
|
|
282
|
-
this.process?.stdout.destroy();
|
|
283
|
-
// In case we need to kill it again, just to be sure it's really dead.
|
|
284
|
-
this.ffmpegTimeout = setTimeout(() => {
|
|
285
|
-
this.process?.kill("SIGKILL");
|
|
286
|
-
}, FFMPEG_INPUT_TIMEOUT);
|
|
287
|
-
// Send the kill shot.
|
|
288
|
-
this.process?.kill();
|
|
289
259
|
}
|
|
290
260
|
/**
|
|
291
|
-
*
|
|
261
|
+
* Abort the process and tear it down. Defaults to `HbpuAbortError("shutdown")` when no reason is supplied; explicit reasons pass through unchanged.
|
|
292
262
|
*
|
|
293
|
-
*
|
|
263
|
+
* Safe to call more than once: subsequent calls are no-ops because the underlying signal only aborts once. Calling `abort()` after natural exit is also safe for the
|
|
264
|
+
* same reason.
|
|
294
265
|
*
|
|
295
|
-
*
|
|
296
|
-
* process.stop();
|
|
297
|
-
* ```
|
|
266
|
+
* @param reason - Optional abort reason. Typically an {@link HbpuAbortError}; platform errors (`TimeoutError`, `AbortError`) also interoperate by convention.
|
|
298
267
|
*/
|
|
299
|
-
|
|
300
|
-
|
|
268
|
+
abort(reason) {
|
|
269
|
+
// Fast path - no need to construct a default reason if we are already done.
|
|
270
|
+
if (this.aborted) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
this.#controller.abort(reason ?? new HbpuAbortError("shutdown"));
|
|
301
274
|
}
|
|
302
275
|
/**
|
|
303
|
-
*
|
|
276
|
+
* `AsyncDisposable` implementation. Aborts the process (defaulting to `"shutdown"`) and awaits actual exit before returning, so callers using `await using` are
|
|
277
|
+
* guaranteed the child has terminated by the time the block exits.
|
|
304
278
|
*
|
|
305
|
-
* @
|
|
306
|
-
* @param signal - The signal, if any, used to terminate the process.
|
|
279
|
+
* @returns A promise that resolves once the child has fully exited.
|
|
307
280
|
*/
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
this.log.error(x);
|
|
314
|
-
}
|
|
281
|
+
async [Symbol.asyncDispose]() {
|
|
282
|
+
this.abort();
|
|
283
|
+
// `exited` rejects only when the child never spawned. In either case, the caller's dispose scope is done; swallow the rejection so `await using` does not surface
|
|
284
|
+
// disposal errors that the caller has no way to react to.
|
|
285
|
+
await this.exited.catch(() => { });
|
|
315
286
|
}
|
|
316
287
|
/**
|
|
317
|
-
*
|
|
288
|
+
* `true` once `this.signal` has aborted. Derived from the signal; no independent state.
|
|
318
289
|
*/
|
|
319
|
-
get
|
|
320
|
-
return this.
|
|
290
|
+
get aborted() {
|
|
291
|
+
return this.signal.aborted;
|
|
321
292
|
}
|
|
322
293
|
/**
|
|
323
|
-
*
|
|
294
|
+
* `true` when the abort reason was `HbpuAbortError("failed")`. Covers spawn failures and non-zero natural exits. Derived from `this.signal.reason`; no stored flag.
|
|
324
295
|
*/
|
|
325
|
-
get
|
|
326
|
-
return this.
|
|
296
|
+
get hasError() {
|
|
297
|
+
return isHbpuAbortReason(this.signal.reason, "failed");
|
|
327
298
|
}
|
|
328
299
|
/**
|
|
329
|
-
*
|
|
300
|
+
* `true` when the abort reason indicates a timeout. Matches both the canonical `HbpuAbortError("timeout")` and the platform `TimeoutError` emitted by
|
|
301
|
+
* `AbortSignal.timeout()`. The branching lives in {@link isTimeoutReason} so this getter stays a one-line delegation and every resource class in the library
|
|
302
|
+
* shares one definition of "timeout."
|
|
330
303
|
*/
|
|
331
|
-
get
|
|
332
|
-
return this.
|
|
304
|
+
get isTimedOut() {
|
|
305
|
+
return isTimeoutReason(this.signal.reason);
|
|
333
306
|
}
|
|
334
307
|
/**
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
* @returns An array of stderr log lines.
|
|
308
|
+
* The accumulated stderr lines this process has produced, preserved across teardown for post-mortem inspection. The array is returned as a readonly view to make the
|
|
309
|
+
* intent explicit: callers read from it, they do not mutate it.
|
|
338
310
|
*/
|
|
339
311
|
get stderrLog() {
|
|
340
|
-
return this
|
|
312
|
+
return this.#stderrLog;
|
|
341
313
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
314
|
+
// Called when Node emits an `"error"` event on the child. The handler has exactly one job: handle the genuine pre-spawn failure case where the syscall never assigned
|
|
315
|
+
// a pid and no `"exit"` event will fire. Every other shape of error (post-spawn AbortError from the signal-driven kill path, post-spawn kill / IPC errors, anything
|
|
316
|
+
// else Node might attach) lands on a live child whose `"exit"` event is the canonical settlement - the exit listener will resolve `exited` with real exit info, and
|
|
317
|
+
// re-aborting from here would only overwrite a meaningful signal reason with a derived one.
|
|
318
|
+
//
|
|
319
|
+
// The pid check is the deciding test. `.pid` is assigned synchronously by libuv when fork+exec succeeds, so checking it here is a reliable "did the kernel run our
|
|
320
|
+
// child?" test that does not depend on event-ordering between `"spawn"` and `"error"`. When pid is set the child is alive (or was alive); when pid is undefined, the
|
|
321
|
+
// spawn syscall failed before assigning one and this handler owns the diagnostic + settlement.
|
|
322
|
+
#onSpawnError(error) {
|
|
323
|
+
// Post-spawn: the child got a pid, so the `"exit"` event is the canonical settlement. Skip - the exit handler will resolve `exited` with the real exit info and the
|
|
324
|
+
// re-abort guard there preserves whatever signal reason aborted us.
|
|
325
|
+
if (this.#process.pid !== undefined) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// Genuine pre-spawn failure. Emit a user-facing diagnostic (ENOENT is the common case - wrong FFmpeg path), abort with a structured reason preserving the
|
|
329
|
+
// underlying error via `cause`, and settle `exited` ourselves since nothing else will.
|
|
330
|
+
const message = (error.code === "ENOENT") ? ("unable to find \"" + (error.path ?? "unknown") + "\"") : error.message;
|
|
331
|
+
this.log.error("FFmpeg failed to start: %s.", message);
|
|
332
|
+
if (!this.aborted) {
|
|
333
|
+
this.#controller.abort(new HbpuAbortError("failed", { cause: error }));
|
|
334
|
+
}
|
|
335
|
+
this.#exitedResolvers.reject(this.signal.reason);
|
|
336
|
+
}
|
|
337
|
+
// Called when Node emits an `"exit"` event on the child. This is the canonical settlement point for `exited` regardless of whether the exit was natural or driven by
|
|
338
|
+
// abort: the caller always gets to read the actual exit code / signal. When we are not yet aborted, we convert the exit into an abort reason; when we are already
|
|
339
|
+
// aborted (because the caller or a parent triggered the teardown), the existing `signal.reason` stands and we only resolve `exited`.
|
|
340
|
+
#onExit(exitCode, exitSignal) {
|
|
341
|
+
// Flush any trailing stderr buffer that did not end with a newline - callers inspecting `stderrLog` after exit should see the complete record.
|
|
342
|
+
if (this.#stderrBuffer.length > 0) {
|
|
343
|
+
this.#stderrLog.push(this.#stderrBuffer);
|
|
344
|
+
this.#stderrBuffer = "";
|
|
345
|
+
}
|
|
346
|
+
// Canonical: always settle `exited` with the actual exit info. Even when abort drove the kill and the exit code reflects the signal we sent, callers benefit from
|
|
347
|
+
// knowing what actually happened at the syscall level.
|
|
348
|
+
this.#exitedResolvers.resolve({ exitCode, exitSignal });
|
|
349
|
+
// Re-abort guard. If the signal is already aborted - because we aborted it externally, because a parent aborted, or because `#onSpawnError` aborted us - the
|
|
350
|
+
// existing reason is authoritative. Re-aborting would overwrite a meaningful reason (e.g., `"shutdown"`) with a less useful one derived from the kill-driven exit.
|
|
351
|
+
if (this.aborted) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// Natural exit. Code 0 is the closed case; anything else is a failure. The cause shape is the single constant either way; only the reason name varies. The
|
|
355
|
+
// `satisfies` operator pins production to the strict `FfmpegProcessExitInfo` type so `exited`'s callers get the `NodeJS.Signals` narrowing; the read-side guard
|
|
356
|
+
// (`isExitInfoShape`) verifies what the runtime can actually check. Both sides share this literal shape, so a future rename of either field breaks both ends.
|
|
357
|
+
const reasonName = (exitCode === 0) ? "closed" : "failed";
|
|
358
|
+
const cause = { exitCode, exitSignal };
|
|
359
|
+
this.#controller.abort(new HbpuAbortError(reasonName, { cause }));
|
|
360
|
+
}
|
|
361
|
+
// Stdin error listener. FFmpeg's stdin typically closes cleanly once we are done piping data; the only error we expect in normal operation is `EPIPE`, which happens
|
|
362
|
+
// when we try to write after ffmpeg has already exited. We swallow `EPIPE` silently and log anything else. The underlying stream is destroyed when the signal aborts
|
|
363
|
+
// (Node's `spawn({ signal, killSignal })` owns the kill path), so post-abort stdin events simply do not fire - no manual `.off()` needed.
|
|
364
|
+
#onStdinError(error) {
|
|
365
|
+
if (error.message.includes("EPIPE")) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
this.log.error("FFmpeg error: %s.", error.message);
|
|
369
|
+
}
|
|
370
|
+
// Stderr data listener. Responsibilities, in order: mark the process as "ready" on first byte (this is the only reliable signal that FFmpeg is running, since stdin
|
|
371
|
+
// and stdout may not be used at all depending on the invocation), append each complete line to the rolling stderr log, and emit live-log output if configured.
|
|
372
|
+
#onStderrData(chunk) {
|
|
373
|
+
if (!this.#firstStderr) {
|
|
374
|
+
this.#firstStderr = true;
|
|
375
|
+
this.#readyResolvers.resolve();
|
|
376
|
+
this.log.debug("FFmpeg process started.");
|
|
377
|
+
}
|
|
378
|
+
// Replace non-printable characters with EOL so control sequences become line boundaries rather than silently vanishing. Keeping this as a pre-normalization step
|
|
379
|
+
// means the line-splitting below does not have to worry about hidden escape sequences fracturing log entries.
|
|
380
|
+
this.#stderrBuffer += chunk.toString().replace(NON_PRINTABLE_CHARS, EOL);
|
|
381
|
+
// Line-by-line drain. EOL from `node:os` handles Windows-style line endings as a single unit; using it explicitly rather than searching for `"\n"` keeps this loop
|
|
382
|
+
// correct across platforms.
|
|
383
|
+
for (;;) {
|
|
384
|
+
const lineIndex = this.#stderrBuffer.indexOf(EOL);
|
|
385
|
+
if (lineIndex === -1) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const line = this.#stderrBuffer.slice(0, lineIndex);
|
|
389
|
+
this.#stderrBuffer = this.#stderrBuffer.slice(lineIndex + EOL.length);
|
|
390
|
+
this.#stderrLog.push(line);
|
|
391
|
+
if (this.#liveLog) {
|
|
392
|
+
this.log.info(line);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Single teardown convergence point. Fires exactly once (registered with `{ once: true }`) when `this.signal` aborts, regardless of which path triggered the abort.
|
|
397
|
+
// Responsibilities: settle `ready` if still pending, apply the reason-based teardown log policy. Note that `exited` is NOT settled here - the `"exit"` or `"error"`
|
|
398
|
+
// listener is the single source of truth for that promise, so that callers always read the real exit info when one is available.
|
|
399
|
+
#teardown() {
|
|
400
|
+
// Reject ready if still pending. If it already resolved because we saw first stderr, this call is a no-op (promise resolvers are inert after first settlement).
|
|
401
|
+
this.#readyResolvers.reject(this.signal.reason);
|
|
402
|
+
// Apply the reason-based teardown log policy.
|
|
403
|
+
this.#logTeardown();
|
|
404
|
+
}
|
|
405
|
+
// Reason-based teardown logging. The policy is driven by `this.signal.reason`, mapping each reason variant to a log level and message shape:
|
|
406
|
+
//
|
|
407
|
+
// - `HbpuAbortError("failed", ...)` -> delegated to `logFailedTeardown()` (ERROR with full stderr dump and exit context by default; subclasses override to
|
|
408
|
+
// substitute a bespoke message for known benign error shapes).
|
|
409
|
+
// - `HbpuAbortError("timeout")` -> delegated to `logTimeoutTeardown()` (a static WARN by default; the recording subclass overrides it to debug for a benign
|
|
410
|
+
// inter-segment timeout).
|
|
411
|
+
// - Any other HbpuAbortError -> DEBUG with the reason name (self-inflicted or orderly teardown).
|
|
412
|
+
// - Platform TimeoutError/AbortError -> DEBUG with the reason name (no dump).
|
|
413
|
+
// - Anything else -> DEBUG with a generic message.
|
|
414
|
+
//
|
|
415
|
+
// Lifting a teardown branch into a `protected` method lets subclasses substitute a level / message for a teardown shape they characterize differently, without
|
|
416
|
+
// re-implementing the whole teardown policy or trying to pre-empt the base's listener ordering. Two such seams exist: `logFailedTeardown` (the recording subclass
|
|
417
|
+
// substitutes a friendly message for known benign HKSV error shapes, returning early to suppress the canonical ERROR dump; non-matching subclasses call
|
|
418
|
+
// `super.logFailedTeardown(reason)` and the dump fires) and `logTimeoutTeardown` (the recording subclass demotes the benign inter-segment timeout to debug, while the
|
|
419
|
+
// default stays WARN because a stall on a general - streaming - process genuinely is a problem).
|
|
420
|
+
#logTeardown() {
|
|
421
|
+
const reason = this.signal.reason;
|
|
422
|
+
if (isHbpuAbortError(reason)) {
|
|
423
|
+
switch (reason.name) {
|
|
424
|
+
case "failed": {
|
|
425
|
+
this.logFailedTeardown(reason);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
case "timeout": {
|
|
429
|
+
this.logTimeoutTeardown(reason);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
default: {
|
|
433
|
+
this.log.debug("%s (%s).", this.#teardownPrefix, reason.name);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Platform TimeoutError / AbortError or any other reason. Log at debug - if callers want richer behavior, they observe `exited` or attach their own abort listener.
|
|
439
|
+
const name = (reason instanceof Error) ? reason.name : "unknown";
|
|
440
|
+
this.log.debug("%s (%s).", this.#teardownPrefix, name);
|
|
349
441
|
}
|
|
350
442
|
/**
|
|
351
|
-
*
|
|
443
|
+
* Log the "failed" teardown branch. Overridable by subclasses that want to substitute a bespoke message for known benign error shapes before falling through to the
|
|
444
|
+
* canonical ERROR dump.
|
|
445
|
+
*
|
|
446
|
+
* The default implementation emits two ERROR lines (an "ended unexpectedly" summary and the command that produced it), then dumps every accumulated stderr line at
|
|
447
|
+
* ERROR level. Subclasses may inspect `this.stderrLog` and `reason.cause`, log a friendly message, and return early to suppress the dump; or call
|
|
448
|
+
* `super.logFailedTeardown(reason)` to emit the canonical dump after prepending their own context.
|
|
352
449
|
*
|
|
353
|
-
* @
|
|
450
|
+
* @param reason - The `"failed"` reason that drove this teardown. Its `cause` field carries structured exit info (`{ exitCode, exitSignal }`) for natural non-zero
|
|
451
|
+
* exits, or the underlying `Error` for spawn failures.
|
|
354
452
|
*/
|
|
355
|
-
|
|
356
|
-
|
|
453
|
+
logFailedTeardown(reason) {
|
|
454
|
+
this.log.error("%s unexpectedly. Reason: %s.", this.#teardownPrefix, describeCause(reason.cause));
|
|
455
|
+
this.log.error("FFmpeg (%s) command that errored out was: %s %s.", this.#ffmpegVersion, this.#ffmpegExec, this.args.join(" "));
|
|
456
|
+
for (const line of this.#stderrLog) {
|
|
457
|
+
this.log.error(line);
|
|
458
|
+
}
|
|
357
459
|
}
|
|
358
460
|
/**
|
|
359
|
-
*
|
|
461
|
+
* Log the "timeout" teardown branch. Overridable by subclasses for which an inter-segment / watchdog timeout is benign by default - e.g. an HKSV recording, which
|
|
462
|
+
* ends exactly this way whenever its segment source quiets.
|
|
463
|
+
*
|
|
464
|
+
* The default implementation emits a single WARN: a stall that trips the watchdog on a general FFmpeg process (a live stream) genuinely is a problem. A subclass may
|
|
465
|
+
* override to demote the reap to debug and leave the severity verdict to the consumer that holds the input-feed and reachability context.
|
|
360
466
|
*
|
|
361
|
-
* @
|
|
467
|
+
* @param _reason - The `"timeout"` abort reason that drove this teardown. Part of the seam contract so an override can inspect it (parallel to `logFailedTeardown`),
|
|
468
|
+
* but neither the default body nor the recording override reads it - a watchdog timeout carries no actionable cause - so it is `_`-prefixed.
|
|
362
469
|
*/
|
|
363
|
-
|
|
364
|
-
|
|
470
|
+
logTimeoutTeardown(_reason) {
|
|
471
|
+
this.log.warn("%s: stalled past its watchdog window.", this.#teardownPrefix);
|
|
472
|
+
}
|
|
473
|
+
// Common prefix used by the BASE teardown log lines: the caller's camera / session name followed by the canonical teardown phrase. Single source of truth so the
|
|
474
|
+
// wording stays consistent across the failed / timeout / debug DEFAULT bodies and any future base teardown message. The subclass overrides (`FfmpegRecordingProcess`'s
|
|
475
|
+
// `logFailedTeardown` and `logTimeoutTeardown`) deliberately do NOT read this getter - they compose their own bespoke messages from `this.options.name()`, so a future
|
|
476
|
+
// change to this prefix cannot leave their wording stale. Keyed off `options.name()` rather than cached because the name function can return different values over the
|
|
477
|
+
// instance's lifetime (e.g., a mid-session camera rename in HBUP).
|
|
478
|
+
get #teardownPrefix() {
|
|
479
|
+
return this.options.name() + ": FFmpeg process ended";
|
|
480
|
+
}
|
|
481
|
+
// Private forwarders to the codec-support scalars this class reads during spawn and teardown; routing each through a named accessor keeps the long
|
|
482
|
+
// `this.options.config.codecSupport.X` path out of the call sites and makes it obvious at a glance which config values the class actually depends on. The class
|
|
483
|
+
// reads these scalars from a handful of call sites across construction, spawn, and teardown. No stored state - each getter is a one-line forward through the
|
|
484
|
+
// config chain.
|
|
485
|
+
get #ffmpegExec() {
|
|
486
|
+
return this.options.config.codecSupport.ffmpegExec;
|
|
487
|
+
}
|
|
488
|
+
get #ffmpegVersion() {
|
|
489
|
+
return this.options.config.codecSupport.ffmpegVersion;
|
|
490
|
+
}
|
|
491
|
+
get #verbose() {
|
|
492
|
+
return this.options.config.codecSupport.verbose;
|
|
365
493
|
}
|
|
366
494
|
}
|
|
367
495
|
//# sourceMappingURL=process.js.map
|