@gentbajko/slopify 2.3.1 → 2.4.0

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 (90) hide show
  1. package/dist/adapter-registry.js +1 -1
  2. package/dist/adapters/image/fal.js +77 -6
  3. package/dist/adapters/image/replicate.js +41 -2
  4. package/dist/adapters/image/video.js +28 -0
  5. package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
  6. package/dist/assets/fonts/SOURCE.txt +6 -0
  7. package/dist/assets/models.yaml +82 -1
  8. package/dist/catalog/schema.js +10 -0
  9. package/dist/catalog/store.js +2 -2
  10. package/dist/catalog/validate.js +32 -4
  11. package/dist/edge/docker-launch.js +4 -0
  12. package/dist/edge/docker-projects/engine.js +57 -1
  13. package/dist/edge/docker-projects/install.js +10 -2
  14. package/dist/edge/docker-projects/prune.js +66 -0
  15. package/dist/edge/http/providers.js +8 -0
  16. package/dist/edge/http/timeouts.js +38 -0
  17. package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
  18. package/dist/kernel/runner/attempt.js +4 -1
  19. package/dist/kernel/runner/providers.js +20 -0
  20. package/dist/main.js +3 -0
  21. package/dist/slices/admission/rules.js +44 -2
  22. package/dist/slices/admission/schema.js +31 -0
  23. package/dist/slices/estimate/index.js +57 -2
  24. package/dist/slices/fonts/catalog.js +23 -0
  25. package/dist/slices/fonts/index.js +1 -1
  26. package/dist/slices/library/model.js +8 -1
  27. package/dist/slices/library/slots.js +6 -1
  28. package/dist/slices/play-drafts/convert.js +37 -1
  29. package/dist/slices/play-drafts/readiness.js +7 -3
  30. package/dist/slices/play-drafts/schema.js +24 -1
  31. package/dist/slices/project-templates/from-project.js +22 -0
  32. package/dist/slices/project-templates/setup.js +2 -0
  33. package/dist/slices/rebuild/preview-details.js +29 -1
  34. package/dist/slices/rebuild/preview-plan.js +2 -2
  35. package/dist/slices/rebuild/recipe-build.js +6 -2
  36. package/dist/slices/rebuild/recipe-edit.js +178 -0
  37. package/dist/slices/rebuild/recipe-exports.js +9 -4
  38. package/dist/slices/rebuild/recipe-input-schema.js +1 -0
  39. package/dist/slices/rebuild/recipe-legacy.js +9 -0
  40. package/dist/slices/rebuild/recipe-model.js +7 -1
  41. package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
  42. package/dist/slices/rebuild/recipe-save.js +25 -1
  43. package/dist/slices/rebuild/recipe-shorts.js +179 -0
  44. package/dist/slices/rebuild/recipe-validation.js +19 -2
  45. package/dist/slices/rebuild/recipe-visual.js +10 -1
  46. package/dist/slices/rebuild/recipe-work.js +83 -1
  47. package/dist/slices/rebuild/runtime-animate.js +110 -0
  48. package/dist/slices/rebuild/runtime-export-edit.js +119 -0
  49. package/dist/slices/rebuild/runtime-export.js +13 -1
  50. package/dist/slices/rebuild/runtime-materialize.js +48 -0
  51. package/dist/slices/rebuild/runtime-piece-label.js +15 -0
  52. package/dist/slices/rebuild/runtime-publication.js +25 -4
  53. package/dist/slices/rebuild/runtime-run.js +11 -5
  54. package/dist/slices/rebuild/runtime-shorts.js +330 -0
  55. package/dist/slices/rebuild/runtime-store.js +7 -3
  56. package/dist/slices/rebuild/transition-repo.js +39 -0
  57. package/dist/slices/revisions/mutation-assets.js +18 -0
  58. package/dist/slices/revisions/mutation-prepare.js +9 -7
  59. package/dist/slices/revisions/mutations.js +2 -1
  60. package/dist/slices/revisions/publish.js +20 -2
  61. package/dist/slices/revisions/schema.js +11 -0
  62. package/dist/slices/shorts/captions.js +272 -0
  63. package/dist/slices/shorts/clips.js +89 -0
  64. package/dist/slices/shorts/model.js +186 -0
  65. package/dist/slices/shorts/pick.js +232 -0
  66. package/dist/slices/shorts/prompts.js +68 -0
  67. package/dist/slices/shorts/render.js +163 -0
  68. package/dist/slices/storage/asset-name.js +9 -2
  69. package/dist/slices/storage/layout.js +8 -0
  70. package/dist/slices/storage/model.js +6 -0
  71. package/dist/slices/storage/schema.js +3 -0
  72. package/dist/slices/telemetry/model.js +3 -0
  73. package/dist/slices/video/cards.js +72 -0
  74. package/dist/slices/video/chapters.js +89 -0
  75. package/dist/slices/video/cuts.js +141 -0
  76. package/dist/slices/video/edit-list.js +36 -6
  77. package/dist/slices/video/edit-settings.js +191 -0
  78. package/dist/slices/video/ffmpeg.js +155 -29
  79. package/dist/slices/video/look.js +164 -0
  80. package/dist/slices/video/plan.js +68 -11
  81. package/dist/slices/video/slideshow.js +22 -6
  82. package/dist/slices/video/transitions.js +51 -0
  83. package/dist/slices/youtube/transcript.js +54 -0
  84. package/dist/web/assets/index-BGgYMm2u.css +1 -0
  85. package/dist/web/assets/index-BVVgtntL.js +171 -0
  86. package/dist/web/assets/{pdf-DR97Byps.js → pdf-CAJNT254.js} +1 -1
  87. package/dist/web/index.html +2 -2
  88. package/package.json +1 -1
  89. package/dist/web/assets/index-DfXmBzCk.js +0 -168
  90. package/dist/web/assets/index-Y3RhfTEh.css +0 -1
