@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
package/dist/main.js CHANGED
@@ -12,10 +12,11 @@ import { nodeRunCli } from "./adapters/llm/run-cli.js";
12
12
  import { curateRegistry } from "./catalog/registry.js";
13
13
  import { createCatalogueStore } from "./catalog/store.js";
14
14
  import { dockerActivationCommitted, dockerFolderConfiguration, } from "./edge/docker-projects/activation.js";
15
- import { createHub } from "./edge/events/hub.js";
15
+ import { createHub, observedHub } 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";
@@ -35,11 +36,15 @@ import { stageProviders } from "./kernel/runner/providers.js";
35
36
  import { createProviderQueue } from "./kernel/runner/queue.js";
36
37
  import { readVersion } from "./kernel/version.js";
37
38
  import { modelSources } from "./model-catalog.js";
38
- import { projectPaused } from "./slices/admission/repo.js";
39
+ import { projectById, projectPaused } from "./slices/admission/repo.js";
40
+ import { createBackupService } from "./slices/backups/service.js";
39
41
  import { pumpQueue, queueWaiting } from "./slices/batch/index.js";
40
42
  import { approveCheckpoint } from "./slices/checkpoints/index.js";
41
43
  import { checkpointDecisionForWork, recoverCheckpointWork, settleReleasedCheckpoints, } from "./slices/checkpoints/recovery.js";
42
44
  import { resolveFont } from "./slices/fonts/index.js";
45
+ import { createRunNotifier } from "./slices/notifications/notifier.js";
46
+ import { createNotificationSender } from "./slices/notifications/send.js";
47
+ import { readNotificationUrl } from "./slices/notifications/settings.js";
43
48
  import { templateById } from "./slices/project-templates/repo.js";
44
49
  import { claimWork, finishWork, maySubmit } from "./slices/rebuild/repo.js";
45
50
  import { materializeAdmittedWork } from "./slices/rebuild/runtime-materialize.js";
@@ -48,7 +53,7 @@ import { executionStages, executionStandings, invocationReady, projectStandings,
48
53
  import { settleTerminalScheduleRuns } from "./slices/schedules/repo.js";
49
54
  import { createScheduleRunner } from "./slices/schedules/scheduler.js";
50
55
  import { nodeCliProbe } from "./slices/settings/cli-status.js";
51
- import { isLocalCliProvider } from "./slices/settings/model.js";
56
+ import { isLocalCliProvider, localCliConcurrency } from "./slices/settings/model.js";
52
57
  import { providerStatuses } from "./slices/settings/readiness.js";
53
58
  import { reconcileStorage } from "./slices/storage/reconcile.js";
54
59
  import { collectorEndpoint, httpPostEvents } from "./slices/telemetry/collector-client.js";
@@ -150,11 +155,23 @@ export async function boot(config, options = {}) {
150
155
  : `interrupted stages ${interrupted}, settled schedules ${settledSchedules}, orphan files ${reconciled.orphanFiles}, staged files ${reconciled.stagedFiles}`,
151
156
  });
152
157
  const eventDb = db;
153
- const hub = createHub({
158
+ const sendNotification = createNotificationSender(globalThis.fetch);
159
+ const notifier = createRunNotifier({
160
+ url: () => readNotificationUrl(eventDb),
161
+ subject: (projectId) => {
162
+ const project = projectById(eventDb, projectId);
163
+ return project === undefined
164
+ ? undefined
165
+ : { title: project.title, makesVideo: project.config.sources.video !== "off" };
166
+ },
167
+ send: sendNotification,
168
+ log,
169
+ });
170
+ const hub = observedHub(createHub({
154
171
  ids,
155
172
  log,
156
173
  acceptEvent: (event) => currentProjectEvent(eventDb, event),
157
- });
174
+ }), notifier.observe);
158
175
  const version = readVersion();
159
176
  const telemetry = { db, ids, clock, log, appVersion: version };
160
177
  const flusher = createFlusher({
@@ -318,14 +335,28 @@ export async function boot(config, options = {}) {
318
335
  }),
319
336
  });
