@gentbajko/slopify 2.3.1 → 2.5.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 (146) hide show
  1. package/dist/adapter-registry.js +9 -2
  2. package/dist/adapters/host-cli/index.js +11 -0
  3. package/dist/adapters/host-cli/transport.js +6 -1
  4. package/dist/adapters/image/codex-output.js +59 -17
  5. package/dist/adapters/image/codex.js +92 -16
  6. package/dist/adapters/image/fal.js +99 -8
  7. package/dist/adapters/image/google.js +13 -1
  8. package/dist/adapters/image/openai.js +37 -15
  9. package/dist/adapters/image/reference.js +7 -0
  10. package/dist/adapters/image/replicate.js +48 -2
  11. package/dist/adapters/image/video.js +28 -0
  12. package/dist/adapters/llm/codex-models.js +2 -1
  13. package/dist/adapters/llm/codex-server-models.js +2 -1
  14. package/dist/adapters/llm/run-cli.js +14 -2
  15. package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
  16. package/dist/assets/fonts/SOURCE.txt +6 -0
  17. package/dist/assets/models.yaml +89 -1
  18. package/dist/catalog/registry.js +24 -3
  19. package/dist/catalog/runtime-models.js +1 -1
  20. package/dist/catalog/schema.js +20 -1
  21. package/dist/catalog/store.js +2 -2
  22. package/dist/catalog/validate.js +47 -4
  23. package/dist/edge/docker-launch.js +4 -0
  24. package/dist/edge/docker-projects/engine.js +57 -1
  25. package/dist/edge/docker-projects/install.js +10 -2
  26. package/dist/edge/docker-projects/prune.js +66 -0
  27. package/dist/edge/events/hub.js +11 -0
  28. package/dist/edge/http/app.js +2 -0
  29. package/dist/edge/http/backups.js +40 -0
  30. package/dist/edge/http/host-cli.js +17 -2
  31. package/dist/edge/http/problem.js +1 -0
  32. package/dist/edge/http/providers.js +8 -0
  33. package/dist/edge/http/schedules.js +1 -1
  34. package/dist/edge/http/settings.js +44 -0
  35. package/dist/edge/http/timeouts.js +38 -0
  36. package/dist/host-cli/runtime.js +3 -2
  37. package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
  38. package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
  39. package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
  40. package/dist/kernel/ports/host-cli.js +17 -2
  41. package/dist/kernel/ports/image.js +3 -1
  42. package/dist/kernel/ports/llm.js +3 -1
  43. package/dist/kernel/runner/attempt.js +5 -2
  44. package/dist/kernel/runner/providers.js +42 -2
  45. package/dist/main.js +55 -7
  46. package/dist/slices/admission/model.js +8 -0
  47. package/dist/slices/admission/rules.js +91 -2
  48. package/dist/slices/admission/schema.js +42 -1
  49. package/dist/slices/admission/start.js +53 -15
  50. package/dist/slices/backups/files.js +135 -0
  51. package/dist/slices/backups/folder.js +35 -0
  52. package/dist/slices/backups/model.js +65 -0
  53. package/dist/slices/backups/repo.js +28 -0
  54. package/dist/slices/backups/schedule.js +60 -0
  55. package/dist/slices/backups/service.js +311 -0
  56. package/dist/slices/batch/index.js +2 -0
  57. package/dist/slices/estimate/index.js +65 -2
  58. package/dist/slices/fonts/catalog.js +23 -0
  59. package/dist/slices/fonts/index.js +1 -1
  60. package/dist/slices/library/model.js +8 -1
  61. package/dist/slices/library/slots.js +13 -2
  62. package/dist/slices/library/snapshot.js +8 -2
  63. package/dist/slices/notifications/notifier.js +76 -0
  64. package/dist/slices/notifications/rules.js +81 -0
  65. package/dist/slices/notifications/send.js +40 -0
  66. package/dist/slices/notifications/settings.js +34 -0
  67. package/dist/slices/play-drafts/convert.js +72 -4
  68. package/dist/slices/play-drafts/readiness.js +7 -3
  69. package/dist/slices/play-drafts/repo.js +7 -1
  70. package/dist/slices/play-drafts/review-inputs.js +2 -0
  71. package/dist/slices/play-drafts/schema.js +44 -3
  72. package/dist/slices/play-drafts/service.js +6 -0
  73. package/dist/slices/play-drafts/uploads.js +2 -1
  74. package/dist/slices/project-templates/from-project.js +42 -0
  75. package/dist/slices/project-templates/setup.js +13 -0
  76. package/dist/slices/rebuild/preview-details.js +53 -3
  77. package/dist/slices/rebuild/preview-plan.js +2 -2
  78. package/dist/slices/rebuild/recipe-build.js +13 -3
  79. package/dist/slices/rebuild/recipe-edit.js +178 -0
  80. package/dist/slices/rebuild/recipe-exports.js +9 -4
  81. package/dist/slices/rebuild/recipe-input-schema.js +9 -1
  82. package/dist/slices/rebuild/recipe-legacy.js +12 -0
  83. package/dist/slices/rebuild/recipe-model.js +7 -1
  84. package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
  85. package/dist/slices/rebuild/recipe-reference.js +44 -0
  86. package/dist/slices/rebuild/recipe-save.js +25 -1
  87. package/dist/slices/rebuild/recipe-shorts.js +186 -0
  88. package/dist/slices/rebuild/recipe-validation.js +33 -2
  89. package/dist/slices/rebuild/recipe-visual.js +29 -8
  90. package/dist/slices/rebuild/recipe-work.js +83 -1
  91. package/dist/slices/rebuild/runtime-actions.js +9 -2
  92. package/dist/slices/rebuild/runtime-animate.js +110 -0
  93. package/dist/slices/rebuild/runtime-export-edit.js +119 -0
  94. package/dist/slices/rebuild/runtime-export.js +13 -1
  95. package/dist/slices/rebuild/runtime-image.js +50 -0
  96. package/dist/slices/rebuild/runtime-local.js +6 -3
  97. package/dist/slices/rebuild/runtime-materialize.js +48 -0
  98. package/dist/slices/rebuild/runtime-piece-label.js +17 -0
  99. package/dist/slices/rebuild/runtime-provider.js +17 -10
  100. package/dist/slices/rebuild/runtime-publication.js +25 -4
  101. package/dist/slices/rebuild/runtime-run.js +11 -5
  102. package/dist/slices/rebuild/runtime-shorts.js +328 -0
  103. package/dist/slices/rebuild/runtime-store.js +7 -3
  104. package/dist/slices/rebuild/service-readiness.js +15 -1
  105. package/dist/slices/rebuild/transition-repo.js +40 -1
  106. package/dist/slices/revisions/adopt-content.js +7 -0
  107. package/dist/slices/revisions/adopt.js +7 -2
  108. package/dist/slices/revisions/mutation-assets.js +51 -9
  109. package/dist/slices/revisions/mutation-prepare.js +14 -14
  110. package/dist/slices/revisions/mutations.js +2 -1
  111. package/dist/slices/revisions/publish.js +20 -2
  112. package/dist/slices/revisions/schema.js +16 -1
  113. package/dist/slices/schedules/repo.js +1 -1
  114. package/dist/slices/schedules/service.js +6 -1
  115. package/dist/slices/settings/model.js +5 -0
  116. package/dist/slices/shorts/captions.js +272 -0
  117. package/dist/slices/shorts/clips.js +89 -0
  118. package/dist/slices/shorts/model.js +186 -0
  119. package/dist/slices/shorts/pick.js +232 -0
  120. package/dist/slices/shorts/prompts.js +68 -0
  121. package/dist/slices/shorts/render.js +163 -0
  122. package/dist/slices/storage/asset-name.js +9 -2
  123. package/dist/slices/storage/layout.js +18 -0
  124. package/dist/slices/storage/model.js +9 -0
  125. package/dist/slices/storage/portable.js +6 -3
  126. package/dist/slices/storage/reconcile.js +5 -1
  127. package/dist/slices/storage/schema.js +3 -0
  128. package/dist/slices/telemetry/model.js +3 -0
  129. package/dist/slices/video/cards.js +72 -0
  130. package/dist/slices/video/chapters.js +89 -0
  131. package/dist/slices/video/cuts.js +141 -0
  132. package/dist/slices/video/edit-list.js +36 -6
  133. package/dist/slices/video/edit-settings.js +191 -0
  134. package/dist/slices/video/ffmpeg.js +155 -29
  135. package/dist/slices/video/look.js +164 -0
  136. package/dist/slices/video/plan.js +68 -11
  137. package/dist/slices/video/slideshow.js +22 -6
  138. package/dist/slices/video/transitions.js +51 -0
  139. package/dist/slices/youtube/transcript.js +54 -0
  140. package/dist/web/assets/index-CktpxlJd.js +171 -0
  141. package/dist/web/assets/index-DB-vOnGU.css +1 -0
  142. package/dist/web/assets/{pdf-DR97Byps.js → pdf-BLF2i8c2.js} +1 -1
  143. package/dist/web/index.html +2 -2
  144. package/package.json +1 -1
  145. package/dist/web/assets/index-DfXmBzCk.js +0 -168
  146. package/dist/web/assets/index-Y3RhfTEh.css +0 -1
