@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
@@ -3,6 +3,8 @@ import { sourceOf } from "../admission/model.js";
3
3
  import { motionStyleLabels } from "../admission/rules.js";
4
4
  import { documentThemeLabel } from "../document/model.js";
5
5
  import { getRevisionView } from "../revisions/view.js";
6
+ import { musicVolumeOf, shortsSpeedOf } from "../shorts/model.js";
7
+ import { videoEditOf, videoEditRows } from "../video/edit-settings.js";
6
8
  export function previewDetails(deps, view, allRecipes, selected) {
7
9
  const parent = view.revision.parentId === null
8
10
  ? undefined
@@ -62,7 +64,9 @@ export function previewDetails(deps, view, allRecipes, selected) {
62
64
  ? "Article"
63
65
  : row.stage === "research"
64
66
  ? "Research"
65
- : "Thumbnail prompt";
67
+ : row.key.startsWith("shorts:")
68
+ ? "Short image prompts"
69
+ : "Thumbnail prompt";
66
70
  return [
67
71
  {
68
72
  key: row.key,
@@ -129,11 +133,35 @@ function inputChanges(parent, view) {
129
133
  add("Narration chunking", before.chunking, after.chunking);
130
134
  add("YouTube description", before.youtubeDescription === true ? "On" : "Off", after.youtubeDescription === true ? "On" : "Off");
131
135
  add("Description prompt", before.descriptionPrompt, after.descriptionPrompt);
136
+ add("Shorts", before.shorts?.enabled === true ? "On" : "Off", after.shorts?.enabled === true ? "On" : "Off");
137
+ add("Number of shorts", before.shorts?.count, after.shorts?.count);
138
+ add("Short length (seconds)", before.shorts === undefined
139
+ ? undefined
140
+ : `${String(before.shorts.minSeconds)}-${String(before.shorts.maxSeconds)}`, after.shorts === undefined
141
+ ? undefined
142
+ : `${String(after.shorts.minSeconds)}-${String(after.shorts.maxSeconds)}`);
143
+ add("Shorts prompt", before.shorts?.prompt, after.shorts?.prompt);
144
+ add("Shorts image prompt", before.shorts?.imagePrompt, after.shorts?.imagePrompt);
145
+ add("Title on the shorts", before.shorts === undefined ? undefined : before.shorts.titleOnScreen === true ? "On" : "Off", after.shorts === undefined ? undefined : after.shorts.titleOnScreen === true ? "On" : "Off");
146
+ add("Shorts speed", before.shorts === undefined ? undefined : `${shortsSpeedOf(before.shorts).toFixed(2)}×`, after.shorts === undefined ? undefined : `${shortsSpeedOf(after.shorts).toFixed(2)}×`);
147
+ add("Shorts music", old.shortsMusic === undefined ? "None" : "Uploaded file", next.shortsMusic === undefined
148
+ ? "None"
149
+ : next.shortsMusic === old.shortsMusic
150
+ ? "Uploaded file"
151
+ : "New uploaded file");
152
+ add("Shorts music volume (%)", before.shorts === undefined ? undefined : musicVolumeOf(before.shorts), after.shorts === undefined ? undefined : musicVolumeOf(after.shorts));
153
+ add("Full video link", before.shorts?.fullVideoLink, after.shorts?.fullVideoLink);
154
+ add("Short ranges set by hand", old.shortsRanges, next.shortsRanges);
132
155
  add("Silence gap (seconds)", before.silenceGapSeconds, after.silenceGapSeconds);
133
156
  add("Silence at start and end (seconds)", before.edgeSilenceSeconds, after.edgeSilenceSeconds);
134
157
  add("Seconds per image", before.imageSeconds, after.imageSeconds);
135
158
  add("Zoom (%)", before.zoomPercent, after.zoomPercent);
136
159
  add("Motion", motionStyleLabels[before.motionStyle], motionStyleLabels[after.motionStyle]);
160
+ // Row by row, so turning on grain says "Film grain: Off → Subtle" and nothing else.
161
+ const editBefore = videoEditRows(videoEditOf(before));
162
+ const editAfter = new Map(videoEditRows(videoEditOf(after)));
163
+ for (const [label, value] of editBefore)
164
+ add(label, value, editAfter.get(label));
137
165
  add("Intro", before.intro, after.intro);
138
166
  add("Outro", before.outro, after.outro);
139
167
  add("Subtitle settings", before.subtitles, after.subtitles);
@@ -8,7 +8,7 @@ import { hasSubmittedRequest, requiresNewSubmission, retainedPreviewPlan, } from
8
8
  import { providedDependencyFingerprint } from "./provided-review.js";
9
9
  import { recipeInputSchema } from "./recipe-input-schema.js";
10
10
  import { recipeProviderChoice } from "./recipe-provider-choice.js";
11
- import { priceRecipe } from "./recipe-work.js";
11
+ import { priceRecipes } from "./recipe-work.js";
12
12
  import { reservationKey } from "./runtime-admission.js";
13
13
  import { narrationOrdinal } from "./runtime-narration-reuse.js";
14
14
  import { executionCatalogue } from "./runtime-plan.js";
@@ -166,7 +166,7 @@ export function planPreview(deps, view, catalogue, selection, id) {
166
166
  state: row.state,
167
167
  })),
168
168
  providedReuseRequired: execution.reviews.map((row) => row.key),
169
- costs: estimateRequests(work.map((row) => priceRecipe(row.inflight || !requiresNewSubmission(deps, view.revision.id, row.key, row.fingerprint)
169
+ costs: estimateRequests(work.flatMap((row) => priceRecipes(row.inflight || !requiresNewSubmission(deps, view.revision.id, row.key, row.fingerprint)
170
170
  ? { ...row, disposition: "reuse" }
171
171
  : row, recipes.find((recipe) => recipe.key === row.key))), snapshot),
172
172
  wholeRequestNotice: plan.wholeRequestNotice,
@@ -1,6 +1,8 @@
1
1
  import { audioRecipes } from "./recipe-audio.js";
2
2
  import { documentRecipes } from "./recipe-document.js";
3
+ import { editPlan } from "./recipe-edit.js";
3
4
  import { exportRecipes } from "./recipe-exports.js";
5
+ import { shortsRecipes } from "./recipe-shorts.js";
4
6
  import { textRecipes } from "./recipe-text.js";
5
7
  import { thumbnailRecipes, visualAssets, visualRecipes } from "./recipe-visual.js";
6
8
  import { youtubeRecipes } from "./recipe-youtube.js";
@@ -10,13 +12,15 @@ export function buildRecipes(context) {
10
12
  const exports = exportRecipes(context, audio);
11
13
  const captions = exports.find((value) => value.key === "subtitles:files");
12
14
  const thumbnail = thumbnailRecipes(context, text.recipes);
15
+ const youtube = youtubeRecipes(context, exports);
13
16
  return [
14
17
  ...text.recipes,
15
18
  ...audio.recipes,
16
19
  ...exports,
17
- ...youtubeRecipes(context, exports),
20
+ ...youtube,
21
+ ...shortsRecipes(context, exports),
18
22
  ...thumbnail,
19
23
  ...documentRecipes(context, text, thumbnail),
20
- ...visualAssets(context, visualRecipes(context.config, context.content, audio.mediaFingerprint, captions?.fingerprint ?? null)),
24
+ ...visualAssets(context, visualRecipes(context.config, context.content, audio.mediaFingerprint, captions?.fingerprint ?? null, (images) => editPlan(context, exports, youtube, images))),
21
25
  ];
22
26
  }
@@ -0,0 +1,178 @@
1
+ import { z } from "zod";
2
+ import { fingerprint } from "../../kernel/runner/work.js";
3
+ import { usesYoutubeDescription } from "../admission/rules.js";
4
+ import { headingChapters, topHeadings } from "../video/chapters.js";
5
+ import { chapterOpeningShots, sentenceCutPoints } from "../video/cuts.js";
6
+ import { animatedClipSeconds, animatedImageIndexes, animatePrompt, editNeedsTiming, usesAnimation, usesChapterCards, usesNarrationCuts, videoEditOf, } from "../video/edit-settings.js";
7
+ import { fps } from "../video/plan.js";
8
+ import { recipe, resourceIdentity, selectedReference, } from "./recipe-model.js";
9
+ // What the Video stage's edit settings add to the plan: the word timing and chapters the
10
+ // render reads, and a paid `animate:<image>` request for each image brought to life. Every
11
+ // setting left at today's behaviour adds nothing, so a project without them, or with them all
12
+ // off, plans the same export fingerprint it always did and no finished video turns outdated.
13
+ //
14
+ // "Chapter openers" can only be told once the word timing (and the YouTube chapters, when
15
+ // that step runs) has landed, so until then one deferred `animate:future` stands in for the
16
+ // requests and carries their cost, the way `shorts:future` does.
17
+ export const animateFutureKey = "animate:future";
18
+ export function editPlan(context, exports, youtube, images) {
19
+ const { config } = context;
20
+ const edit = videoEditOf(config);
21
+ if (config.sources.video !== "generate")
22
+ return { recipes: [], values: [], dependsOn: [] };
23
+ const timing = editNeedsTiming(config)
24
+ ? exports.find((value) => value.key === "subtitles:timing")
25
+ : undefined;
26
+ const description = youtube.find((value) => value.key === "youtube:description");
27
+ const fromDescription = usesYoutubeDescription(config) && description !== undefined;
28
+ const values = [];
29
+ const dependsOn = [];
30
+ if (usesNarrationCuts(config))
31
+ values.push(["cuts", "narration-v1"]);
32
+ if (edit.transition !== "cut")
33
+ values.push(["transition", edit.transition, edit.transitionSeconds]);
34
+ if (edit.vignette !== "off" ||
35
+ edit.grain !== "off" ||
36
+ edit.grade !== "none" ||
37
+ edit.atmosphere !== "none")
38
+ values.push(["look-v1", edit.vignette, edit.grain, edit.grade, edit.atmosphere]);
39
+ if (usesChapterCards(config))
40
+ values.push(["cards-v1", config.subtitles?.fontId ?? "default"]);
41
+ if (timing !== undefined) {
42
+ values.push(["timing", resourceIdentity(context, timing)]);
43
+ dependsOn.push(timing.key);
44
+ if (fromDescription && description !== undefined) {
45
+ values.push(["chapters", resourceIdentity(context, description)]);
46
+ dependsOn.push(description.key);
47
+ }
48
+ else
49
+ values.push(["chapters", "headings", headingsIdentity(context)]);
50
+ }
51
+ const recipes = usesAnimation(config)
52
+ ? animateRecipes(context, images, timing, fromDescription ? description : undefined)
53
+ : [];
54
+ if (recipes.length > 0) {
55
+ values.push(["animate-v1", recipes.map((value) => value.fingerprint)]);
56
+ dependsOn.push(...recipes.map((value) => value.key));
57
+ }
58
+ return { recipes, values, dependsOn };
59
+ }
60
+ function animateRecipes(context, images, timing, description) {
61
+ const { config, content } = context;
62
+ const edit = videoEditOf(config);
63
+ const provider = config.images?.provider ?? "";
64
+ // Only images the project draws are animated; an uploaded picture or clip is shown as it
65
+ // was given.
66
+ const drawn = (index) => {
67
+ const key = content.imageOrder[index];
68
+ return key !== undefined && content.imageDefinitions[key]?.source === "generate";
69
+ };
70
+ let indexes;
71
+ if (edit.animate === "every")
72
+ indexes = animatedImageIndexes(edit, content.imageOrder.length);
73
+ else {
74
+ const opening = openingImages(context, timing, description);
75
+ if (opening === undefined) {
76
+ const upper = Math.min(content.imageOrder.filter((_key, index) => drawn(index)).length, chapterEstimate(context));
77
+ return [
78
+ recipe(context, animateFutureKey, "video", {
79
+ kind: "deferred",
80
+ version: 1,
81
+ operation: "animate",
82
+ template: [
83
+ timing?.fingerprint ?? null,
84
+ description?.fingerprint ?? null,
85
+ provider,
86
+ edit.animateModel,
87
+ upper,
88
+ ],
89
+ }, [
90
+ ...(timing === undefined ? [] : [timing.key]),
91
+ ...(description ? [description.key] : []),
92
+ ]),
93
+ ];
94
+ }
95
+ indexes = opening;
96
+ }
97
+ return indexes.filter(drawn).flatMap((index) => {
98
+ const key = content.imageOrder[index];
99
+ const still = images.find((value) => value.key === `image:${key ?? ""}`);
100
+ if (key === undefined || still === undefined)
101
+ return [];
102
+ return [
103
+ recipe(context, `animate:${key}`, "video", {
104
+ kind: "image",
105
+ version: 1,
106
+ provider,
107
+ model: edit.animateModel,
108
+ prompt: animatePrompt,
109
+ aspect: config.format,
110
+ animate: { image: still.fingerprint, seconds: animatedClipSeconds },
111
+ }, [still.key], { unresolved: still.unresolved }),
112
+ ];
113
+ });
114
+ }
115
+ // The images (0-based, in slideshow order) the video and its chapters open on, once the word
116
+ // timing and chapters are saved; undefined until then.
117
+ function openingImages(context, timing, description) {
118
+ const count = context.content.imageOrder.length;
119
+ if (count === 0)
120
+ return [];
121
+ const words = timing === undefined ? undefined : savedWords(context, timing);
122
+ if (words === undefined)
123
+ return undefined;
124
+ const chapters = description === undefined
125
+ ? headingChapters(context.resolved.articleMarkdown ?? "", words)
126
+ : savedChapters(context, description);
127
+ if (chapters === undefined)
128
+ return undefined;
129
+ const shots = chapterOpeningShots({
130
+ fps,
131
+ imageSeconds: context.config.imageSeconds,
132
+ narration: usesNarrationCuts(context.config),
133
+ cutPoints: sentenceCutPoints(words),
134
+ chapterStarts: chapters.map((chapter) => chapter.start),
135
+ });
136
+ return [...new Set(shots.map((shot) => shot % count))];
137
+ }
138
+ const wordsPayload = z.object({
139
+ words: z.array(z.object({ text: z.string(), start: z.number(), end: z.number() })),
140
+ });
141
+ const chaptersPayload = z.object({
142
+ chapters: z.array(z.object({ start: z.number(), title: z.string() })),
143
+ });
144
+ function savedPayload(context, value) {
145
+ const piece = context.manifest.pieces.find((row) => row.key === value.key &&
146
+ selectedReference(row) &&
147
+ row.piece.state === "done" &&
148
+ row.fingerprint === value.fingerprint);
149
+ if (piece?.piece.payload === undefined || piece.piece.payload === null)
150
+ return undefined;
151
+ try {
152
+ return JSON.parse(piece.piece.payload);
153
+ }
154
+ catch {
155
+ return undefined;
156
+ }
157
+ }
158
+ function savedWords(context, timing) {
159
+ const parsed = wordsPayload.safeParse(savedPayload(context, timing));
160
+ return parsed.success ? parsed.data.words : undefined;
161
+ }
162
+ function savedChapters(context, description) {
163
+ const parsed = chaptersPayload.safeParse(savedPayload(context, description));
164
+ return parsed.success ? parsed.data.chapters : undefined;
165
+ }
166
+ // The article's chapter headings, which decide where heading chapters fall; null while the
167
+ // article is not written yet.
168
+ function headingsIdentity(context) {
169
+ const markdown = context.resolved.articleMarkdown;
170
+ return markdown === null ? null : fingerprint(topHeadings(markdown).map((one) => one.title));
171
+ }
172
+ // ceiling: how many chapters "Chapter openers" is charged for before they are known: the
173
+ // article's headings and the opening, or twelve (the most a YouTube description is asked for)
174
+ // while the article is not written yet.
175
+ function chapterEstimate(context) {
176
+ const markdown = context.resolved.articleMarkdown;
177
+ return markdown === null ? 12 : topHeadings(markdown).length + 1;
178
+ }
@@ -1,5 +1,6 @@
1
- import { usesYoutubeDescription } from "../admission/rules.js";
1
+ import { usesShorts, usesYoutubeDescription } from "../admission/rules.js";
2
2
  import { audioExportArgs } from "../video/audio-export-args.js";
3
+ import { editNeedsTiming } from "../video/edit-settings.js";
3
4
  import { recipe, resourceIdentity, } from "./recipe-model.js";
4
5
  export function exportRecipes(context, audio) {
5
6
  const { config, content } = context;
@@ -17,9 +18,13 @@ export function exportRecipes(context, audio) {
17
18
  ],
18
19
  }, audio.keys));
19
20
  const captions = config.subtitles !== undefined && config.subtitles.mode !== "off";
20
- // The YouTube description's chapters use the same word timing, so it runs for them even
21
- // with captions off; only the caption files below wait for captions.
22
- if (!captions && !usesYoutubeDescription(config))
21
+ // The YouTube description's chapters, the shorts' clips and captions, and the video's cuts,
22
+ // chapter cards and chapter openers use the same word timing, so it runs for them even with
23
+ // captions off; only the caption files below wait for captions.
24
+ if (!captions &&
25
+ !usesYoutubeDescription(config) &&
26
+ !usesShorts(config) &&
27
+ !editNeedsTiming(config))
23
28
  return recipes;
24
29
  const timing = recipe(context, "subtitles:timing", "video", {
25
30
  kind: "local",
@@ -66,6 +66,7 @@ export const recipeInputSchema = z.discriminatedUnion("kind", [
66
66
  model: z.string(),
67
67
  prompt: z.string(),
68
68
  aspect: z.enum(["16:9", "9:16"]),
69
+ animate: z.object({ image: z.string(), seconds: z.number() }).strict().optional(),
69
70
  })
70
71
  .strict(),
71
72
  z
@@ -59,6 +59,15 @@ export function legacyOutputWorkKey(output, config) {
59
59
  case "youtube_description":
60
60
  case "youtube_tags":
61
61
  return "youtube:description";
62
+ case "shorts":
63
+ return "shorts:pick";
64
+ case "short_image":
65
+ return `shorts:${String(output.meta.short ?? 1)}:image:${String(output.meta.index ?? 1)}`;
66
+ case "short_video":
67
+ return `shorts:${String(output.meta.short ?? 1)}:render`;
68
+ // Only ever made by a revision, never by the legacy stages.
69
+ case "animated_image":
70
+ return `animate:${String(output.meta.index ?? 1)}`;
62
71
  case "subtitles_srt":
63
72
  case "subtitles_vtt":
64
73
  case "subtitle_ass":
@@ -17,6 +17,8 @@ export const localOperations = [
17
17
  "narration-files-v1",
18
18
  "render-document",
19
19
  "youtube-description-v1",
20
+ "shorts-pick-v1",
21
+ "short-render-v1",
20
22
  ];
21
23
  export const deferredOperations = [
22
24
  "narration-preparation",
@@ -30,6 +32,8 @@ export const deferredOperations = [
30
32
  "intro-narration",
31
33
  "outro-narration",
32
34
  "resolve-revision-recipe",
35
+ "shorts",
36
+ "animate",
33
37
  ];
34
38
  export function recipe(context, key, stage, input, dependsOn = [], options = {}) {
35
39
  const requestFingerprint = input.kind === "tts"
@@ -40,7 +44,9 @@ export function recipe(context, key, stage, input, dependsOn = [], options = {})
40
44
  : fingerprint(JSON.parse(JSON.stringify(input, (_key, value) => typeof value === "number" ? z.number().finite().parse(value) : value)));
41
45
  const token = input.kind === "tts"
42
46
  ? narrationRegenerationToken(context.content.regenerationTokens, input.logicalKey, input.segment)
43
- : (context.content.regenerationTokens[options.tokenKey ?? key] ?? null);
47
+ : options.token !== undefined
48
+ ? options.token
49
+ : (context.content.regenerationTokens[options.tokenKey ?? key] ?? null);
44
50
  const researchToken = stage === "research" ? context.content.regenerationTokens["research:all"] : undefined;
45
51
  const workFingerprint = fingerprint([
46
52
  requestFingerprint,
@@ -5,6 +5,21 @@ export function recipeProviderChoice(recipe, config) {
5
5
  // The YouTube description's request is built when it runs, from the project's LLM row.
6
6
  if (input.kind === "local" && input.operation === "youtube-description-v1")
7
7
  return config.llm === undefined ? undefined : { ...config.llm, family: "llm" };
8
+ // So is the shorts' pick; what stands in for the rest of the step until it lands is
9
+ // mostly images, which are its cost.
10
+ if (input.kind === "local" && input.operation === "shorts-pick-v1")
11
+ return config.llm === undefined ? undefined : { ...config.llm, family: "llm" };
12
+ if (input.kind === "deferred" && input.operation === "shorts")
13
+ return config.images === undefined ? undefined : { ...config.images, family: "image" };
14
+ // The chapter openers' clips are asked of the image provider's image-to-video model.
15
+ if (input.kind === "deferred" && input.operation === "animate")
16
+ return config.images === undefined
17
+ ? undefined
18
+ : {
19
+ provider: config.images.provider,
20
+ model: config.videoEdit?.animateModel ?? "",
21
+ family: "image",
22
+ };
8
23
  if (input.kind !== "deferred")
9
24
  return undefined;
10
25
  const family = input.operation === "narration-preparation"
@@ -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,179 @@
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 { llmInput, renderedPrompt } from "./recipe-text.js";
9
+ // The Shorts step unfolds as its answers land, the way research's chapters unfold from the
10
+ // plan. `shorts:pick` reads the word timing, so it waits for `subtitles:timing` and runs
11
+ // beside the render; until its saved answer matches, one deferred `shorts:future` stands in
12
+ // for everything after it (and carries the images' cost). Each picked clip then gets
13
+ // `shorts:N:prompts`, one LLM call writing its image prompts; once those match, its
14
+ // `shorts:N:image:M` images and its `shorts:N:render`.
15
+ //
16
+ // A clip's work carries the clip's own token (`clipToken`): the pick's token from when the
17
+ // clip was first picked, plus the one "Make this short again" renews under `shorts:N`. So
18
+ // picking the moments again redoes only the clips whose sentences changed, and one short
19
+ // can be made again alone.
20
+ export const shortsPickKey = "shorts:pick";
21
+ export const shortsFutureKey = "shorts:future";
22
+ export function shortsRecipes(context, exports) {
23
+ const { config } = context;
24
+ const shorts = config.shorts;
25
+ const timing = exports.find((value) => value.key === "subtitles:timing");
26
+ if (!usesShorts(config) || shorts === undefined || timing === undefined)
27
+ return [];
28
+ const pick = recipe(context, shortsPickKey, "video", {
29
+ kind: "local",
30
+ version: 1,
31
+ operation: "shorts-pick-v1",
32
+ values: [
33
+ resourceIdentity(context, timing),
34
+ config.llm?.provider ?? "",
35
+ config.llm?.model ?? "",
36
+ config.llm?.thinking ?? null,
37
+ // Blank means the built-in prompt; the step fills it in, so a new built-in wording
38
+ // ships as a new operation version rather than a silent change here.
39
+ shorts.prompt?.trim() ? renderedPrompt(context, "shorts") : "",
40
+ shorts.count,
41
+ shorts.minSeconds,
42
+ shorts.maxSeconds,
43
+ config.title,
44
+ ],
45
+ }, [timing.key],
46
+ // A paid LLM call: readiness, the charge warning and the estimate treat it like one.
47
+ { kind: "provider" });
48
+ const picked = savedPicked(context, pick);
49
+ const images = config.images ?? { provider: "", model: "" };
50
+ const style = shorts.imagePrompt?.trim()
51
+ ? renderedPrompt(context, "shortsImage")
52
+ : defaultShortsImagePrompt;
53
+ if (picked === undefined)
54
+ return [
55
+ pick,
56
+ recipe(context, shortsFutureKey, "video", {
57
+ kind: "deferred",
58
+ version: 1,
59
+ operation: "shorts",
60
+ template: [
61
+ pick.fingerprint,
62
+ style,
63
+ images.provider,
64
+ images.model,
65
+ config.imageSeconds,
66
+ shorts.count,
67
+ shorts.maxSeconds,
68
+ config.llm?.provider ?? "",
69
+ config.llm?.model ?? "",
70
+ ],
71
+ }, [pick.key], { tokenKey: shortsPickKey }),
72
+ ];
73
+ const recipes = [pick];
74
+ const clips = effectiveClips(picked, context.content.shortsRanges, pick.fingerprint, shorts);
75
+ for (const clip of clips) {
76
+ const prefix = `shorts:${String(clip.number)}`;
77
+ const token = clipToken(context, clip);
78
+ const count = shortImageCount(clip.end - clip.start, config.imageSeconds);
79
+ const prompts = {
80
+ ...recipe(context, `${prefix}:prompts`, "video", llmInput(context, imagePromptMessages({
81
+ style,
82
+ videoTitle: config.title,
83
+ shortTitle: clip.title,
84
+ text: clip.text,
85
+ count,
86
+ imageSeconds: config.imageSeconds,
87
+ })), [pick.key], { token }),
88
+ unfoldsImages: { count, provider: images.provider, model: images.model },
89
+ };
90
+ recipes.push(prompts);
91
+ const written = savedPrompts(context, prompts, token);
92
+ if (written === undefined || written.length !== count)
93
+ continue;
94
+ const stills = written.map((prompt, index) => recipe(context, `${prefix}:image:${String(index + 1)}`, "video", {
95
+ kind: "image",
96
+ version: 1,
97
+ provider: images.provider,
98
+ model: images.model,
99
+ prompt,
100
+ aspect: "9:16",
101
+ }, [prompts.key], { token }));
102
+ recipes.push(...stills);
103
+ const extras = renderExtras(context, clip);
104
+ recipes.push(recipe(context, `${prefix}:render`, "video", {
105
+ kind: "local",
106
+ version: 1,
107
+ operation: "short-render-v1",
108
+ values: [
109
+ resourceIdentity(context, timing),
110
+ clip.number,
111
+ clip.start,
112
+ clip.end,
113
+ stills.map((still) => resourceIdentity(context, still)),
114
+ config.imageSeconds,
115
+ config.zoomPercent,
116
+ config.motionStyle,
117
+ config.subtitles?.fontId ?? "default",
118
+ // Only when one of the later settings is in use, so a short rendered before
119
+ // they existed keeps its fingerprint.
120
+ ...(extras === undefined ? [] : [extras]),
121
+ ],
122
+ }, [pick.key, ...stills.map((still) => still.key)], { token }));
123
+ }
124
+ return recipes;
125
+ }
126
+ // What the render draws and plays beyond the clip itself: the title headline, a faster
127
+ // speed and the background music at its level. Undefined when none is in use.
128
+ export function renderExtras(context, clip) {
129
+ const shorts = context.config.shorts;
130
+ if (shorts === undefined)
131
+ return undefined;
132
+ const speed = shortsSpeedOf(shorts);
133
+ const music = context.content.shortsMusic;
134
+ const extras = {
135
+ ...(shorts.titleOnScreen === true ? { title: clip.title } : {}),
136
+ ...(speed === 1 ? {} : { speed }),
137
+ ...(music === undefined ? {} : { music: [music, musicVolumeOf(shorts)] }),
138
+ };
139
+ return Object.keys(extras).length === 0 ? undefined : extras;
140
+ }
141
+ // The token a clip's prompts, images and render carry: the pick's token from when the clip
142
+ // was first picked (the current one for a pick saved before clips kept theirs), with the
143
+ // clip's own "Make this short again" token when it has one.
144
+ export function clipToken(context, clip) {
145
+ const tokens = context.content.regenerationTokens;
146
+ const base = clip.seed !== undefined ? clip.seed : (tokens[shortsPickKey] ?? null);
147
+ const own = tokens[shortRemakeKey(clip.number)];
148
+ return own === undefined ? base : fingerprint([base, own]);
149
+ }
150
+ // The regeneration key "Make this short again" renews for short N.
151
+ export function shortRemakeKey(number) {
152
+ return `shorts:${String(number)}`;
153
+ }
154
+ // What the pick saved, when its saved answer is the one this plan asks for.
155
+ export function savedPicked(context, pick) {
156
+ const piece = context.manifest.pieces.find((row) => row.key === pick.key &&
157
+ selectedReference(row) &&
158
+ row.piece.state === "done" &&
159
+ row.fingerprint === pick.fingerprint);
160
+ return pickedShortsOf(piece?.piece.payload);
161
+ }
162
+ // A clip's saved image prompts, when they answer this plan's request. The request carries
163
+ // the model's thinking settings from the catalogue, which the plan without a catalogue has
164
+ // not got, so either fingerprint matches (like `matchingText`).
165
+ function savedPrompts(context, prompts, token) {
166
+ const logical = context.catalogue === undefined || prompts.input.kind !== "llm"
167
+ ? prompts.fingerprint
168
+ : recipe({ content: context.content }, prompts.key, prompts.stage, { ...prompts.input, thinkingConfig: null }, prompts.dependsOn, { token }).fingerprint;
169
+ const piece = context.manifest.pieces.find((row) => row.key === prompts.key &&
170
+ selectedReference(row) &&
171
+ row.piece.state === "done" &&
172
+ (row.fingerprint === prompts.fingerprint || row.fingerprint === logical));
173
+ if (piece?.piece.payload === undefined || piece.piece.payload === null)
174
+ return undefined;
175
+ const parsed = z
176
+ .object({ prompts: z.array(z.string()) })
177
+ .safeParse(JSON.parse(piece.piece.payload));
178
+ return parsed.success ? parsed.data.prompts : undefined;
179
+ }