@gentbajko/slopify 2.4.0 → 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 (95) hide show
  1. package/dist/adapter-registry.js +8 -1
  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 +22 -2
  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 +7 -0
  11. package/dist/adapters/llm/codex-models.js +2 -1
  12. package/dist/adapters/llm/codex-server-models.js +2 -1
  13. package/dist/adapters/llm/run-cli.js +14 -2
  14. package/dist/assets/models.yaml +7 -0
  15. package/dist/catalog/registry.js +24 -3
  16. package/dist/catalog/runtime-models.js +1 -1
  17. package/dist/catalog/schema.js +10 -1
  18. package/dist/catalog/validate.js +17 -2
  19. package/dist/edge/events/hub.js +11 -0
  20. package/dist/edge/http/app.js +2 -0
  21. package/dist/edge/http/backups.js +40 -0
  22. package/dist/edge/http/host-cli.js +17 -2
  23. package/dist/edge/http/problem.js +1 -0
  24. package/dist/edge/http/schedules.js +1 -1
  25. package/dist/edge/http/settings.js +44 -0
  26. package/dist/host-cli/runtime.js +3 -2
  27. package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
  28. package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
  29. package/dist/kernel/ports/host-cli.js +17 -2
  30. package/dist/kernel/ports/image.js +3 -1
  31. package/dist/kernel/ports/llm.js +3 -1
  32. package/dist/kernel/runner/attempt.js +1 -1
  33. package/dist/kernel/runner/providers.js +22 -2
  34. package/dist/main.js +52 -7
  35. package/dist/slices/admission/model.js +8 -0
  36. package/dist/slices/admission/rules.js +47 -0
  37. package/dist/slices/admission/schema.js +11 -1
  38. package/dist/slices/admission/start.js +53 -15
  39. package/dist/slices/backups/files.js +135 -0
  40. package/dist/slices/backups/folder.js +35 -0
  41. package/dist/slices/backups/model.js +65 -0
  42. package/dist/slices/backups/repo.js +28 -0
  43. package/dist/slices/backups/schedule.js +60 -0
  44. package/dist/slices/backups/service.js +311 -0
  45. package/dist/slices/batch/index.js +2 -0
  46. package/dist/slices/estimate/index.js +9 -1
  47. package/dist/slices/library/slots.js +8 -2
  48. package/dist/slices/library/snapshot.js +8 -2
  49. package/dist/slices/notifications/notifier.js +76 -0
  50. package/dist/slices/notifications/rules.js +81 -0
  51. package/dist/slices/notifications/send.js +40 -0
  52. package/dist/slices/notifications/settings.js +34 -0
  53. package/dist/slices/play-drafts/convert.js +35 -3
  54. package/dist/slices/play-drafts/repo.js +7 -1
  55. package/dist/slices/play-drafts/review-inputs.js +2 -0
  56. package/dist/slices/play-drafts/schema.js +20 -2
  57. package/dist/slices/play-drafts/service.js +6 -0
  58. package/dist/slices/play-drafts/uploads.js +2 -1
  59. package/dist/slices/project-templates/from-project.js +20 -0
  60. package/dist/slices/project-templates/setup.js +11 -0
  61. package/dist/slices/rebuild/preview-details.js +24 -2
  62. package/dist/slices/rebuild/recipe-build.js +9 -3
  63. package/dist/slices/rebuild/recipe-input-schema.js +8 -1
  64. package/dist/slices/rebuild/recipe-legacy.js +3 -0
  65. package/dist/slices/rebuild/recipe-reference.js +44 -0
  66. package/dist/slices/rebuild/recipe-shorts.js +11 -4
  67. package/dist/slices/rebuild/recipe-validation.js +15 -1
  68. package/dist/slices/rebuild/recipe-visual.js +20 -8
  69. package/dist/slices/rebuild/runtime-actions.js +9 -2
  70. package/dist/slices/rebuild/runtime-image.js +50 -0
  71. package/dist/slices/rebuild/runtime-local.js +6 -3
  72. package/dist/slices/rebuild/runtime-piece-label.js +2 -0
  73. package/dist/slices/rebuild/runtime-provider.js +17 -10
  74. package/dist/slices/rebuild/runtime-shorts.js +4 -6
  75. package/dist/slices/rebuild/service-readiness.js +15 -1
  76. package/dist/slices/rebuild/transition-repo.js +1 -1
  77. package/dist/slices/revisions/adopt-content.js +7 -0
  78. package/dist/slices/revisions/adopt.js +7 -2
  79. package/dist/slices/revisions/mutation-assets.js +33 -9
  80. package/dist/slices/revisions/mutation-prepare.js +8 -10
  81. package/dist/slices/revisions/schema.js +5 -1
  82. package/dist/slices/schedules/repo.js +1 -1
  83. package/dist/slices/schedules/service.js +6 -1
  84. package/dist/slices/settings/model.js +5 -0
  85. package/dist/slices/storage/layout.js +10 -0
  86. package/dist/slices/storage/model.js +3 -0
  87. package/dist/slices/storage/portable.js +6 -3
  88. package/dist/slices/storage/reconcile.js +5 -1
  89. package/dist/web/assets/index-CktpxlJd.js +171 -0
  90. package/dist/web/assets/index-DB-vOnGU.css +1 -0
  91. package/dist/web/assets/{pdf-CAJNT254.js → pdf-BLF2i8c2.js} +1 -1
  92. package/dist/web/index.html +2 -2
  93. package/package.json +1 -1
  94. package/dist/web/assets/index-BGgYMm2u.css +0 -1
  95. package/dist/web/assets/index-BVVgtntL.js +0 -171