@@ -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);
@@ -0,0 +1,50 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { outputPath } from "../storage/layout.js";
4
+ // The provider call an image step makes: its request, the effort when one is chosen and, while
5
+ // the project's Establishing image is on, that image's bytes as the visual reference.
6
+ export function imageCall(deps, projectId, input, previewLabel) {
7
+ const reference = referenceImage(deps, projectId, input);
8
+ return {
9
+ provider: input.provider,
10
+ model: input.model,
11
+ prompt: input.prompt,
12
+ aspect: input.aspect,
13
+ ...(input.thinking === undefined ? {} : { thinking: input.thinking }),
14
+ ...(reference === undefined ? {} : { reference }),
15
+ ...(previewLabel === undefined ? {} : { previewLabel }),
16
+ };
17
+ }
18
+ function referenceImage(deps, projectId, input) {
19
+ if (input.reference === undefined)
20
+ return undefined;
21
+ // The step waits for the establishing image, so a missing one went away since.
22
+ const missing = "The establishing image this image is drawn from is missing. Use Regenerate on the establishing image in the Images section (or upload it again in Edit project → Images), then use Retry stage.";
23
+ if (input.reference.assetId === null)
24
+ throw new Error(missing);
25
+ const row = z
26
+ .object({ path: z.string() })
27
+ .optional()
28
+ .parse(deps.db
29
+ .prepare("SELECT path FROM project_assets WHERE id=? AND project_id=?")
30
+ .get(input.reference.assetId, projectId));
31
+ if (row === undefined)
32
+ throw new Error(missing);
33
+ let bytes;
34
+ try {
35
+ bytes = new Uint8Array(readFileSync(outputPath(deps.paths, projectId, row.path)));
36
+ }
37
+ catch {
38
+ throw new Error(missing);
39
+ }
40
+ const mime = imageMime(bytes);
41
+ if (mime === undefined)
42
+ throw new Error("The establishing image is not a PNG or JPEG file, so it can't be sent as a reference. Upload a PNG or JPEG in Edit project → Images → Establishing image, then use Retry stage.");
43
+ return { bytes, mime };
44
+ }
45
+ function imageMime(bytes) {
46
+ const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
47
+ if (png.every((value, index) => bytes[index] === value))
48
+ return "image/png";
49
+ return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff ? "image/jpeg" : undefined;
50
+ }
@@ -1,6 +1,7 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { z } from "zod";
4
+ import { referenceKey } from "../admission/model.js";
4
5
  import { plainText } from "../article/plain.js";
