@gentbajko/slopify 3.0.11 → 3.0.12

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.
@@ -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,65 @@ 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 === undefined || scenes.thumbnailCount === 0
224
+ ? undefined
225
+ : scenes.thumbnails?.[variant - 1];
226
+ const withScenes = scenes !== undefined && scenes.thumbnailCount > 0;
190
227
  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
- ]));
228
+ return variants.map((variant) => {
229
+ const scene = sceneFor(variant);
230
+ // With a scene the thumbnail waits for it; with the switch off a `{{Scene}}` line is left
231
+ // out, as the images do.
232
+ const sceneFilled = written === null
233
+ ? null
234
+ : withScenes
235
+ ? scene === undefined
236
+ ? null
237
+ : withScene(written, scene)
238
+ : withoutScene(written);
239
+ // A thumbnail stands for the subject, so it always has the subject's look.
240
+ const prompt = sceneFilled === null
241
+ ? null
242
+ : withLooks(sceneFilled, appearance, scene, { subjectAlways: true });
243
+ const wait = lookWait(written, appearance);
244
+ return recipe(context, thumbnailKey(variant), "thumbnail", prompt === null
245
+ ? {
246
+ kind: "deferred",
247
+ version: 1,
248
+ operation: "thumbnail-image",
249
+ template: [
250
+ promptRecipe?.fingerprint ?? null,
251
+ config.images?.provider ?? null,
252
+ config.images?.model ?? null,
253
+ config.format,
254
+ // Only what is in use, so a thumbnail made before these existed keeps its
255
+ // fingerprint.
256
+ ...(config.images?.thinking === undefined ? [] : [config.images.thinking]),
257
+ ...(reference === undefined ? [] : [reference.input.fingerprint]),
258
+ ...(variant === 1 ? [] : [["thumbnail-variant", variant]]),
259
+ ...(withScenes ? [["thumbnail-scene", scenes.recipe.fingerprint]] : []),
260
+ ...wait.template,
261
+ ],
262
+ }
263
+ : {
264
+ kind: "image",
265
+ version: 1,
266
+ ...imageChoice(config),
267
+ aspect: config.format,
268
+ prompt: thumbnailVariantPrompt(prompt, variant),
269
+ ...(reference === undefined ? {} : { reference: reference.input }),
270
+ // The thumbnail stands for the whole video, like the establishing image.
271
+ ...castField(castFor(config, config.title, prompt)),
272
+ }, [
273
+ ...(promptRecipe === undefined ? [] : [promptRecipe.key]),
274
+ ...(reference === undefined ? [] : [reference.key]),
275
+ ...(withScenes ? [scenes.recipe.key] : []),
276
+ ...wait.dependsOn,
277
+ ]);
278
+ });
221
279
  }
222
280
  // The second and third thumbnails are the same prompt asked for another composition, so the
223
281
  // three test one idea against itself rather than three ideas. The first is the prompt as it
@@ -281,7 +281,9 @@ 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 === "image-appearance")) {
285
287
  const choice = config === undefined ? undefined : recipeProviderChoice(value, config);
286
288
  if (choice !== undefined && input.operation === "image-scene")
287
289
  return { kind: "image", stage: work.key, provider: choice.provider, model: choice.model };
@@ -292,7 +294,7 @@ export function priceRecipe(work, value, config) {
292
294
  provider: choice.provider,
293
295
  model: choice.model,
294
296
  inputCharacters: 90000,
295
- outputCharacters: 4000,
297
+ outputCharacters: input.operation === "image-appearance" ? 6000 : 4000,
296
298
  detail: "The article is read when the step runs; its length is estimated. Retries are excluded.",
297
299
  };
298
300
  }
@@ -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,6 +16,7 @@ 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";
19
+ import { imageAppearanceKey } from "./recipe-appearance.js";
18
20
  import { imageScenesKey } from "./recipe-scenes.js";
19
21
  import { executeArticleRequests } from "./runtime-article.js";
20
22
  import { imageCall } from "./runtime-image.js";
@@ -200,6 +202,11 @@ function checkAnswer(piece, answer) {
200
202
  const checked = checkScenes(answer.text, sceneCountOf(piece.input.messages) ?? 0);
201
203
  return checked.ok ? undefined : checked.reason;
202
204
  }
205
+ // The looks it looked up: at least the video's subject.
206
+ if (piece.key === imageAppearanceKey && piece.input.kind === "llm") {
207
+ const checked = checkAppearance(answer.text);
208
+ return checked.ok ? undefined : checked.reason;
209
+ }
203
210
  if (piece.key === "research:planner")
204
211
  return chaptersFrom(answer.text).length === 0
205
212
  ? "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."
@@ -226,6 +233,15 @@ async function publishText(deps, context, piece, answer) {
226
233
  ]), { scenes: checked.scenes, text: answer.text });
227
234
  return;
228
235
  }
236
+ if (piece.key === imageAppearanceKey && piece.input.kind === "llm") {
237
+ const checked = checkAppearance(answer.text);
238
+ if (!checked.ok)
239
+ throw new Error(checked.reason);
240
+ await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
241
+ ["instructions", "instructions.md", frozenInstructions(deps, context, piece)],
242
+ ]), { appearance: checked.appearance, text: answer.text });
243
+ return;
244
+ }
229
245
  if (piece.input.kind === "llm" && piece.input.preparation !== undefined) {
230
246
  const checked = validatePreparation(answer.text, piece.input.preparation.source);
231
247
  if (!checked.ok)
@@ -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 &&
@@ -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 the same call: 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