package/dist/main.js CHANGED
@@ -12,7 +12,7 @@ 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";
@@ -36,11 +36,15 @@ import { stageProviders } from "./kernel/runner/providers.js";
36
36
  import { createProviderQueue } from "./kernel/runner/queue.js";
37
37
  import { readVersion } from "./kernel/version.js";
38
38
  import { modelSources } from "./model-catalog.js";
39
- import { projectPaused } from "./slices/admission/repo.js";
39
+ import { projectById, projectPaused } from "./slices/admission/repo.js";
40
+ import { createBackupService } from "./slices/backups/service.js";
40
41
  import { pumpQueue, queueWaiting } from "./slices/batch/index.js";
41
42
  import { approveCheckpoint } from "./slices/checkpoints/index.js";
42
43
  import { checkpointDecisionForWork, recoverCheckpointWork, settleReleasedCheckpoints, } from "./slices/checkpoints/recovery.js";
43
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";
44
48
  import { templateById } from "./slices/project-templates/repo.js";
45
49
  import { claimWork, finishWork, maySubmit } from "./slices/rebuild/repo.js";
46
50
  import { materializeAdmittedWork } from "./slices/rebuild/runtime-materialize.js";
@@ -49,7 +53,7 @@ import { executionStages, executionStandings, invocationReady, projectStandings,
49
53
  import { settleTerminalScheduleRuns } from "./slices/schedules/repo.js";
50
54
  import { createScheduleRunner } from "./slices/schedules/scheduler.js";
51
55
  import { nodeCliProbe } from "./slices/settings/cli-status.js";
52
- import { isLocalCliProvider } from "./slices/settings/model.js";
56
+ import { isLocalCliProvider, localCliConcurrency } from "./slices/settings/model.js";
53
57
  import { providerStatuses } from "./slices/settings/readiness.js";
54
58
  import { reconcileStorage } from "./slices/storage/reconcile.js";
55
59
  import { collectorEndpoint, httpPostEvents } from "./slices/telemetry/collector-client.js";
@@ -151,11 +155,23 @@ export async function boot(config, options = {}) {
151
155
  : `interrupted stages ${interrupted}, settled schedules ${settledSchedules}, orphan files ${reconciled.orphanFiles}, staged files ${reconciled.stagedFiles}`,
152
156
  });
153
157
  const eventDb = db;
154
- 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({
155
171
  ids,
156
172
  log,
157
173
  acceptEvent: (event) => currentProjectEvent(eventDb, event),
158
- });
174
+ }), notifier.observe);
159
175
  const version = readVersion();
160
176
  const telemetry = { db, ids, clock, log, appVersion: version };
161
177
  const flusher = createFlusher({
@@ -319,14 +335,28 @@ export async function boot(config, options = {}) {
319
335
  }),
320
336
  });