@@ -1,15 +1,19 @@
1
- import { narrationPreparationFields, usesNarrationPreparation, usesYoutubeDescription, youtubeDescriptionFields, } from "../admission/rules.js";
1
+ import { narrationPreparationFields, shortsFields, usesNarrationPreparation, usesShorts, usesYoutubeDescription, videoEditFields, youtubeDescriptionFields, } from "../admission/rules.js";
2
2
  import { detectSlots, render } from "../admission/substitute.js";
3
+ import { usesAnimation } from "../video/edit-settings.js";
3
4
  export function validateRecipeInputs(config, content) {
4
5
  const fields = [
5
6
  ...narrationPreparationFields(config),
6
7
  ...youtubeDescriptionFields(config),
8
+ ...shortsFields(config),
9
+ ...videoEditFields(config),
7
10
  ];
8
11
  const llm = (config.sources.research === "generate" && config.sources.article !== "provide") ||
9
12
  (config.sources.article === "generate" && !content.articleEdited) ||
10
13
  config.sources.thumbnail === "prompt_by_llm" ||
11
14
  usesNarrationPreparation(config) ||
12
15
  usesYoutubeDescription(config) ||
16
+ usesShorts(config) ||
13
17
  (config.sources.audio === "generate" &&
14
18
  (config.intro?.mode === "llm" || config.outro?.mode === "llm"));
15
19
  if (llm && (!config.llm?.provider.trim() || !config.llm.model.trim()))
@@ -24,7 +28,9 @@ export function validateRecipeInputs(config, content) {
24
28
  content.imageOrder.some((key) => content.imageDefinitions[key]?.source === "generate");
25
29
  if ((generatedImages ||
26
30
  config.sources.thumbnail === "from_prompt" ||
27
- config.sources.thumbnail === "prompt_by_llm") &&
31
+ config.sources.thumbnail === "prompt_by_llm" ||
32
+ usesShorts(config) ||
33
+ usesAnimation(config)) &&
28
34
  (!config.images?.provider.trim() || !config.images.model.trim()))
29
35
  fields.push({ field: "images", message: "Pick an image provider and model." });
30
36
  if (content.imageOrder.length > 60)
@@ -56,6 +62,17 @@ export function validateRecipeInputs(config, content) {
56
62
  raw: content.promptTemplates.description,
57
63
  literal: config.rendered.description,
58
64
  });
65
+ // Likewise the Shorts step's two prompts, each only when one is picked.
66
+ for (const [key, name] of [
67
+ ["shorts", config.shorts?.prompt],
68
+ ["shortsImage", config.shorts?.imagePrompt],
69
+ ])
70
+ if (usesShorts(config) && name?.trim() && content.promptTemplates[key] != null)
71
+ prompts.push({
72
+ field: `rendered.${key}`,
73
+ raw: content.promptTemplates[key] ?? null,
74
+ literal: config.rendered[key],
75
+ });
59
76
  if ((config.sources.article === "generate" && !content.articleEdited) ||
60
77
  (config.sources.research === "generate" && config.sources.article !== "provide"))
61
78
  prompts.push({
@@ -1,7 +1,9 @@
1
1
  import { render } from "../admission/substitute.js";
2
2
  import { recipe, resourceIdentity, } from "./recipe-model.js";
3
3
  import { matchingText, renderedPrompt } from "./recipe-text.js";
4
- export function visualRecipes(config, content, audioFingerprint, captionFingerprint) {
4
+ export function visualRecipes(config, content, audioFingerprint, captionFingerprint,
5
+ // What the Video stage's edit settings add (`recipe-edit.ts`), from the images planned here.
6
+ edit) {
5
7
  const recipes = [];
6
8
  const imageKeys = config.sources.images === "off" ? [] : content.imageOrder;
7
9
  for (const key of imageKeys) {
@@ -24,6 +26,7 @@ export function visualRecipes(config, content, audioFingerprint, captionFingerpr
24
26
  }, [], { unresolved: image.source === "provide" ? image.assetId === null : !prompt?.trim() }));
25
27
  }
26
28
  if (config.sources.video !== "off") {
29
+ const edited = edit?.([...recipes]) ?? { recipes: [], values: [], dependsOn: [] };
27
30
  const audioKeys = config.sources.audio === "off"
28
31
  ? []
29
32
  : [
@@ -55,12 +58,18 @@ export function visualRecipes(config, content, audioFingerprint, captionFingerpr
55
58
  ...(config.motionStyle === "zoom" ? [] : [config.motionStyle]),
56
59
  // v1 split the timeline evenly across the images; v2 cycles them.
57
60
  "slideshow-zoom-v2",
61
+ // Only what the edit settings change; nothing at all for today's slideshow, so
62
+ // its fingerprint is the one it always had.
63
+ ...(edited.values.length === 0 ? [] : [["video-edit", ...edited.values]]),
58
64
  ],
59
65
  }, [
60
66
  ...recipes.map((value) => value.key),
61
67
  ...audioKeys,
62
68
  ...(config.subtitles?.mode === "burn-in" ? ["subtitles:files"] : []),
69
+ ...edited.dependsOn,
63
70
  ], { unresolved: imageKeys.length === 0 }));
71
+ // After the export, so the images' recipes above stay the only ones its first values list.
72
+ recipes.splice(recipes.length - 1, 0, ...edited.recipes);
64
73
  }
