@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
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { stageKinds, stageStates } from "../../kernel/pipeline.js";
3
+ import { optionalDeps } from "../../kernel/runner/graph.js";
3
4
  import { sourceOf } from "../admission/model.js";
4
5
  import { usesShorts, usesYoutubeDescription } from "../admission/rules.js";
5
6
  import { reviewsNarration } from "../reviews/rules.js";
@@ -104,10 +105,31 @@ export function invocationReady(deps, work) {
104
105
  return false;
105
106
  return recipe.dependsOn.every((key) => {
106
107
  const dependency = plan.work.find((value) => value.key === key);
107
- return dependency?.disposition === "reuse" && !dependency.inflight;
108
+ if (dependency?.disposition === "reuse" && !dependency.inflight)
109
+ return true;
110
+ // A dependency the step can do without (the document's cover) releases it once it has
111
+ // failed for good; the step then runs without it. The fingerprint is unchanged: it
112
+ // already names the missing cover, so a thumbnail made later marks the document
113
+ // outdated rather than current.
114
+ return (dependency !== undefined &&
115
+ (optionalDeps[recipe.stage]?.includes(dependency.stage) ?? false) &&
116
+ gaveUp(deps, work.projectId, key));
108
117
  });
109
118
  });
110
119
  }
120
+ // Whether the current run's step for `key` failed or was canceled and isn't waiting to try
121
+ // again by itself.
122
+ function gaveUp(deps, projectId, key) {
123
+ const row = deps.db
124
+ .prepare(`SELECT w.state,w.retry_at FROM revision_work_reservations r
125
+ JOIN revision_work w ON w.id=r.work_id
126
+ JOIN project_heads h ON h.project_id=r.project_id AND h.revision_id=r.revision_id
127
+ WHERE r.project_id=? AND r.work_key=?`)
128
+ .get(projectId, key);
129
+ return (row !== undefined &&
130
+ row.retry_at === null &&
131
+ (row.state === "failed" || row.state === "canceled"));
132
+ }
111
133
  export function executionStandings(deps, projectId, derived) {
112
134
  const revisionId = currentRevisionId(deps.db, projectId);
113
135
  const view = revisionId === undefined ? undefined : getRevisionView(deps, projectId, revisionId);
@@ -41,7 +41,7 @@ export async function executeVoicesRecipe(deps, context, piece) {
41
41
  const { words } = wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, context.work.projectId, timing.output.path), "utf8")));
42
42
  const audio = await revisionAudio(deps, context, view, { levelled: true });
43
43
  if (audio.length === 0)
44
- throw new Error("The MP3 and M4B files need narration audio, but this project has none. Turn narration on in Edit project, or turn Audio files off under Speakers (Play → Audio, or Edit project → Providers), then Try again.");
44
+ throw new Error("The MP3 and M4B files need narration audio, but this project has none. Turn narration on in Edit project, or turn Audio files off under Speakers (Play → Narration, or Edit project → Providers), then Try again.");
45
45
  const totalSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