321
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
+ });
322
351
  const app = createApp({
323
352
  rebuild,
324
353
  drafts: draftDeps,
325
354
  schedules: scheduleDeps,
355
+ backups,
326
356
  ...(rebuild.measureAudio === undefined ? {} : { measureAudio: rebuild.measureAudio }),
327
357
  openFolder,
328
358
  folderLocation: {
329
- ...(await dockerFolderConfiguration(process.env, paths.projects)),
359
+ ...folderConfiguration,
330
360
  ...(hostCli === undefined ? {} : { openOnHost: hostCli.openFolder }),
331
361
  },
332
362
  ...(dockerState === undefined ? {} : { installationPending: () => updater.locked() }),
@@ -336,6 +366,7 @@ export async function boot(config, options = {}) {
336
366
  runner,
337
367
  updater,
338
368
  mutations,
369
+ sendNotification,
339
370
  audioPreviews,
340
371
  ...modelSources(registry),
341
372
  catalogue,
@@ -367,6 +398,13 @@ export async function boot(config, options = {}) {
367
398
  void scheduleTicks.tick();
368
399
  }, 15_000);
369
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);
370
408
  listeningPort = portOf(server) ?? config.port;
371
409
  // Whatever last run left queued goes out at start. Nothing waits for
372
410
  // it, and an unreachable collector costs one refused socket.
@@ -388,10 +426,14 @@ export async function boot(config, options = {}) {
388
426
  let stopActivation = () => { };
389
427
  shutdown = () => {
390
428
  stopping ??= (async () => {
429
+ notifier.close();
391
430
  clearInterval(queueTimer);
392
431
  clearInterval(scheduleTimer);
432
+ clearInterval(backupTimer);
393
433
  const mutationDrain = mutations.stop();
394
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();
395
437
  const serverClose = beginServerClose(server);
396
438
  stopActivation();
397
439
  audioPreviews.close();
@@ -401,6 +443,7 @@ export async function boot(config, options = {}) {
401
443
  // database until their response settles; anything racing shutdown receives 503.
402
444
  await drainMutationsWithDeadline(mutationDrain, serverClose.terminate, mutationDrainTimeoutMs);
403
445
  await scheduleDrain;
446
+ await backupDrain;
404
447
  await runner.abortAll();
405
448
  }
406
449
  finally {
@@ -449,7 +492,9 @@ export function wireRunner({ audioPreviews, db, paths, clock, ids, log, hub, tel
449
492
  attempts: sqliteAttempts(db, ids),
450
493
  clock,
451
494
  log,
452
- 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)),
453
498
  };
454
499
  const checkpoints = createCheckpointAuthority({
455
500
  decide: (work) => checkpointDecisionForWork(execution, work),
@@ -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";
@@ -125,6 +125,9 @@ export function admit(input) {
125
125
  fields.push(...youtubeDescriptionFields(draft));
126
126
  fields.push(...shortsFields(draft));
127
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);
128
131
  checkProvided(draft, input.staged, fields);
129
132
  checkValues(draft, input.requiredSlots, fields);
130
133
  if (!Number.isFinite(draft.silenceGapSeconds) ||
@@ -243,6 +246,26 @@ function checkProvided(draft, staged, fields) {
243
246
  checkFile(staged, id, "images", `provided.images.${index}`, "Pick an image.", fields);
244
247
  }
245
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
+ });
246
269
  }
247
270
  // A run never starts with provided content that is missing or still copying.
248
271
  function checkFile(staged, id, kind, field, missing, fields) {
@@ -312,6 +335,30 @@ export function youtubeDescriptionFields(draft) {
312
335
  ]
313
336
  : [];
314
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
+ }
315
362
  // Shorts are cut from the narration and captioned from its word timings, so they need
316
363
  // narration, like the YouTube description; they run whether the video renders or not.
317
364
  export function usesShorts(draft) {
@@ -5,7 +5,7 @@ import { documentSettingsSchema } from "../document/theme-schema.js";
5
5
  import { chunkModes } from "../narration/chunk.js";
6
6
  import { subtitleConfigSchema } from "../subtitles/model.js";
7
7
  import { animateModes, atmospheres, colorGrades, cutModes, lookLevels, transitionKinds, } from "../video/edit-settings.js";
8
- import { entryModes, formats, motionStyles, stageSources } from "./model.js";
8
+ import { entryModes, formats, motionStyles, referenceSources, stageSources } from "./model.js";
9
9
  import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "./rules.js";
10
10
  const providerChoice = z.object({
11
11
  provider: z.string(),
@@ -66,6 +66,13 @@ export const runDraftSchema = z.object({
66
66
  .readonly()
67
67
  .optional(),
68
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(),
69
76
  articlePrompt: z.string().optional(),
70
77
  narrationPrompt: z.string().optional(),
71
78
  imagePrompts: z.array(z.object({ name: z.string(), number: z.number() })),
@@ -79,6 +86,9 @@ export const runDraftSchema = z.object({
79
86
  audio: z.string().optional(),
80
87
  images: z.array(z.string()).optional(),
81
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(),
82
92
  }),
83
93
  // Optional until Play carries the control; unknown keys are stripped by this schema, so
84
94
  // a mode not listed here would never reach the audio stage.
@@ -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
+ }
@@ -0,0 +1,65 @@
1
+ import { z } from "zod";
2
+ import { validTimeZone } from "../schedules/calendar.js";
3
+ // Scheduled backups write the same archive Export everything downloads (storage/backup-export)
4
+ // into a folder on disk, once a day, keeping the newest few. The settings table holds the
5
+ // configuration and the last result under two keys no backup carries: exportableSettings
6
+ // only lets known portable keys through, and a folder path belongs to this machine alone.
7
+ export const configKey = "backups.config";
8
+ export const statusKey = "backups.status";
9
+ export const keepMin = 1;
10
+ export const keepMax = 30;
11
+ export const folderMax = 1024;
12
+ const time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Pick a time as HH:MM, 00:00 to 23:59.");
13
+ export const backupConfigInputSchema = z
14
+ .object({
15
+ enabled: z.boolean(),
16
+ time,
17
+ timeZone: z
18
+ .string()
19
+ .min(1)
20
+ .max(64)
21
+ .refine(validTimeZone, "This time zone is not one Slopify knows."),
22
+ keep: z
23
+ .number()
24
+ .int()
25
+ .min(keepMin)
26
+ .max(keepMax, `Keep between ${keepMin} and ${keepMax} backups.`),
27
+ // null is the default folder, so moving the projects folder moves the backups with it.
28
+ folder: z.string().trim().min(1).max(folderMax).nullable(),
29
+ })
30
+ .strict();
31
+ export const storedConfigSchema = backupConfigInputSchema.extend({
32
+ // When the toggle was last turned on. A slot before it does not count, so turning backups on
33
+ // at 14:00 waits for tonight's 03:00 instead of starting a multi-gigabyte copy at once.
34
+ enabledAt: z.string().nullable(),
35
+ });
36
+ // Off by default, for new and existing installs alike: an archive holds every project's video
37
+ // and can be many gigabytes, so filling a disk with copies is something the user asks for.
38
+ export function defaultConfig(timeZone) {
39
+ return { enabled: false, time: "03:00", timeZone, keep: 5, folder: null, enabledAt: null };
40
+ }
41
+ export const triggers = ["scheduled", "catch-up", "manual"];
42
+ export const backupStatusSchema = z.object({
43
+ lastAttemptAt: z.string().nullable(),
44
+ // "waiting": projects were being made, so the backup is held until they finish.
45
+ lastResult: z.enum(["succeeded", "failed", "waiting"]).nullable(),
46
+ lastTrigger: z.enum(triggers).nullable(),
47
+ // The slot the last attempt was for, so a failure is retried for that slot, not repeated.
48
+ lastSlot: z.string().nullable(),
49
+ detail: z.string().nullable(),
50
+ lastSuccessAt: z.string().nullable(),
51
+ lastSuccessFile: z.string().nullable(),
52
+ lastSuccessBytes: z.number().int().min(0).nullable(),
53
+ lastDurationMs: z.number().int().min(0).nullable(),
54
+ });
55
+ export const emptyStatus = {
56
+ lastAttemptAt: null,
57
+ lastResult: null,
58
+ lastTrigger: null,
59
+ lastSlot: null,
60
+ detail: null,
61
+ lastSuccessAt: null,
62
+ lastSuccessFile: null,
63
+ lastSuccessBytes: null,
64
+ lastDurationMs: null,
65
+ };
@@ -0,0 +1,28 @@
1
+ import { readSetting, writeSetting } from "../settings/repo.js";
2
+ import { backupStatusSchema, configKey, defaultConfig, emptyStatus, statusKey, storedConfigSchema, } from "./model.js";
3
+ // A value this build cannot read (hand-edited, or written by a newer Slopify) falls back to
4
+ // the default rather than stopping the app; the next save writes a good one.
5
+ export function readConfig(db, fallbackTimeZone) {
6
+ const parsed = storedConfigSchema.safeParse(parseJson(readSetting(db, configKey)));
7
+ return parsed.success ? parsed.data : defaultConfig(fallbackTimeZone);
8
+ }
9
+ export function writeConfig(db, config) {
10
+ writeSetting(db, configKey, JSON.stringify(storedConfigSchema.parse(config)));
11
+ }
12
+ export function readStatus(db) {
13
+ const parsed = backupStatusSchema.safeParse(parseJson(readSetting(db, statusKey)));
14
+ return parsed.success ? parsed.data : emptyStatus;
15
+ }
16
+ export function writeStatus(db, status) {
17
+ writeSetting(db, statusKey, JSON.stringify(backupStatusSchema.parse(status)));
18
+ }
19
+ function parseJson(value) {
20
+ if (value === undefined)
21
+ return undefined;
22
+ try {
23
+ return JSON.parse(value);
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }