@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,7 @@
1
+ import { detectSpeechCached } from "../../kernel/ports/system-speech.js";
1
2
  import { cliPathStatus } from "./cli-paths.js";
2
3
  import { cliProbeTimeoutMs, readinessFromProbe } from "./cli-status.js";
3
- import { isLocalCliProvider, providers } from "./model.js";
4
+ import { isLocalCliProvider, providers, sharedKeyOf } from "./model.js";
4
5
  import { keyedProviders } from "./repo.js";
5
6
  // Every supported provider is listed, keyed or not, found or not, so Play can grey one out with
6
7
  // a reason instead of hiding it. A keyed provider is ready when a key is stored; a CLI provider
@@ -10,6 +11,16 @@ export async function providerStatuses(deps) {
10
11
  const probes = new Map();
11
12
  return await Promise.all(providers.map(async (provider) => {
12
13
  const base = { id: provider.id, family: provider.family, displayName: provider.displayName };
14
+ if (provider.auth === "local") {
15
+ const found = await detectSpeechCached(deps.probe, deps.host ?? process);
16
+ const engine = found.engines[0];
17
+ return {
18
+ ...base,
19
+ readiness: engine === undefined
20
+ ? { kind: "local", available: false, ...(found.issue ? { issue: found.issue } : {}) }
21
+ : { kind: "local", available: true, engine: engine.name },
22
+ };
23
+ }
13
24
  if (provider.auth !== "cli")
14
25
  return { ...base, readiness: keyedReadiness(keyed, provider.id) };
15
26
  if (deps.hostCliStatus && isLocalCliProvider(provider.id)) {
@@ -41,5 +52,6 @@ export async function providerStatuses(deps) {
41
52
  }));
42
53
  }
43
54
  function keyedReadiness(keyed, id) {
44
- return { kind: "keyed", hasKey: keyed.has(id) };
55
+ const shared = sharedKeyOf[id];
56
+ return { kind: "keyed", hasKey: keyed.has(id) || (shared !== undefined && keyed.has(shared)) };
45
57
  }
@@ -24,6 +24,11 @@ export const tutorialSessionSchema = z
24
24
  "play-start",
25
25
  "project",
26
26
  "download",
27
+ "run-cost",
28
+ "studio-prep",
29
+ "home",
30
+ "channels",
31
+ "calendar",
27
32
  ]),
28
33
  articleId: z.string().optional(),
29
34
  imageId: z.string().optional(),
@@ -177,26 +177,27 @@ export async function renderShort(run) {
177
177
  }), { mode: 0o600 });
178
178
  mkdirSync(join(workspace, "fonts"), { mode: 0o700 });
179
179
  copyFileSync(run.font.path, join(workspace, "fonts", `selected${run.font.extension}`));
