@stephane-klein/youtube-to-markdown 0.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.
@@ -0,0 +1,176 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { stringify } from "yaml";
3
+ import { runYtDlp } from "./ytdlp.js";
4
+
5
+ const DATE_CHUNK = 50;
6
+
7
+ const toIso = (raw) =>
8
+ /^\d{8}$/.test(raw)
9
+ ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
10
+ : "";
11
+
12
+ async function ytDlp(args, { allowFailure = false } = {}) {
13
+ const { stdout } = await runYtDlp(args, { stderr: "inherit", allowFailure });
14
+ return stdout;
15
+ }
16
+
17
+ async function uploadsPlaylistUrl(channelUrl) {
18
+ const out = await ytDlp([
19
+ "--flat-playlist",
20
+ "--playlist-end",
21
+ "1",
22
+ "-O",
23
+ "%(playlist_channel_id)s",
24
+ channelUrl,
25
+ ]);
26
+ const channelId = out.split("\n").map((line) => line.trim()).filter(Boolean)[0];
27
+
28
+ if (!channelId?.startsWith("UC")) {
29
+ throw new Error(`ID de chaîne introuvable pour ${channelUrl}`);
30
+ }
31
+
32
+ return `https://www.youtube.com/playlist?list=UU${channelId.slice(2)}`;
33
+ }
34
+
35
+ async function listEntries(playlistUrl, lang) {
36
+ const args = [
37
+ "--flat-playlist",
38
+ "-O",
39
+ '{"titre": %(title)j, "url": %(webpage_url)j}',
40
+ "--extractor-args",
41
+ `youtube:lang=${lang}`,
42
+ ];
43
+ args.push(playlistUrl);
44
+
45
+ const out = await ytDlp(args);
46
+ return out
47
+ .split("\n")
48
+ .filter(Boolean)
49
+ .map((line) => JSON.parse(line));
50
+ }
51
+
52
+ const videoId = (url) => /[?&]v=([\w-]+)/.exec(url)?.[1] ?? url;
53
+
54
+ async function fetchExactDates(urls) {
55
+ const dates = new Map();
56
+
57
+ for (let i = 0; i < urls.length; i += DATE_CHUNK) {
58
+ const chunk = urls.slice(i, i + DATE_CHUNK);
59
+ console.error(
60
+ ` exact dates ${Math.min(i + DATE_CHUNK, urls.length)}/${urls.length}`,
61
+ );
62
+ const out = await ytDlp(
63
+ [
64
+ "--skip-download",
65
+ "--no-playlist",
66
+ "--ignore-errors",
67
+ "--print",
68
+ "%(id)s|%(upload_date)s",
69
+ ...chunk,
70
+ ],
71
+ { allowFailure: true },
72
+ );
73
+
74
+ for (const line of out.split("\n").filter(Boolean)) {
75
+ const [id, raw] = line.split("|");
76
+ const iso = toIso(raw ?? "");
77
+ if (id && iso) dates.set(id, iso);
78
+ }
79
+ }
80
+
81
+ return dates;
82
+ }
83
+
84
+ async function fetchVideos(channelUrl, knownDates) {
85
+ const playlistUrl = await uploadsPlaylistUrl(channelUrl);
86
+ const en = await listEntries(playlistUrl, "en");
87
+ const fr = await listEntries(playlistUrl, "fr");
88
+ const frTitles = new Map(fr.map((entry) => [entry.url, entry.titre]));
89
+
90
+ const videos = en
91
+ .filter((entry) => !entry.url.includes("/shorts/"))
92
+ .map((entry) => {
93
+ const enTitle = entry.titre.trim();
94
+ const frTitle = (frTitles.get(entry.url) ?? enTitle).trim();
95
+ const title =
96
+ frTitle && enTitle && frTitle !== enTitle
97
+ ? { fr: frTitle, en: enTitle }
98
+ : { fr: frTitle || enTitle };
99
+ return { title, url: entry.url };
100
+ });
101
+
102
+ const missing = videos
103
+ .filter((video) => !knownDates.has(videoId(video.url)))
104
+ .map((video) => video.url);
105
+ const fetched = await fetchExactDates(missing);
106
+
107
+ for (const video of videos) {
108
+ const id = videoId(video.url);
109
+ video.date = knownDates.get(id) ?? fetched.get(id) ?? "";
110
+ }
111
+
112
+ return videos
113
+ .map((video) => ({ title: video.title, date: video.date, url: video.url }))
114
+ .reverse();
115
+ }
116
+
117
+ function normalizeEntry(entry) {
118
+ const { titre, ...rest } = entry;
119
+ if (typeof titre === "string") {
120
+ return { title: { fr: titre }, ...rest };
121
+ }
122
+ return entry;
123
+ }
124
+
125
+ const MANAGED_KEYS = new Set(["title", "date", "url", "titre"]);
126
+
127
+ function userFields(entry) {
128
+ return Object.fromEntries(
129
+ Object.entries(entry).filter(([key]) => !MANAGED_KEYS.has(key)),
130
+ );
131
+ }
132
+
133
+ function mergeVideos(existing, fetched) {
134
+ const existingByUrl = new Map(
135
+ existing.filter((v) => v.url).map((v) => [v.url, v]),
136
+ );
137
+ const merged = fetched.map((video) => {
138
+ const previous = existingByUrl.get(video.url);
139
+ return previous
140
+ ? { ...video, ...userFields(previous), date: video.date || previous.date }
141
+ : video;
142
+ });
143
+ const fetchedUrls = new Set(fetched.map((v) => v.url));
144
+ const extras = existing
145
+ .filter((v) => v.url && !fetchedUrls.has(v.url))
146
+ .map(normalizeEntry);
147
+ return [...merged, ...extras].sort((a, b) =>
148
+ (a.date ?? "").localeCompare(b.date ?? ""),
149
+ );
150
+ }
151
+
152
+ export async function runExtract({
153
+ path = "youtube_to_markdown.yaml",
154
+ config = {},
155
+ forceDates = false,
156
+ } = {}) {
157
+ for (const channel of config.feed ?? []) {
158
+ if (typeof channel.url !== "string" || !channel.url) continue;
159
+
160
+ const videos = channel.videos ?? [];
161
+ const knownDates = forceDates
162
+ ? new Map()
163
+ : new Map(
164
+ videos
165
+ .filter((video) => video.url && video.date)
166
+ .map((video) => [videoId(video.url), video.date]),
167
+ );
168
+ console.error(`→ ${channel.url}`);
169
+ channel.videos = mergeVideos(
170
+ videos,
171
+ await fetchVideos(channel.url, knownDates),
172
+ );
173
+ }
174
+
175
+ await writeFile(path, stringify(config, { lineWidth: 0 }));
176
+ }
@@ -0,0 +1,475 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, relative } from "node:path";
3
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
4
+ import { APICallError, generateText } from "ai";
5
+ import { Listr, ListrLogger, ProcessOutput } from "listr2";
6
+ import { stringify } from "yaml";
7
+ import {
8
+ DEFAULT_DIR,
9
+ downloadVtt,
10
+ exists,
11
+ titleForLang,
12
+ videoJobs,
13
+ writeMarker,
14
+ } from "./vtt.js";
15
+
16
+ const SYSTEM = [
17
+ "You convert a YouTube video transcript into clean, readable Markdown prose.",
18
+ "- Write in the same language as the transcript.",
19
+ "- Do not summarize and do not add facts that are not in the transcript.",
20
+ "- Merge the fragmented cues into full sentences and paragraphs.",
21
+ "- You may add section headings (##, ###) when the transcript clearly moves between topics.",
22
+ "- Do not add a main title, it is provided separately.",
23
+ "- Do not wrap the output in code fences.",
24
+ "- Output only the Markdown body.",
25
+ ].join("\n");
26
+
27
+ const WIDTH = 80;
28
+
29
+ const PRICES = {
30
+ "mimo-v2.5": { input: 0.14, output: 0.28, cachedInput: 0.0028 }
31
+ };
32
+
33
+ function cachedInputTokens(usage) {
34
+ const details = usage?.inputTokenDetails ?? {};
35
+ return details.cacheReadTokens ?? usage?.cachedInputTokens ?? 0;
36
+ }
37
+
38
+ function estimateCost(modelId, usage) {
39
+ const price = PRICES[modelId];
40
+ if (!price || !usage) return null;
41
+
42
+ const details = usage.inputTokenDetails ?? {};
43
+ const cached = cachedInputTokens(usage);
44
+ const input =
45
+ details.noCacheTokens ?? Math.max((usage.inputTokens ?? 0) - cached, 0);
46
+
47
+ return (
48
+ (input / 1e6) * price.input +
49
+ (cached / 1e6) * (price.cachedInput ?? 0) +
50
+ ((usage.outputTokens ?? 0) / 1e6) * price.output
51
+ );
52
+ }
53
+
54
+ const OUTPUT_FACTOR = 2;
55
+ const OUTPUT_FLOOR = 2048;
56
+ const OUTPUT_CEILING = 32768;
57
+
58
+ function outputBudget(transcript, override) {
59
+ if (override !== undefined && override !== null && override !== "") {
60
+ return Number(override);
61
+ }
62
+
63
+ const inputTokens = Math.ceil(transcript.length / 3.5);
64
+ return Math.min(
65
+ OUTPUT_CEILING,
66
+ Math.max(OUTPUT_FLOOR, Math.ceil(inputTokens * OUTPUT_FACTOR)),
67
+ );
68
+ }
69
+
70
+ function metricsLine(modelId, usage, elapsed, cost) {
71
+ return [
72
+ modelId,
73
+ `llm ${elapsed.toFixed(1)}s`,
74
+ usage
75
+ ? `${usage.inputTokens ?? 0} in / ${usage.outputTokens ?? 0} out`
76
+ : "usage ?",
77
+ cost === null ? null : `~$${cost.toFixed(6)}`,
78
+ ]
79
+ .filter(Boolean)
80
+ .join(" ");
81
+ }
82
+
83
+ function frontmatter(job, { modelId, usage, elapsed, cost, finishReason }) {
84
+ const data = {
85
+ source_url: job.url,
86
+ video_title: titleForLang(job.titles, job.lang),
87
+ generated_at: new Date().toISOString(),
88
+ llm: {
89
+ model: modelId,
90
+ duration_seconds: Number(elapsed.toFixed(1)),
91
+ input_tokens: usage?.inputTokens ?? null,
92
+ cached_input_tokens: usage ? cachedInputTokens(usage) : null,
93
+ output_tokens: usage?.outputTokens ?? null,
94
+ estimated_cost_usd: cost === null ? null : Number(cost.toFixed(6)),
95
+ finish_reason: finishReason,
96
+ },
97
+ };
98
+
99
+ return `---\n${stringify(data, { lineWidth: 0 }).trimEnd()}\n---\n\n`;
100
+ }
101
+
102
+ const isHeading = (text) => /^#{1,6}\s/.test(text);
103
+
104
+ const isStructured = (text) =>
105
+ text
106
+ .split("\n")
107
+ .some((line) => /^\s*(-|\*|\d+\.)\s/.test(line) || /^\s*\|/.test(line));
108
+
109
+ function wrapText(text) {
110
+ const words = text.split(/\s+/).filter(Boolean);
111
+ const lines = [];
112
+ let line = "";
113
+
114
+ for (const word of words) {
115
+ if (!line) {
116
+ line = word;
117
+ } else if (line.length + 1 + word.length <= WIDTH) {
118
+ line += ` ${word}`;
119
+ } else {
120
+ lines.push(line);
121
+ line = word;
122
+ }
123
+ }
124
+
125
+ if (line) lines.push(line);
126
+ return lines.join("\n");
127
+ }
128
+
129
+ function hardWrap(body) {
130
+ return body
131
+ .split(/\n{2,}/)
132
+ .map((block) => {
133
+ const text = block.trim();
134
+ if (!text) return "";
135
+ if (isHeading(text) || isStructured(text)) return text;
136
+ return wrapText(text.replace(/\s*\n\s*/g, " "));
137
+ })
138
+ .join("\n\n");
139
+ }
140
+
141
+ function vttToText(vtt) {
142
+ const lines = vtt
143
+ .split(/\r?\n/)
144
+ .map((line) => line.replace(/<[^>]*>/g, "").trim())
145
+ .filter(
146
+ (line) =>
147
+ line &&
148
+ !line.startsWith("WEBVTT") &&
149
+ !line.startsWith("Kind:") &&
150
+ !line.startsWith("Language:") &&
151
+ !line.includes("-->") &&
152
+ !/^\d+$/.test(line),
153
+ );
154
+
155
+ const out = [];
156
+ for (const line of lines) {
157
+ if (out[out.length - 1] !== line) out.push(line);
158
+ }
159
+ return out.join("\n");
160
+ }
161
+
162
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
163
+
164
+ function isRetryable(error) {
165
+ return APICallError.isInstance(error) && error.isRetryable;
166
+ }
167
+
168
+ function retryDelayMs(error, fallbackMs) {
169
+ const headers = APICallError.isInstance(error)
170
+ ? error.responseHeaders
171
+ : undefined;
172
+ let headerMs;
173
+ if (headers?.["retry-after-ms"] !== undefined) {
174
+ headerMs = Number.parseFloat(headers["retry-after-ms"]);
175
+ } else if (headers?.["retry-after"] !== undefined) {
176
+ const seconds = Number.parseFloat(headers["retry-after"]);
177
+ headerMs = Number.isNaN(seconds)
178
+ ? Date.parse(headers["retry-after"]) - Date.now()
179
+ : seconds * 1000;
180
+ }
181
+
182
+ if (!Number.isFinite(headerMs) || headerMs <= 0) return fallbackMs;
183
+ return Math.max(headerMs, fallbackMs);
184
+ }
185
+
186
+ function describeError(error, attempts) {
187
+ const suffix = ` after ${attempts} attempt(s)`;
188
+ if (!APICallError.isInstance(error)) return `${error.message}${suffix}`;
189
+
190
+ const status = error.statusCode ? `HTTP ${error.statusCode}` : "API error";
191
+ const body = error.responseBody?.trim().replace(/\s+/g, " ").slice(0, 200);
192
+ return `${status} ${error.message}${suffix}${body ? `: ${body}` : ""}`;
193
+ }
194
+
195
+ function parseRetryDelays(value) {
196
+ return `${value}`
197
+ .split(",")
198
+ .map((item) => item.trim())
199
+ .filter(Boolean)
200
+ .map((item) => Number(item) * 1000)
201
+ .filter((item) => Number.isFinite(item) && item >= 0);
202
+ }
203
+
204
+ async function toMarkdown(
205
+ { model, retryDelays, maxOutputTokens },
206
+ title,
207
+ transcript,
208
+ onRetry,
209
+ ) {
210
+ const totalAttempts = retryDelays.length + 1;
211
+
212
+ for (let attempt = 1;; attempt++) {
213
+ const started = performance.now();
214
+ try {
215
+ const { text, usage, finishReason } = await generateText({
216
+ model,
217
+ system: SYSTEM,
218
+ prompt: `Title: ${title}\n\nTranscript:\n${transcript}`,
219
+ temperature: 0.2,
220
+ maxOutputTokens: outputBudget(transcript, maxOutputTokens),
221
+ maxRetries: 0,
222
+ });
223
+ const elapsed = (performance.now() - started) / 1000;
224
+
225
+ const body = text
226
+ .trim()
227
+ .replace(/^```[a-z]*\n?/, "")
228
+ .replace(/\n?```$/, "")
229
+ .trim();
230
+
231
+ return { body, usage, finishReason, elapsed };
232
+ } catch (error) {
233
+ if (!isRetryable(error) || attempt > retryDelays.length) {
234
+ throw new Error(describeError(error, attempt), { cause: error });
235
+ }
236
+ const waitMs = retryDelayMs(error, retryDelays[attempt - 1]);
237
+ onRetry?.(attempt + 1, totalAttempts, waitMs);
238
+ await delay(waitMs);
239
+ }
240
+ }
241
+ }
242
+
243
+ async function forEachConcurrent(items, limit, worker) {
244
+ let next = 0;
245
+ await Promise.all(
246
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
247
+ while (next < items.length) {
248
+ const item = items[next++];
249
+ await worker(item);
250
+ }
251
+ }),
252
+ );
253
+ }
254
+
255
+ export async function runGenerateMarkdown({
256
+ feed = [],
257
+ model: modelId,
258
+ endpoint,
259
+ apiKey,
260
+ session,
261
+ concurrency = 6,
262
+ ytdlpConcurrency = 2,
263
+ retryDelays = "10,30,60",
264
+ maxOutputTokens,
265
+ force = false,
266
+ dir = DEFAULT_DIR,
267
+ } = {}) {
268
+ const key = apiKey ?? process.env.OPENAI_API_KEY;
269
+ if (!key) throw new Error("api key is not set (--api-key or OPENAI_API_KEY)");
270
+ if (!modelId) throw new Error("model is not set (--model)");
271
+ if (!endpoint) throw new Error("endpoint is not set (--endpoint)");
272
+
273
+ const RETRY_DELAYS = parseRetryDelays(retryDelays);
274
+ const llmConcurrency = Number(concurrency);
275
+ const vttConcurrency = Number(ytdlpConcurrency);
276
+
277
+ const provider = createOpenAICompatible({
278
+ name: "opencode",
279
+ apiKey: key,
280
+ baseURL: endpoint.replace(/\/chat\/completions\/?$/, ""),
281
+ headers: session ? { "X-OpenCode-Session": session } : {},
282
+ });
283
+
284
+ const model = provider(modelId);
285
+
286
+ await mkdir(dir, { recursive: true });
287
+
288
+ const jobs = feed.flatMap((channel) =>
289
+ (channel.videos ?? [])
290
+ .filter((video) => video.generate_markdown)
291
+ .flatMap((video) => videoJobs(video, dir, channel.folder_slug)),
292
+ );
293
+
294
+ console.error(`${jobs.length} markdown(s) to generate with ${modelId}`);
295
+
296
+ const stats = {
297
+ generated: 0,
298
+ already: 0,
299
+ skipped: 0,
300
+ errors: 0,
301
+ totalSeconds: 0,
302
+ totalInput: 0,
303
+ totalOutput: 0,
304
+ totalCost: 0,
305
+ costKnown: true,
306
+ };
307
+
308
+ const rendererOptions = {
309
+ logger: new ListrLogger({
310
+ processOutput: new ProcessOutput(process.stderr, process.stderr),
311
+ }),
312
+ showErrorMessage: true,
313
+ collapseErrors: false,
314
+ collapseSkips: false,
315
+ icon: {
316
+ SKIPPED: "–",
317
+ SKIPPED_WITH_COLLAPSE: "–",
318
+ SKIPPED_WITHOUT_COLLAPSE: "–",
319
+ },
320
+ color: {
321
+ SKIPPED: (message) => message,
322
+ SKIPPED_WITH_COLLAPSE: (message) => message,
323
+ SKIPPED_WITHOUT_COLLAPSE: (message) => message,
324
+ },
325
+ };
326
+
327
+ const phaseOptions = { outputBar: 20, persistentOutput: true };
328
+
329
+ const pending = [];
330
+ for (const job of jobs) {
331
+ if (!force && (await exists(`${job.base}.md`))) stats.already++;
332
+ else pending.push(job);
333
+ }
334
+
335
+ const downloadTask = {
336
+ title: "Download transcripts",
337
+ rendererOptions: phaseOptions,
338
+ task: async (_ctx, task) => {
339
+ const total = jobs.length;
340
+ if (total === 0) {
341
+ task.skip("no video");
342
+ return;
343
+ }
344
+
345
+ const pendingSet = new Set(pending);
346
+ let available = 0;
347
+ const items = [];
348
+ for (const job of jobs) {
349
+ if (await exists(job.vtt)) {
350
+ available++;
351
+ } else if (pendingSet.has(job)) {
352
+ items.push(job);
353
+ }
354
+ }
355
+ task.title = `Download transcripts (${available}/${total})`;
356
+
357
+ await forEachConcurrent(items, vttConcurrency, async (job) => {
358
+ const name = relative(dir, `${job.base}.md`);
359
+ try {
360
+ if (await exists(job.marker)) {
361
+ stats.skipped++;
362
+ task.output = `skipped (no transcript) ${name}`;
363
+ return;
364
+ }
365
+
366
+ await mkdir(dirname(job.vtt), { recursive: true });
367
+ const status = await downloadVtt(job.url, job.lang, job.vtt);
368
+ if (status === "missing") {
369
+ await writeMarker(job.marker, job.url);
370
+ stats.skipped++;
371
+ task.output = `skipped (no transcript) ${name}`;
372
+ } else if (status === "error") {
373
+ stats.errors++;
374
+ task.output = `error (download failed) ${name}`;
375
+ } else {
376
+ available++;
377
+ task.output = `downloaded ${name}`;
378
+ }
379
+ } catch (error) {
380
+ stats.errors++;
381
+ task.output = `error (${error.message}) ${name}`;
382
+ } finally {
383
+ task.title = `Download transcripts (${available}/${total})`;
384
+ }
385
+ });
386
+ },
387
+ };
388
+
389
+ const generateTask = {
390
+ title: "Generate markdown",
391
+ rendererOptions: phaseOptions,
392
+ task: async (_ctx, task) => {
393
+ const items = [];
394
+ for (const job of pending) {
395
+ if (await exists(job.vtt)) items.push(job);
396
+ }
397
+ if (items.length === 0) {
398
+ task.skip("nothing to generate");
399
+ return;
400
+ }
401
+
402
+ const already = stats.already > 0 ? `, ${stats.already} already` : "";
403
+ task.title =
404
+ `Generate markdown (${stats.already}/${jobs.length}${already})`;
405
+ await forEachConcurrent(items, llmConcurrency, async (job) => {
406
+ const md = `${job.base}.md`;
407
+ const name = relative(dir, md);
408
+ try {
409
+ const transcript = vttToText(await readFile(job.vtt, "utf8"));
410
+ const { body, usage, finishReason, elapsed } = await toMarkdown(
411
+ { model, retryDelays: RETRY_DELAYS, maxOutputTokens },
412
+ job.title,
413
+ transcript,
414
+ (nextAttempt, totalAttempts, waitMs) => {
415
+ task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
416
+ `(attempt ${nextAttempt}/${totalAttempts}) ${name}`;
417
+ },
418
+ );
419
+
420
+ stats.totalSeconds += elapsed;
421
+ stats.totalInput += usage?.inputTokens ?? 0;
422
+ stats.totalOutput += usage?.outputTokens ?? 0;
423
+ const cost = estimateCost(modelId, usage);
424
+ if (cost === null) stats.costKnown = false;
425
+ else stats.totalCost += cost;
426
+ const metrics = metricsLine(modelId, usage, elapsed, cost);
427
+
428
+ if (finishReason !== "stop") {
429
+ stats.errors++;
430
+ task.output = `error (finishReason=${finishReason}) ${name}`;
431
+ return;
432
+ }
433
+
434
+ const front = frontmatter(job, {
435
+ modelId,
436
+ usage,
437
+ elapsed,
438
+ cost,
439
+ finishReason,
440
+ });
441
+ await mkdir(dirname(md), { recursive: true });
442
+ await writeFile(
443
+ md,
444
+ `${front}# ${job.title}\n\n${hardWrap(body)}\n`,
445
+ );
446
+ stats.generated++;
447
+ task.output = `generated ${name} ${metrics}`;
448
+ } catch (error) {
449
+ stats.errors++;
450
+ task.output = `error (${error.message}) ${name}`;
451
+ } finally {
452
+ const upToDate = stats.already + stats.generated;
453
+ task.title = `Generate markdown (${upToDate}/${jobs.length}${already})`;
454
+ }
455
+ });
456
+ },
457
+ };
458
+
459
+ await new Listr([downloadTask, generateTask], {
460
+ exitOnError: false,
461
+ rendererOptions,
462
+ fallbackRenderer: "simple",
463
+ fallbackRendererOptions: rendererOptions,
464
+ }).run();
465
+
466
+ console.error(
467
+ `summary: ${stats.generated} generated, ${stats.already} already generated, ${stats.skipped} skipped, ${stats.errors} error`,
468
+ );
469
+ const seconds = stats.totalSeconds.toFixed(1);
470
+ console.error(
471
+ `total: ${seconds}s, ${stats.totalInput} in / ${stats.totalOutput} out${
472
+ stats.costKnown ? `, ~$${stats.totalCost.toFixed(6)}` : ""
473
+ }`,
474
+ );
475
+ }