@gentbajko/slopify 3.0.3 → 3.0.4

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.
Files changed (34) hide show
  1. package/dist/adapters/alignment/text.js +1 -1
  2. package/dist/adapters/alignment/window.js +87 -4
  3. package/dist/adapters/alignment/worker.js +1 -1
  4. package/dist/edge/http/files.js +9 -1
  5. package/dist/edge/http/revision-files.js +7 -1
  6. package/dist/edge/http/waveform.js +77 -0
  7. package/dist/extension/slopify-studio-chrome.zip +0 -0
  8. package/dist/extension/slopify-studio-firefox.zip +0 -0
  9. package/dist/{adapters/alignment/numbers.js → kernel/ports/number-words.js} +18 -4
  10. package/dist/patch-notes/3.0.4.md +50 -0
  11. package/dist/patch-notes/index.json +6 -0
  12. package/dist/slices/admission/rules.js +27 -11
  13. package/dist/slices/article/plain.js +19 -0
  14. package/dist/slices/article/split.js +33 -0
  15. package/dist/slices/document/pages.js +33 -4
  16. package/dist/slices/loudness/line-level.js +138 -0
  17. package/dist/slices/narration/pauses.js +30 -4
  18. package/dist/slices/rebuild/recipe-build.js +8 -1
  19. package/dist/slices/rebuild/recipe-exports.js +43 -29
  20. package/dist/slices/rebuild/recipe-lines.js +17 -0
  21. package/dist/slices/rebuild/runtime-export.js +14 -2
  22. package/dist/slices/rebuild/runtime-lines.js +36 -0
  23. package/dist/slices/rebuild/runtime-subtitles.js +6 -3
  24. package/dist/slices/rebuild/runtime-voices.js +4 -1
  25. package/dist/tutorials/Play-Narration.md +1 -1
  26. package/dist/tutorials/Play-Overview.md +1 -1
  27. package/dist/tutorials/Play-Title-and-Article.md +1 -1
  28. package/dist/tutorials/Project-Page.md +2 -2
  29. package/dist/web/assets/index-KWtMGqKB.css +1 -0
  30. package/dist/web/assets/{index-C37k_oL0.js → index-haZKPEuq.js} +117 -117
  31. package/dist/web/assets/{pdf-DmL8a5oI.js → pdf-BEwm41kk.js} +1 -1
  32. package/dist/web/index.html +2 -2
  33. package/package.json +1 -1
  34. package/dist/web/assets/index-DBLSfqA-.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import { aliasMatches } from "../../kernel/ports/narration-aliases.js";
2
- import { cardinal, numberForms } from "./numbers.js";
2
+ import { cardinal, numberForms } from "../../kernel/ports/number-words.js";
3
3
  // Original display words remain intact; only acoustic matching uses normalized English.
4
4
  // Narration aliases ("Dr." said as "Doctor") make the aliased form the default for the
5
5
  // written words they cover, with the written form kept as an alternative for audio that
@@ -1,8 +1,19 @@
1
1
  import { alignWindow, frameSeconds } from "./ctc.js";
2
2
  import { agreesWithSpeech } from "./quality.js";
3
3
  import { englishSpec } from "./spec.js";
4
+ // ceiling: the longest run of words a voice may say its own way (a year, an abbreviation, a
5
+ // name the model cannot spell) and still be placed between the words around it.
6
+ const theirWayWords = 10;
7
+ const theirWaySeconds = 6;
8
+ // floor: the share of a window's words that must match what is heard, so a recording of
9
+ // other words, or of the right words in the wrong order, is still refused.
10
+ const matchingShare = 0.6;
11
+ // Words either side of a run that hold it in place.
12
+ const holdingWords = 3;
4
13
  // Recovery omits bounded transcript spans only after confirming surrounding speech.
