@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
@@ -8,6 +8,7 @@ import { masterPlan } from "./recipe-loudness.js";
8
8
  import { resourceIdentity } from "./recipe-model.js";
9
9
  import { imageReference, referenceRecipe } from "./recipe-reference.js";
10
10
  import { withReviews } from "./recipe-reviews.js";
11
+ import { imageScenes } from "./recipe-scenes.js";
11
12
  import { shortsRecipes } from "./recipe-shorts.js";
12
13
  import { textRecipes } from "./recipe-text.js";
13
14
  import { thumbnailRecipes, visualAssets, visualRecipes } from "./recipe-visual.js";
@@ -29,6 +30,8 @@ export function buildRecipes(context) {
29
30
  const drawnFrom = imageReference(context, reference);
30
31
  const thumbnail = thumbnailRecipes(context, text.recipes, referenceForThumbnail(context.config) ? drawnFrom : undefined);
31
32
  const youtube = youtubeRecipes(context, exports);
33
+ // Each image's scene, written from the article, for image prompts that ask for one.
34
+ const scenes = imageScenes(context, text);
32
35
  return withReviews(context, [
33
36
  ...text.recipes,
34
37
  ...audio.recipes,
@@ -38,6 +41,7 @@ export function buildRecipes(context) {
38
41
  ...shortsRecipes(context, exports, drawnFrom, audio.cards ?? [], masterPlan(context, audio.levels, "video")),
39
42
  ...thumbnail,
40
43
  ...documentRecipes(context, text, thumbnail),
41
- ...visualAssets(context, visualRecipes(context.config, context.content, audio.mediaFingerprint, captions?.fingerprint ?? null, (images) => editPlan(context, exports, youtube, images, audio.cards ?? []), drawnFrom, timing === undefined ? null : resourceIdentity(context, timing), withLines(masterPlan(context, audio.levels, "video"), linePlan(context, timing)))),
44
+ ...visualAssets(context, visualRecipes(context.config, context.content, audio.mediaFingerprint, captions?.fingerprint ?? null, (images) => editPlan(context, exports, youtube, images, audio.cards ?? []), drawnFrom, timing === undefined ? null : resourceIdentity(context, timing), withLines(masterPlan(context, audio.levels, "video"), linePlan(context, timing)), scenes)),
45
+ ...(scenes === undefined ? [] : [scenes.recipe]),
42
46
  ]);
43
47
  }
@@ -44,6 +44,9 @@ export const deferredOperations = [
44
44
  "shorts",
45
45
  "animate",
46
46
  "script-attribution",
47
+ // Scenes from the article (`recipe-scenes.ts`): the scenes step and each image waiting on it.
48
+ "image-scenes",
49
+ "image-scene",
47
50
  ];
48
51
  export function recipe(context, key, stage, input, dependsOn = [], options = {}) {
49
52
  const requestFingerprint = input.kind === "tts" && input.dialogue !== undefined
@@ -32,7 +32,9 @@ export function recipeProviderChoice(recipe, config) {
32
32
  };
33
33
  if (input.kind !== "deferred")
34
34
  return undefined;
35
- const family = input.operation === "narration-preparation" || input.operation === "narration-description"
35
+ const family = input.operation === "narration-preparation" ||
36
+ input.operation === "narration-description" ||
37
+ input.operation === "image-scenes"
36
38
  ? "llm"
37
39
  : recipe.stage === "audio"
38
40
  ? "tts"
@@ -1,6 +1,6 @@
1
1
  import { stageKinds } from "../../kernel/pipeline.js";
2
2
  import { sourceOf } from "../admission/model.js";
3
- import { allowedSources, edgeSilenceSecondsProblem, imageSecondsProblem, normaliseDraft, silenceGapSecondsMax, voicesFields, zoomPercentProblem, } from "../admission/rules.js";
3
+ import { allowedSources, articleOffFields, edgeSilenceSecondsProblem, imageSecondsProblem, normaliseDraft, silenceGapSecondsMax, voicesFields, zoomPercentProblem, } from "../admission/rules.js";
4
4
  import { shortModeFields } from "../admission/short-mode.js";
5
5
  import { render } from "../admission/substitute.js";
6
6
  import { effectiveClips, pickedShortsOf, rangeProblem } from "../shorts/clips.js";
@@ -27,7 +27,7 @@ function shortRangeFields(base, config, content) {
27
27
  }
28
28
  export function normalizeArticleIntent(base, edit) {
29
29
  const restart = edit.regenerate?.includes("article:body") === true ||
30
- (base.revision.config.sources.article === "provide" &&
30
+ (base.revision.config.sources.article !== "generate" &&
31
31
  edit.config.sources.article === "generate");
32
32
  const submittedText = edit.content.articleEdited === true ||
33
33
  edit.content.articleMarkdown !== base.revision.content.articleMarkdown;
@@ -232,6 +232,7 @@ export function validateRevisionEdit(config, content) {
232
232
  if (config.sources.article === "provide" &&
233
233
  !(content.articleMarkdown ?? config.provided.article)?.trim())
234
234
  fields.push({ field: "provided.article", message: "Paste the article." });
235
+ fields.push(...articleOffFields(config));
235
236
  if (config.sources.audio === "off" &&
236
237
  config.subtitles !== undefined &&
237
238
  config.subtitles.mode !== "off")
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+ import { withLanguage } from "../../kernel/ports/languages.js";
3
+ import { pictureKind, sceneMessages } from "../images/scenes.js";
4
+ import { recipe, selectedReference, } from "./recipe-model.js";
5
+ import { llmInput, llmInputFingerprint } from "./recipe-text.js";
6
+ export const imageScenesKey = "images:scenes";
7
+ export function imageScenes(context, text) {
8
+ const { config, content } = context;
9
+ if (config.sources.images !== "generate" || config.imageScenes !== true)
10
+ return undefined;
11
+ const keys = [];
12
+ const pictures = [];
13
+ for (const key of content.imageOrder) {
14
+ const image = content.imageDefinitions[key];
15
+ if (image?.source !== "generate")
16
+ continue;
17
+ const body = image.templateKey == null
18
+ ? image.prompt
19
+ : (content.promptTemplates[image.templateKey] ?? image.prompt);
20
+ if (body == null || body.trim() === "")
21
+ continue;
22
+ const at = /^imagePrompts\.(\d+)$/.exec(image.templateKey ?? "")?.[1];
23
+ const name = at === undefined ? "Image" : (config.imagePrompts[Number(at)]?.name ?? "Image");
24
+ keys.push(key);
25
+ pictures.push(pictureKind(body, name));
26
+ }
27
+ if (keys.length === 0)
28
+ return undefined;
29
+ const messages = withLanguage(sceneMessages({ title: config.title, article: text.articleText ?? "", pictures }), config.language);
30
+ const value = recipe(context, imageScenesKey, "images", text.articleText === null
31
+ ? {
32
+ kind: "deferred",
33
+ version: 1,
34
+ operation: "image-scenes",
35
+ template: [llmInputFingerprint(context, messages), text.article.fingerprint],
36
+ }
37
+ : llmInput(context, messages), [text.article.key]);
38
+ return {
39
+ recipe: value,
40
+ keys,
41
+ scenes: text.articleText === null ? undefined : savedScenes(context, value, keys.length),
42
+ };
43
+ }
44
+ // The scenes saved for this request. The request carries the model's thinking settings from
45
+ // the catalogue, which the plan without a catalogue has not got, so either fingerprint
46
+ // matches (like the shorts' saved prompts).
47
+ function savedScenes(context, value, count) {
48
+ const logical = context.catalogue === undefined || value.input.kind !== "llm"
49
+ ? value.fingerprint
50
+ : recipe({ content: context.content }, value.key, value.stage, { ...value.input, thinkingConfig: null }, value.dependsOn).fingerprint;
51
+ const piece = context.manifest.pieces.find((row) => row.key === value.key &&
52
+ selectedReference(row) &&
53
+ row.piece.state === "done" &&
54
+ (row.fingerprint === value.fingerprint || row.fingerprint === logical));
55
+ if (piece?.piece.payload == null)
56
+ return undefined;
57
+ const parsed = z
58
+ .object({ scenes: z.array(z.string()) })
59
+ .safeParse(JSON.parse(piece.piece.payload));
60
+ return parsed.success && parsed.data.scenes.length === count ? parsed.data.scenes : undefined;
61
+ }
@@ -124,7 +124,8 @@ export function textRecipes(context) {
124
124
  speakers: voices.speakers.map((speaker) => ({ id: speaker.id, name: speaker.name })),
125
125
  attribute: !writesScript,
126
126
  };
127
- const article = config.sources.article === "provide" || content.articleEdited === true
127
+ // A pasted article, or with Article Off an empty one: nothing is written.
128
+ const article = config.sources.article !== "generate" || content.articleEdited === true
128
129
  ? recipe(context, "article:body", "article", {
129
130
  kind: "local",
130
131
  version: 1,
@@ -250,7 +251,7 @@ export function textRecipes(context) {
250
251
  ...(script === undefined ? {} : { script }),
251
252
  };
252
253
  }
253
- function llmInputFingerprint(context, messages) {
254
+ export function llmInputFingerprint(context, messages) {
254
255
  return fingerprint(JSON.parse(JSON.stringify(llmInput(context, messages))));
255
256
  }
256
257
  export function matchingText(context, value, field) {
@@ -265,7 +266,7 @@ export function matchingText(context, value, field) {
265
266
  }
266
267
  function articleMarkdownOf(context) {
267
268
  const { config, content, resolved } = context;
268
- return config.sources.article === "provide" || content.articleEdited === true
269
+ return config.sources.article !== "generate" || content.articleEdited === true
269
270
  ? (content.articleMarkdown ?? config.provided.article ?? "")
270
271
  : resolved.articleMarkdown;
271
272
  }
@@ -1,5 +1,5 @@
1
1
  import { narrationPreparationFields, shortsFields, usesNarrationPreparation, usesReference, usesShorts, usesYoutubeDescription, videoEditFields, youtubeDescriptionFields, } from "../admission/rules.js";
2
- import { detectSlots, render } from "../admission/substitute.js";
2
+ import { detectSlots, render, sceneKeyword } from "../admission/substitute.js";
3
3
  import { imagesPerVideoMax } from "../images/scale.js";
4
4
  import { loudnessFields } from "../loudness/model.js";
5
5
  import { pauseFields } from "../narration/pauses-model.js";
@@ -143,7 +143,8 @@ export function validateRecipeInputs(config, content) {
143
143
  if (slots.errors.length > 0)
144
144
  fields.push({ field: prompt.field, message: "Fix the keyword placeholders in this prompt." });
145
145
  for (const key of slots.names)
146
- if (!Object.hasOwn(config.values, key) || !config.values[key]?.trim())
146
+ if (key !== sceneKeyword &&
147
+ (!Object.hasOwn(config.values, key) || !config.values[key]?.trim()))
147
148
  fields.push({ field: `values.${key}`, message: "Enter a value for this keyword." });
148
149
  }
149
150
  return fields;
@@ -1,6 +1,7 @@
1
1
  import { thumbnailCountOf, thumbnailKey } from "../admission/model.js";
2
2
  import { usesShortMode } from "../admission/short-mode.js";
3
3
  import { render } from "../admission/substitute.js";
4
+ import { withoutScene, withScene } from "../images/scenes.js";
4
5
  import { usesAmbientBed } from "../video/ambient-bed.js";
5
6
  import { castFor } from "./recipe-cast.js";
6
7
  import { noMaster } from "./recipe-loudness.js";
@@ -15,7 +16,9 @@ reference,
15
16
  // The word timing's identity, which a short's captions are drawn from.
16
17
  timing = null,
17
18
  // Level the volume: the levelled narration and the master (`recipe-loudness.ts`).
18
- master = noMaster) {
19
+ master = noMaster,
20
+ // Each image's own scene, for the images whose prompt asks for one (`recipe-scenes.ts`).
21
+ scenes) {
19
22
  const recipes = [];
20
23
  const imageKeys = config.sources.images === "off" ? [] : content.imageOrder;
21
24
  for (const key of imageKeys) {
@@ -25,7 +28,17 @@ master = noMaster) {
25
28
  const raw = image.templateKey === undefined || image.templateKey === null
26
29
  ? undefined
27
30
  : content.promptTemplates[image.templateKey];
28
- const prompt = raw === undefined || raw === null ? image.prompt : render(raw, config.values);
31
+ const at = scenes?.keys.indexOf(key) ?? -1;
32
+ if (scenes !== undefined && at !== -1 && image.source === "generate") {
33
+ recipes.push(sceneImage(config, content, key, raw ?? image.prompt ?? "", scenes, at, reference));
34
+ continue;
35
+ }
36
+ // With Scenes from the article off, a prompt's `{{Scene}}` line is left out.
37
+ const prompt = raw === undefined || raw === null
38
+ ? image.prompt === null || image.prompt === undefined
39
+ ? image.prompt
40
+ : withoutScene(image.prompt)
41
+ : render(withoutScene(raw), config.values);
29
42
  recipes.push(recipe({ content }, `image:${key}`, "images", image.source === "provide"
30
43
  ? { kind: "provided", version: 1, assetId: image.assetId, semantic: null }
31
44
  : {
@@ -109,6 +122,37 @@ master = noMaster) {
109
122
  }
110
123
  return recipes;
111
124
  }
125
+ // An image drawn from its own scene. Until the scenes are written it waits as a deferred
126
+ // request that names the scenes step and the image's place among them, so writing them turns
127
+ // it into its image request (materialization), and writing them again draws it again.
128
+ function sceneImage(config, content, key, body, scenes, at, reference) {
129
+ const scene = scenes.scenes?.[at];
130
+ const prompt = scene === undefined ? undefined : withScene(render(body, config.values), scene);
131
+ return recipe({ content }, `image:${key}`, "images", prompt === undefined
132
+ ? {
133
+ kind: "deferred",
134
+ version: 1,
135
+ operation: "image-scene",
136
+ template: [
137
+ scenes.recipe.fingerprint,
138
+ at,
139
+ config.images?.provider ?? null,
140
+ config.images?.model ?? null,
141
+ config.format,
142
+ ...(config.images?.thinking === undefined ? [] : [config.images.thinking]),
143
+ ...(reference === undefined ? [] : [reference.input.fingerprint]),
144
+ ],
145
+ }
146
+ : {
147
+ kind: "image",
148
+ version: 1,
149
+ ...imageChoice(config),
150
+ aspect: config.format,
151
+ prompt,
152
+ ...(reference === undefined ? {} : { reference: reference.input }),
153
+ ...castField(castFor(config, prompt)),
154
+ }, [scenes.recipe.key, ...(reference === undefined ? [] : [reference.key])]);
155
+ }
112
156
  // What the ambient bed adds to the render's fingerprint: its settings and, for the user's own
113
157
  // file, the project asset it plays. Undefined without a bed.
114
158
  export function ambientBedValues(config, content) {
@@ -278,6 +278,24 @@ export function priceRecipe(work, value, config) {
278
278
  const onPlan = config === undefined ? undefined : cliRequest(work, value, config);
279
279
  if (onPlan !== undefined)
280
280
  return onPlan;
281
+ // Scenes from the article: an image waiting for its scene is one image, and the scenes are
282
+ // one call that reads the article (its length is known once the article is written).
283
+ if (input.kind === "deferred" &&
284
+ (input.operation === "image-scene" || input.operation === "image-scenes")) {
285
+ const choice = config === undefined ? undefined : recipeProviderChoice(value, config);
286
+ if (choice !== undefined && input.operation === "image-scene")
287
+ return { kind: "image", stage: work.key, provider: choice.provider, model: choice.model };
288
+ if (choice !== undefined)
289
+ return {
290
+ kind: "llm",
291
+ stage: work.key,
292
+ provider: choice.provider,
293
+ model: choice.model,
294
+ inputCharacters: 90000,
295
+ outputCharacters: 4000,
296
+ detail: "The article is read when the step runs; its length is estimated. Retries are excluded.",
297
+ };
298
+ }
281
299
  if (input.kind === "deferred" && input.operation === "narration-preparation")
282
300
  return {
283
301
  kind: "unknown",
@@ -44,6 +44,13 @@ export function redoTarget(view, plan, item) {
44
44
  return plan.recipes.some((row) => row.key === `${item}:prompts`)
45
45
  ? own([`${item}:prompts`])
46
46
  : undefined;
47
+ // One narration chunk, by the key the Narration editor regenerates it by: its TTS requests
48
+ // are the roots (`narration-retry.ts`).
49
+ const chunk = plan.recipes
50
+ .filter((row) => row.input.kind === "tts" && row.input.logicalKey === item)
51
+ .map((row) => row.key);
52
+ if (chunk.length > 0)
53
+ return own(chunk);
47
54
  return plan.recipes.some((row) => row.key === item && row.kind === "provider")
48
55
  ? own([item])
49
56
  : undefined;
@@ -24,7 +24,7 @@ export async function executeDocumentRecipe(deps, context, piece) {
24
24
  : readFileSync(outputPath(deps.paths, projectId, row.output.path), "utf8");
25
25
  };
26
26
  const config = view.revision.config;
27
- const article = view.revision.content.articleEdited === true || config.sources.article === "provide"
27
+ const article = view.revision.content.articleEdited === true || config.sources.article !== "generate"
28
28
  ? view.articleMarkdown
29
29
  : text("article_md", "article:body");
30
30
  if (article === null)
@@ -45,6 +45,8 @@ export function pieceLabel(deps, context, piece) {
45
45
  return "Shorts";
46
46
  if (key === "animate:future")
47
47
  return "Animated images";
48
+ if (key === "images:scenes")
49
+ return "Image scenes";
48
50
  const short = /^shorts:(\d+):(prompts|image:(\d+)|render)$/.exec(key);
49
51
  if (short !== null)
50
52
  return short[2] === "prompts"
@@ -35,7 +35,7 @@ export function executionPlan(deps, view, catalogue) {
35
35
  const payloads = view.pieces.filter((piece) => piece.selected && piece.piece.state === "done" && piece.piece.payload !== null);
36
36
  const resolved = {
37
37
  articleMarkdown: view.revision.content.articleEdited === true ||
38
- view.revision.config.sources.article === "provide"
38
+ view.revision.config.sources.article !== "generate"
39
39
  ? view.articleMarkdown
40
40
  : textOutput("article_md"),
41
41
  researchNotes: textOutput("notes"),
@@ -2,6 +2,7 @@ import { basename } from "node:path";
2
2
  import { referenceKey, thumbnailVariant } from "../admission/model.js";
3
3
  import { plainText } from "../article/plain.js";
4
4
  import { splitEndMatter } from "../article/split.js";
5
+ import { checkScenes, sceneCountOf } from "../images/scenes.js";
5
6
  import { spokenPassage } from "../narration/describe.js";
6
7
  import { observeNarration } from "../narration/live.js";
7
8
  import { validatePreparation } from "../narration/preparation.js";
@@ -14,6 +15,7 @@ import { outputPath } from "../storage/layout.js";
14
15
  import { probeDurationMs } from "../video/ffmpeg.js";
15
16
  import { parseAttribution } from "../voices/attribution.js";
16
17
  import { parseScript } from "../voices/script.js";
18
+ import { imageScenesKey } from "./recipe-scenes.js";
17
19
  import { executeArticleRequests } from "./runtime-article.js";
18
20
  import { imageCall } from "./runtime-image.js";
19
21
  import { frozenInstructions } from "./runtime-instructions.js";
@@ -193,6 +195,11 @@ function checkAnswer(piece, answer) {
193
195
  return spokenPassage(answer.text) === ""
194
196
  ? "The AI model's description had no words a narrator could say. Use Try again; if it keeps happening, choose another model in the Providers section of Edit project, or turn off Describe tables and figures there."
195
197
  : undefined;
198
+ // Scenes from the article: one scene per image, as many as the request lists.
199
+ if (piece.key === imageScenesKey && piece.input.kind === "llm") {
200
+ const checked = checkScenes(answer.text, sceneCountOf(piece.input.messages) ?? 0);
201
+ return checked.ok ? undefined : checked.reason;
202
+ }
196
203
  if (piece.key === "research:planner")
197
204
  return chaptersFrom(answer.text).length === 0
198
205
  ? "The AI model's research plan listed no chapters, so research could not go on. Use Try again; if it keeps happening, choose another model in the Providers section of Edit project."
@@ -210,6 +217,15 @@ function attributionSource(messages) {
210
217
  return messages.at(-1)?.content ?? "";
211
218
  }
212
219
  async function publishText(deps, context, piece, answer) {
220
+ if (piece.key === imageScenesKey && piece.input.kind === "llm") {
221
+ const checked = checkScenes(answer.text, sceneCountOf(piece.input.messages) ?? 0);
222
+ if (!checked.ok)
223
+ throw new Error(checked.reason);
224
+ await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
225
+ ["instructions", "instructions.md", frozenInstructions(deps, context, piece)],
226
+ ]), { scenes: checked.scenes, text: answer.text });
227
+ return;
228
+ }
213
229
  if (piece.input.kind === "llm" && piece.input.preparation !== undefined) {
214
230
  const checked = validatePreparation(answer.text, piece.input.preparation.source);
215
231
  if (!checked.ok)
@@ -16,6 +16,7 @@ import { speakerColour } from "../voices/palette.js";
16
16
  import { speakerPanelEvents, usesSpeakerPanel } from "../voices/panel.js";
17
17
  import { panelPortraits } from "../voices/portraits.js";
18
18
  import { attributeWords } from "../voices/timing.js";
19
+ import { narrationRetryLimit, requestNarrationRetry } from "./narration-retry.js";
19
20
  import { exportSnapshot, retainedOutput, revisionAudio, revisionTranscript, revisionTurns, } from "./runtime-export-inputs.js";
20
21
  import { narrationChunks, narrationTextParts, } from "./runtime-narration-text.js";
21
22
  import { preparedResult, preparedText, publishResult } from "./runtime-publication.js";
@@ -77,7 +78,7 @@ async function timing(deps, context, piece, snapshot) {
77
78
  context.signal.throwIfAborted();
78
79
  const { path, kind } = segment;
79
80
  if (path !== null && spoken(kind)) {
80
- const aligned = await located(snapshot, kind, () => alignSubtitles({
81
+ const aligned = await located(deps, context, snapshot, kind, () => alignSubtitles({
81
82
  audioPath: path,
82
83
  text: revisionTranscript(deps, snapshot, kind),
83
84
  aliases: narrationAliasesOf(snapshot.view.revision.config),
@@ -250,45 +251,86 @@ async function files(deps, context, piece, snapshot) {
250
251
  }
251
252
  // A mismatch names the place to fix: the time in the narration, the chunk that holds it (as
252
253
  // the Narration editor numbers them) and what was expected against what was heard. The
253
- // usual cause is a TTS request that skipped or reworded a sentence.
254
- async function located(snapshot, segment, align) {
254
+ // usual cause is a TTS request that skipped or reworded a sentence, which a second request
255
+ // almost always reads right, so a chunk made by a voice is first recorded again by itself
256
+ // (`narration-retry.ts`); the message says so, and says when its tries are used up.
257
+ async function located(deps, context, snapshot, segment, align) {
255
258
  try {
256
259
  return await align();
257
260
  }
258
261
  catch (error) {
259
262
  if (!(error instanceof SubtitleMismatch))
260
263
  throw error;
261
- throw new Error(mismatchMessage(snapshot, segment, error), { cause: error });
264
+ const chunks = mismatchChunks(snapshot, segment);
265
+ const at = mismatchChunk(chunks, error);
266
+ const chunk = at === -1 ? undefined : chunks[at];
267
+ const retry = chunk !== undefined && retryable(snapshot, chunk.key)
268
+ ? {
269
+ chunk: at + 1,
270
+ try: requestNarrationRetry(deps.db, {
271
+ projectId: context.work.projectId,
272
+ chunkKey: chunk.key,
273
+ now: deps.clock.now().toISOString(),
274
+ }),
275
+ }
276
+ : undefined;
277
+ throw new Error(describeMismatch(chunks, segment, error, retry), { cause: error });
262
278
  }
263
279
  }
264
- export function mismatchMessage(snapshot, segment, mismatch) {
265
- let chunks = [];
280
+ // A chunk a voice made from the text: not uploaded audio, not captions edited by hand (their
281
+ // remake waits for the person) and not a multi-voice turn, whose key the regeneration list
282
+ // can't hold yet.
283
+ function retryable(snapshot, key) {
284
+ if (snapshot.view.revision.content.subtitleCues !== undefined)
285
+ return false;
286
+ if (/:turn:\d+$/.test(key))
287
+ return false;
288
+ return snapshot.plan.recipes.some((row) => row.input.kind === "tts" && row.input.logicalKey === key);
289
+ }
290
+ function mismatchChunks(snapshot, segment) {
266
291
  try {
267
- chunks = narrationChunks(narrationTextParts(snapshot.view, snapshot.plan, segment));
292
+ return narrationChunks(narrationTextParts(snapshot.view, snapshot.plan, segment));
268
293
  }
269
294
  catch {
270
295
  // Without a readable plan the message still gives the time and the words.
296
+ return [];
271
297
  }
272
- return describeMismatch(chunks, segment, mismatch);
273
298
  }
274
- export function describeMismatch(chunks, segment, mismatch) {
299
+ export function mismatchMessage(snapshot, segment, mismatch) {
300
+ return describeMismatch(mismatchChunks(snapshot, segment), segment, mismatch);
301
+ }
302
+ // The chunk that holds the words the audio stopped matching, by index; -1 when none does.
303
+ export function mismatchChunk(chunks, mismatch) {
275
304
  const probe = comparable(mismatch.expected).split(" ").slice(0, 6).join(" ");
276
- const at = probe === ""
305
+ return probe === ""
277
306
  ? chunks.length - 1
278
307
  : chunks.findIndex((chunk) => comparable(chunk.spokenText).includes(probe));
308
+ }
309
+ export function describeMismatch(chunks, segment, mismatch,
310
+ // Set when the chunk is one a voice made: `try` is the automatic try just asked for, or
311
+ // undefined once its tries are used up.
312
+ retry) {
313
+ const at = mismatchChunk(chunks, mismatch);
279
314
  const chunk = at === -1 ? undefined : chunks[at];
280
315
  const where = chunk === undefined
281
316
  ? `${clock(mismatch.at)} into the ${segment} narration`
282
317
  : `${clock(mismatch.at)} into the ${segment} narration, in narration chunk ${String(at + 1)} of ${String(chunks.length)} (it starts "${opening(chunk.spokenText)}")`;
283
318
  const expected = mismatch.expected === "" ? "the end of the text" : `"${mismatch.expected}…"`;
284
319
  const heard = mismatch.heard === "" ? "no more speech" : `"${mismatch.heard.toLowerCase()}…"`;
320
+ const stopped = `Subtitles stopped matching the audio at ${where}. The text expected ${expected} but the audio has ${heard}`;
321
+ if (retry !== undefined) {
322
+ const n = String(retry.chunk);
323
+ return retry.try === undefined
324
+ ? `${stopped} Slopify already recorded narration chunk ${n} again ${String(narrationRetryLimit)} times and the audio still doesn't match there, so the voice probably reads something its own way (a year, an abbreviation or a name). Listen at ${clock(mismatch.at)}: if words are missing or wrong, reword that sentence in Edit project → Narration → narration chunk ${n}, save, then Continue the run. If it says them right, use Download diagnostics in Settings and report it.`
325
+ : `${stopped} The voice probably skipped or garbled words there, so Slopify is recording narration chunk ${n} again by itself (try ${String(retry.try)} of ${String(narrationRetryLimit)}) and carries on with the video when it's done. If this message is still here in a few minutes, regenerate narration chunk ${n} in Edit project → Narration, then Continue the run.`;
326
+ }
285
327
  const fix = chunk === undefined
286
328
  ? `Listen at ${clock(mismatch.at)}: if words are missing or wrong, regenerate that part in Edit project → Narration, then Continue the run.`
287
329
  : `Listen at ${clock(mismatch.at)}: if words are missing or wrong, regenerate narration chunk ${String(at + 1)} in Edit project → Narration, then Continue the run.`;
288
330
  // A voice that reads a year, an abbreviation or a name its own way reads it the same way
289
331
  // again, so a remake would cost a chunk and fail here once more.
290
332
  const same = "If it says them right, only another way (a year, an abbreviation or a name read its own way), regenerating gives the same reading: use Download diagnostics in Settings and report it.";
291
- return `Subtitles stopped matching the audio at ${where}. The text expected ${expected} but the audio has ${heard} The recording there probably skips or changes words. ${fix} ${same}`;
333
+ return `${stopped} The recording there probably skips or changes words. ${fix} ${same}`;
292
334
  }
293
335
  function comparable(text) {
294
336
  return text
@@ -121,7 +121,7 @@ export async function prepareEditAssets(deps, base, edit) {
121
121
  };
122
122
  prepared.push(item);
123
123
  }
124
- const article = edit.config.sources.article === "provide" || content.articleEdited === true
124
+ const article = edit.config.sources.article !== "generate" || content.articleEdited === true
125
125
  ? (content.articleMarkdown ?? edit.config.provided.article)
126
126
  : undefined;
127
127
  if (article !== undefined &&
@@ -40,5 +40,7 @@ export function resolvedArticleMarkdown(deps, revision, outputs) {
40
40
  return readFileSync(outputPath(deps.paths, revision.projectId, article.output.path), "utf8");
41
41
  return revision.config.sources.article === "provide"
42
42
  ? (revision.config.provided.article ?? null)
43
- : null;
43
+ : revision.config.sources.article === "off"
44
+ ? ""
45
+ : null;
44
46
  }
@@ -57,6 +57,7 @@ export const projectTables = [
57
57
  "project_uploads",
58
58
  // Since 2.5.0. Left out on purpose: plan_limit_waits and plan_limit_waiters (an account's
59
59
  // current wait, a lease of this install) and prompt_softening (a pending one-off request).
60
+ // Since 3.0.8, narration_retries is left out too: the tries of a run, not of the project.
60
61
  "review_verdicts",
61
62
  "provider_usage",
62
63
  "plan_limit_readings",
@@ -105,6 +105,22 @@ How the count is worked out:
105
105
 
106
106
  Turning it on also switches **Motion** (in the **Video and style** row) from **Zoom in and out** to **Mix of both**, so a long video pans and zooms by turns. A **Pan across** or **Still** you picked stays, and you can pick Zoom again.
107
107
 
108
+ ## Scenes from the article
109
+
110
+ Without it, every image of a prompt is drawn from the same text, so three images of one prompt tend to show the same picture three times. **Scenes from the article** gives every image its own moment.
111
+
112
+ | Option | What it does | Default |
113
+ | --- | --- | --- |
114
+ | **Scenes from the article** | Before the images are drawn, the project's AI model reads the article once and writes a short scene for each image. Scene 1 comes from the article's opening and the last from its ending, in the order the images are shown. Each image is drawn from its prompt with its scene added. One more AI call per run. | Off |
115
+
116
+ Where the scene goes:
117
+
118
+ - A prompt with `{{Scene}}` gets the scene there, for example a line `Scene: {{Scene}}` under the prompt's first sentence. `{{Scene}}` is never a field to fill in: Slopify writes it.
119
+ - A prompt without it gets the scene as a line of its own after its first paragraph, so any prompt works unchanged.
120
+ - With the switch off, a line holding `{{Scene}}` is left out and the prompt is drawn as it always was.
121
+
122
+ The style, composition and everything else in the prompt stay as written; only the scene changes from image to image. The scenes are written once the article exists, and each image waits for them. Switching it on or off in **Edit project** remakes the images.
123
+
108
124
  ## How long each image stays on screen
109
125
 
110
126
  **Seconds per image** (1 to 600, default 15) is in the **Video and style** row, beside **Cuts**, **Zoom** and **Motion**. When cuts follow the narration, it is the target length and a cut waits for a sentence end. When images run out, they start again. See [Play Video and Style](Play-Video-and-Style).
@@ -25,6 +25,7 @@ The Article row starts with a **Source** switch:
25
25
  | --- | --- | --- |
26
26
  | **Generate** (default) | The text model writes the article from the article prompt, in one call. | One text-model call |
27
27
  | **Provide** | Slopify narrates the text you paste, word for word. No text model writes one. | No text-model cost for the article |
28
+ | **Off** | No article at all, for a project of images or a thumbnail made from prompts. Choosing it switches off what reads the article (research, narration, captions, the YouTube description, shorts, the PDF, Scenes from the article) and takes those sections out of Play; the thumbnail offers From prompt and Provide only. | Nothing |
28
29
 
29
30
  The article is what the narration reads and what the PDF lays out.
30
31
 
@@ -2,6 +2,7 @@ import { timingSafeEqual } from "node:crypto";
2
2
  import { setImmediate } from "node:timers";
3
3
  import { isStableVersion, isUpdateToken, newerVersion } from "./model.js";
4
4
  const waitCheckMs = 5_000;
5
+ const idleLooksToInstall = 2;
5
6
  export function createUpdater(deps) {
6
7
  let latestVersion = null;
7
8
  let checkedAt = -Infinity;
@@ -66,13 +67,23 @@ export function createUpdater(deps) {
66
67
  deps.report(error);
67
68
  });
68
69
  }
69
- // Looks again every few seconds and installs the moment no work is running.
70
+ // Looks again every few seconds and installs once no work is running. Idle has to hold for
71
+ // two looks in a row, so the moment between one step finishing and the next starting is
72
+ // never mistaken for the end of the work.
70
73
  function wait(version) {
71
74
  pending = version;
72
75
  status = "waiting";
73
76
  error = undefined;
77
+ let idleLooks = 0;
74
78
  stopWatching = every(() => {
75
- if (status !== "waiting" || busyNow())
79
+ if (status !== "waiting")
80
+ return;
81
+ if (busyNow()) {
82
+ idleLooks = 0;
83
+ return;
84
+ }
85
+ idleLooks += 1;
86
+ if (idleLooks < idleLooksToInstall)
76
87
  return;
77
88
  stopWatching?.();
78
89
  stopWatching = undefined;