65
74
  return recipes;
66
75
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { estimateRequests } from "../estimate/index.js";
3
+ import { defaultShortsPrompt, shortsImageUpperBound } from "../shorts/model.js";
3
4
  import { planDependencies } from "./dependencies.js";
4
5
  import { matchingNarrationPiece } from "./narration-reuse.js";
5
6
  import { buildRecipes } from "./recipe-build.js";
@@ -63,7 +64,7 @@ export function planRevisionWork(revision, manifest, catalogue, availableAssetId
63
64
  null,
64
65
  after: row.fingerprint,
65
66
  }));
66
- const priced = work.map((row) => priceRecipe(row, recipes.find((one) => one.key === row.key)));
67
+ const priced = work.flatMap((row) => priceRecipes(row, recipes.find((one) => one.key === row.key)));
67
68
  return {
68
69
  recipes,
69
70
  work,
@@ -180,6 +181,74 @@ function retainedFor(row, logical, manifest, available) {
180
181
  // ceiling: the timed transcript of a 20-minute narration, about 3,000 words. The real one
181
182
  // is only built once the word timing lands.
182
183
  const youtubeTranscriptEstimate = 20000;
184
+ // The numbered transcript the shorts are picked from is the same text with a number and a
185
+ // span before every sentence rather than a time before every passage.
186
+ const shortsTranscriptEstimate = 24000;
187
+ // What a step is charged as. Most steps are one request; what stands in for the shorts
188
+ // until they are picked is every clip's image prompts and, at the longest length allowed,
189
+ // every image, which is the most the step can cost.
190
+ export function priceRecipes(work, value) {
191
+ const input = value?.input;
192
+ const unfolds = value?.unfoldsImages;
193
+ // A short's image prompts once the clips are picked: the call, and the images it leads to,
194
+ // as many as the clip's length asks for.
195
+ if (work.disposition === "generate" && unfolds !== undefined)
196
+ return [
197
+ priceRecipe(work, value),
198
+ ...Array.from({ length: unfolds.count }, () => ({
199
+ kind: "image",
200
+ stage: work.key,
201
+ provider: unfolds.provider,
202
+ model: unfolds.model,
203
+ detail: `${String(unfolds.count)} vertical ${unfolds.count === 1 ? "image" : "images"} for this short, one per stretch of the seconds each image is held.`,
204
+ })),
205
+ ];
206
+ // What stands in for the chapter openers' clips until the chapters are known: one clip for
207
+ // the opening and for each chapter the article's headings promise, the most it can cost.
208
+ if (work.disposition === "generate" &&
209
+ input?.kind === "deferred" &&
210
+ input.operation === "animate" &&
211
+ Array.isArray(input.template)) {
212
+ const [, , provider, model, upper] = input.template;
213
+ const clips = typeof upper === "number" ? upper : 0;
214
+ return Array.from({ length: clips }, () => ({
215
+ kind: "image",
216
+ stage: work.key,
217
+ provider: typeof provider === "string" ? provider : "",
218
+ model: typeof model === "string" ? model : "",
219
+ detail: `Up to ${String(clips)} animated clips, one per chapter opening; the chapters are found when the word timing lands, so fewer may be made.`,
220
+ }));
221
+ }
222
+ if (work.disposition !== "generate" ||
223
+ input?.kind !== "deferred" ||
224
+ input.operation !== "shorts" ||
225
+ !Array.isArray(input.template))
226
+ return [priceRecipe(work, value)];
227
+ const [, style, provider, model, imageSeconds, count, maxSeconds, llmProvider, llmModel] = input.template;
228
+ const clips = typeof count === "number" ? count : 0;
229
+ const images = typeof imageSeconds === "number" && typeof maxSeconds === "number"
230
+ ? shortsImageUpperBound({ count: clips, maxSeconds }, imageSeconds)
231
+ : 0;
232
+ const detail = `Up to ${String(images)} vertical images: ${String(clips)} shorts at the longest length allowed. The clips are picked when the step runs, so shorter ones use fewer.`;
233
+ return [
234
+ ...Array.from({ length: clips }, () => ({
235
+ kind: "llm",
236
+ stage: work.key,
237
+ provider: typeof llmProvider === "string" ? llmProvider : "",
238
+ model: typeof llmModel === "string" ? llmModel : "",
239
+ inputCharacters: (typeof style === "string" ? style.length : 0) + 3000,
240
+ outputCharacters: 2400,
241
+ detail: "One LLM call per short writes its image prompts; its length is estimated.",
242
+ })),
243
+ ...Array.from({ length: images }, () => ({
244
+ kind: "image",
245
+ stage: work.key,
246
+ provider: typeof provider === "string" ? provider : "",
247
+ model: typeof model === "string" ? model : "",
248
+ detail,
249
+ })),
250
+ ];
251
+ }
183
252
  export function priceRecipe(work, value) {
184
253
  if (value === undefined || work.disposition !== "generate")
185
254
  return {
@@ -217,6 +286,19 @@ export function priceRecipe(work, value) {
217
286
  };
218
287
  if (input.kind === "image")
219
288
  return { kind: "image", stage: work.key, provider: input.provider, model: input.model };
289
+ if (input.kind === "local" && input.operation === "shorts-pick-v1") {
290
+ const [, provider, model, , prompt, count] = Array.isArray(input.values) ? input.values : [];
291
+ return {
292
+ kind: "llm",
293
+ stage: work.key,
294
+ provider: typeof provider === "string" ? provider : "",
295
+ model: typeof model === "string" ? model : "",
296
+ inputCharacters: (typeof prompt === "string" && prompt !== "" ? prompt.length : defaultShortsPrompt.length) +
297
+ shortsTranscriptEstimate,
298
+ outputCharacters: 400 * (typeof count === "number" ? count : 1),
299
+ detail: "The numbered transcript is built when the step runs; its length is estimated. One more call is made when the first answer breaks the rules. Retries are excluded.",
300
+ };
301
+ }
220
302
  if (input.kind === "local" && input.operation === "youtube-description-v1") {
221
303
  const [, provider, model, , prompt] = Array.isArray(input.values) ? input.values : [];
222
304
  return {
@@ -0,0 +1,110 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
4
+ import { outputPath, projectDir } from "../storage/layout.js";
5
+ import { runFfmpeg } from "../video/ffmpeg.js";
6
+ import { executionView } from "./runtime-plan.js";
7
+ import { preparedResult, publishResult } from "./runtime-publication.js";
8
+ // Animate images: one still of the slideshow sent to the image provider's image-to-video
9
+ // model, and the clip it makes saved beside the image. The still goes up as a small JPEG, so
10
+ // the request stays well under what either provider takes inline.
11
+ //
12
+ // A clip that cannot be made is not worth failing the video for: after the provider's own
13
+ // retries the step finishes without a clip, saying why, and the render shows the still in
14
+ // its place and puts that sentence on the video (`runtime-export.ts`).
15
+ // ceiling: 1280 px on the long side is more than the 720p the catalogue's models are asked
16
+ // for, and keeps a JPEG to a few hundred KB.
17
+ const sentWidth = 1280;
18
+ export async function executeAnimateRecipe(deps, context, providers, piece) {
19
+ if (deps.db
20
+ .prepare("SELECT state FROM revision_work_pieces WHERE id=? AND work_id=?")
21
+ .get(piece.id, context.work.workId)?.state === "done")
22
+ return "done";
23
+ const input = piece.input;
24
+ if (input.kind !== "image" || input.animate === undefined)
25
+ throw new Error("Slopify hit an internal error (an animated image was set up wrongly). Use Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
26
+ const imageKey = piece.key.slice("animate:".length);
27
+ const view = executionView(deps, context.work.projectId, context.work.revisionId);
28
+ const index = (view?.revision.content.imageOrder.indexOf(imageKey) ?? -1) + 1;
29
+ const still = view?.outputs.find((row) => row.workKey === `image:${imageKey}` && row.selected && row.available && row.state === "ready");
30
+ const fallback = async (reason) => {
31
+ deps.log.write("warn", "video.animate.fallback", { detail: reason });
32
+ await publishResult(deps, context, piece, [], {
33
+ fallback: `Image ${String(index)} is shown as a still: ${reason}`,
34
+ });
35
+ return "done";
36
+ };
37
+ if (still === undefined)
38
+ return fallback("its image wasn't ready to animate. Use Re-run section on Video after the image is made.");
39
+ const animate = providers.forPiece(piece.id).animate;
40
+ if (animate === undefined)
41
+ throw new Error("Slopify hit an internal error (animating images isn't wired up in this build). Use Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
42
+ if (!context.maySubmit(piece.id))
43
+ return "held";
44
+ let made;
45
+ try {
46
+ made = await animate({
47
+ provider: input.provider,
48
+ model: input.model,
49
+ prompt: input.prompt,
50
+ image: await sendable(deps, context, outputPath(deps.paths, context.work.projectId, still.output.path)),
51
+ aspect: input.aspect,
52
+ seconds: input.animate.seconds,
53
+ });
54
+ }
55
+ catch (error) {
56
+ if (context.signal.aborted)
57
+ throw error;
58
+ return fallback(`${error instanceof Error ? error.message : String(error)} To try again, use Re-run section on Video.`);
59
+ }
60
+ if (!made.ok)
61
+ return "held";
62
+ const asset = writeAsset(deps, context.work.projectId, "animated.mp4", made.value.bytes);
63
+ try {
64
+ await publishResult(deps, context, piece, [
65
+ preparedResult(deps, context, piece, "animated_image", asset, null, {
66
+ index,
67
+ prompt: input.prompt,
68
+ provider: input.provider,
69
+ model: input.model,
70
+ }),
71
+ ], { prompt: input.prompt }, asset);
72
+ deps.count?.("stage.completed", {
73
+ stage: context.work.kind,
74
+ provider: input.provider,
75
+ model: input.model,
76
+ });
77
+ }
78
+ finally {
79
+ discardPreparedAssets(deps, [asset]);
80
+ }
81
+ return "done";
82
+ }
83
+ // The still as a JPEG no wider than 1280 px, or as it is when there is no ffmpeg to shrink it.
84
+ async function sendable(deps, context, path) {
85
+ const original = () => {
86
+ const bytes = new Uint8Array(readFileSync(path));
87
+ return { bytes, mime: bytes[0] === 0xff ? "image/jpeg" : "image/png" };
88
+ };
89
+ if (deps.ffmpeg === undefined)
90
+ return original();
91
+ const scratch = mkdtempSync(join(projectDir(deps.paths, context.work.projectId), "animate-"));
92
+ try {
93
+ const output = join(scratch, "still.jpg");
94
+ await runFfmpeg({
95
+ bin: deps.ffmpeg,
96
+ args: [
97
+ ...["-hide_banner", "-nostdin", "-loglevel", "error", "-y", "-i", path],
98
+ ...["-vf", `scale='min(${String(sentWidth)},iw)':-2`, "-frames:v", "1", "-q:v", "3"],
99
+ output,
100
+ ],
101
+ signal: context.signal,
102
+ log: deps.log,
103
+ onProgress: () => undefined,
104
+ });
105
+ return { bytes: new Uint8Array(readFileSync(output)), mime: "image/jpeg" };
106
+ }
107
+ finally {
108
+ rmSync(scratch, { recursive: true, force: true });
109
+ }
110
+ }
@@ -0,0 +1,119 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { extname } from "node:path";
3
+ import { z } from "zod";
4
+ import { usesYoutubeDescription } from "../admission/rules.js";
5
+ import { resolveFont } from "../fonts/index.js";
6
+ import { outputPath } from "../storage/layout.js";
7
+ import { headingChapters } from "../video/chapters.js";
8
+ import { sentenceCutPoints } from "../video/cuts.js";
9
+ import { usesChapterCards, usesNarrationCuts, videoEditOf, } from "../video/edit-settings.js";
10
+ import { probeDurationMs } from "../video/ffmpeg.js";
11
+ import { wordsSchema } from "./runtime-subtitles.js";
12
+ // The containers an uploaded clip may come in; anything else in the images is a still.
13
+ const clipExtensions = new Set([".mp4", ".mov", ".m4v", ".webm", ".mkv"]);
14
+ export function isClipPath(path) {
15
+ return clipExtensions.has(extname(path).toLowerCase());
16
+ }
17
+ export async function exportEdit(deps, context, view, images) {
18
+ const config = view.revision.config;
19
+ const order = view.revision.content.imageOrder;
20
+ const ready = (key) => view.outputs.find((row) => row.workKey === key && row.selected && row.available && row.state === "ready");
21
+ // Clips: an animated image's clip first, then an uploaded clip in the image's own place.
22
+ const clips = [];
23
+ for (const [index, key] of order.entries()) {
24
+ const animated = ready(`animate:${key}`);
25
+ const path = animated?.output.role === "animated_image"
26
+ ? outputPath(deps.paths, context.work.projectId, animated.output.path)
27
+ : images[index] !== undefined && isClipPath(images[index] ?? "")
28
+ ? images[index]
29
+ : undefined;
30
+ clips.push(path === undefined
31
+ ? undefined
32
+ : { kind: "video", path, seconds: await clipSeconds(deps, context, path, index + 1) });
33
+ }
34
+ const warnings = view.pieces.flatMap((row) => {
35
+ if (!row.key.startsWith("animate:") || !row.selected || row.piece.state !== "done")
36
+ return [];
37
+ const parsed = z
38
+ .object({ fallback: z.string() })
39
+ .safeParse(JSON.parse(row.piece.payload ?? "null"));
40
+ return parsed.success ? [parsed.data.fallback] : [];
41
+ });
42
+ if (config.videoEdit === undefined)
43
+ return {
44
+ edit: clips.some((clip) => clip !== undefined) ? { clips } : undefined,
45
+ warnings,
46
+ settings: undefined,
47
+ };
48
+ const settings = videoEditOf(config);
49
+ const cuts = usesNarrationCuts(config);
50
+ const cards = usesChapterCards(config);
51
+ const words = cuts || cards ? timingWords(deps, context, view) : [];
52
+ const chapters = cuts || cards ? chaptersOf(view, config, words) : [];
53
+ const font = cards
54
+ ? await resolveFont(deps.paths, config.subtitles?.fontId ?? "default")
55
+ : undefined;
56
+ return {
57
+ edit: {
58
+ clips,
59
+ ...(cuts
60
+ ? {
61
+ narration: {
62
+ cutPoints: sentenceCutPoints(words),
63
+ chapterStarts: chapters.map((chapter) => chapter.start),
64
+ },
65
+ }
66
+ : {}),
67
+ ...(settings.transition === "cut"
68
+ ? {}
69
+ : { transition: { kind: settings.transition, seconds: settings.transitionSeconds } }),
70
+ look: {
71
+ vignette: settings.vignette,
72
+ grain: settings.grain,
73
+ grade: settings.grade,
74
+ atmosphere: settings.atmosphere,
75
+ },
76
+ ...(font === undefined
77
+ ? {}
78
+ : { cards: { chapters, font: { path: font.path, name: font.assName } } }),
79
+ },
80
+ warnings,
81
+ settings,
82
+ };
83
+ }
84
+ function timingWords(deps, context, view) {
85
+ const timing = view.outputs.find((row) => row.selected &&
86
+ row.available &&
87
+ row.state === "ready" &&
88
+ row.workKey === "subtitles:timing" &&
89
+ row.output.role === "subtitle_words");
90
+ if (timing === undefined)
91
+ throw new Error("The narration's word timing, which the video's cuts and chapter cards follow, is missing. Use Re-run section on Video, then Retry stage.");
92
+ return wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, context.work.projectId, timing.output.path), "utf8"))).words;
93
+ }
94
+ // The YouTube description's chapters when that step runs, the article's headings otherwise.
95
+ function chaptersOf(view, config, words) {
96
+ if (usesYoutubeDescription(config)) {
97
+ const row = view.pieces.find((one) => one.key === "youtube:description" && one.selected && one.piece.state === "done");
98
+ const parsed = z
99
+ .object({ chapters: z.array(z.object({ start: z.number(), title: z.string() })) })
100
+ .safeParse(JSON.parse(row?.piece.payload ?? "null"));
101
+ if (parsed.success)
102
+ return parsed.data.chapters;
103
+ }
104
+ return headingChapters(view.articleMarkdown ?? "", words);
105
+ }
106
+ async function clipSeconds(deps, context, path, place) {
107
+ let ms;
108
+ try {
109
+ ms = await probeDurationMs(deps.ffmpeg, path, context.signal, deps.log);
110
+ }
111
+ catch (error) {
112
+ if (context.signal.aborted)
113
+ throw error;
114
+ ms = 0;
115
+ }
116
+ if (!(ms > 0))
117
+ throw new Error(`Image ${String(place)} is a video clip Slopify can't read, so the video can't show it. Replace it with another clip or an image in Edit project → Images, then Retry stage.`);
118
+ return ms / 1000;
119
+ }
@@ -7,6 +7,7 @@ import { withPaths } from "../video/edit-list.js";
7
7
  import { runFfmpeg } from "../video/ffmpeg.js";
8
8
  import { planRender } from "../video/plan.js";
9
9
  import { renderSlideshow } from "../video/slideshow.js";
10
+ import { exportEdit } from "./runtime-export-edit.js";
10
11
  import { exportSnapshot, retainedOutput, revisionAudio, } from "./runtime-export-inputs.js";
11
12
  import { preparedResult, preparedText, publishResult } from "./runtime-publication.js";
12
13
  export async function executeExportRecipe(deps, context, piece) {
@@ -53,6 +54,7 @@ export async function executeExportRecipe(deps, context, piece) {
53
54
  throw new Error("The video export can't find one of the slideshow images. Use Re-run section on Images (or regenerate that image in Edit project → Images), then Retry stage.");
54
55
  return outputPath(deps.paths, context.work.projectId, row.output.path);
55
56
  });
57
+ const edited = wav ? undefined : await exportEdit(deps, context, view, images);
56
58
  const plan = wav
57
59
  ? undefined
58
60
  : planRender({
@@ -67,6 +69,7 @@ export async function executeExportRecipe(deps, context, piece) {
67
69
  outro: segment("outro"),
68
70
  images,
69
71
  output: pending.absolutePath,
72
+ edit: edited?.edit,
70
73
  });
71
74
  const totalSeconds = plan?.totalSeconds ?? audio.reduce((sum, row) => sum + row.seconds, 0);
72
75
  const projectDirectory = projectDir(deps.paths, context.work.projectId);
@@ -90,6 +93,10 @@ export async function executeExportRecipe(deps, context, piece) {
90
93
  output: pending.path,
91
94
  editList: withPaths(plan.editList, relativePath),
92
95
  subtitles: config.subtitles,
96
+ ...(edited?.settings === undefined ? {} : { videoEdit: edited.settings }),
97
+ ...(edited === undefined || edited.warnings.length === 0
98
+ ? {}
99
+ : { warnings: edited.warnings }),
93
100
  };
94
101
  if (!context.maySubmit(piece.id))
95
102
  return "held";
@@ -122,7 +129,12 @@ export async function executeExportRecipe(deps, context, piece) {
122
129
  });
123
130
  context.signal.throwIfAborted();
124
131
  const asset = sealAsset(deps, pending);
125
- prepared.push(preparedResult(deps, context, piece, wav ? "audio_export" : "video", asset, Math.round(totalSeconds * 1000), { subtitlesMode: mode }));
132
+ prepared.push(preparedResult(deps, context, piece, wav ? "audio_export" : "video", asset, Math.round(totalSeconds * 1000), {
133
+ subtitlesMode: mode,
134
+ ...(edited === undefined || edited.warnings.length === 0
135
+ ? {}
136
+ : { warnings: edited.warnings }),
137
+ }));
126
138
  prepared.push(preparedText(deps, context, piece, "render_params", "render.json", JSON.stringify(record)));
127
139
  prepared.push(...captions.map((row) => retainedOutput(deps, view, row)));
128
140
  await publishResult(deps, context, piece, prepared, { totalSeconds }, asset);
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { transact } from "../../kernel/db/tx.js";
3
3
  import { currentRevisionId } from "../revisions/repo.js";
4
4
  import { getRevisionView } from "../revisions/view.js";
5
+ import { recipeInputSchema } from "./recipe-input-schema.js";
5
6
  import { insertInvocation } from "./runtime-admission.js";
6
7
  import { bindNarrationReuse, narrationOrdinal } from "./runtime-narration-reuse.js";
7
8
  import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
@@ -56,6 +57,14 @@ export function materializeAdmittedWork(deps, projectId) {
56
57
  continue;
57
58
  if (plan.work.find((row) => row.key === recipe.key)?.disposition === "review")
58
59
  continue;
60
+ // A step waiting on its inputs changes fingerprint each time one lands: every
61
+ // narration chunk moves the joined audio, the timing, the export and the YouTube
62
+ // description after it. The waiting row is the same request with newer inputs, so it
63
+ // takes them in place; minting a row per landing left one never-run row per chunk.
64
+ if (own !== undefined &&
65
+ plan.work.find((row) => row.key === recipe.key)?.disposition !== "reuse" &&
66
+ refreshWaiting(deps, head, own, recipe))
67
+ continue;
59
68
  if (own !== undefined) {
60
69
  deps.db
61
70
  .prepare("UPDATE revision_work SET state='done' WHERE id=? AND state='pending'")
@@ -89,6 +98,34 @@ export function materializeAdmittedWork(deps, projectId) {
89
98
  }
90
99
  });
91
100
  }
101
+ // Only a row nothing has touched: still pending, one unsent piece, no attempt. Anything
102
+ // further along keeps the old path, which retires it and admits a fresh row.
103
+ function refreshWaiting(deps, head, own, recipe) {
104
+ const pieces = workPieces(deps.db, own.work_id);
105
+ const piece = pieces[0];
106
+ if (pieces.length !== 1 ||
107
+ piece === undefined ||
108
+ piece.key !== recipe.key ||
109
+ piece.state !== "pending" ||
110
+ piece.submittedAt !== null ||
111
+ piece.continuation !== null)
112
+ return false;
113
+ const waiting = deps.db
114
+ .prepare("SELECT 1 FROM revision_work w WHERE w.id=? AND w.state='pending' AND NOT EXISTS(SELECT 1 FROM attempts a WHERE a.work_id=w.id)")
115
+ .get(own.work_id);
116
+ if (waiting === undefined)
117
+ return false;
118
+ deps.db
119
+ .prepare("UPDATE revision_work SET fingerprint=? WHERE id=?")
120
+ .run(recipe.fingerprint, own.work_id);
121
+ deps.db
122
+ .prepare("UPDATE revision_work_pieces SET fingerprint=?,request_fingerprint=?,logical_fingerprint=?,input_json=? WHERE id=?")
123
+ .run(recipe.fingerprint, recipe.requestFingerprint, recipe.logicalFingerprint, JSON.stringify(recipeInputSchema.parse(recipe.input)), piece.id);
124
+ deps.db
125
+ .prepare("UPDATE revision_work_reservations SET fingerprint=? WHERE revision_id=? AND work_id=? AND work_key=?")
126
+ .run(recipe.fingerprint, head, own.work_id, recipe.key);
127
+ return true;
128
+ }
92
129
  function desiredAnchor(deps, projectId, head, row) {
93
130
  const view = getRevisionView(deps, projectId, head);
94
131
  return (view?.revision.fingerprints[row.logical_key ?? row.work_key] ===
@@ -103,5 +140,16 @@ function futureKey(recipe) {
103
140
  return "research:planner";
104
141
  if (recipe.key === "article:continuation")
105
142
  return "article:body";
143
+ // A short's images and render unfold from its image prompts, admitted with them: under the
144
+ // pick on a first run, or on their own when one short is made again and the pick is kept.
145
+ // The prompts unfold from the pick's answer.
146
+ const clip = /^(shorts:\d+):(?:image:\d+|render)$/.exec(recipe.key);
147
+ if (clip?.[1] !== undefined)
148
+ return `${clip[1]}:prompts`;
149
+ if (recipe.key.startsWith("shorts:"))
150
+ return "shorts:pick";
151
+ // The chapter openers' clips unfold from the deferred request that stood in for them.
152
+ if (recipe.key.startsWith("animate:"))
153
+ return "animate:future";
106
154
  return recipe.key;
107
155
  }
@@ -12,6 +12,17 @@ export function pieceLabel(deps, context, piece) {
12
12
  return "Document";
13
13
  if (key === "youtube:description")
14
14
  return "YouTube description";
15
+ if (key === "shorts:pick" || key === "shorts:future")
16
+ return "Shorts";
17
+ if (key === "animate:future")
18
+ return "Animated images";
19
+ const short = /^shorts:(\d+):(prompts|image:(\d+)|render)$/.exec(key);
20
+ if (short !== null)
21
+ return short[2] === "prompts"
22
+ ? `Short ${short[1]} image prompts`
23
+ : short[2] === "render"
24
+ ? `Short ${short[1]}`
25
+ : `Short ${short[1]} image ${short[3]}`;
15
26
  if (key === "entry:intro:text")
16
27
  return "Intro text";
17
28
  if (key === "entry:outro:text")
@@ -28,6 +39,10 @@ export function pieceLabel(deps, context, piece) {
28
39
  const at = view()?.revision.content.imageOrder.indexOf(key.slice("image:".length)) ?? -1;
29
40
  return at === -1 ? "An image" : `Image ${String(at + 1)}`;
30
41
  }
42
+ if (key.startsWith("animate:")) {
43
+ const at = view()?.revision.content.imageOrder.indexOf(key.slice("animate:".length)) ?? -1;
44
+ return at === -1 ? "An animated image" : `Animated image ${String(at + 1)}`;
45
+ }
31
46
  const narration = /^(?:narration:prepare:(?:intro|body|outro):)?audio:(intro|body|outro)(?::(.+))?$/.exec(key);
32
47
  if (narration === null)
33
48
  return undefined;
@@ -8,7 +8,13 @@ import { rebindPublishedNarration } from "./runtime-narration-publication.js";
8
8
  import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
9
9
  export function preparedResult(deps, context, piece, role, asset, durationMs = null, meta = {}) {
10
10
  return {
11
- slot: role === "image" ? piece.key : `${context.work.kind}:${role}`,
11
+ // A project holds many images, and one video and several images per short.
12
+ slot: role === "image" ||
13
+ role === "short_image" ||
14
+ role === "short_video" ||
15
+ role === "animated_image"
16
+ ? piece.key
17
+ : `${context.work.kind}:${role}`,
12
18
  workKey: piece.key,
13
19
  fingerprint: piece.logicalFingerprint ?? piece.fingerprint,
14
20
  asset,
@@ -46,7 +52,7 @@ export function preparedTexts(deps, context, piece, texts) {
46
52
  throw error;
47
53
  }
48
54
  }
49
- export async function publishResult(deps, context, piece, outputs, payload, asset = null) {
55
+ export async function publishResult(deps, context, piece, outputs, payload, asset = null, retired) {
50
56
  try {
51
57
  const record = {
52
58
  key: piece.key,
@@ -67,7 +73,7 @@ export async function publishResult(deps, context, piece, outputs, payload, asse
67
73
  },
68
74
  };
69
75
  const result = transact(deps.db, () => {
70
- const committed = commitRevisionOutputs(deps, { work: context.work, pieceId: piece.id, publicationId: piece.id }, outputs, [record]);
76
+ const committed = commitRevisionOutputs(deps, { work: context.work, pieceId: piece.id, publicationId: piece.id }, outputs, [record], retired);
71
77
  rebindPublishedNarration(deps, context.work, piece);
72
78
  deps.db
73
79
  .prepare("UPDATE revision_work_pieces SET state='done' WHERE id=? AND work_id=?")
@@ -93,7 +99,7 @@ function pieceKind(piece) {
93
99
  return "chunk";
94
100
  if (piece.input.kind === "provided" && /^audio:(intro|outro):\d+$/.test(piece.key))
95
101
  return "segment";
96
- if (piece.key.startsWith("image:"))
102
+ if (piece.key.startsWith("image:") || piece.key.startsWith("animate:"))
97
103
  return "image";
98
104
  if (piece.key.startsWith("research:chapter:"))
99
105
  return "chapter";
@@ -117,6 +123,21 @@ function pieceIndex(deps, context, piece) {
117
123
  return 1;
118
124
  if (piece.key === "entry:outro:text")
119
125
  return 2;
126
+ // The shorts' steps take places of their own, past every other step of the Video stage:
127
+ // the pick, then per short its prompts, its images and its render.
128
+ const short = /^shorts:(?:(\d+):(prompts|image:(\d+)|render)|pick)$/.exec(piece.key);
129
+ if (short !== null)
130
+ return short[1] === undefined
131
+ ? 10000
132
+ : 10000 +
133
+ Number(short[1]) * 1000 +
134
+ (short[2] === "prompts" ? 0 : short[2] === "render" ? 999 : Number(short[3]));
135
+ // Animated images take places past the shorts', one per image of the slideshow.
136
+ if (piece.key.startsWith("animate:")) {
137
+ const order = executionView(deps, context.work.projectId, context.work.revisionId)?.revision
138
+ .content.imageOrder;
139
+ return 900000 + (order?.indexOf(piece.key.slice("animate:".length)) ?? -1) + 1;
140
+ }
120
141
  const localOrder = [
121
142
  "audio:body:concat",
122
143
  "audio:intro",