@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
@@ -0,0 +1,38 @@
1
+ export const requestLimits = { requestMs: 300_000, uploadIdleMs: 300_000 };
2
+ // Node's `requestTimeout` (5 minutes) is one limit for the whole server: every
3
+ // `connectionsCheckingInterval` it answers 408 and closes any connection whose current request
4
+ // started longer ago than that and has not fully arrived, however steadily its body is still
5
+ // coming in. A multi-GB backup over a slow link needs longer, so the server's limit is switched
6
+ // off and the same limit is kept here per request, with one exception: Import everything
7
+ // (PUT /api/storage/import) may take as long as it needs, and is only cut off when it sends
8
+ // nothing at all for `uploadIdleMs`. `headersTimeout` (1 minute) is Node's and still applies to
9
+ // every request.
10
+ export function limitRequestTimes(server, limits = requestLimits) {
11
+ if (!("requestTimeout" in server))
12
+ return;
13
+ server.requestTimeout = 0;
14
+ server.on("request", (request) => {
15
+ if (isBackupUpload(request)) {
16
+ // Idle only while the file is arriving; once it is in, the import may take its time.
17
+ request.setTimeout(limits.uploadIdleMs);
18
+ request.once("end", () => request.setTimeout(0));
19
+ return;
20
+ }
21
+ const timer = setTimeout(() => {
22
+ if (request.complete)
23
+ return;
24
+ // What Node itself does: a 408 if nothing has been answered on this connection yet.
25
+ const socket = request.socket;
26
+ if (socket.writable && socket.bytesWritten === 0)
27
+ socket.write("HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n");
28
+ socket.destroy();
29
+ }, limits.requestMs);
30
+ timer.unref();
31
+ const clear = () => clearTimeout(timer);
32
+ request.once("end", clear);
33
+ request.once("close", clear);
34
+ });
35
+ }
36
+ function isBackupUpload(request) {
37
+ return request.method === "PUT" && (request.url ?? "").split("?")[0] === "/api/storage/import";
38
+ }
@@ -0,0 +1,16 @@
1
+ -- Library prompts of the new "shorts" kind: what the Video stage's Shorts step asks the text
2
+ -- model for when it picks the clips. SQLite cannot change a CHECK in place, so the table is
3
+ -- copied into one whose CHECK lists the new kind, like 0015 did for "description".
4
+ CREATE TABLE prompts_shorts_upgrade (
5
+ id TEXT PRIMARY KEY,
6
+ kind TEXT NOT NULL CHECK (kind IN ('article','image','thumbnail','narration','description','shorts')),
7
+ name TEXT NOT NULL,
8
+ body TEXT NOT NULL,
9
+ slots TEXT NOT NULL,
10
+ updated_at TEXT NOT NULL
11
+ );
12
+ INSERT INTO prompts_shorts_upgrade (id,kind,name,body,slots,updated_at)
13
+ SELECT id,kind,name,body,slots,updated_at FROM prompts;
14
+ DROP TABLE prompts;
15
+ ALTER TABLE prompts_shorts_upgrade RENAME TO prompts;
16
+ CREATE UNIQUE INDEX prompts_name ON prompts(kind,lower(name));
@@ -5,11 +5,13 @@ import { isProviderError, providerError } from "../ports/model.js";
5
5
  // Four attempts - the first plus 3 retries - waiting 2 s, 8 s, 30 s.
6
6
  export const attemptLimit = 4;
7
7
  export const backoffMs = [2000, 8000, 30_000];
8
- // Each attempt times out at 120 s, image calls at 300 s.
8
+ // Each attempt times out at 120 s, image calls at 300 s, and an image animated into a clip at
9
+ // 900 s: image-to-video models queue and take one to five minutes when they run.
9
10
  export const timeoutMs = {
10
11
  llm: 120_000,
11
12
  tts: 120_000,
12
13
  image: 300_000,
14
+ video: 900_000,
13
15
  };
14
16
  // A refusal and an unsupported capability are the provider's final answer; retrying only
15
17
  // spends the user's money again. The third is a key removed mid-run, which leaves nothing
@@ -131,6 +133,7 @@ const callee = {
131
133
  llm: "AI model",
132
134
  tts: "narration provider",
133
135
  image: "image provider",
136
+ video: "image-to-video model",
134
137
  };
135
138
  // Node's `fetch` rejects a request that never reached the server with this TypeError.
