@gentbajko/slopify 3.0.6 → 3.0.8

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 (37) hide show
  1. package/dist/adapters/tts/elevenlabs.js +2 -0
  2. package/dist/assets/document/fonts/Literata-Italic.ttf +0 -0
  3. package/dist/assets/document/fonts/Literata-OFL.txt +93 -0
  4. package/dist/assets/document/fonts/Literata-Regular.ttf +0 -0
  5. package/dist/assets/document/fonts/Literata-SemiBold.ttf +0 -0
  6. package/dist/assets/document/fonts/Literata-SemiBoldItalic.ttf +0 -0
  7. package/dist/assets/document/fonts/SOURCE.txt +13 -1
  8. package/dist/assets/models.yaml +26 -0
  9. package/dist/edge/autostart/native.js +12 -3
  10. package/dist/extension/slopify-studio-chrome.zip +0 -0
  11. package/dist/extension/slopify-studio-firefox.zip +0 -0
  12. package/dist/kernel/db/migrations/0042-narration-retries.sql +13 -0
  13. package/dist/main.js +9 -1
  14. package/dist/patch-notes/3.0.7.md +33 -0
  15. package/dist/patch-notes/3.0.8.md +23 -0
  16. package/dist/patch-notes/index.json +12 -0
  17. package/dist/slices/document/fonts.js +29 -11
  18. package/dist/slices/document/render.js +1 -1
  19. package/dist/slices/document/theme.js +3 -3
  20. package/dist/slices/rebuild/narration-retry.js +128 -0
  21. package/dist/slices/rebuild/recovery-selection.js +7 -0
  22. package/dist/slices/rebuild/runtime-subtitles.js +53 -11
  23. package/dist/slices/schedules/service.js +2 -1
  24. package/dist/slices/storage/backup-format.js +1 -0
  25. package/dist/slices/youtube/answer.js +1 -1
  26. package/dist/slices/youtube/model.js +3 -2
  27. package/dist/tutorials/Document-Themes.md +1 -1
  28. package/dist/tutorials/Home-and-Projects.md +4 -4
  29. package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +3 -3
  30. package/dist/tutorials/Play-Overview.md +1 -1
  31. package/dist/tutorials/Schedules.md +2 -2
  32. package/dist/tutorials/YouTube-Description.md +1 -1
  33. package/dist/web/assets/index-Cdb0Jh3P.js +310 -0
  34. package/dist/web/assets/{pdf-C5xctS6p.js → pdf-BhFuoBjr.js} +1 -1
  35. package/dist/web/index.html +1 -1
  36. package/package.json +1 -1
  37. package/dist/web/assets/index-CskH1GRY.js +0 -310
@@ -13,6 +13,8 @@ export const elevenLabsBase = "https://api.elevenlabs.io/v1";
13
13
  // keep arriving.
14
14
  export const elevenLabsModel = "eleven_multilingual_v2";
