@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 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 `max_output_tokens`). I only set the source URLs, and the
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). Existing files
332
- are left untouched: run `youtube-to-markdown generate-markdown --force` to
333
- regenerate every file, which is also the way to add the frontmatter to files
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.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
  }
@@ -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,45 @@ 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, 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
- 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
+ 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
- const body = text
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 price = prices ? prices.priceAt(modelId) : null;
449
- const { body, usage, finishReason, elapsed } = await toMarkdown(
450
- {
451
- model,
452
- retryDelays: RETRY_DELAYS,
453
- maxOutputTokens,
454
- session: videoSession(sessionBase, job),
455
- },
456
- job.title,
457
- transcript,
458
- (nextAttempt, totalAttempts, waitMs) => {
459
- task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
460
- `(attempt ${nextAttempt}/${totalAttempts}) ${name}`;
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
  }
@@ -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
+ }