136
139
  function unreachable(error) {
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { providerError } from "../ports/model.js";
2
3
  import { attempt } from "./attempt.js";
3
4
  export function stageProviders(deps, context, pieceId) {
4
5
  const ctx = {
@@ -149,6 +150,25 @@ export function stageProviders(deps, context, pieceId) {
149
150
  // One request, one answer: the 300 s runs over the whole call.
150
151
  { kind: "image" }));
151
152
  },
153
+ animate: (call) => {
154
+ const port = deps.registry.image(call.provider);
155
+ const animate = port.animate;
156
+ if (animate === undefined)
157
+ return Promise.reject(providerError({
158
+ kind: "unsupported",
159
+ message: `${call.provider} can't turn images into video clips. Choose fal.ai or Replicate as the image provider in Edit project → Providers, or turn Animate images off in Edit project → Inputs → Look.`,
160
+ }));
161
+ return schedule(call.provider, () => attempt(ctx, (signal) => animate({
162
+ model: call.model,
163
+ prompt: call.prompt,
164
+ image: call.image,
165
+ aspect: call.aspect,
166
+ seconds: call.seconds,
167
+ signal,
168
+ }),
169
+ // Queued and slow: the 900 s runs over the whole call, polling included.
170
+ { kind: "video" }));
171
+ },
152
172
  forPiece: (piece) => stageProviders(deps, context, piece),
153
173
  };
154
174
  }
package/dist/main.js CHANGED
@@ -16,6 +16,7 @@ import { createHub } from "./edge/events/hub.js";
16
16
  import { currentProjectEvent } from "./edge/events/visibility.js";
17
17
  import { createApp } from "./edge/http/app.js";
18
18
  import { createMutationLifecycle, drainMutationsWithDeadline } from "./edge/http/mutations.js";
19
+ import { limitRequestTimes } from "./edge/http/timeouts.js";
19
20
  import { openFolder } from "./edge/open-folder.js";
20
21
  import { createAudioPreviewStore } from "./kernel/audio-preview.js";
21
22
  import { systemClock } from "./kernel/clock.js";
@@ -517,6 +518,8 @@ function listen(app, config, log) {
517
518
  });
518
519
  resolve(server);
519
520
  });
521
+ // Before the first connection: a backup import may outlast Node's 5-minute request limit.
522
+ limitRequestTimes(server);
520
523
  server.once("error", reject);
521
524
  });
522
525
  }
@@ -1,4 +1,6 @@
1
1
  import { stageKinds } from "../../kernel/pipeline.js";
2
+ import { shortsSettingsProblems } from "../shorts/model.js";
3
+ import { usesAnimation, videoEditProblems } from "../video/edit-settings.js";
2
4
  import { sourceOf } from "./model.js";
3
5
  export const titleMax = 200;
4
6
  export const valueMax = 200;
@@ -81,7 +83,8 @@ export function admit(input) {
81
83
  draft.intro?.mode === "llm" ||
82
84
  draft.outro?.mode === "llm" ||
83
85
  usesNarrationPreparation(draft) ||
84
- usesYoutubeDescription(draft);
86
+ usesYoutubeDescription(draft) ||
87
+ usesShorts(draft);
85
88
  if (needsLlm && !chosen(draft.llm)) {
86
89
  fields.push({ field: "llm", message: "Choose a text (LLM) provider and model." });
87
90
  }
@@ -103,7 +106,9 @@ export function admit(input) {
103
106
  }
104
107
  if ((sources.images === "generate" ||
105
108
  sources.thumbnail === "from_prompt" ||
106
- sources.thumbnail === "prompt_by_llm") &&
109
+ sources.thumbnail === "prompt_by_llm" ||
110
+ usesShorts(draft) ||
111
+ usesAnimation(draft)) &&
107
112
  !chosen(draft.images)) {
108
113
  fields.push({ field: "images", message: "Choose an image provider and model." });
109
114
  }
@@ -118,6 +123,8 @@ export function admit(input) {
118
123
  });
119
124
  }
120
125
  fields.push(...youtubeDescriptionFields(draft));
126
+ fields.push(...shortsFields(draft));
127
+ fields.push(...videoEditFields(draft));
121
128
  checkProvided(draft, input.staged, fields);
122
129
  checkValues(draft, input.requiredSlots, fields);
123
130
  if (!Number.isFinite(draft.silenceGapSeconds) ||
@@ -305,6 +312,41 @@ export function youtubeDescriptionFields(draft) {
305
312
  ]
306
313
  : [];
307
314
  }
