@stephane-klein/youtube-to-markdown 0.4.0 → 0.9.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 +96 -10
- package/package.json +1 -1
- package/src/cli.js +22 -1
- package/src/config.js +3 -0
- package/src/doctor.js +14 -1
- package/src/extract-video-metadata.js +28 -3
- package/src/generate-markdown.js +236 -24
- package/src/progress.js +32 -0
package/README.md
CHANGED
|
@@ -13,6 +13,37 @@ channel or playlist and then to download its transcripts.
|
|
|
13
13
|
In the final step, I use an LLM through a standard OpenAI-compatible API to
|
|
14
14
|
reconstruct a complete text from the video transcripts.
|
|
15
15
|
|
|
16
|
+
## AI usage in this project
|
|
17
|
+
|
|
18
|
+
This project was developed using:
|
|
19
|
+
|
|
20
|
+
- Harness: [OpenCode](https://opencode.ai)
|
|
21
|
+
- Models: [DeepSeek-V4.1-Flash](https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash) via [OpenCode Go](https://opencode.ai/go)
|
|
22
|
+
- I mostly vibe coded this project: I made the implementation, library and architecture choices, but I reviewed the code very little.
|
|
23
|
+
|
|
24
|
+
The figures below cover the period from the start of the project to version 0.4.0
|
|
25
|
+
([de39801](https://github.com/stephane-klein/youtube-to-markdown/commit/de39801f123fa1cfaeaa3b9cfed5071968977474)).
|
|
26
|
+
|
|
27
|
+
Metrics:
|
|
28
|
+
|
|
29
|
+
- 16 OpenCode sessions, 1426 interactions;
|
|
30
|
+
- 11 h of human time on this project, against 4 h of AI generation time over the same period, so a human/AI time ratio of 2.75 (11 h / 4 h);
|
|
31
|
+
- Cost: $2.4958;
|
|
32
|
+
- Tokens:
|
|
33
|
+
- input: 7,047,916
|
|
34
|
+
- output: 421,770
|
|
35
|
+
- cache read: 228,841,600
|
|
36
|
+
- total: 236,311,286
|
|
37
|
+
|
|
38
|
+
Per agent:
|
|
39
|
+
|
|
40
|
+
| agent | interactions | share | output tokens | active hours | active share | generation hours | cost |
|
|
41
|
+
|:--------|-------------:|------:|--------------:|-------------:|-------------:|-----------------:|-------:|
|
|
42
|
+
| build | 962 | 68% | 259,706 | 3.71 | 38% | 1.93 | $1.385 |
|
|
43
|
+
| plan | 450 | 32% | 155,136 | 5.91 | 61% | 2.10 | $1.098 |
|
|
44
|
+
| explore | 14 | 1% | 6,928 | 0.02 | 0% | 0.02 | $0.013 |
|
|
45
|
+
|
|
46
|
+
|
|
16
47
|
## Requirements
|
|
17
48
|
|
|
18
49
|
- Node.js 22 or later;
|
|
@@ -129,7 +160,13 @@ can gate a CI job.
|
|
|
129
160
|
```sh
|
|
130
161
|
$ youtube-to-markdown extract-video-metadata
|
|
131
162
|
→ https://www.youtube.com/@le_science4all
|
|
163
|
+
exact dates 50/2217 2.4s elapsed ~1m 44s left
|
|
164
|
+
exact dates 100/2217 4.8s elapsed ~1m 42s left
|
|
165
|
+
exact dates 2217/2217 1m 46s elapsed
|
|
166
|
+
done in 1m 46s
|
|
132
167
|
→ https://www.youtube.com/@MonsieurPhi
|
|
168
|
+
done in 12.3s
|
|
169
|
+
summary: 2 sources, 2678 videos, 2m 05s elapsed
|
|
133
170
|
```
|
|
134
171
|
|
|
135
172
|
The command is idempotent: a new run only adds the missing videos.
|
|
@@ -147,8 +184,9 @@ variables, then the settings written in the YAML file, then the built-in
|
|
|
147
184
|
defaults. The API key stays a secret: `--api-key` or `OPENAI_API_KEY`. The
|
|
148
185
|
settings are `x_opencode_session`, `model_id`, `openaiapi_endpoint`,
|
|
149
186
|
`concurrency` and `ytdlp_concurrency` (plus the optional `api_key`,
|
|
150
|
-
`retry_delays`, `max_output_tokens` and `
|
|
151
|
-
source URLs, and the `extract-video-metadata` command fills the
|
|
187
|
+
`retry_delays`, `max_output_tokens`, `chunk_target_tokens` and `reasoning`). I
|
|
188
|
+
only set the source URLs, and the `extract-video-metadata` command fills the
|
|
189
|
+
rest:
|
|
152
190
|
|
|
153
191
|
`x_opencode_session` is the base of the `X-OpenCode-Session` header; it is
|
|
154
192
|
optional and specific to OpenCode. A stable per-video suffix is appended to it,
|
|
@@ -298,9 +336,49 @@ only the last 20 events on screen, so it stays bounded even with a large feed.
|
|
|
298
336
|
Retryable API errors (HTTP 408, 409, 429 or >= 500) are retried with growing
|
|
299
337
|
delays — 10 s, 30 s, 60 s by default, configurable with `--retry-delays`
|
|
300
338
|
(comma-separated seconds, or `YT_TO_MD_RETRY_DELAYS`). A `Retry-After` header
|
|
301
|
-
from the server is honored when longer than the schedule. Each retry
|
|
302
|
-
the
|
|
303
|
-
|
|
339
|
+
from the server is honored when longer than the schedule. Each retry line shows
|
|
340
|
+
the reason reported by the API (status and response body), for example:
|
|
341
|
+
|
|
342
|
+
```
|
|
343
|
+
retrying in 30s (attempt 3/4) HTTP 429 rate limited david-cayla/....fr.md
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
After the last attempt the file is reported as an error and is retried on the
|
|
347
|
+
next run. At the end of the run, a `failures:` block lists each failed file with
|
|
348
|
+
its reason, so the cause stays visible even after the phase output has scrolled.
|
|
349
|
+
The command exits with a non-zero status when at least one file failed.
|
|
350
|
+
|
|
351
|
+
### Empty output
|
|
352
|
+
|
|
353
|
+
A model can finish with `finish_reason: stop` but return no text at all, for
|
|
354
|
+
example when it spends its whole response on internal reasoning. Such a
|
|
355
|
+
generation is reported as an error and no file is written:
|
|
356
|
+
|
|
357
|
+
```
|
|
358
|
+
error (empty output: part 1/1, finish=stop, text_tokens=1, reasoning_tokens=4001) ....fr.md
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The `failures:` block repeats the reason at the end of the run. Add
|
|
362
|
+
`--debug-llm` (or `YT_TO_MD_DEBUG_LLM=1`) to log the request's `max_tokens` and
|
|
363
|
+
`reasoning_effort`, plus the size of the provider's `content` and
|
|
364
|
+
`reasoning_content`, which helps diagnose provider-side empty answers.
|
|
365
|
+
|
|
366
|
+
### Reasoning effort
|
|
367
|
+
|
|
368
|
+
The command asks the model for a reasoning effort of `low` by default. Set
|
|
369
|
+
`reasoning` in the YAML file, `YT_TO_MD_REASONING`, or `--reasoning` to change
|
|
370
|
+
it. The accepted values are `provider-default`, `none`, `minimal`, `low`,
|
|
371
|
+
`medium`, `high` and `xhigh`, as exposed by ai-sdk. `none` disables reasoning:
|
|
372
|
+
on a DeepSeek endpoint it sends the provider-specific `thinking: disabled`
|
|
373
|
+
option, elsewhere it sends `reasoning: none`. `provider-default` leaves the
|
|
374
|
+
choice to the provider.
|
|
375
|
+
|
|
376
|
+
```yaml
|
|
377
|
+
reasoning: "none"
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
The `doctor` command shows the effective value and applies it to its test
|
|
381
|
+
request, so it reflects the real configuration.
|
|
304
382
|
|
|
305
383
|
### Output budget and long transcripts
|
|
306
384
|
|
|
@@ -319,7 +397,8 @@ LLM call that rewrites the beginning of the next section so it flows from the
|
|
|
319
397
|
previous one. `--chunk-target-tokens` (or `YT_TO_MD_CHUNK_TARGET_TOKENS`,
|
|
320
398
|
default 8000) sets the target input size of each chunk. The generation of a
|
|
321
399
|
chunk is guaranteed to stop with `stop`; otherwise the file is reported as an
|
|
322
|
-
error and nothing is written. The
|
|
400
|
+
error and nothing is written. The same happens when a part comes back with an
|
|
401
|
+
empty body. The frontmatter records how many chunks were used.
|
|
323
402
|
|
|
324
403
|
### Generated frontmatter
|
|
325
404
|
|
|
@@ -337,8 +416,11 @@ llm:
|
|
|
337
416
|
input_tokens: 4428
|
|
338
417
|
cached_input_tokens: 0
|
|
339
418
|
output_tokens: 3262
|
|
419
|
+
reasoning_tokens: 0
|
|
420
|
+
text_tokens: 3262
|
|
340
421
|
estimated_cost_usd: 0.000927
|
|
341
422
|
finish_reason: stop
|
|
423
|
+
raw_finish_reason: stop
|
|
342
424
|
pricing:
|
|
343
425
|
source: models.dev
|
|
344
426
|
peak: null
|
|
@@ -348,10 +430,10 @@ llm:
|
|
|
348
430
|
`video_title` is the original video title in the file's language and
|
|
349
431
|
`generated_at` is the UTC time of the generation. `pricing.source` names the
|
|
350
432
|
catalog the rates came from and `pricing.peak` tells whether the peak rate
|
|
351
|
-
applied (`null` when the source has no peak/off-peak pricing).
|
|
352
|
-
are left untouched: run `youtube-to-markdown generate-markdown
|
|
353
|
-
regenerate every file, which is also the way to add the frontmatter
|
|
354
|
-
generated before it existed.
|
|
433
|
+
applied to the first call (`null` when the source has no peak/off-peak pricing).
|
|
434
|
+
Existing files are left untouched: run `youtube-to-markdown generate-markdown
|
|
435
|
+
--force` to regenerate every file, which is also the way to add the frontmatter
|
|
436
|
+
to files generated before it existed.
|
|
355
437
|
|
|
356
438
|
### Pricing
|
|
357
439
|
|
|
@@ -364,6 +446,10 @@ catalogs are cached for one day under `$XDG_CACHE_HOME/youtube-to-markdown`
|
|
|
364
446
|
(`~/.cache/youtube-to-markdown` by default). When a catalog or the model is
|
|
365
447
|
unavailable, `estimated_cost_usd` is `null` and the run continues.
|
|
366
448
|
|
|
449
|
+
The rate is sampled at the start of every LLM call, so a run that straddles a
|
|
450
|
+
peak/off-peak boundary is priced with the right rate on each side. The
|
|
451
|
+
`pricing` block in the frontmatter describes the first call of the file.
|
|
452
|
+
|
|
367
453
|
## Organize the files into folders
|
|
368
454
|
|
|
369
455
|
A channel may set `folder_slug` to keep its files in their own subdirectory of
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stephane-klein/youtube-to-markdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Collect YouTube channel videos into a YAML config with yt-dlp, download transcripts and turn them into Markdown prose with an LLM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ const SETTING_KEYS = [
|
|
|
24
24
|
"retryDelays",
|
|
25
25
|
"maxOutputTokens",
|
|
26
26
|
"chunkTargetTokens",
|
|
27
|
+
"reasoning",
|
|
27
28
|
];
|
|
28
29
|
|
|
29
30
|
const handle = (run) => async (argv) => {
|
|
@@ -121,6 +122,15 @@ const cli = yargs(hideBin(process.argv))
|
|
|
121
122
|
describe: "Target input tokens per chunk when a transcript is split (default: 8000)",
|
|
122
123
|
type: "number",
|
|
123
124
|
})
|
|
125
|
+
.option("reasoning", {
|
|
126
|
+
describe: "Reasoning effort: provider-default, none, minimal, low, medium, high or xhigh (default: low)",
|
|
127
|
+
type: "string",
|
|
128
|
+
})
|
|
129
|
+
.option("debug-llm", {
|
|
130
|
+
describe: "Print LLM request and response diagnostics",
|
|
131
|
+
type: "boolean",
|
|
132
|
+
default: false,
|
|
133
|
+
})
|
|
124
134
|
.option("force", {
|
|
125
135
|
describe: "Regenerate every Markdown file",
|
|
126
136
|
type: "boolean",
|
|
@@ -128,7 +138,13 @@ const cli = yargs(hideBin(process.argv))
|
|
|
128
138
|
}),
|
|
129
139
|
handle(async (argv) => {
|
|
130
140
|
const { feed, settings, dir } = project(argv);
|
|
131
|
-
await runGenerateMarkdown({
|
|
141
|
+
await runGenerateMarkdown({
|
|
142
|
+
feed,
|
|
143
|
+
...settings,
|
|
144
|
+
dir,
|
|
145
|
+
force: argv.force,
|
|
146
|
+
debugLlm: argv.debugLlm,
|
|
147
|
+
});
|
|
132
148
|
}),
|
|
133
149
|
)
|
|
134
150
|
.command(
|
|
@@ -175,6 +191,10 @@ const cli = yargs(hideBin(process.argv))
|
|
|
175
191
|
describe: "Value of the X-OpenCode-Session header",
|
|
176
192
|
type: "string",
|
|
177
193
|
})
|
|
194
|
+
.option("reasoning", {
|
|
195
|
+
describe: "Reasoning effort to test (default: low)",
|
|
196
|
+
type: "string",
|
|
197
|
+
})
|
|
178
198
|
.option("llm", {
|
|
179
199
|
describe: "Run a minimal LLM request to test the API access",
|
|
180
200
|
type: "boolean",
|
|
@@ -193,6 +213,7 @@ const cli = yargs(hideBin(process.argv))
|
|
|
193
213
|
endpoint: argv.endpoint,
|
|
194
214
|
apiKey: argv.apiKey,
|
|
195
215
|
session: argv.session,
|
|
216
|
+
reasoning: argv.reasoning,
|
|
196
217
|
},
|
|
197
218
|
llm: argv.llm,
|
|
198
219
|
timeoutMs: argv.timeout * 1000,
|
package/src/config.js
CHANGED
|
@@ -15,6 +15,7 @@ const YAML_KEYS = {
|
|
|
15
15
|
retryDelays: "retry_delays",
|
|
16
16
|
maxOutputTokens: "max_output_tokens",
|
|
17
17
|
chunkTargetTokens: "chunk_target_tokens",
|
|
18
|
+
reasoning: "reasoning",
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export function settingsFromConfig(config) {
|
|
@@ -36,6 +37,8 @@ const ENV = {
|
|
|
36
37
|
retryDelays: "YT_TO_MD_RETRY_DELAYS",
|
|
37
38
|
maxOutputTokens: "YT_TO_MD_MAX_OUTPUT_TOKENS",
|
|
38
39
|
chunkTargetTokens: "YT_TO_MD_CHUNK_TARGET_TOKENS",
|
|
40
|
+
reasoning: "YT_TO_MD_REASONING",
|
|
41
|
+
debugLlm: "YT_TO_MD_DEBUG_LLM",
|
|
39
42
|
};
|
|
40
43
|
|
|
41
44
|
export function loadEnv(env = process.env) {
|
package/src/doctor.js
CHANGED
|
@@ -2,7 +2,12 @@ import { access, constants } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { generateText } from "ai";
|
|
4
4
|
import { loadConfig, settingsFromConfig } from "./config.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createModel,
|
|
7
|
+
DEFAULT_REASONING,
|
|
8
|
+
describeError,
|
|
9
|
+
reasoningOptions,
|
|
10
|
+
} from "./generate-markdown.js";
|
|
6
11
|
import { loadPricing } from "./pricing.js";
|
|
7
12
|
import { exists } from "./vtt.js";
|
|
8
13
|
import { runYtDlp } from "./ytdlp.js";
|
|
@@ -124,11 +129,13 @@ async function checkLlm({ llm, settings, key, timeoutMs }) {
|
|
|
124
129
|
session: settings.session,
|
|
125
130
|
});
|
|
126
131
|
const started = performance.now();
|
|
132
|
+
const reasoning = settings.reasoning ?? DEFAULT_REASONING;
|
|
127
133
|
const { usage } = await generateText({
|
|
128
134
|
model,
|
|
129
135
|
prompt: "ping",
|
|
130
136
|
maxOutputTokens: 8,
|
|
131
137
|
maxRetries: 0,
|
|
138
|
+
...reasoningOptions(reasoning, settings.endpoint),
|
|
132
139
|
abortSignal: AbortSignal.timeout(timeoutMs),
|
|
133
140
|
});
|
|
134
141
|
const elapsed = (performance.now() - started) / 1000;
|
|
@@ -218,6 +225,12 @@ export async function runDoctor({
|
|
|
218
225
|
if (settings.session) {
|
|
219
226
|
rows.push({ label: "session", value: "set", status: "info", note: null });
|
|
220
227
|
}
|
|
228
|
+
rows.push({
|
|
229
|
+
label: "reasoning",
|
|
230
|
+
value: settings.reasoning ?? DEFAULT_REASONING,
|
|
231
|
+
status: "info",
|
|
232
|
+
note: null,
|
|
233
|
+
});
|
|
221
234
|
rows.push({ label: "pricing", ...(await checkPricing({ settings })) });
|
|
222
235
|
const llmRow = await checkLlm({ llm, settings, key, timeoutMs });
|
|
223
236
|
rows.push({ label: "llm", ...llmRow });
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { writeFile } from "node:fs/promises";
|
|
2
2
|
import { stringify } from "yaml";
|
|
3
|
+
import { formatDuration, progressLine } from "./progress.js";
|
|
3
4
|
import { runYtDlp } from "./ytdlp.js";
|
|
4
5
|
|
|
5
6
|
const DATE_CHUNK = 50;
|
|
6
7
|
|
|
8
|
+
const count = (value, word) => `${value} ${word}${value === 1 ? "" : "s"}`;
|
|
9
|
+
|
|
7
10
|
const toIso = (raw) =>
|
|
8
11
|
/^\d{8}$/.test(raw)
|
|
9
12
|
? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
|
|
@@ -61,12 +64,10 @@ const videoId = (url) => /[?&]v=([\w-]+)/.exec(url)?.[1] ?? url;
|
|
|
61
64
|
|
|
62
65
|
async function fetchExactDates(urls) {
|
|
63
66
|
const dates = new Map();
|
|
67
|
+
const startedAt = performance.now();
|
|
64
68
|
|
|
65
69
|
for (let i = 0; i < urls.length; i += DATE_CHUNK) {
|
|
66
70
|
const chunk = urls.slice(i, i + DATE_CHUNK);
|
|
67
|
-
console.error(
|
|
68
|
-
` exact dates ${Math.min(i + DATE_CHUNK, urls.length)}/${urls.length}`,
|
|
69
|
-
);
|
|
70
71
|
const out = await ytDlp(
|
|
71
72
|
[
|
|
72
73
|
"--skip-download",
|
|
@@ -84,6 +85,15 @@ async function fetchExactDates(urls) {
|
|
|
84
85
|
const iso = toIso(raw ?? "");
|
|
85
86
|
if (id && iso) dates.set(id, iso);
|
|
86
87
|
}
|
|
88
|
+
|
|
89
|
+
console.error(
|
|
90
|
+
progressLine({
|
|
91
|
+
label: "exact dates",
|
|
92
|
+
done: Math.min(i + DATE_CHUNK, urls.length),
|
|
93
|
+
total: urls.length,
|
|
94
|
+
startedAt,
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
return dates;
|
|
@@ -180,9 +190,14 @@ export async function runExtract({
|
|
|
180
190
|
config = {},
|
|
181
191
|
forceDates = false,
|
|
182
192
|
} = {}) {
|
|
193
|
+
const startedAt = performance.now();
|
|
194
|
+
let sourceCount = 0;
|
|
195
|
+
let videoCount = 0;
|
|
196
|
+
|
|
183
197
|
for (const channel of config.feed ?? []) {
|
|
184
198
|
if (typeof channel.url !== "string" || !channel.url) continue;
|
|
185
199
|
|
|
200
|
+
const channelStartedAt = performance.now();
|
|
186
201
|
const videos = channel.videos ?? [];
|
|
187
202
|
const knownDates = forceDates
|
|
188
203
|
? new Map()
|
|
@@ -197,7 +212,17 @@ export async function runExtract({
|
|
|
197
212
|
channel.videos = mergeVideos(videos, fetched.videos, {
|
|
198
213
|
sort: !fetched.playlist,
|
|
199
214
|
});
|
|
215
|
+
|
|
216
|
+
sourceCount += 1;
|
|
217
|
+
videoCount += channel.videos.length;
|
|
218
|
+
console.error(
|
|
219
|
+
` done in ${formatDuration(performance.now() - channelStartedAt)}`,
|
|
220
|
+
);
|
|
200
221
|
}
|
|
201
222
|
|
|
202
223
|
await writeFile(path, stringify(config, { lineWidth: 0 }));
|
|
224
|
+
|
|
225
|
+
console.error(
|
|
226
|
+
`summary: ${count(sourceCount, "source")}, ${count(videoCount, "video")}, ${formatDuration(performance.now() - startedAt)} elapsed`,
|
|
227
|
+
);
|
|
203
228
|
}
|
package/src/generate-markdown.js
CHANGED
|
@@ -68,6 +68,7 @@ function estimateCost(price, usage) {
|
|
|
68
68
|
const OUTPUT_FACTOR = 2;
|
|
69
69
|
const OUTPUT_FLOOR = 2048;
|
|
70
70
|
const DEFAULT_CEILING = 32768;
|
|
71
|
+
const DEFAULT_REASONING = "low";
|
|
71
72
|
const DEFAULT_CHUNK_TARGET_TOKENS = 8000;
|
|
72
73
|
|
|
73
74
|
function isSet(value) {
|
|
@@ -88,19 +89,33 @@ function budgetForPart(text, { maxOutputTokens, ceiling }) {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
function metricsLine(modelId, usage, elapsed, cost) {
|
|
92
|
+
const reasoning = usage?.outputTokenDetails?.reasoningTokens;
|
|
91
93
|
return [
|
|
92
94
|
modelId,
|
|
93
95
|
`llm ${elapsed.toFixed(1)}s`,
|
|
94
96
|
usage
|
|
95
97
|
? `${usage.inputTokens ?? 0} in / ${usage.outputTokens ?? 0} out`
|
|
96
98
|
: "usage ?",
|
|
99
|
+
reasoning ? `${reasoning} reasoning` : null,
|
|
97
100
|
cost === null ? null : `~$${cost.toFixed(6)}`,
|
|
98
101
|
]
|
|
99
102
|
.filter(Boolean)
|
|
100
103
|
.join(" ");
|
|
101
104
|
}
|
|
102
105
|
|
|
103
|
-
function frontmatter(
|
|
106
|
+
function frontmatter(
|
|
107
|
+
job,
|
|
108
|
+
{
|
|
109
|
+
modelId,
|
|
110
|
+
usage,
|
|
111
|
+
elapsed,
|
|
112
|
+
cost,
|
|
113
|
+
finishReason,
|
|
114
|
+
rawFinishReason,
|
|
115
|
+
price,
|
|
116
|
+
chunks,
|
|
117
|
+
},
|
|
118
|
+
) {
|
|
104
119
|
const data = {
|
|
105
120
|
source_url: job.url,
|
|
106
121
|
video_title: titleForLang(job.titles, job.lang),
|
|
@@ -112,8 +127,11 @@ function frontmatter(job, { modelId, usage, elapsed, cost, finishReason, price,
|
|
|
112
127
|
input_tokens: usage?.inputTokens ?? null,
|
|
113
128
|
cached_input_tokens: usage ? cachedInputTokens(usage) : null,
|
|
114
129
|
output_tokens: usage?.outputTokens ?? null,
|
|
130
|
+
reasoning_tokens: usage?.outputTokenDetails?.reasoningTokens ?? null,
|
|
131
|
+
text_tokens: usage?.outputTokenDetails?.textTokens ?? null,
|
|
115
132
|
estimated_cost_usd: cost === null ? null : Number(cost.toFixed(6)),
|
|
116
133
|
finish_reason: finishReason,
|
|
134
|
+
raw_finish_reason: rawFinishReason ?? null,
|
|
117
135
|
pricing: price ? { source: price.source, peak: price.peak } : null,
|
|
118
136
|
},
|
|
119
137
|
};
|
|
@@ -121,6 +139,26 @@ function frontmatter(job, { modelId, usage, elapsed, cost, finishReason, price,
|
|
|
121
139
|
return `---\n${stringify(data, { lineWidth: 0 }).trimEnd()}\n---\n\n`;
|
|
122
140
|
}
|
|
123
141
|
|
|
142
|
+
export function emptyOutputReason({
|
|
143
|
+
emptyParts,
|
|
144
|
+
chunks,
|
|
145
|
+
finishReason,
|
|
146
|
+
rawFinishReason,
|
|
147
|
+
usage,
|
|
148
|
+
}) {
|
|
149
|
+
const raw =
|
|
150
|
+
rawFinishReason && rawFinishReason !== finishReason
|
|
151
|
+
? `/${rawFinishReason}`
|
|
152
|
+
: "";
|
|
153
|
+
const text = usage?.outputTokenDetails?.textTokens ?? null;
|
|
154
|
+
const reasoning = usage?.outputTokenDetails?.reasoningTokens ?? null;
|
|
155
|
+
return (
|
|
156
|
+
`empty output: part ${emptyParts.join(",")}/${chunks}, ` +
|
|
157
|
+
`finish=${finishReason}${raw}, text_tokens=${text}, ` +
|
|
158
|
+
`reasoning_tokens=${reasoning}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
124
162
|
const isHeading = (text) => /^#{1,6}\s/.test(text);
|
|
125
163
|
|
|
126
164
|
const isStructured = (text) =>
|
|
@@ -205,13 +243,16 @@ function retryDelayMs(error, fallbackMs) {
|
|
|
205
243
|
return Math.max(headerMs, fallbackMs);
|
|
206
244
|
}
|
|
207
245
|
|
|
208
|
-
export function
|
|
209
|
-
|
|
210
|
-
if (!APICallError.isInstance(error)) return `${error.message}${suffix}`;
|
|
246
|
+
export function describeCause(error) {
|
|
247
|
+
if (!APICallError.isInstance(error)) return `${error.message}`;
|
|
211
248
|
|
|
212
249
|
const status = error.statusCode ? `HTTP ${error.statusCode}` : "API error";
|
|
213
250
|
const body = error.responseBody?.trim().replace(/\s+/g, " ").slice(0, 200);
|
|
214
|
-
return `${status} ${error.message}${
|
|
251
|
+
return `${status} ${error.message}${body ? `: ${body}` : ""}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function describeError(error, attempts) {
|
|
255
|
+
return `${describeCause(error)} after ${attempts} attempt(s)`;
|
|
215
256
|
}
|
|
216
257
|
|
|
217
258
|
function parseRetryDelays(value) {
|
|
@@ -258,6 +299,44 @@ export function createModel({ modelId, endpoint, apiKey, session }) {
|
|
|
258
299
|
return provider(modelId);
|
|
259
300
|
}
|
|
260
301
|
|
|
302
|
+
export function reasoningOptions(reasoning, endpoint = "") {
|
|
303
|
+
if (reasoning === "none") {
|
|
304
|
+
return /deepseek/i.test(endpoint)
|
|
305
|
+
? {
|
|
306
|
+
providerOptions: {
|
|
307
|
+
opencode: { thinking: { type: "disabled" } },
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
: { reasoning: "none" };
|
|
311
|
+
}
|
|
312
|
+
return reasoning ? { reasoning } : {};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function logLlmDebug({
|
|
316
|
+
maxOutputTokens,
|
|
317
|
+
reasoning,
|
|
318
|
+
finishReason,
|
|
319
|
+
rawFinishReason,
|
|
320
|
+
text,
|
|
321
|
+
reasoningText,
|
|
322
|
+
warnings,
|
|
323
|
+
}) {
|
|
324
|
+
console.error(
|
|
325
|
+
`llm debug: max_tokens=${maxOutputTokens ?? "-"} ` +
|
|
326
|
+
`reasoning=${reasoning ?? "provider-default"} ` +
|
|
327
|
+
`finish=${finishReason} raw=${rawFinishReason ?? "-"} ` +
|
|
328
|
+
`text_chars=${text?.length ?? 0} ` +
|
|
329
|
+
`reasoning_chars=${reasoningText?.length ?? 0}`,
|
|
330
|
+
);
|
|
331
|
+
if (warnings?.length) {
|
|
332
|
+
console.error(`llm debug: warnings=${JSON.stringify(warnings)}`);
|
|
333
|
+
}
|
|
334
|
+
if (!`${text ?? ""}`.trim() && reasoningText) {
|
|
335
|
+
const preview = reasoningText.replace(/\s+/g, " ").slice(0, 300);
|
|
336
|
+
console.error(`llm debug: empty text, reasoning preview: ${preview}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
261
340
|
function videoSession(base, job) {
|
|
262
341
|
const id = createHash("sha256")
|
|
263
342
|
.update(`${job.url}#${job.lang}`)
|
|
@@ -275,7 +354,16 @@ function cleanBody(text) {
|
|
|
275
354
|
}
|
|
276
355
|
|
|
277
356
|
async function generateOnce(
|
|
278
|
-
{
|
|
357
|
+
{
|
|
358
|
+
model,
|
|
359
|
+
retryDelays,
|
|
360
|
+
session,
|
|
361
|
+
limiter,
|
|
362
|
+
priceAt,
|
|
363
|
+
reasoning,
|
|
364
|
+
endpoint,
|
|
365
|
+
debug,
|
|
366
|
+
},
|
|
279
367
|
{ system, prompt, maxOutputTokens, onRetry },
|
|
280
368
|
) {
|
|
281
369
|
const totalAttempts = retryDelays.length + 1;
|
|
@@ -283,29 +371,60 @@ async function generateOnce(
|
|
|
283
371
|
for (let attempt = 1;; attempt++) {
|
|
284
372
|
const started = performance.now();
|
|
285
373
|
try {
|
|
286
|
-
|
|
287
|
-
|
|
374
|
+
let at;
|
|
375
|
+
const call = () => {
|
|
376
|
+
at = new Date();
|
|
377
|
+
return generateText({
|
|
288
378
|
model,
|
|
289
379
|
system,
|
|
290
380
|
prompt,
|
|
291
381
|
temperature: 0.2,
|
|
292
|
-
reasoning
|
|
382
|
+
...reasoningOptions(reasoning, endpoint),
|
|
293
383
|
maxOutputTokens,
|
|
294
384
|
maxRetries: 0,
|
|
295
385
|
headers: { "X-OpenCode-Session": session },
|
|
296
386
|
});
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
387
|
+
};
|
|
388
|
+
const result = await (limiter ? limiter(call) : call());
|
|
389
|
+
const {
|
|
390
|
+
text,
|
|
391
|
+
usage,
|
|
392
|
+
finishReason,
|
|
393
|
+
rawFinishReason,
|
|
394
|
+
reasoningText,
|
|
395
|
+
warnings,
|
|
396
|
+
} = result;
|
|
300
397
|
const elapsed = (performance.now() - started) / 1000;
|
|
398
|
+
const price = priceAt ? priceAt(at) : null;
|
|
399
|
+
const cost = estimateCost(price, usage);
|
|
400
|
+
|
|
401
|
+
if (debug) {
|
|
402
|
+
logLlmDebug({
|
|
403
|
+
maxOutputTokens,
|
|
404
|
+
reasoning,
|
|
405
|
+
finishReason,
|
|
406
|
+
rawFinishReason,
|
|
407
|
+
text,
|
|
408
|
+
reasoningText,
|
|
409
|
+
warnings,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
301
412
|
|
|
302
|
-
return {
|
|
413
|
+
return {
|
|
414
|
+
text,
|
|
415
|
+
usage,
|
|
416
|
+
finishReason,
|
|
417
|
+
rawFinishReason,
|
|
418
|
+
elapsed,
|
|
419
|
+
price,
|
|
420
|
+
cost,
|
|
421
|
+
};
|
|
303
422
|
} catch (error) {
|
|
304
423
|
if (!isRetryable(error) || attempt > retryDelays.length) {
|
|
305
424
|
throw new Error(describeError(error, attempt), { cause: error });
|
|
306
425
|
}
|
|
307
426
|
const waitMs = retryDelayMs(error, retryDelays[attempt - 1]);
|
|
308
|
-
onRetry?.(attempt + 1, totalAttempts, waitMs);
|
|
427
|
+
onRetry?.(attempt + 1, totalAttempts, waitMs, error);
|
|
309
428
|
await delay(waitMs);
|
|
310
429
|
}
|
|
311
430
|
}
|
|
@@ -400,6 +519,10 @@ async function toMarkdown(
|
|
|
400
519
|
chunkTargetTokens,
|
|
401
520
|
session,
|
|
402
521
|
limiter,
|
|
522
|
+
priceAt,
|
|
523
|
+
reasoning,
|
|
524
|
+
endpoint,
|
|
525
|
+
debug,
|
|
403
526
|
},
|
|
404
527
|
title,
|
|
405
528
|
transcript,
|
|
@@ -423,12 +546,25 @@ async function toMarkdown(
|
|
|
423
546
|
const usages = [];
|
|
424
547
|
let elapsed = 0;
|
|
425
548
|
let finishReason = "stop";
|
|
549
|
+
let rawFinishReason;
|
|
550
|
+
let cost = 0;
|
|
551
|
+
let costKnown = true;
|
|
552
|
+
let price = null;
|
|
426
553
|
|
|
427
554
|
await Promise.all(
|
|
428
555
|
parts.map(async (part, index) => {
|
|
429
556
|
onProgress?.(index + 1, total);
|
|
430
557
|
const result = await generateOnce(
|
|
431
|
-
{
|
|
558
|
+
{
|
|
559
|
+
model,
|
|
560
|
+
retryDelays,
|
|
561
|
+
session,
|
|
562
|
+
limiter,
|
|
563
|
+
priceAt,
|
|
564
|
+
reasoning,
|
|
565
|
+
endpoint,
|
|
566
|
+
debug,
|
|
567
|
+
},
|
|
432
568
|
{
|
|
433
569
|
system: SYSTEM,
|
|
434
570
|
prompt: chunkPrompt(title, part, index, total),
|
|
@@ -439,8 +575,15 @@ async function toMarkdown(
|
|
|
439
575
|
bodies[index] = cleanBody(result.text);
|
|
440
576
|
usages.push(result.usage);
|
|
441
577
|
elapsed += result.elapsed;
|
|
578
|
+
if (result.cost === null) costKnown = false;
|
|
579
|
+
else cost += result.cost;
|
|
580
|
+
if (price === null && result.price) price = result.price;
|
|
581
|
+
if (rawFinishReason === undefined) {
|
|
582
|
+
rawFinishReason = result.rawFinishReason;
|
|
583
|
+
}
|
|
442
584
|
if (result.finishReason !== "stop" && finishReason === "stop") {
|
|
443
585
|
finishReason = result.finishReason;
|
|
586
|
+
rawFinishReason = result.rawFinishReason;
|
|
444
587
|
}
|
|
445
588
|
}),
|
|
446
589
|
);
|
|
@@ -452,7 +595,16 @@ async function toMarkdown(
|
|
|
452
595
|
if (!previousTail || !nextHead) continue;
|
|
453
596
|
|
|
454
597
|
const smoothing = await generateOnce(
|
|
455
|
-
{
|
|
598
|
+
{
|
|
599
|
+
model,
|
|
600
|
+
retryDelays,
|
|
601
|
+
session,
|
|
602
|
+
limiter,
|
|
603
|
+
priceAt,
|
|
604
|
+
reasoning,
|
|
605
|
+
endpoint,
|
|
606
|
+
debug,
|
|
607
|
+
},
|
|
456
608
|
{
|
|
457
609
|
system: SMOOTH_SYSTEM,
|
|
458
610
|
prompt: smoothPrompt(previousTail, nextHead),
|
|
@@ -462,6 +614,9 @@ async function toMarkdown(
|
|
|
462
614
|
);
|
|
463
615
|
usages.push(smoothing.usage);
|
|
464
616
|
elapsed += smoothing.elapsed;
|
|
617
|
+
if (smoothing.cost === null) costKnown = false;
|
|
618
|
+
else cost += smoothing.cost;
|
|
619
|
+
if (price === null && smoothing.price) price = smoothing.price;
|
|
465
620
|
if (smoothing.finishReason === "stop") {
|
|
466
621
|
const rewritten = cleanBody(smoothing.text);
|
|
467
622
|
if (rewritten) {
|
|
@@ -477,13 +632,20 @@ async function toMarkdown(
|
|
|
477
632
|
.replace(/\n{3,}/g, "\n\n")
|
|
478
633
|
.trim(),
|
|
479
634
|
);
|
|
635
|
+
const emptyParts = bodies.flatMap((part, index) =>
|
|
636
|
+
part ? [] : [index + 1],
|
|
637
|
+
);
|
|
480
638
|
|
|
481
639
|
return {
|
|
482
640
|
body,
|
|
483
641
|
usage: sumUsage(usages),
|
|
484
642
|
finishReason,
|
|
643
|
+
rawFinishReason,
|
|
644
|
+
emptyParts,
|
|
485
645
|
elapsed,
|
|
486
646
|
chunks: total,
|
|
647
|
+
cost: costKnown ? cost : null,
|
|
648
|
+
price,
|
|
487
649
|
};
|
|
488
650
|
}
|
|
489
651
|
|
|
@@ -510,7 +672,9 @@ export async function runGenerateMarkdown({
|
|
|
510
672
|
retryDelays = "10,30,60",
|
|
511
673
|
maxOutputTokens,
|
|
512
674
|
chunkTargetTokens = DEFAULT_CHUNK_TARGET_TOKENS,
|
|
675
|
+
reasoning = DEFAULT_REASONING,
|
|
513
676
|
force = false,
|
|
677
|
+
debugLlm = false,
|
|
514
678
|
dir = DEFAULT_DIR,
|
|
515
679
|
} = {}) {
|
|
516
680
|
const key = apiKey ?? process.env.OPENAI_API_KEY;
|
|
@@ -518,6 +682,7 @@ export async function runGenerateMarkdown({
|
|
|
518
682
|
if (!modelId) throw new Error("model is not set (--model)");
|
|
519
683
|
if (!endpoint) throw new Error("endpoint is not set (--endpoint)");
|
|
520
684
|
|
|
685
|
+
const debug = debugLlm === true || /^(1|true|yes|on)$/i.test(`${debugLlm}`);
|
|
521
686
|
const RETRY_DELAYS = parseRetryDelays(retryDelays);
|
|
522
687
|
const llmConcurrency = Number(concurrency);
|
|
523
688
|
const vttConcurrency = Number(ytdlpConcurrency);
|
|
@@ -540,6 +705,7 @@ export async function runGenerateMarkdown({
|
|
|
540
705
|
already: 0,
|
|
541
706
|
skipped: 0,
|
|
542
707
|
errors: 0,
|
|
708
|
+
failures: [],
|
|
543
709
|
totalSeconds: 0,
|
|
544
710
|
totalInput: 0,
|
|
545
711
|
totalOutput: 0,
|
|
@@ -581,6 +747,7 @@ export async function runGenerateMarkdown({
|
|
|
581
747
|
: isSet(modelOutputLimit)
|
|
582
748
|
? Number(modelOutputLimit)
|
|
583
749
|
: DEFAULT_CEILING;
|
|
750
|
+
const priceAt = prices ? (at) => prices.priceAt(modelId, at) : null;
|
|
584
751
|
console.error(
|
|
585
752
|
`output budget: up to ${ceiling} tokens per call, chunk target ${chunkTargetTokens} tokens`,
|
|
586
753
|
);
|
|
@@ -660,8 +827,17 @@ export async function runGenerateMarkdown({
|
|
|
660
827
|
const name = relative(dir, md);
|
|
661
828
|
try {
|
|
662
829
|
const transcript = vttToText(await readFile(job.vtt, "utf8"));
|
|
663
|
-
const
|
|
664
|
-
|
|
830
|
+
const {
|
|
831
|
+
body,
|
|
832
|
+
usage,
|
|
833
|
+
finishReason,
|
|
834
|
+
rawFinishReason,
|
|
835
|
+
emptyParts,
|
|
836
|
+
elapsed,
|
|
837
|
+
chunks,
|
|
838
|
+
cost,
|
|
839
|
+
price,
|
|
840
|
+
} = await toMarkdown(
|
|
665
841
|
{
|
|
666
842
|
model,
|
|
667
843
|
retryDelays: RETRY_DELAYS,
|
|
@@ -670,40 +846,63 @@ export async function runGenerateMarkdown({
|
|
|
670
846
|
chunkTargetTokens,
|
|
671
847
|
session: videoSession(sessionBase, job),
|
|
672
848
|
limiter,
|
|
849
|
+
priceAt,
|
|
850
|
+
reasoning,
|
|
851
|
+
endpoint,
|
|
852
|
+
debug,
|
|
673
853
|
},
|
|
674
854
|
job.title,
|
|
675
855
|
transcript,
|
|
676
|
-
(nextAttempt, totalAttempts, waitMs) => {
|
|
856
|
+
(nextAttempt, totalAttempts, waitMs, error) => {
|
|
677
857
|
task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
|
|
678
|
-
`(attempt ${nextAttempt}/${totalAttempts})
|
|
858
|
+
`(attempt ${nextAttempt}/${totalAttempts}) ` +
|
|
859
|
+
`${describeCause(error)} ${name}`;
|
|
679
860
|
},
|
|
680
861
|
(part, totalParts) => {
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
862
|
+
task.output = totalParts > 1
|
|
863
|
+
? `generating part ${part}/${totalParts} ${name}`
|
|
864
|
+
: `generating ${name}`;
|
|
684
865
|
},
|
|
685
866
|
);
|
|
686
867
|
|
|
687
868
|
stats.totalSeconds += elapsed;
|
|
688
869
|
stats.totalInput += usage?.inputTokens ?? 0;
|
|
689
870
|
stats.totalOutput += usage?.outputTokens ?? 0;
|
|
690
|
-
const cost = estimateCost(price, usage);
|
|
691
871
|
if (cost === null) stats.costKnown = false;
|
|
692
872
|
else stats.totalCost += cost;
|
|
693
873
|
const metrics = metricsLine(modelId, usage, elapsed, cost);
|
|
694
874
|
|
|
695
875
|
if (finishReason !== "stop") {
|
|
696
876
|
stats.errors++;
|
|
877
|
+
stats.failures.push({
|
|
878
|
+
name,
|
|
879
|
+
reason: `finishReason=${finishReason}`,
|
|
880
|
+
});
|
|
697
881
|
task.output = `error (finishReason=${finishReason}) ${name}`;
|
|
698
882
|
return;
|
|
699
883
|
}
|
|
700
884
|
|
|
885
|
+
if (emptyParts.length > 0) {
|
|
886
|
+
const reason = emptyOutputReason({
|
|
887
|
+
emptyParts,
|
|
888
|
+
chunks,
|
|
889
|
+
finishReason,
|
|
890
|
+
rawFinishReason,
|
|
891
|
+
usage,
|
|
892
|
+
});
|
|
893
|
+
stats.errors++;
|
|
894
|
+
stats.failures.push({ name, reason });
|
|
895
|
+
task.output = `error (${reason}) ${name}`;
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
701
899
|
const front = frontmatter(job, {
|
|
702
900
|
modelId,
|
|
703
901
|
usage,
|
|
704
902
|
elapsed,
|
|
705
903
|
cost,
|
|
706
904
|
finishReason,
|
|
905
|
+
rawFinishReason,
|
|
707
906
|
price,
|
|
708
907
|
chunks,
|
|
709
908
|
});
|
|
@@ -716,6 +915,7 @@ export async function runGenerateMarkdown({
|
|
|
716
915
|
task.output = `generated ${name} ${metrics}`;
|
|
717
916
|
} catch (error) {
|
|
718
917
|
stats.errors++;
|
|
918
|
+
stats.failures.push({ name, reason: error.message });
|
|
719
919
|
task.output = `error (${error.message}) ${name}`;
|
|
720
920
|
} finally {
|
|
721
921
|
const upToDate = stats.already + stats.generated;
|
|
@@ -741,6 +941,15 @@ export async function runGenerateMarkdown({
|
|
|
741
941
|
stats.costKnown ? `, ~$${stats.totalCost.toFixed(6)}` : ""
|
|
742
942
|
}`,
|
|
743
943
|
);
|
|
944
|
+
|
|
945
|
+
if (stats.failures.length > 0) {
|
|
946
|
+
console.error("\nfailures:");
|
|
947
|
+
for (const failure of stats.failures) {
|
|
948
|
+
console.error(` - ${failure.name}: ${failure.reason}`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (stats.errors > 0) process.exitCode = 1;
|
|
744
953
|
}
|
|
745
954
|
|
|
746
955
|
export {
|
|
@@ -748,6 +957,9 @@ export {
|
|
|
748
957
|
chunkPrompt,
|
|
749
958
|
cleanBody,
|
|
750
959
|
createLimiter,
|
|
960
|
+
DEFAULT_REASONING,
|
|
961
|
+
estimateCost,
|
|
962
|
+
metricsLine,
|
|
751
963
|
plannedBudget,
|
|
752
964
|
smoothPrompt,
|
|
753
965
|
sumUsage,
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function formatDuration(ms) {
|
|
2
|
+
const value = Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
3
|
+
if (value < 1000) return `${Math.round(value)}ms`;
|
|
4
|
+
|
|
5
|
+
const seconds = value / 1000;
|
|
6
|
+
if (seconds < 59.95) return `${seconds.toFixed(1)}s`;
|
|
7
|
+
|
|
8
|
+
const rounded = Math.round(seconds);
|
|
9
|
+
const minutes = Math.floor(rounded / 60);
|
|
10
|
+
if (minutes < 60) return `${minutes}m ${String(rounded % 60).padStart(2, "0")}s`;
|
|
11
|
+
|
|
12
|
+
const hours = Math.floor(minutes / 60);
|
|
13
|
+
return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function progressStats({ done, total, startedAt, now = performance.now() }) {
|
|
17
|
+
const elapsed = Math.max(0, now - startedAt);
|
|
18
|
+
const completed = Math.min(Math.max(done, 0), Math.max(total, 0));
|
|
19
|
+
|
|
20
|
+
if (completed === 0 || total <= 0) return { elapsed, eta: null };
|
|
21
|
+
|
|
22
|
+
return { elapsed, eta: (elapsed * (total - completed)) / completed };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function progressLine({ label, done, total, startedAt, now }) {
|
|
26
|
+
const { elapsed, eta } = progressStats({ done, total, startedAt, now });
|
|
27
|
+
const parts = [` ${label} ${done}/${total}`, `${formatDuration(elapsed)} elapsed`];
|
|
28
|
+
|
|
29
|
+
if (eta !== null && eta > 0) parts.push(`~${formatDuration(eta)} left`);
|
|
30
|
+
|
|
31
|
+
return parts.join(" ");
|
|
32
|
+
}
|