@gentbajko/slopify 3.0.7 → 3.0.9

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 (44) hide show
  1. package/dist/edge/autostart/native.js +12 -3
  2. package/dist/extension/slopify-studio-chrome.zip +0 -0
  3. package/dist/extension/slopify-studio-firefox.zip +0 -0
  4. package/dist/kernel/db/migrations/0042-narration-retries.sql +13 -0
  5. package/dist/main.js +14 -19
  6. package/dist/patch-notes/3.0.8.md +23 -0
  7. package/dist/patch-notes/3.0.9.md +27 -0
  8. package/dist/patch-notes/index.json +12 -0
  9. package/dist/slices/admission/rules.js +71 -2
  10. package/dist/slices/admission/schema.js +2 -0
  11. package/dist/slices/admission/substitute.js +5 -2
  12. package/dist/slices/estimate/index.js +3 -0
  13. package/dist/slices/images/scenes.js +103 -0
  14. package/dist/slices/play-drafts/convert.js +5 -1
  15. package/dist/slices/play-drafts/schema.js +2 -0
  16. package/dist/slices/project-templates/from-project.js +1 -0
  17. package/dist/slices/rebuild/narration-retry.js +128 -0
  18. package/dist/slices/rebuild/preview-details.js +3 -1
  19. package/dist/slices/rebuild/recipe-build.js +5 -1
  20. package/dist/slices/rebuild/recipe-model.js +3 -0
  21. package/dist/slices/rebuild/recipe-provider-choice.js +3 -1
  22. package/dist/slices/rebuild/recipe-save.js +3 -2
  23. package/dist/slices/rebuild/recipe-scenes.js +61 -0
  24. package/dist/slices/rebuild/recipe-text.js +4 -3
  25. package/dist/slices/rebuild/recipe-validation.js +3 -2
  26. package/dist/slices/rebuild/recipe-visual.js +46 -2
  27. package/dist/slices/rebuild/recipe-work.js +18 -0
  28. package/dist/slices/rebuild/recovery-selection.js +7 -0
  29. package/dist/slices/rebuild/runtime-document.js +1 -1
  30. package/dist/slices/rebuild/runtime-piece-label.js +2 -0
  31. package/dist/slices/rebuild/runtime-plan.js +1 -1
  32. package/dist/slices/rebuild/runtime-provider.js +16 -0
  33. package/dist/slices/rebuild/runtime-subtitles.js +53 -11
  34. package/dist/slices/revisions/mutation-prepare.js +1 -1
  35. package/dist/slices/revisions/view.js +3 -1
  36. package/dist/slices/storage/backup-format.js +1 -0
  37. package/dist/tutorials/Play-Images.md +16 -0
  38. package/dist/tutorials/Play-Title-and-Article.md +1 -0
  39. package/dist/updater/service.js +13 -2
  40. package/dist/updater/work-in-progress.js +59 -0
  41. package/dist/web/assets/{index-Cdb0Jh3P.js → index-CIXw6vpi.js} +115 -115
  42. package/dist/web/assets/{pdf-BhFuoBjr.js → pdf-B9CdIc1m.js} +1 -1
  43. package/dist/web/index.html +1 -1
  44. package/package.json +1 -1
@@ -77,6 +77,10 @@ export function createNativeAutostart(o) {
77
77
  const ours = text.includes(platform === "linux" ? desktopMarker : launcherMarker);
78
78
  return { state: ours ? "ours" : "foreign", text };
79
79
  }
