@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
@@ -2,9 +2,28 @@ import { stageKinds } from "../../kernel/pipeline.js";
2
2
  import { sourceOf } from "../admission/model.js";
3
3
  import { allowedSources, edgeSilenceSecondsProblem, imageSecondsProblem, normaliseDraft, silenceGapSecondsMax, zoomPercentProblem, } from "../admission/rules.js";
4
4
  import { render } from "../admission/substitute.js";
5
+ import { effectiveClips, pickedShortsOf, rangeProblem } from "../shorts/clips.js";
5
6
  import { buildRecipes } from "./recipe-build.js";
6
7
  import { selectedReference } from "./recipe-model.js";
7
8
  import { validateRecipeInputs } from "./recipe-validation.js";
9
+ // A range set by hand for a short is checked against the pick it was set on, while that is
10
+ // still the project's pick; one left from an earlier pick no longer applies and is ignored.
11
+ function shortRangeFields(base, config, content) {
12
+ const ranges = content.shortsRanges;
13
+ const shorts = config.shorts;
14
+ if (ranges === undefined || shorts?.enabled !== true)
15
+ return [];
16
+ const row = base.pieces.find((one) => one.key === "shorts:pick" && one.selected && one.piece.state === "done");
17
+ const picked = pickedShortsOf(row?.piece.payload);
18
+ if (row === undefined || picked === undefined)
19
+ return [];
20
+ const current = Object.fromEntries(Object.entries(ranges).filter(([, range]) => range.pick === row.fingerprint));
21
+ const clips = effectiveClips(picked, current, row.fingerprint, shorts);
22
+ return Object.entries(current).flatMap(([number, range]) => {
23
+ const message = rangeProblem(picked, Number(number), range.first, range.last, shorts, clips);
24
+ return message === undefined ? [] : [{ field: `content.shortsRanges.${number}`, message }];
25
+ });
26
+ }
8
27
  export function normalizeArticleIntent(base, edit) {
9
28
  const restart = edit.regenerate?.includes("article:body") === true ||
10
29
  (base.revision.config.sources.article === "provide" &&
@@ -27,6 +46,7 @@ export function planRevision(base, edit, prepared = { outputs: [], pieces: [] },
27
46
  ...validateRevisionEdit(edit.config, content),
28
47
  ...validateCues(content),
29
48
  ...validateRecipeInputs(edit.config, content),
49
+ ...shortRangeFields(base, edit.config, content),
30
50
  ];
31
51
  if (fields.length > 0)
32
52
  return { ok: false, fields };
@@ -122,7 +142,11 @@ export function planRevision(base, edit, prepared = { outputs: [], pieces: [] },
122
142
  : prepared.outputs.includes(row) || (unchanged && row.state === "ready")
123
143
  ? "ready"
124
144
  : row.fingerprint === next.fingerprint
125
- ? row.state
145
+ ? // An outdated result made for exactly this step is current again: an undo
146
+ // or a second edit that lands back on it must not ask for it to be redone.
147
+ row.state === "outdated"
148
+ ? "ready"
149
+ : row.state
126
150
  : next.kind === "provided"
127
151
  ? "review"
128
152
  : "outdated";
@@ -0,0 +1,186 @@
1
+ import { z } from "zod";
2
+ import { fingerprint } from "../../kernel/runner/work.js";
3
+ import { usesShorts } from "../admission/rules.js";
4
+ import { effectiveClips, pickedShortsOf } from "../shorts/clips.js";
5
+ import { defaultShortsImagePrompt, musicVolumeOf, shortImageCount, shortsSpeedOf, } from "../shorts/model.js";
6
+ import { imagePromptMessages } from "../shorts/prompts.js";
7
+ import { recipe, resourceIdentity, selectedReference, } from "./recipe-model.js";
8
+ import { imageChoice } from "./recipe-reference.js";
9
+ import { llmInput, renderedPrompt } from "./recipe-text.js";
10
+ // The Shorts step unfolds as its answers land, the way research's chapters unfold from the
11
+ // plan. `shorts:pick` reads the word timing, so it waits for `subtitles:timing` and runs
12
+ // beside the render; until its saved answer matches, one deferred `shorts:future` stands in
13
+ // for everything after it (and carries the images' cost). Each picked clip then gets
14
+ // `shorts:N:prompts`, one LLM call writing its image prompts; once those match, its
15
+ // `shorts:N:image:M` images and its `shorts:N:render`.
16
+ //
17
+ // A clip's work carries the clip's own token (`clipToken`): the pick's token from when the
18
+ // clip was first picked, plus the one "Make this short again" renews under `shorts:N`. So
19
+ // picking the moments again redoes only the clips whose sentences changed, and one short
20
+ // can be made again alone.
21
+ export const shortsPickKey = "shorts:pick";
22
+ export const shortsFutureKey = "shorts:future";
23
+ export function shortsRecipes(context, exports,
24
+ // The establishing image the shorts' images are drawn from, when it is on.
25
+ reference) {
26
+ const { config } = context;
27
+ const shorts = config.shorts;
28
+ const timing = exports.find((value) => value.key === "subtitles:timing");
29
+ if (!usesShorts(config) || shorts === undefined || timing === undefined)
30
+ return [];
31
+ const pick = recipe(context, shortsPickKey, "video", {
32
+ kind: "local",
33
+ version: 1,
34
+ operation: "shorts-pick-v1",
35
+ values: [
36
+ resourceIdentity(context, timing),
37
+ config.llm?.provider ?? "",
38
+ config.llm?.model ?? "",
39
+ config.llm?.thinking ?? null,
40
+ // Blank means the built-in prompt; the step fills it in, so a new built-in wording
41
+ // ships as a new operation version rather than a silent change here.
42
+ shorts.prompt?.trim() ? renderedPrompt(context, "shorts") : "",
43
+ shorts.count,
44
+ shorts.minSeconds,
45
+ shorts.maxSeconds,
46
+ config.title,
47
+ ],
48
+ }, [timing.key],
49
+ // A paid LLM call: readiness, the charge warning and the estimate treat it like one.
50
+ { kind: "provider" });
51
+ const picked = savedPicked(context, pick);
52
+ const images = config.images ?? { provider: "", model: "" };
53
+ const style = shorts.imagePrompt?.trim()
54
+ ? renderedPrompt(context, "shortsImage")
55
+ : defaultShortsImagePrompt;
56
+ if (picked === undefined)
57
+ return [
58
+ pick,
59
+ recipe(context, shortsFutureKey, "video", {
60
+ kind: "deferred",
61
+ version: 1,
62
+ operation: "shorts",
63
+ template: [
64
+ pick.fingerprint,
65
+ style,
66
+ images.provider,
67
+ images.model,
68
+ config.imageSeconds,
69
+ shorts.count,
70
+ shorts.maxSeconds,
71
+ config.llm?.provider ?? "",
72
+ config.llm?.model ?? "",
73
+ // Only what is in use, so shorts planned before these existed keep their
74
+ // fingerprint; the estimate reads the fields above by position.
75
+ ...(config.images?.thinking === undefined ? [] : [config.images.thinking]),
76
+ ...(reference === undefined ? [] : [reference.input.fingerprint]),
77
+ ],
78
+ }, [pick.key], { tokenKey: shortsPickKey }),
79
+ ];
80
+ const recipes = [pick];
81
+ const clips = effectiveClips(picked, context.content.shortsRanges, pick.fingerprint, shorts);
82
+ for (const clip of clips) {
83
+ const prefix = `shorts:${String(clip.number)}`;
84
+ const token = clipToken(context, clip);
85
+ const count = shortImageCount(clip.end - clip.start, config.imageSeconds);
86
+ const prompts = {
87
+ ...recipe(context, `${prefix}:prompts`, "video", llmInput(context, imagePromptMessages({
88
+ style,
89
+ videoTitle: config.title,
90
+ shortTitle: clip.title,
91
+ text: clip.text,
92
+ count,
93
+ imageSeconds: config.imageSeconds,
94
+ })), [pick.key], { token }),
95
+ unfoldsImages: { count, provider: images.provider, model: images.model },
96
+ };
97
+ recipes.push(prompts);
98
+ const written = savedPrompts(context, prompts, token);
99
+ if (written === undefined || written.length !== count)
100
+ continue;
101
+ const stills = written.map((prompt, index) => recipe(context, `${prefix}:image:${String(index + 1)}`, "video", {
102
+ kind: "image",
103
+ version: 1,
104
+ ...imageChoice(config),
105
+ prompt,
106
+ aspect: "9:16",
107
+ ...(reference === undefined ? {} : { reference: reference.input }),
108
+ }, [prompts.key, ...(reference === undefined ? [] : [reference.key])], { token }));
109
+ recipes.push(...stills);
110
+ const extras = renderExtras(context, clip);
111
+ recipes.push(recipe(context, `${prefix}:render`, "video", {
112
+ kind: "local",
113
+ version: 1,
114
+ operation: "short-render-v1",
115
+ values: [
116
+ resourceIdentity(context, timing),
117
+ clip.number,
118
+ clip.start,
119
+ clip.end,
120
+ stills.map((still) => resourceIdentity(context, still)),
121
+ config.imageSeconds,
122
+ config.zoomPercent,
123
+ config.motionStyle,
124
+ config.subtitles?.fontId ?? "default",
125
+ // Only when one of the later settings is in use, so a short rendered before
126
+ // they existed keeps its fingerprint.
127
+ ...(extras === undefined ? [] : [extras]),
128
+ ],
129
+ }, [pick.key, ...stills.map((still) => still.key)], { token }));
130
+ }
131
+ return recipes;
132
+ }
133
+ // What the render draws and plays beyond the clip itself: the title headline, a faster
134
+ // speed and the background music at its level. Undefined when none is in use.
135
+ export function renderExtras(context, clip) {
136
+ const shorts = context.config.shorts;
137
+ if (shorts === undefined)
138
+ return undefined;
139
+ const speed = shortsSpeedOf(shorts);
140
+ const music = context.content.shortsMusic;
141
+ const extras = {
142
+ ...(shorts.titleOnScreen === true ? { title: clip.title } : {}),
143
+ ...(speed === 1 ? {} : { speed }),
144
+ ...(music === undefined ? {} : { music: [music, musicVolumeOf(shorts)] }),
145
+ };
146
+ return Object.keys(extras).length === 0 ? undefined : extras;
147
+ }
148
+ // The token a clip's prompts, images and render carry: the pick's token from when the clip
149
+ // was first picked (the current one for a pick saved before clips kept theirs), with the
150
+ // clip's own "Make this short again" token when it has one.
151
+ export function clipToken(context, clip) {
152
+ const tokens = context.content.regenerationTokens;
153
+ const base = clip.seed !== undefined ? clip.seed : (tokens[shortsPickKey] ?? null);
154
+ const own = tokens[shortRemakeKey(clip.number)];
155
+ return own === undefined ? base : fingerprint([base, own]);
156
+ }
157
+ // The regeneration key "Make this short again" renews for short N.
158
+ export function shortRemakeKey(number) {
159
+ return `shorts:${String(number)}`;
160
+ }
161
+ // What the pick saved, when its saved answer is the one this plan asks for.
162
+ export function savedPicked(context, pick) {
163
+ const piece = context.manifest.pieces.find((row) => row.key === pick.key &&
164
+ selectedReference(row) &&
165
+ row.piece.state === "done" &&
166
+ row.fingerprint === pick.fingerprint);
167
+ return pickedShortsOf(piece?.piece.payload);
168
+ }
169
+ // A clip's saved image prompts, when they answer this plan's request. The request carries
170
+ // the model's thinking settings from the catalogue, which the plan without a catalogue has
171
+ // not got, so either fingerprint matches (like `matchingText`).
172
+ function savedPrompts(context, prompts, token) {
173
+ const logical = context.catalogue === undefined || prompts.input.kind !== "llm"
174
+ ? prompts.fingerprint
175
+ : recipe({ content: context.content }, prompts.key, prompts.stage, { ...prompts.input, thinkingConfig: null }, prompts.dependsOn, { token }).fingerprint;
176
+ const piece = context.manifest.pieces.find((row) => row.key === prompts.key &&
177
+ selectedReference(row) &&
178
+ row.piece.state === "done" &&
179
+ (row.fingerprint === prompts.fingerprint || row.fingerprint === logical));
180
+ if (piece?.piece.payload === undefined || piece.piece.payload === null)
181
+ return undefined;
182
+ const parsed = z
183
+ .object({ prompts: z.array(z.string()) })
184
+ .safeParse(JSON.parse(piece.piece.payload));
185
+ return parsed.success ? parsed.data.prompts : undefined;
186
+ }
@@ -1,15 +1,19 @@
1
- import { narrationPreparationFields, usesNarrationPreparation, usesYoutubeDescription, youtubeDescriptionFields, } from "../admission/rules.js";
1
+ import { narrationPreparationFields, shortsFields, usesNarrationPreparation, usesReference, 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({
@@ -76,6 +93,20 @@ export function validateRecipeInputs(config, content) {
76
93
  raw: content.promptTemplates.thumbnailPrompt ?? null,
77
94
  literal: config.rendered.thumbnailPrompt,
78
95
  });
96
+ // The establishing image: its prompt is checked like the thumbnail's, an upload must be there.
97
+ if (usesReference(config) && config.reference?.source === "prompt")
98
+ prompts.push({
99
+ field: "rendered.referencePrompt",
100
+ raw: content.promptTemplates.referencePrompt ?? null,
101
+ literal: config.rendered.referencePrompt,
102
+ });
103
+ if (usesReference(config) &&
104
+ config.reference?.source === "provide" &&
105
+ content.provided.reference === undefined)
106
+ fields.push({
107
+ field: "content.provided.reference",
108
+ message: "Upload the establishing image, choose From a prompt instead, or set Establishing image to Off.",
109
+ });
79
110
  if (config.sources.images !== "off")
80
111
  for (const key of content.imageOrder) {
81
112
  const image = content.imageDefinitions[key];
@@ -1,7 +1,12 @@
1
1
  import { render } from "../admission/substitute.js";
2
2
  import { recipe, resourceIdentity, } from "./recipe-model.js";
3
+ import { imageChoice } from "./recipe-reference.js";
3
4
  import { matchingText, renderedPrompt } from "./recipe-text.js";
4
- export function visualRecipes(config, content, audioFingerprint, captionFingerprint) {
5
+ export function visualRecipes(config, content, audioFingerprint, captionFingerprint,
6
+ // What the Video stage's edit settings add (`recipe-edit.ts`), from the images planned here.
7
+ edit,
8
+ // The establishing image the generated images are drawn from, when it is on.
9
+ reference) {
5
10
  const recipes = [];
6
11
  const imageKeys = config.sources.images === "off" ? [] : content.imageOrder;
7
12
  for (const key of imageKeys) {
@@ -17,13 +22,14 @@ export function visualRecipes(config, content, audioFingerprint, captionFingerpr
17
22
  : {
18
23
  kind: "image",
19
24
  version: 1,
20
- provider: config.images?.provider ?? "",
21
- model: config.images?.model ?? "",
25
+ ...imageChoice(config),
22
26
  aspect: config.format,
23
27
  prompt: prompt ?? "",
24
- }, [], { unresolved: image.source === "provide" ? image.assetId === null : !prompt?.trim() }));
28
+ ...(reference === undefined ? {} : { reference: reference.input }),
29
+ }, image.source === "provide" || reference === undefined ? [] : [reference.key], { unresolved: image.source === "provide" ? image.assetId === null : !prompt?.trim() }));
25
30
  }
26
31
  if (config.sources.video !== "off") {
32
+ const edited = edit?.([...recipes]) ?? { recipes: [], values: [], dependsOn: [] };
27
33
  const audioKeys = config.sources.audio === "off"
28
34
  ? []
29
35
  : [
@@ -55,16 +61,24 @@ export function visualRecipes(config, content, audioFingerprint, captionFingerpr
55
61
  ...(config.motionStyle === "zoom" ? [] : [config.motionStyle]),
56
62
  // v1 split the timeline evenly across the images; v2 cycles them.
57
63
  "slideshow-zoom-v2",
64
+ // Only what the edit settings change; nothing at all for today's slideshow, so
65
+ // its fingerprint is the one it always had.
66
+ ...(edited.values.length === 0 ? [] : [["video-edit", ...edited.values]]),
58
67
  ],
59
68
  }, [
60
69
  ...recipes.map((value) => value.key),
61
70
  ...audioKeys,
62
71
  ...(config.subtitles?.mode === "burn-in" ? ["subtitles:files"] : []),
72
+ ...edited.dependsOn,
63
73
  ], { unresolved: imageKeys.length === 0 }));
74
+ // After the export, so the images' recipes above stay the only ones its first values list.
75
+ recipes.splice(recipes.length - 1, 0, ...edited.recipes);
64
76
  }
65
77
  return recipes;
66
78
  }
67
- export function thumbnailRecipes(context, textRecipes) {
79
+ export function thumbnailRecipes(context, textRecipes,
80
+ // The establishing image, when it is on and the thumbnail is drawn from it too.
81
+ reference) {
68
82
  const { config, content } = context;
69
83
  if (config.sources.thumbnail === "off")
70
84
  return [];
@@ -92,16 +106,23 @@ export function thumbnailRecipes(context, textRecipes) {
92
106
  config.images?.provider ?? null,
93
107
  config.images?.model ?? null,
94
108
  config.format,
109
+ // Only what is in use, so a thumbnail made before these existed keeps its
110
+ // fingerprint.
111
+ ...(config.images?.thinking === undefined ? [] : [config.images.thinking]),
112
+ ...(reference === undefined ? [] : [reference.input.fingerprint]),
95
113
  ],
96
114
  }
97
115
  : {
98
116
  kind: "image",
99
117
  version: 1,
100
- provider: config.images?.provider ?? "",
101
- model: config.images?.model ?? "",
118
+ ...imageChoice(config),
102
119
  aspect: config.format,
103
120
  prompt,
104
- }, promptRecipe === undefined ? [] : [promptRecipe.key]),
121
+ ...(reference === undefined ? {} : { reference: reference.input }),
122
+ }, [
123
+ ...(promptRecipe === undefined ? [] : [promptRecipe.key]),
124
+ ...(reference === undefined ? [] : [reference.key]),
125
+ ]),
105
126
  ];
