@gentbajko/slopify 3.0.8 → 3.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/extension/slopify-studio-chrome.zip +0 -0
  2. package/dist/extension/slopify-studio-firefox.zip +0 -0
  3. package/dist/main.js +5 -18
  4. package/dist/patch-notes/3.0.9.md +27 -0
  5. package/dist/patch-notes/index.json +6 -0
  6. package/dist/slices/admission/rules.js +71 -2
  7. package/dist/slices/admission/schema.js +2 -0
  8. package/dist/slices/admission/substitute.js +5 -2
  9. package/dist/slices/estimate/index.js +3 -0
  10. package/dist/slices/images/scenes.js +103 -0
  11. package/dist/slices/play-drafts/convert.js +5 -1
  12. package/dist/slices/play-drafts/schema.js +2 -0
  13. package/dist/slices/project-templates/from-project.js +1 -0
  14. package/dist/slices/rebuild/preview-details.js +3 -1
  15. package/dist/slices/rebuild/recipe-build.js +5 -1
  16. package/dist/slices/rebuild/recipe-model.js +3 -0
  17. package/dist/slices/rebuild/recipe-provider-choice.js +3 -1
  18. package/dist/slices/rebuild/recipe-save.js +3 -2
  19. package/dist/slices/rebuild/recipe-scenes.js +61 -0
  20. package/dist/slices/rebuild/recipe-text.js +4 -3
  21. package/dist/slices/rebuild/recipe-validation.js +3 -2
  22. package/dist/slices/rebuild/recipe-visual.js +46 -2
  23. package/dist/slices/rebuild/recipe-work.js +18 -0
  24. package/dist/slices/rebuild/runtime-document.js +1 -1
  25. package/dist/slices/rebuild/runtime-piece-label.js +2 -0
  26. package/dist/slices/rebuild/runtime-plan.js +1 -1
  27. package/dist/slices/rebuild/runtime-provider.js +16 -0
  28. package/dist/slices/revisions/mutation-prepare.js +1 -1
  29. package/dist/slices/revisions/view.js +3 -1
  30. package/dist/tutorials/Play-Images.md +16 -0
  31. package/dist/tutorials/Play-Title-and-Article.md +1 -0
  32. package/dist/updater/service.js +13 -2
  33. package/dist/updater/work-in-progress.js +59 -0
  34. package/dist/web/assets/{index-Cdb0Jh3P.js → index-CIXw6vpi.js} +115 -115
  35. package/dist/web/assets/{pdf-BhFuoBjr.js → pdf-B9CdIc1m.js} +1 -1
  36. package/dist/web/index.html +1 -1
  37. package/package.json +1 -1