320
337
  const mutations = createMutationLifecycle();
338
+ const folderConfiguration = await dockerFolderConfiguration(process.env, paths.projects);
339
+ const backups = createBackupService({
340
+ db,
341
+ paths,
342
+ clock,
343
+ ids,
344
+ log,
345
+ appVersion: version,
346
+ hasInflight: runner.hasInflight,
347
+ location: folderConfiguration,
348
+ bootedAt: clock.now(),
349
+ beginMutation: updater.beginMutation,
350
+ });
321
351
  const app = createApp({
322
352
  rebuild,
323
353
  drafts: draftDeps,
324
354
  schedules: scheduleDeps,
355
+ backups,
325
356
  ...(rebuild.measureAudio === undefined ? {} : { measureAudio: rebuild.measureAudio }),
326
357
  openFolder,
327
358
  folderLocation: {
328
- ...(await dockerFolderConfiguration(process.env, paths.projects)),
359
+ ...folderConfiguration,
329
360
  ...(hostCli === undefined ? {} : { openOnHost: hostCli.openFolder }),
330
361
  },
331
362
  ...(dockerState === undefined ? {} : { installationPending: () => updater.locked() }),
@@ -335,6 +366,7 @@ export async function boot(config, options = {}) {
335
366
  runner,
336
367
  updater,
337
368
  mutations,
369
+ sendNotification,
338
370
  audioPreviews,
339
371
  ...modelSources(registry),
340
372
  catalogue,
@@ -366,6 +398,13 @@ export async function boot(config, options = {}) {
366
398
  void scheduleTicks.tick();
367
399
  }, 15_000);
368
400
  void scheduleTicks.tick();
401
+ // Once a minute is plenty for a daily slot; the decision itself (slices/backups/schedule)
402
+ // holds the first run back for a couple of minutes after a start.
403
+ const backupTimer = setInterval(() => {
404
+ backups.tick().catch((error) => {
405
+ log.write("error", "backups.tick", { detail: causedBy(error) });
406
+ });
407
+ }, 60_000);
369
408
  listeningPort = portOf(server) ?? config.port;
370
409
  // Whatever last run left queued goes out at start. Nothing waits for
371
410
  // it, and an unreachable collector costs one refused socket.
@@ -387,10 +426,14 @@ export async function boot(config, options = {}) {
387
426
  let stopActivation = () => { };
388
427
  shutdown = () => {
389
428
  stopping ??= (async () => {
429
+ notifier.close();
390
430
  clearInterval(queueTimer);
391
431
  clearInterval(scheduleTimer);
432
+ clearInterval(backupTimer);
392
433
  const mutationDrain = mutations.stop();
393
434
  const scheduleDrain = scheduleTicks.stop();
435
+ // A backup being written is stopped and its partial file removed, not waited for.
436
+ const backupDrain = backups.stop();
394
437
  const serverClose = beginServerClose(server);
395
438
  stopActivation();
396
439
  audioPreviews.close();
@@ -400,6 +443,7 @@ export async function boot(config, options = {}) {
400
443
  // database until their response settles; anything racing shutdown receives 503.
401
444
  await drainMutationsWithDeadline(mutationDrain, serverClose.terminate, mutationDrainTimeoutMs);
402
445
  await scheduleDrain;
446
+ await backupDrain;
403
447
  await runner.abortAll();
404
448
  }
405
449
  finally {
@@ -448,7 +492,9 @@ export function wireRunner({ audioPreviews, db, paths, clock, ids, log, hub, tel
448
492
  attempts: sqliteAttempts(db, ids),
449
493
  clock,
450
494
  log,
451
- queue: createProviderQueue((provider) => isLocalCliProvider(provider) ? 3 : (catalogue.read().providers[provider]?.maxConcurrent ?? 1)),
495
+ queue: createProviderQueue((provider) => isLocalCliProvider(provider)
496
+ ? localCliConcurrency(provider)
497
+ : (catalogue.read().providers[provider]?.maxConcurrent ?? 1)),
452
498
  };
453
499
  const checkpoints = createCheckpointAuthority({
454
500
  decide: (work) => checkpointDecisionForWork(execution, work),
@@ -517,6 +563,8 @@ function listen(app, config, log) {
517
563
  });
518
564
  resolve(server);
519
565
  });
566
+ // Before the first connection: a backup import may outlast Node's 5-minute request limit.
567
+ limitRequestTimes(server);
520
568
  server.once("error", reject);
521
569
  });
522
570
  }
@@ -10,3 +10,11 @@ export function sourceOf(sources, kind) {
10
10
  // Mix of both taking turns, or Still. `video/motion.ts` has the rules.
11
11
  export const motionStyles = ["zoom", "pan", "mixed", "still"];
12
12
  export const entryModes = ["text", "llm"];
13
+ // The Images stage's establishing image: made first (from a library image prompt) or uploaded,
14
+ // then every other image of the run - the video's, the shorts' and, unless `thumbnail` is
15
+ // false, the thumbnail - is drawn with it as a visual reference for characters, style and
16
+ // palette. It is never shown in the video. Absent is Off, which is what every project saved
17
+ // before it existed was.
18
+ export const referenceSources = ["prompt", "provide"];
19
+ // The work key of the establishing image's step, in the Images stage.
20
+ export const referenceKey = "reference:image";
@@ -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,11 @@ export function admit(input) {
118
123
  });
119
124
  }
120
125
  fields.push(...youtubeDescriptionFields(draft));
126
+ fields.push(...shortsFields(draft));
127
+ fields.push(...videoEditFields(draft));
128
+ fields.push(...referenceFields(draft));
129
+ if (usesReference(draft) && draft.reference?.source === "provide")
130
+ checkFile(input.staged, draft.provided.reference, "images", "provided.reference", "Upload the establishing image, or set Establishing image to Off in the Images section.", fields);
121
131
  checkProvided(draft, input.staged, fields);
122
132
  checkValues(draft, input.requiredSlots, fields);
123
133
  if (!Number.isFinite(draft.silenceGapSeconds) ||
@@ -236,6 +246,26 @@ function checkProvided(draft, staged, fields) {
236
246
  checkFile(staged, id, "images", `provided.images.${index}`, "Pick an image.", fields);
237
247
  }
238
248
  }
249
+ checkShortsMusic(draft, staged, fields);
250
+ }
251
+ // The shorts' background music is optional, so only a file that was picked is checked, and
252
+ // only while Shorts is on. Named by where the control sits: Play's Export rail.
253
+ function checkShortsMusic(draft, staged, fields) {
254
+ const id = draft.provided.shortsMusic;
255
+ if (draft.shorts?.enabled !== true || id === undefined)
256
+ return;
257
+ const where = "under Outputs → Export → More shorts options → Background music";
258
+ const file = staged.find((candidate) => candidate.id === id);
259
+ if (file === undefined || file.stageKind !== "audio")
260
+ fields.push({
261
+ field: "shorts.music",
262
+ message: `The shorts' background music file is no longer available, so it can't be added to the project. Choose the file again ${where}, or remove it.`,
263
+ });
264
+ else if (file.state !== "staged")
265
+ fields.push({
266
+ field: "shorts.music",
267
+ message: `The shorts' background music is still uploading, so the run can't start yet. Wait for it to finish, or remove it ${where}.`,
268
+ });
239
269
  }
240
270
  // A run never starts with provided content that is missing or still copying.
241
271
  function checkFile(staged, id, kind, field, missing, fields) {
@@ -305,6 +335,65 @@ export function youtubeDescriptionFields(draft) {
305
335
  ]
306
336
  : [];
307
337
  }
338
+ // The establishing image belongs to the Images stage: it is made, and the images drawn with
339
+ // it, only while images are Generate.
340
+ export function usesReference(draft) {
341
+ return draft.reference !== undefined && draft.sources.images === "generate";
342
+ }
343
+ // Whether the thumbnail is drawn with it too: on unless turned off, and only for a thumbnail
344
+ // the image provider makes.
345
+ export function referenceForThumbnail(draft) {
346
+ return (usesReference(draft) &&
347
+ draft.reference?.thumbnail !== false &&
348
+ (draft.sources.thumbnail === "from_prompt" || draft.sources.thumbnail === "prompt_by_llm"));
349
+ }
350
+ export function referenceFields(draft) {
351
+ return usesReference(draft) &&
352
+ draft.reference?.source === "prompt" &&
353
+ (draft.reference.prompt === undefined || draft.reference.prompt.trim() === "")
354
+ ? [
355
+ {
356
+ field: "reference.prompt",
357
+ message: "Pick an image prompt for the establishing image, upload one instead, or set Establishing image to Off in the Images section.",
358
+ },
359
+ ]
360
+ : [];
361
+ }
362
+ // Shorts are cut from the narration and captioned from its word timings, so they need
363
+ // narration, like the YouTube description; they run whether the video renders or not.
364
+ export function usesShorts(draft) {
365
+ return draft.shorts?.enabled === true && draft.sources.audio !== "off";
366
+ }
367
+ export function shortsFields(draft) {
368
+ const shorts = draft.shorts;
369
+ if (shorts?.enabled !== true)
370
+ return [];
371
+ if (draft.sources.audio === "off")
372
+ return [
373
+ {
374
+ field: "shorts.enabled",
375
+ message: "Shorts are cut from the narration. Turn narration on, or turn Shorts off.",
376
+ },
377
+ ];
378
+ const fields = shortsSettingsProblems(shorts).map((problem) => ({
379
+ field: `shorts.${problem.field}`,
380
+ message: problem.message,
381
+ }));
382
+ // The shorts hold each image as long as the video does. With the video off nothing else
383
+ // checks the number.
384
+ const imageProblem = draft.sources.video === "generate" ? undefined : imageSecondsProblem(draft.imageSeconds);
385
+ if (imageProblem !== undefined)
386
+ fields.push({ field: "imageSeconds", message: imageProblem });
387
+ return fields;
388
+ }
389
+ // The Video stage's edit settings, checked only while the video renders: a hidden setting
390
+ // cannot hold up a run.
391
+ export function videoEditFields(draft) {
392
+ return videoEditProblems(draft).map((problem) => ({
393
+ field: `videoEdit.${problem.field}`,
394
+ message: problem.message,
395
+ }));
396
+ }
308
397
  function chosen(choice) {
309
398
  return choice !== undefined && choice.provider.trim() !== "" && choice.model.trim() !== "";
310
399
  }
@@ -4,7 +4,8 @@ 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 { entryModes, formats, motionStyles, stageSources } from "./model.js";
7
+ import { animateModes, atmospheres, colorGrades, cutModes, lookLevels, transitionKinds, } from "../video/edit-settings.js";
8
+ import { entryModes, formats, motionStyles, referenceSources, stageSources } from "./model.js";
8
9
  import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "./rules.js";
9
10
  const providerChoice = z.object({
10
11
  provider: z.string(),
@@ -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({
@@ -52,6 +66,13 @@ export const runDraftSchema = z.object({
52
66
  .readonly()
53
67
  .optional(),
54
68
  images: providerChoice.optional(),
69
+ reference: z
70
+ .object({
71
+ source: z.enum(referenceSources),
72
+ prompt: z.string().optional(),
73
+ thumbnail: z.boolean().optional(),
74
+ })
75
+ .optional(),
55
76
  articlePrompt: z.string().optional(),
56
77
  narrationPrompt: z.string().optional(),
57
78
  imagePrompts: z.array(z.object({ name: z.string(), number: z.number() })),
@@ -65,6 +86,9 @@ export const runDraftSchema = z.object({
65
86
  audio: z.string().optional(),
66
87
  images: z.array(z.string()).optional(),
67
88
  thumbnail: z.string().optional(),
89
+ reference: z.string().optional(),
90
+ // The staged file of the shorts' background music; used only while Shorts is on.
91
+ shortsMusic: z.string().optional(),
68
92
  }),
69
93
  // Optional until Play carries the control; unknown keys are stripped by this schema, so
70
94
  // a mode not listed here would never reach the audio stage.
@@ -86,6 +110,23 @@ export const runDraftSchema = z.object({
86
110
  document: documentSettingsSchema.optional(),
87
111
  youtubeDescription: z.boolean().optional(),
88
112
  descriptionPrompt: z.string().optional(),
113
+ // The ranges are `slices/shorts/model.ts`'s, checked by admission, not the schema's.
114
+ shorts: z
115
+ .object({
116
+ enabled: z.boolean(),
117
+ count: z.number(),
118
+ minSeconds: z.number(),
119
+ maxSeconds: z.number(),
120
+ prompt: z.string().optional(),
121
+ imagePrompt: z.string().optional(),
122
+ titleOnScreen: z.boolean().optional(),
123
+ fullVideoLink: z.string().optional(),
124
+ musicVolume: z.number().optional(),
125
+ speed: z.number().optional(),
126
+ })
127
+ .optional(),
128
+ // The ranges are `slices/video/edit-settings.ts`'s, checked by admission, not the schema's.
129
+ videoEdit: videoEditSchema.optional(),
89
130
  });
90
131
  export const runConfigSchema = runDraftSchema.extend({
91
132
  rendered: z.record(z.string(), z.string()),
@@ -5,11 +5,15 @@ import { defaultDocumentTheme } from "../document/model.js";
5
5
  import { collectSharedGlossary } from "../narration/shared-glossary.js";
6
6
  import { admitInitialRevision } from "../rebuild/runtime-admission.js";
7
7
  import { adoptBaseline } from "../revisions/adopt.js";
8
+ import { insertAsset } from "../revisions/repo.js";
9
+ import { discardPreparedAssets } from "../storage/assets.js";
10
+ import { prepareStagedFile } from "../storage/prepare.js";
8
11
  import { attachStagedFile, storeText } from "../storage/staging.js";
9
12
  import { releaseStagedFile } from "../storage/staging-refs.js";
10
13
  import { sourceOf, } from "./model.js";
11
14
  import { insertProject, insertStage } from "./repo.js";
12
- import { usesPronunciationGlossary } from "./rules.js";
15
+ import { usesPronunciationGlossary, usesReference } from "./rules.js";
16
+ import { render } from "./substitute.js";
13
17
  // Provide → `provided` with its output attached; Off → `skipped`;
14
18
  // everything else → `pending`.
15
19
  export function initialState(source) {
@@ -26,8 +30,12 @@ export function startRun(deps, draft, rendered, retainStaged = false, templates)
26
30
  const shared = usesPronunciationGlossary(draft) && draft.audio?.shareGlossary === true
27
31
  ? collectSharedGlossary(deps).entries
28
32
  : [];
33
+ // The title may name keywords too ("D&D Lore: {{Topic}}"), filled like a prompt's, so a
34
+ // template or Play keeps the pattern and each project gets its own title.
35
+ const title = render(draft.title, draft.values).trim() || draft.title;
29
36
  const config = {
30
37
  ...draft,
38
+ title,
31
39
  // A new project always names its document theme: a config without one reads as the
32
40
  // DiceMaster of older projects (see documentThemeOf), which no new project should get.
33
41
  ...(sourceOf(draft.sources, "document") === "generate" && draft.document === undefined
@@ -38,7 +46,7 @@ export function startRun(deps, draft, rendered, retainStaged = false, templates)
38
46
  };
39
47
  const project = {
40
48
  id,
41
- title: draft.title,
49
+ title,
42
50
  format: draft.format,
43
51
  config,
44
52
  createdAt: at,
@@ -63,19 +71,32 @@ export function startRun(deps, draft, rendered, retainStaged = false, templates)
63
71
  // the project folder rather than moved, so a rollback leaves the staging files exactly
64
72
  // as the form left them and the same Play can simply be pressed again.
65
73
  const moved = [];
66
- transact(deps.db, () => {
67
- insertProject(deps.db, project);
68
- for (const stage of stages) {
69
- insertStage(deps.db, stage);
70
- }
71
- attachProvided(deps, id, draft, moved, retainStaged);
72
- if (deps.catalogue !== undefined) {
73
- const baseline = adoptBaseline(deps, id, templates);
74
- if (!baseline.ok)
75
- throw new Error("The new project has no revision.");
76
- admitInitialRevision(deps, baseline.view, deps.catalogue.read());
77
- }
78
- });
74
+ let music;
75
+ try {
76
+ transact(deps.db, () => {
77
+ insertProject(deps.db, project);
78
+ for (const stage of stages) {
79
+ insertStage(deps.db, stage);
80
+ }
81
+ attachProvided(deps, id, draft, moved, retainStaged);
82
+ music = attachShortsMusic(deps, id, draft, moved);
83
+ // The music is named by the first revision, as Edit project → Shorts names it, so the
84
+ // project gets its revision now even where it would otherwise wait for its first view.
85
+ if (deps.catalogue !== undefined || music !== undefined) {
86
+ const baseline = adoptBaseline(deps, id, templates, music === undefined ? {} : { shortsMusic: music.id });
87
+ if (!baseline.ok)
88
+ throw new Error("The new project has no revision.");
89
+ if (deps.catalogue !== undefined)
90
+ admitInitialRevision(deps, baseline.view, deps.catalogue.read());
91
+ }
92
+ });
93
+ }
94
+ catch (error) {
95
+ // The copy was written outside the transaction; nothing names it once it rolled back.
96
+ if (music !== undefined)
97
+ discardPreparedAssets(deps, [music]);
98
+ throw error;
99
+ }
79
100
  for (const source of retainStaged ? [] : moved) {
80
101
  releaseStagedFile(deps, source);
81
102
  }
@@ -103,6 +124,9 @@ function attachProvided(deps, projectId, draft, collected, retainStaged) {
103
124
  if (sources.thumbnail === "provide") {
104
125
  attach(deps, projectId, "thumbnail", provided.thumbnail, "thumbnail", collected, retainStaged);
105
126
  }
127
+ if (usesReference(draft) && draft.reference?.source === "provide") {
128
+ attach(deps, projectId, "images", provided.reference, "reference", collected, retainStaged);
129
+ }
106
130
  if (sources.images === "provide") {
107
131
  // Slideshow order is the order the user left the list in.
108
132
  for (const [index, stagedFileId] of (provided.images ?? []).entries()) {
@@ -110,6 +134,20 @@ function attachProvided(deps, projectId, draft, collected, retainStaged) {
110
134
  }
111
135
  }
112
136
  }
137
+ // The Shorts step's background music, copied into the project as an asset of its own (never
138
+ // an output), exactly as Edit project → Shorts keeps an uploaded one. Only while Shorts is on.
139
+ function attachShortsMusic(deps, projectId, draft, collected) {
140
+ const stagedFileId = draft.provided.shortsMusic;
141
+ if (draft.shorts?.enabled !== true || stagedFileId === undefined)
142
+ return undefined;
143
+ const result = prepareStagedFile(deps, { projectId, stagedFileId, role: "audio_body" });
144
+ if (!result.ok)
145
+ // admit() already refused a missing or still-copying file, as for the stages' own files.
146
+ throw new Error(`the shorts' background music could not be attached: ${result.reason}`);
147
+ insertAsset(deps.db, result.asset);
148
+ collected.push(stagedFileId);
149
+ return result.asset;
150
+ }
113
151
  function attach(deps, projectId, kind, stagedFileId, role, collected, retainStaged, index) {
114
152
  if (stagedFileId === undefined) {
115
153
  throw new Error(`the ${kind} stage is provided but carries no staged file`);
@@ -0,0 +1,135 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open, readdir, rename, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ // Only files whose whole name matches these are this feature's. Pruning and the leftover sweep
5
+ // touch nothing else in the folder: not an Export everything download
6
+ // (slopify-backup-2026-09-27.tar), not a file the user renamed, not a symlink.
7
+ const finalPattern = /^slopify-backup-(\d{4})-(\d{2})-(\d{2})T(\d{2})(\d{2})(\d{2})Z\.tar$/;
8
+ const partialPattern = /^\.slopify-backup-\d{4}-\d{2}-\d{2}T\d{6}Z\.tar\.partial$/;
9
+ export function backupFileName(at) {
10
+ const iso = at.toISOString();
11
+ return `slopify-backup-${iso.slice(0, 10)}T${iso.slice(11, 13)}${iso.slice(14, 16)}${iso.slice(17, 19)}Z.tar`;
12
+ }
13
+ export function isBackupFileName(name) {
14
+ return finalPattern.test(name);
15
+ }
16
+ function createdAtOf(name) {
17
+ const match = finalPattern.exec(name);
18
+ if (match === null)
19
+ return null;
20
+ const [, y, mo, d, h, mi, s] = match;
21
+ return `${y}-${mo}-${d}T${h}:${mi}:${s}.000Z`;
22
+ }
23
+ function partialName(name) {
24
+ return `.${name}.partial`;
25
+ }
26
+ export async function ensureFolder(folder) {
27
+ await mkdir(folder, { recursive: true, mode: 0o700 });
28
+ const stat = await lstat(folder);
29
+ if (!stat.isDirectory())
30
+ throw Object.assign(new Error("not a folder"), { code: "ENOTDIR" });
31
+ }
32
+ // Writes the archive under a hidden temporary name and renames it into place only after every
33
+ // byte is on disk, so a crash, a full disk or a stop never leaves a file that looks like a
34
+ // finished backup, and pruning never counts one.
35
+ export async function writeAtomically(folder, name, chunks, signal) {
36
+ if (!isBackupFileName(name))
37
+ throw new Error(`${name} is not a backup file name.`);
38
+ const target = join(folder, name);
39
+ if ((await lstat(target).catch(() => undefined)) !== undefined)
40
+ throw Object.assign(new Error(`${name} already exists.`), { code: "EEXIST" });
41
+ const temporary = join(folder, partialName(name));
42
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
43
+ let bytes = 0;
44
+ let renamed = false;
45
+ try {
46
+ for await (const chunk of chunks) {
47
+ signal?.throwIfAborted();
48
+ let written = 0;
49
+ while (written < chunk.byteLength) {
50
+ const result = await handle.write(chunk, written, chunk.byteLength - written);
51
+ written += result.bytesWritten;
52
+ }
53
+ bytes += chunk.byteLength;
54
+ // One turn of the event loop per chunk, so requests keep being answered while a large
55
+ // archive is written.
56
+ await new Promise((resolve) => setImmediate(resolve));
57
+ }
58
+ signal?.throwIfAborted();
59
+ await handle.sync();
60
+ await handle.close();
61
+ await rename(temporary, target);
62
+ renamed = true;
63
+ await syncFolder(folder);
64
+ return { path: target, bytes };
65
+ }
66
+ finally {
67
+ if (!renamed) {
68
+ await handle.close().catch(() => undefined);
69
+ await rm(temporary, { force: true }).catch(() => undefined);
70
+ }
71
+ }
72
+ }
73
+ async function syncFolder(folder) {
74
+ try {
75
+ const handle = await open(folder, constants.O_RDONLY);
76
+ try {
77
+ await handle.sync();
78
+ }
79
+ finally {
80
+ await handle.close();
81
+ }
82
+ }
83
+ catch {
84
+ // Some filesystems refuse fsync on a directory; the rename itself already happened.
85
+ }
86
+ }
87
+ // This feature's finished backups in the folder, oldest first. The time in the name orders
88
+ // them, not the file's mtime, which a copy or a restore can change.
89
+ export async function listBackups(folder) {
90
+ let names;
91
+ try {
92
+ names = await readdir(folder);
93
+ }
94
+ catch (error) {
95
+ if (codeOf(error) === "ENOENT")
96
+ return [];
97
+ throw error;
98
+ }
99
+ const out = [];
100
+ for (const name of names.filter(isBackupFileName).toSorted()) {
101
+ const stat = await lstat(join(folder, name)).catch(() => undefined);
102
+ const createdAt = createdAtOf(name);
103
+ if (stat?.isFile() !== true || createdAt === null)
104
+ continue;
105
+ out.push({ name, bytes: stat.size, createdAt });
106
+ }
107
+ return out;
108
+ }
109
+ // Deletes this feature's oldest backups until `keep` remain. Nothing else is ever removed.
110
+ export async function pruneBackups(folder, keep) {
111
+ const files = await listBackups(folder);
112
+ const doomed = files.slice(0, Math.max(0, files.length - Math.max(1, keep)));
113
+ for (const file of doomed)
114
+ await rm(join(folder, file.name));
115
+ return doomed.map((file) => file.name);
116
+ }
117
+ // A partial file left by a crash or a power cut. Only called while no backup is being written.
118
+ export async function removeLeftovers(folder) {
119
+ let removed = 0;
120
+ for (const name of await readdir(folder)) {
121
+ if (!partialPattern.test(name))
122
+ continue;
123
+ const stat = await lstat(join(folder, name)).catch(() => undefined);
124
+ if (stat?.isFile() !== true)
125
+ continue;
126
+ await rm(join(folder, name));
127
+ removed += 1;
128
+ }
129
+ return removed;
130
+ }
131
+ export function codeOf(error) {
132
+ return error instanceof Error && "code" in error && typeof error.code === "string"
133
+ ? error.code
134
+ : undefined;
135
+ }
@@ -0,0 +1,35 @@
1
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
2
+ import { defaultBackupsDir } from "../storage/layout.js";
3
+ export function effectiveFolder(paths, folder) {
4
+ return folder === null ? defaultBackupsDir(paths) : resolve(folder);
5
+ }
6
+ function within(root, path) {
7
+ const inside = relative(root, path);
8
+ return (inside === "" || (!inside.startsWith(`..${sep}`) && inside !== ".." && !isAbsolute(inside)));
9
+ }
10
+ // Why a chosen folder cannot hold backups, as the sentence the Folder field shows; undefined
11
+ // when it can. Staging and logs are swept by Slopify itself, and the projects folder belongs to
12
+ // storage reconciliation except for its Backups folder.
13
+ export function folderProblem(paths, folder) {
14
+ if (folder === null)
15
+ return undefined;
16
+ if (!isAbsolute(folder))
17
+ return "Enter the folder's full path, starting from the top of the disk (for example /home/you/Slopify backups), or leave Folder empty to use the default.";
18
+ const path = resolve(folder);
19
+ if (within(paths.projects, path) && !within(defaultBackupsDir(paths), path))
20
+ return "This folder is inside Slopify's projects folder, which Slopify cleans up on its own. Pick a folder outside it, or leave Folder empty to use the default Backups folder.";
21
+ for (const own of [paths.staging, paths.logs, join(paths.dataDir, "models")])
22
+ if (within(own, path))
23
+ return "This folder is one Slopify uses for its own temporary files. Pick another folder, or leave Folder empty to use the default.";
24
+ return undefined;
25
+ }
26
+ // Where the folder is on the user's computer. In Docker only the projects folder is shared
27
+ // with the host, so a folder elsewhere lives in the container's private volume.
28
+ export function hostFolder(paths, location, folder) {
29
+ if (!location.container)
30
+ return folder;
31
+ if (location.hostProjects === null || !within(paths.projects, folder))
32
+ return null;
33
+ const inside = relative(paths.projects, folder);
34
+ return inside === "" ? location.hostProjects : join(location.hostProjects, inside);
35
+ }