315
+ // Shorts are cut from the narration and captioned from its word timings, so they need
316
+ // narration, like the YouTube description; they run whether the video renders or not.
317
+ export function usesShorts(draft) {
318
+ return draft.shorts?.enabled === true && draft.sources.audio !== "off";
319
+ }
320
+ export function shortsFields(draft) {
321
+ const shorts = draft.shorts;
322
+ if (shorts?.enabled !== true)
323
+ return [];
324
+ if (draft.sources.audio === "off")
325
+ return [
326
+ {
327
+ field: "shorts.enabled",
328
+ message: "Shorts are cut from the narration. Turn narration on, or turn Shorts off.",
329
+ },
330
+ ];
331
+ const fields = shortsSettingsProblems(shorts).map((problem) => ({
332
+ field: `shorts.${problem.field}`,
333
+ message: problem.message,
334
+ }));
335
+ // The shorts hold each image as long as the video does. With the video off nothing else
336
+ // checks the number.
337
+ const imageProblem = draft.sources.video === "generate" ? undefined : imageSecondsProblem(draft.imageSeconds);
338
+ if (imageProblem !== undefined)
339
+ fields.push({ field: "imageSeconds", message: imageProblem });
340
+ return fields;
341
+ }
342
+ // The Video stage's edit settings, checked only while the video renders: a hidden setting
343
+ // cannot hold up a run.
344
+ export function videoEditFields(draft) {
345
+ return videoEditProblems(draft).map((problem) => ({
346
+ field: `videoEdit.${problem.field}`,
347
+ message: problem.message,
348
+ }));
349
+ }
308
350
  function chosen(choice) {
309
351
  return choice !== undefined && choice.provider.trim() !== "" && choice.model.trim() !== "";
310
352
  }
@@ -4,6 +4,7 @@ import { checkpointStageSchema } from "../checkpoints/schema.js";
4
4
  import { documentSettingsSchema } from "../document/theme-schema.js";
5
5
  import { chunkModes } from "../narration/chunk.js";
6
6
  import { subtitleConfigSchema } from "../subtitles/model.js";
7
+ import { animateModes, atmospheres, colorGrades, cutModes, lookLevels, transitionKinds, } from "../video/edit-settings.js";
7
8
  import { entryModes, formats, motionStyles, stageSources } from "./model.js";
8
9
  import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "./rules.js";