15
15
  export const elevenLabsModels = [
16
+ { id: "eleven_v4", name: "Eleven v4" },
17
+ { id: "eleven_v4_turbo", name: "Eleven v4 Turbo" },
16
18
  { id: "eleven_v3", name: "Eleven v3" },
17
19
  { id: "eleven_multilingual_v2", name: "Eleven Multilingual v2" },
18
20
  { id: "eleven_flash_v2_5", name: "Eleven Flash v2.5" },
@@ -0,0 +1,93 @@
1
+ Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -3,9 +3,21 @@ Cinzel 2.000 by Natanael Gama, from the embedded font data of the lore2script2 P
3
3
  (same owner). Copyright and licence are read from each file's own name table.
4
4
  Upstream: https://github.com/NDISCOVER/Cinzel
5
5
 
6
+ Literata-Regular.ttf, Literata-SemiBold.ttf, Literata-Italic.ttf, Literata-SemiBoldItalic.ttf
7
+ Literata 3 by TypeTogether, instanced from Google Fonts' variable Literata[opsz,wght].ttf and
8
+ Literata-Italic[opsz,wght].ttf (https://github.com/google/fonts/tree/main/ofl/literata) at
9
+ opsz 12 and wght 400 / 600, subset to Latin, Greek, Cyrillic, punctuation and symbols
10
+ (fontTools instancer and subsetter).
11
+
6
12
  OFL.txt
7
- SIL Open Font License 1.1, with the copyright line from the fonts' name table.
13
+ SIL Open Font License 1.1, with the copyright line from the fonts' name table (Cinzel).
14
+ Literata-OFL.txt
15
+ SIL Open Font License 1.1 for Literata, as published with it.
8
16
  7445469e9f302618200b15b1645d6aa3565b93a6634a0f9e5cd069c7fc2343fe Cinzel-Black.ttf
9
17
  cbe8f6a665cc58d4540b6ce2a29e6f1b7e096fa6835aea1d2ffb40c088c5cdf6 Cinzel-Bold.ttf
10
18
  3d50001a451ba6cfd3ef07a2dfb0c564980a5229f70decdfa23f08f4a30e1bed Cinzel-Medium.ttf
11
19
  540b628614b728fc87e50b883189a524dac4d8163ca4a0f8e49b499910762e86 Cinzel-Regular.ttf
20
+ 184bbf724352ffbf7313dc9463f4d242246bcc587aaf10a78f0f4fe826a547bb Literata-Italic.ttf
21
+ efcb6ed9f994f21e3a1759d193d655708d4b836568616967cef80c6a015481cd Literata-Regular.ttf
22
+ a0eeda32c8098fee2cabf5951f6f65aa9396121053e511a11406076f2cd41569 Literata-SemiBoldItalic.ttf
23
+ 68415d934f51efd396dfdfc5628ba4bc35042b7ac753e3a4b75b677b970c89a1 Literata-SemiBold.ttf
@@ -328,6 +328,32 @@ image:
328
328
  - 16:9
329
329
  - 9:16
330
330
  tts:
331
+ - provider: elevenlabs
332
+ id: eleven_v4
333
+ name: Eleven v4
334
+ source: https://elevenlabs.io/docs/overview/models
335
+ keywords:
336
+ - tts
337
+ pricing:
338
+ perMillionCharacters: 22
339
+ note: Launch API rate, verified 2026-09-28 and offered for two weeks; the most expressive ElevenLabs model, 90+ languages. Your plan,
340
+ voice and account credits affect actual cost.
341
+ tts:
342
+ maxCharacters: 10000
343
+ streaming: true
344
+ - provider: elevenlabs
345
+ id: eleven_v4_turbo
346
+ name: Eleven v4 Turbo
347
+ source: https://elevenlabs.io/docs/overview/models
348
+ keywords:
349
+ - tts
350
+ pricing:
351
+ perMillionCharacters: 11
352
+ note: Launch API rate, verified 2026-09-28 and offered for two weeks; the fast v4, for low latency. Your plan,
353
+ voice and account credits affect actual cost.
354
+ tts:
355
+ maxCharacters: 10000
356
+ streaming: true
331
357
  - provider: elevenlabs
332
358
  id: eleven_v3
333
359
  name: Eleven v3
@@ -77,6 +77,10 @@ export function createNativeAutostart(o) {
77
77
  const ours = text.includes(platform === "linux" ? desktopMarker : launcherMarker);
78
78
  return { state: ours ? "ours" : "foreign", text };
79
79
  }
80
+ const thisLauncher = launcherRef(platform, at.launcher);
81
+ function runsThisFolder(now) {
82
+ return now.state === "ours" && (now.text?.includes(thisLauncher) ?? false);
83
+ }
80
84
  function state(enabled) {
81
85
  return {
82
86
  available: true,
@@ -113,7 +117,9 @@ export function createNativeAutostart(o) {
113
117
  await o.fs.write(at.entry, entryText, 0o644);
114
118
  }
115
119
  return {
116
- status: async () => state((await entryNow()).state === "ours"),
120
+ // On only when the entry starts this data folder: a second Slopify (another --data-dir,
121
+ // a test copy) sees the switch off, so turning it off there can't remove this one's entry.
122
+ status: async () => state(runsThisFolder(await entryNow())),
117
123
  enable: async () => {
118
124
  if (platform === "win32") {
119
125
  const problem = windowsPathProblem(o.dataDir);
@@ -121,6 +127,8 @@ export function createNativeAutostart(o) {
121
127
  throw new Error(problem);
122
128
  }
123
129
  const now = await entryNow();
130
+ if (now.state === "ours" && !runsThisFolder(now))
131
+ throw new Error(`Another Slopify, with a different data folder, already starts when you log in (${at.where}). Slopify left it alone. Turn it off in that Slopify's Settings → General, or delete ${platform === "win32" ? `the ${runValue} entry in Task Manager → Startup apps` : "that file"}, then turn the switch on here again.`);
124
132
  if (now.state === "foreign")
125
133
  throw new Error(platform === "win32"
126
134
  ? `Your start-up list already has an entry named ${runValue} that Slopify didn't make (${runKey}). Slopify left it alone. Remove it in Task Manager → Startup apps, or rename it, then turn the switch on again.`
@@ -131,7 +139,8 @@ export function createNativeAutostart(o) {
131
139
  },
132
140
  disable: async () => {
133
141
  const now = await entryNow();
134
- if (now.state === "ours") {
142
+ // Only this data folder's entry: another Slopify's is not this switch's to remove.
143
+ if (runsThisFolder(now)) {
135
144
  if (platform === "win32") {
136
145
  const result = await o.exec("reg", ["delete", runKey, "/v", runValue, "/f"]);
137
146
  if (result.code !== 0)
@@ -151,7 +160,7 @@ export function createNativeAutostart(o) {
151
160
  const now = await entryNow();
152
161
  // Only the entry that runs this data folder's launcher: a Slopify started with another
153
162
  // --data-dir must not take over the one the switch was turned on from.
154
- if (now.state !== "ours" || !now.text?.includes(launcherRef(platform, at.launcher)))
163
+ if (!runsThisFolder(now))
155
164
  return;
156
165
  await writeLauncher();
157
166
  await writeEntry(now.text);
@@ -0,0 +1,13 @@
1
+ -- A narration chunk whose audio stopped matching its text is recorded again by itself: the
2
+ -- caption step asks for it here, and the retry starts once that step's failure is saved. The
3
+ -- count caps it at two tries per chunk; `state` is what the last try did.
4
+ CREATE TABLE narration_retries (
5
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
6
+ chunk_key TEXT NOT NULL,
7
+ tries INTEGER NOT NULL CHECK (tries > 0),
8
+ state TEXT NOT NULL CHECK (state IN ('pending', 'started', 'failed')),
9
+ detail TEXT,
10
+ updated_at TEXT NOT NULL,
11
+ PRIMARY KEY(project_id, chunk_key)
12
+ );
13
+ CREATE INDEX narration_retries_pending ON narration_retries(state) WHERE state = 'pending';
package/dist/main.js CHANGED
@@ -55,6 +55,7 @@ import { createNotificationSender } from "./slices/notifications/send.js";
55
55
  import { readNotificationUrl } from "./slices/notifications/settings.js";
56
56
  import { seedSamples } from "./slices/onboarding/sample.js";
57
57
  import { templateById } from "./slices/project-templates/repo.js";
58
+ import { createNarrationRetries } from "./slices/rebuild/narration-retry.js";
58
59
  import { recoverProject } from "./slices/rebuild/recovery.js";
59
60
  import { claimWork, finishWork, maySubmit } from "./slices/rebuild/repo.js";
60
61
  import { waitToRetry, wakeRetries } from "./slices/rebuild/retry.js";
@@ -261,8 +262,12 @@ export async function boot(config, options = {}) {
261
262
  });
262
263
  const audioPreviews = createAudioPreviewStore();
263
264
  const reviewRedos = createReviewRedos();
265
+ const narrationRetries = createNarrationRetries();
264
266
  const runner = wireRunner({
265
- onFinished: (work) => reviewRedos.kick(work.projectId),
267
+ onFinished: (work) => {
268
+ reviewRedos.kick(work.projectId);
269
+ narrationRetries.kick(work.projectId);
270
+ },
266
271
  db,
267
272
  paths,
268
273
  clock,
@@ -386,6 +391,9 @@ export async function boot(config, options = {}) {
386
391
  // Redos a review asked for before the last shutdown start now.
387
392
  reviewRedos.bind(rebuild);
388
393
  reviewRedos.kick();
394
+ // So do narration chunks the captions asked to record again.
395
+ narrationRetries.bind(rebuild);
396
+ narrationRetries.kick();
389
397
  // A stage that was waiting for a CLI's plan limits when the app stopped carries on waiting.
390
398
  void resumeAfterRestart(db, async (projectId) => {
391
399
  const baseRevisionId = currentRevisionId(runtimeDb, projectId);
@@ -0,0 +1,33 @@
1
+ # Slopify 3.0.7
2
+
3
+ Released 28 September 2026.
4
+
5
+ 3.0.7 makes schedules show the template version they really use, adds Literata to PDF themes, and keeps pinned comments short.
6
+
7
+ ## Highlights
8
+
9
+ - **Schedules show the template they run.** Editing a schedule shows each template at its newest version.
10
+ - **Literata for PDFs**, a text face made for long reading.
11
+ - **Short pinned comments:** a line or two, not paragraphs.
12
+ - **New project** in the sidebar.
13
+
14
+ ## Schedules
15
+
16
+ - Edit schedule lists every template at its newest version, the one every run uses, and saving records it. It used to show the version the schedule was first saved with ("· v3") and offer no other, even though runs already used the newest.
17
+
18
+ ## PDF themes
19
+
20
+ - PDF themes can use **Literata** in regular, bold, italic and bold italic: a text face made for long reading on screens.
21
+
22
+ ## YouTube description
23
+
24
+ - The pinned comment is one or two short sentences (about 200 characters): a question viewers can answer and an ask for the next topic. It was two to four paragraphs.
25
+
26
+ ## Everywhere
27
+
28
+ - **New video** is now **New project**, in the sidebar, on Home and Projects, and as the `C` command: a project can be an audiobook, a podcast or a PDF as well as a video.
29
+ - ElevenLabs lists **Eleven v4** and **Eleven v4 Turbo** even before the model list is checked.
30
+
31
+ ## Upgrading from 3.0.6
32
+
33
+ - **Nothing becomes outdated.** To see a schedule's newest template version, open it and press **Save changes** once.
@@ -0,0 +1,23 @@
1
+ # Slopify 3.0.8
2
+
3
+ Released 29 September 2026.
4
+
5
+ 3.0.8 fixes a garbled narration sentence by itself instead of stopping the run.
6
+
7
+ ## Highlights
8
+
9
+ - **Garbled narration is recorded again by itself.** A run no longer stops for a sentence the voice mumbled or skipped.
10
+
11
+ ## Narration
12
+
13
+ - When captions find the narration no longer matches the text (the voice garbled or skipped a sentence), Slopify records that narration chunk again by itself, rebuilds the narration, captions and video, and carries on. It used to stop the run until you pressed Regenerate on the chunk in Edit project → Narration and continued.
14
+ - Each chunk gets two tries. The step says which try is running; if the audio still doesn't match after both, the voice reads something its own way (a year, an abbreviation or a name), and the step says to reword that sentence in Edit project → Narration.
15
+ - Uploaded narration, captions you edited by hand and multi-voice turns aren't retried: they stop and ask, as before.
16
+
17
+ ## Everywhere
18
+
19
+ - **Start Slopify when I log in** belongs to one data folder. A second Slopify on the same account (another `--data-dir`) sees the switch off and can no longer remove or take over the first one's login entry.
20
+
21
+ ## Upgrading from 3.0.7
22
+
23
+ - **Nothing becomes outdated.** Retries start with the next run that finds a mismatch.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.0.8",
4
+ "title": "Slopify 3.0.8",
5
+ "version": "3.0.8",
6
+ "date": "2026-09-29"
7
+ },
8
+ {
9
+ "id": "3.0.7",
10
+ "title": "Slopify 3.0.7",
11
+ "version": "3.0.7",
12
+ "date": "2026-09-28"
13
+ },
2
14
  {
3
15
  "id": "3.0.6",
4
16
  "title": "Slopify 3.0.6",
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
- // The bundled Cinzel weights (SIL OFL 1.1, assets/document/fonts/OFL.txt) and the page
3
- // texture. Read on every render rather than kept in memory: four fonts and one image are
4
- // under a megabyte, and a document is made once per article.
2
+ // The bundled Cinzel weights and Literata styles (SIL OFL 1.1, assets/document/fonts) and the
3
+ // page texture. Read on every render rather than kept in memory: eight fonts and one image
4
+ // are about a megabyte and a half, and a document is made once per article.
5
5
  const assets = new URL("../../assets/document/", import.meta.url);
6
6
  const cinzelFiles = {
7
7
  normal: "Cinzel-Regular.ttf",
@@ -9,13 +9,27 @@ const cinzelFiles = {
9
9
  bold: "Cinzel-Bold.ttf",
10
10
  black: "Cinzel-Black.ttf",
11
11
  };
12
+ // Literata, a text face made for long reading on screens: its text optical size, at 400 and
13
+ // 600 for bold (a heavier bold crowds a page of body text).
14
+ const literataFiles = {
15
+ normal: "Literata-Regular.ttf",
16
+ bold: "Literata-SemiBold.ttf",
17
+ italic: "Literata-Italic.ttf",
18
+ bolditalic: "Literata-SemiBoldItalic.ttf",
19
+ };
20
+ function readFonts(files) {
21
+ return Object.fromEntries(Object.entries(files).map(([style, file]) => [
22
+ style,
23
+ readFileSync(new URL(`fonts/${file}`, assets)).toString("base64"),
24
+ ]));
25
+ }
12
26
  export function readDocumentAssets() {
13
27
  try {
14
- const cinzel = Object.fromEntries(Object.entries(cinzelFiles).map(([style, file]) => [
15
- style,
16
- readFileSync(new URL(`fonts/${file}`, assets)).toString("base64"),
17
- ]));
18
- return { cinzel, parchment: new Uint8Array(readFileSync(new URL("background.jpg", assets))) };
28
+ return {
29
+ cinzel: readFonts(cinzelFiles),
30
+ literata: readFonts(literataFiles),
31
+ parchment: new Uint8Array(readFileSync(new URL("background.jpg", assets))),
32
+ };
19
33
  }
20
34
  catch (error) {
21
35
  throw new Error(`Slopify couldn't read the fonts and page texture it makes documents with (${error instanceof Error ? error.message : String(error)}). Its installation is incomplete: reinstall or update Slopify, then use Try again on Document.`, { cause: error });
@@ -23,12 +37,16 @@ export function readDocumentAssets() {
23
37
  }
24
38
  export function registerFonts(doc, fonts) {
25
39
  for (const [style, file] of Object.entries(cinzelFiles)) {
26
- doc.addFileToVFS(file, fonts[style]);
40
+ doc.addFileToVFS(file, fonts.cinzel[style]);
27
41
  doc.addFont(file, "Cinzel", style);
28
42
  }
43
+ for (const [style, file] of Object.entries(literataFiles)) {
44
+ doc.addFileToVFS(file, fonts.literata[style]);
45
+ doc.addFont(file, "Literata", style);
46
+ }
29
47
  }
30
- // Cinzel exists only in the four bundled weights; the standard fonts only in normal, bold,
31
- // italic and bold italic. A face asking for anything else gets the nearest one.
48
+ // Cinzel exists only in the four bundled weights; Literata and the standard fonts only in
49
+ // normal, bold, italic and bold italic. A face asking for anything else gets the nearest one.
32
50
  export function useFace(doc, face, size) {
33
51
  doc.setFont(face.family, styleFor(face));
34
52
  doc.setFontSize(size);
@@ -22,7 +22,7 @@ export function renderDocument(input) {
22
22
  format: theme.page.format,
23
23
  compress: true,
24
24
  });
25
- registerFonts(doc, input.assets.cinzel);
25
+ registerFonts(doc, input.assets);
26
26
  const fill = (template) => template.replaceAll("{title}", input.title).trim();
27
27
  doc.setProperties({
28
28
  title: input.title,
@@ -1,8 +1,8 @@
1
1
  import { legacyDiceMasterTheme } from "./legacy-dicemaster.js";
2
2
  import { defaultDocumentTheme, documentThemeOf, legacyDocumentTheme, } from "./model.js";
3
- // "Cinzel" is bundled (assets/document/fonts) in the styles below; the other three are the
4
- // PDF standard fonts every reader has.
5
- export const fontFamilies = ["Cinzel", "times", "helvetica", "courier"];
3
+ // "Cinzel" and "Literata" are bundled (assets/document/fonts); the other three are the PDF
4
+ // standard fonts every reader has.
5
+ export const fontFamilies = ["Cinzel", "Literata", "times", "helvetica", "courier"];
6
6
  export const fontStyles = ["normal", "medium", "bold", "black", "italic", "bolditalic"];
7
7
  const cinzel = (style, letterSpacing) => ({
8
8
  family: "Cinzel",
@@ -0,0 +1,128 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { transact } from "../../kernel/db/tx.js";
4
+ import { currentRevisionId } from "../revisions/repo.js";
5
+ import { recoverProject } from "./recovery.js";
6
+ // How many times one narration chunk is recorded again by itself. A voice that garbles a
7
+ // sentence rarely does it twice; one that reads a name its own way does it every time, so
8
+ // after this the run stops and asks the person.
9
+ export const narrationRetryLimit = 2;
10
+ const row = z.object({
11
+ project_id: z.string(),
12
+ chunk_key: z.string(),
13
+ tries: z.number(),
14
+ state: z.enum(["pending", "started", "failed"]),
15
+ });
16
+ // Asked by the caption step when the audio stopped matching the text in a chunk. Returns the
17
+ // try this is (1 or 2), or undefined once the chunk has had its tries.
18
+ export function requestNarrationRetry(db, input) {
19
+ return transact(db, () => {
20
+ const saved = db
21
+ .prepare("SELECT tries FROM narration_retries WHERE project_id=? AND chunk_key=?")
22
+ .get(input.projectId, input.chunkKey);
23
+ const tries = saved === undefined ? 0 : z.number().parse(saved.tries);
24
+ if (tries >= narrationRetryLimit)
25
+ return undefined;
26
+ db.prepare(`INSERT INTO narration_retries(project_id, chunk_key, tries, state, detail, updated_at)
27
+ VALUES (?, ?, 1, 'pending', NULL, ?)
28
+ ON CONFLICT(project_id, chunk_key)
29
+ DO UPDATE SET tries=tries+1, state='pending', detail=NULL, updated_at=excluded.updated_at`).run(input.projectId, input.chunkKey, input.now);
30
+ return tries + 1;
31
+ });
32
+ }
33
+ export function pendingNarrationRetries(db, projectId) {
34
+ const rows = projectId === undefined
35
+ ? db.prepare("SELECT * FROM narration_retries WHERE state='pending'").all()
36
+ : db
37
+ .prepare("SELECT * FROM narration_retries WHERE state='pending' AND project_id=?")
38
+ .all(projectId);
39
+ return rows.map((value) => {
40
+ const parsed = row.parse(value);
41
+ return { projectId: parsed.project_id, chunkKey: parsed.chunk_key, tries: parsed.tries };
42
+ });
43
+ }
44
+ function settle(db, retry, state, detail, now) {
45
+ db.prepare("UPDATE narration_retries SET state=?, detail=?, updated_at=? WHERE project_id=? AND chunk_key=? AND tries=?").run(state, detail, now, retry.projectId, retry.chunkKey, retry.tries);
46
+ }
47
+ // Starts the retries the caption step asked for, once its failure is saved (the runner's
48
+ // `onFinished`). The chunk is made again through Redo's path: a new regeneration token saved as
49
+ // a new project version, then the chunk and everything built on it (the joined narration,
50
+ // the captions, the video) rebuilt as ordinary work.
51
+ export function createNarrationRetries() {
52
+ let bound;
53
+ const running = new Set();
54
+ const kick = (projectId) => {
55
+ const deps = bound;
56
+ if (deps === undefined)
57
+ return;
58
+ for (const retry of pendingNarrationRetries(deps.db, projectId)) {
59
+ const id = `${retry.projectId}\n${retry.chunkKey}`;
60
+ if (running.has(id))
61
+ continue;
62
+ running.add(id);
63
+ void recordAgain(deps, retry)
64
+ .catch((error) => {
65
+ deps.log.write("error", "narration.retry", {
66
+ projectId: retry.projectId,
67
+ detail: error instanceof Error ? error.message : String(error),
68
+ });
69
+ settle(deps.db, retry, "failed", "internal error", deps.clock.now().toISOString());
70
+ })
71
+ .finally(() => {
72
+ running.delete(id);
73
+ try {
74
+ deps.runner.tick(retry.projectId);
75
+ }
76
+ catch {
77
+ // The next tick of this project picks the released work up.
78
+ }
79
+ });
80
+ }
81
+ };
82
+ return {
83
+ bind: (deps) => {
84
+ bound = deps;
85
+ },
86
+ kick,
87
+ };
88
+ }
89
+ async function recordAgain(deps, retry) {
90
+ const base = currentRevisionId(deps.db, retry.projectId);
91
+ if (base === undefined) {
92
+ settle(deps.db, retry, "failed", "no project", deps.clock.now().toISOString());
93
+ return;
94
+ }
95
+ const result = await recoverProject(deps, retry.projectId, {
96
+ baseRevisionId: base,
97
+ idempotencyKey: retryKey(retry),
98
+ action: { kind: "redo", item: retry.chunkKey },
99
+ }, { pendingSuperseded: true });
100
+ const now = deps.clock.now().toISOString();
101
+ if (result.ok) {
102
+ settle(deps.db, retry, "started", null, now);
103
+ deps.log.write("info", "narration.retry", {
104
+ projectId: retry.projectId,
105
+ detail: `Recording ${retry.chunkKey} again (try ${String(retry.tries)} of ${String(narrationRetryLimit)}).`,
106
+ });
107
+ }
108
+ else {
109
+ settle(deps.db, retry, "failed", refusal(result), now);
110
+ deps.log.write("warn", "narration.retry", {
111
+ projectId: retry.projectId,
112
+ detail: `Could not record ${retry.chunkKey} again: ${refusal(result)}`,
113
+ });
114
+ }
115
+ deps.emit(retry.projectId, { type: "project.updated", projectId: retry.projectId });
116
+ }
117
+ function refusal(result) {
118
+ const detail = result.fields?.map((field) => field.message).join(" ");
119
+ return detail ? `${result.reason}: ${detail}` : result.reason;
120
+ }
121
+ // One request identity per try, so a restart that starts the same try again gets the first
122
+ // start's answer instead of a second recording.
123
+ export function retryKey(retry) {
124
+ const hex = createHash("sha256")
125
+ .update(`narration-retry:${retry.projectId}:${retry.chunkKey}:${String(retry.tries)}`)
126
+ .digest("hex");
127
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
128
+ }
@@ -44,6 +44,13 @@ export function redoTarget(view, plan, item) {
44
44
  return plan.recipes.some((row) => row.key === `${item}:prompts`)
45
45
  ? own([`${item}:prompts`])
46
46
  : undefined;
47
+ // One narration chunk, by the key the Narration editor regenerates it by: its TTS requests
48
+ // are the roots (`narration-retry.ts`).
49
+ const chunk = plan.recipes
50
+ .filter((row) => row.input.kind === "tts" && row.input.logicalKey === item)
51
+ .map((row) => row.key);
52
+ if (chunk.length > 0)
53
+ return own(chunk);
47
54
  return plan.recipes.some((row) => row.key === item && row.kind === "provider")
48
55
  ? own([item])
49
56
  : undefined;