@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
@@ -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
+ }
@@ -0,0 +1,60 @@
1
+ import { nextOccurrence } from "../schedules/calendar.js";
2
+ // After a start, the first backup waits this long so it never competes with the boot's own
3
+ // work (migrations, reconcile, the queue resuming).
4
+ export const startupDelayMs = 2 * 60_000;
5
+ // A failed backup is tried again this often until the slot's backup succeeds or the next slot.
6
+ export const retryAfterFailureMs = 60 * 60_000;
7
+ // A backup held back by projects being made looks again this often.
8
+ export const retryAfterWaitingMs = 10 * 60_000;
9
+ // A slot missed while Slopify was off is made up for after the next start, unless a backup
10
+ // already succeeded this recently ("about a day", with room for a slot that ran late).
11
+ export const catchUpFreshMs = 20 * 60 * 60_000;
12
+ // The most recent time-of-day slot at or before `now`, in the configured zone. Days are 23 to 25
13
+ // hours long, so 26 hours back always holds at least one slot.
14
+ export function latestSlot(time, timeZone, now) {
15
+ const daily = { kind: "daily", time };
16
+ let slot = nextOccurrence(daily, timeZone, new Date(now.valueOf() - 26 * 60 * 60_000));
17
+ if (slot === null || slot > now)
18
+ return null;
19
+ for (;;) {
20
+ const next = nextOccurrence(daily, timeZone, slot);
21
+ if (next === null || next > now)
22
+ return slot;
23
+ slot = next;
24
+ }
25
+ }
26
+ export function nextSlot(time, timeZone, now) {
27
+ return nextOccurrence({ kind: "daily", time }, timeZone, now);
28
+ }
29
+ // At most one automatic backup per daily slot. A slot is due once no backup has succeeded
30
+ // since it; a slot that passed while Slopify was off is caught up after the next start.
31
+ export function decideBackup(input) {
32
+ const { config, status, now, bootedAt } = input;
33
+ if (!config.enabled)
34
+ return { due: false, reason: "off" };
35
+ if (input.running)
36
+ return { due: false, reason: "running" };
37
+ const slot = latestSlot(config.time, config.timeZone, now);
38
+ if (slot === null)
39
+ return { due: false, reason: "not-yet" };
40
+ if (config.enabledAt !== null && slot.valueOf() < Date.parse(config.enabledAt))
41
+ return { due: false, reason: "not-yet" };
42
+ const lastSuccess = status.lastSuccessAt === null ? null : Date.parse(status.lastSuccessAt);
43
+ if (lastSuccess !== null && lastSuccess >= slot.valueOf())
44
+ return { due: false, reason: "done" };
45
+ const missed = bootedAt.valueOf() > slot.valueOf();
46
+ if (missed && lastSuccess !== null && now.valueOf() - lastSuccess < catchUpFreshMs)
47
+ return { due: false, reason: "done" };
48
+ if (now.valueOf() - bootedAt.valueOf() < startupDelayMs)
49
+ return { due: false, reason: "starting" };
50
+ if (status.lastSlot === slot.toISOString() && status.lastAttemptAt !== null) {
51
+ const wait = status.lastResult === "failed"
52
+ ? retryAfterFailureMs
53
+ : status.lastResult === "waiting"
54
+ ? retryAfterWaitingMs
55
+ : 0;
56
+ if (now.valueOf() - Date.parse(status.lastAttemptAt) < wait)
57
+ return { due: false, reason: "retry-later" };
58
+ }
59
+ return { due: true, slot: slot.toISOString(), trigger: missed ? "catch-up" : "scheduled" };
60
+ }
@@ -0,0 +1,311 @@
1
+ import { statfs } from "node:fs/promises";
2
+ import { BackupBusyError, planBackup, streamBackup, } from "../storage/backup-export.js";
3
+ import { defaultBackupsDir } from "../storage/layout.js";
4
+ import { backupFileName, codeOf, ensureFolder, listBackups, pruneBackups, removeLeftovers, writeAtomically, } from "./files.js";
5
+ import { effectiveFolder, folderProblem, hostFolder } from "./folder.js";
6
+ import { readConfig, readStatus, writeConfig, writeStatus } from "./repo.js";
7
+ import { decideBackup, nextSlot } from "./schedule.js";
8
+ // Scheduled backups reuse Export everything's own code path (planBackup, streamBackup) and so
9
+ // its rule too: nothing is copied while a project is being made, because its files are still
10
+ // being written. A backup that meets one waits and looks again, so it never competes with a
11
+ // render; the writing itself yields to the event loop after every chunk.
12
+ export function createBackupService(deps) {
13
+ let current;
14
+ let stopped = false;
15
+ const zone = deps.serverTimeZone ?? (Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
16
+ const config = () => readConfig(deps.db, zone);
17
+ const folderOf = (value) => effectiveFolder(deps.paths, value.folder);
18
+ const record = (status) => {
19
+ try {
20
+ writeStatus(deps.db, status);
21
+ }
22
+ catch (error) {
23
+ deps.log.write("error", "backups.status", { detail: String(error) });
24
+ }
25
+ };
26
+ const start = (plan, trigger, slot, release) => {
27
+ const abort = new AbortController();
28
+ const done = write(plan, trigger, slot, abort.signal)
29
+ .catch((error) => {
30
+ deps.log.write("error", "backups.run", { detail: String(error) });
31
+ })
32
+ .finally(() => {
33
+ release();
34
+ current = undefined;
35
+ });
36
+ current = { abort, done };
37
+ return done;
38
+ };
39
+ async function write(plan, trigger, slot, signal) {
40
+ const settings = config();
41
+ const folder = folderOf(settings);
42
+ const startedAt = deps.clock.now();
43
+ const previous = readStatus(deps.db);
44
+ const attempt = {
45
+ ...previous,
46
+ lastAttemptAt: startedAt.toISOString(),
47
+ lastTrigger: trigger,
48
+ lastSlot: slot,
49
+ };
50
+ try {
51
+ await ensureFolder(folder);
52
+ await removeLeftovers(folder);
53
+ await checkSpace(folder, plan.archiveBytes);
54
+ const name = backupFileName(startedAt);
55
+ const written = await writeAtomically(folder, name, streamBackup(plan), signal);
56
+ let detail = null;
57
+ try {
58
+ await pruneBackups(folder, settings.keep);
59
+ }
60
+ catch (error) {
61
+ deps.log.write("warn", "backups.prune", { detail: String(error) });
62
+ detail = `The backup was saved, but older backups in ${folder} could not be removed (${codeOf(error) ?? "unknown error"}), so more than ${settings.keep} are kept. Check Slopify can delete files in that folder, or pick another Folder in Settings → Backups.`;
63
+ }
64
+ record({
65
+ ...attempt,
66
+ lastResult: "succeeded",
67
+ detail,
68
+ lastSuccessAt: deps.clock.now().toISOString(),
69
+ lastSuccessFile: written.path,
70
+ lastSuccessBytes: written.bytes,
71
+ lastDurationMs: deps.clock.now().valueOf() - startedAt.valueOf(),
72
+ });
73
+ }
74
+ catch (error) {
75
+ deps.log.write("error", "backups.run", { detail: String(error) });
76
+ record({
77
+ ...attempt,
78
+ lastResult: "failed",
79
+ detail: explain(error, folder, trigger !== "manual", signal),
80
+ lastDurationMs: deps.clock.now().valueOf() - startedAt.valueOf(),
81
+ });
82
+ }
83
+ }
84
+ const waiting = (projects, trigger, slot) => {
85
+ record({
86
+ ...readStatus(deps.db),
87
+ lastAttemptAt: deps.clock.now().toISOString(),
88
+ lastResult: "waiting",
89
+ lastTrigger: trigger,
90
+ lastSlot: slot,
91
+ detail: `Waiting for ${named(projects)} to finish: a backup never copies a project while its files are being written. It starts by itself when they finish or are paused.`,
92
+ lastDurationMs: null,
93
+ });
94
+ };
95
+ return {
96
+ view: async () => {
97
+ const settings = config();
98
+ const folder = folderOf(settings);
99
+ const now = deps.clock.now();
100
+ let files = [];
101
+ try {
102
+ files = await listBackups(folder);
103
+ }
104
+ catch {
105
+ files = [];
106
+ }
107
+ const decision = decideBackup({
108
+ config: settings,
109
+ status: readStatus(deps.db),
110
+ now,
111
+ bootedAt: deps.bootedAt,
112
+ running: current !== undefined,
113
+ });
114
+ const overdue = decision.due || (!decision.due && ["starting", "retry-later"].includes(decision.reason));
115
+ return {
116
+ config: {
117
+ enabled: settings.enabled,
118
+ time: settings.time,
119
+ timeZone: settings.timeZone,
120
+ keep: settings.keep,
121
+ folder: settings.folder,
122
+ },
123
+ folder,
124
+ defaultFolder: defaultBackupsDir(deps.paths),
125
+ hostFolder: hostFolder(deps.paths, deps.location, folder),
126
+ container: deps.location.container,
127
+ running: current !== undefined,
128
+ nextRunAt: settings.enabled
129
+ ? (nextSlot(settings.time, settings.timeZone, now)?.toISOString() ?? null)
130
+ : null,
131
+ overdue: settings.enabled && overdue,
132
+ status: readStatus(deps.db),
133
+ files,
134
+ };
135
+ },
136
+ save: (input) => {
137
+ const problem = folderProblem(deps.paths, input.folder);
138
+ if (problem !== undefined)
139
+ return { ok: false, detail: problem };
140
+ const before = config();
141
+ const now = deps.clock.now().toISOString();
142
+ const moved = before.time !== input.time || before.timeZone !== input.timeZone;
143
+ const enabledAt = !input.enabled
144
+ ? null
145
+ : !before.enabled || moved || before.enabledAt === null
146
+ ? now
147
+ : before.enabledAt;
148
+ writeConfig(deps.db, { ...input, enabledAt });
149
+ return { ok: true };
150
+ },
151
+ runNow: () => {
152
+ if (stopped)
153
+ return {
154
+ ok: false,
155
+ status: 409,
156
+ detail: "Slopify is shutting down. Start it again, then press Back up now.",
157
+ };
158
+ if (current !== undefined)
159
+ return {
160
+ ok: false,
161
+ status: 409,
162
+ detail: "A backup is already being written. Wait for it to finish; its result shows here.",
163
+ };
164
+ const problem = folderProblem(deps.paths, config().folder);
165
+ if (problem !== undefined)
166
+ return { ok: false, status: 400, detail: problem };
167
+ const release = deps.beginMutation === undefined ? () => { } : deps.beginMutation();
168
+ if (release === undefined)
169
+ return busyUpdating();
170
+ let plan;
171
+ try {
172
+ plan = planBackup(deps);
173
+ }
174
+ catch (error) {
175
+ release();
176
+ if (error instanceof BackupBusyError)
177
+ return {
178
+ ok: false,
179
+ status: 409,
180
+ detail: `Slopify can't back up while ${named(error.projects)} ${error.projects.length === 1 ? "is" : "are"} being made: their files are still being written. Wait for them to finish, or pause them on their project page, then press Back up now again.`,
181
+ };
182
+ throw error;
183
+ }
184
+ void start(plan, "manual", null, release);
185
+ return { ok: true };
186
+ },
187
+ tick: async () => {
188
+ if (stopped || current !== undefined)
189
+ return;
190
+ const settings = config();
191
+ const decision = decideBackup({
192
+ config: settings,
193
+ status: readStatus(deps.db),
194
+ now: deps.clock.now(),
195
+ bootedAt: deps.bootedAt,
196
+ running: false,
197
+ });
198
+ if (!decision.due)
199
+ return;
200
+ if (folderProblem(deps.paths, settings.folder) !== undefined)
201
+ return;
202
+ const release = deps.beginMutation === undefined ? () => { } : deps.beginMutation();
203
+ // An update is being installed; the backup runs after the restart.
204
+ if (release === undefined)
205
+ return;
206
+ let plan;
207
+ try {
208
+ plan = planBackup(deps);
209
+ }
210
+ catch (error) {
211
+ release();
212
+ if (error instanceof BackupBusyError) {
213
+ waiting(error.projects, decision.trigger, decision.slot);
214
+ return;
215
+ }
216
+ deps.log.write("error", "backups.plan", { detail: String(error) });
217
+ record({
218
+ ...readStatus(deps.db),
219
+ lastAttemptAt: deps.clock.now().toISOString(),
220
+ lastResult: "failed",
221
+ lastTrigger: decision.trigger,
222
+ lastSlot: decision.slot,
223
+ detail: "The backup was not saved because Slopify could not read what to back up. It tries again in an hour; if it keeps failing, use Download diagnostics in Settings and report it.",
224
+ lastDurationMs: null,
225
+ });
226
+ return;
227
+ }
228
+ await start(plan, decision.trigger, decision.slot, release);
229
+ },
230
+ stop: async () => {
231
+ stopped = true;
232
+ const running = current;
233
+ if (running === undefined)
234
+ return;
235
+ running.abort.abort(new Error("Slopify is stopping."));
236
+ await running.done;
237
+ },
238
+ };
239
+ }
240
+ function busyUpdating() {
241
+ return {
242
+ ok: false,
243
+ status: 409,
244
+ detail: "Slopify is installing an update. Press Back up now again after it restarts.",
245
+ };
246
+ }
247
+ async function checkSpace(folder, needed) {
248
+ let free;
249
+ try {
250
+ const stats = await statfs(folder);
251
+ free = Number(stats.bavail) * Number(stats.bsize);
252
+ }
253
+ catch {
254
+ return;
255
+ }
256
+ if (free < needed)
257
+ throw new NoSpaceError(needed, free);
258
+ }
259
+ class NoSpaceError extends Error {
260
+ needed;
261
+ free;
262
+ constructor(needed, free) {
263
+ super("Not enough free space for the backup.");
264
+ this.needed = needed;
265
+ this.free = free;
266
+ }
267
+ }
268
+ function named(projects) {
269
+ const titles = projects
270
+ .slice(0, 3)
271
+ .map((project) => `"${project.title}"`)
272
+ .join(", ");
273
+ return projects.length > 3 ? `${titles} and ${projects.length - 3} more` : titles;
274
+ }
275
+ // What failed, why, and what to change - the sentence Settings → Backups shows.
276
+ export function explain(error, folder, retries, signal) {
277
+ const fix = "Pick another Folder in Settings → Backups, or leave it empty to use the default";
278
+ if (signal?.aborted === true && error === signal.reason)
279
+ return "The backup was not saved: Slopify stopped while writing it, and the unfinished file was removed. It runs again after Slopify starts.";
280
+ if (error instanceof NoSpaceError)
281
+ return `The backup was not saved: it needs about ${bytes(error.needed)}, but the disk holding ${folder} has ${bytes(error.free)} free. Free up space there, lower "Keep last" in Settings → Backups, or pick another Folder.`;
282
+ switch (codeOf(error)) {
283
+ case "ENOSPC":
284
+ case "EDQUOT":
285
+ return `The backup was not saved: the disk holding ${folder} ran out of space. Free up space there, lower "Keep last" in Settings → Backups, or pick another Folder.`;
286
+ case "EACCES":
287
+ case "EPERM":
288
+ case "EROFS":
289
+ return `The backup was not saved: Slopify is not allowed to write to ${folder}. ${fix}.`;
290
+ case "ENOTDIR":
291
+ case "EEXIST":
292
+ return `The backup was not saved: ${folder} (or a part of its path) is a file, not a folder. ${fix}.`;
293
+ }
294
+ const again = retries
295
+ ? "It tries again within the hour, or press Back up now"
296
+ : "Press Back up now to try again";
297
+ if (error instanceof Error &&
298
+ error.message.endsWith("changed while the backup was being written."))
299
+ return `The backup was not saved: a project file changed while it was being copied. ${again}.`;
300
+ return `The backup was not saved because of an unexpected error. ${again}; if it keeps failing, use Download diagnostics in Settings and report it.`;
301
+ }
302
+ function bytes(value) {
303
+ const units = ["B", "KB", "MB", "GB", "TB"];
304
+ let amount = value;
305
+ let unit = 0;
306
+ while (amount >= 1024 && unit < units.length - 1) {
307
+ amount /= 1024;
308
+ unit += 1;
309
+ }
310
+ return `${amount >= 10 || unit === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
311
+ }
@@ -48,6 +48,8 @@ export function enqueueBatch(deps, batchId, runs, retainStaged = false) {
48
48
  if (draft.sources.images === "provide")
49
49
  for (const id of draft.provided.images ?? [])
50
50
  used.add(id);
51
+ if (draft.shorts?.enabled === true && draft.provided.shortsMusic)
52
+ used.add(draft.provided.shortsMusic);
51
53
  }
52
54
  for (const id of retainStaged ? [] : used) {
53
55
  sources.add(id);
@@ -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, usesReference, 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
  };
@@ -112,6 +120,14 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
112
120
  });
113
121
  if (images === 0)
114
122
  local("Images", "Provided or off.");
123
+ // The establishing image is one more image when it is made from a prompt.
124
+ if (usesReference(draft) && draft.reference?.source === "prompt")
125
+ requests.push({
126
+ kind: "image",
127
+ stage: "Establishing image",
128
+ provider: image.provider,
129
+ model: image.model,
130
+ });
115
131
  if (["from_prompt", "prompt_by_llm"].includes(draft.sources.thumbnail))
116
132
  requests.push({
117
133
  kind: "image",
@@ -132,6 +148,53 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
132
148
  : defaultDescriptionPrompt.length) +
133
149
  Math.round(articleChars * 1.05) +
134
150
  1500, 2400, "One LLM call on the timed transcript.");
151
+ // Shorts: one call picks the clips from the numbered transcript, one per clip writes its
152
+ // image prompts, and the images are charged at the most the step can ask for, every clip
153
+ // at the longest length allowed.
154
+ const shorts = draft.shorts;
155
+ // Numbers the run would refuse are not priced: a typo of 3000 shorts would list them all.
156
+ if (usesShorts(draft) &&
157
+ shorts !== undefined &&
158
+ shortsSettingsProblems(shorts).length === 0 &&
159
+ imageSecondsProblem(draft.imageSeconds) === undefined) {
160
+ text("Shorts", (rendered.shorts?.trim() ? rendered.shorts.length : defaultShortsPrompt.length) +
161
+ Math.round(articleChars * 1.15) +
162
+ 1500, 400 * shorts.count, "One LLM call on the numbered transcript picks the clips.");
163
+ const style = rendered.shortsImage?.trim()
164
+ ? rendered.shortsImage.length
165
+ : defaultShortsImagePrompt.length;
166
+ for (let clip = 0; clip < shorts.count; clip++)
167
+ text("Shorts", style + 3000, 2400, "One LLM call per short writes its image prompts.");
168
+ const count = shortsImageUpperBound(shorts, draft.imageSeconds);
169
+ for (let index = 0; index < count; index++)
170
+ requests.push({
171
+ kind: "image",
172
+ stage: "Shorts",
173
+ provider: image.provider,
174
+ model: image.model,
175
+ detail: `Up to ${String(count)} vertical images. ${imageNote}`,
176
+ });
177
+ }
178
+ // Animated images: one clip from the image-to-video model per animated image, each priced
179
+ // as the catalogue prices one clip. Only drawn images are animated; the chapter openers are
180
+ // charged at the most there can be, one per image up to twelve chapters.
181
+ const edit = draft.videoEdit;
182
+ if (usesAnimation(draft) && edit !== undefined && videoEditProblems(draft).length === 0) {
183
+ const clips = edit.animate === "every"
184
+ ? animatedImageIndexes(edit, images).length
185
+ : Math.min(images, chapterOpenersMax);
186
+ const note = data.image.find((model) => model.provider === image.provider && model.id === edit.animateModel)?.pricing.note ?? "Model or account pricing is unknown.";
187
+ for (let index = 0; index < clips; index++)
188
+ requests.push({
189
+ kind: "image",
190
+ stage: "Animated images",
191
+ provider: image.provider,
192
+ model: edit.animateModel,
193
+ detail: edit.animate === "every"
194
+ ? `${String(clips)} clips of ${String(animatedClipSeconds)} seconds. ${note}`
195
+ : `Up to ${String(clips)} clips of ${String(animatedClipSeconds)} seconds, one per chapter opening. ${note}`,
196
+ });
197
+ }
135
198
  if (sourceOf(draft.sources, "document") === "generate")
136
199
  local("Document", "Laid out locally from the article; no API fee.");
137
200
  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;