80
+ const thisLauncher = launcherRef(platform, at.launcher);
81
+ function runsThisFolder(now) {
82
+ return now.state === "ours" && (now.text?.includes(thisLauncher) ?? false);
83
+ }
80
84
  function state(enabled) {
81
85
  return {
82
86
  available: true,
@@ -113,7 +117,9 @@ export function createNativeAutostart(o) {
113
117
  await o.fs.write(at.entry, entryText, 0o644);
114
118
  }
115
119
  return {
116
- status: async () => state((await entryNow()).state === "ours"),
120
+ // On only when the entry starts this data folder: a second Slopify (another --data-dir,
121
+ // a test copy) sees the switch off, so turning it off there can't remove this one's entry.
122
+ status: async () => state(runsThisFolder(await entryNow())),
117
123
  enable: async () => {
118
124
  if (platform === "win32") {
119
125
  const problem = windowsPathProblem(o.dataDir);
@@ -121,6 +127,8 @@ export function createNativeAutostart(o) {
121
127
  throw new Error(problem);
122
128
  }
123
129
  const now = await entryNow();
130
+ if (now.state === "ours" && !runsThisFolder(now))
131
+ throw new Error(`Another Slopify, with a different data folder, already starts when you log in (${at.where}). Slopify left it alone. Turn it off in that Slopify's Settings → General, or delete ${platform === "win32" ? `the ${runValue} entry in Task Manager → Startup apps` : "that file"}, then turn the switch on here again.`);
124
132
  if (now.state === "foreign")
125
133
  throw new Error(platform === "win32"
126
134
  ? `Your start-up list already has an entry named ${runValue} that Slopify didn't make (${runKey}). Slopify left it alone. Remove it in Task Manager → Startup apps, or rename it, then turn the switch on again.`
@@ -131,7 +139,8 @@ export function createNativeAutostart(o) {
131
139
  },
132
140
  disable: async () => {
133
141
  const now = await entryNow();
134
- if (now.state === "ours") {
142
+ // Only this data folder's entry: another Slopify's is not this switch's to remove.
143
+ if (runsThisFolder(now)) {
135
144
  if (platform === "win32") {
136
145
  const result = await o.exec("reg", ["delete", runKey, "/v", runValue, "/f"]);
137
146
  if (result.code !== 0)
@@ -151,7 +160,7 @@ export function createNativeAutostart(o) {
151
160
  const now = await entryNow();
152
161
  // Only the entry that runs this data folder's launcher: a Slopify started with another
153
162
  // --data-dir must not take over the one the switch was turned on from.
154
- if (now.state !== "ours" || !now.text?.includes(launcherRef(platform, at.launcher)))
163
+ if (!runsThisFolder(now))
155
164
  return;
156
165
  await writeLauncher();
157
166
  await writeEntry(now.text);
@@ -0,0 +1,13 @@
1
+ -- A narration chunk whose audio stopped matching its text is recorded again by itself: the
2
+ -- caption step asks for it here, and the retry starts once that step's failure is saved. The
3
+ -- count caps it at two tries per chunk; `state` is what the last try did.
4
+ CREATE TABLE narration_retries (
5
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
6
+ chunk_key TEXT NOT NULL,
7
+ tries INTEGER NOT NULL CHECK (tries > 0),
8
+ state TEXT NOT NULL CHECK (state IN ('pending', 'started', 'failed')),
9
+ detail TEXT,
10
+ updated_at TEXT NOT NULL,
11
+ PRIMARY KEY(project_id, chunk_key)
12
+ );
13
+ CREATE INDEX narration_retries_pending ON narration_retries(state) WHERE state = 'pending';
package/dist/main.js CHANGED
@@ -55,6 +55,7 @@ import { createNotificationSender } from "./slices/notifications/send.js";
55
55
  import { readNotificationUrl } from "./slices/notifications/settings.js";
56
56
  import { seedSamples } from "./slices/onboarding/sample.js";
57
57
  import { templateById } from "./slices/project-templates/repo.js";
58
+ import { createNarrationRetries } from "./slices/rebuild/narration-retry.js";
58
59
  import { recoverProject } from "./slices/rebuild/recovery.js";
59
60
  import { claimWork, finishWork, maySubmit } from "./slices/rebuild/repo.js";
60
61
  import { waitToRetry, wakeRetries } from "./slices/rebuild/retry.js";
@@ -88,6 +89,7 @@ import { isUpdateToken } from "./updater/model.js";
88
89
  import { npmCommand, updateCommitted } from "./updater/plan.js";
89
90
  import { publishedVersion } from "./updater/registry.js";
90
91
  import { createUpdater } from "./updater/service.js";
92
+ import { workInProgress } from "./updater/work-in-progress.js";
91
93
  // ceiling: a burst of finished stages coalesces into one delivery a second later, and the
92
94
  // collector gets ten seconds to answer before the attempt is abandoned and the events
93
95
  // stay queued.
@@ -261,8 +263,12 @@ export async function boot(config, options = {}) {
261
263
  });
262
264
  const audioPreviews = createAudioPreviewStore();
263
265
  const reviewRedos = createReviewRedos();
266
+ const narrationRetries = createNarrationRetries();
264
267
  const runner = wireRunner({
265
- onFinished: (work) => reviewRedos.kick(work.projectId),
268
+ onFinished: (work) => {
269
+ reviewRedos.kick(work.projectId);
270
+ narrationRetries.kick(work.projectId);
271
+ },
266
272
  db,
267
273
  paths,
268
274
  clock,
@@ -332,24 +338,10 @@ export async function boot(config, options = {}) {
332
338
  : npm === undefined
333
339
  ? "npm was not found, so in-app updates are off. Install Node.js with npm (https://nodejs.org), or update from the terminal: npx @gentbajko/slopify@latest"
334
340
  : undefined,
335
- busy: () => updateDb.prepare("SELECT 1 FROM stages WHERE state = 'running' LIMIT 1").get() !==
336
- undefined ||
337
- updateDb
338
- .prepare("SELECT id FROM projects")
339
- .all()
340
- .some((row) => typeof row.id === "string" && runner.hasInflight?.(row.id) === true),
341
- busyWith: () => {
342
- const running = updateDb
343
- .prepare("SELECT p.title FROM stages s JOIN projects p ON p.id=s.project_id WHERE s.state='running' LIMIT 1")
344
- .get();
345
- if (typeof running?.title === "string")
346
- return running.title;
347
- const inflight = updateDb
348
- .prepare("SELECT id,title FROM projects")
349
- .all()
350
- .find((row) => typeof row.id === "string" && runner.hasInflight?.(row.id) === true);
351
- return typeof inflight?.title === "string" ? inflight.title : undefined;
352
- },
341
+ // An update never installs while a job is going (`updater/work-in-progress.ts`).
342
+ busy: () => workInProgress(updateDb, (id) => runner.hasInflight?.(id) === true, Date.now()) !==
343
+ undefined,
344
+ busyWith: () => workInProgress(updateDb, (id) => runner.hasInflight?.(id) === true, Date.now()),
353
345
  report: (message) => log.write("warn", "update", { detail: message }),
354
346
  install: async (next, restarting) => {
355
347
  if (npm === undefined)
@@ -386,6 +378,9 @@ export async function boot(config, options = {}) {
386
378
  // Redos a review asked for before the last shutdown start now.
387
379
  reviewRedos.bind(rebuild);
388
380
  reviewRedos.kick();
381
+ // So do narration chunks the captions asked to record again.
382
+ narrationRetries.bind(rebuild);
383
+ narrationRetries.kick();
389
384
  // A stage that was waiting for a CLI's plan limits when the app stopped carries on waiting.
390
385
  void resumeAfterRestart(db, async (projectId) => {
391
386
  const baseRevisionId = currentRevisionId(runtimeDb, projectId);
@@ -0,0 +1,23 @@
1
+ # Slopify 3.0.8
2
+
3
+ Released 29 September 2026.
4
+
5
+ 3.0.8 fixes a garbled narration sentence by itself instead of stopping the run.
6
+
7
+ ## Highlights
8
+
9
+ - **Garbled narration is recorded again by itself.** A run no longer stops for a sentence the voice mumbled or skipped.
10
+
11
+ ## Narration
12
+
13
+ - When captions find the narration no longer matches the text (the voice garbled or skipped a sentence), Slopify records that narration chunk again by itself, rebuilds the narration, captions and video, and carries on. It used to stop the run until you pressed Regenerate on the chunk in Edit project → Narration and continued.
14
+ - Each chunk gets two tries. The step says which try is running; if the audio still doesn't match after both, the voice reads something its own way (a year, an abbreviation or a name), and the step says to reword that sentence in Edit project → Narration.
15
+ - Uploaded narration, captions you edited by hand and multi-voice turns aren't retried: they stop and ask, as before.
16
+
17
+ ## Everywhere
18
+
19
+ - **Start Slopify when I log in** belongs to one data folder. A second Slopify on the same account (another `--data-dir`) sees the switch off and can no longer remove or take over the first one's login entry.
20
+
21
+ ## Upgrading from 3.0.7
22
+
23
+ - **Nothing becomes outdated.** Retries start with the next run that finds a mismatch.
@@ -0,0 +1,27 @@
1
+ # Slopify 3.0.9
2
+
3
+ Released 29 September 2026.
4
+
5
+ 3.0.9 gives every image its own moment from the article, lets a project run without an article, and makes sure an update never lands in the middle of a job.
6
+
7
+ ## Highlights
8
+
9
+ - **Scenes from the article:** images of one prompt stop being the same picture drawn again.
10
+ - **Article can be Off**, for projects of images or a thumbnail only.
11
+ - **Updates wait for every kind of job**, including a batch between two videos and a schedule about to run.
12
+
13
+ ## Images
14
+
15
+ - **Scenes from the article** (Images, in Play and Edit project; off by default). Before the images are drawn, the project's AI model reads the article once and writes a short scene for each image, from the article's opening to its ending in the order the images are shown. Each image is drawn from its prompt with its scene added: where the prompt has `{{Scene}}`, there, otherwise after the prompt's first paragraph, so any prompt works unchanged. `{{Scene}}` is never a field to fill in; with the switch off, a `{{Scene}}` line is left out. One more AI call per run, shown on Review as "Image scenes".
16
+
17
+ ## Article
18
+
19
+ - **Article: Off.** A project can have no article at all: for images or a thumbnail made from prompts, with no placeholder text. Choosing Off switches off what reads the article (research, narration, captions, the YouTube description, shorts, the PDF and Scenes from the article) and takes their sections out of Play and Edit project; the thumbnail offers From prompt and Provide only.
20
+
21
+ ## Updates
22
+
23
+ - An update never installs while a job is going. Besides a running step, that now includes a step waiting to try again, a project between two steps, a batch with videos still queued, a narration chunk about to be recorded again, and a schedule writing topics or due to start within 10 minutes. A waiting update installs by itself once everything has been idle for two looks in a row. Updates still start only when you press Update.
24
+
25
+ ## Upgrading from 3.0.8
26
+
27
+ - **Nothing becomes outdated.** Scenes from the article is off until you switch it on in a project or template.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.0.9",
4
+ "title": "Slopify 3.0.9",
5
+ "version": "3.0.9",
6
+ "date": "2026-09-29"
7
+ },
8
+ {
9
+ "id": "3.0.8",
10
+ "title": "Slopify 3.0.8",
11
+ "version": "3.0.8",
12
+ "date": "2026-09-29"
13
+ },
2
14
  {
3
15
  "id": "3.0.7",
4
16
  "title": "Slopify 3.0.7",
@@ -58,7 +58,7 @@ export function edgeSilenceSecondsProblem(value) {
58
58
  // drawn in; the rule itself only asks whether a source is on its stage's list.
59
59
  export const allowedSources = {
60
60
  research: ["off", "generate", "provide"],
61
- article: ["generate", "provide"],
61
+ article: ["off", "generate", "provide"],
62
62
  audio: ["off", "generate", "provide"],
63
63
  images: ["off", "generate", "provide"],
64
64
  thumbnail: ["off", "from_prompt", "prompt_by_llm", "provide"],
@@ -159,7 +159,8 @@ export function admit(input) {
159
159
  // only generated narration uses separately selected intro/outro entries.
160
160
  export function normaliseDraft(draft) {
161
161
  const sources = { ...draft.sources };
162
- if (sources.article === "provide") {
162
+ // A pasted article, or none, is not researched.
163
+ if (sources.article !== "generate") {
163
164
  sources.research = "off";
164
165
  }
165
166
  if (sources.images === "off" && sources.video === "generate") {
@@ -226,6 +227,7 @@ function checkProvided(draft, staged, fields) {
226
227
  if (sources.article === "provide" && blank(provided.article)) {
227
228
  fields.push({ field: "provided.article", message: "Paste the article." });
228
229
  }
230
+ fields.push(...articleOffFields(draft));
229
231
  if (sources.audio === "provide") {
230
232
  checkFile(staged, provided.audio, "audio", "provided.audio", "Pick an audio file.", fields);
231
233
  }
@@ -497,3 +499,70 @@ export function usesPronunciationGlossary(draft) {
497
499
  draft.audio.provider === "inworld" &&
498
500
  (draft.audio.model === "inworld-tts-2" || draft.audio.model === "inworld-tts-2-flash"));
499
501
  }
502
+ // Article Off: a project of images or a thumbnail from prompts, with no text at all. What reads
503
+ // the article can't be on with it.
504
+ export function articleOffFields(draft) {
505
+ const { sources } = draft;
506
+ if (sources.article !== "off")
507
+ return [];
508
+ const fix = "Set Article to Generate or Provide";
509
+ return [
510
+ ...(sources.audio === "generate"
511
+ ? [
512
+ {
513
+ field: "sources.audio",
514
+ message: `Narration reads the article, and Article is Off. ${fix}, or set Narration to Off or Provide.`,
515
+ },
516
+ ]
517
+ : []),
518
+ ...(sources.document === "generate"
519
+ ? [
520
+ {
521
+ field: "sources.document",
522
+ message: `The PDF is made from the article, and Article is Off. ${fix}, or set PDF to Off.`,
523
+ },
524
+ ]
525
+ : []),
526
+ ...(sources.thumbnail === "prompt_by_llm"
527
+ ? [
528
+ {
529
+ field: "sources.thumbnail",
530
+ message: `The AI writes the thumbnail prompt from the article, and Article is Off. ${fix}, or pick From prompt for the thumbnail.`,
531
+ },
532
+ ]
533
+ : []),
534
+ // Captions, the description and the shorts match the narration to the article's words.
535
+ ...(sources.audio !== "off" && draft.subtitles !== undefined && draft.subtitles.mode !== "off"
536
+ ? [
537
+ {
538
+ field: "subtitles.mode",
539
+ message: `Captions are matched to the article's words, and Article is Off. ${fix}, or turn captions off.`,
540
+ },
541
+ ]
542
+ : []),
543
+ ...(usesYoutubeDescription(draft)
544
+ ? [
545
+ {
546
+ field: "youtubeDescription",
547
+ message: `The YouTube description is written from the narration's words, which come from the article, and Article is Off. ${fix}, or turn the YouTube description off.`,
548
+ },
549
+ ]
550
+ : []),
551
+ ...(usesShorts(draft)
552
+ ? [
553
+ {
554
+ field: "shorts.enabled",
555
+ message: `Shorts are picked from the narration's words, which come from the article, and Article is Off. ${fix}, or turn Shorts off.`,
556
+ },
557
+ ]
558
+ : []),
559
+ ...(sources.images === "generate" && draft.imageScenes === true
560
+ ? [
561
+ {
562
+ field: "imageScenes",
563
+ message: `Scenes from the article need the article, and Article is Off. ${fix}, or turn Scenes from the article off.`,
564
+ },
565
+ ]
566
+ : []),
567
+ ];
568
+ }
@@ -91,6 +91,8 @@ export const runDraftSchema = z.object({
91
91
  imagePrompts: z.array(z.object({ name: z.string(), number: z.number() })),
92
92
  // The ranges are `slices/images/scale.ts`'s, checked by admission, not the schema's.
93
93
  imageScale: z.object({ perHour: z.number(), words: z.number() }).optional(),
94
+ // Scenes from the article: each image drawn from its own scene (`images/scenes.ts`).
95
+ imageScenes: z.boolean().optional(),
94
96
  thumbnailPrompt: z.string().optional(),
95
97
  thumbnailCount: z.union([z.literal(1), z.literal(3)]).optional(),
96
98
  intro: entryChoice.optional(),
@@ -37,15 +37,18 @@ export function detectSlots(body) {
37
37
  }
38
38
  return { names, errors };
39
39
  }
40
+ // Filled by Slopify, never by the person: each image's own scene, which the project's AI model
41
+ // writes from the article when an image prompt asks for it (`images/scenes.ts`).
42
+ export const sceneKeyword = "Scene";
40
43
  // One field per distinct name; Common when a name is used on both sides, otherwise Text or
41
44
  // Image. The order is first appearance, which is why both sides arrive as ordered lists of
42
- // bodies rather than as sets.
45
+ // bodies rather than as sets. The scene keyword is not a field: Slopify fills it.
43
46
  export function collectFields(textBodies, imageBodies) {
44
47
  const text = namesOf(textBodies);
45
48
  const image = namesOf(imageBodies);
46
49
  const fields = [];
47
50
  for (const name of [...text, ...image]) {
48
- if (fields.some((field) => field.name === name)) {
51
+ if (name === sceneKeyword || fields.some((field) => field.name === name)) {
49
52
  continue;
50
53
  }
51
54
  const onText = text.includes(name);
@@ -176,6 +176,9 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
176
176
  });
177
177
  if (images === 0)
178
178
  local("Images", "Provided or off.");
179
+ // Scenes from the article: one call reads the article and writes every image's scene.
180
+ if (images > 0 && draft.imageScenes === true)
181
+ text("Image scenes", articleChars + 1500, 120 * images);
179
182
  // The establishing image is one more image when it is made from a prompt.
180
183
  if (usesReference(draft) && draft.reference?.source === "prompt")
181
184
  requests.push({
@@ -0,0 +1,103 @@
1
+ import { z } from "zod";
2
+ import { detectSlots, render, sceneKeyword } from "../admission/substitute.js";
3
+ // Scenes from the article (Images → Scenes from the article): each image is drawn from its
4
+ // own scene instead of the same text every time. One call to the project's AI model reads the
5
+ // article and writes every scene, following the article from its opening to its end in the
6
+ // order the images are shown.
7
+ export function usesScene(body) {
8
+ return body != null && detectSlots(body).names.includes(sceneKeyword);
9
+ }
10
+ // The prompt with its scene: where the prompt has `{{Scene}}`, there; otherwise as a line of
11
+ // its own after the prompt's first paragraph, so any prompt takes a scene without editing.
12
+ export function withScene(body, scene) {
13
+ if (usesScene(body))
14
+ return render(body, { [sceneKeyword]: scene });
15
+ const at = body.indexOf("\n\n");
16
+ return at === -1
17
+ ? `${body}\n\nScene: ${scene}`
18
+ : `${body.slice(0, at)}\n\nScene: ${scene}${body.slice(at)}`;
19
+ }
20
+ // The prompt with the switch off: a line that holds `{{Scene}}` is left out, so the image is
21
+ // drawn from the rest of the prompt as it always was.
22
+ export function withoutScene(body) {
23
+ if (!usesScene(body))
24
+ return body;
25
+ return body
26
+ .split("\n")
27
+ .filter((line) => !usesScene(line))
28
+ .join("\n")
29
+ .replace(/\n{3,}/g, "\n\n")
30
+ .trim();
31
+ }
32
+ // What kind of picture each image is: the prompt's own "Composition:" paragraph when it has
33
+ // one (the wide shot, the close portrait), otherwise the prompt's name.
34
+ export function pictureKind(body, name) {
35
+ const composition = /^\s*Composition:\s*(.+)$/im.exec(body)?.[1]?.trim();
36
+ return composition === undefined || composition === "" ? name : composition;
37
+ }
38
+ // The count is written on a line of its own so the answer can be checked against it when the
39
+ // call runs (`sceneCountOf`), from the saved request alone.
40
+ const countLine = /^Pictures: (\d+)$/m;
41
+ export function sceneMessages(brief) {
42
+ const count = brief.pictures.length;
43
+ return [
44
+ {
45
+ role: "system",
46
+ content: [
47
+ "You plan the pictures for a narrated video. Read the article and write one scene for each picture listed, in the listed order.",
48
+ `The pictures are shown in that order while the article is narrated from start to finish. Divide the article into ${String(count)} consecutive stretches of about equal length: scene 1 comes from the first stretch, scene 2 from the second, and so on, so the last scene comes from the ending. Keep to this order even when a later moment would suit a picture better.`,
49
+ "Each scene is one or two sentences saying what the picture shows: the subject, the place, what is happening, and the light and mood. Choose a moment its stretch of the article actually describes, and frame it the way the picture's kind asks (a wide view, a close figure, a moment of action, a mood).",
50
+ "Every scene shows something different: never the same subject or moment twice.",
51
+ "Describe only what can be seen. No text, lettering, captions, labels or logos in the picture, and nothing about art style or medium: the style is set elsewhere.",
52
+ `Answer with a JSON array of exactly ${String(count)} strings, one scene per picture, and nothing else.`,
53
+ `Pictures: ${String(count)}`,
54
+ ].join("\n\n"),
55
+ },
56
+ {
57
+ role: "user",
58
+ content: [
59
+ `Video: ${brief.title}`,
60
+ `The ${String(count)} pictures, in order:\n${brief.pictures.map((kind, index) => `${String(index + 1)}. ${kind}`).join("\n")}`,
61
+ `Article:\n\n${brief.article}`,
62
+ ].join("\n\n"),
63
+ },
64
+ ];
65
+ }
66
+ export function sceneCountOf(messages) {
67
+ for (const message of messages) {
68
+ const found = countLine.exec(message.content);
69
+ if (found?.[1] !== undefined)
70
+ return Number(found[1]);
71
+ }
72
+ return undefined;
73
+ }
74
+ export function checkScenes(text, count) {
75
+ const fix = "Use Try again; if it keeps happening, choose another model in the Providers section of Edit project.";
76
+ const parsed = z.array(z.string()).safeParse(jsonArrayOf(text));
77
+ if (!parsed.success)
78
+ return {
79
+ ok: false,
80
+ reason: `The AI model didn't answer with a list of image scenes. ${fix}`,
81
+ };
82
+ const scenes = parsed.data.map((scene) => scene.replace(/\s+/g, " ").trim());
83
+ if (scenes.length !== count)
84
+ return {
85
+ ok: false,
86
+ reason: `The AI model wrote ${String(scenes.length)} image scenes for ${String(count)} images. ${fix}`,
87
+ };
88
+ if (scenes.some((scene) => scene === ""))
89
+ return { ok: false, reason: `One of the AI model's image scenes is empty. ${fix}` };
90
+ return { ok: true, scenes };
91
+ }
92
+ function jsonArrayOf(text) {
93
+ const start = text.indexOf("[");
94
+ const end = text.lastIndexOf("]");
95
+ if (start === -1 || end <= start)
96
+ return undefined;
97
+ try {
98
+ return JSON.parse(text.slice(start, end + 1));
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
@@ -31,7 +31,7 @@ export function toAdmissionDraft(input) {
31
31
  const fields = [];
32
32
  const sources = {
33
33
  ...form.sources,
34
- ...(form.sources.article === "provide" ? { research: "off" } : {}),
34
+ ...(form.sources.article !== "generate" ? { research: "off" } : {}),
35
35
  ...(form.sources.images === "off" ? { video: "off" } : {}),
36
36
  };
37
37
  const number = (raw, field, max, min = 1) => {
@@ -217,6 +217,10 @@ export function toAdmissionDraft(input) {
217
217
  ...(sources.images === "generate" && form.imageScale !== undefined
218
218
  ? { imageScale: imageScaleOf(input.document, fields) }
219
219
  : {}),
220
+ // Scenes are written from the article: with Article Off the switch is left out.
221
+ ...(sources.images === "generate" && sources.article !== "off" && form.imageScenes === true
222
+ ? { imageScenes: true }
223
+ : {}),
220
224
  thumbnailPrompt: ["from_prompt", "prompt_by_llm"].includes(sources.thumbnail)
221
225
  ? form.thumbnailPrompt
222
226
  : undefined,
@@ -141,6 +141,8 @@ export const playDraftFormSchema = z
141
141
  .strict()
142
142
  .readonly()
143
143
  .optional(),
144
+ // Scenes from the article, switched on. Absent is off.
145
+ imageScenes: z.boolean().optional(),
144
146
  thumbnailPrompt: text,
145
147
  intro: text,
146
148
  outro: text,
@@ -238,6 +238,7 @@ export function documentFromProject(deps, revision) {
238
238
  : { paragraphPause: String(config.paragraphPauseSeconds) }),
239
239
  imagePrompts,
240
240
  ...(config.imageScale === undefined ? {} : { imageScale: imageScaleForm(config.imageScale) }),
241
+ ...(config.imageScenes === true ? { imageScenes: true } : {}),
241
242
  thumbnailPrompt: config.thumbnailPrompt ?? "",
242
243
  intro: config.intro?.name ?? "",
243
244
  outro: config.outro?.name ?? "",
@@ -0,0 +1,128 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { transact } from "../../kernel/db/tx.js";
4
+ import { currentRevisionId } from "../revisions/repo.js";
5
+ import { recoverProject } from "./recovery.js";
6
+ // How many times one narration chunk is recorded again by itself. A voice that garbles a
7
+ // sentence rarely does it twice; one that reads a name its own way does it every time, so
8
+ // after this the run stops and asks the person.
9
+ export const narrationRetryLimit = 2;
10
+ const row = z.object({
11
+ project_id: z.string(),
12
+ chunk_key: z.string(),
13
+ tries: z.number(),
14
+ state: z.enum(["pending", "started", "failed"]),
15
+ });
16
+ // Asked by the caption step when the audio stopped matching the text in a chunk. Returns the
17
+ // try this is (1 or 2), or undefined once the chunk has had its tries.
18
+ export function requestNarrationRetry(db, input) {
19
+ return transact(db, () => {
20
+ const saved = db
21
+ .prepare("SELECT tries FROM narration_retries WHERE project_id=? AND chunk_key=?")
22
+ .get(input.projectId, input.chunkKey);
23
+ const tries = saved === undefined ? 0 : z.number().parse(saved.tries);
24
+ if (tries >= narrationRetryLimit)
25
+ return undefined;
26
+ db.prepare(`INSERT INTO narration_retries(project_id, chunk_key, tries, state, detail, updated_at)
27
+ VALUES (?, ?, 1, 'pending', NULL, ?)
28
+ ON CONFLICT(project_id, chunk_key)
29
+ DO UPDATE SET tries=tries+1, state='pending', detail=NULL, updated_at=excluded.updated_at`).run(input.projectId, input.chunkKey, input.now);
30
+ return tries + 1;
31
+ });
32
+ }
33
+ export function pendingNarrationRetries(db, projectId) {
34
+ const rows = projectId === undefined
35
+ ? db.prepare("SELECT * FROM narration_retries WHERE state='pending'").all()
36
+ : db
37
+ .prepare("SELECT * FROM narration_retries WHERE state='pending' AND project_id=?")
38
+ .all(projectId);
39
+ return rows.map((value) => {
40
+ const parsed = row.parse(value);
41
+ return { projectId: parsed.project_id, chunkKey: parsed.chunk_key, tries: parsed.tries };
42
+ });
43
+ }
44
+ function settle(db, retry, state, detail, now) {
45
+ db.prepare("UPDATE narration_retries SET state=?, detail=?, updated_at=? WHERE project_id=? AND chunk_key=? AND tries=?").run(state, detail, now, retry.projectId, retry.chunkKey, retry.tries);
46
+ }
47
+ // Starts the retries the caption step asked for, once its failure is saved (the runner's
48
+ // `onFinished`). The chunk is made again through Redo's path: a new regeneration token saved as
49
+ // a new project version, then the chunk and everything built on it (the joined narration,
50
+ // the captions, the video) rebuilt as ordinary work.
51
+ export function createNarrationRetries() {
52
+ let bound;
53
+ const running = new Set();
54
+ const kick = (projectId) => {
55
+ const deps = bound;
56
+ if (deps === undefined)
57
+ return;
58
+ for (const retry of pendingNarrationRetries(deps.db, projectId)) {
59
+ const id = `${retry.projectId}\n${retry.chunkKey}`;
60
+ if (running.has(id))
61
+ continue;
62
+ running.add(id);
63
+ void recordAgain(deps, retry)
64
+ .catch((error) => {
65
+ deps.log.write("error", "narration.retry", {
66
+ projectId: retry.projectId,
67
+ detail: error instanceof Error ? error.message : String(error),
68
+ });
69
+ settle(deps.db, retry, "failed", "internal error", deps.clock.now().toISOString());
70
+ })
71
+ .finally(() => {
72
+ running.delete(id);
73
+ try {
74
+ deps.runner.tick(retry.projectId);
75
+ }
76
+ catch {
77
+ // The next tick of this project picks the released work up.
78
+ }
79
+ });
80
+ }
81
+ };
82
+ return {
83
+ bind: (deps) => {
84
+ bound = deps;
85
+ },
86
+ kick,
87
+ };
88
+ }
89
+ async function recordAgain(deps, retry) {
90
+ const base = currentRevisionId(deps.db, retry.projectId);
91
+ if (base === undefined) {
92
+ settle(deps.db, retry, "failed", "no project", deps.clock.now().toISOString());
93
+ return;
94
+ }
95
+ const result = await recoverProject(deps, retry.projectId, {
96
+ baseRevisionId: base,
97
+ idempotencyKey: retryKey(retry),
98
+ action: { kind: "redo", item: retry.chunkKey },
99
+ }, { pendingSuperseded: true });
100
+ const now = deps.clock.now().toISOString();
101
+ if (result.ok) {
102
+ settle(deps.db, retry, "started", null, now);
103
+ deps.log.write("info", "narration.retry", {
104
+ projectId: retry.projectId,
105
+ detail: `Recording ${retry.chunkKey} again (try ${String(retry.tries)} of ${String(narrationRetryLimit)}).`,
106
+ });
107
+ }
108
+ else {
109
+ settle(deps.db, retry, "failed", refusal(result), now);
110
+ deps.log.write("warn", "narration.retry", {
111
+ projectId: retry.projectId,
112
+ detail: `Could not record ${retry.chunkKey} again: ${refusal(result)}`,
113
+ });
114
+ }
115
+ deps.emit(retry.projectId, { type: "project.updated", projectId: retry.projectId });
116
+ }
117
+ function refusal(result) {
118
+ const detail = result.fields?.map((field) => field.message).join(" ");
119
+ return detail ? `${result.reason}: ${detail}` : result.reason;
120
+ }
121
+ // One request identity per try, so a restart that starts the same try again gets the first
122
+ // start's answer instead of a second recording.
123
+ export function retryKey(retry) {
124
+ const hex = createHash("sha256")
125
+ .update(`narration-retry:${retry.projectId}:${retry.chunkKey}:${String(retry.tries)}`)
126
+ .digest("hex");
127
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
128
+ }
@@ -82,7 +82,9 @@ export function previewDetails(deps, view, allRecipes, selected) {
82
82
  ? "Research"
83
83
  : row.key.startsWith("shorts:")
84
84
  ? "Short image prompts"
85
- : "Thumbnail prompt";
85
+ : row.key === "images:scenes"
86
+ ? "Image scenes"
87
+ : "Thumbnail prompt";
86
88
  return [
87
89
  {
88
90
  key: row.key,