@gentbajko/slopify 3.0.0 → 3.0.1

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 (195) hide show
  1. package/README.md +6 -4
  2. package/dist/adapter-registry.js +28 -0
  3. package/dist/adapters/key-probes.js +57 -8
  4. package/dist/adapters/tts/gemini.js +206 -0
  5. package/dist/adapters/tts/pcm-mp3.js +62 -0
  6. package/dist/adapters/tts/system.js +147 -0
  7. package/dist/assets/models.yaml +38 -0
  8. package/dist/assets/style-preview/narration.mp3 +0 -0
  9. package/dist/assets/style-preview/tall-1.jpg +0 -0
  10. package/dist/assets/style-preview/tall-2.jpg +0 -0
  11. package/dist/assets/style-preview/tall-3.jpg +0 -0
  12. package/dist/assets/style-preview/wide-1.jpg +0 -0
  13. package/dist/assets/style-preview/wide-2.jpg +0 -0
  14. package/dist/assets/style-preview/wide-3.jpg +0 -0
  15. package/dist/catalog/registry.js +4 -1
  16. package/dist/catalog/validate.js +2 -2
  17. package/dist/edge/autostart/docker-record.js +2 -2
  18. package/dist/edge/autostart/docker.js +68 -0
  19. package/dist/edge/autostart/prompt.js +7 -1
  20. package/dist/edge/autostart/service.js +18 -9
  21. package/dist/edge/cli-args.js +71 -0
  22. package/dist/edge/cli.js +11 -16
  23. package/dist/edge/docker-install/run.js +7 -1
  24. package/dist/edge/events/hub.js +3 -1
  25. package/dist/edge/http/actions.js +6 -1
  26. package/dist/edge/http/app.js +2 -0
  27. package/dist/edge/http/byte-range.js +42 -0
  28. package/dist/edge/http/diagnostics.js +1 -0
  29. package/dist/edge/http/draft-files.js +2 -4
  30. package/dist/edge/http/files.js +10 -6
  31. package/dist/edge/http/onboarding.js +64 -4
  32. package/dist/edge/http/project-templates.js +19 -2
  33. package/dist/edge/http/projects.js +6 -1
  34. package/dist/edge/http/providers.js +39 -9
  35. package/dist/edge/http/reviews.js +18 -8
  36. package/dist/edge/http/revision-files.js +2 -4
  37. package/dist/edge/http/schedules.js +9 -5
  38. package/dist/edge/http/storage.js +16 -2
  39. package/dist/edge/http/studio.js +111 -31
  40. package/dist/edge/http/style-preview.js +3 -42
  41. package/dist/edge/http/tutorials.js +59 -0
  42. package/dist/edge/http/youtube-edits.js +11 -2
  43. package/dist/extension/slopify-studio-chrome.zip +0 -0
  44. package/dist/extension/slopify-studio-firefox.zip +0 -0
  45. package/dist/host-cli/gemini-login.js +83 -0
  46. package/dist/host-cli/status.js +4 -2
  47. package/dist/kernel/db/migrations/0041-cast-hosts.sql +3 -0
  48. package/dist/kernel/ports/model.js +5 -3
  49. package/dist/kernel/ports/system-speech.js +203 -0
  50. package/dist/kernel/runner/graph.js +17 -1
  51. package/dist/kernel/runner/index.js +4 -4
  52. package/dist/kernel/runner/providers.js +1 -1
  53. package/dist/main.js +5 -0
  54. package/dist/model-catalog.js +2 -0
  55. package/dist/patch-notes/3.0.1.md +105 -0
  56. package/dist/patch-notes/index.json +6 -0
  57. package/dist/slices/admission/rules.js +3 -3
  58. package/dist/slices/admission/start.js +1 -1
  59. package/dist/slices/channels/cast-match.js +1 -1
  60. package/dist/slices/channels/repo.js +3 -0
  61. package/dist/slices/channels/schema.js +23 -0
  62. package/dist/slices/channels/service.js +6 -6
  63. package/dist/slices/control/index.js +3 -1
  64. package/dist/slices/control/providers.js +4 -1
  65. package/dist/slices/episodes/related.js +2 -2
  66. package/dist/slices/estimate/requests.js +9 -1
  67. package/dist/slices/eta/history.js +56 -0
  68. package/dist/slices/eta/model.js +63 -0
  69. package/dist/slices/eta/view.js +25 -0
  70. package/dist/slices/fixes/rules.js +1 -1
  71. package/dist/slices/fonts/coverage.js +1 -1
  72. package/dist/slices/loudness/model.js +1 -1
  73. package/dist/slices/model-upkeep/usage.js +4 -2
  74. package/dist/slices/narration/pauses-model.js +1 -1
  75. package/dist/slices/narration/pronunciation.js +2 -2
  76. package/dist/slices/notifications/notifier.js +28 -2
  77. package/dist/slices/notifications/rules.js +26 -2
  78. package/dist/slices/onboarding/first-run.js +40 -14
  79. package/dist/slices/onboarding/full-video.js +81 -0
  80. package/dist/slices/onboarding/model.js +21 -0
  81. package/dist/slices/onboarding/quick-short.js +25 -10
  82. package/dist/slices/play-drafts/convert.js +3 -3
  83. package/dist/slices/play-drafts/readiness.js +1 -1
  84. package/dist/slices/project-templates/from-project.js +10 -3
  85. package/dist/slices/project-templates/next-chapter.js +94 -0
  86. package/dist/slices/project-templates/one-off.js +1 -1
  87. package/dist/slices/project-templates/schema.js +7 -1
  88. package/dist/slices/rebuild/recipe-exports.js +5 -0
  89. package/dist/slices/rebuild/recipe-voices.js +1 -1
  90. package/dist/slices/rebuild/review-redo.js +15 -0
  91. package/dist/slices/rebuild/runtime-export-bed.js +1 -1
  92. package/dist/slices/rebuild/runtime-review.js +35 -5
  93. package/dist/slices/rebuild/runtime-store.js +23 -1
  94. package/dist/slices/rebuild/runtime-voices.js +2 -2
  95. package/dist/slices/rebuild/service-readiness.js +3 -3
  96. package/dist/slices/reviews/repo.js +16 -4
  97. package/dist/slices/run-cost/panel.js +30 -8
  98. package/dist/slices/run-cost/pricing.js +4 -1
  99. package/dist/slices/schedules/agenda.js +66 -2
  100. package/dist/slices/schedules/repo.js +1 -1
  101. package/dist/slices/schedules/scheduler.js +1 -1
  102. package/dist/slices/schedules/schema.js +43 -2
  103. package/dist/slices/schedules/similar.js +41 -3
  104. package/dist/slices/schedules/topic-list.js +2 -2
  105. package/dist/slices/schedules/topics.js +118 -17
  106. package/dist/slices/settings/first-run.js +5 -1
  107. package/dist/slices/settings/health.js +64 -7
  108. package/dist/slices/settings/key-guides.js +17 -7
  109. package/dist/slices/settings/key-test.js +12 -4
  110. package/dist/slices/settings/keys.js +10 -5
  111. package/dist/slices/settings/model-reach.js +52 -0
  112. package/dist/slices/settings/model.js +49 -0
  113. package/dist/slices/settings/models.js +5 -1
  114. package/dist/slices/settings/readiness.js +14 -2
  115. package/dist/slices/settings/tutorial-schema.js +5 -0
  116. package/dist/slices/shorts/render.js +18 -17
  117. package/dist/slices/storage/documents.js +11 -3
  118. package/dist/slices/storage/downloads.js +33 -1
  119. package/dist/slices/storage/portable.js +4 -2
  120. package/dist/slices/studio/model.js +2 -0
  121. package/dist/slices/studio/pack.js +6 -4
  122. package/dist/slices/studio/queue.js +69 -0
  123. package/dist/slices/studio/settings.js +42 -5
  124. package/dist/slices/style-preview/narration.js +33 -0
  125. package/dist/slices/style-preview/render.js +43 -56
  126. package/dist/slices/style-preview/schema.js +17 -3
  127. package/dist/slices/style-preview/service.js +3 -4
  128. package/dist/slices/style-preview/settings.js +75 -25
  129. package/dist/slices/tutorials/anchors.js +57 -0
  130. package/dist/slices/tutorials/library.js +185 -0
  131. package/dist/slices/voices/audio-files.js +7 -2
  132. package/dist/slices/voices/cast.js +26 -8
  133. package/dist/slices/voices/grouping.js +10 -0
  134. package/dist/slices/voices/model.js +49 -10
  135. package/dist/slices/voices/script.js +1 -1
  136. package/dist/slices/youtube/edits-repo.js +19 -4
  137. package/dist/slices/youtube/placeholders.js +2 -2
  138. package/dist/tutorials/AI-CLIs.md +146 -0
  139. package/dist/tutorials/Backups.md +121 -0
  140. package/dist/tutorials/Calendar.md +103 -0
  141. package/dist/tutorials/Cast-Library.md +112 -0
  142. package/dist/tutorials/Channels.md +148 -0
  143. package/dist/tutorials/Costs-and-Run-Cost.md +136 -0
  144. package/dist/tutorials/Docker.md +171 -0
  145. package/dist/tutorials/Document-Themes.md +207 -0
  146. package/dist/tutorials/Editing-a-Project.md +204 -0
  147. package/dist/tutorials/FAQ.md +134 -0
  148. package/dist/tutorials/First-Launch-and-Welcome.md +153 -0
  149. package/dist/tutorials/Home-and-Projects.md +174 -0
  150. package/dist/tutorials/Home.md +119 -0
  151. package/dist/tutorials/Install.md +154 -0
  152. package/dist/tutorials/Intros-and-Outros.md +86 -0
  153. package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +215 -0
  154. package/dist/tutorials/Library-Overview.md +75 -0
  155. package/dist/tutorials/Models.md +133 -0
  156. package/dist/tutorials/Multiple-Voices.md +161 -0
  157. package/dist/tutorials/Narration-Aliases-and-Glossary.md +129 -0
  158. package/dist/tutorials/Notifications.md +86 -0
  159. package/dist/tutorials/Other-Languages.md +110 -0
  160. package/dist/tutorials/PDF-Documents.md +100 -0
  161. package/dist/tutorials/Play-Images.md +135 -0
  162. package/dist/tutorials/Play-Narration.md +167 -0
  163. package/dist/tutorials/Play-Outputs.md +109 -0
  164. package/dist/tutorials/Play-Overview.md +216 -0
  165. package/dist/tutorials/Play-Title-and-Article.md +135 -0
  166. package/dist/tutorials/Play-Video-and-Style.md +143 -0
  167. package/dist/tutorials/Project-Page.md +228 -0
  168. package/dist/tutorials/Prompts.md +136 -0
  169. package/dist/tutorials/Providers-and-Keys.md +290 -0
  170. package/dist/tutorials/Publishing-to-YouTube.md +135 -0
  171. package/dist/tutorials/Recovery-and-Retries.md +141 -0
  172. package/dist/tutorials/Reviews-and-Checkpoints.md +143 -0
  173. package/dist/tutorials/Schedules.md +154 -0
  174. package/dist/tutorials/Settings-Reference.md +258 -0
  175. package/dist/tutorials/Shorts.md +116 -0
  176. package/dist/tutorials/Start-at-Login.md +126 -0
  177. package/dist/tutorials/Studio-Extension.md +146 -0
  178. package/dist/tutorials/Templates.md +146 -0
  179. package/dist/tutorials/Trash.md +87 -0
  180. package/dist/tutorials/Troubleshooting.md +149 -0
  181. package/dist/tutorials/Uninstalling-and-Moving.md +113 -0
  182. package/dist/tutorials/Updating-and-Patch-Notes.md +120 -0
  183. package/dist/tutorials/Video-Editing.md +135 -0
  184. package/dist/tutorials/Where-Your-Files-Live.md +149 -0
  185. package/dist/tutorials/YouTube-Description.md +132 -0
  186. package/dist/tutorials/Your-First-Short.md +125 -0
  187. package/dist/tutorials/_Footer.md +1 -0
  188. package/dist/tutorials/_Sidebar.md +69 -0
  189. package/dist/web/assets/index-DBLSfqA-.css +1 -0
  190. package/dist/web/assets/index-DFdIhmm0.js +307 -0
  191. package/dist/web/assets/{pdf-BG8n_uc5.js → pdf-B9OY1ioX.js} +1 -1
  192. package/dist/web/index.html +2 -2
  193. package/package.json +2 -2
  194. package/dist/web/assets/index-CUA2sUxU.css +0 -1
  195. package/dist/web/assets/index-CX590kE-.js +0 -303