5
6
  import { splitEndMatter } from "../article/split.js";
6
7
  import { joinNarration } from "../narration/concat.js";
@@ -36,9 +37,11 @@ export async function executeLocalRecipe(deps, context, piece) {
36
37
  ? "audio_body"
37
38
  : piece.key === "thumbnail:image"
38
39
  ? "thumbnail"
39
- : piece.key.startsWith("image:")
40
- ? "image"
41
- : undefined;
40
+ : piece.key === referenceKey
41
+ ? "reference"
42
+ : piece.key.startsWith("image:")
43
+ ? "image"
44
+ : undefined;
42
45
  await publishResult(deps, context, piece, role === undefined ? [] : [preparedResult(deps, context, piece, role, asset)], {}, asset);
43
46
  return "done";
44
47
  }
@@ -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
  }
@@ -6,12 +6,25 @@ export function pieceLabel(deps, context, piece) {
6
6
  const { key } = piece;
7
7
  if (key === "thumbnail:image" || key.startsWith("thumbnail:"))
8
8
  return "Thumbnail";
9
+ if (key === "reference:image")
10
+ return "Establishing image";
9
11
  if (key === "article:body")
10
12
  return "Article";
11
13
  if (key === "document:pdf")
12
14
  return "Document";
13
15
  if (key === "youtube:description")
14
16
  return "YouTube description";
17
+ if (key === "shorts:pick" || key === "shorts:future")
18
+ return "Shorts";
19
+ if (key === "animate:future")
20
+ return "Animated images";
21
+ const short = /^shorts:(\d+):(prompts|image:(\d+)|render)$/.exec(key);
22
+ if (short !== null)
23
+ return short[2] === "prompts"
24
+ ? `Short ${short[1]} image prompts`
25
+ : short[2] === "render"
26
+ ? `Short ${short[1]}`
27
+ : `Short ${short[1]} image ${short[3]}`;
15
28
  if (key === "entry:intro:text")
16
29
  return "Intro text";
17
30
  if (key === "entry:outro:text")
@@ -28,6 +41,10 @@ export function pieceLabel(deps, context, piece) {
28
41
  const at = view()?.revision.content.imageOrder.indexOf(key.slice("image:".length)) ?? -1;
29
42
  return at === -1 ? "An image" : `Image ${String(at + 1)}`;
30
43
  }
44
+ if (key.startsWith("animate:")) {
45
+ const at = view()?.revision.content.imageOrder.indexOf(key.slice("animate:".length)) ?? -1;
46
+ return at === -1 ? "An animated image" : `Animated image ${String(at + 1)}`;
47
+ }
31
48
  const narration = /^(?:narration:prepare:(?:intro|body|outro):)?audio:(intro|body|outro)(?::(.+))?$/.exec(key);
32
49
  if (narration === null)
33
50
  return undefined;
@@ -1,3 +1,4 @@
1
+ import { referenceKey } from "../admission/model.js";
1
2
  import { plainText } from "../article/plain.js";
2
3
  import { splitEndMatter } from "../article/split.js";
3
4
  import { observeNarration } from "../narration/live.js";
@@ -9,6 +10,7 @@ import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
9
10
  import { outputPath } from "../storage/layout.js";
10
11
  import { probeDurationMs } from "../video/ffmpeg.js";
11
12
  import { executeArticleRequests } from "./runtime-article.js";
13
+ import { imageCall } from "./runtime-image.js";
12
14
  import { frozenInstructions } from "./runtime-instructions.js";
13
15
  import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
14
16
  import { preparedResult, preparedTexts, publishResult } from "./runtime-publication.js";
@@ -89,20 +91,25 @@ export async function executeProviderRecipe(deps, context, providers, piece) {
89
91
  return "done";
90
92
  }
91
93
  if (input.kind === "image") {
92
- const image = await wrapped.image({
93
- provider: input.provider,
94
- model: input.model,
95
- prompt: input.prompt,
96
- aspect: input.aspect,
97
- });
98
- if (!image.ok)
99
- return "held";
100
- const asset = writeAsset(deps, context.work.projectId, image.value.mime === "image/jpeg" ? "image.jpg" : "image.png", image.value.bytes);
101
94
  const view = executionView(deps, context.work.projectId, context.work.revisionId);
102
95
  const index = piece.key.startsWith("image:")
103
96
  ? (view?.revision.content.imageOrder.indexOf(piece.key.slice(6)) ?? 0) + 1
104
97
  : undefined;
105
- const output = preparedResult(deps, context, piece, piece.key === "thumbnail:image" ? "thumbnail" : "image", asset, null, {
98
+ const image = await wrapped.image(imageCall(deps, context.work.projectId, input, piece.key === "thumbnail:image"
99
+ ? "Thumbnail"
100
+ : piece.key === referenceKey
101
+ ? "Establishing image"
102
+ : index === undefined
103
+ ? "Image"
104
+ : `Image ${String(index)}`));
105
+ if (!image.ok)
106
+ return "held";
107
+ const asset = writeAsset(deps, context.work.projectId, image.value.mime === "image/jpeg" ? "image.jpg" : "image.png", image.value.bytes);
108
+ const output = preparedResult(deps, context, piece, piece.key === "thumbnail:image"
109
+ ? "thumbnail"
110
+ : piece.key === referenceKey
111
+ ? "reference"
112
+ : "image", asset, null, {
106
113
  prompt: input.prompt,
107
114
  provider: input.provider,
108
115
  model: input.model,
@@ -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",
@@ -1,8 +1,10 @@
1
+ import { executeAnimateRecipe } from "./runtime-animate.js";
1
2
  import { executeDocumentRecipe } from "./runtime-document.js";
2
3
  import { executeExportRecipe } from "./runtime-export.js";
3
4
  import { executeLocalRecipe } from "./runtime-local.js";
4
5
  import { pieceLabel } from "./runtime-piece-label.js";
5
6
  import { executeProviderRecipe } from "./runtime-provider.js";
7
+ import { executeShortsRecipe } from "./runtime-shorts.js";
6
8
  import { executeSubtitleRecipe } from "./runtime-subtitles.js";
7
9
  import { executeYoutubeRecipe } from "./runtime-youtube.js";
8
10
  import { workPieces } from "./work-records.js";
@@ -22,11 +24,15 @@ export async function runRevisionInvocation(deps, context, providers) {
22
24
  ? await executeDocumentRecipe(deps, context, piece)
23
25
  : piece.key.startsWith("youtube:")
24
26
  ? await executeYoutubeRecipe(deps, context, providers, piece)
25
- : piece.input.kind === "llm" ||
26
- piece.input.kind === "tts" ||
27
- piece.input.kind === "image"
28
- ? await executeProviderRecipe(deps, context, providers, piece)
29
- : await executeLocalRecipe(deps, context, piece);
27
+ : piece.key.startsWith("shorts:")
28
+ ? await executeShortsRecipe(deps, context, providers, piece)
29
+ : piece.key.startsWith("animate:")
30
+ ? await executeAnimateRecipe(deps, context, providers, piece)
31
+ : piece.input.kind === "llm" ||
32
+ piece.input.kind === "tts" ||
33
+ piece.input.kind === "image"
34
+ ? await executeProviderRecipe(deps, context, providers, piece)
35
+ : await executeLocalRecipe(deps, context, piece);
30
36
  if (outcome === "held")
31
37
  return outcome;
32
38
  }