@gentbajko/slopify 3.0.0 → 3.0.2

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 (198) hide show
  1. package/README.md +6 -4
  2. package/dist/adapter-registry.js +28 -0
  3. package/dist/adapters/alignment/text.js +3 -1
  4. package/dist/adapters/key-probes.js +57 -8
  5. package/dist/adapters/tts/gemini.js +206 -0
  6. package/dist/adapters/tts/pcm-mp3.js +62 -0
  7. package/dist/adapters/tts/system.js +147 -0
  8. package/dist/assets/models.yaml +38 -0
  9. package/dist/assets/style-preview/narration.mp3 +0 -0
  10. package/dist/assets/style-preview/tall-1.jpg +0 -0
  11. package/dist/assets/style-preview/tall-2.jpg +0 -0
  12. package/dist/assets/style-preview/tall-3.jpg +0 -0
  13. package/dist/assets/style-preview/wide-1.jpg +0 -0
  14. package/dist/assets/style-preview/wide-2.jpg +0 -0
  15. package/dist/assets/style-preview/wide-3.jpg +0 -0
  16. package/dist/catalog/registry.js +4 -1
  17. package/dist/catalog/validate.js +2 -2
  18. package/dist/edge/autostart/docker-record.js +2 -2
  19. package/dist/edge/autostart/docker.js +68 -0
  20. package/dist/edge/autostart/prompt.js +7 -1
  21. package/dist/edge/autostart/service.js +18 -9
  22. package/dist/edge/cli-args.js +71 -0
  23. package/dist/edge/cli.js +11 -16
  24. package/dist/edge/docker-install/run.js +7 -1
  25. package/dist/edge/events/hub.js +3 -1
  26. package/dist/edge/http/actions.js +6 -1
  27. package/dist/edge/http/app.js +2 -0
  28. package/dist/edge/http/byte-range.js +42 -0
  29. package/dist/edge/http/diagnostics.js +1 -0
  30. package/dist/edge/http/draft-files.js +2 -4
  31. package/dist/edge/http/files.js +10 -6
  32. package/dist/edge/http/onboarding.js +64 -4
  33. package/dist/edge/http/project-templates.js +19 -2
  34. package/dist/edge/http/projects.js +6 -1
  35. package/dist/edge/http/providers.js +39 -9
  36. package/dist/edge/http/reviews.js +18 -8
  37. package/dist/edge/http/revision-files.js +2 -4
  38. package/dist/edge/http/schedules.js +9 -5
  39. package/dist/edge/http/storage.js +16 -2
  40. package/dist/edge/http/studio.js +111 -31
  41. package/dist/edge/http/style-preview.js +3 -42
  42. package/dist/edge/http/tutorials.js +59 -0
  43. package/dist/edge/http/youtube-edits.js +11 -2
  44. package/dist/extension/slopify-studio-chrome.zip +0 -0
  45. package/dist/extension/slopify-studio-firefox.zip +0 -0
  46. package/dist/host-cli/gemini-login.js +83 -0
  47. package/dist/host-cli/status.js +4 -2
  48. package/dist/kernel/db/migrations/0041-cast-hosts.sql +3 -0
  49. package/dist/kernel/ports/model.js +5 -3
  50. package/dist/kernel/ports/system-speech.js +203 -0
  51. package/dist/kernel/runner/graph.js +17 -1
  52. package/dist/kernel/runner/index.js +4 -4
  53. package/dist/kernel/runner/providers.js +1 -1
  54. package/dist/main.js +5 -0
  55. package/dist/model-catalog.js +2 -0
  56. package/dist/patch-notes/3.0.1.md +105 -0
  57. package/dist/patch-notes/3.0.2.md +20 -0
  58. package/dist/patch-notes/index.json +12 -0
  59. package/dist/slices/admission/rules.js +3 -3
  60. package/dist/slices/admission/start.js +1 -1
  61. package/dist/slices/channels/cast-match.js +1 -1
  62. package/dist/slices/channels/repo.js +3 -0
  63. package/dist/slices/channels/schema.js +23 -0
  64. package/dist/slices/channels/service.js +6 -6
  65. package/dist/slices/control/index.js +3 -1
  66. package/dist/slices/control/providers.js +4 -1
  67. package/dist/slices/episodes/related.js +2 -2
  68. package/dist/slices/estimate/requests.js +9 -1
  69. package/dist/slices/eta/history.js +56 -0
  70. package/dist/slices/eta/model.js +63 -0
  71. package/dist/slices/eta/view.js +25 -0
  72. package/dist/slices/fixes/rules.js +1 -1
  73. package/dist/slices/fonts/coverage.js +1 -1
  74. package/dist/slices/loudness/model.js +1 -1
  75. package/dist/slices/model-upkeep/usage.js +4 -2
  76. package/dist/slices/narration/pauses-model.js +1 -1
  77. package/dist/slices/narration/pronunciation.js +2 -2
  78. package/dist/slices/notifications/notifier.js +28 -2
  79. package/dist/slices/notifications/rules.js +26 -2
  80. package/dist/slices/onboarding/first-run.js +40 -14
  81. package/dist/slices/onboarding/full-video.js +81 -0
  82. package/dist/slices/onboarding/model.js +21 -0
  83. package/dist/slices/onboarding/quick-short.js +25 -10
  84. package/dist/slices/play-drafts/convert.js +3 -3
  85. package/dist/slices/play-drafts/readiness.js +1 -1
  86. package/dist/slices/project-templates/from-project.js +10 -3
  87. package/dist/slices/project-templates/next-chapter.js +94 -0
  88. package/dist/slices/project-templates/one-off.js +1 -1
  89. package/dist/slices/project-templates/schema.js +7 -1
  90. package/dist/slices/rebuild/recipe-exports.js +5 -0
  91. package/dist/slices/rebuild/recipe-save.js +1 -1
  92. package/dist/slices/rebuild/recipe-voices.js +1 -1
  93. package/dist/slices/rebuild/review-redo.js +15 -0
  94. package/dist/slices/rebuild/runtime-export-bed.js +1 -1
  95. package/dist/slices/rebuild/runtime-review.js +35 -5
  96. package/dist/slices/rebuild/runtime-store.js +23 -1
  97. package/dist/slices/rebuild/runtime-voices.js +2 -2
  98. package/dist/slices/rebuild/service-readiness.js +3 -3
  99. package/dist/slices/reviews/repo.js +16 -4
  100. package/dist/slices/run-cost/panel.js +30 -8
  101. package/dist/slices/run-cost/pricing.js +4 -1
  102. package/dist/slices/schedules/agenda.js +66 -2
  103. package/dist/slices/schedules/repo.js +1 -1
  104. package/dist/slices/schedules/scheduler.js +1 -1
  105. package/dist/slices/schedules/schema.js +43 -2
  106. package/dist/slices/schedules/similar.js +41 -3
  107. package/dist/slices/schedules/topic-list.js +2 -2
  108. package/dist/slices/schedules/topics.js +118 -17
  109. package/dist/slices/settings/first-run.js +5 -1
  110. package/dist/slices/settings/health.js +64 -7
  111. package/dist/slices/settings/key-guides.js +17 -7
  112. package/dist/slices/settings/key-test.js +12 -4
  113. package/dist/slices/settings/keys.js +10 -5
  114. package/dist/slices/settings/model-reach.js +52 -0
  115. package/dist/slices/settings/model.js +49 -0
  116. package/dist/slices/settings/models.js +5 -1
  117. package/dist/slices/settings/readiness.js +14 -2
  118. package/dist/slices/settings/tutorial-schema.js +5 -0
  119. package/dist/slices/shorts/render.js +18 -17
  120. package/dist/slices/storage/documents.js +11 -3
  121. package/dist/slices/storage/downloads.js +33 -1
  122. package/dist/slices/storage/portable.js +4 -2
  123. package/dist/slices/studio/model.js +2 -0
  124. package/dist/slices/studio/pack.js +6 -4
  125. package/dist/slices/studio/queue.js +69 -0
  126. package/dist/slices/studio/settings.js +42 -5
  127. package/dist/slices/style-preview/narration.js +33 -0
  128. package/dist/slices/style-preview/render.js +43 -56
  129. package/dist/slices/style-preview/schema.js +17 -3
  130. package/dist/slices/style-preview/service.js +3 -4
  131. package/dist/slices/style-preview/settings.js +75 -25
  132. package/dist/slices/tutorials/anchors.js +57 -0
  133. package/dist/slices/tutorials/library.js +185 -0
  134. package/dist/slices/voices/audio-files.js +7 -2
  135. package/dist/slices/voices/cast.js +26 -8
  136. package/dist/slices/voices/grouping.js +10 -0
  137. package/dist/slices/voices/model.js +49 -10
  138. package/dist/slices/voices/script.js +1 -1
  139. package/dist/slices/youtube/edits-repo.js +19 -4
  140. package/dist/slices/youtube/placeholders.js +2 -2
  141. package/dist/tutorials/AI-CLIs.md +146 -0
  142. package/dist/tutorials/Backups.md +121 -0
  143. package/dist/tutorials/Calendar.md +103 -0
  144. package/dist/tutorials/Cast-Library.md +112 -0
  145. package/dist/tutorials/Channels.md +148 -0
  146. package/dist/tutorials/Costs-and-Run-Cost.md +136 -0
  147. package/dist/tutorials/Docker.md +171 -0
  148. package/dist/tutorials/Document-Themes.md +207 -0
  149. package/dist/tutorials/Editing-a-Project.md +204 -0
  150. package/dist/tutorials/FAQ.md +134 -0
  151. package/dist/tutorials/First-Launch-and-Welcome.md +153 -0
  152. package/dist/tutorials/Home-and-Projects.md +174 -0
  153. package/dist/tutorials/Home.md +119 -0
  154. package/dist/tutorials/Install.md +154 -0
  155. package/dist/tutorials/Intros-and-Outros.md +86 -0
  156. package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +215 -0
  157. package/dist/tutorials/Library-Overview.md +75 -0
  158. package/dist/tutorials/Models.md +133 -0
  159. package/dist/tutorials/Multiple-Voices.md +161 -0
  160. package/dist/tutorials/Narration-Aliases-and-Glossary.md +129 -0
  161. package/dist/tutorials/Notifications.md +86 -0
  162. package/dist/tutorials/Other-Languages.md +110 -0
  163. package/dist/tutorials/PDF-Documents.md +100 -0
  164. package/dist/tutorials/Play-Images.md +135 -0
  165. package/dist/tutorials/Play-Narration.md +167 -0
  166. package/dist/tutorials/Play-Outputs.md +109 -0
  167. package/dist/tutorials/Play-Overview.md +216 -0
  168. package/dist/tutorials/Play-Title-and-Article.md +135 -0
  169. package/dist/tutorials/Play-Video-and-Style.md +143 -0
  170. package/dist/tutorials/Project-Page.md +228 -0
  171. package/dist/tutorials/Prompts.md +136 -0
  172. package/dist/tutorials/Providers-and-Keys.md +290 -0
  173. package/dist/tutorials/Publishing-to-YouTube.md +135 -0
  174. package/dist/tutorials/Recovery-and-Retries.md +141 -0
  175. package/dist/tutorials/Reviews-and-Checkpoints.md +143 -0
  176. package/dist/tutorials/Schedules.md +154 -0
  177. package/dist/tutorials/Settings-Reference.md +258 -0
  178. package/dist/tutorials/Shorts.md +116 -0
  179. package/dist/tutorials/Start-at-Login.md +126 -0
  180. package/dist/tutorials/Studio-Extension.md +146 -0
  181. package/dist/tutorials/Templates.md +146 -0
  182. package/dist/tutorials/Trash.md +87 -0
  183. package/dist/tutorials/Troubleshooting.md +149 -0
  184. package/dist/tutorials/Uninstalling-and-Moving.md +113 -0
  185. package/dist/tutorials/Updating-and-Patch-Notes.md +120 -0
  186. package/dist/tutorials/Video-Editing.md +135 -0
  187. package/dist/tutorials/Where-Your-Files-Live.md +149 -0
  188. package/dist/tutorials/YouTube-Description.md +132 -0
  189. package/dist/tutorials/Your-First-Short.md +125 -0
  190. package/dist/tutorials/_Footer.md +1 -0
  191. package/dist/tutorials/_Sidebar.md +69 -0
  192. package/dist/web/assets/index-C4McGNke.js +307 -0
  193. package/dist/web/assets/index-DBLSfqA-.css +1 -0
  194. package/dist/web/assets/{pdf-BG8n_uc5.js → pdf-COowcLcz.js} +1 -1
  195. package/dist/web/index.html +2 -2
  196. package/package.json +2 -2
  197. package/dist/web/assets/index-CUA2sUxU.css +0 -1
  198. package/dist/web/assets/index-CX590kE-.js +0 -303