@@ -278,6 +278,24 @@ export function priceRecipe(work, value, config) {
278
278
  const onPlan = config === undefined ? undefined : cliRequest(work, value, config);
279
279
  if (onPlan !== undefined)
280
280
  return onPlan;
281
+ // Scenes from the article: an image waiting for its scene is one image, and the scenes are
282
+ // one call that reads the article (its length is known once the article is written).
283
+ if (input.kind === "deferred" &&
284
+ (input.operation === "image-scene" || input.operation === "image-scenes")) {
285
+ const choice = config === undefined ? undefined : recipeProviderChoice(value, config);
286
+ if (choice !== undefined && input.operation === "image-scene")
287
+ return { kind: "image", stage: work.key, provider: choice.provider, model: choice.model };
288
+ if (choice !== undefined)
289
+ return {
290
+ kind: "llm",
291
+ stage: work.key,
292
+ provider: choice.provider,
293
+ model: choice.model,
294
+ inputCharacters: 90000,
295
+ outputCharacters: 4000,
296
+ detail: "The article is read when the step runs; its length is estimated. Retries are excluded.",
297
+ };
298
+ }
281
299
  if (input.kind === "deferred" && input.operation === "narration-preparation")
282
300
  return {
283
301
  kind: "unknown",
@@ -24,7 +24,7 @@ export async function executeDocumentRecipe(deps, context, piece) {
24
24
  : readFileSync(outputPath(deps.paths, projectId, row.output.path), "utf8");
25
25
  };
26
26
  const config = view.revision.config;
27
- const article = view.revision.content.articleEdited === true || config.sources.article === "provide"
27
+ const article = view.revision.content.articleEdited === true || config.sources.article !== "generate"
28
28
  ? view.articleMarkdown
29
29
  : text("article_md", "article:body");
30
30
  if (article === null)
@@ -45,6 +45,8 @@ export function pieceLabel(deps, context, piece) {
45
45
  return "Shorts";
46
46
  if (key === "animate:future")
47
47
  return "Animated images";
48
+ if (key === "images:scenes")
49
+ return "Image scenes";
48
50
  const short = /^shorts:(\d+):(prompts|image:(\d+)|render)$/.exec(key);
49
51
  if (short !== null)
50
52
  return short[2] === "prompts"
@@ -35,7 +35,7 @@ export function executionPlan(deps, view, catalogue) {
35
35
  const payloads = view.pieces.filter((piece) => piece.selected && piece.piece.state === "done" && piece.piece.payload !== null);
36
36
  const resolved = {
37
37
  articleMarkdown: view.revision.content.articleEdited === true ||
38
- view.revision.config.sources.article === "provide"
38
+ view.revision.config.sources.article !== "generate"
39
39
  ? view.articleMarkdown
40
40
  : textOutput("article_md"),
41
41
  researchNotes: textOutput("notes"),
@@ -2,6 +2,7 @@ import { basename } from "node:path";
2
2
  import { referenceKey, thumbnailVariant } from "../admission/model.js";
3
3
  import { plainText } from "../article/plain.js";
4
4
  import { splitEndMatter } from "../article/split.js";
5
+ import { checkScenes, sceneCountOf } from "../images/scenes.js";
5
6
  import { spokenPassage } from "../narration/describe.js";
6
7
  import { observeNarration } from "../narration/live.js";
7
8
  import { validatePreparation } from "../narration/preparation.js";
@@ -14,6 +15,7 @@ import { outputPath } from "../storage/layout.js";
14
15
  import { probeDurationMs } from "../video/ffmpeg.js";
15
16
  import { parseAttribution } from "../voices/attribution.js";
16
17
  import { parseScript } from "../voices/script.js";
18
+ import { imageScenesKey } from "./recipe-scenes.js";
17
19
  import { executeArticleRequests } from "./runtime-article.js";
18
20
  import { imageCall } from "./runtime-image.js";
19
21
  import { frozenInstructions } from "./runtime-instructions.js";
@@ -193,6 +195,11 @@ function checkAnswer(piece, answer) {
193
195
  return spokenPassage(answer.text) === ""
194
196
  ? "The AI model's description had no words a narrator could say. Use Try again; if it keeps happening, choose another model in the Providers section of Edit project, or turn off Describe tables and figures there."
195
197
  : undefined;
198
+ // Scenes from the article: one scene per image, as many as the request lists.
199
+ if (piece.key === imageScenesKey && piece.input.kind === "llm") {
200
+ const checked = checkScenes(answer.text, sceneCountOf(piece.input.messages) ?? 0);
201
+ return checked.ok ? undefined : checked.reason;
202
+ }
196
203
  if (piece.key === "research:planner")
197
204
  return chaptersFrom(answer.text).length === 0
198
205
  ? "The AI model's research plan listed no chapters, so research could not go on. Use Try again; if it keeps happening, choose another model in the Providers section of Edit project."
@@ -210,6 +217,15 @@ function attributionSource(messages) {
210
217
  return messages.at(-1)?.content ?? "";
211
218
  }
212
219
  async function publishText(deps, context, piece, answer) {
220
+ if (piece.key === imageScenesKey && piece.input.kind === "llm") {
221
+ const checked = checkScenes(answer.text, sceneCountOf(piece.input.messages) ?? 0);
222
+ if (!checked.ok)
223
+ throw new Error(checked.reason);
224
+ await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
225
+ ["instructions", "instructions.md", frozenInstructions(deps, context, piece)],
226
+ ]), { scenes: checked.scenes, text: answer.text });
227
+ return;
228
+ }
213
229
  if (piece.input.kind === "llm" && piece.input.preparation !== undefined) {
214
230
  const checked = validatePreparation(answer.text, piece.input.preparation.source);
215
231
  if (!checked.ok)
@@ -121,7 +121,7 @@ export async function prepareEditAssets(deps, base, edit) {
121
121
  };
122
122
  prepared.push(item);
123
123
  }
124
- const article = edit.config.sources.article === "provide" || content.articleEdited === true
124
+ const article = edit.config.sources.article !== "generate" || content.articleEdited === true
125
125
  ? (content.articleMarkdown ?? edit.config.provided.article)
126
126
  : undefined;
127
127
  if (article !== undefined &&
@@ -40,5 +40,7 @@ export function resolvedArticleMarkdown(deps, revision, outputs) {
40
40
  return readFileSync(outputPath(deps.paths, revision.projectId, article.output.path), "utf8");
41
41
  return revision.config.sources.article === "provide"
42
42
  ? (revision.config.provided.article ?? null)
43
- : null;
43
+ : revision.config.sources.article === "off"
44
+ ? ""
45
+ : null;
44
46
  }
@@ -105,6 +105,22 @@ How the count is worked out:
105
105
 
106
106
  Turning it on also switches **Motion** (in the **Video and style** row) from **Zoom in and out** to **Mix of both**, so a long video pans and zooms by turns. A **Pan across** or **Still** you picked stays, and you can pick Zoom again.
107
107
 
108
+ ## Scenes from the article
109
+
110
+ Without it, every image of a prompt is drawn from the same text, so three images of one prompt tend to show the same picture three times. **Scenes from the article** gives every image its own moment.
111
+
112
+ | Option | What it does | Default |
113
+ | --- | --- | --- |
114
+ | **Scenes from the article** | Before the images are drawn, the project's AI model reads the article once and writes a short scene for each image. Scene 1 comes from the article's opening and the last from its ending, in the order the images are shown. Each image is drawn from its prompt with its scene added. One more AI call per run. | Off |
115
+
116
+ Where the scene goes:
117
+
118
+ - A prompt with `{{Scene}}` gets the scene there, for example a line `Scene: {{Scene}}` under the prompt's first sentence. `{{Scene}}` is never a field to fill in: Slopify writes it.
119
+ - A prompt without it gets the scene as a line of its own after its first paragraph, so any prompt works unchanged.
120
+ - With the switch off, a line holding `{{Scene}}` is left out and the prompt is drawn as it always was.
121
+
122
+ The style, composition and everything else in the prompt stay as written; only the scene changes from image to image. The scenes are written once the article exists, and each image waits for them. Switching it on or off in **Edit project** remakes the images.
123
+
108
124
  ## How long each image stays on screen
109
125
 
110
126
  **Seconds per image** (1 to 600, default 15) is in the **Video and style** row, beside **Cuts**, **Zoom** and **Motion**. When cuts follow the narration, it is the target length and a cut waits for a sentence end. When images run out, they start again. See [Play Video and Style](Play-Video-and-Style).
@@ -25,6 +25,7 @@ The Article row starts with a **Source** switch:
25
25
  | --- | --- | --- |
26
26
  | **Generate** (default) | The text model writes the article from the article prompt, in one call. | One text-model call |
27
27
  | **Provide** | Slopify narrates the text you paste, word for word. No text model writes one. | No text-model cost for the article |
28
+ | **Off** | No article at all, for a project of images or a thumbnail made from prompts. Choosing it switches off what reads the article (research, narration, captions, the YouTube description, shorts, the PDF, Scenes from the article) and takes those sections out of Play; the thumbnail offers From prompt and Provide only. | Nothing |
28
29
 
29
30
  The article is what the narration reads and what the PDF lays out.
30
31
 
@@ -2,6 +2,7 @@ import { timingSafeEqual } from "node:crypto";
2
2
  import { setImmediate } from "node:timers";
3
3
  import { isStableVersion, isUpdateToken, newerVersion } from "./model.js";
4
4
  const waitCheckMs = 5_000;
5
+ const idleLooksToInstall = 2;
5
6
  export function createUpdater(deps) {
6
7
  let latestVersion = null;
7
8
  let checkedAt = -Infinity;
@@ -66,13 +67,23 @@ export function createUpdater(deps) {
66
67
  deps.report(error);
67
68
  });
68
69
  }
69
- // Looks again every few seconds and installs the moment no work is running.
70
+ // Looks again every few seconds and installs once no work is running. Idle has to hold for
71
+ // two looks in a row, so the moment between one step finishing and the next starting is
72
+ // never mistaken for the end of the work.
70
73
  function wait(version) {
71
74
  pending = version;
72
75
  status = "waiting";
73
76
  error = undefined;
77
+ let idleLooks = 0;
74
78
  stopWatching = every(() => {
75
- if (status !== "waiting" || busyNow())
79
+ if (status !== "waiting")
80
+ return;
81
+ if (busyNow()) {
82
+ idleLooks = 0;
83
+ return;
84
+ }
85
+ idleLooks += 1;
86
+ if (idleLooks < idleLooksToInstall)
76
87
  return;
77
88
  stopWatching?.();
78
89
  stopWatching = undefined;
@@ -0,0 +1,59 @@
1
+ import { derive } from "../kernel/runner/graph.js";
2
+ import { projectPaused, stagesOf } from "../slices/admission/repo.js";
3
+ // A scheduled run this close is treated as running: a restart then could land on its time,
4
+ // and a schedule that skips missed runs would skip it.
5
+ export const scheduledSoonMs = 10 * 60_000;
6
+ // What an update must wait for, named for "installs when … finishes"; undefined when nothing
7
+ // is. An update never installs while a job is going: a step running or about to run, a step
8
+ // waiting to try again, a batch with videos still to make, a narration chunk about to be
9
+ // recorded again, a schedule writing topics or due to start a run.
10
+ export function workInProgress(db, inflight, now) {
11
+ const projects = db
12
+ .prepare("SELECT id, title FROM projects WHERE id NOT IN (SELECT project_id FROM project_trash)")
13
+ .all()
14
+ .flatMap((row) => typeof row.id === "string" && typeof row.title === "string"
15
+ ? [{ id: row.id, title: row.title }]
16
+ : []);
17
+ for (const project of projects) {
18
+ if (inflight(project.id))
19
+ return project.title;
20
+ if (projectPaused(db, project.id))
21
+ continue;
22
+ // Running, or waiting to try a step again (`derive`); or between two steps, with work
23
+ // admitted and free to start. Work held for a person (a review checkpoint) doesn't count.
24
+ const state = derive(stagesOf(db, project.id));
25
+ if (state === "running")
26
+ return project.title;
27
+ if (state === "pending" &&
28
+ db
29
+ .prepare("SELECT 1 FROM revision_work WHERE project_id=? AND state IN ('pending','running') AND dispatch_state='allowed' LIMIT 1")
30
+ .get(project.id) !== undefined)
31
+ return project.title;
32
+ }
33
+ const queued = db
34
+ .prepare(`SELECT p.title FROM project_queue q JOIN projects p ON p.id=q.project_id
35
+ WHERE q.state IN ('queued','active')
36
+ AND q.project_id NOT IN (SELECT project_id FROM project_trash)
37
+ AND q.project_id NOT IN (SELECT project_id FROM project_controls WHERE paused=1)
38
+ LIMIT 1`)
39
+ .get();
40
+ if (typeof queued?.title === "string")
41
+ return queued.title;
42
+ const retry = db
43
+ .prepare(`SELECT p.title FROM narration_retries r JOIN projects p ON p.id=r.project_id
44
+ WHERE r.state='pending' LIMIT 1`)
45
+ .get();
46
+ if (typeof retry?.title === "string")
47
+ return retry.title;
48
+ const schedule = db
49
+ .prepare(`SELECT name FROM schedules WHERE deleted_at IS NULL AND (
50
+ (status='active' AND next_run_at IS NOT NULL AND next_run_at <= ?)
51
+ OR (topics_generating_at IS NOT NULL
52
+ AND (topics_generated_at IS NULL OR topics_generated_at < topics_generating_at)
53
+ AND (topics_failed_at IS NULL OR topics_failed_at < topics_generating_at))
54
+ ) LIMIT 1`)
55
+ .get(new Date(now + scheduledSoonMs).toISOString());
56
+ if (typeof schedule?.name === "string")
57
+ return schedule.name;
58
+ return undefined;
59
+ }