@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.
- package/dist/edge/autostart/native.js +12 -3
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0042-narration-retries.sql +13 -0
- package/dist/main.js +14 -19
- package/dist/patch-notes/3.0.8.md +23 -0
- package/dist/patch-notes/3.0.9.md +27 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/rules.js +71 -2
- package/dist/slices/admission/schema.js +2 -0
- package/dist/slices/admission/substitute.js +5 -2
- package/dist/slices/estimate/index.js +3 -0
- package/dist/slices/images/scenes.js +103 -0
- package/dist/slices/play-drafts/convert.js +5 -1
- package/dist/slices/play-drafts/schema.js +2 -0
- package/dist/slices/project-templates/from-project.js +1 -0
- package/dist/slices/rebuild/narration-retry.js +128 -0
- package/dist/slices/rebuild/preview-details.js +3 -1
- package/dist/slices/rebuild/recipe-build.js +5 -1
- package/dist/slices/rebuild/recipe-model.js +3 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +3 -1
- package/dist/slices/rebuild/recipe-save.js +3 -2
- package/dist/slices/rebuild/recipe-scenes.js +61 -0
- package/dist/slices/rebuild/recipe-text.js +4 -3
- package/dist/slices/rebuild/recipe-validation.js +3 -2
- package/dist/slices/rebuild/recipe-visual.js +46 -2
- package/dist/slices/rebuild/recipe-work.js +18 -0
- package/dist/slices/rebuild/recovery-selection.js +7 -0
- package/dist/slices/rebuild/runtime-document.js +1 -1
- package/dist/slices/rebuild/runtime-piece-label.js +2 -0
- package/dist/slices/rebuild/runtime-plan.js +1 -1
- package/dist/slices/rebuild/runtime-provider.js +16 -0
- package/dist/slices/rebuild/runtime-subtitles.js +53 -11
- package/dist/slices/revisions/mutation-prepare.js +1 -1
- package/dist/slices/revisions/view.js +3 -1
- package/dist/slices/storage/backup-format.js +1 -0
- package/dist/tutorials/Play-Images.md +16 -0
- package/dist/tutorials/Play-Title-and-Article.md +1 -0
- package/dist/updater/service.js +13 -2
- package/dist/updater/work-in-progress.js +59 -0
- package/dist/web/assets/{index-Cdb0Jh3P.js → index-CIXw6vpi.js} +115 -115
- package/dist/web/assets/{pdf-BhFuoBjr.js → pdf-B9CdIc1m.js} +1 -1
- package/dist/web/index.html +1 -1
- 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" ||
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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
|
|
package/dist/updater/service.js
CHANGED
|
@@ -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
|
|
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"
|
|
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;
|