@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.
Files changed (36) hide show
  1. package/dist/edge/events/hub.js +35 -15
  2. package/dist/edge/events/preview-cache.js +1 -0
  3. package/dist/edge/events/visibility.js +42 -8
  4. package/dist/extension/slopify-studio-chrome.zip +0 -0
  5. package/dist/extension/slopify-studio-firefox.zip +0 -0
  6. package/dist/main.js +2 -2
  7. package/dist/patch-notes/3.0.12.md +25 -0
  8. package/dist/patch-notes/3.0.13.md +43 -0
  9. package/dist/patch-notes/index.json +12 -0
  10. package/dist/slices/admission/substitute.js +7 -2
  11. package/dist/slices/images/appearance.js +107 -0
  12. package/dist/slices/images/scenes.js +50 -1
  13. package/dist/slices/rebuild/recipe-appearance.js +82 -0
  14. package/dist/slices/rebuild/recipe-build.js +13 -6
  15. package/dist/slices/rebuild/recipe-model.js +3 -0
  16. package/dist/slices/rebuild/recipe-provider-choice.js +3 -1
  17. package/dist/slices/rebuild/recipe-reference.js +22 -2
  18. package/dist/slices/rebuild/recipe-scenes.js +35 -1
  19. package/dist/slices/rebuild/recipe-validation.js +2 -2
  20. package/dist/slices/rebuild/recipe-visual.js +94 -38
  21. package/dist/slices/rebuild/recipe-work.js +5 -2
  22. package/dist/slices/rebuild/runtime-export.js +2 -1
  23. package/dist/slices/rebuild/runtime-provider.js +21 -3
  24. package/dist/slices/rebuild/transition-repo.js +13 -5
  25. package/dist/slices/revisions/publication-rules.js +11 -3
  26. package/dist/slices/revisions/restore.js +6 -1
  27. package/dist/slices/run-cost/timing.js +17 -5
  28. package/dist/tutorials/Notifications.md +9 -0
  29. package/dist/tutorials/Play-Images.md +10 -0
  30. package/dist/tutorials/Play-Outputs.md +1 -1
  31. package/dist/web/assets/index-CcJ6VLvA.css +1 -0
  32. package/dist/web/assets/{index-CT9_JJJS.js → index-aN9pfG3k.js} +120 -120
  33. package/dist/web/assets/{pdf-CNkg4tBo.js → pdf-D0quBJqy.js} +1 -1
  34. package/dist/web/index.html +2 -2
  35. package/package.json +1 -1
  36. 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 prompt = renderedPrompt(context, referencePromptKey);
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 { pictureKind, sceneMessages } from "../images/scenes.js";
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, render, sceneKeyword } from "../admission/substitute.js";
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 !== sceneKeyword &&
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 prompt = raw === undefined || raw === null
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 ? undefined : withScene(render(body, config.values), scene);
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 prompt = config.sources.thumbnail === "prompt_by_llm" && promptRecipe !== undefined
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) => recipe(context, thumbnailKey(variant), "thumbnail", prompt === null
192
- ? {
193
- kind: "deferred",
194
- version: 1,
195
- operation: "thumbnail-image",
196
- template: [
197
- promptRecipe?.fingerprint ?? null,
198
- config.images?.provider ?? null,
199
- config.images?.model ?? null,
200
- config.format,
201
- // Only what is in use, so a thumbnail made before these existed keeps its
202
- // fingerprint.
203
- ...(config.images?.thinking === undefined ? [] : [config.images.thinking]),
204
- ...(reference === undefined ? [] : [reference.input.fingerprint]),
205
- ...(variant === 1 ? [] : [["thumbnail-variant", variant]]),
206
- ],
207
- }
208
- : {
209
- kind: "image",
210
- version: 1,
211
- ...imageChoice(config),
212
- aspect: config.format,
213
- prompt: thumbnailVariantPrompt(prompt, variant),
214
- ...(reference === undefined ? {} : { reference: reference.input }),
215
- // The thumbnail stands for the whole video, like the establishing image.
216
- ...castField(castFor(config, config.title, prompt)),
217
- }, [
218
- ...(promptRecipe === undefined ? [] : [promptRecipe.key]),
219
- ...(reference === undefined ? [] : [reference.key]),
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" || input.operation === "image-scenes")) {
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
- current: Math.min(100, Math.round(elapsedMs / (totalSeconds * 10))),
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 { imageScenesKey } from "./recipe-scenes.js";
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 && piece.input.kind === "llm") {
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 && piece.input.kind === "llm") {
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
- if (prefix === "image" || prefix === "reference")
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" || prefix === "subtitles" || prefix === "video" || prefix === "voices")
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, publication.work.revisionId).some((old) => old.selected &&
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 ? { baseFingerprints: comparison.baseFingerprints } : {}),
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.started_at AS started_at,
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
- if (row.revision_id !== null) {
54
- const run = runs.get(row.revision_id) ?? { intervals: [], running: false, last: start };
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(row.revision_id, run);
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