@@ -1,6 +1,6 @@
1
1
  import { thinkingModes } from "../kernel/ports/llm.js";
2
2
  import { providerError } from "../kernel/ports/model.js";
3
- import { isLocalCliProvider } from "../slices/settings/model.js";
3
+ import { isLocalCliProvider, systemVoiceProvider } from "../slices/settings/model.js";
4
4
  import { referenceRefusal, takesReferenceImage } from "./schema.js";
5
5
  export function curateRegistry(registry, catalogue) {
6
6
  const requireModel = (provider, family, id) => {
@@ -146,6 +146,9 @@ export function curateRegistry(registry, catalogue) {
146
146
  },
147
147
  tts: (id) => {
148
148
  const port = registry.tts(id);
149
+ // The system voice's models are the speech programs on this computer, not list rows.
150
+ if (id === systemVoiceProvider)
151
+ return port;
149
152
  return {
150
153
  ...port,
151
154
  models: async () => catalogue.models(id, "tts"),
@@ -1,5 +1,5 @@
1
1
  import { usesNarrationPreparation, usesReference, usesShorts, usesYoutubeDescription, } from "../slices/admission/rules.js";
2
- import { isLocalCliProvider } from "../slices/settings/model.js";
2
+ import { isLocalCliProvider, isUncataloguedProvider } from "../slices/settings/model.js";
3
3
  import { usesAnimation } from "../slices/video/edit-settings.js";
4
4
  import { referenceRefusal, takesReferenceImage, videoModelsOf } from "./schema.js";
5
5
  export function modelFields(draft, catalogue) {
@@ -32,7 +32,7 @@ export function modelFields(draft, catalogue) {
32
32
  },
33
33
  ];
34
34
  for (const { field, family, choice, needed } of checks) {
35
- if (!needed || !choice || isLocalCliProvider(choice.provider))
35
+ if (!needed || !choice || isUncataloguedProvider(choice.provider))
36
36
  continue;
37
37
  const model = catalogue.models(choice.provider, family).find((m) => m.id === choice.model);
38
38
  if (!model)
@@ -1,6 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import { installSchema, readState, writeState } from "../docker-install/state.js";
3
- import { checkDockerStart, loginStartFile, loginStartReport, loginStartSchema, } from "./docker.js";
3
+ import { inspectDockerStart, loginStartFile, loginStartReport, loginStartSchema, } from "./docker.js";
4
4
  import { autostartQuestion } from "./prompt.js";
5
5
  /**
6
6
  * The --docker installer's part: ask "Start Slopify when you log in?" once (or take
@@ -20,9 +20,9 @@ export async function recordLoginStart(o) {
20
20
  const record = {
21
21
  version: 1,
22
22
  checkedAt: o.now().toISOString(),
23
- docker: await checkDockerStart(o.exec, manager),
24
23
  manager,
25
24
  wanted,
25
+ ...(await inspectDockerStart({ exec: o.exec, manager, host: o.host })),
26
26
  };
27
27
  await writeState(path, record);
28
28
  o.report(wanted === false
@@ -1,3 +1,4 @@
1
+ import { posix, win32 } from "node:path";
1
2
  import { z } from "zod";
2
3
  // Slopify in Docker restarts with Docker (`restart: unless-stopped` in compose.yaml), so it
3
4
  // starts at login exactly when Docker does. Slopify never changes Docker's own setting; the
@@ -15,6 +16,10 @@ export const loginStartSchema = z
15
16
  manager: z.enum(["system", "rootless"]),
16
17
  // The installer's "Start Slopify when you log in?" answer, or null when it wasn't asked.
17
18
  wanted: z.boolean().nullable(),
19
+ // The system the installer ran on, and whether its Docker is Docker Desktop, which has its
20
+ // own sign-in setting instead of a systemd service. Absent in records from before 3.0.1.
21
+ platform: z.enum(["linux", "darwin", "win32", "other"]).optional(),
22
+ desktop: z.boolean().optional(),
18
23
  })
19
24
  .strict();
20
25
  export function dockerHowTo(manager) {
@@ -22,6 +27,17 @@ export function dockerHowTo(manager) {
22
27
  ? "Turn on Docker's start at login in a terminal: systemctl --user enable docker (rootless Docker). Slopify then starts with it."
23
28
  : "Turn on Docker's start with the computer in a terminal: sudo systemctl enable docker. Slopify then starts with it.";
24
29
  }
30
+ export const dockerDesktopHowTo = "Turn on Docker Desktop → Settings → General → Start Docker Desktop when you sign in to your computer. Slopify then starts with it.";
31
+ // Where to turn Docker's start at login on, for the Docker this installation uses. A record
32
+ // from before the installer noted the system can't tell Docker Desktop from Docker Engine
33
+ // when systemctl didn't answer (macOS and Windows have none), so it names both.
34
+ export function howToFor(record) {
35
+ if (record.desktop === true)
36
+ return dockerDesktopHowTo;
37
+ if (record.platform === undefined && record.docker === "unknown")
38
+ return desktopHowTo;
39
+ return dockerHowTo(record.manager);
40
+ }
25
41
  export const desktopHowTo = "With Docker Desktop: Docker Desktop → Settings → General → Start Docker Desktop when you sign in. With Docker Engine on Linux: sudo systemctl enable docker. Slopify starts whenever Docker does.";
26
42
  /**
27
43
  * Reads, never changes, whether Docker starts by itself. `systemctl is-enabled` only reports;
@@ -38,8 +54,60 @@ export async function checkDockerStart(exec, manager) {
38
54
  return "no";
39
55
  return "unknown";
40
56
  }
57
+ // Docker Desktop's settings files, newest name first: settings-store.json in current
58
+ // versions, settings.json before it.
59
+ export function desktopSettingsPaths(host) {
60
+ const names = ["settings-store.json", "settings.json"];
61
+ if (host.platform === "win32") {
62
+ const appData = host.env.APPDATA?.trim() || win32.join(host.home, "AppData", "Roaming");
63
+ return names.map((name) => win32.join(appData, "Docker", name));
64
+ }
65
+ if (host.platform === "darwin")
66
+ return names.map((name) => posix.join(host.home, "Library", "Group Containers", "group.com.docker", name));
67
+ return names.map((name) => posix.join(host.home, ".docker", "desktop", name));
68
+ }
69
+ // `AutoStart` in settings-store.json, `autoStart` in the older settings.json: Start Docker
70
+ // Desktop when you sign in to your computer.
71
+ export function desktopAutoStart(text) {
72
+ try {
73
+ const value = JSON.parse(text);
74
+ if (typeof value !== "object" || value === null)
75
+ return "unknown";
76
+ const record = value;
77
+ const start = record.AutoStart ?? record.autoStart;
78
+ return start === true ? "yes" : start === false ? "no" : "unknown";
79
+ }
80
+ catch {
81
+ return "unknown";
82
+ }
83
+ }
84
+ /**
85
+ * Reads, never changes, whether Docker starts by itself on this machine. macOS and Windows run
86
+ * Docker Desktop, whose settings file says; on Linux, Docker Desktop's settings file when it
87
+ * is there, otherwise the system or rootless docker service through systemctl.
88
+ */
89
+ export async function inspectDockerStart(o) {
90
+ const platform = o.host.platform === "linux" || o.host.platform === "darwin" || o.host.platform === "win32"
91
+ ? o.host.platform
92
+ : "other";
93
+ for (const path of desktopSettingsPaths(o.host)) {
94
+ const text = await o.host.read(path);
95
+ if (text !== undefined)
96
+ return { docker: desktopAutoStart(text), desktop: true, platform };
97
+ }
98
+ if (platform === "darwin" || platform === "win32")
99
+ return { docker: "unknown", desktop: true, platform };
100
+ return { docker: await checkDockerStart(o.exec, o.manager), desktop: false, platform };
101
+ }
41
102
  // The terminal line the installer prints after its question.
42
103
  export function loginStartReport(record) {
104
+ if (record.desktop === true) {
105
+ if (record.docker === "yes")
106
+ return "Slopify starts when you sign in: Docker Desktop starts at sign-in and restarts Slopify.";
107
+ if (record.docker === "no")
108
+ return `Docker Desktop doesn't start when you sign in, so neither does Slopify. ${dockerDesktopHowTo}`;
109
+ return `Slopify couldn't tell whether Docker Desktop starts when you sign in (its settings file didn't say). ${dockerDesktopHowTo}`;
110
+ }
43
111
  if (record.docker === "yes")
44
112
  return record.manager === "rootless"
45
113
  ? "Slopify starts when you log in: rootless Docker starts with your login and restarts Slopify."
@@ -45,8 +45,14 @@ export async function settleAutostart(options) {
45
45
  try {
46
46
  let wanted = options.flag;
47
47
  if (wanted === undefined) {
48
- if (!options.interactive || !(await options.autostart.unanswered()))
48
+ if (!(await options.autostart.unanswered()))
49
49
  return;
50
+ // Started without a terminal (a service, a script, a desktop shortcut): nobody can answer
51
+ // here, so the question stays open for the first-run screen and Settings → General.
52
+ if (!options.interactive) {
53
+ options.report("To start Slopify when you log in, turn it on in the app: the first-run screen offers it, or Settings → General → Start Slopify when I log in.");
54
+ return;
55
+ }
50
56
  wanted = await options.ask(autostartQuestion);
51
57
  if (wanted === undefined)
52
58
  return;
@@ -1,5 +1,5 @@
1
1
  import { readSetting, writeSetting } from "../../slices/settings/repo.js";
2
- import { desktopHowTo, dockerHowTo } from "./docker.js";
2
+ import { desktopHowTo, howToFor } from "./docker.js";
3
3
  import { autostartAnsweredKey } from "./model.js";
4
4
  export class AutostartRefusal extends Error {
5
5
  }
@@ -15,17 +15,19 @@ export function createAutostartService(db, source) {
15
15
  enabled: record === null || record.docker === "unknown" ? null : record.docker === "yes",
16
16
  summary: record === null
17
17
  ? "Slopify runs in Docker and starts whenever Docker starts. From inside the container it can't see whether Docker starts when you log in."
18
- : record.docker === "yes"
19
- ? record.manager === "rootless"
20
- ? "Slopify runs in Docker and starts when you log in: rootless Docker starts with your login and restarts Slopify."
21
- : "Slopify runs in Docker and starts with this computer: Docker starts at boot and restarts Slopify."
22
- : record.docker === "no"
23
- ? "Slopify runs in Docker, and Docker doesn't start by itself on this computer, so Slopify doesn't either."
24
- : "Slopify runs in Docker and starts whenever Docker starts. The installer couldn't tell whether Docker starts by itself here.",
18
+ : record.desktop === true
19
+ ? desktopSummary(record.docker)
20
+ : record.docker === "yes"
21
+ ? record.manager === "rootless"
22
+ ? "Slopify runs in Docker and starts when you log in: rootless Docker starts with your login and restarts Slopify."
23
+ : "Slopify runs in Docker and starts with this computer: Docker starts at boot and restarts Slopify."
24
+ : record.docker === "no"
25
+ ? "Slopify runs in Docker, and Docker doesn't start by itself on this computer, so Slopify doesn't either."
26
+ : "Slopify runs in Docker and starts whenever Docker starts. The installer couldn't tell whether Docker starts by itself here.",
25
27
  where: null,
26
28
  howTo: record === null
27
29
  ? desktopHowTo
28
- : `${dockerHowTo(record.manager)} To check again after changing it, run npx @gentbajko/slopify --docker.`,
30
+ : `${howToFor(record)} To check again after changing it, run npx @gentbajko/slopify --docker.`,
29
31
  checkedAt: record?.checkedAt ?? null,
30
32
  offer: false,
31
33
  };
@@ -66,6 +68,13 @@ export function createAutostartService(db, source) {
66
68
  refresh: () => (source.kind === "native" ? source.native.refresh() : Promise.resolve()),
67
69
  };
68
70
  }
71
+ function desktopSummary(docker) {
72
+ return docker === "yes"
73
+ ? "Slopify runs in Docker Desktop and starts when you sign in: Docker Desktop starts at sign-in and restarts Slopify."
74
+ : docker === "no"
75
+ ? "Slopify runs in Docker Desktop, and Docker Desktop doesn't start when you sign in, so Slopify doesn't either."
76
+ : "Slopify runs in Docker Desktop and starts whenever it does. The installer couldn't read whether Docker Desktop starts when you sign in.";
77
+ }
69
78
  // The native steps already speak in sentences; a file error from the disk doesn't.
70
79
  function plain(error, enabled) {
71
80
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,71 @@
1
+ import { parseArgs } from "node:util";
2
+ const options = {
3
+ port: { type: "string" },
4
+ host: { type: "string" },
5
+ "data-dir": { type: "string" },
6
+ "projects-dir": { type: "string" },
7
+ "no-open": { type: "boolean" },
8
+ docker: { type: "boolean" },
9
+ "host-cli": { type: "string" },
10
+ "accept-host-cli": { type: "boolean" },
11
+ autostart: { type: "boolean" },
12
+ "no-autostart": { type: "boolean" },
13
+ help: { type: "boolean", short: "h" },
14
+ version: { type: "boolean", short: "v" },
15
+ };
16
+ function parse(args) {
17
+ return parseArgs({ args: [...args], allowPositionals: true, options });
18
+ }
19
+ // Node's own wording for a bad flag ("Unknown option '--foo'. To specify a positional argument
20
+ // starting with a '-', place it after '--'") is replaced by one that says where the list is.
21
+ export function parseCli(args) {
22
+ let parsed;
23
+ try {
24
+ parsed = parse(args);
25
+ }
26
+ catch (error) {
27
+ const code = error instanceof Error && "code" in error && typeof error.code === "string"
28
+ ? error.code
29
+ : undefined;
30
+ if (code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
31
+ const flag = /'([^']+)'/.exec(error instanceof Error ? error.message : "")?.[1];
32
+ throw new Error(`Slopify doesn't know the option ${flag ?? "you gave"}. Run npx @gentbajko/slopify --help to see every option.`);
33
+ }
34
+ if (code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE")
35
+ throw new Error(`${error instanceof Error ? error.message : String(error)}. Run npx @gentbajko/slopify --help to see how each option is written.`);
36
+ throw error;
37
+ }
38
+ if (parsed.values.help === true || parsed.positionals[0] === "help")
39
+ return { kind: "help" };
40
+ if (parsed.values.version === true)
41
+ return { kind: "version" };
42
+ return { kind: "run", values: parsed.values, positionals: parsed.positionals };
43
+ }
44
+ export function helpText(version) {
45
+ return `Slopify ${version}: makes narrated YouTube videos, Shorts, articles and PDFs from a topic.
46
+
47
+ Usage
48
+ npx @gentbajko/slopify [options] Start Slopify on this machine and open it in the browser
49
+ npx @gentbajko/slopify --docker [options] Install (or reinstall) Slopify in Docker (install --docker works too)
50
+ npx @gentbajko/slopify@latest update Update Slopify while it runs, or the Docker install (waits for running work first)
51
+
52
+ Options for starting Slopify
53
+ --port <number> The port the web app listens on (default 6969; or SLOPIFY_PORT)
54
+ --host <address> The address it listens on (default 127.0.0.1, only this machine; or SLOPIFY_HOST)
55
+ --data-dir <folder> Where Slopify keeps its database, settings and logs (default ~/.slopify; or SLOPIFY_DATA_DIR)
56
+ --no-open Don't open the browser when Slopify starts (or SLOPIFY_NO_OPEN=1)
57
+ --autostart Start Slopify when you log in, without asking
58
+ --no-autostart Don't start Slopify when you log in, without asking
59
+
60
+ Options for the Docker install
61
+ --docker Install or update Slopify in Docker instead of running it directly
62
+ --projects-dir <folder> The folder on this machine where the Docker version keeps project files
63
+ --host-cli=off Use API keys only; don't connect the AI CLIs installed on this machine
64
+ --accept-host-cli Connect the AI CLIs installed on this machine without asking
65
+
66
+ Other
67
+ -h, --help Show this list
68
+ -v, --version Print the version
69
+
70
+ More: https://slopify.stream`;
71
+ }
package/dist/edge/cli.js CHANGED
@@ -1,29 +1,24 @@
1
1
  #!/usr/bin/env node
2
- import { parseArgs } from "node:util";
3
2
  import { configFrom } from "../kernel/config/index.js";
4
3
  import { readVersion } from "../kernel/version.js";
5
4
  import { boot } from "../main.js";
6
5
  import { forwardManagedUpdate } from "../updater/forward.js";
7
6
  import { askTerminal, autostartFlag, settleAutostart } from "./autostart/prompt.js";
7
+ import { helpText, parseCli } from "./cli-args.js";
8
8
  import { openBrowser } from "./open-browser.js";
9
9
  import { installSignalShutdown } from "./signal-shutdown.js";
10
- const { values, positionals } = parseArgs({
11
- allowPositionals: true,
12
- options: {
13
- port: { type: "string" },
14
- host: { type: "string" },
15
- "data-dir": { type: "string" },
16
- "projects-dir": { type: "string" },
17
- "no-open": { type: "boolean" },
18
- docker: { type: "boolean" },
19
- "host-cli": { type: "string" },
20
- "accept-host-cli": { type: "boolean" },
21
- autostart: { type: "boolean" },
22
- "no-autostart": { type: "boolean" },
23
- },
24
- });
25
10
  let config;
26
11
  try {
12
+ const parsed = parseCli(process.argv.slice(2));
13
+ if (parsed.kind === "help") {
14
+ console.log(helpText(readVersion()));
15
+ process.exit(0);
16
+ }
17
+ if (parsed.kind === "version") {
18
+ console.log(readVersion());
19
+ process.exit(0);
20
+ }
21
+ const { values, positionals } = parsed;
27
22
  const autostart = autostartFlag(values);
28
23
  const [action, ...extra] = positionals;
29
24
  if (extra.length > 0 || (action !== undefined && action !== "install" && action !== "update"))
@@ -1,4 +1,4 @@
1
- import { access } from "node:fs/promises";
1
+ import { access, readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
@@ -107,6 +107,12 @@ export async function runDockerCommand(command) {
107
107
  interactive: process.stdin.isTTY === true,
108
108
  ask: askTerminal,
109
109
  exec: nodeAutostartExec,
110
+ host: {
111
+ platform: process.platform,
112
+ home,
113
+ env,
114
+ read: (path) => readFile(path, "utf8").catch(() => undefined),
115
+ },
110
116
  now: () => new Date(),
111
117
  report: (line) => console.log(line),
112
118
  }).catch((error) => {
@@ -72,7 +72,9 @@ export function createHub(deps) {
72
72
  for (const subscriber of projects.get(projectId) ?? []) {
73
73
  send(subscriber, event);
74
74
  }
75
- if (event.type === "project.state" || event.type === "project.updated") {
75
+ if (event.type === "project.state" ||
76
+ event.type === "project.updated" ||
77
+ event.type === "review.flagged") {
76
78
  for (const subscriber of globals)
77
79
  send(subscriber, event);
78
80
  }
@@ -60,7 +60,12 @@ export function actionRoutes(deps) {
60
60
  ...reruns,
61
61
  runner: deps.runner,
62
62
  emit: (projectId, event) => deps.hub.emit(projectId, event),
63
- providers: () => providerStatuses({ db: deps.db, probe: deps.probe, hostCliStatus: deps.hostCliStatus }),
63
+ providers: () => providerStatuses({
64
+ db: deps.db,
65
+ probe: deps.probe,
66
+ hostCliStatus: deps.hostCliStatus,
67
+ host: deps.speechHost,
68
+ }),
64
69
  allowsCustomModels: deps.catalogue ? () => false : allowsCustomModel,
65
70
  modelsFor: deps.modelsFor ?? (() => Promise.reject(new Error("Model catalog unavailable"))),
66
71
  };
@@ -44,6 +44,7 @@ import { subtitleRoutes } from "./subtitles.js";
44
44
  import { telemetryRoutes } from "./telemetry.js";
45
45
  import { trashRoutes } from "./trash.js";
46
46
  import { tutorialRoutes } from "./tutorial.js";
47
+ import { tutorialPagesRoutes } from "./tutorials.js";
47
48
  import { updateRoutes } from "./update.js";
48
49
  import { usageRoutes } from "./usage.js";
49
50
  import { whatsNewRoutes } from "./whats-new.js";
@@ -102,6 +103,7 @@ function apiRoutes(deps, startedAt) {
102
103
  .route("/tutorial", tutorialRoutes(deps))
103
104
  .route("/whats-new", whatsNewRoutes(deps))
104
105
  .route("/patch-notes", patchNotesRoutes(deps))
106
+ .route("/tutorials", tutorialPagesRoutes(deps))
105
107
  .route("/providers", providerRoutes(deps))
106
108
  .route("/onboarding", onboardingRoutes(deps)));
107
109
  }
@@ -0,0 +1,42 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { Readable } from "node:stream";
3
+ // One `bytes=` range, as browsers ask for media; anything else is answered with the whole file.
4
+ export function byteRange(header, size) {
5
+ const matched = /^bytes=(\d*)-(\d*)$/.exec(header?.trim() ?? "");
6
+ if (matched === null)
7
+ return undefined;
8
+ const [, from = "", to = ""] = matched;
9
+ if (from === "" && to === "")
10
+ return undefined;
11
+ if (from === "") {
12
+ const suffix = Number(to);
13
+ if (suffix === 0)
14
+ return "unsatisfiable";
15
+ return { start: Math.max(0, size - suffix), end: size - 1 };
16
+ }
17
+ const start = Number(from);
18
+ const end = to === "" ? size - 1 : Math.min(Number(to), size - 1);
19
+ if (start >= size || end < start)
20
+ return "unsatisfiable";
21
+ return { start, end };
22
+ }
23
+ // A file on disk as a response that honours one byte range, so an audio or video element
24
+ // can seek: it asks for the bytes at the new time and gets 206 with just that slice. Built
25
+ // with `c.body` so the app's own headers (the version, among others) are still added.
26
+ export function fileResponse(c, path, size, headers) {
27
+ const base = { ...headers, "accept-ranges": "bytes" };
28
+ const range = byteRange(c.req.header("range"), size);
29
+ if (range === "unsatisfiable")
30
+ return c.body(null, 416, { ...base, "content-range": `bytes */${String(size)}` });
31
+ if (size === 0)
32
+ return c.body(null, 200, { ...base, "content-length": "0" });
33
+ const { start, end } = range ?? { start: 0, end: size - 1 };
34
+ const body = Readable.toWeb(createReadStream(path, { start, end }));
35
+ return c.body(body, range === undefined ? 200 : 206, {
36
+ ...base,
37
+ "content-length": String(end - start + 1),
38
+ ...(range === undefined
39
+ ? {}
40
+ : { "content-range": `bytes ${String(start)}-${String(end)}/${String(size)}` }),
41
+ });
42
+ }
@@ -11,6 +11,7 @@ export function diagnosticsRoutes(deps) {
11
11
  db: deps.db,
12
12
  probe: deps.probe,
13
13
  hostCliStatus: deps.hostCliStatus,
14
+ host: deps.speechHost,
14
15
  });
15
16
  const projectCount = deps.db.prepare("SELECT count(*) AS count FROM projects").get();
16
17
  const schema = deps.db.prepare("SELECT max(version) AS version FROM schema_migrations").get();
@@ -1,6 +1,4 @@
1
- import { createReadStream } from "node:fs";
2
1
  import { extname } from "node:path";
3
- import { Readable } from "node:stream";
4
2
  import { zValidator } from "@hono/zod-validator";
5
3
  import { Hono } from "hono";
6
4
  import { z } from "zod";
@@ -9,6 +7,7 @@ import { readDraft } from "../../slices/play-drafts/service.js";
9
7
  import { uploadDraftAttachment } from "../../slices/play-drafts/uploads.js";
10
8
  import { stagingPath } from "../../slices/storage/layout.js";
11
9
  import { stagedFileById } from "../../slices/storage/repo.js";
10
+ import { fileResponse } from "./byte-range.js";
12
11
  import { draftProblem } from "./draft-problem.js";
13
12
  import { readMultipart } from "./multipart.js";
14
13
  import { onInvalid, problem, titleOf } from "./problem.js";
@@ -59,9 +58,8 @@ export function draftFileRoutes(service) {
59
58
  title: titleOf(404),
60
59
  detail: "This attached file has not finished uploading, so it cannot be previewed yet. Wait a moment or attach it again.",
61
60
  });
62
- return c.body(Readable.toWeb(createReadStream(stagingPath(deps.paths, staged.path))), 200, {
61
+ return fileResponse(c, stagingPath(deps.paths, staged.path), staged.bytes, {
63
62
  "content-type": contentType,
64
- "content-length": String(staged.bytes),
65
63
  "x-content-type-options": "nosniff",
66
64
  "cache-control": "no-store",
67
65
  });
@@ -1,9 +1,8 @@
1
- import { createReadStream } from "node:fs";
2
- import { Readable } from "node:stream";
3
1
  import { zValidator } from "@hono/zod-validator";
4
2
  import { Hono } from "hono";
5
3
  import { z } from "zod";
6
4
  import { findDownload, imagesZip } from "../../slices/storage/downloads.js";
5
+ import { fileResponse } from "./byte-range.js";
7
6
  import { onInvalid, problem, titleOf } from "./problem.js";
8
7
  const projectParam = z.object({
9
8
  projectId: z
@@ -42,11 +41,16 @@ export function fileRoutes(deps) {
42
41
  return missing(c, result.reason);
43
42
  }
44
43
  const { download } = result;
45
- // ceiling: whole-file responses only. Seeking inside the finished video would
46
- // need a Range handler answering 206 with the requested slice.
47
- return c.body(Readable.toWeb(createReadStream(download.path)), 200, {
44
+ // The YouTube description and tags as the page shows them, edits and links in.
45
+ if (download.text !== undefined)
46
+ return c.body(download.text, 200, {
47
+ "content-type": download.contentType,
48
+ "content-length": String(download.text.byteLength),
49
+ "content-disposition": disposition(download.filename),
50
+ });
51
+ // Byte ranges, so the players can jump to any time in the video or narration.
52
+ return fileResponse(c, download.path, download.bytes, {
48
53
  "content-type": download.contentType,
49
- "content-length": String(download.bytes),
50
54
  "content-disposition": disposition(download.filename),
51
55
  });
52
56
  }));
@@ -2,17 +2,21 @@ import { randomUUID } from "node:crypto";
2
2
  import { zValidator } from "@hono/zod-validator";
3
3
  import { Hono } from "hono";
4
4
  import { z } from "zod";
5
+ import { defaultSpeechVoice, detectSpeechCached, primaryLanguage, } from "../../kernel/ports/system-speech.js";
5
6
  import { loudnessOfDefault } from "../../slices/loudness/model.js";
6
7
  import { firstRunView } from "../../slices/onboarding/first-run.js";
8
+ import { fullVideoDraft } from "../../slices/onboarding/full-video.js";
7
9
  import { installPack } from "../../slices/onboarding/install.js";
8
- import { quickShortInputSchema, sampleCopyInputSchema } from "../../slices/onboarding/model.js";
10
+ import { fullVideoInputSchema, quickShortInputSchema, sampleCopyInputSchema, } from "../../slices/onboarding/model.js";
9
11
  import { packById, starterSet } from "../../slices/onboarding/packs.js";
10
- import { planShortProviders, shortDraft } from "../../slices/onboarding/quick-short.js";
12
+ import { planShortProviders, shortDraft, } from "../../slices/onboarding/quick-short.js";
11
13
  import { copySample, restoreSamples, sampleProjectIds, } from "../../slices/onboarding/sample.js";
12
14
  import { dismissFirstRun, recordShortRequest, shortRequestProject, } from "../../slices/onboarding/state.js";
15
+ import { systemVoiceProvider } from "../../slices/settings/model.js";
13
16
  import { readSettings } from "../../slices/settings/playback.js";
14
17
  import { providerStatuses } from "../../slices/settings/readiness.js";
15
18
  import { listVoices } from "../../slices/settings/repo.js";
19
+ import { addVoice } from "../../slices/settings/voices.js";
16
20
  import { projectTitle } from "../../slices/storage/repo.js";
17
21
  import { onInvalid, problem, titleOf } from "./problem.js";
18
22
  import { createProject } from "./project-create.js";
@@ -20,7 +24,21 @@ const packParam = z.object({ id: z.string().min(1).max(40) });
20
24
  // The first five minutes: the first-run screen's state, starter packs, "Make a 60-second
21
25
  // short" and the bundled samples' Restore and Make my own copy.
22
26
  export function onboardingRoutes(deps) {
23
- const statuses = () => providerStatuses({ db: deps.db, probe: deps.probe, hostCliStatus: deps.hostCliStatus });
27
+ const statuses = () => providerStatuses({
28
+ db: deps.db,
29
+ probe: deps.probe,
30
+ hostCliStatus: deps.hostCliStatus,
31
+ host: deps.speechHost,
32
+ });
33
+ const speech = () => detectSpeechCached(deps.probe, deps.speechHost ?? process);
34
+ // The best speech program found and its English voice, for a short made without a voice key.
35
+ const systemVoicePick = async () => {
36
+ const engine = (await speech()).engines[0];
37
+ const voice = engine === undefined ? undefined : defaultSpeechVoice(engine);
38
+ return engine === undefined || voice === undefined
39
+ ? undefined
40
+ : { model: engine.id, voiceId: voice.id, name: voice.name };
41
+ };
24
42
  const templates = { ...deps, uuid: randomUUID };
25
43
  const sample = {
26
44
  db: deps.db,
@@ -35,7 +53,7 @@ export function onboardingRoutes(deps) {
35
53
  return new Hono()
36
54
  .get("/", async (c) => {
37
55
  c.header("Cache-Control", "no-store");
38
- return c.json(firstRunView(deps.db, await statuses(), deps.clock.now().toISOString()));
56
+ return c.json(firstRunView(deps.db, await statuses()));
39
57
  })
40
58
  .post("/dismiss", (c) => {
41
59
  dismissFirstRun(deps.db, deps.clock.now().toISOString());
@@ -75,6 +93,7 @@ export function onboardingRoutes(deps) {
75
93
  voices: listVoices(deps.db),
76
94
  modelsFor,
77
95
  suggested: pack.voice,
96
+ systemVoice: await systemVoicePick(),
78
97
  });
79
98
  if (!plan.ok)
80
99
  return problem(c, {
@@ -90,6 +109,22 @@ export function onboardingRoutes(deps) {
90
109
  title: titleOf(404),
91
110
  detail: "Slopify doesn't have that starter pack. Reload the page and pick one of the packs listed.",
92
111
  });
112
+ // The computer's voice is saved like any other, so Play and a later rebuild offer it.
113
+ const audio = plan.providers.audio;
114
+ if (plan.systemVoice &&
115
+ !listVoices(deps.db).some((voice) => voice.provider === audio.provider && voice.voiceId === audio.voice)) {
116
+ const found = await speech();
117
+ const spoken = found.engines
118
+ .flatMap((engine) => engine.voices)
119
+ .find((voice) => voice.id === audio.voice);
120
+ const language = spoken === undefined ? undefined : primaryLanguage(spoken);
121
+ addVoice({ db: deps.db, ids: deps.ids }, {
122
+ provider: systemVoiceProvider,
123
+ name: `${spoken?.name ?? audio.voice} (computer voice)`,
124
+ voiceId: audio.voice,
125
+ ...(language === undefined ? {} : { languages: [language] }),
126
+ });
127
+ }
93
128
  const created = await createProject(deps, shortDraft({
94
129
  topic: input.topic,
95
130
  pack,
@@ -105,7 +140,32 @@ export function onboardingRoutes(deps) {
105
140
  ...(created.fields.length === 0 ? {} : { extensions: { fields: created.fields } }),
106
141
  });
107
142
  recordShortRequest(deps.db, input.requestId, created.project.id);
143
+ // A real project now exists, so the first-run screen is done with.
144
+ dismissFirstRun(deps.db, deps.clock.now().toISOString());
108
145
  return c.json({ projectId: created.project.id, replayed: false }, 201);
146
+ })
147
+ .post("/full-video", zValidator("json", fullVideoInputSchema, onInvalid), (c) => {
148
+ const made = fullVideoDraft(templates, c.req.valid("json"));
149
+ if (made.ok)
150
+ return c.json(made.value, 201);
151
+ if (made.reason === "not-found")
152
+ return problem(c, {
153
+ status: 404,
154
+ title: titleOf(404),
155
+ detail: "This short or its starter pack is no longer here, so Play couldn't be set up from it. Open Play and pick a template yourself.",
156
+ });
157
+ if (made.reason === "not-short")
158
+ return problem(c, {
159
+ status: 409,
160
+ title: titleOf(409),
161
+ detail: "This project is already a long video. Open Play to start another one on a new topic.",
162
+ });
163
+ return problem(c, {
164
+ status: 409,
165
+ title: titleOf(409),
166
+ detail: "Slopify couldn't open a Play draft for the full video. Press Make the full video on this topic again; if it keeps failing, open Play and pick the pack's template in Library → Templates.",
167
+ extensions: { reason: made.reason },
168
+ });
109
169
  })
110
170
  .get("/sample", (c) => {
111
171
  const samples = sampleProjectIds(deps.db);