tokwatchr 0.4.4 → 0.6.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/dist/index.d.mts +25 -3
- package/dist/index.mjs +42 -9
- package/package.json +1 -1
- package/src/TikTokLiveDownloader.ts +49 -6
- package/src/download/ffmpeg.ts +6 -6
- package/src/index.ts +2 -0
- package/src/types.ts +26 -2
package/dist/index.d.mts
CHANGED
|
@@ -63,7 +63,7 @@ interface TikTokLiveDownloaderOptions {
|
|
|
63
63
|
maxDuration?: number;
|
|
64
64
|
/** Split recording into segments of this many seconds (default: Infinity = single file). */
|
|
65
65
|
maxSegmentDuration?: number;
|
|
66
|
-
/** Polling interval in
|
|
66
|
+
/** Polling interval in seconds when waiting for live (default: 180). */
|
|
67
67
|
checkInterval?: number;
|
|
68
68
|
/** HTTP proxy URL (supports http, https, socks4, socks5). */
|
|
69
69
|
proxyUrl?: string;
|
|
@@ -71,7 +71,7 @@ interface TikTokLiveDownloaderOptions {
|
|
|
71
71
|
cookieJar?: CookieJarLike;
|
|
72
72
|
/** Browser to emulate (default: "chrome"). */
|
|
73
73
|
browser?: Browser;
|
|
74
|
-
/** Request timeout in
|
|
74
|
+
/** Request timeout in seconds (default: 30). */
|
|
75
75
|
timeout?: number;
|
|
76
76
|
/** Extra headers to send with every request. */
|
|
77
77
|
headers?: Record<string, string>;
|
|
@@ -88,7 +88,29 @@ interface CookieJarLike {
|
|
|
88
88
|
setCookie(raw: string, url: string): Promise<void>;
|
|
89
89
|
getCookieString(url: string): Promise<string>;
|
|
90
90
|
}
|
|
91
|
+
interface WaitingInfo {
|
|
92
|
+
/** The username being waited on. */
|
|
93
|
+
username: string;
|
|
94
|
+
/** Which phase we're waiting in: "room" = looking for a room, "stream" = stream not active. */
|
|
95
|
+
phase: "room" | "stream";
|
|
96
|
+
/** Seconds elapsed since this phase started. */
|
|
97
|
+
elapsed: number;
|
|
98
|
+
}
|
|
99
|
+
interface RemuxInfo {
|
|
100
|
+
/** Input .ts file path. */
|
|
101
|
+
filePath: string;
|
|
102
|
+
/** Input file size in MB. */
|
|
103
|
+
inputSizeMB: number;
|
|
104
|
+
/** Remux status: started → completed (with outputPath) or failed (keeping .ts). */
|
|
105
|
+
status: "started" | "completed" | "failed";
|
|
106
|
+
/** Output file path. Only set when status is "completed". */
|
|
107
|
+
outputPath?: string;
|
|
108
|
+
}
|
|
91
109
|
interface TikTokLiveDownloaderEvents {
|
|
110
|
+
/** Emitted periodically while waiting for a room or stream to become active. */
|
|
111
|
+
waiting: [info: WaitingInfo];
|
|
112
|
+
/** Emitted when a remux operation starts, completes, or fails. */
|
|
113
|
+
remux: [info: RemuxInfo];
|
|
92
114
|
/** Emitted when a live stream is detected and we're about to start recording. */
|
|
93
115
|
start: [info: StreamInfo];
|
|
94
116
|
/** Emitted periodically (every ~1s) during recording with current stats. */
|
|
@@ -330,4 +352,4 @@ declare function renderFilename(template: string, values: {
|
|
|
330
352
|
part?: number;
|
|
331
353
|
}): string;
|
|
332
354
|
//#endregion
|
|
333
|
-
export { AbortError, type CookieJarLike, type CreateClientOptions, DownloadFailedError, type DownloadFunctionOptions, type DownloadResult, type DownloadStats, type DownloaderState, FfmpegError, type OutputFormat, type QualityOption, RoomResolveError, type RoomResolveOptions, StreamFetchError, type StreamInfo, type StreamInfoOptions, type StreamQualityKey, TikTokLiveDownloader, type TikTokLiveDownloaderEvents, type TikTokLiveDownloaderOptions, TikTokLiveError, UserNotFoundError, UserOfflineError, buildQualityOption, createClient, download, fetchStreamInfo, parseQualities, renderFilename, resolveRoomId, selectQuality };
|
|
355
|
+
export { AbortError, type CookieJarLike, type CreateClientOptions, DownloadFailedError, type DownloadFunctionOptions, type DownloadResult, type DownloadStats, type DownloaderState, FfmpegError, type OutputFormat, type QualityOption, type RemuxInfo, RoomResolveError, type RoomResolveOptions, StreamFetchError, type StreamInfo, type StreamInfoOptions, type StreamQualityKey, TikTokLiveDownloader, type TikTokLiveDownloaderEvents, type TikTokLiveDownloaderOptions, TikTokLiveError, UserNotFoundError, UserOfflineError, type WaitingInfo, buildQualityOption, createClient, download, fetchStreamInfo, parseQualities, renderFilename, resolveRoomId, selectQuality };
|
package/dist/index.mjs
CHANGED
|
@@ -368,7 +368,7 @@ function parseFfmpegProgress(line) {
|
|
|
368
368
|
* - Better handling of stream interruptions
|
|
369
369
|
*/
|
|
370
370
|
async function downloadWithFfmpeg(options) {
|
|
371
|
-
const { ffmpegPath, url, outputPath, quality, args: extraArgs, bitrate, signal, onProgress, maxDuration, timeout =
|
|
371
|
+
const { ffmpegPath, url, outputPath, quality, args: extraArgs, bitrate, signal, onProgress, maxDuration, timeout = 30 } = options;
|
|
372
372
|
const ffmpegArgs = [
|
|
373
373
|
"-y",
|
|
374
374
|
"-i",
|
|
@@ -394,8 +394,8 @@ async function downloadWithFfmpeg(options) {
|
|
|
394
394
|
let firstDataTimer = setTimeout(() => {
|
|
395
395
|
firstDataTimer = null;
|
|
396
396
|
proc.kill("SIGTERM");
|
|
397
|
-
reject(new FfmpegError(`ffmpeg produced no output within ${timeout}
|
|
398
|
-
}, timeout);
|
|
397
|
+
reject(new FfmpegError(`ffmpeg produced no output within ${timeout}s — check the stream URL`));
|
|
398
|
+
}, timeout * 1e3);
|
|
399
399
|
proc.stderr?.on("data", (chunk) => {
|
|
400
400
|
if (firstDataTimer) {
|
|
401
401
|
clearTimeout(firstDataTimer);
|
|
@@ -620,11 +620,11 @@ const DEFAULT_OPTIONS = {
|
|
|
620
620
|
bitrate: null,
|
|
621
621
|
maxDuration: Infinity,
|
|
622
622
|
maxSegmentDuration: Infinity,
|
|
623
|
-
checkInterval:
|
|
623
|
+
checkInterval: 180,
|
|
624
624
|
proxyUrl: null,
|
|
625
625
|
cookieJar: null,
|
|
626
626
|
browser: "chrome",
|
|
627
|
-
timeout:
|
|
627
|
+
timeout: 30,
|
|
628
628
|
headers: {},
|
|
629
629
|
onProgress: null,
|
|
630
630
|
onStart: null,
|
|
@@ -808,6 +808,11 @@ var TikTokLiveDownloader = class {
|
|
|
808
808
|
this.emit("complete", finalResults);
|
|
809
809
|
return finalResults[finalResults.length - 1];
|
|
810
810
|
} catch (err) {
|
|
811
|
+
if (err instanceof StreamFetchError) this.emit("waiting", {
|
|
812
|
+
username: this.username,
|
|
813
|
+
phase: "stream",
|
|
814
|
+
elapsed: 0
|
|
815
|
+
});
|
|
811
816
|
if (pendingRemuxes.length > 0) await Promise.allSettled(pendingRemuxes);
|
|
812
817
|
this.setState("done");
|
|
813
818
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
@@ -834,12 +839,18 @@ var TikTokLiveDownloader = class {
|
|
|
834
839
|
}
|
|
835
840
|
async resolveRoomIdWithRetry() {
|
|
836
841
|
const interval = this.options.checkInterval;
|
|
837
|
-
const maxInterval = Math.max(interval,
|
|
842
|
+
const maxInterval = Math.max(interval, 180);
|
|
843
|
+
const waitStart = Date.now();
|
|
838
844
|
while (true) {
|
|
839
845
|
this.abortController.signal?.throwIfAborted();
|
|
840
846
|
const roomId = await this.resolveRoomIdOnce();
|
|
841
847
|
if (roomId) return roomId;
|
|
842
|
-
|
|
848
|
+
this.emit("waiting", {
|
|
849
|
+
username: this.username,
|
|
850
|
+
phase: "room",
|
|
851
|
+
elapsed: (Date.now() - waitStart) / 1e3
|
|
852
|
+
});
|
|
853
|
+
await sleep(maxInterval * 1e3);
|
|
843
854
|
}
|
|
844
855
|
}
|
|
845
856
|
async fetchStreamInfo(roomId) {
|
|
@@ -858,14 +869,20 @@ var TikTokLiveDownloader = class {
|
|
|
858
869
|
*/
|
|
859
870
|
async pollStreamInfo(roomId) {
|
|
860
871
|
const interval = this.options.checkInterval;
|
|
861
|
-
const maxInterval = Math.max(interval,
|
|
872
|
+
const maxInterval = Math.max(interval, 180);
|
|
873
|
+
const waitStart = Date.now();
|
|
862
874
|
while (true) {
|
|
863
875
|
this.abortController.signal?.throwIfAborted();
|
|
864
876
|
try {
|
|
865
877
|
return await this.fetchStreamInfo(roomId);
|
|
866
878
|
} catch (err) {
|
|
867
879
|
if (err instanceof StreamFetchError) {
|
|
868
|
-
|
|
880
|
+
this.emit("waiting", {
|
|
881
|
+
username: this.username,
|
|
882
|
+
phase: "stream",
|
|
883
|
+
elapsed: (Date.now() - waitStart) / 1e3
|
|
884
|
+
});
|
|
885
|
+
await sleep(maxInterval * 1e3);
|
|
869
886
|
continue;
|
|
870
887
|
}
|
|
871
888
|
throw err;
|
|
@@ -964,12 +981,23 @@ var TikTokLiveDownloader = class {
|
|
|
964
981
|
const finalExt = targetFormat === "mkv" ? "mkv" : "mp4";
|
|
965
982
|
const finalPath = inputPath.replace(/\.ts$/, `.${finalExt}`);
|
|
966
983
|
if (finalPath === inputPath) return tsResult;
|
|
984
|
+
this.emit("remux", {
|
|
985
|
+
filePath: inputPath,
|
|
986
|
+
inputSizeMB: tsResult.sizeMB,
|
|
987
|
+
status: "started"
|
|
988
|
+
});
|
|
967
989
|
try {
|
|
968
990
|
await this.remuxAndNormalize(this.options.ffmpegPath, inputPath, finalPath);
|
|
969
991
|
try {
|
|
970
992
|
const { unlinkSync } = await import("node:fs");
|
|
971
993
|
unlinkSync(inputPath);
|
|
972
994
|
} catch {}
|
|
995
|
+
this.emit("remux", {
|
|
996
|
+
filePath: inputPath,
|
|
997
|
+
outputPath: finalPath,
|
|
998
|
+
inputSizeMB: tsResult.sizeMB,
|
|
999
|
+
status: "completed"
|
|
1000
|
+
});
|
|
973
1001
|
return {
|
|
974
1002
|
...tsResult,
|
|
975
1003
|
filePath: finalPath,
|
|
@@ -978,6 +1006,11 @@ var TikTokLiveDownloader = class {
|
|
|
978
1006
|
} catch (err) {
|
|
979
1007
|
const msg = err instanceof Error ? err.message : String(err);
|
|
980
1008
|
console.warn(`tokwatchr: remux failed for ${inputPath}, keeping .ts as fallback: ${msg}`);
|
|
1009
|
+
this.emit("remux", {
|
|
1010
|
+
filePath: inputPath,
|
|
1011
|
+
inputSizeMB: tsResult.sizeMB,
|
|
1012
|
+
status: "failed"
|
|
1013
|
+
});
|
|
981
1014
|
return tsResult;
|
|
982
1015
|
}
|
|
983
1016
|
}
|
package/package.json
CHANGED
|
@@ -36,11 +36,11 @@ const DEFAULT_OPTIONS: ResolvedOptions = {
|
|
|
36
36
|
bitrate: null,
|
|
37
37
|
maxDuration: Infinity,
|
|
38
38
|
maxSegmentDuration: Infinity,
|
|
39
|
-
checkInterval:
|
|
39
|
+
checkInterval: 180,
|
|
40
40
|
proxyUrl: null,
|
|
41
41
|
cookieJar: null,
|
|
42
42
|
browser: "chrome",
|
|
43
|
-
timeout:
|
|
43
|
+
timeout: 30,
|
|
44
44
|
headers: {},
|
|
45
45
|
onProgress: null,
|
|
46
46
|
onStart: null,
|
|
@@ -320,6 +320,16 @@ export class TikTokLiveDownloader {
|
|
|
320
320
|
// biome-ignore lint/style/noNonNullAssertion: at least one result
|
|
321
321
|
return finalResults[finalResults.length - 1]!;
|
|
322
322
|
} catch (err) {
|
|
323
|
+
// One-shot waiting feedback for startRecording() users
|
|
324
|
+
// when the stream isn't active yet.
|
|
325
|
+
if (err instanceof StreamFetchError) {
|
|
326
|
+
this.emit("waiting", {
|
|
327
|
+
username: this.username,
|
|
328
|
+
phase: "stream",
|
|
329
|
+
elapsed: 0,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
323
333
|
// Await any background remuxes before shutting down,
|
|
324
334
|
// so they can finish producing .mp4 and delete temp .ts files.
|
|
325
335
|
if (pendingRemuxes.length > 0) {
|
|
@@ -367,7 +377,8 @@ export class TikTokLiveDownloader {
|
|
|
367
377
|
|
|
368
378
|
private async resolveRoomIdWithRetry(): Promise<string> {
|
|
369
379
|
const interval = this.options.checkInterval;
|
|
370
|
-
const maxInterval = Math.max(interval,
|
|
380
|
+
const maxInterval = Math.max(interval, 180);
|
|
381
|
+
const waitStart = Date.now();
|
|
371
382
|
|
|
372
383
|
while (true) {
|
|
373
384
|
this.abortController.signal?.throwIfAborted();
|
|
@@ -377,7 +388,12 @@ export class TikTokLiveDownloader {
|
|
|
377
388
|
return roomId;
|
|
378
389
|
}
|
|
379
390
|
|
|
380
|
-
|
|
391
|
+
this.emit("waiting", {
|
|
392
|
+
username: this.username,
|
|
393
|
+
phase: "room",
|
|
394
|
+
elapsed: (Date.now() - waitStart) / 1_000,
|
|
395
|
+
});
|
|
396
|
+
await sleep(maxInterval * 1_000);
|
|
381
397
|
}
|
|
382
398
|
}
|
|
383
399
|
|
|
@@ -400,7 +416,8 @@ export class TikTokLiveDownloader {
|
|
|
400
416
|
*/
|
|
401
417
|
private async pollStreamInfo(roomId: string): Promise<StreamInfo> {
|
|
402
418
|
const interval = this.options.checkInterval;
|
|
403
|
-
const maxInterval = Math.max(interval,
|
|
419
|
+
const maxInterval = Math.max(interval, 180);
|
|
420
|
+
const waitStart = Date.now();
|
|
404
421
|
|
|
405
422
|
while (true) {
|
|
406
423
|
this.abortController.signal?.throwIfAborted();
|
|
@@ -409,7 +426,12 @@ export class TikTokLiveDownloader {
|
|
|
409
426
|
return await this.fetchStreamInfo(roomId);
|
|
410
427
|
} catch (err) {
|
|
411
428
|
if (err instanceof StreamFetchError) {
|
|
412
|
-
|
|
429
|
+
this.emit("waiting", {
|
|
430
|
+
username: this.username,
|
|
431
|
+
phase: "stream",
|
|
432
|
+
elapsed: (Date.now() - waitStart) / 1_000,
|
|
433
|
+
});
|
|
434
|
+
await sleep(maxInterval * 1_000);
|
|
413
435
|
continue;
|
|
414
436
|
}
|
|
415
437
|
throw err;
|
|
@@ -550,6 +572,12 @@ export class TikTokLiveDownloader {
|
|
|
550
572
|
|
|
551
573
|
if (finalPath === inputPath) return tsResult;
|
|
552
574
|
|
|
575
|
+
this.emit("remux", {
|
|
576
|
+
filePath: inputPath,
|
|
577
|
+
inputSizeMB: tsResult.sizeMB,
|
|
578
|
+
status: "started",
|
|
579
|
+
});
|
|
580
|
+
|
|
553
581
|
try {
|
|
554
582
|
await this.remuxAndNormalize(
|
|
555
583
|
this.options.ffmpegPath,
|
|
@@ -563,6 +591,14 @@ export class TikTokLiveDownloader {
|
|
|
563
591
|
} catch {
|
|
564
592
|
// ignore cleanup failure
|
|
565
593
|
}
|
|
594
|
+
|
|
595
|
+
this.emit("remux", {
|
|
596
|
+
filePath: inputPath,
|
|
597
|
+
outputPath: finalPath,
|
|
598
|
+
inputSizeMB: tsResult.sizeMB,
|
|
599
|
+
status: "completed",
|
|
600
|
+
});
|
|
601
|
+
|
|
566
602
|
return {
|
|
567
603
|
...tsResult,
|
|
568
604
|
filePath: finalPath,
|
|
@@ -573,6 +609,13 @@ export class TikTokLiveDownloader {
|
|
|
573
609
|
console.warn(
|
|
574
610
|
`tokwatchr: remux failed for ${inputPath}, keeping .ts as fallback: ${msg}`,
|
|
575
611
|
);
|
|
612
|
+
|
|
613
|
+
this.emit("remux", {
|
|
614
|
+
filePath: inputPath,
|
|
615
|
+
inputSizeMB: tsResult.sizeMB,
|
|
616
|
+
status: "failed",
|
|
617
|
+
});
|
|
618
|
+
|
|
576
619
|
return tsResult;
|
|
577
620
|
}
|
|
578
621
|
}
|
package/src/download/ffmpeg.ts
CHANGED
|
@@ -13,10 +13,10 @@ export interface FfmpegDownloadOptions {
|
|
|
13
13
|
onProgress?: (stats: DownloadStats) => void;
|
|
14
14
|
maxDuration?: number;
|
|
15
15
|
/**
|
|
16
|
-
* Startup timeout in
|
|
16
|
+
* Startup timeout in seconds. If ffmpeg produces no stderr output within
|
|
17
17
|
* this window, it is killed and the promise rejects. Prevents
|
|
18
18
|
* indefinite hangs on bad/ stalled stream URLs.
|
|
19
|
-
* @default
|
|
19
|
+
* @default 30
|
|
20
20
|
*/
|
|
21
21
|
timeout?: number;
|
|
22
22
|
}
|
|
@@ -89,7 +89,7 @@ export async function downloadWithFfmpeg(
|
|
|
89
89
|
signal,
|
|
90
90
|
onProgress,
|
|
91
91
|
maxDuration,
|
|
92
|
-
timeout =
|
|
92
|
+
timeout = 30,
|
|
93
93
|
} = options;
|
|
94
94
|
|
|
95
95
|
const ffmpegArgs: string[] = [
|
|
@@ -123,7 +123,7 @@ export async function downloadWithFfmpeg(
|
|
|
123
123
|
let duration = 0;
|
|
124
124
|
|
|
125
125
|
// Startup timeout — if ffmpeg produces no stderr output within
|
|
126
|
-
// `timeout`
|
|
126
|
+
// `timeout` seconds, it likely stalled on a bad URL. Kill it so the
|
|
127
127
|
// caller doesn't hang indefinitely.
|
|
128
128
|
let firstDataTimer: ReturnType<typeof setTimeout> | null = setTimeout(
|
|
129
129
|
() => {
|
|
@@ -131,11 +131,11 @@ export async function downloadWithFfmpeg(
|
|
|
131
131
|
proc.kill("SIGTERM");
|
|
132
132
|
reject(
|
|
133
133
|
new FfmpegError(
|
|
134
|
-
`ffmpeg produced no output within ${timeout}
|
|
134
|
+
`ffmpeg produced no output within ${timeout}s — check the stream URL`,
|
|
135
135
|
),
|
|
136
136
|
);
|
|
137
137
|
},
|
|
138
|
-
timeout,
|
|
138
|
+
timeout * 1_000,
|
|
139
139
|
);
|
|
140
140
|
|
|
141
141
|
// Parse stderr for progress
|
package/src/index.ts
CHANGED
|
@@ -28,10 +28,12 @@ export type {
|
|
|
28
28
|
DownloadStats,
|
|
29
29
|
OutputFormat,
|
|
30
30
|
QualityOption,
|
|
31
|
+
RemuxInfo,
|
|
31
32
|
StreamInfo,
|
|
32
33
|
StreamQualityKey,
|
|
33
34
|
TikTokLiveDownloaderEvents,
|
|
34
35
|
TikTokLiveDownloaderOptions,
|
|
36
|
+
WaitingInfo,
|
|
35
37
|
} from "./types.js";
|
|
36
38
|
|
|
37
39
|
// ─── Quality utilities ────────────────────────────────────
|
package/src/types.ts
CHANGED
|
@@ -85,7 +85,7 @@ export interface TikTokLiveDownloaderOptions {
|
|
|
85
85
|
maxDuration?: number;
|
|
86
86
|
/** Split recording into segments of this many seconds (default: Infinity = single file). */
|
|
87
87
|
maxSegmentDuration?: number;
|
|
88
|
-
/** Polling interval in
|
|
88
|
+
/** Polling interval in seconds when waiting for live (default: 180). */
|
|
89
89
|
checkInterval?: number;
|
|
90
90
|
/** HTTP proxy URL (supports http, https, socks4, socks5). */
|
|
91
91
|
proxyUrl?: string;
|
|
@@ -93,7 +93,7 @@ export interface TikTokLiveDownloaderOptions {
|
|
|
93
93
|
cookieJar?: CookieJarLike;
|
|
94
94
|
/** Browser to emulate (default: "chrome"). */
|
|
95
95
|
browser?: Browser;
|
|
96
|
-
/** Request timeout in
|
|
96
|
+
/** Request timeout in seconds (default: 30). */
|
|
97
97
|
timeout?: number;
|
|
98
98
|
/** Extra headers to send with every request. */
|
|
99
99
|
headers?: Record<string, string>;
|
|
@@ -114,7 +114,31 @@ export interface CookieJarLike {
|
|
|
114
114
|
|
|
115
115
|
// ─── Events ───────────────────────────────────────────────
|
|
116
116
|
|
|
117
|
+
export interface WaitingInfo {
|
|
118
|
+
/** The username being waited on. */
|
|
119
|
+
username: string;
|
|
120
|
+
/** Which phase we're waiting in: "room" = looking for a room, "stream" = stream not active. */
|
|
121
|
+
phase: "room" | "stream";
|
|
122
|
+
/** Seconds elapsed since this phase started. */
|
|
123
|
+
elapsed: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface RemuxInfo {
|
|
127
|
+
/** Input .ts file path. */
|
|
128
|
+
filePath: string;
|
|
129
|
+
/** Input file size in MB. */
|
|
130
|
+
inputSizeMB: number;
|
|
131
|
+
/** Remux status: started → completed (with outputPath) or failed (keeping .ts). */
|
|
132
|
+
status: "started" | "completed" | "failed";
|
|
133
|
+
/** Output file path. Only set when status is "completed". */
|
|
134
|
+
outputPath?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
117
137
|
export interface TikTokLiveDownloaderEvents {
|
|
138
|
+
/** Emitted periodically while waiting for a room or stream to become active. */
|
|
139
|
+
waiting: [info: WaitingInfo];
|
|
140
|
+
/** Emitted when a remux operation starts, completes, or fails. */
|
|
141
|
+
remux: [info: RemuxInfo];
|
|
118
142
|
/** Emitted when a live stream is detected and we're about to start recording. */
|
|
119
143
|
start: [info: StreamInfo];
|
|
120
144
|
/** Emitted periodically (every ~1s) during recording with current stats. */
|