@stephane-klein/youtube-to-markdown 0.3.0 → 0.4.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 CHANGED
@@ -147,8 +147,8 @@ variables, then the settings written in the YAML file, then the built-in
147
147
  defaults. The API key stays a secret: `--api-key` or `OPENAI_API_KEY`. The
148
148
  settings are `x_opencode_session`, `model_id`, `openaiapi_endpoint`,
149
149
  `concurrency` and `ytdlp_concurrency` (plus the optional `api_key`,
150
- `retry_delays` and `max_output_tokens`). I only set the source URLs, and the
151
- `extract-video-metadata` command fills the rest:
150
+ `retry_delays`, `max_output_tokens` and `chunk_target_tokens`). I only set the
151
+ source URLs, and the `extract-video-metadata` command fills the rest:
152
152
 
153
153
  `x_opencode_session` is the base of the `X-OpenCode-Session` header; it is
154
154
  optional and specific to OpenCode. A stable per-video suffix is appended to it,
@@ -302,6 +302,25 @@ from the server is honored when longer than the schedule. Each retry is shown in
302
302
  the phase output; after the last attempt the file is reported as an error and is
303
303
  retried on the next run.
304
304
 
305
+ ### Output budget and long transcripts
306
+
307
+ The model must rewrite the whole transcript, so the command asks it for roughly
308
+ twice the input size (`maxOutputTokens`). That budget is capped per call: the
309
+ command reads the model's advertised output limit from the pricing catalogs and
310
+ uses it as the ceiling; if the catalog has no entry it falls back to 32768
311
+ tokens. `--max-output-tokens` (or `YT_TO_MD_MAX_OUTPUT_TOKENS`) overrides the
312
+ ceiling by hand.
313
+
314
+ When the projected output would exceed the ceiling — a long video on a model
315
+ with a small output limit — the transcript is split into chunks that each fit
316
+ the budget, generated independently, then stitched together: immediately
317
+ repeated headings are dropped and each junction is smoothed by a small extra
318
+ LLM call that rewrites the beginning of the next section so it flows from the
319
+ previous one. `--chunk-target-tokens` (or `YT_TO_MD_CHUNK_TARGET_TOKENS`,
320
+ default 8000) sets the target input size of each chunk. The generation of a
321
+ chunk is guaranteed to stop with `stop`; otherwise the file is reported as an
322
+ error and nothing is written. The frontmatter records how many chunks were used.
323
+
305
324
  ### Generated frontmatter
306
325
 
307
326
  Each generated file starts with a YAML frontmatter describing the run:
@@ -313,6 +332,7 @@ video_title: Pourquoi π est-il si fou ? Relativité 1
313
332
  generated_at: 2026-09-19T10:12:33.456Z
314
333
  llm:
315
334
  model: mimo-v2.5
335
+ chunks: 1
316
336
  duration_seconds: 87.1
317
337
  input_tokens: 4428
318
338
  cached_input_tokens: 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stephane-klein/youtube-to-markdown",
3
- "version": "0.3.0",
3
+ "version": "0.4.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) {
@@ -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 OUTPUT_CEILING = 32768;
70
+ const DEFAULT_CEILING = 32768;
71
+ const DEFAULT_CHUNK_TARGET_TOKENS = 8000;
54
72
 