@@ -18,6 +18,23 @@ export function noticeOf(previous, next) {
18
18
  return undefined;
19
19
  }
20
20
  }
21
+ const reviewItemNames = {
22
+ article: "the article",
23
+ images: "an image",
24
+ narration: "the narration",
25
+ thumbnail: "the thumbnail",
26
+ shorts: "a short",
27
+ };
28
+ export function reviewNoticeText(subject) {
29
+ const title = subject.title.trim() === "" ? "Untitled project" : subject.title.trim();
30
+ const reason = shortReason(subject.reason);
31
+ const item = reviewItemNames[subject.stage] ?? "an item";
32
+ const headline = `Review needs a decision: ${title}`;
33
+ return {
34
+ headline: reason === undefined ? headline : `${headline} — ${reason}`,
35
+ detail: `The automatic review flagged ${item} and kept it. Open the project and press Overrule to keep it or Redo to make it again.`,
36
+ };
37
+ }
21
38
  const reasonMax = 140;
22
39
  // A provider's failure can run to paragraphs; a notification has a line.
23
40
  export function shortReason(reason) {
@@ -71,12 +88,19 @@ export function topicsNoticeText(subject) {
71
88
  }
72
89
  export const testNotice = {
73
90
  headline: "Slopify test notification",
74
- detail: "Notifications work. You'll get one when a run finishes, fails or waits for you.",
91
+ detail: "Notifications work. You'll get one when a run finishes, fails or waits for you, and when a review needs your decision.",
75
92
  };
76
93
  // The body a Notification URL receives: plain text, which ntfy shows as the message. Nothing
77
94
  // but the project's title, what happened and the provider's own short reason goes in it.
78
95
  export function webhookBody(text) {
79
- return `${text.headline}\n${text.detail}\n`;
96
+ return `${text.headline}\n${text.detail}\n${text.link === undefined ? "" : `${text.link}\n`}`;
97
+ }
98
+ // Where a project opens in the browser, from the address the server listens on. A wildcard
99
+ // (0.0.0.0 in Docker) or loopback address is reached as localhost from the same machine.
100
+ export function projectLink(host, port, projectId) {
101
+ const wildcard = host === "0.0.0.0" || host === "::" || host === "" || host === "127.0.0.1";
102
+ const authority = wildcard ? "localhost" : host.includes(":") ? `[${host}]` : host;
103
+ return `http://${authority}:${String(port)}/projects/${encodeURIComponent(projectId)}`;
80
104
  }
81
105
  export const notificationUrlMax = 2048;
82
106
  // Empty clears the setting. Otherwise one absolute http(s) URL with no user name or password:
@@ -1,29 +1,55 @@
1
+ import { readinessIsUsable } from "../../kernel/ports/model.js";
2
+ import { templateById } from "../project-templates/repo.js";
1
3
  import { starterPacks } from "./packs.js";
2
4
  import { sampleProjectIds } from "./sample.js";
3
- import { dismissFirstRun, firstRunDismissed, readPackRecords } from "./state.js";
5
+ import { firstRunDismissed, readPackRecords } from "./state.js";
4
6
  // The first-run screen shows on a fresh install and never again once it is skipped or a real
5
- // project (anything but the samples) exists. A real project also dismisses it for good, so
6
- // deleting every project later does not bring it back.
7
- export function firstRunView(db, statuses, at) {
7
+ // project (anything but the samples) exists. Reading it writes nothing: a real project that
8
+ // exists before the screen was recorded as done is reported as `settle`, and the client
9
+ // records it with POST /dismiss, so deleting every project later does not bring it back.
10
+ export function firstRunView(db, statuses) {
8
11
  const samples = sampleProjectIds(db);
9
12
  const real = db
10
13
  .prepare("SELECT 1 FROM projects WHERE id IS NOT ? AND id IS NOT ? AND id IS NOT ? LIMIT 1")
11
14
  .get(samples.library, samples.audiobook, samples.podcast) !== undefined;
12
- if (real)
13
- dismissFirstRun(db, at);
15
+ const dismissed = firstRunDismissed(db);
14
16
  const packs = readPackRecords(db);
15
17
  return {
16
- show: !real && !firstRunDismissed(db),
18
+ show: !real && !dismissed,
19
+ settle: real && !dismissed,
20
+ voice: firstVoice(statuses),
17
21
  sampleProjectId: samples.library,
18
22
  samples,
19
23
  clis: detectedClis(statuses),
20
- packs: starterPacks.map((pack) => ({
21
- id: pack.id,
22
- name: pack.name,
23
- summary: pack.summary,
24
- installed: packs[pack.id] !== undefined,
25
- templateId: packs[pack.id]?.template ?? null,
26
- })),
24
+ // A pack counts as added only with its template: the first short adds a pack's prompts and
25
+ // voice but no template, and Add pack must still be able to add that template later (or
26
+ // again, after it was deleted).
27
+ packs: starterPacks.map((pack) => {
28
+ const template = packs[pack.id]?.template;
29
+ const kept = template !== undefined && templateById(db, template) !== undefined;
30
+ return {
31
+ id: pack.id,
32
+ name: pack.name,
33
+ summary: pack.summary,
34
+ installed: kept,
35
+ templateId: kept ? template : null,
36
+ };
37
+ }),
38
+ };
39
+ }
40
+ // Who would narrate a first short: a keyed voice provider when one has a key, else the
41
+ // computer's own voice when a speech program was found.
42
+ export function firstVoice(statuses) {
43
+ const keyed = statuses.find((row) => row.family === "tts" && row.readiness.kind === "keyed" && readinessIsUsable(row.readiness));
44
+ const system = statuses.find((row) => row.readiness.kind === "local")?.readiness;
45
+ const local = system?.kind === "local" ? system : undefined;
46
+ return {
47
+ keyed: keyed?.displayName ?? null,
48
+ system: {
49
+ available: local?.available === true,
50
+ engine: local?.engine ?? null,
51
+ issue: local?.issue ?? null,
52
+ },
27
53
  };
28
54
  }
29
55
  // What this machine can make without a key: each agent CLI Slopify found, and whether it
@@ -0,0 +1,81 @@
1
+ import { projectById } from "../admission/repo.js";
2
+ import { usesShortMode } from "../admission/short-mode.js";
3
+ import { promptById } from "../library/repo.js";
4
+ import { createDraft } from "../play-drafts/service.js";
5
+ import { templateById } from "../project-templates/repo.js";
6
+ import { freshTemplateDraft } from "../project-templates/setup.js";
7
+ import { installPack } from "./install.js";
8
+ import { starterPacks, starterSet } from "./packs.js";
9
+ import { readPackRecords } from "./state.js";
10
+ import { packTemplate } from "./template.js";
11
+ // The pack whose short script the short was written from. A short made without a pack, or
12
+ // whose pack's prompts were since renamed or removed, is the starter set's.
13
+ export function shortPack(deps, articlePrompt) {
14
+ const records = readPackRecords(deps.db);
15
+ for (const pack of starterPacks) {
16
+ const id = records[pack.id]?.prompts.shortScript;
17
+ if (id !== undefined && promptById(deps.db, id)?.name === articlePrompt)
18
+ return pack;
19
+ }
20
+ return starterSet;
21
+ }
22
+ export function fullVideoDraft(deps, input) {
23
+ const project = projectById(deps.db, input.projectId);
24
+ if (project === undefined)
25
+ return { ok: false, reason: "not-found" };
26
+ const config = project.config;
27
+ if (!usesShortMode(config))
28
+ return { ok: false, reason: "not-short" };
29
+ const topic = config.values.topic?.trim() || project.title;
30
+ const pack = shortPack(deps, config.articlePrompt ?? "");
31
+ const document = packDocument(deps, pack);
32
+ if (document === undefined)
33
+ return { ok: false, reason: "not-found" };
34
+ const provider = (choice, fallback) => choice === undefined || fallback.provider !== ""
35
+ ? fallback
36
+ : {
37
+ provider: choice.provider,
38
+ model: choice.model,
39
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
40
+ };
41
+ const form = document.form;
42
+ return createDraft(deps, {
43
+ id: input.draftId,
44
+ document: {
45
+ ...document,
46
+ form: {
47
+ ...form,
48
+ title: project.title,
49
+ values: { ...form.values, topic },
50
+ llm: provider(config.llm, form.llm),
51
+ images: provider(config.images, form.images),
52
+ audio: config.audio === undefined
53
+ ? form.audio
54
+ : {
55
+ ...form.audio,
56
+ provider: config.audio.provider,
57
+ model: config.audio.model,
58
+ voice: config.audio.voice,
59
+ },
60
+ },
61
+ },
62
+ });
63
+ }
64
+ // A pack's own template, as Templates → Apply to Play opens it. The starter set has no long
65
+ // video prompt of its own and no template, so it is its settings without one saved.
66
+ function packDocument(deps, pack) {
67
+ const withTemplate = pack.id !== starterSet.id;
68
+ const installed = installPack(deps, pack.id, { template: withTemplate });
69
+ if (!installed.ok)
70
+ return undefined;
71
+ const { templateId } = installed.value;
72
+ if (!withTemplate || templateId === null)
73
+ return packTemplate(pack, installed.value.prompts, installed.value.voice ?? pack.voice.voiceId);
74
+ const template = templateById(deps.db, templateId);
75
+ if (template === undefined)
76
+ return undefined;
77
+ return freshTemplateDraft(deps, template.document, {
78
+ id: template.id,
79
+ version: template.version,
80
+ });
81
+ }
@@ -13,6 +13,22 @@ export const sampleProjectsSchema = z.object({
13
13
  export const firstRunViewSchema = z
14
14
  .object({
15
15
  show: z.boolean(),
16
+ // A real project exists but the screen was never recorded as done: the client records it
17
+ // with POST /dismiss, so deleting every project later does not bring the screen back.
18
+ // Reading this view writes nothing.
19
+ settle: z.boolean(),
20
+ // Who narrates a first short: a voice provider with a saved key, else the computer's own.
21
+ voice: z.object({
22
+ // The keyed voice provider that would be used, by name ("OpenAI"), or null.
23
+ keyed: z.string().nullable(),
24
+ system: z.object({
25
+ available: z.boolean(),
26
+ // The speech program found ("eSpeak NG", "macOS voices").
27
+ engine: z.string().nullable(),
28
+ // Why none can be used, with the fix.
29
+ issue: z.string().nullable(),
30
+ }),
31
+ }),
16
32
  // The Library of Alexandria's project, as before the demos; `samples` has all three.
17
33
  sampleProjectId: z.string().nullable(),
18
34
  samples: sampleProjectsSchema,
@@ -42,6 +58,11 @@ export const quickShortInputSchema = z
42
58
  requestId: z.uuid(),
43
59
  })
44
60
  .strict();
61
+ // A finished short's "Make the full video on this topic": the draft id is the browser's, so a
62
+ // repeated press opens the same Play draft instead of making another.
63
+ export const fullVideoInputSchema = z
64
+ .object({ projectId: z.string().min(1).max(64), draftId: z.uuid() })
65
+ .strict();
45
66
  export const quickShortResultSchema = z.object({
46
67
  projectId: z.string(),
47
68
  replayed: z.boolean(),
@@ -1,5 +1,7 @@
1
+ import { readinessIsUsable } from "../../kernel/ports/model.js";
1
2
  import { titleMax } from "../admission/rules.js";
2
3
  import { defaultSentencePauseSeconds } from "../narration/pauses-model.js";
4
+ import { systemVoiceProvider } from "../settings/model.js";
3
5
  const textClis = ["claude-code", "codex", "gemini"];
4
6
  const keyedImages = ["openai-image", "google-image", "fal", "replicate"];
5
7
  const keyedVoices = ["openai-tts", "elevenlabs", "cartesia", "inworld"];
@@ -10,12 +12,7 @@ const preferred = {
10
12
  "openai-tts": /^gpt-4o-mini-tts$/,
11
13
  };
12
14
  function ready(status) {
13
- if (status === undefined)
14
- return false;
15
- const readiness = status.readiness;
16
- return readiness.kind === "cli"
17
- ? readiness.installed && readiness.issue === undefined
18
- : readiness.hasKey;
15
+ return status !== undefined && readinessIsUsable(status.readiness);
19
16
  }
20
17
  async function firstModel(modelsFor, provider, family) {
21
18
  let models;
@@ -62,8 +59,11 @@ export async function planShortProviders(input) {
62
59
  need: "images",
63
60
  message: "No image model is ready. Install Codex CLI and sign in to it, or add an OpenAI, Google, fal.ai or Replicate key in Settings → Providers.",
64
61
  });
62
+ // A keyed voice first (a saved one, else the pack's suggestion), then the computer's own: a
63
+ // saved system voice, else the one found. Keys are the better voice, never a requirement.
65
64
  let audio;
66
- const saved = input.voices.find((voice) => ready(status(voice.provider)));
65
+ const keyed = (provider) => provider !== systemVoiceProvider;
66
+ const saved = input.voices.find((voice) => keyed(voice.provider) && ready(status(voice.provider)));
67
67
  const provider = saved?.provider ??
68
68
  (ready(status(input.suggested.provider)) ? input.suggested.provider : undefined);
69
69
  if (provider !== undefined) {
@@ -71,16 +71,31 @@ export async function planShortProviders(input) {
71
71
  if (model !== undefined)
72
72
  audio = { provider, model, voice: saved?.voiceId ?? input.suggested.voiceId };
73
73
  }
74
- if (audio === undefined)
74
+ let systemVoice = false;
75
+ const system = status(systemVoiceProvider);
76
+ if (audio === undefined && ready(system) && input.systemVoice !== undefined) {
77
+ const savedSystem = input.voices.find((voice) => voice.provider === systemVoiceProvider);
78
+ audio = {
79
+ provider: systemVoiceProvider,
80
+ model: input.systemVoice.model,
81
+ voice: savedSystem?.voiceId ?? input.systemVoice.voiceId,
82
+ };
83
+ systemVoice = true;
84
+ }
85
+ if (audio === undefined) {
86
+ const systemIssue = system?.readiness.kind === "local" && system.readiness.issue !== undefined
87
+ ? ` ${system.readiness.issue}`
88
+ : "";
75
89
  gaps.push({
76
90
  need: "voice",
77
91
  message: keyedVoices.some((id) => ready(status(id)))
78
92
  ? "Your voice provider has no saved voice yet. Add one in Settings → Voices, then try again."
79
- : "No voice is ready to narrate the short: command-line tools can't speak. Add an OpenAI, ElevenLabs, Cartesia or Inworld key in Settings → Providers (with ElevenLabs, Cartesia or Inworld, also save a voice in Settings → Voices).",
93
+ : `No voice is ready to narrate the short: no voice key is saved and this computer's built-in voice can't be used.${systemIssue || " Install espeak-ng, or add an OpenAI, ElevenLabs, Cartesia or Inworld key in Settings → Providers (with ElevenLabs, Cartesia or Inworld, also save a voice in Settings → Voices)."}`,
80
94
  });
95
+ }
81
96
  return llm === undefined || images === undefined || audio === undefined
82
97
  ? { ok: false, gaps }
83
- : { ok: true, providers: { llm, audio, images } };
98
+ : { ok: true, providers: { llm, audio, images }, systemVoice };
84
99
  }
85
100
  // The short-mode draft for a topic, using the pack's (or starter set's) script and scene
86
101
  // prompts under the names they have in the library.
@@ -12,13 +12,13 @@ import { defaultSubtitles } from "../subtitles/model.js";
12
12
  import { ambientBedOfForm, ambientBedProblems } from "../video/ambient-bed.js";
13
13
  // Said where the control is, since the music sits in a closed disclosure on the Export rail.
14
14
  const musicMessages = {
15
- uploading: "The shorts' background music is still uploading, so the run can't start yet. Wait for it to finish, or remove it under Outputs → Export → More shorts options → Background music.",
16
- missing: "The shorts' background music file is no longer available (it was not uploaded again after the draft was copied, or the upload failed). Choose the file again under Outputs → Export → More shorts options → Background music, or remove it.",
15
+ uploading: "The shorts' background music is still uploading, so the run can't start yet. Wait for it to finish, or remove it under Outputs → More shorts options → Background music.",
16
+ missing: "The shorts' background music file is no longer available (it was not uploaded again after the draft was copied, or the upload failed). Choose the file again under Outputs → More shorts options → Background music, or remove it.",
17
17
  };
18
18
  function pickMusic(id) {
19
19
  return id === undefined ? {} : { shortsMusic: id };
20
20
  }
21
- const bedWhere = "under Outputs → Export → Ambient sound";
21
+ const bedWhere = "under Video and style → Ambient sound";
22
22
  const bedMessages = {
23
23
  uploading: `The ambient sound's audio file is still uploading, so the run can't start yet. Wait for it to finish ${bedWhere}.`,
24
24
  missing: `The ambient sound's audio file is missing (it was never chosen, was not uploaded again after the draft was copied, or the upload failed). Choose the file ${bedWhere}, or pick Rain, Fireplace or Wind instead.`,
@@ -54,7 +54,7 @@ export async function checkDraftReadiness(deps, runs) {
54
54
  if (!provider || !readinessIsUsable(provider.readiness)) {
55
55
  fields.push({
56
56
  field: c.field,
57
- message: provider?.readiness.kind === "cli" && provider.readiness.issue
57
+ message: provider !== undefined && provider.readiness.kind !== "keyed" && provider.readiness.issue
58
58
  ? provider.readiness.issue
59
59
  : "Set up this provider in Settings → Providers before starting.",
60
60
  });
@@ -13,11 +13,12 @@ import { currentRevisionId, revisionById } from "../revisions/repo.js";
13
13
  import { shortsExtrasForm } from "../shorts/model.js";
14
14
  import { ambientBedFormOf } from "../video/ambient-bed.js";
15
15
  import { usesScriptPrompt } from "../voices/model.js";
16
+ import { templateNameMax } from "./schema.js";
16
17
  import { createTemplate, readTemplate } from "./service.js";
17
18
  export const projectTemplateCreateSchema = z
18
19
  .object({
19
20
  id: z.uuid(),
20
- name: z.string().trim().min(1).max(200),
21
+ name: z.string().trim().min(1).max(templateNameMax),
21
22
  projectId: z.string().min(1).max(64),
22
23
  revisionId: z.string().min(1).max(64),
23
24
  })
@@ -65,7 +66,7 @@ export function createTemplateFromProject(deps, input) {
65
66
  return result;
66
67
  });
67
68
  }
68
- function documentFromProject(deps, revision) {
69
+ export function documentFromProject(deps, revision) {
69
70
  const config = revision.config;
70
71
  const prompts = [];
71
72
  const entries = [];
@@ -211,7 +212,9 @@ function documentFromProject(deps, revision) {
211
212
  // it cuts every N seconds like the project did.
212
213
  ...(config.videoEdit === undefined ? {} : { videoEdit: config.videoEdit }),
213
214
  ...(config.reviews === undefined ? {} : { reviews: reviewSettingsForm(config.reviews) }),
214
- ...(config.voices === undefined ? {} : { voices: config.voices }),
215
+ // Without the book: every run of a template would otherwise be the same chapter. "Make the
216
+ // next chapter" (`next-chapter.ts`) sets it itself.
217
+ ...(config.voices === undefined ? {} : { voices: withoutBook(config.voices) }),
215
218
  ...(config.ambientBed === undefined
216
219
  ? {}
217
220
  : { ambientBed: ambientBedFormOf(config.ambientBed) }),
@@ -265,3 +268,7 @@ function documentFromProject(deps, revision) {
265
268
  fontUpload: null,
266
269
  };
267
270
  }
271
+ function withoutBook(voices) {
272
+ const { book: _book, ...rest } = voices;
273
+ return rest;
274
+ }
@@ -0,0 +1,94 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { projectById } from "../admission/repo.js";
4
+ import { projectChannelId } from "../channels/repo.js";
5
+ import { draftRow } from "../play-drafts/repo.js";
6
+ import { createDraft, readDraft } from "../play-drafts/service.js";
7
+ import { currentRevisionId, revisionById } from "../revisions/repo.js";
8
+ import { bookChapterMax, bookLabel, bookTitleMax } from "../voices/model.js";
9
+ import { documentFromProject } from "./from-project.js";
10
+ // Books: an audiobook is written chapter by chapter, one project each. "Make the next chapter"
11
+ // on a chapter opens a new Play draft with everything the book shares - format, speakers and
12
+ // their voices, channel (and so its cast), prompts and settings - and the chapter number one
13
+ // higher. The chapter's own words are left empty: the new chapter is written or pasted fresh,
14
+ // and files the last one was given are listed to attach again, as a template's are.
15
+ export const nextChapterSchema = z
16
+ .object({ id: z.uuid(), projectId: z.string().min(1).max(64) })
17
+ .strict()
18
+ .readonly();
19
+ // The book a project's next chapter belongs to: its own book one chapter on, or, for an
20
+ // audiobook made on its own, a book named after it whose second chapter comes next.
21
+ export function nextBook(title, book) {
22
+ return book === undefined
23
+ ? { title: title.trim().slice(0, bookTitleMax) || "Untitled book", chapter: 2 }
24
+ : { title: book.title, chapter: Math.min(bookChapterMax, book.chapter + 1) };
25
+ }
26
+ export function makeNextChapter(deps, input) {
27
+ const parsed = nextChapterSchema.safeParse(input);
28
+ if (!parsed.success)
29
+ return {
30
+ ok: false,
31
+ reason: "not-found",
32
+ message: "That project could not be found. Open it again from Projects, then try again.",
33
+ };
34
+ const { id, projectId } = parsed.data;
35
+ return transact(deps.db, () => {
36
+ // Pressed twice (a retry after a dropped answer): the draft it made the first time.
37
+ if (draftRow(deps.db, id) !== undefined) {
38
+ const made = readDraft(deps, id);
39
+ if (made.ok)
40
+ return { ok: true, value: made.value };
41
+ }
42
+ const project = projectById(deps.db, projectId);
43
+ const current = project === undefined ? undefined : currentRevisionId(deps.db, project.id);
44
+ const revision = project === undefined || current === undefined
45
+ ? undefined
46
+ : revisionById(deps.db, project.id, current);
47
+ if (project === undefined || revision === undefined)
48
+ return {
49
+ ok: false,
50
+ reason: "not-found",
51
+ message: "That project could not be found; it may have been deleted. Open Projects and pick another chapter.",
52
+ };
53
+ const voices = revision.config.voices;
54
+ if (voices?.format !== "audiobook")
55
+ return {
56
+ ok: false,
57
+ reason: "not-an-audiobook",
58
+ message: "Only an audiobook has chapters. Pick Audiobook under Speakers (Play → Narration) to start a book.",
59
+ };
60
+ const book = nextBook(revision.config.title, voices.book);
61
+ const document = documentFromProject(deps, revision);
62
+ const form = document.form;
63
+ const result = createDraft(deps, {
64
+ id,
65
+ document: {
66
+ ...document,
67
+ channelId: projectChannelId(deps.db, project.id),
68
+ form: {
69
+ ...form,
70
+ title: bookLabel(book),
71
+ voices: { ...voices, book },
72
+ provided: {
73
+ ...form.provided,
74
+ // The new chapter's own words, written or pasted fresh.
75
+ research: "",
76
+ article: "",
77
+ },
78
+ },
79
+ },
80
+ });
81
+ if (!result.ok)
82
+ return {
83
+ ok: false,
84
+ reason: "conflict",
85
+ message: "The next chapter's draft could not be made. Press Make the next chapter again; if it keeps failing, use Download diagnostics in Settings and report it.",
86
+ };
87
+ // Files the last chapter was given are attached again on Play, as with a template.
88
+ deps.db.prepare("UPDATE play_draft_attachments SET status='reattach' WHERE draft_id=?").run(id);
89
+ const made = readDraft(deps, id);
90
+ if (!made.ok)
91
+ throw new Error("The next chapter's draft could not be read back");
92
+ return { ok: true, value: made.value };
93
+ });
94
+ }
@@ -1,6 +1,6 @@
1
1
  import { detectSlots } from "../admission/substitute.js";
2
2
  // A template keeps the settings, never what was typed for one video. A keyword the project
3
- // title names ({{Topic}} in "D&D Lore: {{Topic}}") is the video's topic: it changes with every
3
+ // title names ({{Topic}} in "History: {{Topic}}") is the video's topic: it changes with every
4
4
  // video, so a template stores it empty. Every other keyword ({{minWords}}, {{style}}) is a
5
5
  // setting and keeps its value.
6
6
  export function topicKeywords(title) {
@@ -1,9 +1,14 @@
1
1
  import { z } from "zod";
2
2
  import { playDraftDocumentSchema } from "../play-drafts/schema.js";
3
+ // The longest template name, in characters. Play's Save as template and Library → Templates
4
+ // (save and rename) cap their name fields at it, and a new template is refused past it.
5
+ export const templateNameMax = 120;
6
+ // A template named before the cap (Play once allowed 200) can still be saved under its name.
7
+ const templateNameStoredMax = 200;
3
8
  export const templateCreateSchema = z
4
9
  .object({
5
10
  id: z.uuid(),
6
- name: z.string().trim().min(1).max(200),
11
+ name: z.string().trim().min(1).max(templateNameMax),
7
12
  document: playDraftDocumentSchema,
8
13
  })
9
14
  .strict()
@@ -11,6 +16,7 @@ export const templateCreateSchema = z
11
16
  export const templateUpdateSchema = templateCreateSchema
12
17
  .unwrap()
13
18
  .extend({
19
+ name: z.string().trim().min(1).max(templateNameStoredMax),
14
20
  baseVersion: z.number().int().positive(),
15
21
  mutationId: z.uuid(),
16
22
  })
@@ -67,6 +67,11 @@ export function exportRecipes(context, audio) {
67
67
  (audio.sections ?? []).map((section) => [section.title, section.firstTurn]),
68
68
  config.title,
69
69
  ...master.values,
70
+ // The book's title and chapter become the files' album and track tags; a project
71
+ // that is no chapter of a book keeps the values it always had.
72
+ ...(voices.book === undefined
73
+ ? []
74
+ : [["book-v1", voices.book.title, voices.book.chapter]]),
70
75
  ],
71
76
  }, [...audio.keys, timing.key, ...master.keys], { unresolved: audio.sections === undefined }));
72
77
  if (config.subtitles === undefined || config.subtitles.mode === "off")
@@ -249,7 +249,7 @@ function speakerGlossary(context, speaker, glossary) {
249
249
  if (!own.ok)
250
250
  return {
251
251
  ok: false,
252
- reason: `${speaker.name.trim()}'s pronunciations can't be used: ${own.reason.replace("Edit the glossary or turn off Use Pronunciation Glossary.", "Fix them under Speakers (Play → Audio, or Edit project → Providers).")}`,
252
+ reason: `${speaker.name.trim()}'s pronunciations can't be used: ${own.reason.replace("Edit the glossary or turn off Use Pronunciation Glossary.", "Fix them under Speakers (Play → Narration, or Edit project → Providers).")}`,
253
253
  };
254
254
  const run = context.config.audio?.usePronunciationGlossary === true && glossary?.ok === true
255
255
  ? glossary.entries
@@ -71,7 +71,22 @@ export function createReviewRedos() {
71
71
  kick,
72
72
  };
73
73
  }
74
+ // The verdicts whose redo is being started right now. Overrule may call off a redo only while
75
+ // it waits, not once Slopify has begun making the item again.
76
+ const starting = new Set();
77
+ export function redoStarting(verdictId) {
78
+ return starting.has(verdictId);
79
+ }
74
80
  export async function startRedo(deps, verdict) {
81
+ starting.add(verdict.id);
82
+ try {
83
+ await makeAgain(deps, verdict);
84
+ }
85
+ finally {
86
+ starting.delete(verdict.id);
87
+ }
88
+ }
89
+ async function makeAgain(deps, verdict) {
75
90
  const base = currentRevisionId(deps.db, verdict.projectId);
76
91
  if (base === undefined) {
77
92
  setRedoState(deps.db, verdict.id, "failed", "The project no longer exists.");
@@ -17,7 +17,7 @@ export async function exportBed(deps, context, view) {
17
17
  };
18
18
  if (bed.source !== "upload")
19
19
  return { source: { kind: "noise", preset: bed.source }, ...settings };
20
- const fix = "Make the video again from Play with another file under Outputs → Export → Ambient sound, or pick Rain, Fireplace or Wind there instead.";
20
+ const fix = "Make the video again from Play with another file under Video and style → Ambient sound, or pick Rain, Fireplace or Wind there instead.";
21
21
  const assetId = view.revision.content.ambientBed;
22
22
  const row = z
23
23
  .object({ path: z.string() })
@@ -8,7 +8,7 @@ import { latestVerdict, saveVerdict } from "../reviews/repo.js";
8
8
  import { parseVerdict, reviewMessages } from "../reviews/verdict.js";
9
9
  import { pickedShortsOf } from "../shorts/clips.js";
10
10
  import { outputPath } from "../storage/layout.js";
11
- import { exportSnapshot } from "./runtime-export-inputs.js";
11
+ import { exportSnapshot, revisionTranscript, } from "./runtime-export-inputs.js";
12
12
  import { savedCatalogue } from "./runtime-plan.js";
13
13
  import { publishResult } from "./runtime-publication.js";
14
14
  import { wordsSchema } from "./runtime-subtitles.js";
@@ -46,8 +46,8 @@ export async function executeReviewRecipe(deps, context, providers, piece) {
46
46
  if (values === undefined || !values.success)
47
47
  throw new Error("Slopify hit an internal error (a review step was set up wrongly). Try again; if it happens again, use Download diagnostics in Settings and report it.");
48
48
  const [stage, itemKey, , provider, model, thinking, prompt, mode, retries] = values.data;
49
- const { view, plan } = exportSnapshot(deps, context, piece);
50
- const material = reviewMaterial(deps, context, view, plan.recipes, stage, itemKey, prompt);
49
+ const snapshot = exportSnapshot(deps, context, piece);
50
+ const material = reviewMaterial(deps, context, snapshot, stage, itemKey, prompt);
51
51
  const previous = latestVerdict(deps.db, context.work.projectId, itemKey);
52
52
  const attempt = nextAttempt(previous, material.itemFingerprint);
53
53
  const row = deps.db
@@ -102,6 +102,16 @@ export async function executeReviewRecipe(deps, context, providers, piece) {
102
102
  outcome: saved.outcome,
103
103
  attempt: saved.attempt,
104
104
  });
105
+ // Kept and flagged: nothing more happens to the item until someone decides.
106
+ if (saved.outcome === "flagged")
107
+ context.emit({
108
+ type: "review.flagged",
109
+ projectId: context.work.projectId,
110
+ verdictId: saved.id,
111
+ stage: saved.stage,
112
+ itemKey: saved.itemKey,
113
+ ...(saved.reasons[0] === undefined ? {} : { reason: saved.reasons[0] }),
114
+ });
105
115
  deps.count?.("stage.completed", {
106
116
  provider,
107
117
  model,
@@ -110,7 +120,27 @@ export async function executeReviewRecipe(deps, context, providers, piece) {
110
120
  });
111
121
  return "done";
112
122
  }
113
- function reviewMaterial(deps, context, view, recipes, stage, itemKey, prompt) {
123
+ // What the voice was asked to say, segment by segment, as the word timing matched it: the intro
124
+ // and outro, and the prepared or hand-edited spoken text rather than the article as written.
125
+ // Falls back to the article when those texts can't be read.
126
+ function spokenText(deps, snapshot) {
127
+ const present = ["intro", "body", "outro"].filter((kind) => snapshot.view.outputs.some((one) => one.selected &&
128
+ one.available &&
129
+ one.state === "ready" &&
130
+ one.output.role === `audio_${kind}`));
131
+ try {
132
+ const text = present.map((kind) => revisionTranscript(deps, snapshot, kind)).join("\n\n");
133
+ if (text.trim() !== "")
134
+ return text;
135
+ }
136
+ catch {
137
+ // The article below is still a fair reference.
138
+ }
139
+ return plainText(snapshot.view.articleMarkdown ?? "");
140
+ }
141
+ function reviewMaterial(deps, context, snapshot, stage, itemKey, prompt) {
142
+ const { view } = snapshot;
143
+ const recipes = snapshot.plan.recipes;
114
144
  const config = view.revision.config;
115
145
  const output = (workKey, role) => view.outputs.find((row) => row.workKey === workKey &&
116
146
  row.selected &&
@@ -186,7 +216,7 @@ function reviewMaterial(deps, context, view, recipes, stage, itemKey, prompt) {
186
216
  request: request([
187
217
  {
188
218
  label: "Text the narration was read from",
189
- text: clip(plainText(view.articleMarkdown ?? "")),
219
+ text: clip(spokenText(deps, snapshot)),
190
220
  },
191
221
  { label: "What the timing heard", text: clip(words.map((word) => word.text).join(" ")) },
192
222
  {