@gentbajko/slopify 3.0.11 → 3.0.13
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/events/hub.js +35 -15
- package/dist/edge/events/preview-cache.js +1 -0
- package/dist/edge/events/visibility.js +42 -8
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/main.js +2 -2
- package/dist/patch-notes/3.0.12.md +25 -0
- package/dist/patch-notes/3.0.13.md +43 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/substitute.js +7 -2
- package/dist/slices/images/appearance.js +107 -0
- package/dist/slices/images/scenes.js +50 -1
- package/dist/slices/rebuild/recipe-appearance.js +82 -0
- package/dist/slices/rebuild/recipe-build.js +13 -6
- package/dist/slices/rebuild/recipe-model.js +3 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +3 -1
- package/dist/slices/rebuild/recipe-reference.js +22 -2
- package/dist/slices/rebuild/recipe-scenes.js +35 -1
- package/dist/slices/rebuild/recipe-validation.js +2 -2
- package/dist/slices/rebuild/recipe-visual.js +94 -38
- package/dist/slices/rebuild/recipe-work.js +5 -2
- package/dist/slices/rebuild/runtime-export.js +2 -1
- package/dist/slices/rebuild/runtime-provider.js +21 -3
- package/dist/slices/rebuild/transition-repo.js +13 -5
- package/dist/slices/revisions/publication-rules.js +11 -3
- package/dist/slices/revisions/restore.js +6 -1
- package/dist/slices/run-cost/timing.js +17 -5
- package/dist/tutorials/Notifications.md +9 -0
- package/dist/tutorials/Play-Images.md +10 -0
- package/dist/tutorials/Play-Outputs.md +1 -1
- package/dist/web/assets/index-CcJ6VLvA.css +1 -0
- package/dist/web/assets/{index-CT9_JJJS.js → index-aN9pfG3k.js} +120 -120
- package/dist/web/assets/{pdf-CNkg4tBo.js → pdf-D0quBJqy.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Cvuxycwt.css +0 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { referenceKey } from "../admission/model.js";
|
|
2
2
|
import { usesReference } from "../admission/rules.js";
|
|
3
|
+
import { lookWait, withLooks } from "./recipe-appearance.js";
|
|
3
4
|
import { castFor } from "./recipe-cast.js";
|
|
4
5
|
import { recipe, selectedAsset, } from "./recipe-model.js";
|
|
5
6
|
import { renderedPrompt } from "./recipe-text.js";
|
|
@@ -16,7 +17,9 @@ export function imageChoice(config) {
|
|
|
16
17
|
...(config.images?.thinking === undefined ? {} : { thinking: config.images.thinking }),
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
|
-
export function referenceRecipe(context
|
|
20
|
+
export function referenceRecipe(context,
|
|
21
|
+
// How the subject looks, for an establishing prompt with `{{Appearance}}`.
|
|
22
|
+
appearance) {
|
|
20
23
|
const { config, content } = context;
|
|
21
24
|
if (!usesReference(config) || config.reference === undefined)
|
|
22
25
|
return undefined;
|
|
@@ -27,7 +30,24 @@ export function referenceRecipe(context) {
|
|
|
27
30
|
assetId: content.provided.reference ?? null,
|
|
28
31
|
semantic: "reference",
|
|
29
32
|
}, [], { unresolved: content.provided.reference === undefined });
|
|
30
|
-
const
|
|
33
|
+
const written = renderedPrompt(context, referencePromptKey);
|
|
34
|
+
const prompt = withLooks(written, appearance);
|
|
35
|
+
if (prompt === null) {
|
|
36
|
+
const wait = lookWait(written, appearance);
|
|
37
|
+
return recipe(context, referenceKey, "images", {
|
|
38
|
+
kind: "deferred",
|
|
39
|
+
version: 1,
|
|
40
|
+
operation: "image-scene",
|
|
41
|
+
template: [
|
|
42
|
+
...wait.template,
|
|
43
|
+
written,
|
|
44
|
+
config.images?.provider ?? null,
|
|
45
|
+
config.images?.model ?? null,
|
|
46
|
+
config.format,
|
|
47
|
+
...(config.images?.thinking === undefined ? [] : [config.images.thinking]),
|
|
48
|
+
],
|
|
49
|
+
}, wait.dependsOn);
|
|
50
|
+
}
|
|
31
51
|
return recipe(context, referenceKey, "images", {
|
|
32
52
|
kind: "image",
|
|
33
53
|
version: 1,
|
|
@@ -1,9 +1,43 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { withLanguage } from "../../kernel/ports/languages.js";
|
|
3
|
-
import {
|
|
3
|
+
import { thumbnailCountOf } from "../admission/model.js";
|
|
4
|
+
import { pictureKind, sceneMessages, usesScene } from "../images/scenes.js";
|
|
4
5
|
import { recipe, selectedReference, } from "./recipe-model.js";
|
|
5
6
|
import { llmInput, llmInputFingerprint } from "./recipe-text.js";
|
|
6
7
|
export const imageScenesKey = "images:scenes";
|
|
8
|
+
export const thumbnailScenesKey = "thumbnail:scenes";
|
|
9
|
+
// With Scenes from the article on, a thumbnail drawn from a Library prompt whose text asks for
|
|
10
|
+
// `{{Scene}}` takes one scene per thumbnail; any other thumbnail is drawn as it always was.
|
|
11
|
+
export function thumbnailScenes(context, text) {
|
|
12
|
+
const { config, content } = context;
|
|
13
|
+
if (config.sources.images !== "generate" || config.imageScenes !== true)
|
|
14
|
+
return undefined;
|
|
15
|
+
if (config.sources.thumbnail !== "from_prompt")
|
|
16
|
+
return undefined;
|
|
17
|
+
const body = content.promptTemplates.thumbnailPrompt ?? config.rendered.thumbnailPrompt;
|
|
18
|
+
if (!usesScene(body))
|
|
19
|
+
return undefined;
|
|
20
|
+
const count = thumbnailCountOf(config);
|
|
21
|
+
const messages = withLanguage(sceneMessages({
|
|
22
|
+
title: config.title,
|
|
23
|
+
article: text.articleText ?? "",
|
|
24
|
+
pictures: [],
|
|
25
|
+
thumbnails: count,
|
|
26
|
+
}), config.language);
|
|
27
|
+
const value = recipe(context, thumbnailScenesKey, "thumbnail", text.articleText === null
|
|
28
|
+
? {
|
|
29
|
+
kind: "deferred",
|
|
30
|
+
version: 1,
|
|
31
|
+
operation: "thumbnail-scenes",
|
|
32
|
+
template: [llmInputFingerprint(context, messages), text.article.fingerprint],
|
|
33
|
+
}
|
|
34
|
+
: llmInput(context, messages), [text.article.key]);
|
|
35
|
+
return {
|
|
36
|
+
recipe: value,
|
|
37
|
+
count,
|
|
38
|
+
scenes: text.articleText === null ? undefined : savedScenes(context, value, count),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
7
41
|
export function imageScenes(context, text) {
|
|
8
42
|
const { config, content } = context;
|
|
9
43
|
if (config.sources.images !== "generate" || config.imageScenes !== true)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { narrationPreparationFields, shortsFields, usesNarrationPreparation, usesReference, usesShorts, usesYoutubeDescription, videoEditFields, youtubeDescriptionFields, } from "../admission/rules.js";
|
|
2
|
-
import { detectSlots,
|
|
2
|
+
import { detectSlots, filledKeywords, render } 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,7 @@ 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 (key
|
|
146
|
+
if (!filledKeywords.includes(key) &&
|
|
147
147
|
(!Object.hasOwn(config.values, key) || !config.values[key]?.trim()))
|
|
148
148
|
fields.push({ field: `values.${key}`, message: "Enter a value for this keyword." });
|
|
149
149
|
}
|
|
@@ -3,6 +3,7 @@ import { usesShortMode } from "../admission/short-mode.js";
|
|
|
3
3
|
import { render } from "../admission/substitute.js";
|
|
4
4
|
import { withoutScene, withScene } from "../images/scenes.js";
|
|
5
5
|
import { usesAmbientBed } from "../video/ambient-bed.js";
|
|
6
|
+
import { lookWait, withLooks } from "./recipe-appearance.js";
|
|
6
7
|
import { castFor } from "./recipe-cast.js";
|
|
7
8
|
import { noMaster } from "./recipe-loudness.js";
|
|
8
9
|
import { recipe, resourceIdentity, } from "./recipe-model.js";
|
|
@@ -18,7 +19,9 @@ timing = null,
|
|
|
18
19
|
// Level the volume: the levelled narration and the master (`recipe-loudness.ts`).
|
|
19
20
|
master = noMaster,
|
|
20
21
|
// Each image's own scene, for the images whose prompt asks for one (`recipe-scenes.ts`).
|
|
21
|
-
scenes
|
|
22
|
+
scenes,
|
|
23
|
+
// How the subject and characters look, for prompts with `{{Appearance}}`.
|
|
24
|
+
appearance) {
|
|
22
25
|
const recipes = [];
|
|
23
26
|
const imageKeys = config.sources.images === "off" ? [] : content.imageOrder;
|
|
24
27
|
for (const key of imageKeys) {
|
|
@@ -30,15 +33,37 @@ scenes) {
|
|
|
30
33
|
: content.promptTemplates[image.templateKey];
|
|
31
34
|
const at = scenes?.keys.indexOf(key) ?? -1;
|
|
32
35
|
if (scenes !== undefined && at !== -1 && image.source === "generate") {
|
|
33
|
-
recipes.push(sceneImage(config, content, key, raw ?? image.prompt ?? "", scenes, at, reference));
|
|
36
|
+
recipes.push(sceneImage(config, content, key, raw ?? image.prompt ?? "", scenes, at, reference, appearance));
|
|
34
37
|
continue;
|
|
35
38
|
}
|
|
36
39
|
// With Scenes from the article off, a prompt's `{{Scene}}` line is left out.
|
|
37
|
-
const
|
|
40
|
+
const written = raw === undefined || raw === null
|
|
38
41
|
? image.prompt === null || image.prompt === undefined
|
|
39
42
|
? image.prompt
|
|
40
43
|
: withoutScene(image.prompt)
|
|
41
44
|
: render(withoutScene(raw), config.values);
|
|
45
|
+
const prompt = written === null || written === undefined || image.source !== "generate"
|
|
46
|
+
? written
|
|
47
|
+
: withLooks(written, appearance);
|
|
48
|
+
// Waiting for the looks: the same wait an image with a scene has, on the lookup instead.
|
|
49
|
+
if (prompt === null && written !== null && written !== undefined) {
|
|
50
|
+
const wait = lookWait(written, appearance);
|
|
51
|
+
recipes.push(recipe({ content }, `image:${key}`, "images", {
|
|
52
|
+
kind: "deferred",
|
|
53
|
+
version: 1,
|
|
54
|
+
operation: "image-scene",
|
|
55
|
+
template: [
|
|
56
|
+
...wait.template,
|
|
57
|
+
written,
|
|
58
|
+
config.images?.provider ?? null,
|
|
59
|
+
config.images?.model ?? null,
|
|
60
|
+
config.format,
|
|
61
|
+
...(config.images?.thinking === undefined ? [] : [config.images.thinking]),
|
|
62
|
+
...(reference === undefined ? [] : [reference.input.fingerprint]),
|
|
63
|
+
],
|
|
64
|
+
}, [...wait.dependsOn, ...(reference === undefined ? [] : [reference.key])]));
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
42
67
|
recipes.push(recipe({ content }, `image:${key}`, "images", image.source === "provide"
|
|
43
68
|
? { kind: "provided", version: 1, assetId: image.assetId, semantic: null }
|
|
44
69
|
: {
|
|
@@ -125,9 +150,12 @@ scenes) {
|
|
|
125
150
|
// An image drawn from its own scene. Until the scenes are written it waits as a deferred
|
|
126
151
|
// request that names the scenes step and the image's place among them, so writing them turns
|
|
127
152
|
// it into its image request (materialization), and writing them again draws it again.
|
|
128
|
-
function sceneImage(config, content, key, body, scenes, at, reference) {
|
|
153
|
+
function sceneImage(config, content, key, body, scenes, at, reference, appearance) {
|
|
129
154
|
const scene = scenes.scenes?.[at];
|
|
130
|
-
const prompt = scene === undefined
|
|
155
|
+
const prompt = scene === undefined
|
|
156
|
+
? undefined
|
|
157
|
+
: (withLooks(withScene(render(body, config.values), scene), appearance, scene) ?? undefined);
|
|
158
|
+
const wait = lookWait(body, appearance);
|
|
131
159
|
return recipe({ content }, `image:${key}`, "images", prompt === undefined
|
|
132
160
|
? {
|
|
133
161
|
kind: "deferred",
|
|
@@ -141,6 +169,7 @@ function sceneImage(config, content, key, body, scenes, at, reference) {
|
|
|
141
169
|
config.format,
|
|
142
170
|
...(config.images?.thinking === undefined ? [] : [config.images.thinking]),
|
|
143
171
|
...(reference === undefined ? [] : [reference.input.fingerprint]),
|
|
172
|
+
...wait.template,
|
|
144
173
|
],
|
|
145
174
|
}
|
|
146
175
|
: {
|
|
@@ -151,7 +180,7 @@ function sceneImage(config, content, key, body, scenes, at, reference) {
|
|
|
151
180
|
prompt,
|
|
152
181
|
...(reference === undefined ? {} : { reference: reference.input }),
|
|
153
182
|
...castField(castFor(config, prompt)),
|
|
154
|
-
}, [scenes.recipe.key, ...(reference === undefined ? [] : [reference.key])]);
|
|
183
|
+
}, [scenes.recipe.key, ...wait.dependsOn, ...(reference === undefined ? [] : [reference.key])]);
|
|
155
184
|
}
|
|
156
185
|
// What the ambient bed adds to the render's fingerprint: its settings and, for the user's own
|
|
157
186
|
// file, the project asset it plays. Undefined without a bed.
|
|
@@ -170,7 +199,11 @@ export function ambientBedValues(config, content) {
|
|
|
170
199
|
}
|
|
171
200
|
export function thumbnailRecipes(context, textRecipes,
|
|
172
201
|
// The establishing image, when it is on and the thumbnail is drawn from it too.
|
|
173
|
-
reference
|
|
202
|
+
reference,
|
|
203
|
+
// Scenes from the article: a thumbnail prompt with `{{Scene}}` takes one per thumbnail.
|
|
204
|
+
scenes,
|
|
205
|
+
// How the subject and characters look, for a prompt with `{{Appearance}}`.
|
|
206
|
+
appearance) {
|
|
174
207
|
const { config, content } = context;
|
|
175
208
|
if (config.sources.thumbnail === "off")
|
|
176
209
|
return [];
|
|
@@ -184,40 +217,63 @@ reference) {
|
|
|
184
217
|
}, [], { unresolved: content.provided.thumbnail === undefined }),
|
|
185
218
|
];
|
|
186
219
|
const promptRecipe = textRecipes.find((value) => value.key === "thumbnail:prompt");
|
|
187
|
-
const
|
|
220
|
+
const written = config.sources.thumbnail === "prompt_by_llm" && promptRecipe !== undefined
|
|
188
221
|
? matchingText(context, promptRecipe, "prompt")
|
|
189
222
|
: renderedPrompt(context, "thumbnailPrompt");
|
|
223
|
+
const sceneFor = (variant) => scenes?.scenes?.[variant - 1];
|
|
224
|
+
const withScenes = scenes !== undefined;
|
|
190
225
|
const variants = Array.from({ length: thumbnailCountOf(config) }, (_, index) => index + 1);
|
|
191
|
-
return variants.map((variant) =>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
226
|
+
return variants.map((variant) => {
|
|
227
|
+
const scene = sceneFor(variant);
|
|
228
|
+
// With a scene the thumbnail waits for it; with the switch off a `{{Scene}}` line is left
|
|
229
|
+
// out, as the images do.
|
|
230
|
+
const sceneFilled = written === null
|
|
231
|
+
? null
|
|
232
|
+
: withScenes
|
|
233
|
+
? scene === undefined
|
|
234
|
+
? null
|
|
235
|
+
: withScene(written, scene)
|
|
236
|
+
: withoutScene(written);
|
|
237
|
+
// A thumbnail stands for the subject, so it always has the subject's look.
|
|
238
|
+
const prompt = sceneFilled === null
|
|
239
|
+
? null
|
|
240
|
+
: withLooks(sceneFilled, appearance, scene, { subjectAlways: true });
|
|
241
|
+
const wait = lookWait(written, appearance);
|
|
242
|
+
return recipe(context, thumbnailKey(variant), "thumbnail", prompt === null
|
|
243
|
+
? {
|
|
244
|
+
kind: "deferred",
|
|
245
|
+
version: 1,
|
|
246
|
+
operation: "thumbnail-image",
|
|
247
|
+
template: [
|
|
248
|
+
promptRecipe?.fingerprint ?? null,
|
|
249
|
+
config.images?.provider ?? null,
|
|
250
|
+
config.images?.model ?? null,
|
|
251
|
+
config.format,
|
|
252
|
+
// Only what is in use, so a thumbnail made before these existed keeps its
|
|
253
|
+
// fingerprint.
|
|
254
|
+
...(config.images?.thinking === undefined ? [] : [config.images.thinking]),
|
|
255
|
+
...(reference === undefined ? [] : [reference.input.fingerprint]),
|
|
256
|
+
...(variant === 1 ? [] : [["thumbnail-variant", variant]]),
|
|
257
|
+
...(withScenes ? [["thumbnail-scene", scenes.recipe.fingerprint]] : []),
|
|
258
|
+
...wait.template,
|
|
259
|
+
],
|
|
260
|
+
}
|
|
261
|
+
: {
|
|
262
|
+
kind: "image",
|
|
263
|
+
version: 1,
|
|
264
|
+
...imageChoice(config),
|
|
265
|
+
aspect: config.format,
|
|
266
|
+
prompt: thumbnailVariantPrompt(prompt, variant),
|
|
267
|
+
...(reference === undefined ? {} : { reference: reference.input }),
|
|
268
|
+
// The thumbnail stands for the whole video, like the establishing image.
|
|
269
|
+
...castField(castFor(config, config.title, prompt)),
|
|
270
|
+
}, [
|
|
271
|
+
...(promptRecipe === undefined ? [] : [promptRecipe.key]),
|
|
272
|
+
...(reference === undefined ? [] : [reference.key]),
|
|
273
|
+
...(withScenes ? [scenes.recipe.key] : []),
|
|
274
|
+
...wait.dependsOn,
|
|
275
|
+
]);
|
|
276
|
+
});
|
|
221
277
|
}
|
|
222
278
|
// The second and third thumbnails are the same prompt asked for another composition, so the
|
|
223
279
|
// three test one idea against itself rather than three ideas. The first is the prompt as it
|
|
@@ -281,7 +281,10 @@ export function priceRecipe(work, value, config) {
|
|
|
281
281
|
// Scenes from the article: an image waiting for its scene is one image, and the scenes are
|
|
282
282
|
// one call that reads the article (its length is known once the article is written).
|
|
283
283
|
if (input.kind === "deferred" &&
|
|
284
|
-
(input.operation === "image-scene" ||
|
|
284
|
+
(input.operation === "image-scene" ||
|
|
285
|
+
input.operation === "image-scenes" ||
|
|
286
|
+
input.operation === "thumbnail-scenes" ||
|
|
287
|
+
input.operation === "image-appearance")) {
|
|
285
288
|
const choice = config === undefined ? undefined : recipeProviderChoice(value, config);
|
|
286
289
|
if (choice !== undefined && input.operation === "image-scene")
|
|
287
290
|
return { kind: "image", stage: work.key, provider: choice.provider, model: choice.model };
|
|
@@ -292,7 +295,7 @@ export function priceRecipe(work, value, config) {
|
|
|
292
295
|
provider: choice.provider,
|
|
293
296
|
model: choice.model,
|
|
294
297
|
inputCharacters: 90000,
|
|
295
|
-
outputCharacters: 4000,
|
|
298
|
+
outputCharacters: input.operation === "image-appearance" ? 6000 : 4000,
|
|
296
299
|
detail: "The article is read when the step runs; its length is estimated. Retries are excluded.",
|
|
297
300
|
};
|
|
298
301
|
}
|
|
@@ -123,7 +123,8 @@ export async function executeExportRecipe(deps, context, piece) {
|
|
|
123
123
|
type: "stage.progress",
|
|
124
124
|
projectId: context.work.projectId,
|
|
125
125
|
stage: "video",
|
|
126
|
-
|
|
126
|
+
// A tenth of a percent, so a two-hour render moves every few seconds, not minutes.
|
|
127
|
+
current: Math.min(100, Math.round(elapsedMs / totalSeconds) / 10),
|
|
127
128
|
total: 100,
|
|
128
129
|
});
|
|
129
130
|
// Level the volume: the audio-only file is mastered to the audio files' target, the video to
|
|
@@ -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 { checkAppearance } from "../images/appearance.js";
|
|
5
6
|
import { checkScenes, sceneCountOf } from "../images/scenes.js";
|
|
6
7
|
import { spokenPassage } from "../narration/describe.js";
|
|
7
8
|
import { observeNarration } from "../narration/live.js";
|
|
@@ -15,7 +16,8 @@ import { outputPath } from "../storage/layout.js";
|
|
|
15
16
|
import { probeDurationMs } from "../video/ffmpeg.js";
|
|
16
17
|
import { parseAttribution } from "../voices/attribution.js";
|
|
17
18
|
import { parseScript } from "../voices/script.js";
|
|
18
|
-
import {
|
|
19
|
+
import { imageAppearanceKey } from "./recipe-appearance.js";
|
|
20
|
+
import { imageScenesKey, thumbnailScenesKey } from "./recipe-scenes.js";
|
|
19
21
|
import { executeArticleRequests } from "./runtime-article.js";
|
|
20
22
|
import { imageCall } from "./runtime-image.js";
|
|
21
23
|
import { frozenInstructions } from "./runtime-instructions.js";
|
|
@@ -196,10 +198,16 @@ function checkAnswer(piece, answer) {
|
|
|
196
198
|
? "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."
|
|
197
199
|
: undefined;
|
|
198
200
|
// Scenes from the article: one scene per image, as many as the request lists.
|
|
199
|
-
if (piece.key === imageScenesKey
|
|
201
|
+
if ((piece.key === imageScenesKey || piece.key === thumbnailScenesKey) &&
|
|
202
|
+
piece.input.kind === "llm") {
|
|
200
203
|
const checked = checkScenes(answer.text, sceneCountOf(piece.input.messages) ?? 0);
|
|
201
204
|
return checked.ok ? undefined : checked.reason;
|
|
202
205
|
}
|
|
206
|
+
// The looks it looked up: at least the video's subject.
|
|
207
|
+
if (piece.key === imageAppearanceKey && piece.input.kind === "llm") {
|
|
208
|
+
const checked = checkAppearance(answer.text);
|
|
209
|
+
return checked.ok ? undefined : checked.reason;
|
|
210
|
+
}
|
|
203
211
|
if (piece.key === "research:planner")
|
|
204
212
|
return chaptersFrom(answer.text).length === 0
|
|
205
213
|
? "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."
|
|
@@ -217,7 +225,8 @@ function attributionSource(messages) {
|
|
|
217
225
|
return messages.at(-1)?.content ?? "";
|
|
218
226
|
}
|
|
219
227
|
async function publishText(deps, context, piece, answer) {
|
|
220
|
-
if (piece.key === imageScenesKey
|
|
228
|
+
if ((piece.key === imageScenesKey || piece.key === thumbnailScenesKey) &&
|
|
229
|
+
piece.input.kind === "llm") {
|
|
221
230
|
const checked = checkScenes(answer.text, sceneCountOf(piece.input.messages) ?? 0);
|
|
222
231
|
if (!checked.ok)
|
|
223
232
|
throw new Error(checked.reason);
|
|
@@ -226,6 +235,15 @@ async function publishText(deps, context, piece, answer) {
|
|
|
226
235
|
]), { scenes: checked.scenes, text: answer.text });
|
|
227
236
|
return;
|
|
228
237
|
}
|
|
238
|
+
if (piece.key === imageAppearanceKey && piece.input.kind === "llm") {
|
|
239
|
+
const checked = checkAppearance(answer.text);
|
|
240
|
+
if (!checked.ok)
|
|
241
|
+
throw new Error(checked.reason);
|
|
242
|
+
await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
|
|
243
|
+
["instructions", "instructions.md", frozenInstructions(deps, context, piece)],
|
|
244
|
+
]), { appearance: checked.appearance, text: answer.text });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
229
247
|
if (piece.input.kind === "llm" && piece.input.preparation !== undefined) {
|
|
230
248
|
const checked = validatePreparation(answer.text, piece.input.preparation.source);
|
|
231
249
|
if (!checked.ok)
|
|
@@ -79,7 +79,7 @@ export function transitionRevisionWork(deps, input) {
|
|
|
79
79
|
db.prepare(`INSERT INTO revision_work_reservations(project_id,revision_id,work_key,work_id,piece_id,fingerprint) VALUES (?,?,?,?,?,?)`).run(input.projectId, input.revisionId, key, existing.workId, existing.pieceId, fp);
|
|
80
80
|
continue;
|
|
81
81
|
}
|
|
82
|
-
const kind = exact?.stage ?? stageForKey(key);
|
|
82
|
+
const kind = exact?.stage ?? input.stages?.[key] ?? stageForKey(key);
|
|
83
83
|
const stage = db
|
|
84
84
|
.prepare("SELECT id FROM stages WHERE project_id=? AND kind=?")
|
|
85
85
|
.get(input.projectId, kind);
|
|
@@ -129,7 +129,7 @@ function existingWork(db, projectId, key, fingerprint) {
|
|
|
129
129
|
? undefined
|
|
130
130
|
: { workId: z.string().parse(row.work_id), pieceId: z.string().parse(row.piece_id) };
|
|
131
131
|
}
|
|
132
|
-
function stageForKey(key) {
|
|
132
|
+
export function stageForKey(key) {
|
|
133
133
|
// A review runs in its item's stage; the narration's review in Audio.
|
|
134
134
|
if (key === "review:narration")
|
|
135
135
|
return "audio";
|
|
@@ -142,13 +142,21 @@ function stageForKey(key) {
|
|
|
142
142
|
return "research";
|
|
143
143
|
if (prefix === "article" || prefix === "entry" || prefix === "script")
|
|
144
144
|
return "article";
|
|
145
|
-
if (prefix === "audio" || prefix === "narration")
|
|
145
|
+
if (prefix === "audio" || prefix === "narration" || prefix === "level")
|
|
146
146
|
return "audio";
|
|
147
|
-
|
|
147
|
+
// `images:scenes` and `images:appearance` (the latter is Thumbnail's in a project that draws
|
|
148
|
+
// no images, which the plan's own stages say).
|
|
149
|
+
if (prefix === "image" || prefix === "images" || prefix === "reference" || prefix === "figure")
|
|
148
150
|
return "images";
|
|
149
151
|
if (prefix === "thumbnail")
|
|
150
152
|
return "thumbnail";
|
|
151
|
-
if (prefix === "export" ||
|
|
153
|
+
if (prefix === "export" ||
|
|
154
|
+
prefix === "subtitles" ||
|
|
155
|
+
prefix === "video" ||
|
|
156
|
+
prefix === "voices" ||
|
|
157
|
+
prefix === "shorts" ||
|
|
158
|
+
prefix === "youtube" ||
|
|
159
|
+
prefix === "animate")
|
|
152
160
|
return "video";
|
|
153
161
|
if (prefix === "document")
|
|
154
162
|
return "document";
|
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { workExists } from "../../kernel/runner/work-authority.js";
|
|
4
4
|
import { pieceFile } from "../storage/reconcile.js";
|
|
5
5
|
import { outputSchema } from "../storage/schema.js";
|
|
6
|
-
import { outputsForRevision, piecesForRevision, revisionById } from "./repo.js";
|
|
6
|
+
import { currentRevisionId, outputsForRevision, piecesForRevision, revisionById } from "./repo.js";
|
|
7
7
|
import { stagePieceSchema } from "./schema.js";
|
|
8
8
|
export function publicationAuthority(db, publication) {
|
|
9
9
|
const { work, pieceId, publicationId } = publication;
|
|
@@ -42,6 +42,14 @@ export function validatePublication(db, publication, outputs, pieces) {
|
|
|
42
42
|
.prepare("SELECT logical_fingerprint FROM revision_work_pieces WHERE id=? AND work_id=?")
|
|
43
43
|
.get(publication.pieceId, publication.work.workId);
|
|
44
44
|
const revision = revisionById(db, publication.work.projectId, publication.work.revisionId);
|
|
45
|
+
// The captions an export carries are the ones selected when it started. An edit while it ran
|
|
46
|
+
// (a thumbnail redo, say) makes a newer revision: captions made after that are selected there
|
|
47
|
+
// only, so the current revision counts as well as the work's own.
|
|
48
|
+
const head = currentRevisionId(db, publication.work.projectId);
|
|
49
|
+
const captionRevisions = [
|
|
50
|
+
publication.work.revisionId,
|
|
51
|
+
...(head === undefined || head === publication.work.revisionId ? [] : [head]),
|
|
52
|
+
];
|
|
45
53
|
for (const row of outputs) {
|
|
46
54
|
const retainedMember = ((authority?.key === "subtitles:files" &&
|
|
47
55
|
((row.workKey === "export:wav" && row.output.role === "audio_export") ||
|
|
@@ -51,12 +59,12 @@ export function validatePublication(db, publication, outputs, pieces) {
|
|
|
51
59
|
((authority?.key === "export:wav" || authority?.key === "export:video") &&
|
|
52
60
|
row.workKey === "subtitles:files" &&
|
|
53
61
|
["subtitles_srt", "subtitles_vtt", "subtitle_ass", "subtitle_font"].includes(row.output.role))) &&
|
|
54
|
-
outputsForRevision(db, publication.work.projectId,
|
|
62
|
+
captionRevisions.some((revisionId) => outputsForRevision(db, publication.work.projectId, revisionId).some((old) => old.selected &&
|
|
55
63
|
old.state === "ready" &&
|
|
56
64
|
old.workKey === row.workKey &&
|
|
57
65
|
old.output.role === row.output.role &&
|
|
58
66
|
old.assetId === row.asset.id &&
|
|
59
|
-
old.fingerprint === row.fingerprint);
|
|
67
|
+
old.fingerprint === row.fingerprint));
|
|
60
68
|
if (!retainedMember &&
|
|
61
69
|
row.fingerprint !== revision?.fingerprints[row.workKey] &&
|
|
62
70
|
!(row.workKey === authority?.key &&
|
|
@@ -48,7 +48,12 @@ export async function restoreRevision(deps, input) {
|
|
|
48
48
|
baseRevisionId: input.baseRevisionId,
|
|
49
49
|
revisionId: revision.id,
|
|
50
50
|
fingerprints: revision.fingerprints,
|
|
51
|
-
...(comparison.ok
|
|
51
|
+
...(comparison.ok
|
|
52
|
+
? {
|
|
53
|
+
baseFingerprints: comparison.baseFingerprints,
|
|
54
|
+
stages: Object.fromEntries(comparison.recipes.map((row) => [row.key, row.stage])),
|
|
55
|
+
}
|
|
56
|
+
: {}),
|
|
52
57
|
logicalKeys: logicalKeys(deps, input.projectId, input.baseRevisionId, revision.fingerprints),
|
|
53
58
|
});
|
|
54
59
|
carryCheckpointGates(deps, input.baseRevisionId, revision);
|
|
@@ -4,6 +4,7 @@ import { currentRevisionId } from "../revisions/repo.js";
|
|
|
4
4
|
const attemptRow = z.object({
|
|
5
5
|
kind: z.enum(stageKinds),
|
|
6
6
|
revision_id: z.string().nullable(),
|
|
7
|
+
work_id: z.string().nullable(),
|
|
7
8
|
started_at: z.string(),
|
|
8
9
|
ended_at: z.string().nullable(),
|
|
9
10
|
work_state: z.string().nullable(),
|
|
@@ -26,14 +27,22 @@ export function coveredMs(intervals) {
|
|
|
26
27
|
}
|
|
27
28
|
export function projectTiming(db, projectId, now) {
|
|
28
29
|
const rows = db
|
|
29
|
-
.prepare(`SELECT s.kind AS kind, a.revision_id AS revision_id, a.
|
|
30
|
-
a.ended_at AS ended_at, w.state AS work_state
|
|
30
|
+
.prepare(`SELECT s.kind AS kind, a.revision_id AS revision_id, a.work_id AS work_id,
|
|
31
|
+
a.started_at AS started_at, a.ended_at AS ended_at, w.state AS work_state
|
|
31
32
|
FROM attempts a JOIN stages s ON s.id = a.stage_id
|
|
32
33
|
LEFT JOIN revision_work w ON w.id = a.work_id
|
|
33
34
|
WHERE s.project_id = ?`)
|
|
34
35
|
.all(projectId)
|
|
35
36
|
.map((row) => attemptRow.parse(row));
|
|
36
37
|
const head = currentRevisionId(db, projectId);
|
|
38
|
+
// Work an edit carried over to the current revision is still its run's work: a video
|
|
39
|
+
// retried after a thumbnail redo renders on the revision it started on, and its hours count.
|
|
40
|
+
const carried = new Set(head === undefined
|
|
41
|
+
? []
|
|
42
|
+
: db
|
|
43
|
+
.prepare("SELECT work_id FROM revision_work_reservations WHERE revision_id=?")
|
|
44
|
+
.all(head)
|
|
45
|
+
.flatMap((row) => (typeof row.work_id === "string" ? [row.work_id] : [])));
|
|
37
46
|
const all = [];
|
|
38
47
|
const runs = new Map();
|
|
39
48
|
const stages = new Map();
|
|
@@ -50,12 +59,15 @@ export function projectTiming(db, projectId, now) {
|
|
|
50
59
|
const interval = [start, end];
|
|
51
60
|
all.push(interval);
|
|
52
61
|
stages.set(row.kind, [...(stages.get(row.kind) ?? []), interval]);
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
const owner = head !== undefined && row.work_id !== null && carried.has(row.work_id)
|
|
63
|
+
? head
|
|
64
|
+
: row.revision_id;
|
|
65
|
+
if (owner !== null) {
|
|
66
|
+
const run = runs.get(owner) ?? { intervals: [], running: false, last: start };
|
|
55
67
|
run.intervals.push(interval);
|
|
56
68
|
run.running ||= live;
|
|
57
69
|
run.last = Math.max(run.last, start);
|
|
58
|
-
runs.set(
|
|
70
|
+
runs.set(owner, run);
|
|
59
71
|
}
|
|
60
72
|
}
|
|
61
73
|
const latest = (head === undefined ? undefined : runs.get(head)) ??
|
|
@@ -42,6 +42,15 @@ If you blocked notifications for Slopify earlier, Slopify says "Notifications ar
|
|
|
42
42
|
|
|
43
43
|
Some browsers only allow notifications from installed apps. If the test says so, use a Notification URL instead.
|
|
44
44
|
|
|
45
|
+
## Run sounds
|
|
46
|
+
|
|
47
|
+
**Settings → Notifications → Run sounds** (on by default) plays a chime when a run starts and a different, longer one when it finishes, fails or stops to wait for you, like a message arriving on a phone. **Play start** and **Play end** let you hear them.
|
|
48
|
+
|
|
49
|
+
- Works while any Slopify tab is open; only one tab plays each chime.
|
|
50
|
+
- Needs no permission, but browsers keep a page silent until it has been clicked once, so click anywhere in Slopify after opening it.
|
|
51
|
+
- With **Browser notifications** on too, a run's start also shows "Started: *title*".
|
|
52
|
+
- It is this browser's own setting. Your computer's volume sets how loud.
|
|
53
|
+
|
|
45
54
|
## Get notifications on your phone (Notification URL)
|
|
46
55
|
|
|
47
56
|
A Notification URL works with no Slopify tab open. When a run finishes, fails or waits, or a review needs a decision, Slopify sends that address one `POST` with a short plain-text body. Its last line is the project's link, so you can open it straight from the message, for example:
|
|
@@ -118,9 +118,19 @@ Where the scene goes:
|
|
|
118
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
119
|
- A prompt without it gets the scene as a line of its own after its first paragraph, so any prompt works unchanged.
|
|
120
120
|
- With the switch off, a line holding `{{Scene}}` is left out and the prompt is drawn as it always was.
|
|
121
|
+
- A **thumbnail** prompt with `{{Scene}}` gets a scene from a call of its own (one more AI call, in the Thumbnail stage, so changing the thumbnail never remakes the images): the article's most striking moment, the one that would make someone click, taken from anywhere in the article. With **Thumbnails** set to 3, each thumbnail gets a different moment, so the A/B test compares ideas as well as compositions. A thumbnail prompt without `{{Scene}}` is drawn as it always was.
|
|
121
122
|
|
|
122
123
|
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
124
|
|
|
125
|
+
## Looks looked up: {{Appearance}}
|
|
126
|
+
|
|
127
|
+
An image model draws a named figure from whatever it half remembers, so the same character can look different in every image. Put `{{Appearance}}` in an image, thumbnail or establishing prompt, for example a line `Looks: {{Appearance}}`, and Slopify looks the looks up instead.
|
|
128
|
+
|
|
129
|
+
- Once the article exists, one call to the project's AI model **searches the web** for how the video's subject and every named character in the article look: build, face, clothing, colours and the marks that make them recognisable. Where depictions disagree, it describes the most iconic one.
|
|
130
|
+
- With **Scenes from the article** on, each picture gets the looks of the figures its scene names (up to three besides the subject), so a scene of a place or of someone else isn't redrawn with the subject in it; a scene that names no one leaves the looks line out. A thumbnail always gets the subject's look. Without scenes, every picture gets the subject's look.
|
|
131
|
+
- There is no switch: a prompt with `{{Appearance}}` turns it on, and prompts without it are drawn as they always were. `{{Appearance}}` is never a field to fill in.
|
|
132
|
+
- It costs one more AI call per run, with web search. The pictures that use it wait for it.
|
|
133
|
+
|
|
124
134
|
## How long each image stays on screen
|
|
125
135
|
|
|
126
136
|
**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).
|
|
@@ -32,7 +32,7 @@ The Thumbnail part has its own **Source** switch:
|
|
|
32
32
|
| Option | What it does | Default |
|
|
33
33
|
| --- | --- | --- |
|
|
34
34
|
| **Thumbnail prompt** | The Library thumbnail prompt, keywords filled in. **From prompt** sends it to the image model as it is; **Prompt by LLM** gives it to the text model with the title and article to write the image prompt. | None |
|
|
35
|
-
| **Thumbnails** (**1** or **3**) | **3** makes two more thumbnails from the same prompt with different compositions, for YouTube's Test & compare. Each costs one more image, so 3 thumbnails are 3 image calls. | 1 |
|
|
35
|
+
| **Thumbnails** (**1** or **3**) | **3** makes two more thumbnails from the same prompt with different compositions, for YouTube's Test & compare. Each costs one more image, so 3 thumbnails are 3 image calls. With a thumbnail prompt that has `{{Scene}}` and Images → **Scenes from the article** on, each also shows a different moment of the article (see [Play Images](Play-Images#scenes-from-the-article)). | 1 |
|
|
36
36
|
|
|
37
37
|
### Make three thumbnails for Test & compare
|
|
38
38
|
|