@stephane-klein/youtube-to-markdown 0.3.0 → 0.5.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 +67 -6
- package/package.json +3 -2
- package/src/chunk.js +68 -0
- package/src/cli.js +5 -0
- package/src/config.js +2 -0
- package/src/extract-video-metadata.js +28 -3
- package/src/generate-markdown.js +299 -46
- package/src/pricing.js +8 -0
- 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,8 @@ 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` and `
|
|
151
|
-
`extract-video-metadata` command fills the rest:
|
|
187
|
+
`retry_delays`, `max_output_tokens` and `chunk_target_tokens`). I only set the
|
|
188
|
+
source URLs, and the `extract-video-metadata` command fills the rest:
|
|
152
189
|
|
|
153
190
|
`x_opencode_session` is the base of the `X-OpenCode-Session` header; it is
|
|
154
191
|
optional and specific to OpenCode. A stable per-video suffix is appended to it,
|
|
@@ -302,6 +339,25 @@ from the server is honored when longer than the schedule. Each retry is shown in
|
|
|
302
339
|
the phase output; after the last attempt the file is reported as an error and is
|
|
303
340
|
retried on the next run.
|
|
304
341
|
|
|
342
|
+
### Output budget and long transcripts
|
|
343
|
+
|
|
344
|
+
The model must rewrite the whole transcript, so the command asks it for roughly
|
|
345
|
+
twice the input size (`maxOutputTokens`). That budget is capped per call: the
|
|
346
|
+
command reads the model's advertised output limit from the pricing catalogs and
|
|
347
|
+
uses it as the ceiling; if the catalog has no entry it falls back to 32768
|
|
348
|
+
tokens. `--max-output-tokens` (or `YT_TO_MD_MAX_OUTPUT_TOKENS`) overrides the
|
|
349
|
+
ceiling by hand.
|
|
350
|
+
|
|
351
|
+
When the projected output would exceed the ceiling — a long video on a model
|
|
352
|
+
with a small output limit — the transcript is split into chunks that each fit
|
|
353
|
+
the budget, generated independently, then stitched together: immediately
|
|
354
|
+
repeated headings are dropped and each junction is smoothed by a small extra
|
|
355
|
+
LLM call that rewrites the beginning of the next section so it flows from the
|
|
356
|
+
previous one. `--chunk-target-tokens` (or `YT_TO_MD_CHUNK_TARGET_TOKENS`,
|
|
357
|
+
default 8000) sets the target input size of each chunk. The generation of a
|
|
358
|
+
chunk is guaranteed to stop with `stop`; otherwise the file is reported as an
|
|
359
|
+
error and nothing is written. The frontmatter records how many chunks were used.
|
|
360
|
+
|
|
305
361
|
### Generated frontmatter
|
|
306
362
|
|
|
307
363
|
Each generated file starts with a YAML frontmatter describing the run:
|
|
@@ -313,6 +369,7 @@ video_title: Pourquoi π est-il si fou ? Relativité 1
|
|
|
313
369
|
generated_at: 2026-09-19T10:12:33.456Z
|
|
314
370
|
llm:
|
|
315
371
|
model: mimo-v2.5
|
|
372
|
+
chunks: 1
|
|
316
373
|
duration_seconds: 87.1
|
|
317
374
|
input_tokens: 4428
|
|
318
375
|
cached_input_tokens: 0
|
|
@@ -328,10 +385,10 @@ llm:
|
|
|
328
385
|
`video_title` is the original video title in the file's language and
|
|
329
386
|
`generated_at` is the UTC time of the generation. `pricing.source` names the
|
|
330
387
|
catalog the rates came from and `pricing.peak` tells whether the peak rate
|
|
331
|
-
applied (`null` when the source has no peak/off-peak pricing).
|
|
332
|
-
are left untouched: run `youtube-to-markdown generate-markdown
|
|
333
|
-
regenerate every file, which is also the way to add the frontmatter
|
|
334
|
-
generated before it existed.
|
|
388
|
+
applied to the first call (`null` when the source has no peak/off-peak pricing).
|
|
389
|
+
Existing files are left untouched: run `youtube-to-markdown generate-markdown
|
|
390
|
+
--force` to regenerate every file, which is also the way to add the frontmatter
|
|
391
|
+
to files generated before it existed.
|
|
335
392
|
|
|
336
393
|
### Pricing
|
|
337
394
|
|
|
@@ -344,6 +401,10 @@ catalogs are cached for one day under `$XDG_CACHE_HOME/youtube-to-markdown`
|
|
|
344
401
|
(`~/.cache/youtube-to-markdown` by default). When a catalog or the model is
|
|
345
402
|
unavailable, `estimated_cost_usd` is `null` and the run continues.
|
|
346
403
|
|
|
404
|
+
The rate is sampled at the start of every LLM call, so a run that straddles a
|
|
405
|
+
peak/off-peak boundary is priced with the right rate on each side. The
|
|
406
|
+
`pricing` block in the frontmatter describes the first call of the file.
|
|
407
|
+
|
|
347
408
|
## Organize the files into folders
|
|
348
409
|
|
|
349
410
|
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.5.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",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
|
-
"youtube-to-markdown": "node src/cli.js"
|
|
25
|
+
"youtube-to-markdown": "node src/cli.js",
|
|
26
|
+
"test": "node --test"
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
29
|
"@ai-sdk/openai-compatible": "~3.0.51",
|
package/src/chunk.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export const CHARS_PER_TOKEN = 3.5;
|
|
2
|
+
|
|
3
|
+
export function estimateTokens(text) {
|
|
4
|
+
return Math.ceil(`${text ?? ""}`.length / CHARS_PER_TOKEN);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function chunkTranscript(transcript, { targetTokens }) {
|
|
8
|
+
const target = Math.max(1, Math.floor(Number(targetTokens) || 1));
|
|
9
|
+
const lines = `${transcript ?? ""}`.split("\n");
|
|
10
|
+
const chunks = [];
|
|
11
|
+
let current = [];
|
|
12
|
+
let currentTokens = 0;
|
|
13
|
+
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
const lineTokens = estimateTokens(line) + 1;
|
|
16
|
+
if (current.length > 0 && currentTokens + lineTokens > target) {
|
|
17
|
+
chunks.push(current.join("\n"));
|
|
18
|
+
current = [];
|
|
19
|
+
currentTokens = 0;
|
|
20
|
+
}
|
|
21
|
+
current.push(line);
|
|
22
|
+
currentTokens += lineTokens;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (current.length > 0) chunks.push(current.join("\n"));
|
|
26
|
+
return chunks.length > 0 ? chunks : [""];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function splitBlocks(markdown) {
|
|
30
|
+
return `${markdown ?? ""}`.split(/\n{2,}/);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function firstBlock(markdown) {
|
|
34
|
+
return splitBlocks(markdown)[0]?.trim() ?? "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function lastBlock(markdown) {
|
|
38
|
+
const blocks = splitBlocks(markdown).filter((block) => block.trim());
|
|
39
|
+
return blocks[blocks.length - 1]?.trim() ?? "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function replaceFirstBlock(markdown, replacement) {
|
|
43
|
+
const blocks = splitBlocks(markdown);
|
|
44
|
+
if (blocks.length === 0) return replacement;
|
|
45
|
+
blocks[0] = replacement;
|
|
46
|
+
return blocks.join("\n\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function dedupeAdjacentHeadings(markdown) {
|
|
50
|
+
const lines = `${markdown ?? ""}`.split("\n");
|
|
51
|
+
const out = [];
|
|
52
|
+
let lastHeading = null;
|
|
53
|
+
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const heading = line.match(/^\s*#{1,6}\s+(.*\S)\s*$/);
|
|
56
|
+
if (heading) {
|
|
57
|
+
const key = heading[1].trim().toLowerCase();
|
|
58
|
+
if (key === lastHeading) continue;
|
|
59
|
+
lastHeading = key;
|
|
60
|
+
out.push(line);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (line.trim() !== "") lastHeading = null;
|
|
64
|
+
out.push(line);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
68
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ const SETTING_KEYS = [
|
|
|
23
23
|
"ytdlpConcurrency",
|
|
24
24
|
"retryDelays",
|
|
25
25
|
"maxOutputTokens",
|
|
26
|
+
"chunkTargetTokens",
|
|
26
27
|
];
|
|
27
28
|
|
|
28
29
|
const handle = (run) => async (argv) => {
|
|
@@ -116,6 +117,10 @@ const cli = yargs(hideBin(process.argv))
|
|
|
116
117
|
describe: "Override the computed output token budget",
|
|
117
118
|
type: "number",
|
|
118
119
|
})
|
|
120
|
+
.option("chunk-target-tokens", {
|
|
121
|
+
describe: "Target input tokens per chunk when a transcript is split (default: 8000)",
|
|
122
|
+
type: "number",
|
|
123
|
+
})
|
|
119
124
|
.option("force", {
|
|
120
125
|
describe: "Regenerate every Markdown file",
|
|
121
126
|
type: "boolean",
|
package/src/config.js
CHANGED
|
@@ -14,6 +14,7 @@ const YAML_KEYS = {
|
|
|
14
14
|
ytdlpConcurrency: "ytdlp_concurrency",
|
|
15
15
|
retryDelays: "retry_delays",
|
|
16
16
|
maxOutputTokens: "max_output_tokens",
|
|
17
|
+
chunkTargetTokens: "chunk_target_tokens",
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
export function settingsFromConfig(config) {
|
|
@@ -34,6 +35,7 @@ const ENV = {
|
|
|
34
35
|
ytdlpConcurrency: "YT_TO_MD_YTDLP_CONCURRENCY",
|
|
35
36
|
retryDelays: "YT_TO_MD_RETRY_DELAYS",
|
|
36
37
|
maxOutputTokens: "YT_TO_MD_MAX_OUTPUT_TOKENS",
|
|
38
|
+
chunkTargetTokens: "YT_TO_MD_CHUNK_TARGET_TOKENS",
|
|
37
39
|
};
|
|
38
40
|
|
|
39
41
|
export function loadEnv(env = process.env) {
|
|
@@ -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
|
@@ -5,6 +5,14 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
|
5
5
|
import { APICallError, generateText } from "ai";
|
|
6
6
|
import { Listr, ListrLogger, ProcessOutput } from "listr2";
|
|
7
7
|
import { stringify } from "yaml";
|
|
8
|
+
import {
|
|
9
|
+
chunkTranscript,
|
|
10
|
+
dedupeAdjacentHeadings,
|
|
11
|
+
estimateTokens,
|
|
12
|
+
firstBlock,
|
|
13
|
+
lastBlock,
|
|
14
|
+
replaceFirstBlock,
|
|
15
|
+
} from "./chunk.js";
|
|
8
16
|
import { loadPricing } from "./pricing.js";
|
|
9
17
|
import {
|
|
10
18
|
DEFAULT_DIR,
|
|
@@ -26,6 +34,15 @@ const SYSTEM = [
|
|
|
26
34
|
"- Output only the Markdown body.",
|
|
27
35
|
].join("\n");
|
|
28
36
|
|
|
37
|
+
const SMOOTH_SYSTEM = [
|
|
38
|
+
"You improve the transition between two consecutive Markdown sections that were written independently.",
|
|
39
|
+
"- You are given the end of the previous section and the beginning of the next one.",
|
|
40
|
+
"- Rewrite ONLY the beginning of the next section so it flows naturally from the previous one.",
|
|
41
|
+
"- Keep the same language, the same facts and roughly the same length.",
|
|
42
|
+
"- Keep the Markdown formatting of the beginning.",
|
|
43
|
+
"- Output only the rewritten beginning, without any preamble or code fences.",
|
|
44
|
+
].join("\n");
|
|
45
|
+
|
|
29
46
|
const WIDTH = 80;
|
|
30
47
|
|
|
31
48
|
function cachedInputTokens(usage) {
|
|
@@ -50,20 +67,26 @@ function estimateCost(price, usage) {
|
|
|
50
67
|
|
|
51
68
|
const OUTPUT_FACTOR = 2;
|
|
52
69
|
const OUTPUT_FLOOR = 2048;
|
|
53
|
-
const
|
|
70
|
+
const DEFAULT_CEILING = 32768;
|
|
71
|
+
const DEFAULT_CHUNK_TARGET_TOKENS = 8000;
|
|
54
72
|
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
73
|
+
function isSet(value) {
|
|
74
|
+
return value !== undefined && value !== null && value !== "";
|
|
75
|
+
}
|
|
59
76
|
|
|
60
|
-
|
|
77
|
+
function plannedBudget(transcript, ceiling) {
|
|
78
|
+
const inputTokens = estimateTokens(transcript);
|
|
61
79
|
return Math.min(
|
|
62
|
-
|
|
80
|
+
ceiling,
|
|
63
81
|
Math.max(OUTPUT_FLOOR, Math.ceil(inputTokens * OUTPUT_FACTOR)),
|
|
64
82
|
);
|
|
65
83
|
}
|
|
66
84
|
|
|
85
|
+
function budgetForPart(text, { maxOutputTokens, ceiling }) {
|
|
86
|
+
if (isSet(maxOutputTokens)) return Number(maxOutputTokens);
|
|
87
|
+
return plannedBudget(text, ceiling);
|
|
88
|
+
}
|
|
89
|
+
|
|
67
90
|
function metricsLine(modelId, usage, elapsed, cost) {
|
|
68
91
|
return [
|
|
69
92
|
modelId,
|
|
@@ -77,13 +100,14 @@ function metricsLine(modelId, usage, elapsed, cost) {
|
|
|
77
100
|
.join(" ");
|
|
78
101
|
}
|
|
79
102
|
|
|
80
|
-
function frontmatter(job, { modelId, usage, elapsed, cost, finishReason, price }) {
|
|
103
|
+
function frontmatter(job, { modelId, usage, elapsed, cost, finishReason, price, chunks }) {
|
|
81
104
|
const data = {
|
|
82
105
|
source_url: job.url,
|
|
83
106
|
video_title: titleForLang(job.titles, job.lang),
|
|
84
107
|
generated_at: new Date().toISOString(),
|
|
85
108
|
llm: {
|
|
86
109
|
model: modelId,
|
|
110
|
+
chunks: chunks ?? 1,
|
|
87
111
|
duration_seconds: Number(elapsed.toFixed(1)),
|
|
88
112
|
input_tokens: usage?.inputTokens ?? null,
|
|
89
113
|
cached_input_tokens: usage ? cachedInputTokens(usage) : null,
|
|
@@ -242,36 +266,45 @@ function videoSession(base, job) {
|
|
|
242
266
|
return `${base || "youtube-to-markdown"}-${id}`;
|
|
243
267
|
}
|
|
244
268
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
269
|
+
function cleanBody(text) {
|
|
270
|
+
return text
|
|
271
|
+
.trim()
|
|
272
|
+
.replace(/^```[a-z]*\n?/, "")
|
|
273
|
+
.replace(/\n?```$/, "")
|
|
274
|
+
.trim();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function generateOnce(
|
|
278
|
+
{ model, retryDelays, session, limiter, priceAt },
|
|
279
|
+
{ system, prompt, maxOutputTokens, onRetry },
|
|
250
280
|
) {
|
|
251
281
|
const totalAttempts = retryDelays.length + 1;
|
|
252
282
|
|
|
253
283
|
for (let attempt = 1;; attempt++) {
|
|
254
284
|
const started = performance.now();
|
|
255
285
|
try {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
286
|
+
let at;
|
|
287
|
+
const call = () => {
|
|
288
|
+
at = new Date();
|
|
289
|
+
return generateText({
|
|
290
|
+
model,
|
|
291
|
+
system,
|
|
292
|
+
prompt,
|
|
293
|
+
temperature: 0.2,
|
|
294
|
+
reasoning: "low",
|
|
295
|
+
maxOutputTokens,
|
|
296
|
+
maxRetries: 0,
|
|
297
|
+
headers: { "X-OpenCode-Session": session },
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
const { text, usage, finishReason } = await (
|
|
301
|
+
limiter ? limiter(call) : call()
|
|
302
|
+
);
|
|
266
303
|
const elapsed = (performance.now() - started) / 1000;
|
|
304
|
+
const price = priceAt ? priceAt(at) : null;
|
|
305
|
+
const cost = estimateCost(price, usage);
|
|
267
306
|
|
|
268
|
-
|
|
269
|
-
.trim()
|
|
270
|
-
.replace(/^```[a-z]*\n?/, "")
|
|
271
|
-
.replace(/\n?```$/, "")
|
|
272
|
-
.trim();
|
|
273
|
-
|
|
274
|
-
return { body, usage, finishReason, elapsed };
|
|
307
|
+
return { text, usage, finishReason, elapsed, price, cost };
|
|
275
308
|
} catch (error) {
|
|
276
309
|
if (!isRetryable(error) || attempt > retryDelays.length) {
|
|
277
310
|
throw new Error(describeError(error, attempt), { cause: error });
|
|
@@ -283,6 +316,194 @@ async function toMarkdown(
|
|
|
283
316
|
}
|
|
284
317
|
}
|
|
285
318
|
|
|
319
|
+
function chunkPrompt(title, transcript, index, total) {
|
|
320
|
+
if (total <= 1) return `Title: ${title}\n\nTranscript:\n${transcript}`;
|
|
321
|
+
return [
|
|
322
|
+
`Title: ${title}`,
|
|
323
|
+
"",
|
|
324
|
+
`This is part ${index + 1} of ${total} of the transcript.`,
|
|
325
|
+
"Output only the Markdown body for this part, without the main title.",
|
|
326
|
+
"",
|
|
327
|
+
`Transcript (part ${index + 1}/${total}):`,
|
|
328
|
+
transcript,
|
|
329
|
+
].join("\n");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function smoothPrompt(previousTail, nextHead) {
|
|
333
|
+
return [
|
|
334
|
+
"End of the previous section:",
|
|
335
|
+
"",
|
|
336
|
+
previousTail,
|
|
337
|
+
"",
|
|
338
|
+
"Beginning of the next section:",
|
|
339
|
+
"",
|
|
340
|
+
nextHead,
|
|
341
|
+
].join("\n");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function sumUsage(usages) {
|
|
345
|
+
const present = usages.filter(Boolean);
|
|
346
|
+
if (present.length === 0) return null;
|
|
347
|
+
|
|
348
|
+
const total = {
|
|
349
|
+
inputTokens: 0,
|
|
350
|
+
outputTokens: 0,
|
|
351
|
+
totalTokens: 0,
|
|
352
|
+
inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0 },
|
|
353
|
+
outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
for (const usage of present) {
|
|
357
|
+
total.inputTokens += usage.inputTokens ?? 0;
|
|
358
|
+
total.outputTokens += usage.outputTokens ?? 0;
|
|
359
|
+
total.totalTokens += usage.totalTokens ?? 0;
|
|
360
|
+
total.inputTokenDetails.noCacheTokens +=
|
|
361
|
+
usage.inputTokenDetails?.noCacheTokens ?? 0;
|
|
362
|
+
total.inputTokenDetails.cacheReadTokens +=
|
|
363
|
+
usage.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
364
|
+
total.outputTokenDetails.textTokens +=
|
|
365
|
+
usage.outputTokenDetails?.textTokens ?? 0;
|
|
366
|
+
total.outputTokenDetails.reasoningTokens +=
|
|
367
|
+
usage.outputTokenDetails?.reasoningTokens ?? 0;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return total;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function createLimiter(limit) {
|
|
374
|
+
const max = Math.max(1, Number(limit) || 1);
|
|
375
|
+
let active = 0;
|
|
376
|
+
const queue = [];
|
|
377
|
+
|
|
378
|
+
const pump = () => {
|
|
379
|
+
while (active < max && queue.length > 0) {
|
|
380
|
+
active++;
|
|
381
|
+
const { task, resolve, reject } = queue.shift();
|
|
382
|
+
Promise.resolve()
|
|
383
|
+
.then(task)
|
|
384
|
+
.then(resolve, reject)
|
|
385
|
+
.finally(() => {
|
|
386
|
+
active--;
|
|
387
|
+
pump();
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
return (task) =>
|
|
393
|
+
new Promise((resolve, reject) => {
|
|
394
|
+
queue.push({ task, resolve, reject });
|
|
395
|
+
pump();
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function toMarkdown(
|
|
400
|
+
{
|
|
401
|
+
model,
|
|
402
|
+
retryDelays,
|
|
403
|
+
maxOutputTokens,
|
|
404
|
+
ceiling,
|
|
405
|
+
chunkTargetTokens,
|
|
406
|
+
session,
|
|
407
|
+
limiter,
|
|
408
|
+
priceAt,
|
|
409
|
+
},
|
|
410
|
+
title,
|
|
411
|
+
transcript,
|
|
412
|
+
onRetry,
|
|
413
|
+
onProgress,
|
|
414
|
+
) {
|
|
415
|
+
const budgetCeiling = isSet(maxOutputTokens)
|
|
416
|
+
? Number(maxOutputTokens)
|
|
417
|
+
: ceiling;
|
|
418
|
+
const desired = Math.max(
|
|
419
|
+
OUTPUT_FLOOR,
|
|
420
|
+
Math.ceil(estimateTokens(transcript) * OUTPUT_FACTOR),
|
|
421
|
+
);
|
|
422
|
+
const parts =
|
|
423
|
+
desired > budgetCeiling
|
|
424
|
+
? chunkTranscript(transcript, { targetTokens: chunkTargetTokens })
|
|
425
|
+
: [transcript];
|
|
426
|
+
|
|
427
|
+
const total = parts.length;
|
|
428
|
+
const bodies = new Array(total);
|
|
429
|
+
const usages = [];
|
|
430
|
+
let elapsed = 0;
|
|
431
|
+
let finishReason = "stop";
|
|
432
|
+
let cost = 0;
|
|
433
|
+
let costKnown = true;
|
|
434
|
+
let price = null;
|
|
435
|
+
|
|
436
|
+
await Promise.all(
|
|
437
|
+
parts.map(async (part, index) => {
|
|
438
|
+
onProgress?.(index + 1, total);
|
|
439
|
+
const result = await generateOnce(
|
|
440
|
+
{ model, retryDelays, session, limiter, priceAt },
|
|
441
|
+
{
|
|
442
|
+
system: SYSTEM,
|
|
443
|
+
prompt: chunkPrompt(title, part, index, total),
|
|
444
|
+
maxOutputTokens: budgetForPart(part, { maxOutputTokens, ceiling }),
|
|
445
|
+
onRetry,
|
|
446
|
+
},
|
|
447
|
+
);
|
|
448
|
+
bodies[index] = cleanBody(result.text);
|
|
449
|
+
usages.push(result.usage);
|
|
450
|
+
elapsed += result.elapsed;
|
|
451
|
+
if (result.cost === null) costKnown = false;
|
|
452
|
+
else cost += result.cost;
|
|
453
|
+
if (price === null && result.price) price = result.price;
|
|
454
|
+
if (result.finishReason !== "stop" && finishReason === "stop") {
|
|
455
|
+
finishReason = result.finishReason;
|
|
456
|
+
}
|
|
457
|
+
}),
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
if (finishReason === "stop" && total > 1) {
|
|
461
|
+
for (let index = 0; index + 1 < total; index++) {
|
|
462
|
+
const previousTail = lastBlock(bodies[index]);
|
|
463
|
+
const nextHead = firstBlock(bodies[index + 1]);
|
|
464
|
+
if (!previousTail || !nextHead) continue;
|
|
465
|
+
|
|
466
|
+
const smoothing = await generateOnce(
|
|
467
|
+
{ model, retryDelays, session, limiter, priceAt },
|
|
468
|
+
{
|
|
469
|
+
system: SMOOTH_SYSTEM,
|
|
470
|
+
prompt: smoothPrompt(previousTail, nextHead),
|
|
471
|
+
maxOutputTokens: plannedBudget(nextHead, budgetCeiling),
|
|
472
|
+
onRetry,
|
|
473
|
+
},
|
|
474
|
+
);
|
|
475
|
+
usages.push(smoothing.usage);
|
|
476
|
+
elapsed += smoothing.elapsed;
|
|
477
|
+
if (smoothing.cost === null) costKnown = false;
|
|
478
|
+
else cost += smoothing.cost;
|
|
479
|
+
if (price === null && smoothing.price) price = smoothing.price;
|
|
480
|
+
if (smoothing.finishReason === "stop") {
|
|
481
|
+
const rewritten = cleanBody(smoothing.text);
|
|
482
|
+
if (rewritten) {
|
|
483
|
+
bodies[index + 1] = replaceFirstBlock(bodies[index + 1], rewritten);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const body = dedupeAdjacentHeadings(
|
|
490
|
+
bodies
|
|
491
|
+
.join("\n\n")
|
|
492
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
493
|
+
.trim(),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
body,
|
|
498
|
+
usage: sumUsage(usages),
|
|
499
|
+
finishReason,
|
|
500
|
+
elapsed,
|
|
501
|
+
chunks: total,
|
|
502
|
+
cost: costKnown ? cost : null,
|
|
503
|
+
price,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
286
507
|
async function forEachConcurrent(items, limit, worker) {
|
|
287
508
|
let next = 0;
|
|
288
509
|
await Promise.all(
|
|
@@ -305,6 +526,7 @@ export async function runGenerateMarkdown({
|
|
|
305
526
|
ytdlpConcurrency = 2,
|
|
306
527
|
retryDelays = "10,30,60",
|
|
307
528
|
maxOutputTokens,
|
|
529
|
+
chunkTargetTokens = DEFAULT_CHUNK_TARGET_TOKENS,
|
|
308
530
|
force = false,
|
|
309
531
|
dir = DEFAULT_DIR,
|
|
310
532
|
} = {}) {
|
|
@@ -316,6 +538,7 @@ export async function runGenerateMarkdown({
|
|
|
316
538
|
const RETRY_DELAYS = parseRetryDelays(retryDelays);
|
|
317
539
|
const llmConcurrency = Number(concurrency);
|
|
318
540
|
const vttConcurrency = Number(ytdlpConcurrency);
|
|
541
|
+
const limiter = createLimiter(llmConcurrency);
|
|
319
542
|
|
|
320
543
|
const model = createModel({ modelId, endpoint, apiKey: key, session: sessionBase });
|
|
321
544
|
|
|
@@ -369,6 +592,16 @@ export async function runGenerateMarkdown({
|
|
|
369
592
|
}
|
|
370
593
|
|
|
371
594
|
const prices = pending.length > 0 ? await loadPricing({ endpoint }) : null;
|
|
595
|
+
const modelOutputLimit = prices?.outputLimitAt(modelId) ?? null;
|
|
596
|
+
const ceiling = isSet(maxOutputTokens)
|
|
597
|
+
? Number(maxOutputTokens)
|
|
598
|
+
: isSet(modelOutputLimit)
|
|
599
|
+
? Number(modelOutputLimit)
|
|
600
|
+
: DEFAULT_CEILING;
|
|
601
|
+
const priceAt = prices ? (at) => prices.priceAt(modelId, at) : null;
|
|
602
|
+
console.error(
|
|
603
|
+
`output budget: up to ${ceiling} tokens per call, chunk target ${chunkTargetTokens} tokens`,
|
|
604
|
+
);
|
|
372
605
|
|
|
373
606
|
const downloadTask = {
|
|
374
607
|
title: "Download transcripts",
|
|
@@ -445,26 +678,34 @@ export async function runGenerateMarkdown({
|
|
|
445
678
|
const name = relative(dir, md);
|
|
446
679
|
try {
|
|
447
680
|
const transcript = vttToText(await readFile(job.vtt, "utf8"));
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
681
|
+
const { body, usage, finishReason, elapsed, chunks, cost, price } =
|
|
682
|
+
await toMarkdown(
|
|
683
|
+
{
|
|
684
|
+
model,
|
|
685
|
+
retryDelays: RETRY_DELAYS,
|
|
686
|
+
maxOutputTokens,
|
|
687
|
+
ceiling,
|
|
688
|
+
chunkTargetTokens,
|
|
689
|
+
session: videoSession(sessionBase, job),
|
|
690
|
+
limiter,
|
|
691
|
+
priceAt,
|
|
692
|
+
},
|
|
693
|
+
job.title,
|
|
694
|
+
transcript,
|
|
695
|
+
(nextAttempt, totalAttempts, waitMs) => {
|
|
696
|
+
task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
|
|
697
|
+
`(attempt ${nextAttempt}/${totalAttempts}) ${name}`;
|
|
698
|
+
},
|
|
699
|
+
(part, totalParts) => {
|
|
700
|
+
if (totalParts > 1) {
|
|
701
|
+
task.output = `generating part ${part}/${totalParts} ${name}`;
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
);
|
|
463
705
|
|
|
464
706
|
stats.totalSeconds += elapsed;
|
|
465
707
|
stats.totalInput += usage?.inputTokens ?? 0;
|
|
466
708
|
stats.totalOutput += usage?.outputTokens ?? 0;
|
|
467
|
-
const cost = estimateCost(price, usage);
|
|
468
709
|
if (cost === null) stats.costKnown = false;
|
|
469
710
|
else stats.totalCost += cost;
|
|
470
711
|
const metrics = metricsLine(modelId, usage, elapsed, cost);
|
|
@@ -482,6 +723,7 @@ export async function runGenerateMarkdown({
|
|
|
482
723
|
cost,
|
|
483
724
|
finishReason,
|
|
484
725
|
price,
|
|
726
|
+
chunks,
|
|
485
727
|
});
|
|
486
728
|
await mkdir(dirname(md), { recursive: true });
|
|
487
729
|
await writeFile(
|
|
@@ -518,3 +760,14 @@ export async function runGenerateMarkdown({
|
|
|
518
760
|
}`,
|
|
519
761
|
);
|
|
520
762
|
}
|
|
763
|
+
|
|
764
|
+
export {
|
|
765
|
+
budgetForPart,
|
|
766
|
+
chunkPrompt,
|
|
767
|
+
cleanBody,
|
|
768
|
+
createLimiter,
|
|
769
|
+
estimateCost,
|
|
770
|
+
plannedBudget,
|
|
771
|
+
smoothPrompt,
|
|
772
|
+
sumUsage,
|
|
773
|
+
};
|
package/src/pricing.js
CHANGED
|
@@ -173,5 +173,13 @@ export async function loadPricing({ endpoint } = {}) {
|
|
|
173
173
|
if (entry) return litellmPrice(entry, at);
|
|
174
174
|
return modelsDevPrice(provider.models?.[modelId]);
|
|
175
175
|
},
|
|
176
|
+
outputLimitAt(modelId) {
|
|
177
|
+
const entry = litellm?.get(modelId);
|
|
178
|
+
const limit =
|
|
179
|
+
entry?.max_output_tokens ??
|
|
180
|
+
provider.models?.[modelId]?.limit?.output ??
|
|
181
|
+
entry?.max_tokens;
|
|
182
|
+
return typeof limit === "number" && limit > 0 ? limit : null;
|
|
183
|
+
},
|
|
176
184
|
};
|
|
177
185
|
}
|
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
|
+
}
|