180
+ const edit = shortEditList({
181
+ audioPath: audio,
182
+ seconds,
183
+ images: run.images,
184
+ imageSeconds: run.imageSeconds / speed,
185
+ motionStyle: run.motionStyle,
186
+ zoomPercent: run.zoomPercent,
187
+ ...(run.figures === undefined
188
+ ? {}
189
+ : {
190
+ figures: run.figures.map((figure) => ({
191
+ ...figure,
192
+ start: figure.start / speed,
193
+ end: figure.end / speed,
194
+ })),
195
+ }),
196
+ });
180
197
  return await renderSlideshow({
181
198
  bin: run.bin,
182
199
  master: run.master,
183
- edit: shortEditList({
184
- audioPath: audio,
185
- seconds,
186
- images: run.images,
187
- imageSeconds: run.imageSeconds / speed,
188
- motionStyle: run.motionStyle,
189
- zoomPercent: run.zoomPercent,
190
- ...(run.figures === undefined
191
- ? {}
192
- : {
193
- figures: run.figures.map((figure) => ({
194
- ...figure,
195
- start: figure.start / speed,
196
- end: figure.end / speed,
197
- })),
198
- }),
199
- }),
200
+ edit: run.frame === undefined ? edit : { ...edit, ...run.frame },
200
201
  output: run.output,
201
202
  burnSubtitles: true,
202
203
  cwd: workspace,
@@ -4,15 +4,23 @@ import { homedir } from "node:os";
4
4
  import { isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  const execute = promisify(execFile);
7
- export function nodeDocumentsHost() {
7
+ const execBytes = async (command, args) => (await execute(command, [...args], { timeout: 10_000, windowsHide: true, encoding: "buffer" }))
8
+ .stdout;
9
+ // Output is always decoded as UTF-8: the PowerShell command below switches its own output to
10
+ // UTF-8 first, so a Documents folder named with non-ASCII letters (a Cyrillic or accented
11
+ // OneDrive path) survives instead of arriving in the console codepage.
12
+ export function nodeDocumentsHost(exec = execBytes) {
8
13
  return {
9
14
  platform: process.platform,
10
15
  env: process.env,
11
16
  home: homedir(),
12
- run: async (command, args) => (await execute(command, [...args], { timeout: 10_000, windowsHide: true })).stdout,
17
+ run: async (command, args) => new TextDecoder("utf-8").decode(await exec(command, args)),
13
18
  read: (path) => readFile(path, "utf8").catch(() => undefined),
14
19
  };
15
20
  }
21
+ // Forces PowerShell's stdout to UTF-8 before printing: by default it writes in the console's
22
+ // codepage (437, 850, 1252...), which mangles every non-ASCII letter in the path.
23
+ export const WINDOWS_DOCUMENTS_COMMAND = "[Console]::OutputEncoding = [Text.Encoding]::UTF8; [Environment]::GetFolderPath('MyDocuments')";
16
24
  // The user's own Documents folder, the way the system itself names it: Windows asks for the
17
25
  // known folder (which follows OneDrive and a folder moved in its Properties), Linux asks
18
26
  // xdg-user-dir and then reads user-dirs.dirs, macOS has no setting for it. Never throws: the
@@ -24,7 +32,7 @@ export async function documentsDir(host) {
24
32
  "-NoProfile",
25
33
  "-NonInteractive",
26
34
  "-Command",
27
- "[Environment]::GetFolderPath('MyDocuments')",
35
+ WINDOWS_DOCUMENTS_COMMAND,
28
36
  ])
29
37
  .then((out) => out.trim())
30
38
  .catch(() => "");
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { extname } from "node:path";
3
3
  import { zipSync } from "fflate";
4
+ import { effectiveDescription } from "../youtube/edits-repo.js";
4
5
  import { assetOf } from "./asset-name.js";
5
6
  import { outputPath } from "./layout.js";
6
7
  import { outputsOf, projectTitle } from "./repo.js";
@@ -57,16 +58,47 @@ export function findDownload(deps, projectId, asset) {
57
58
  if (stats === undefined) {
58
59
  return { ok: false, reason: "missing-file" };
59
60
  }
61
+ const shown = output.role === "youtube_description" || output.role === "youtube_tags"
62
+ ? shownYoutubeText(deps, projectId, output.role)
63
+ : undefined;
60
64
  return {
61
65
  ok: true,
62
66
  download: {
63
67
  path,
64
68
  filename: downloadName(slugOf(title), output),
65
- bytes: stats,
69
+ bytes: shown?.byteLength ?? stats,
66
70
  contentType: contentTypeOf(path),
71
+ ...(shown === undefined ? {} : { text: shown }),
67
72
  },
68
73
  };
69
74
  }
75
+ // The description and tags download as the project page shows and copies them, and as Prepare
76
+ // upload hands them on: the user's hand edits over the written text, chapters fitted, and the
77
+ // channel's and project's links filled in (`youtube/edits-repo.ts`). The file on disk stays
78
+ // the generated text, so regeneration can tell the user's edits from its own.
79
+ function shownYoutubeText(deps, projectId, role) {
80
+ const outputs = outputsOf(deps.db, projectId);
81
+ const read = (wanted) => {
82
+ const output = outputs.find((candidate) => candidate.role === wanted);
83
+ if (output === undefined)
84
+ return undefined;
85
+ const path = outputPath(deps.paths, projectId, output.path);
86
+ return sizeOf(path) === undefined ? undefined : readFileSync(path, "utf8").trim();
87
+ };
88
+ const description = read("youtube_description");
89
+ if (description === undefined)
90
+ return undefined;
91
+ const video = outputs.find((output) => output.role === "video");
92
+ const shown = effectiveDescription(deps.db, projectId, {
93
+ description,
94
+ tags: read("youtube_tags") ?? "",
95
+ durationSeconds: video?.durationMs === null || video?.durationMs === undefined
96
+ ? undefined
97
+ : video.durationMs / 1000,
98
+ });
99
+ const text = role === "youtube_description" ? shown.description : shown.tags;
100
+ return new TextEncoder().encode(`${text}\n`);
101
+ }
70
102
  // "download all" images as `<title-slug>-images.zip`, thumbnail included.
71
103
  export function imagesZip(deps, projectId) {
72
104
  const title = projectTitle(deps.db, projectId);
@@ -24,7 +24,7 @@ import { listVoices } from "../settings/repo.js";
24
24
  import { realPersonVoicesKey, voiceIdMax, voiceNameMax } from "../settings/voices.js";
25
25
  import { whatsNewSeenKey } from "../settings/whats-new.js";
26
26
  import { studioPlaylistMax } from "../studio/model.js";
27
- import { studioPairingKey, studioPlaylistKey, studioRealFootageKey } from "../studio/settings.js";
27
+ import { studioChannelPlaylistPrefix, studioFillQueuePrefix, studioPairingKey, studioPlaylistKey, studioRealFootageKey, } from "../studio/settings.js";
28
28
  import { channelLinksKey } from "../youtube/edits-repo.js";
29
29
  import { defaultBackupsDir, projectDir, stagingPath } from "./layout.js";
30
30
  import { stageKinds } from "./model.js";
@@ -376,6 +376,8 @@ function portableSettings(settings) {
376
376
  key === "first-run.done" ||
377
377
  key === notificationUrlKey ||
378
378
  key === studioPairingKey ||
379
+ // What waits to be filled in Studio belongs to this install's pairing.
380
+ key.startsWith(studioFillQueuePrefix) ||
379
381
  key === whatsNewSeenKey ||
380
382
  key === patchNotesSeenKey)
381
383
  continue;
@@ -386,7 +388,7 @@ function portableSettings(settings) {
386
388
  storedAppearance.parse(parsed);
387
389
  else if (key === "loudness")
388
390
  storedLoudness.parse(parsed);
389
- else if (key === studioPlaylistKey)
391
+ else if (key === studioPlaylistKey || key.startsWith(studioChannelPlaylistPrefix))
390
392
  storedPlaylist.parse(parsed);
391
393
  else if (key === channelLinksKey)
392
394
  storedChannelLinks.parse(parsed);
@@ -26,3 +26,5 @@ export const studioSteps = [
26
26
  export function tagsLine(tags) {
27
27
  return tags.join(", ");
28
28
  }
29
+ // The browsers the extension is built for, as its download names them.
30
+ export const studioExtensionBrowsers = ["chrome", "firefox"];
@@ -49,10 +49,12 @@ export function uploadPack(deps, projectId) {
49
49
  const text = (output) => output === undefined
50
50
  ? undefined
51
51
  : readFileSync(outputPath(deps.paths, projectId, output.path), "utf8").trim();
52
- const playlist = readStudioPlaylist(deps.db);
52
+ const channelId = projectChannelId(deps.db, projectId);
53
+ // The channel's own playlist, else the default (Settings → YouTube Studio).
54
+ const playlist = readStudioPlaylist(deps.db, channelId);
53
55
  const missing = [];
54
56
  // The channel's setting, then what the project narrates and shows (`disclosure.ts`).
55
- const setting = channelById(deps.db, projectChannelId(deps.db, projectId))?.aiDisclosure ?? "auto";
57
+ const setting = channelById(deps.db, channelId)?.aiDisclosure ?? "auto";
56
58
  const realPersonVoices = realPersonVoicesOf(deps, config);
57
59
  const uploadedClips = config.sources.images === "provide"
58
60
  ? outputs.filter((output) => output.role === "image" && isClipPath(output.path)).length
@@ -92,7 +94,7 @@ export function uploadPack(deps, projectId) {
92
94
  if (description === undefined)
93
95
  missing.push(config.youtubeDescription === true
94
96
  ? "The YouTube description isn't written yet. It is written after the video's subtitle timing; wait for the Video stage to finish."
95
- : "No YouTube description is written for this project. Turn on YouTube description in Edit project → Video to have one written with chapters and tags.");
97
+ : "No YouTube description is written for this project. Turn on YouTube description in Edit project → Prompts to have one written with chapters and tags.");
96
98
  const count = thumbnailCountOf(config);
97
99
  // The first thumbnail, then the second and third, and never one left from when the project
98
100
  // made three and now makes one.
@@ -106,7 +108,7 @@ export function uploadPack(deps, projectId) {
106
108
  else if (thumbnails.length < count)
107
109
  missing.push(`Only ${String(thumbnails.length)} of the ${String(count)} thumbnails are made. Regenerate the missing ones in the project's Images section.`);
108
110
  if (playlist === null)
109
- missing.push("No playlist is set, so the playlist step is left to you. Set one in Settings → YouTube Studio.");
111
+ missing.push("No playlist is set for this project's channel, so the playlist step is left to you. Set one in Settings → YouTube Studio → Playlist.");
110
112
  const items = [
111
113
  {
112
114
  kind: "video",
@@ -0,0 +1,69 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { readSetting, writeSetting } from "../settings/repo.js";
4
+ import { studioFillQueuePrefix, studioPairing } from "./settings.js";
5
+ // What Fill in YouTube Studio chose, waiting for the extension: one entry per video or short,
6
+ // oldest first. Each new upload dialog in Studio is filled from the first entry, which then
7
+ // leaves the queue, so preparing several shorts in a row fills them one upload at a time.
8
+ // Kept in the settings table under the current pairing (`studio.fillQueue.<token hash>`), so a
9
+ // restart keeps it and a new pairing token starts with nothing waiting. An entry older than
10
+ // `fillQueueMs` is dropped as stale.
11
+ export const fillQueueMs = 24 * 60 * 60 * 1000;
12
+ // More would be a list nobody works through; the oldest leave first.
13
+ export const fillQueueMax = 50;
14
+ const entrySchema = z.object({
15
+ projectId: z.string().min(1).max(64),
16
+ short: z.number().int().min(1).max(99).nullable(),
17
+ at: z.string(),
18
+ });
19
+ function queueKey(db) {
20
+ const hash = createHash("sha256").update(studioPairing(db).token).digest("hex").slice(0, 16);
21
+ return `${studioFillQueuePrefix}${hash}`;
22
+ }
23
+ function write(db, key, entries) {
24
+ if (entries.length === 0)
25
+ db.prepare("DELETE FROM settings WHERE key = ?").run(key);
26
+ else
27
+ writeSetting(db, key, JSON.stringify(entries));
28
+ }
29
+ // The waiting entries, oldest first, without the stale ones.
30
+ export function readFillQueue(db, now) {
31
+ const stored = readSetting(db, queueKey(db));
32
+ if (stored === undefined)
33
+ return [];
34
+ let raw;
35
+ try {
36
+ raw = JSON.parse(stored);
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ if (!Array.isArray(raw))
42
+ return [];
43
+ return raw.flatMap((one) => {
44
+ const parsed = entrySchema.safeParse(one);
45
+ if (!parsed.success)
46
+ return [];
47
+ const age = now.getTime() - Date.parse(parsed.data.at);
48
+ return Number.isFinite(age) && age <= fillQueueMs ? [parsed.data] : [];
49
+ });
50
+ }
51
+ const same = (entry, projectId, short) => entry.projectId === projectId && entry.short === short;
52
+ // Adds an item at the end; one already waiting keeps its place.
53
+ export function enqueueFill(db, projectId, short, now) {
54
+ const key = queueKey(db);
55
+ const current = readFillQueue(db, now);
56
+ if (current.some((entry) => same(entry, projectId, short))) {
57
+ write(db, key, current);
58
+ return current;
59
+ }
60
+ const next = [...current, { projectId, short, at: now.toISOString() }].slice(-fillQueueMax);
61
+ write(db, key, next);
62
+ return next;
63
+ }
64
+ // Takes an item out: filled by the extension, or removed by the person.
65
+ export function removeFill(db, projectId, short, now) {
66
+ const next = readFillQueue(db, now).filter((entry) => !same(entry, projectId, short));
67
+ write(db, queueKey(db), next);
68
+ return next;
69
+ }
@@ -12,8 +12,38 @@ const pairingSchema = z.object({
12
12
  origin: z.string().nullable(),
13
13
  pairedAt: z.string().nullable(),
14
14
  });
15
- export function readStudioPlaylist(db) {
16
- const stored = readSetting(db, studioPlaylistKey);
15
+ // A channel's own playlist is a row of its own, `studio.playlist.<channelId>`; a channel
16
+ // without one uses the default above.
17
+ export const studioChannelPlaylistPrefix = `${studioPlaylistKey}.`;
18
+ function playlistKey(channelId) {
19
+ return channelId === undefined ? studioPlaylistKey : `${studioChannelPlaylistPrefix}${channelId}`;
20
+ }
21
+ // The playlist an upload pack names: the channel's own, else the default. Without a channel,
22
+ // the default alone.
23
+ export function readStudioPlaylist(db, channelId) {
24
+ if (channelId !== undefined) {
25
+ const own = storedPlaylist(db, playlistKey(channelId));
26
+ if (own !== null)
27
+ return own;
28
+ }
29
+ return storedPlaylist(db, studioPlaylistKey);
30
+ }
31
+ // Every channel's own playlist, by channel id.
32
+ export function readChannelPlaylists(db) {
33
+ const prefix = studioChannelPlaylistPrefix;
34
+ const rows = db
35
+ .prepare("SELECT key FROM settings WHERE substr(key, 1, ?) = ? ORDER BY key")
36
+ .all(prefix.length, prefix);
37
+ const out = {};
38
+ for (const { key } of rows) {
39
+ const value = storedPlaylist(db, key);
40
+ if (value !== null)
41
+ out[key.slice(prefix.length)] = value;
42
+ }
43
+ return out;
44
+ }
45
+ function storedPlaylist(db, key) {
46
+ const stored = readSetting(db, key);
17
47
  if (stored === undefined)
18
48
  return null;
19
49
  try {
@@ -29,13 +59,16 @@ export function studioPlaylistProblem(raw) {
29
59
  ? `The playlist name is longer than YouTube allows (${String(studioPlaylistMax)} characters). Shorten it in Settings → YouTube Studio.`
30
60
  : undefined;
31
61
  }
32
- export function saveStudioPlaylist(db, raw) {
62
+ // Saves the default playlist, or a channel's own; empty clears it (the channel then uses the
63
+ // default again).
64
+ export function saveStudioPlaylist(db, raw, channelId) {
33
65
  const value = raw.trim();
66
+ const key = playlistKey(channelId);
34
67
  if (value === "") {
35
- db.prepare("DELETE FROM settings WHERE key = ?").run(studioPlaylistKey);
68
+ db.prepare("DELETE FROM settings WHERE key = ?").run(key);
36
69
  return null;
37
70
  }
38
- writeSetting(db, studioPlaylistKey, JSON.stringify(value));
71
+ writeSetting(db, key, JSON.stringify(value));
39
72
  return value;
40
73
  }
41
74
  // The projects whose uploaded clips are real footage (filmed, not generated): ticked in the
@@ -83,12 +116,16 @@ export function studioPairing(db) {
83
116
  }
84
117
  return resetStudioPairing(db);
85
118
  }
119
+ // What is waiting to be filled in Studio is kept per pairing (`queue.ts`): one row,
120
+ // `studio.fillQueue.<token hash>`, so a new token starts with nothing waiting.
121
+ export const studioFillQueuePrefix = "studio.fillQueue.";
86
122
  export function resetStudioPairing(db) {
87
123
  const pairing = {
88
124
  token: randomBytes(24).toString("base64url"),
89
125
  origin: null,
90
126
  pairedAt: null,
91
127
  };
128
+ db.prepare("DELETE FROM settings WHERE substr(key, 1, ?) = ?").run(studioFillQueuePrefix.length, studioFillQueuePrefix);
92
129
  writeSetting(db, studioPairingKey, JSON.stringify(pairing));
93
130
  return pairing;
94
131
  }
@@ -0,0 +1,33 @@
1
+ import { join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ // The sample a preview is made of: six seconds of the bundled sample project's narration
4
+ // ("The Library of Alexandria", `assets/sample/sample-project.tar`) with the word timing its
5
+ // alignment found, and three of its images in each shape. Taken once, offline, from the body
6
+ // narration at 3.4 s (the video's 4.4 s, after its one second of edge silence); the words are
7
+ // on the clip's own timeline. Nothing here is generated at preview time.
8
+ export const sampleNarrationSeconds = 6;
9
+ export const sampleNarrationWords = [
10
+ { text: "Around", start: 0.5, end: 0.88 },
11
+ { text: "three", start: 1.14, end: 1.36 },
12
+ { text: "hundred", start: 1.46, end: 1.78 },
13
+ { text: "years", start: 1.88, end: 2.26 },
14
+ { text: "before", start: 2.4, end: 2.78 },
15
+ { text: "our", start: 2.92, end: 3.02 },
16
+ { text: "era,", start: 3.22, end: 3.4 },
17
+ { text: "on", start: 4.18, end: 4.22 },
18
+ { text: "the", start: 4.3, end: 4.36 },
19
+ { text: "coast", start: 4.44, end: 4.72 },
20
+ { text: "of", start: 4.84, end: 4.88 },
21
+ { text: "Egypt,", start: 5.06, end: 5.4 },
22
+ ];
23
+ export const sampleNarrationText = sampleNarrationWords.map((word) => word.text).join(" ");
24
+ // The sample project's title, the headline a Shorts preview shows when none is given.
25
+ export const sampleShortTitle = "The Library of Alexandria";
26
+ export function bundledSampleAssets(dir = fileURLToPath(new URL("../../assets/style-preview/", import.meta.url))) {
27
+ const at = (name) => join(dir, name);
28
+ return {
29
+ narration: at("narration.mp3"),
30
+ wide: [1, 2, 3].map((n) => at(`wide-${String(n)}.jpg`)),
31
+ tall: [1, 2, 3].map((n) => at(`tall-${String(n)}.jpg`)),
32
+ };
33
+ }
@@ -1,14 +1,15 @@
1
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, } from "node:fs";
1
+ import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { defaultMotionStyle, defaultZoomPercent } from "../admission/rules.js";
4
- import { isMissingFont, resolveFont } from "../fonts/index.js";
4
+ import { isMissingFont, resolveBoldFont, resolveFont } from "../fonts/index.js";
5
+ import { renderShort } from "../shorts/render.js";
5
6
  import { serializeAss } from "../subtitles/captions.js";
6
7
  import { subtitleFrame } from "../subtitles/layout.js";
7
- import { runFfmpeg } from "../video/ffmpeg.js";
8
8
  import { planRender } from "../video/plan.js";
9
9
  import { renderSlideshow } from "../video/slideshow.js";
10
+ import { bundledSampleAssets, sampleNarrationSeconds } from "./narration.js";
10
11
  import { stylePreviewSeconds } from "./schema.js";
11
- import { sampleCues } from "./settings.js";
12
+ import { previewCues, previewWords } from "./settings.js";
12
13
  export class StylePreviewError extends Error {
13
14
  reason;
14
15
  constructor(reason, message) {
@@ -23,69 +24,30 @@ const previewFrame = {
23
24
  "16:9": { width: 480, height: 270 },
24
25
  "9:16": { width: 270, height: 480 },
25
26
  };
26
- const stillSize = 1280;
27
27
  const shotSeconds = stylePreviewSeconds / 3;
28
28
  const chapterTitle = "Chapter one";
29
- // Three stills, each a different colour story with a horizon and a light, so motion, grade
30
- // and transitions all read. Square, so either format crops them to cover.
31
- const stills = [
32
- `gradients=s=${stillSize}x${stillSize}:c0=0x1d2b53:c1=0xe07a5f:x0=0:y0=0:x1=0:y1=${stillSize}:d=1,` +
33
- "drawbox=x=820:y=300:w=140:h=140:color=0xfff1c1@0.9:t=fill," +
34
- "drawbox=x=0:y=880:w=iw:h=400:color=0x14213d@0.85:t=fill",
35
- `gradients=s=${stillSize}x${stillSize}:c0=0x0b3d2e:c1=0x88c9a1:x0=0:y0=${stillSize}:x1=${stillSize}:y1=0:d=1,` +
36
- "drawbox=x=200:y=380:w=260:h=260:color=0xf4f1de@0.8:t=fill," +
37
- "drawbox=x=0:y=840:w=iw:h=440:color=0x081c15@0.9:t=fill",
38
- `gradients=s=${stillSize}x${stillSize}:c0=0x3d1e6d:c1=0xf2a65a:x0=${stillSize}:y0=0:x1=0:y1=${stillSize}:d=1,` +
39
- "drawbox=x=540:y=330:w=200:h=200:color=0xffd166@0.9:t=fill," +
40
- "drawbox=x=0:y=820:w=iw:h=460:color=0x1b0f2e@0.85:t=fill",
41
- ];
42
29
  export function ffmpegStylePreview(deps) {
43
- let samples;
44
- const ensureSamples = (signal) => {
45
- samples ??= makeSamples(deps, signal).catch((error) => {
46
- samples = undefined;
47
- throw error;
48
- });
49
- return samples;
50
- };
30
+ const assets = deps.assets ?? bundledSampleAssets();
51
31
  return async (settings, output, signal, picture) => {
52
32
  try {
53
33
  // A real picture is shown for all three shots: the motion, the Look and the captions read
54
34
  // on it, and the transition and chapter card still show at the cuts.
55
- const images = picture === undefined ? await ensureSamples(signal) : [picture, picture, picture];
56
- await renderPreview(deps, settings, images, output, signal);
35
+ const images = picture !== undefined
36
+ ? [picture, picture, picture]
37
+ : settings.format === "9:16"
38
+ ? assets.tall
39
+ : assets.wide;
40
+ if (settings.short !== undefined)
41
+ await renderShortPreview(deps, settings, settings.short, assets, images, output, signal);
42
+ else
43
+ await renderPreview(deps, settings, assets, images, output, signal);
57
44
  }
58
45
  catch (error) {
59
46
  throw explained(deps.log, error);
60
47
  }
61
48
  };
62
49
  }
63
- async function makeSamples(deps, signal) {
64
- const folder = join(deps.dir, "samples");
65
- mkdirSync(folder, { recursive: true, mode: 0o700 });
66
- const paths = [];
67
- for (const [at, source] of stills.entries()) {
68
- const path = join(folder, `still-${String(at + 1)}.png`);
69
- paths.push(path);
70
- if (existsSync(path))
71
- continue;
72
- const part = join(folder, `still-${String(at + 1)}.part.png`);
73
- await runFfmpeg({
74
- bin: deps.ffmpeg,
75
- args: [
76
- ...["-hide_banner", "-nostdin", "-loglevel", "error", "-y"],
77
- ...["-f", "lavfi", "-i", source, "-frames:v", "1", part],
78
- ],
79
- signal,
80
- log: deps.log,
81
- onProgress: () => { },
82
- });
83
- renameSync(part, path);
84
- }
85
- return paths;
86
- }
87
- async function renderPreview(deps, settings, images, output, signal) {
88
- // A render on a given picture never made the stills, which is what created the folder.
50
+ async function renderPreview(deps, settings, assets, images, output, signal) {
89
51
  mkdirSync(deps.dir, { recursive: true, mode: 0o700 });
90
52
  const workspace = mkdtempSync(join(deps.dir, "work-"));
91
53
  try {
@@ -118,14 +80,14 @@ async function renderPreview(deps, settings, images, output, signal) {
118
80
  const edit = {
119
81
  ...plan.editList,
120
82
  ...previewFrame[settings.format],
121
- audio: [{ kind: "gap", path: null, seconds: plan.totalSeconds }],
83
+ audio: [{ kind: "body", path: assets.narration, seconds: plan.totalSeconds }],
122
84
  };
123
85
  const captions = settings.captions;
124
86
  if (captions !== null) {
125
87
  const font = await resolveFont(deps.paths, captions.fontId);
126
88
  mkdirSync(join(workspace, "fonts"), { mode: 0o700 });
127
89
  copyFileSync(font.path, join(workspace, "fonts", `selected${font.extension}`));
128
- writeFileSync(join(workspace, "subtitles.ass"), serializeAss(sampleCues(captions.text, plan.totalSeconds), {
90
+ writeFileSync(join(workspace, "subtitles.ass"), serializeAss(previewCues(captions.text), {
129
91
  ...subtitleFrame(settings.format),
130
92
  fontName: font.assName,
131
93
  fontSize: captions.fontSize,
@@ -148,6 +110,31 @@ async function renderPreview(deps, settings, images, output, signal) {
148
110
  rmSync(workspace, { recursive: true, force: true });
149
111
  }
150
112
  }
113
+ // The Shorts layout, drawn at the preview's size: the same audio cut, image timing, captions
114
+ // and headline a short gets, from the sample narration.
115
+ async function renderShortPreview(deps, settings, short, assets, images, output, signal) {
116
+ mkdirSync(deps.dir, { recursive: true, mode: 0o700 });
117
+ await renderShort({
118
+ bin: deps.ffmpeg,
119
+ timeline: [{ kind: "body", path: assets.narration, seconds: sampleNarrationSeconds }],
120
+ start: 0,
121
+ end: sampleNarrationSeconds,
122
+ images,
123
+ imageSeconds: shotSeconds,
124
+ motionStyle: defaultMotionStyle,
125
+ zoomPercent: defaultZoomPercent,
126
+ words: previewWords(short.text),
127
+ font: await resolveBoldFont(deps.paths, short.fontId),
128
+ ...(short.title === null ? {} : { title: short.title }),
129
+ speed: short.speed,
130
+ frame: previewFrame[settings.format],
131
+ output,
132
+ scratch: deps.dir,
133
+ signal,
134
+ log: deps.log,
135
+ onProgress: () => { },
136
+ });
137
+ }
151
138
  function explained(log, error) {
152
139
  if (error instanceof StylePreviewError)
153
140
  return error;
@@ -3,13 +3,16 @@ import { formats } from "../../kernel/pipeline.js";
3
3
  import { videoEditSchema } from "../admission/schema.js";
4
4
  import { subtitleModes, subtitlePositions } from "../subtitles/model.js";
5
5
  // "See it before you make it": a few seconds rendered by the real renderer with a project's
6
- // caption and Look settings, on stills and silence Slopify makes itself. Browser-safe, so
6
+ // caption and Look settings, on the bundled sample's images and narration. Browser-safe, so
7
7
  // Play and Edit project read the same request and reply shapes the server checks.
8
- // How long the preview runs: three stills of two seconds, so a transition and a chapter
9
- // card both show.
8
+ // How long the preview runs: three images of two seconds, so a transition and a chapter
9
+ // card both show, under six seconds of the sample narration.
10
10
  export const stylePreviewSeconds = 6;
11
11
  export const stylePreviewTextMax = 200;
12
+ // The sample text Play starts with. It, or no text, captions the sample narration with its own
13
+ // words; any other text is spread over the stretches the narration speaks.
12
14
  export const defaultPreviewText = "Every story begins with a word.";
15
+ export const shortTitlePreviewMax = 100;
13
16
  // A picture the preview is drawn on instead of the sample stills: the establishing image a
14
17
  // draft uploaded (its staged file), one a project already has (its output), or a channel cast
15
18
  // member's picture (by content hash). One the server can no longer find falls back to the
@@ -33,6 +36,17 @@ export const stylePreviewRequestSchema = z.object({
33
36
  videoEdit: videoEditSchema.optional(),
34
37
  previewText: z.string().max(stylePreviewTextMax).optional(),
35
38
  image: stylePreviewImageSchema.optional(),
39
+ // The Shorts layout instead of the video's: 9:16 through the Shorts renderer, with its
40
+ // big word-by-word captions in the caption font, the title as a headline when it is on,
41
+ // and the clips' speed. The caption size, position and the Look don't apply to a short.
42
+ shorts: z
43
+ .object({
44
+ titleOnScreen: z.boolean(),
45
+ speed: z.number().finite().min(1).max(1.25).optional(),
46
+ // The headline; absent or blank shows the sample's title.
47
+ title: z.string().max(shortTitlePreviewMax).optional(),
48
+ })
49
+ .optional(),
36
50
  // Render again even when this exact preview is already saved.
37
51
  force: z.boolean().optional(),
38
52
  });
@@ -1,8 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { stylePreviewSeconds } from "./schema.js";
5
- import { normalizeStylePreview, stylePreviewHash } from "./settings.js";
4
+ import { normalizeStylePreview, previewSeconds, stylePreviewHash } from "./settings.js";
6
5
  // Rendered previews are kept in the data folder by the hash of their settings, so a setting
7
6
  // changed back, or the same style in another project, plays at once. `force` renders again
8
7
  // anyway. Requests for a preview already being rendered wait for that render instead of
@@ -49,7 +48,7 @@ export function createStylePreviews(deps) {
49
48
  const saved = file(hash);
50
49
  if (saved === undefined)
51
50
  throw new Error("The style preview render finished without writing its video.");
52
- return { hash, cached: false, seconds: stylePreviewSeconds, version: saved.version };
51
+ return { hash, cached: false, seconds: previewSeconds(settings), version: saved.version };
53
52
  };
54
53
  return {
55
54
  file,
@@ -65,7 +64,7 @@ export function createStylePreviews(deps) {
65
64
  return Promise.resolve({
66
65
  hash,
67
66
  cached: true,
68
- seconds: stylePreviewSeconds,
67
+ seconds: previewSeconds(settings),
69
68
  version: saved.version,
70
69
  });
71
70
  const started = run(hash, settings, picture).finally(() => {