46
46
  const chapters = audioChapters({
47
47
  title: view.revision.config.title,
@@ -57,7 +57,7 @@ export async function executeVoicesRecipe(deps, context, piece) {
57
57
  const prepared = [];
58
58
  try {
59
59
  const metadata = join(directory, "chapters.txt");
60
- writeFileSync(metadata, ffmetadata(view.revision.config.title, chapters), { mode: 0o600 });
60
+ writeFileSync(metadata, ffmetadata(view.revision.config.title, chapters, view.revision.config.voices?.book), { mode: 0o600 });
61
61
  // Level the volume: the sound is mixed once and mastered to the audio files' target, and both
62
62
  // files are encoded from that (`loudness/model.ts`).
63
63
  const goal = masterGoal(view.revision.config, "audioFiles");
@@ -2,7 +2,7 @@ import { checkRuntimeModel } from "../../catalog/runtime-models.js";
2
2
  import { referenceRefusal, takesReferenceImage } from "../../catalog/schema.js";
3
3
  import { readinessIsUsable } from "../../kernel/ports/model.js";
4
4
  import { cliPathChanged } from "../settings/cli-paths.js";
5
- import { isLocalCliProvider } from "../settings/model.js";
5
+ import { isLocalCliProvider, isUncataloguedProvider } from "../settings/model.js";
6
6
  import { hasKey, listVoices } from "../settings/repo.js";
7
7
  import { requiresNewSubmission } from "./preview-retained.js";
8
8
  import { recipeProviderChoice } from "./recipe-provider-choice.js";
@@ -33,7 +33,7 @@ export async function checkReadiness(deps, snapshot, view) {
33
33
  if (provider === undefined || !readinessIsUsable(provider.readiness)) {
34
34
  fields.push({
35
35
  field: choice.family,
36
- message: provider?.readiness.kind === "cli" && provider.readiness.issue
36
+ message: provider !== undefined && provider.readiness.kind !== "keyed" && provider.readiness.issue
37
37
  ? provider.readiness.issue
38
38
  : "Configure this provider before rebuilding.",
39
39
  });
@@ -60,7 +60,7 @@ export function localReadiness(deps, snapshot, view, providers, catalogue) {
60
60
  row.id === choice.model &&
61
61
  row.enabled &&
62
62
  !row.deprecated);
63
- if (model === undefined && !isLocalCliProvider(choice.provider))
63
+ if (model === undefined && !isUncataloguedProvider(choice.provider))
64
64
  fields.push({
65
65
  field: `${choice.family}.model`,
66
66
  message: "The selected model is no longer available.",
@@ -90,17 +90,29 @@ export function setRedoState(db, id, state, error = null) {
90
90
  db.prepare("UPDATE review_verdicts SET redo_state=?, redo_error=? WHERE id=?").run(state, error, id);
91
91
  }
92
92
  // Overrule accepts a failed item as it is; Redo records that the person had it made again.
93
- // A passed verdict has nothing to overrule, and an automatic redo already under way owns the
94
- // item until it lands.
93
+ // A passed verdict has nothing to overrule. Overrule also calls off an automatic redo that is
94
+ // still waiting to start (what depends on the item then goes ahead with it as it is), but a
95
+ // redo already under way owns the item until it lands.
95
96
  export function actOnVerdict(db, projectId, id, action, at) {
96
97
  const record = verdictById(db, projectId, id);
97
98
  if (record === undefined)
98
99
  return { ok: false, reason: "not-found" };
99
100
  if (action === "overruled" && record.passed)
100
101
  return { ok: false, reason: "conflict" };
101
- if (record.redoState === "pending" || record.redoState === "started")
102
+ if (record.redoState === "started")
102
103
  return { ok: false, reason: "conflict" };
103
- db.prepare("UPDATE review_verdicts SET action=?, action_at=? WHERE id=?").run(action, at, id);
104
+ if (record.redoState === "pending") {
105
+ if (action !== "overruled")
106
+ return { ok: false, reason: "conflict" };
107
+ const called = db
108
+ .prepare("UPDATE review_verdicts SET action=?, action_at=?, redo_state=NULL WHERE id=? AND redo_state='pending'")
109
+ .run(action, at, id);
110
+ if (Number(called.changes) !== 1)
111
+ return { ok: false, reason: "conflict" };
112
+ }
113
+ else {
114
+ db.prepare("UPDATE review_verdicts SET action=?, action_at=? WHERE id=?").run(action, at, id);
115
+ }
104
116
  const updated = verdictById(db, projectId, id);
105
117
  return updated === undefined ? { ok: false, reason: "not-found" } : { ok: true, value: updated };
106
118
  }
@@ -207,16 +207,35 @@ function planUses(db, projectId, rows) {
207
207
  });
208
208
  }
209
209
  export function limitWaitsOf(db, projectId) {
210
+ return limitWaitRows(db, projectId).map((row) => row.wait);
211
+ }
212
+ // Every project's waits at once, for the lists that show "Waiting for Codex limits" on a row
213
+ // (Projects, Home's Running now, the calendar) without asking once per project.
214
+ export function limitWaitsByProject(db) {
215
+ const found = new Map();
216
+ for (const row of limitWaitRows(db)) {
217
+ const list = found.get(row.projectId) ?? [];
218
+ list.push(row.wait);
219
+ found.set(row.projectId, list);
220
+ }
221
+ return found;
222
+ }
223
+ // A wait as a list row carries it: the plan's name and times, not its account key.
224
+ export function listingWait(wait) {
225
+ return { name: wait.name, stage: wait.stage, resetsAt: wait.resetsAt, retryAt: wait.retryAt };
226
+ }
227
+ function limitWaitRows(db, projectId) {
210
228
  // Only a stage still running is waiting: a row left by a cancel or pause is not shown.
211
229
  return db
212
- .prepare(`SELECT w.account, w.stage, l.resets_at, l.retry_at FROM plan_limit_waiters w
230
+ .prepare(`SELECT w.project_id, w.account, w.stage, l.resets_at, l.retry_at FROM plan_limit_waiters w
213
231
  JOIN plan_limit_waits l ON l.account = w.account
214
232
  JOIN stages s ON s.project_id = w.project_id AND s.kind = w.stage AND s.state = 'running'
215
- WHERE w.project_id = ? ORDER BY w.since`)
216
- .all(projectId)
233
+ ${projectId === undefined ? "" : "WHERE w.project_id = ?"} ORDER BY w.since`)
234
+ .all(...(projectId === undefined ? [] : [projectId]))
217
235
  .map((raw) => {
218
236
  const row = z
219
237
  .object({
238
+ project_id: z.string(),
220
239
  account: z.enum(planAccounts),
221
240
  stage: z.enum(stageKinds),
222
241
  resets_at: z.string().nullable(),
@@ -224,11 +243,14 @@ export function limitWaitsOf(db, projectId) {
224
243
  })
225
244
  .parse(raw);
226
245
  return {
227
- account: row.account,
228
- name: planAccountNames[row.account],
229
- stage: row.stage,
230
- resetsAt: row.resets_at,
231
- retryAt: row.retry_at,
246
+ projectId: row.project_id,
247
+ wait: {
248
+ account: row.account,
249
+ name: planAccountNames[row.account],
250
+ stage: row.stage,
251
+ resetsAt: row.resets_at,
252
+ retryAt: row.retry_at,
253
+ },
232
254
  };
233
255
  });
234
256
  }
@@ -1,4 +1,4 @@
1
- import { isLocalCliProvider } from "../settings/model.js";
1
+ import { isLocalCliProvider, systemVoiceProvider } from "../settings/model.js";
2
2
  // Per million tokens; cached input at its own rate when the catalogue gives one.
3
3
  export function tokenCost(rates, tokensIn, tokensOut, cachedTokens = 0) {
4
4
  if (rates.inputPerMillionTokens === undefined || rates.outputPerMillionTokens === undefined)
@@ -56,6 +56,9 @@ export function priceCall(call, catalogue) {
56
56
  price: api === undefined ? null : { catalogue: date, model: api.id, ...api.pricing },
57
57
  };
58
58
  }
59
+ // The computer's own voice costs nothing.
60
+ if (call.provider === systemVoiceProvider)
61
+ return { onPlan: false, cost: 0, apiModel: null, apiCost: null, price: null };
59
62
  const family = call.kind === "video" ? "image" : call.kind;
60
63
  const entry = catalogue[family].find((model) => model.provider === call.provider && model.id === call.model);
61
64
  const rates = entry?.pricing;
@@ -2,6 +2,10 @@ import { z } from "zod";
2
2
  import { derive } from "../../kernel/runner/graph.js";
3
3
  import { liveProject, projectPaused, stageStandingsByProject } from "../admission/repo.js";
4
4
  import { queueEntries } from "../batch/index.js";
5
+ import { sampleIds } from "../onboarding/model.js";
6
+ import { readSampleRecord } from "../onboarding/state.js";
7
+ import { limitWaitsByProject, listingWait } from "../run-cost/panel.js";
8
+ import { uploadedProjects } from "../uploads/repo.js";
5
9
  import { nextOccurrence } from "./calendar.js";
6
10
  import { scheduleRows } from "./repo.js";
7
11
  import { calendarMaxDays, } from "./schema.js";
@@ -92,13 +96,64 @@ function upcomingRuns(deps, schedule, now, from, to) {
92
96
  const projectRow = z.object({
93
97
  id: z.string(),
94
98
  title: z.string(),
99
+ config: z.string(),
95
100
  created_at: z.string(),
96
101
  finished_at: z.string().nullable(),
97
102
  });
103
+ // Projects the person has something to decide on in the current revision: a review
104
+ // checkpoint holding the run, or an automatic review that failed an item and waits for
105
+ // Overrule or Redo (its latest verdict, with no redo under way).
106
+ function awaitingReview(db) {
107
+ const ids = db
108
+ .prepare(`SELECT c.project_id AS id FROM review_checkpoints c
109
+ JOIN project_heads h ON h.project_id=c.project_id AND h.revision_id=c.revision_id
110
+ WHERE c.state IN ('held','pending-review') AND c.approved_at IS NULL
111
+ UNION
112
+ SELECT v.project_id AS id FROM review_verdicts v
113
+ JOIN project_heads h ON h.project_id=v.project_id AND h.revision_id=v.revision_id
114
+ WHERE v.passed=0 AND v.action IS NULL AND (v.redo_state IS NULL OR v.redo_state='failed')
115
+ AND NOT EXISTS (SELECT 1 FROM review_verdicts n WHERE n.project_id=v.project_id
116
+ AND n.item_key=v.item_key AND (n.created_at>v.created_at OR (n.created_at=v.created_at AND n.rowid>v.rowid)))`)
117
+ .all();
118
+ return new Set(ids.map((row) => z.string().parse(row.id)));
119
+ }
120
+ // Whether a finished project's settings make a video: projects stored before sources were
121
+ // recorded made one.
122
+ const videoSource = z.object({ sources: z.object({ video: z.string() }).partial() }).partial();
123
+ function makesVideo(config) {
124
+ try {
125
+ const parsed = videoSource.safeParse(JSON.parse(config));
126
+ return !parsed.success || parsed.data.sources?.video !== "off";
127
+ }
128
+ catch {
129
+ return true;
130
+ }
131
+ }
132
+ function sampleProjectIds(db) {
133
+ return new Set(sampleIds.flatMap((id) => {
134
+ const record = readSampleRecord(db, id);
135
+ return record?.projectId === undefined ? [] : [record.projectId];
136
+ }));
137
+ }
138
+ // What a project on the calendar asks of the person, if anything. The same readings Home's
139
+ // Needs you uses: a failed or paused run, or one waiting for a review.
140
+ function needsOf(state, reviewing) {
141
+ if (state === "failed")
142
+ return "failed";
143
+ if (state === "paused")
144
+ return "paused";
145
+ if (reviewing && state !== "canceled")
146
+ return "review";
147
+ return undefined;
148
+ }
98
149
  // Running (or waiting) projects made before `to`, and finished ones whose last stage ended in
99
- // the range.
150
+ // the range, with what each needs from the person and whether its video is ready to upload.
100
151
  function projectsIn(deps, from, to) {
101
152
  const standings = stageStandingsByProject(deps.db);
153
+ const reviewing = awaitingReview(deps.db);
154
+ const uploads = uploadedProjects(deps.db);
155
+ const samples = sampleProjectIds(deps.db);
156
+ const waits = limitWaitsByProject(deps.db);
102
157
  const fromSchedule = new Map();
103
158
  for (const row of deps.db
104
159
  .prepare(`SELECT DISTINCT admitted.value AS project_id, schedule_runs.schedule_id
@@ -108,7 +163,7 @@ function projectsIn(deps, from, to) {
108
163
  fromSchedule.set(parsed.project_id, parsed.schedule_id);
109
164
  }
110
165
  return deps.db
111
- .prepare(`SELECT projects.id,projects.title,projects.created_at,max(stages.finished_at) AS finished_at
166
+ .prepare(`SELECT projects.id,projects.title,projects.config,projects.created_at,max(stages.finished_at) AS finished_at
112
167
  FROM projects LEFT JOIN stages ON stages.project_id=projects.id
113
168
  WHERE projects.created_at<? AND ${liveProject()} GROUP BY projects.id ORDER BY projects.created_at,projects.id`)
114
169
  .all(to.toISOString())
@@ -121,6 +176,12 @@ function projectsIn(deps, from, to) {
121
176
  if (ended < from.toISOString() || ended >= to.toISOString())
122
177
  return [];
123
178
  }
179
+ const needs = needsOf(state, reviewing.has(project.id));
180
+ const ready = (state === "done" || state === "partial") &&
181
+ makesVideo(project.config) &&
182
+ !uploads.has(project.id) &&
183
+ !samples.has(project.id);
184
+ const waiting = waits.get(project.id);
124
185
  return [
125
186
  {
126
187
  id: project.id,
@@ -129,6 +190,9 @@ function projectsIn(deps, from, to) {
129
190
  createdAt: project.created_at,
130
191
  finishedAt: terminal ? (project.finished_at ?? project.created_at) : null,
131
192
  scheduleId: fromSchedule.get(project.id) ?? null,
193
+ ...(needs === undefined ? {} : { needs }),
194
+ ...(ready ? { readyToUpload: true } : {}),
195
+ ...(waiting === undefined ? {} : { limitWaits: waiting.map(listingWait) }),
132
196
  },
133
197
  ];
134
198
  });
@@ -348,7 +348,7 @@ function parseRun(row) {
348
348
  const internalRunError = "Slopify hit an internal error while starting this run. The next run will try again; if it keeps happening, use Download diagnostics in Settings and report it.";
349
349
  const runErrors = {
350
350
  "missing-template": "The template this schedule uses was deleted or changed. Edit the schedule and choose a template again.",
351
- "unsupported-media": "This template uses audio, images, a thumbnail, shorts background music or an ambient sound file you supplied, which scheduled runs cannot use. Pick a template that generates these instead, or on Play remove the music under Outputs → Export → More shorts options (or pick Rain, Fireplace or Wind under Outputs → Export → Ambient sound) and save the template again.",
351
+ "unsupported-media": "This template uses audio, images, a thumbnail, shorts background music or an ambient sound file you supplied, which scheduled runs cannot use. Pick a template that generates these instead, or on Play remove the music under Outputs → More shorts options (or pick Rain, Fireplace or Wind under Video and style → Ambient sound) and save the template again.",
352
352
  "spend-limit": "Not started: the estimated cost was above this schedule's spend limit, or some prices were unknown. Raise the spend limit or choose models with known prices.",
353
353
  readiness: "Not started: a provider was not ready, for example a missing API key, model or voice. Check Settings → Providers; the next run will try again.",
354
354
  "not-found": "The template's settings are no longer valid, for example a prompt or voice it uses was deleted. Fix the template in Library > Templates and save it.",
@@ -194,7 +194,7 @@ async function execute(deps, claimed) {
194
194
  }
195
195
  // One project per run. The first queued topic fills the chosen keyword, the schedule's fixed
196
196
  // values fill the rest over the template's own, and the template's title is filled from the
197
- // same keywords, so "D&D Lore: {{Topic}}" names the project after the topic. A topic saved
197
+ // same keywords, so "History: {{Topic}}" names the project after the topic. A topic saved
198
198
  // before topics had a keyword brings its own title and values instead.
199
199
  function runForm(form, schedule, topic) {
200
200
  return {
@@ -1,8 +1,11 @@
1
1
  import { z } from "zod";
2
- import { projectStates } from "../../kernel/pipeline.js";
2
+ import { projectStates, stageKinds } from "../../kernel/pipeline.js";
3
3
  import { thinkingModes } from "../../kernel/ports/llm.js";
4
4
  import { cadenceSchema, validTimeZone } from "./calendar.js";
5
5
  const id = z.uuid();
6
+ // What a project on the calendar waits on the person for: a failed or paused run, or a review
7
+ // (a checkpoint holding the run, or an automatic review's failed item).
8
+ export const calendarNeeds = ["failed", "paused", "review"];
6
9
  const keywordName = z
7
10
  .string()
8
11
  .min(1)
@@ -148,10 +151,22 @@ export const scheduleRunSchema = z
148
151
  })
149
152
  .strict()
150
153
  .readonly();
154
+ // `values` are the keywords the person set on a held topic before approving it; they go with it
155
+ // into the queue. Absent from a reply of a Slopify before they existed: none.
151
156
  export const heldTopicSchema = z
152
- .object({ id, title: z.string(), rank: z.number().int(), createdAt: z.string() })
157
+ .object({
158
+ id,
159
+ title: z.string(),
160
+ values: z.record(z.string(), z.string()).readonly().default({}),
161
+ rank: z.number().int(),
162
+ createdAt: z.string(),
163
+ })
153
164
  .strict()
154
165
  .readonly();
166
+ // Edit on a held topic: its title, and its keywords when given (replacing the ones it had).
167
+ export const heldTopicEditSchema = z
168
+ .object({ title: z.string(), values: z.record(keywordName, z.string()).optional() })
169
+ .strict();
155
170
  // Reordering is by position against the version the person saw, so a run taking the first
156
171
  // topic meanwhile makes the move refuse instead of moving the wrong one.
157
172
  export const topicMoveSchema = z
@@ -162,6 +177,15 @@ export const topicMoveSchema = z
162
177
  })
163
178
  .strict()
164
179
  .readonly();
180
+ // The whole queue at once: the schedule page adds, renames, removes and reorders topics in
181
+ // place, and Undo puts the queue back as it was.
182
+ export const topicQueueSchema = z
183
+ .object({
184
+ baseVersion: z.number().int().positive(),
185
+ items: z.array(item).max(queueMax).readonly(),
186
+ })
187
+ .strict()
188
+ .readonly();
165
189
  // The calendar: what will run, what is running or finished, and what waits in the batch queue.
166
190
  // `topicSource` says where a run's topic comes from: the queue (`topic` names it), topics held
167
191
  // for approval, a generation still to come, or none (the template as saved).
@@ -194,6 +218,23 @@ export const calendarProjectSchema = z
194
218
  createdAt: z.string(),
195
219
  finishedAt: z.string().nullable(),
196
220
  scheduleId: z.string().nullable(),
221
+ // What the project waits on the person for; left out when nothing.
222
+ needs: z.enum(calendarNeeds).optional(),
223
+ // Finished, makes a video, not marked uploaded and not a bundled sample.
224
+ readyToUpload: z.literal(true).optional(),
225
+ // Stages waiting for a CLI plan's limits to reset.
226
+ limitWaits: z
227
+ .array(z
228
+ .object({
229
+ name: z.string(),
230
+ stage: z.enum(stageKinds),
231
+ resetsAt: z.string().nullable(),
232
+ retryAt: z.string(),
233
+ })
234
+ .strict()
235
+ .readonly())
236
+ .readonly()
237
+ .optional(),
197
238
  })
198
239
  .strict()
199
240
  .readonly();
@@ -5,10 +5,13 @@
5
5
  // - the texts are equal, or
6
6
  // - their word sets overlap by at least `jaccardMin` (shared words / all words), or
7
7
  // - one's words all appear in the other's and cover at least `containmentMin` of it
8
- // ("Tiamat" and "Tiamat's Lair" are one video; "Red Dragons" and "Blue Dragons" are two).
8
+ // ("Cleopatra" and "Cleopatra's Palace" are one video; "Red Pyramids" and "Bent Pyramids" are two).
9
9
  // Words are compared without filler words ("the", "of", …) and without a plural "s".
10
10
  // A project title is compared more loosely: every word of the topic appearing in it is enough,
11
- // because the title wraps the topic in the template's own words ("D&D Lore: Tiamat").
11
+ // because the title wraps the topic in the template's own words ("History: Cleopatra"). A topic
12
+ // of one or two words is too easily found inside an unrelated title ("Pyramids" in "The Great
13
+ // Pyramids of Giza"), so one of the title's clauses must hold exactly its words, leaving aside
14
+ // question and framing words ("Hypatia" in "Who was Hypatia? The Last Scholar Explained").
12
15
  export const jaccardMin = 0.6;
13
16
  export const containmentMin = 0.5;
14
17
  const filler = new Set([
@@ -65,15 +68,50 @@ export function similarTopics(a, b) {
65
68
  const large = Math.max(wa.size, wb.size);
66
69
  return common === small && small / large >= containmentMin;
67
70
  }
68
- // A topic already made as a project: all of its words appear in the project's title.
71
+ export const shortTopicWords = 2;
72
+ // A topic already made as a project: all of its words appear in the project's title, or for a
73
+ // short topic, the title or one of its parts is near the same.
69
74
  export function topicInTitle(topic, title) {
70
75
  if (similarTopics(topic, title))
71
76
  return true;
72
77
  const words = topicWords(topic);
73
78
  if (words.size === 0)
74
79
  return false;
80
+ if (words.size <= shortTopicWords) {
81
+ const wanted = subjectWords(topic);
82
+ return titleClauses(title).some((clause) => sameWords(subjectWords(clause), wanted));
83
+ }
75
84
  return shared(words, topicWords(title)) === words.size;
76
85
  }
86
+ // Words that frame a subject in a title without being part of it.
87
+ const framing = new Set([
88
+ "who",
89
+ "what",
90
+ "why",
91
+ "how",
92
+ "when",
93
+ "where",
94
+ "which",
95
+ "is",
96
+ "was",
97
+ "are",
98
+ "were",
99
+ "really",
100
+ "explained",
101
+ ]);
102
+ function subjectWords(text) {
103
+ return new Set([...topicWords(text)].filter((word) => !framing.has(word)));
104
+ }
105
+ function sameWords(a, b) {
106
+ return a.size > 0 && a.size === b.size && shared(a, b) === a.size;
107
+ }
108
+ // "History: Cleopatra (Part 2) | Who is Hypatia?" → ["History", "Cleopatra", "Part 2", "Who is Hypatia"].
109
+ function titleClauses(title) {
110
+ return title
111
+ .split(/\s*[:|()[\]?!.,;\u2013\u2014]\s*|\s+-\s+/u)
112
+ .map((part) => part.trim())
113
+ .filter((part) => part !== "");
114
+ }
77
115
  // Keeps the candidates that are new, in their order, dropping repeats among themselves too.
78
116
  export function newTopics(candidates, known) {
79
117
  const kept = [];
@@ -105,7 +105,7 @@ export function parseTopicList(text, options) {
105
105
  return {
106
106
  ok: false,
107
107
  problems: [
108
- `The list couldn't be read as YAML or JSON: ${message ?? ""}. Write one item per topic, such as "- Topic: Tiamat".`,
108
+ `The list couldn't be read as YAML or JSON: ${message ?? ""}. Write one item per topic, such as "- Topic: Cleopatra".`,
109
109
  ],
110
110
  };
111
111
  }
@@ -125,7 +125,7 @@ export function parseTopicList(text, options) {
125
125
  return;
126
126
  }
127
127
  if (item === null || typeof item !== "object" || Array.isArray(item)) {
128
- problems.push(`${name} is not a topic or a set of keywords. Write it as "- Tiamat" or "- Topic: Tiamat".`);
128
+ problems.push(`${name} is not a topic or a set of keywords. Write it as "- Cleopatra" or "- Topic: Cleopatra".`);
129
129
  return;
130
130
  }
131
131
  let title = "";