@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 +22 -2
- 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/generate-markdown.js +265 -31
- package/src/pricing.js +8 -0
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 `
|
|
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
|
+
"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) {
|
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,40 @@ 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 },
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
}
|