9
10
  const providerChoice = z.object({
@@ -12,6 +13,19 @@ const providerChoice = z.object({
12
13
  thinking: z.enum(thinkingModes).optional(),
13
14
  });
14
15
  const entryChoice = z.object({ name: z.string(), mode: z.enum(entryModes) });
16
+ export const videoEditSchema = z.object({
17
+ cuts: z.enum(cutModes),
18
+ transition: z.enum(transitionKinds),
19
+ transitionSeconds: z.number(),
20
+ vignette: z.enum(lookLevels),
21
+ grain: z.enum(lookLevels),
22
+ grade: z.enum(colorGrades),
23
+ atmosphere: z.enum(atmospheres),
24
+ chapterCards: z.boolean(),
25
+ animate: z.enum(animateModes),
26
+ animateEvery: z.number(),
27
+ animateModel: z.string(),
28
+ });
15
29
  // The shape Play posts and the shape `projects.config` holds, in one place: the second
16
30
  // is the first plus the rendered prompt texts.
17
31
  export const runDraftSchema = z.object({
@@ -86,6 +100,23 @@ export const runDraftSchema = z.object({
86
100
  document: documentSettingsSchema.optional(),
87
101
  youtubeDescription: z.boolean().optional(),
88
102
  descriptionPrompt: z.string().optional(),
103
+ // The ranges are `slices/shorts/model.ts`'s, checked by admission, not the schema's.
104
+ shorts: z
105
+ .object({
106
+ enabled: z.boolean(),
107
+ count: z.number(),
108
+ minSeconds: z.number(),
109
+ maxSeconds: z.number(),
110
+ prompt: z.string().optional(),
111
+ imagePrompt: z.string().optional(),
112
+ titleOnScreen: z.boolean().optional(),
113
+ fullVideoLink: z.string().optional(),
114
+ musicVolume: z.number().optional(),
115
+ speed: z.number().optional(),
116
+ })
117
+ .optional(),
118
+ // The ranges are `slices/video/edit-settings.ts`'s, checked by admission, not the schema's.
119
+ videoEdit: videoEditSchema.optional(),
89
120
  });
90
121
  export const runConfigSchema = runDraftSchema.extend({
91
122
  rendered: z.record(z.string(), z.string()),
@@ -1,12 +1,17 @@
1
1
  import { z } from "zod";
2
2
  import { sourceOf } from "../admission/model.js";
3
- import { usesNarrationPreparation, usesYoutubeDescription } from "../admission/rules.js";
3
+ import { imageSecondsProblem, usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
4
4
  import { plainText } from "../article/plain.js";
5
5
  import { splitEndMatter } from "../article/split.js";
6
6
  import { chunkNarration, defaultChunking } from "../narration/chunk.js";
7
7
  import { normalizeNarrationText } from "../narration/plan.js";
8
8
  import { preparationMessages } from "../narration/preparation.js";
9
+ import { defaultShortsImagePrompt, defaultShortsPrompt, shortsImageUpperBound, shortsSettingsProblems, } from "../shorts/model.js";
10
+ import { animatedClipSeconds, animatedImageIndexes, usesAnimation, videoEditProblems, } from "../video/edit-settings.js";
9
11
  import { defaultDescriptionPrompt } from "../youtube/model.js";
12
+ // ceiling: the most chapters "Chapter openers" is charged for before the article exists, the
13
+ // top of the 5-12 chapters the built-in YouTube description prompt asks for.
14
+ const chapterOpenersMax = 12;
10
15
  import { estimateRequests, groupEstimateRows } from "./requests.js";
11
16
  export { estimateRequests } from "./requests.js";
12
17
  export function estimateRun(draft, rendered, expectedWords, catalogue) {
@@ -34,7 +39,10 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
34
39
  !model.deprecated)?.pricing.note ?? "Model or account pricing is unknown.";
35
40
  const generatedArticle = draft.sources.article === "generate";
36
41
  const articleChars = generatedArticle ? expectedWords * 6 : (draft.provided.article?.length ?? 0);
37
- const promptChars = Object.entries(rendered).reduce((sum, [key, value]) => sum + (key === "narration" || key === "description" ? 0 : value.length), 0);
42
+ const promptChars = Object.entries(rendered).reduce((sum, [key, value]) => sum +
43
+ (key === "narration" || key === "description" || key === "shorts" || key === "shortsImage"
44
+ ? 0
45
+ : value.length), 0);
38
46
  const local = (stage, detail) => {
39
47
  requests.push({ kind: "local", stage, detail });
40
48
  };
@@ -132,6 +140,53 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
132
140
  : defaultDescriptionPrompt.length) +
133
141
  Math.round(articleChars * 1.05) +
134
142
  1500, 2400, "One LLM call on the timed transcript.");
143
+ // Shorts: one call picks the clips from the numbered transcript, one per clip writes its
144
+ // image prompts, and the images are charged at the most the step can ask for, every clip
145
+ // at the longest length allowed.
146
+ const shorts = draft.shorts;
147
+ // Numbers the run would refuse are not priced: a typo of 3000 shorts would list them all.
148
+ if (usesShorts(draft) &&
149
+ shorts !== undefined &&
150
+ shortsSettingsProblems(shorts).length === 0 &&
151
+ imageSecondsProblem(draft.imageSeconds) === undefined) {
152
+ text("Shorts", (rendered.shorts?.trim() ? rendered.shorts.length : defaultShortsPrompt.length) +
153
+ Math.round(articleChars * 1.15) +
154
+ 1500, 400 * shorts.count, "One LLM call on the numbered transcript picks the clips.");
155
+ const style = rendered.shortsImage?.trim()
156
+ ? rendered.shortsImage.length
157
+ : defaultShortsImagePrompt.length;
158
+ for (let clip = 0; clip < shorts.count; clip++)
159
+ text("Shorts", style + 3000, 2400, "One LLM call per short writes its image prompts.");
160
+ const count = shortsImageUpperBound(shorts, draft.imageSeconds);
161
+ for (let index = 0; index < count; index++)
162
+ requests.push({
163
+ kind: "image",
164
+ stage: "Shorts",
165
+ provider: image.provider,
166
+ model: image.model,
167
+ detail: `Up to ${String(count)} vertical images. ${imageNote}`,
168
+ });
169
+ }
170
+ // Animated images: one clip from the image-to-video model per animated image, each priced
171
+ // as the catalogue prices one clip. Only drawn images are animated; the chapter openers are
172
+ // charged at the most there can be, one per image up to twelve chapters.
173
+ const edit = draft.videoEdit;
174
+ if (usesAnimation(draft) && edit !== undefined && videoEditProblems(draft).length === 0) {
175
+ const clips = edit.animate === "every"
176
+ ? animatedImageIndexes(edit, images).length
177
+ : Math.min(images, chapterOpenersMax);
178
+ const note = data.image.find((model) => model.provider === image.provider && model.id === edit.animateModel)?.pricing.note ?? "Model or account pricing is unknown.";
179
+ for (let index = 0; index < clips; index++)
180
+ requests.push({
181
+ kind: "image",
182
+ stage: "Animated images",
183
+ provider: image.provider,
184
+ model: edit.animateModel,
185
+ detail: edit.animate === "every"
186
+ ? `${String(clips)} clips of ${String(animatedClipSeconds)} seconds. ${note}`
187
+ : `Up to ${String(clips)} clips of ${String(animatedClipSeconds)} seconds, one per chapter opening. ${note}`,
188
+ });
189
+ }
135
190
  if (sourceOf(draft.sources, "document") === "generate")
136
191
  local("Document", "Laid out locally from the article; no API fee.");
137
192
  return {
@@ -9,6 +9,9 @@ import { readFontMetadata } from "./sfnt.js";
9
9
  export const fontIdPattern = /^(?:default|(?:system|uploaded)-[a-f0-9]{64})$/;
10
10
  const uploadName = /^(uploaded-[a-f0-9]{64})\.(ttf|otf)$/;
11
11
  const bundled = fileURLToPath(new URL("../../assets/fonts/Barlow-Regular.ttf", import.meta.url));
12
+ // Barlow's own Bold face, for text drawn bold in the bundled font (the shorts' captions);
13
+ // not listed as a font of its own.
14
+ const bundledBold = fileURLToPath(new URL("../../assets/fonts/Barlow-Bold.ttf", import.meta.url));
12
15
  export async function listFonts(paths) {
13
16
  const [defaultFont, uploaded, system] = await Promise.all([
14
17
  bundledFont(),
@@ -30,6 +33,26 @@ export async function resolveFont(paths, id) {
30
33
  throw missingFont();
31
34
  return font;
32
35
  }
36
+ // The font to draw bold text in: for the bundled Barlow its real Bold face, so the letters
37
+ // are drawn as designed; any other font is the font itself, which libass emboldens.
38
+ export async function resolveBoldFont(paths, id) {
39
+ if (id !== "default")
40
+ return resolveFont(paths, id);
41
+ const bytes = await readFontFile(bundledBold);
42
+ const face = bytes === undefined ? undefined : readFontMetadata(bytes)?.[0];
43
+ if (face === undefined)
44
+ throw new Error("Slopify's bundled Barlow Bold font is missing or invalid. Reinstall Slopify.");
45
+ return {
46
+ id: "default",
47
+ name: face.name,
48
+ family: face.family,
49
+ source: "bundled",
50
+ path: bundledBold,
51
+ extension: ".ttf",
52
+ assName: face.assName,
53
+ faceIndex: 0,
54
+ };
55
+ }
33
56
  export function missingFont() {
34
57
  return new Error("The chosen caption font is no longer available; it may have been uninstalled or deleted. Choose another font in Edit project → Subtitles, then try again.", {
35
58
  cause: "font-not-found",
@@ -1,4 +1,4 @@
1
- export { fontIdPattern, isMissingFont, listFonts, resolveFont } from "./catalog.js";
1
+ export { fontIdPattern, isMissingFont, listFonts, resolveBoldFont, resolveFont, } from "./catalog.js";
2
2
  export { fontMaxBytes } from "./model.js";
3
3
  export { previewFont } from "./preview.js";
4
4
  export { standaloneFont } from "./sfnt.js";
@@ -2,7 +2,14 @@
2
2
  // set covers both, which is why the drafts, the lint and the save path below are shared
3
3
  // rather than mirrored.
4
4
  export { entryModes } from "../admission/model.js";
5
- export const promptKinds = ["article", "image", "thumbnail", "narration", "description"];
5
+ export const promptKinds = [
6
+ "article",
7
+ "image",
8
+ "thumbnail",
9
+ "narration",
10
+ "description",
11
+ "shorts",
12
+ ];
6
13
  export const entryCategories = ["intro", "outro"];
7
14
  // The name is what the Play pickers show, so it shares admission's title bound.
8
15
  export const nameMax = 200;
@@ -2,7 +2,7 @@
2
2
  // the picked templates, the slot names they ask for, and the rendered text stored on the
3
3
  // project. The bodies are read at the click, not at selection, so an edit made in between
4
4
  // is the one that runs.
5
- import { normaliseDraft, usesNarrationPreparation, usesYoutubeDescription, } from "../admission/rules.js";
5
+ import { normaliseDraft, usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
6
6
  import { collectFields, render } from "../admission/substitute.js";
7
7
  import { snapshotEntry, snapshotPrompt } from "./snapshot.js";
8
8
  export function pickTemplates(db, input, snapshot) {
@@ -25,6 +25,11 @@ export function pickTemplates(db, input, snapshot) {
25
25
  // None picked is the built-in prompt, which asks for no keyword.
26
26
  if (usesYoutubeDescription(draft))
27
27
  body(db, "description", draft.descriptionPrompt, "descriptionPrompt", missing, text, "description", snapshot);
28
+ // The Shorts step's two prompts; none picked is the built-in one, which asks for no keyword.
29
+ if (usesShorts(draft)) {
30
+ body(db, "shorts", draft.shorts?.prompt, "shorts.prompt", missing, text, "shorts", snapshot);
31
+ body(db, "image", draft.shorts?.imagePrompt, "shorts.imagePrompt", missing, image, "shortsImage", snapshot);
32
+ }
28
33
  if (sources.images === "generate") {
29
34
  for (const [index, picked] of draft.imagePrompts.entries()) {
30
35
  const field = `imagePrompts.${String(index)}.name`;
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultZoomPercent, edgeSilenceSecondsProblem, imageSecondsProblem, numberPerPromptMax, zoomPercentProblem, } from "../admission/rules.js";
3
3
  import { runDraftSchema } from "../admission/schema.js";
4
4
  import { draftDocumentThemeOf } from "../document/model.js";
5
+ import { defaultShorts, shortsExtrasOf, shortsSettingsProblems, } from "../shorts/model.js";
5
6
  import { defaultSubtitles } from "../subtitles/model.js";
6
7
  export function toAdmissionDraft(input) {
7
8
  const { form } = input.document;
@@ -66,6 +67,36 @@ export function toAdmissionDraft(input) {
66
67
  ? "files"
67
68
  : form.subtitles.mode;
68
69
  const chunkMode = sources.audio === "generate" ? form.chunking.mode : "whole";
70
+ const shortsOn = sources.audio !== "off" && form.shorts?.enabled === true;
71
+ // Refused in admission's words, so Play and the run say the same sentence.
72
+ const shortsOf = (raw) => {
73
+ const whole = (value) => (value.trim() === "" ? Number.NaN : Number(value));
74
+ const settings = {
75
+ enabled: true,
76
+ count: whole(raw.count),
77
+ minSeconds: whole(raw.minSeconds),
78
+ maxSeconds: whole(raw.maxSeconds),
79
+ ...(raw.prompt.trim() ? { prompt: raw.prompt } : {}),
80
+ ...(raw.imagePrompt.trim() ? { imagePrompt: raw.imagePrompt } : {}),
81
+ ...shortsExtrasOf(raw),
82
+ };
83
+ for (const problem of shortsSettingsProblems(settings))
84
+ fields.push({ field: `shorts.${problem.field}`, message: problem.message });
85
+ const { musicVolume, speed, ...rest } = settings;
86
+ return {
87
+ ...rest,
88
+ // A number already refused above is left out rather than saved as NaN.
89
+ ...(musicVolume !== undefined && Number.isFinite(musicVolume) ? { musicVolume } : {}),
90
+ ...(speed !== undefined && Number.isFinite(speed) ? { speed } : {}),
91
+ count: Number.isFinite(settings.count) ? settings.count : defaultShorts.count,
92
+ minSeconds: Number.isFinite(settings.minSeconds)
93
+ ? settings.minSeconds
94
+ : defaultShorts.minSeconds,
95
+ maxSeconds: Number.isFinite(settings.maxSeconds)
96
+ ? settings.maxSeconds
97
+ : defaultShorts.maxSeconds,
98
+ };
99
+ };
69
100
  const draft = {
70
101
  ...(form.checkpoints === undefined ? {} : { checkpoints: form.checkpoints }),
71
102
  title: form.title,
@@ -99,6 +130,9 @@ export function toAdmissionDraft(input) {
99
130
  ...(form.descriptionPrompt?.trim() ? { descriptionPrompt: form.descriptionPrompt } : {}),
100
131
  }
101
132
  : {}),
133
+ // Cut from the narration too, so the same rule: a switch left on with narration Off asks
134
+ // for nothing.
135
+ ...(shortsOn && form.shorts !== undefined ? { shorts: shortsOf(form.shorts) } : {}),
102
136
  imagePrompts: sources.images === "generate"
103
137
  ? form.imagePrompts.map((prompt, index) => ({
104
138
  name: prompt.name,
@@ -144,11 +178,13 @@ export function toAdmissionDraft(input) {
144
178
  fontSize: number(form.subtitles.fontSize, "subtitles.fontSize", 120, 16),
145
179
  },
146
180
  silenceGapSeconds: input.silenceGapSeconds,
147
- imageSeconds: measure("imageSeconds", sources.video === "generate", defaultImageSeconds, imageSecondsProblem),
181
+ imageSeconds: measure("imageSeconds", sources.video === "generate" || shortsOn, defaultImageSeconds, imageSecondsProblem),
148
182
  zoomPercent: measure("zoomPercent", sources.video === "generate", defaultZoomPercent, zoomPercentProblem),
149
183
  // A choice from a list, so there is nothing to refuse; a run without a video keeps it
150
184
  // for when the video is turned back on.
151
185
  motionStyle: form.motionStyle,
186
+ // Kept the same way; admission checks it only while the video renders.
187
+ ...(form.videoEdit === undefined ? {} : { videoEdit: form.videoEdit }),
152
188
  edgeSilenceSeconds: measure("edgeSilenceSeconds", sources.audio !== "off", defaultEdgeSilenceSeconds, edgeSilenceSecondsProblem),
153
189
  };
154
190
  const parsed = runDraftSchema.safeParse(draft);
@@ -1,9 +1,10 @@
1
1
  import { checkRuntimeModel } from "../../catalog/runtime-models.js";
2
2
  import { modelFields } from "../../catalog/validate.js";
3
3
  import { readinessIsUsable } from "../../kernel/ports/model.js";
4
- import { usesNarrationPreparation, usesYoutubeDescription, } from "../admission/rules.js";
4
+ import { usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
5
5
  import { cliPathChanged } from "../settings/cli-paths.js";
6
6
  import { hasKey, listVoices } from "../settings/repo.js";
7
+ import { usesAnimation } from "../video/edit-settings.js";
7
8
  export function choices(runs) {
8
9
  const selected = runs
9
10
  .flatMap(({ draft: d }) => [
@@ -17,7 +18,8 @@ export function choices(runs) {
17
18
  d.intro?.mode === "llm" ||
18
19
  d.outro?.mode === "llm" ||
19
20
  usesNarrationPreparation(d) ||
20
- usesYoutubeDescription(d),
21
+ usesYoutubeDescription(d) ||
22
+ usesShorts(d),
21
23
  },
22
24
  {
23
25
  field: "audio",
@@ -30,7 +32,9 @@ export function choices(runs) {
30
32
  family: "image",
31
33
  choice: d.images,
32
34
  needed: d.sources.images === "generate" ||
33
- ["from_prompt", "prompt_by_llm"].includes(d.sources.thumbnail),
35
+ ["from_prompt", "prompt_by_llm"].includes(d.sources.thumbnail) ||
36
+ usesShorts(d) ||
37
+ usesAnimation(d),
34
38
  },
35
39
  ])
36
40
  .flatMap((c) => (c.needed && c.choice ? [{ ...c, ...c.choice }] : []));
@@ -3,7 +3,7 @@ import { formats, stageKinds } from "../../kernel/pipeline.js";
3
3
  import { thinkingModes } from "../../kernel/ports/llm.js";
4
4
  import { motionStyles, stageSources } from "../admission/model.js";
5
5
  import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "../admission/rules.js";
6
- import { runDraftSchema } from "../admission/schema.js";
6
+ import { runDraftSchema, videoEditSchema } from "../admission/schema.js";
7
7
  import { checkpointRowSchema, checkpointStageSchema } from "../checkpoints/schema.js";
8
8
  import { documentSettingsSchema } from "../document/theme-schema.js";
9
9
  import { librarySnapshotSchema } from "../library/snapshot.js";
@@ -50,6 +50,26 @@ export const playDraftFormSchema = z
50
50
  // Absent on drafts and templates saved before the YouTube description: off, built-in prompt.
51
51
  youtubeDescription: z.boolean().optional(),
52
52
  descriptionPrompt: text.optional(),
53
+ // Absent on drafts and templates saved before Shorts: off. The numbers are raw text, like
54
+ // every other number on Play; the prompts are names, "" being the built-in ones.
55
+ shorts: z
56
+ .object({
57
+ enabled: z.boolean(),
58
+ count: text,
59
+ minSeconds: text,
60
+ maxSeconds: text,
61
+ prompt: text,
62
+ imagePrompt: text,
63
+ // Absent on drafts saved before these: the title off, no link, the music at its
64
+ // default level, normal speed. The two numbers are raw text too.
65
+ titleOnScreen: z.boolean().optional(),
66
+ fullVideoLink: text.optional(),
67
+ musicVolume: text.optional(),
68
+ speed: text.optional(),
69
+ })
70
+ .strict()
71
+ .readonly()
72
+ .optional(),
53
73
  imagePrompts: z.array(z.object({ name: text, number: text }).strict().readonly()).readonly(),
54
74
  thumbnailPrompt: text,
55
75
  intro: text,
@@ -74,6 +94,9 @@ export const playDraftFormSchema = z
74
94
  edgeSilenceSeconds: text.default(String(defaultEdgeSilenceSeconds)),
75
95
  zoomPercent: text.default(String(defaultZoomPercent)),
76
96
  motionStyle: z.enum(motionStyles).default(defaultMotionStyle),
97
+ // Absent on drafts and templates saved before the edit settings: today's slideshow. Every
98
+ // field is a pick from a list, so it is kept as the settings themselves.
99
+ videoEdit: videoEditSchema.strict().readonly().optional(),
77
100
  values,
78
101
  provided: z
79
102
  .object({
@@ -7,6 +7,7 @@ import { listCheckpoints } from "../checkpoints/repo.js";
7
7
  import { documentThemeOf } from "../document/model.js";
8
8
  import { requestHash } from "../play-drafts/repo.js";
9
9
  import { currentRevisionId, revisionById } from "../revisions/repo.js";
10
+ import { shortsExtrasForm } from "../shorts/model.js";
10
11
  import { createTemplate, readTemplate } from "./service.js";
11
12
  export const projectTemplateCreateSchema = z
12
13
  .object({
@@ -97,6 +98,11 @@ function documentFromProject(deps, revision) {
97
98
  return { name: prompt.name, number: String(prompt.number) };
98
99
  });
99
100
  addPrompt("thumbnail", config.thumbnailPrompt, "thumbnailPrompt");
101
+ addPrompt("shorts", config.shorts?.prompt, "shorts");
102
+ // The shorts' image style may be the same Library prompt one of the slideshow images uses,
103
+ // and a snapshot holds one prompt per kind and name.
104
+ if (!prompts.some((prompt) => prompt.kind === "image" && prompt.name === config.shorts?.imagePrompt))
105
+ addPrompt("image", config.shorts?.imagePrompt, "shortsImage");
100
106
  for (const category of ["intro", "outro"]) {
101
107
  const choice = config[category];
102
108
  const body = revision.content.promptTemplates[category] ?? config.rendered[category];
@@ -147,6 +153,22 @@ function documentFromProject(deps, revision) {
147
153
  ...(config.descriptionPrompt === undefined
148
154
  ? {}
149
155
  : { descriptionPrompt: config.descriptionPrompt }),
156
+ ...(config.shorts === undefined
157
+ ? {}
158
+ : {
159
+ shorts: {
160
+ enabled: config.shorts.enabled,
161
+ count: String(config.shorts.count),
162
+ minSeconds: String(config.shorts.minSeconds),
163
+ maxSeconds: String(config.shorts.maxSeconds),
164
+ prompt: config.shorts.prompt ?? "",
165
+ imagePrompt: config.shorts.imagePrompt ?? "",
166
+ ...shortsExtrasForm(config.shorts),
167
+ },
168
+ }),
169
+ // A project without edit settings makes a template without them, so a video made from
170
+ // it cuts every N seconds like the project did.
171
+ ...(config.videoEdit === undefined ? {} : { videoEdit: config.videoEdit }),
150
172
  imagePrompts,
151
173
  thumbnailPrompt: config.thumbnailPrompt ?? "",
152
174
  intro: config.intro?.name ?? "",
@@ -8,6 +8,8 @@ export function templateSetup(deps, input) {
8
8
  { kind: "description", name: input.form.descriptionPrompt ?? "" },
9
9
  ...input.form.imagePrompts.map((row) => ({ kind: "image", name: row.name })),
10
10
  { kind: "thumbnail", name: input.form.thumbnailPrompt },
11
+ { kind: "shorts", name: input.form.shorts?.prompt ?? "" },
12
+ { kind: "image", name: input.form.shorts?.imagePrompt ?? "" },
11
13
  ];
12
14
  for (const choice of choices) {
13
15
  if (choice.name === "")