55
- function outputBudget(transcript, override) {
56
- if (override !== undefined && override !== null && override !== "") {
57
- return Number(override);
58
- }
73
+ function isSet(value) {
74
+ return value !== undefined && value !== null && value !== "";
75
+ }
59
76
 
60
- const inputTokens = Math.ceil(transcript.length / 3.5);
77
+ function plannedBudget(transcript, ceiling) {
78
+ const inputTokens = estimateTokens(transcript);
61
79
  return Math.min(
62
- OUTPUT_CEILING,
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,40 @@ function videoSession(base, job) {
242
266
  return `${base || "youtube-to-markdown"}-${id}`;
243
267
  }
244
268
 
245
- async function toMarkdown(
246
- { model, retryDelays, maxOutputTokens, session },
247
- title,
248
- transcript,
249
- onRetry,
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 },
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
- const { text, usage, finishReason } = await generateText({
257
- model,
258
- system: SYSTEM,
259
- prompt: `Title: ${title}\n\nTranscript:\n${transcript}`,
260
- temperature: 0.2,
261
- reasoning: "low",
262
- maxOutputTokens: outputBudget(transcript, maxOutputTokens),
263
- maxRetries: 0,
264
- headers: { "X-OpenCode-Session": session },
265
- });
286
+ const call = () =>
287
+ generateText({
288
+ model,
289
+ system,
290
+ prompt,
291
+ temperature: 0.2,
292
+ reasoning: "low",
293
+ maxOutputTokens,
294
+ maxRetries: 0,
295
+ headers: { "X-OpenCode-Session": session },
296
+ });
297
+ const { text, usage, finishReason } = await (
298
+ limiter ? limiter(call) : call()
299
+ );
266
300
  const elapsed = (performance.now() - started) / 1000;
267
301
 
268
- const body = text
269
- .trim()
270
- .replace(/^```[a-z]*\n?/, "")
271
- .replace(/\n?```$/, "")
272
- .trim();
273
-
274
- return { body, usage, finishReason, elapsed };
302
+ return { text, usage, finishReason, elapsed };
275
303
  } catch (error) {
276
304
  if (!isRetryable(error) || attempt > retryDelays.length) {
277
305
  throw new Error(describeError(error, attempt), { cause: error });
@@ -283,6 +311,182 @@ async function toMarkdown(
283
311
  }
284
312
  }
285
313
 
314
+ function chunkPrompt(title, transcript, index, total) {
315
+ if (total <= 1) return `Title: ${title}\n\nTranscript:\n${transcript}`;
316
+ return [
317
+ `Title: ${title}`,
318
+ "",
319
+ `This is part ${index + 1} of ${total} of the transcript.`,
320
+ "Output only the Markdown body for this part, without the main title.",
321
+ "",
322
+ `Transcript (part ${index + 1}/${total}):`,
323
+ transcript,
324
+ ].join("\n");
325
+ }
326
+
327
+ function smoothPrompt(previousTail, nextHead) {
328
+ return [
329
+ "End of the previous section:",
330
+ "",
331
+ previousTail,
332
+ "",
333
+ "Beginning of the next section:",
334
+ "",
335
+ nextHead,
336
+ ].join("\n");
337
+ }
338
+
339
+ function sumUsage(usages) {
340
+ const present = usages.filter(Boolean);
341
+ if (present.length === 0) return null;
342
+
343
+ const total = {
344
+ inputTokens: 0,
345
+ outputTokens: 0,
346
+ totalTokens: 0,
347
+ inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0 },
348
+ outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
349
+ };
350
+
351
+ for (const usage of present) {
352
+ total.inputTokens += usage.inputTokens ?? 0;
353
+ total.outputTokens += usage.outputTokens ?? 0;
354
+ total.totalTokens += usage.totalTokens ?? 0;
355
+ total.inputTokenDetails.noCacheTokens +=
356
+ usage.inputTokenDetails?.noCacheTokens ?? 0;
357
+ total.inputTokenDetails.cacheReadTokens +=
358
+ usage.inputTokenDetails?.cacheReadTokens ?? 0;
359
+ total.outputTokenDetails.textTokens +=
360
+ usage.outputTokenDetails?.textTokens ?? 0;
361
+ total.outputTokenDetails.reasoningTokens +=
362
+ usage.outputTokenDetails?.reasoningTokens ?? 0;
363
+ }
364
+
365
+ return total;
366
+ }
367
+
368
+ function createLimiter(limit) {
369
+ const max = Math.max(1, Number(limit) || 1);
370
+ let active = 0;
371
+ const queue = [];
372
+
373
+ const pump = () => {
374
+ while (active < max && queue.length > 0) {
375
+ active++;
376
+ const { task, resolve, reject } = queue.shift();
377
+ Promise.resolve()
378
+ .then(task)
379
+ .then(resolve, reject)
380
+ .finally(() => {
381
+ active--;
382
+ pump();
383
+ });
384
+ }
385
+ };
386
+
387
+ return (task) =>
388
+ new Promise((resolve, reject) => {
389
+ queue.push({ task, resolve, reject });
390
+ pump();
391
+ });
392
+ }
393
+
394
+ async function toMarkdown(
395
+ {
396
+ model,
397
+ retryDelays,
398
+ maxOutputTokens,
399
+ ceiling,
400
+ chunkTargetTokens,
401
+ session,
402
+ limiter,
403
+ },
404
+ title,
405
+ transcript,
406
+ onRetry,
407
+ onProgress,
408
+ ) {
409
+ const budgetCeiling = isSet(maxOutputTokens)
410
+ ? Number(maxOutputTokens)
411
+ : ceiling;
412
+ const desired = Math.max(
413
+ OUTPUT_FLOOR,
414
+ Math.ceil(estimateTokens(transcript) * OUTPUT_FACTOR),
415
+ );
416
+ const parts =
417
+ desired > budgetCeiling
418
+ ? chunkTranscript(transcript, { targetTokens: chunkTargetTokens })
419
+ : [transcript];
420
+
421
+ const total = parts.length;
422
+ const bodies = new Array(total);
423
+ const usages = [];
424
+ let elapsed = 0;
425
+ let finishReason = "stop";
426
+
427
+ await Promise.all(
428
+ parts.map(async (part, index) => {
429
+ onProgress?.(index + 1, total);
430
+ const result = await generateOnce(
431
+ { model, retryDelays, session, limiter },
432
+ {
433
+ system: SYSTEM,
434
+ prompt: chunkPrompt(title, part, index, total),
435
+ maxOutputTokens: budgetForPart(part, { maxOutputTokens, ceiling }),
436
+ onRetry,
437
+ },
438
+ );
439
+ bodies[index] = cleanBody(result.text);
440
+ usages.push(result.usage);
441
+ elapsed += result.elapsed;
442
+ if (result.finishReason !== "stop" && finishReason === "stop") {
443
+ finishReason = result.finishReason;
444
+ }
445
+ }),
446
+ );
447
+
448
+ if (finishReason === "stop" && total > 1) {
449
+ for (let index = 0; index + 1 < total; index++) {
450
+ const previousTail = lastBlock(bodies[index]);
451
+ const nextHead = firstBlock(bodies[index + 1]);
452
+ if (!previousTail || !nextHead) continue;
453
+
454
+ const smoothing = await generateOnce(
455
+ { model, retryDelays, session, limiter },
456
+ {
457
+ system: SMOOTH_SYSTEM,
458
+ prompt: smoothPrompt(previousTail, nextHead),
459
+ maxOutputTokens: plannedBudget(nextHead, budgetCeiling),
460
+ onRetry,
461
+ },
462
+ );
463
+ usages.push(smoothing.usage);
464
+ elapsed += smoothing.elapsed;
465
+ if (smoothing.finishReason === "stop") {
466
+ const rewritten = cleanBody(smoothing.text);
467
+ if (rewritten) {
468
+ bodies[index + 1] = replaceFirstBlock(bodies[index + 1], rewritten);
469
+ }
470
+ }
471
+ }
472
+ }
473
+
474
+ const body = dedupeAdjacentHeadings(
475
+ bodies
476
+ .join("\n\n")
477
+ .replace(/\n{3,}/g, "\n\n")
478
+ .trim(),
479
+ );
480
+
481
+ return {
482
+ body,
483
+ usage: sumUsage(usages),
484
+ finishReason,
485
+ elapsed,
486
+ chunks: total,
487
+ };
488
+ }
489
+
286
490
  async function forEachConcurrent(items, limit, worker) {
287
491
  let next = 0;
288
492
  await Promise.all(
@@ -305,6 +509,7 @@ export async function runGenerateMarkdown({
305
509
  ytdlpConcurrency = 2,
306
510
  retryDelays = "10,30,60",
307
511
  maxOutputTokens,
512
+ chunkTargetTokens = DEFAULT_CHUNK_TARGET_TOKENS,
308
513
  force = false,
309
514
  dir = DEFAULT_DIR,
310
515
  } = {}) {
@@ -316,6 +521,7 @@ export async function runGenerateMarkdown({
316
521
  const RETRY_DELAYS = parseRetryDelays(retryDelays);
317
522
  const llmConcurrency = Number(concurrency);
318
523
  const vttConcurrency = Number(ytdlpConcurrency);
524
+ const limiter = createLimiter(llmConcurrency);
319
525
 
320
526
  const model = createModel({ modelId, endpoint, apiKey: key, session: sessionBase });
321
527
 
@@ -369,6 +575,15 @@ export async function runGenerateMarkdown({
369
575
  }
370
576
 
371
577
  const prices = pending.length > 0 ? await loadPricing({ endpoint }) : null;
578
+ const modelOutputLimit = prices?.outputLimitAt(modelId) ?? null;
579
+ const ceiling = isSet(maxOutputTokens)
580
+ ? Number(maxOutputTokens)
581
+ : isSet(modelOutputLimit)
582
+ ? Number(modelOutputLimit)
583
+ : DEFAULT_CEILING;
584
+ console.error(
585
+ `output budget: up to ${ceiling} tokens per call, chunk target ${chunkTargetTokens} tokens`,
586
+ );
372
587
 
373
588
  const downloadTask = {
374
589
  title: "Download transcripts",
@@ -446,12 +661,15 @@ export async function runGenerateMarkdown({
446
661
  try {
447
662
  const transcript = vttToText(await readFile(job.vtt, "utf8"));
448
663
  const price = prices ? prices.priceAt(modelId) : null;
449
- const { body, usage, finishReason, elapsed } = await toMarkdown(
664
+ const { body, usage, finishReason, elapsed, chunks } = await toMarkdown(
450
665
  {
451
666
  model,
452
667
  retryDelays: RETRY_DELAYS,
453
668
  maxOutputTokens,
669
+ ceiling,
670
+ chunkTargetTokens,
454
671
  session: videoSession(sessionBase, job),
672
+ limiter,
455
673
  },
456
674
  job.title,
457
675
  transcript,
@@ -459,6 +677,11 @@ export async function runGenerateMarkdown({
459
677
  task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
460
678
  `(attempt ${nextAttempt}/${totalAttempts}) ${name}`;
461
679
  },
680
+ (part, totalParts) => {
681
+ if (totalParts > 1) {
682
+ task.output = `generating part ${part}/${totalParts} ${name}`;
683
+ }
684
+ },
462
685
  );
463
686
 
464
687
  stats.totalSeconds += elapsed;
@@ -482,6 +705,7 @@ export async function runGenerateMarkdown({
482
705
  cost,
483
706
  finishReason,
484
707
  price,
708
+ chunks,
485
709
  });
486
710
  await mkdir(dirname(md), { recursive: true });
487
711
  await writeFile(
@@ -518,3 +742,13 @@ export async function runGenerateMarkdown({
518
742
  }`,
519
743
  );
520
744
  }
745
+
746
+ export {
747
+ budgetForPart,
748
+ chunkPrompt,
749
+ cleanBody,
750
+ createLimiter,
751
+ plannedBudget,
752
+ smoothPrompt,
753
+ sumUsage,
754
+ };
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
  }