106
127
  }
107
128
  export function visualAssets(context, recipes) {
@@ -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 {
@@ -12,7 +12,14 @@ export async function revisionAction(deps, projectId, action) {
12
12
  const config = view.revision.config;
13
13
  const content = view.revision.content;
14
14
  const image = action.kind === "delete-image" || action.kind === "regenerate-image"
15
- ? view.outputs.find((row) => row.selected && row.output.id === action.outputId && row.output.role === "image")
15
+ ? view.outputs.find((row) => row.selected &&
16
+ row.output.id === action.outputId &&
17
+ (row.output.role === "image" ||
18
+ // A generated establishing image is made again like any image; it is never deleted
19
+ // on its own (Edit project sets it Off), and an uploaded one has nothing to redo.
20
+ (action.kind === "regenerate-image" &&
21
+ row.output.role === "reference" &&
22
+ config.reference?.source === "prompt")))
16
23
  : undefined;
17
24
  if ((action.kind === "delete-image" || action.kind === "regenerate-image") && image === undefined)
18
25
  return { ok: false, reason: "unknown-image" };
@@ -64,7 +71,7 @@ function keyStage(key) {
64
71
  const prefix = key.split(":")[0];
65
72
  if (prefix === "entry" || prefix === "article")
66
73
  return "article";
67
- if (prefix === "image")
74
+ if (prefix === "image" || prefix === "reference")
68
75
  return "images";
69
76
  if (prefix === "audio")
70
77
  return "audio";
@@ -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
+ }