5
- export function alignSpeechWindow(logits, frames, candidate, complete, cutoff, skipBudget, spec = englishSpec) {
14
+ export function alignSpeechWindow(logits, frames, candidate, complete, cutoff, skipBudget, spec = englishSpec,
15
+ // The window starts right after words already placed, which hold its first words in place.
16
+ held = true) {
6
17
  const { gates, mismatch } = spec;
7
18
  const agrees = (expected, observed, maximumError) => agreesWithSpeech(expected, observed, maximumError, spec.comparable);
8
19
  try {
@@ -16,8 +27,16 @@ export function alignSpeechWindow(logits, frames, candidate, complete, cutoff, s
16
27
  if (!(error instanceof Error) || error.message !== mismatch)
17
28
  throw error;
18
29
  }
30
+ // Last, words the voice said its own way: after skipped passages, which leave no words to
31
+ // place and are better told apart.
32
+ const theirWay = () => {
33
+ const said = saidTheirWay(logits, frames, candidate, complete, cutoff, spec, held);
34
+ if (said === undefined)
35
+ throw new Error(mismatch);
36
+ return { words: said, skipped: 0, omissionStart: 0 };
37
+ };
19
38
  if (skipBudget <= 0)
20
- throw new Error(mismatch);
39
+ return theirWay();
21
40
  const heard = greedy(logits, frames, spec).split(/\s+/);
22
41
  for (let skipped = 1; skipped <= Math.min(40, skipBudget, candidate.length - 4); skipped += 1) {
23
42
  const remaining = candidate.slice(skipped);
@@ -75,7 +94,67 @@ export function alignSpeechWindow(logits, frames, candidate, complete, cutoff, s
75
94
  }
76
95
  }
77
96
  }
78
- throw new Error(mismatch);
97
+ return theirWay();
98
+ }
99
+ // The window's words when all that differs from what is heard is short runs the voice said its
100
+ // own way ("eleven o four" for 1104, "Saint" for St., a name heard as other letters):
101
+ // forced alignment still places every word, and each run sits between words that match on both
102
+ // sides, so its captions keep time with the ones around it. A run that reaches the end of the
103
+ // window is left for the next one, which starts right before it. Undefined when the words do
104
+ // not match enough of the window, or a run is too long or has nothing holding it.
105
+ export function saidTheirWay(logits, frames, candidate, complete, cutoff, spec, held) {
106
+ let words;
107
+ try {
108
+ words = alignWindow(logits, frames, candidate, complete, spec).words.filter((word) => word.end <= cutoff);
109
+ }
110
+ catch (error) {
111
+ if (error instanceof Error && error.message === spec.mismatch)
112
+ return undefined;
113
+ throw error;
114
+ }
115
+ if (words.length < holdingWords + 1)
116
+ return undefined;
117
+ const matches = words.map((word, index) => {
118
+ const expected = candidate[index]?.spoken ?? "";
119
+ // A word too short to tell apart takes its neighbours' word for it.
120
+ if (spec.lettersOnly(expected).length < 3)
121
+ return true;
122
+ const heard = greedyBetween(logits, Math.floor(word.start / frameSeconds), Math.min(frames, Math.ceil(word.end / frameSeconds)), spec);
123
+ return agreesWithSpeech(expected, heard, 0.5, spec.comparable);
124
+ });
125
+ if (matches.filter(Boolean).length < words.length * matchingShare)
126
+ return undefined;
127
+ let keep = words.length;
128
+ for (let start = 0; start < words.length; start += 1) {
129
+ if (matches[start])
130
+ continue;
131
+ let end = start;
132
+ while (end < words.length && !matches[end])
133
+ end += 1;
134
+ const after = matches.slice(end, end + holdingWords);
135
+ const heldAfter = after.length === holdingWords && after.every(Boolean);
136
+ const heldBefore = start === 0 ? held : matches[start - 1] === true;
137
+ const first = words[start];
138
+ const last = words[end - 1];
139
+ if (first === undefined ||
140
+ last === undefined ||
141
+ end - start > theirWayWords ||
142
+ last.end - first.start > theirWaySeconds)
143
+ return undefined;
144
+ if (!heldAfter) {
145
+ // Nothing after it in this window: the next window starts before the run.
146
+ keep = start;
147
+ break;
148
+ }
149
+ if (!heldBefore)
150
+ return undefined;
151
+ start = end;
152
+ }
153
+ if (keep < holdingWords)
154
+ return undefined;
155
+ return words
156
+ .slice(0, keep)
157
+ .map((word, index) => (matches[index] ? word : { ...word, confidence: 0 }));
79
158
  }
80
159
  function accepted(logits, frames, candidate, complete, cutoff, spec, maximumError = spec.gates.maximumError) {
81
160
  const words = alignWindow(logits, frames, candidate, complete, spec).words.filter((word) => word.end <= cutoff);
@@ -89,10 +168,14 @@ function accepted(logits, frames, candidate, complete, cutoff, spec, maximumErro
89
168
  return words;
90
169
  }
91
170
  export function greedy(logits, frames, spec = englishSpec) {
171
+ return greedyBetween(logits, 0, frames, spec);
172
+ }
173
+ // What the model heard between two frames.
174
+ function greedyBetween(logits, from, to, spec = englishSpec) {
92
175
  const labels = spec.labels;
93
176
  let previous = -1;
94
177
  let text = "";
95
- for (let frame = 0; frame < frames; frame += 1) {
178
+ for (let frame = from; frame < to; frame += 1) {
96
179
  let best = 0;
97
180
  for (let label = 1; label < labels; label += 1)
98
181
  if ((logits[frame * labels + label] ?? -Infinity) > (logits[frame * labels + best] ?? -Infinity))
@@ -74,7 +74,7 @@ async function run(input) {
74
74
  const stuck = () => new SubtitleMismatch(mismatch, sampleAt / sampleRate, snippet(candidate.map((word) => word.text)), snippet(observed.split(/\s+/)));
75
75
  let recovered;
76
76
  try {
77
- recovered = alignSpeechWindow(logits.data, frames, candidate, complete, cutoff, cursor === 0 ? 0 : omissionBudget - omitted, spec);
77
+ recovered = alignSpeechWindow(logits.data, frames, candidate, complete, cutoff, cursor === 0 ? 0 : omissionBudget - omitted, spec, cursor > 0);
78
78
  }
79
79
  catch (error) {
80
80
  if (error instanceof Error && error.message === mismatch)
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { findDownload, imagesZip } from "../../slices/storage/downloads.js";
5
5
  import { fileResponse } from "./byte-range.js";
6
6
  import { onInvalid, problem, titleOf } from "./problem.js";
7
+ import { waveformAnswer } from "./waveform.js";
7
8
  const projectParam = z.object({
8
9
  projectId: z
9
10
  .string()
@@ -21,6 +22,7 @@ const assetParam = projectParam.extend({
21
22
  // The return type is inferred so Hono keeps the route types; see stagingRoutes.
22
23
  export function fileRoutes(deps) {
23
24
  const storage = { db: deps.db, paths: deps.paths };
25
+ const waveform = waveformAnswer(deps.decodePeaks);
24
26
  return (new Hono()
25
27
  // Declared before :asset so the zip is never read as an asset name.
26
28
  .get("/files/:projectId/images.zip", zValidator("param", projectParam, onInvalid), (c) => {
@@ -34,13 +36,19 @@ export function fileRoutes(deps) {
34
36
  "content-disposition": disposition(result.filename),
35
37
  });
36
38
  })
37
- .get("/files/:projectId/:asset", zValidator("param", assetParam, onInvalid), (c) => {
39
+ .get("/files/:projectId/:asset", zValidator("param", assetParam, onInvalid), async (c) => {
38
40
  const { projectId, asset } = c.req.valid("param");
39
41
  const result = findDownload(storage, projectId, asset);
40
42
  if (!result.ok) {
41
43
  return missing(c, result.reason);
42
44
  }
43
45
  const { download } = result;
46
+ // `?waveform=N`: the audio player's bars instead of the file.
47
+ if (download.text === undefined) {
48
+ const bars = await waveform(c, download);
49
+ if (bars !== undefined)
50
+ return bars;
51
+ }
44
52
  // The YouTube description and tags as the page shows them, edits and links in.
45
53
  if (download.text !== undefined)
46
54
  return c.body(download.text, 200, {
@@ -5,6 +5,7 @@ import { findRevisionDownload, revisionImagesZip } from "../../slices/revisions/
5
5
  import { fileResponse } from "./byte-range.js";
6
6
  import { replyForFolder } from "./folder-location.js";
7
7
  import { onInvalid, problem, titleOf } from "./problem.js";
8
+ import { waveformAnswer } from "./waveform.js";
8
9
  const id = z
9
10
  .string()
10
11
  .min(1)
@@ -14,6 +15,7 @@ const revisionParam = z.object({ projectId: id, revisionId: id });
14
15
  const recordParam = revisionParam.extend({ recordId: id });
15
16
  const folderParam = z.object({ id, revisionId: id, recordId: id });
16
17
  export function revisionFileRoutes(deps) {
18
+ const waveform = waveformAnswer(deps.decodePeaks);
17
19
  return new Hono()
18
20
  .get("/files/:projectId/revisions/:revisionId/images.zip", zValidator("param", revisionParam, onInvalid), (c) => {
19
21
  const { projectId, revisionId } = c.req.valid("param");
@@ -26,12 +28,16 @@ export function revisionFileRoutes(deps) {
26
28
  "content-disposition": `attachment; filename="${result.filename}"`,
27
29
  });
28
30
  })
29
- .get("/files/:projectId/revisions/:revisionId/:recordId", zValidator("param", recordParam, onInvalid), (c) => {
31
+ .get("/files/:projectId/revisions/:revisionId/:recordId", zValidator("param", recordParam, onInvalid), async (c) => {
30
32
  const { projectId, revisionId, recordId } = c.req.valid("param");
31
33
  const result = findRevisionDownload(deps, projectId, revisionId, recordId);
32
34
  if (!result.ok)
33
35
  return unavailable(c, result.reason);
34
36
  const value = result.download;
37
+ // `?waveform=N`: the audio player's bars instead of the file.
38
+ const bars = await waveform(c, value);
39
+ if (bars !== undefined)
40
+ return bars;
35
41
  // `?inline=1` lets the project page open a PDF in a browser tab instead of saving
36
42
  // it. Only PDFs: anything else the browser might render stays a download.
37
43
  const inline = c.req.query("inline") === "1" && value.contentType === "application/pdf";
@@ -0,0 +1,77 @@
1
+ import { problem, titleOf } from "./problem.js";
2
+ // The audio player's waveform: `?waveform=N` on a file's own URL answers the file's loudness
3
+ // as N bars from 0 to 1, instead of the file. The peaks come from the same ffmpeg decode as the
4
+ // live view's waveform (`slices/narration/peaks.ts`), ten a second, folded to N.
5
+ // ceiling: bars a player asks for; its track is a few hundred pixels at the widest.
6
+ export const waveformBarsMax = 2000;
7
+ // ceiling: files whose peaks stay in memory. A file keeps its path and size until it is
8
+ // replaced, so a cached row is keyed by both; the oldest goes first.
9
+ const cachedFiles = 100;
10
+ // The average of each of `bars` equal runs of `peaks`; fewer peaks than bars are kept as they
11
+ // are. The average, not the loudest: a long narration's every run has a loud word in it, so
12
+ // the loudest made every bar full height, and the average shows where it is quiet.
13
+ export function foldPeaks(peaks, bars) {
14
+ if (peaks.length <= bars)
15
+ return peaks;
16
+ const step = peaks.length / bars;
17
+ return Array.from({ length: bars }, (_value, bar) => {
18
+ const from = Math.floor(bar * step);
19
+ const end = Math.max(from + 1, Math.min(peaks.length, Math.floor((bar + 1) * step)));
20
+ let sum = 0;
21
+ for (let at = from; at < end; at++)
22
+ sum += peaks[at] ?? 0;
23
+ return Math.round((sum / (end - from)) * 1000) / 1000;
24
+ });
25
+ }
26
+ // The answer to a file request asking for its waveform, or undefined when it does not ask.
27
+ export function waveformAnswer(decode) {
28
+ const cache = new Map();
29
+ return async (c, file) => {
30
+ const raw = c.req.query("waveform");
31
+ if (raw === undefined)
32
+ return undefined;
33
+ const bars = Number(raw);
34
+ if (!Number.isInteger(bars) || bars < 1 || bars > waveformBarsMax)
35
+ return problem(c, {
36
+ status: 400,
37
+ title: titleOf(400),
38
+ detail: `Ask for a waveform of 1 to ${String(waveformBarsMax)} bars.`,
39
+ });
40
+ if (!file.contentType.startsWith("audio/"))
41
+ return problem(c, {
42
+ status: 400,
43
+ title: titleOf(400),
44
+ detail: "Only an audio file has a waveform. This file plays without one.",
45
+ });
46
+ if (decode === undefined)
47
+ return problem(c, {
48
+ status: 503,
49
+ title: titleOf(503),
50
+ detail: "Slopify can't draw the waveform because ffmpeg isn't available. The audio still plays. Restart Slopify; if it keeps happening, use Download diagnostics in Settings and report it.",
51
+ });
52
+ const key = `${file.path}\0${String(file.bytes)}`;
53
+ let waveform = cache.get(key);
54
+ if (waveform === undefined) {
55
+ try {
56
+ waveform = await decode(file.path, c.req.raw.signal);
57
+ }
58
+ catch (error) {
59
+ return problem(c, {
60
+ status: 500,
61
+ title: titleOf(500),
62
+ detail: `Slopify couldn't read this audio to draw its waveform (${error instanceof Error ? error.message : String(error)}). The audio still plays.`,
63
+ });
64
+ }
65
+ cache.set(key, waveform);
66
+ while (cache.size > cachedFiles) {
67
+ const oldest = cache.keys().next().value;
68
+ if (oldest !== undefined)
69
+ cache.delete(oldest);
70
+ }
71
+ }
72
+ return c.json({
73
+ seconds: waveform.seconds,
74
+ peaks: foldPeaks(waveform.peaks, bars),
75
+ });
76
+ };
77
+ }
@@ -1,3 +1,6 @@
1
+ // English number words, as a narrator reads digits: subtitle timing matches them against the
2
+ // audio (`adapters/alignment/text.ts`) and the pauses weigh a sentence by how long it takes to
3
+ // say (`slices/narration/pauses.ts`).
1
4
  const small = [
2
5
  "ZERO",
3
6
  "ONE",
@@ -79,13 +82,24 @@ export function numberForms(raw) {
79
82
  .map((digit) => small[Number(digit)])
80
83
  .join(" ")}`;
81
84
  const ordinary = `${cardinal(value)}${decimal}`;
82
- const year = value >= 1900 && value <= 2099 && value % 100 >= 10
83
- ? `${cardinal(Math.floor(value / 100))} ${cardinal(value % 100)}`
84
- : undefined;
85
- const forms = year === undefined ? [ordinary] : [year, ordinary];
85
+ const forms = [...(fraction === undefined ? yearForms(value) : []), ordinary];
86
86
  if (suffix === "s")
87
87
  return forms.map((form) => `${form.replace(/Y$/, "IE")}S`);
88
88
  if (suffix !== undefined)
89
89
  return forms.map((form) => form.replace(/[A-Z]+$/, (word) => ordinal[word] ?? (word.endsWith("Y") ? `${word.slice(0, -1)}IETH` : `${word}TH`)));
90
90
  return forms;
91
91
  }
92
+ // A four-digit number said as a year: "eleven fifty seven", "eleven o four", "thirteen hundred".
93
+ // Any year from 1000 is read this way, not only this age's: a history or a fantasy setting's
94
+ // calendar ("the year 1104") has them all through it. 2000 to 2009 are "two thousand four".
95
+ function yearForms(value) {
96
+ if (value < 1000 || value > 2099 || (value >= 2000 && value <= 2009))
97
+ return [];
98
+ const century = cardinal(Math.floor(value / 100));
99
+ const rest = value % 100;
100
+ if (rest === 0)
101
+ return [`${century} HUNDRED`];
102
+ if (rest < 10)
103
+ return [`${century} O ${cardinal(rest)}`, `${century} OH ${cardinal(rest)}`];
104
+ return [`${century} ${cardinal(rest)}`];
105
+ }
@@ -0,0 +1,50 @@
1
+ # Slopify 3.0.4
2
+
3
+ Released 28 September 2026.
4
+
5
+ 3.0.4 keeps long videos moving: subtitles stay in sync through hard stretches instead of stopping the video, Slopify no longer freezes during a long run, and audiobooks and podcasts sound even from one voice to the next. Play's settings are laid out in one tidy grid.
6
+
7
+ ## Highlights
8
+
9
+ - **Subtitles stay in sync** through years, names and abbreviations the voice says its own way.
10
+ - **No more freezes** while a long video is being made.
11
+ - **Even voices** in audiobooks and podcasts: every line within 1 LU of the narrator.
12
+ - **Visible buttons** to make a stage again, and **a waveform** in the audio player.
13
+
14
+ ## Subtitles
15
+
16
+ - Years from 1000 on are read as a narrator says them ("eleven fifty seven", "eleven o four", "thirteen hundred"), so history and fantasy dates match their narration.
17
+ - A short stretch the voice says differently from the text, such as a name or an abbreviation, is placed between the words around it rather than stopping the video.
18
+ - When timing does stop, the message says where to listen, and to regenerate the chunk only if words are missing; a voice that read it right, just another way, reads it the same way again.
19
+
20
+ ## Speed
21
+
22
+ - Slopify no longer stops answering for seconds at a time while a long video is made. The article used to be read again from scratch several times a step.
23
+
24
+ ## Audiobooks and podcasts
25
+
26
+ - Every speaker's line is levelled to within 1 LU of the narrator's in the audio export, the MP3 and M4B and the video, so no character sits louder than the narrator. The gain changes in the pause between lines and the captions keep their timing.
27
+ - With several speakers the single voice at the top of Narration is hidden when there is no intro or outro, and labelled **Intro and outro voice** when there is.
28
+ - When a provided book only needs a text model to work out who speaks each line, **Text generation** shows under Narration and says what it is used for.
29
+ - **Script by speaker** is in the Download menus of Narration and Article.
30
+
31
+ ## Project page
32
+
33
+ - Making a whole stage again is a button on its section: **Export the audio again**, **Render the video again**, **Render the PDF again** and the rest. Each still asks first.
34
+ - The Narration stage says when it plays a levelled narration an edit made outdated, and plays the new one beside it until the run levels it.
35
+ - A paused run lists what it still has to make.
36
+ - When the Library's intro or outro has changed since a project copied it, its picker in **Edit settings** lists both: **this project's version** and the **Library version (newer)**. Prompts under **Prompts** say so too, with **Use the Library version** or **Keep this project's version**. Intros and outros were missed before.
37
+ - The audio player shows the narration's waveform.
38
+
39
+ ## Play
40
+
41
+ - Every section's settings sit in one grid: labels above, equal columns, three or four across a wide screen. Every dropdown and number box is the same size, and image prompts line up in columns.
42
+
43
+ ## Fixes
44
+
45
+ - The PDF's Sources page reads as text; only the web addresses are links, and an entry that cites several addresses links every one.
46
+ - Pauses between sentences weigh a number by how long it takes to say, skip a name's initial and never lengthen the short breath between two words.
47
+
48
+ ## Upgrading from 3.0.3
49
+
50
+ - **Nothing becomes outdated.** Audiobooks and podcasts made before get the even voices when their audio is exported again (**Export the audio again**).
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "id": "3.0.4",
4
+ "title": "Slopify 3.0.4",
5
+ "version": "3.0.4",
6
+ "date": "2026-09-28"
7
+ },
2
8
  {
3
9
  "id": "3.0.3",
4
10
  "title": "Slopify 3.0.3",
@@ -83,17 +83,7 @@ export function admit(input) {
83
83
  });
84
84
  }
85
85
  }
86
- // The LLM row is required only when something in the run actually asks an LLM for text.
87
- const needsLlm = sources.research === "generate" ||
88
- sources.article === "generate" ||
89
- sources.thumbnail === "prompt_by_llm" ||
90
- draft.intro?.mode === "llm" ||
91
- draft.outro?.mode === "llm" ||
92
- usesNarrationPreparation(draft) ||
93
- usesYoutubeDescription(draft) ||
94
- usesShorts(draft) ||
95
- (usesVoices(draft) && draft.voices?.source === "attribute");
96
- if (needsLlm && !chosen(draft.llm)) {
86
+ if (needsLlmFor(draft) && !chosen(draft.llm)) {
97
87
  fields.push({ field: "llm", message: "Choose a text (LLM) provider and model." });
98
88
  }
99
89
  if (sources.article === "generate" && blank(draft.articlePrompt)) {
@@ -323,6 +313,32 @@ function checkValues(draft, requiredSlots, fields) {
323
313
  function blank(value) {
324
314
  return value === undefined || value.trim() === "";
325
315
  }
316
+ export function llmUses(draft) {
317
+ const sources = draft.sources;
318
+ const uses = [];
319
+ if (sources.research === "generate")
320
+ uses.push({ section: "article", label: "researching the topic" });
321
+ if (sources.article === "generate")
322
+ uses.push({ section: "article", label: "writing the article" });
323
+ if (usesVoices(draft) && draft.voices?.source === "attribute")
324
+ uses.push({ section: "narration", label: "working out who speaks each line" });
325
+ if (usesNarrationPreparation(draft))
326
+ uses.push({ section: "narration", label: "preparing the narration's delivery" });
327
+ if (draft.intro?.mode === "llm")
328
+ uses.push({ section: "narration", label: "writing the intro" });
329
+ if (draft.outro?.mode === "llm")
330
+ uses.push({ section: "narration", label: "writing the outro" });
331
+ if (sources.thumbnail === "prompt_by_llm")
332
+ uses.push({ section: "outputs", label: "writing the thumbnail prompt" });
333
+ if (usesYoutubeDescription(draft))
334
+ uses.push({ section: "outputs", label: "writing the YouTube description" });
335
+ if (usesShorts(draft))
336
+ uses.push({ section: "outputs", label: "picking the shorts" });
337
+ return uses;
338
+ }
339
+ export function needsLlmFor(draft) {
340
+ return llmUses(draft).length > 0;
341
+ }
326
342
  export function usesNarrationPreparation(draft) {
327
343
  return draft.sources.audio === "generate" && !blank(draft.narrationPrompt);
328
344
  }
@@ -1,7 +1,26 @@
1
1
  import { remark } from "remark";
2
2
  import remarkGfm from "remark-gfm";
3
3
  import stripMarkdown from "strip-markdown";
4
+ // ceiling: conversions remembered. The same article is converted again every time a run plans
5
+ // its work, which is several times a step; a long article took seconds each time, on the
6
+ // thread that answers the page. The oldest goes first.
7
+ const remembered = 64;
8
+ const conversions = new Map();
4
9
  export function plainText(markdown) {
10
+ const known = conversions.get(markdown);
11
+ if (known !== undefined)
12
+ return known;
13
+ const text = convert(markdown);
14
+ conversions.set(markdown, text);
15
+ while (conversions.size > remembered) {
16
+ const oldest = conversions.keys().next().value;
17
+ if (oldest === undefined)
18
+ break;
19
+ conversions.delete(oldest);
20
+ }
21
+ return text;
22
+ }
23
+ function convert(markdown) {
5
24
  const processor = remark().use(remarkGfm).use(stripMarkdown);
6
25
  const tree = processor.runSync(processor.parse(markdown));
7
26
  const paragraphs = tree.children
@@ -8,7 +8,25 @@ const endHeadings = {
8
8
  "sources consulted": "sources",
9
9
  "pronunciation glossary": "glossary",
10
10
  };
11
+ // ceiling: articles whose split is remembered; a run plans from the same article again and
12
+ // again (`plain.ts` says why that matters). The oldest goes first.
13
+ const rememberedSplits = 16;
14
+ const splits = new Map();
11
15
  export function splitEndMatter(markdown) {
16
+ const known = splits.get(markdown);
17
+ if (known !== undefined)
18
+ return known;
19
+ const split = splitOnce(markdown);
20
+ splits.set(markdown, split);
21
+ while (splits.size > rememberedSplits) {
22
+ const oldest = splits.keys().next().value;
23
+ if (oldest === undefined)
24
+ break;
25
+ splits.delete(oldest);
26
+ }
27
+ return split;
28
+ }
29
+ function splitOnce(markdown) {
12
30
  const marks = endMatterMarks(markdown);
13
31
  const first = marks[0];
14
32
  if (first === undefined) {
@@ -43,7 +61,22 @@ function endMatterMarks(markdown) {
43
61
  }
44
62
  return marks;
45
63
  }
64
+ // Each end heading's letters, in order: a paragraph whose letters don't hold one of these in
65
+ // order can't be that heading, so it is never converted. Most of an article is such text.
66
+ const headingLetters = Object.keys(endHeadings).map((heading) => heading.replace(/[^a-z]/g, ""));
67
+ function mightBeHeading(source) {
68
+ const letters = source.toLowerCase().replace(/[^a-z]/g, "");
69
+ return headingLetters.some((wanted) => {
70
+ let at = 0;
71
+ for (const letter of letters)
72
+ if (letter === wanted[at])
73
+ at += 1;
74
+ return at >= wanted.length;
75
+ });
76
+ }
46
77
  function partOf(source) {
78
+ if (!mightBeHeading(source))
79
+ return undefined;
47
80
  // The heading's own markup is dropped by the same conversion the narration source uses,
48
81
  // so nothing here has to know what a heading looks like.
49
82
  const heading = plainText(source).trim().toLowerCase().replace(/:$/, "");
@@ -1,4 +1,4 @@
1
- import { useFace } from "./fonts.js";
1
+ import { faceWidth, useFace } from "./fonts.js";
2
2
  const dateFormat = new Intl.DateTimeFormat("en-US", {
3
3
  year: "numeric",
4
4
  month: "long",
@@ -127,11 +127,40 @@ export function sourcesPage(w, items) {
127
127
  const lines = doc.splitTextToSize(item.text, w.contentWidth - bullet - 5);
128
128
  w.ensure(sources.line);
129
129
  w.write("•", w.left, w.y, fonts.body, sizes.body, "muted");
130
+ // The entry reads as text; only its addresses are links, each in the link colour and
131
+ // pointing at itself. An entry whose address isn't in its words (a titled link) is
132
+ // clickable as a whole, still in text colour.
133
+ const addresses = [...item.text.matchAll(/https?:\/\/[^\s)>\]]+/g)].map((match) => ({
134
+ from: match.index,
135
+ to: match.index + match[0].replace(/[.,;:]+$/, "").length,
136
+ url: match[0].replace(/[.,;:]+$/, ""),
137
+ }));
138
+ const width = (text) => faceWidth(doc, fonts.body, sizes.body, text);
139
+ let cursor = 0;
130
140
  for (const line of lines) {
131
141
  w.ensure(sources.line);
132
- w.write(line, w.left + bullet, w.y, fonts.body, sizes.body, item.href === null ? "text" : "heading");
133
- if (item.href !== null)
134
- doc.link(w.left + bullet, w.y - 5, doc.getTextWidth(line), 7, { url: item.href });
142
+ const start = Math.max(cursor, item.text.indexOf(line, cursor));
143
+ cursor = start + line.length;
144
+ let x = w.left + bullet;
145
+ let at = 0;
146
+ const draw = (text, color, url) => {
147
+ if (text === "")
148
+ return;
149
+ w.write(text, x, w.y, fonts.body, sizes.body, color);
150
+ if (url !== undefined)
151
+ doc.link(x, w.y - 5, width(text), 7, { url });
152
+ x += width(text);
153
+ };
154
+ for (const address of addresses) {
155
+ const from = Math.min(line.length, Math.max(at, address.from - start));
156
+ const to = Math.min(line.length, Math.max(from, address.to - start));
157
+ if (to <= from)
158
+ continue;
159
+ draw(line.slice(at, from), "text");
160
+ draw(line.slice(from, to), "heading", address.url);
161
+ at = to;
162
+ }
163
+ draw(line.slice(at), "text", addresses.length === 0 && item.href !== null ? item.href : undefined);
135
164
  w.y += sources.line;
136
165
  }
137
166
  w.y += sources.gap;