@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.
- package/dist/adapter-registry.js +8 -1
- package/dist/adapters/host-cli/index.js +11 -0
- package/dist/adapters/host-cli/transport.js +6 -1
- package/dist/adapters/image/codex-output.js +59 -17
- package/dist/adapters/image/codex.js +92 -16
- package/dist/adapters/image/fal.js +22 -2
- package/dist/adapters/image/google.js +13 -1
- package/dist/adapters/image/openai.js +37 -15
- package/dist/adapters/image/reference.js +7 -0
- package/dist/adapters/image/replicate.js +7 -0
- package/dist/adapters/llm/codex-models.js +2 -1
- package/dist/adapters/llm/codex-server-models.js +2 -1
- package/dist/adapters/llm/run-cli.js +14 -2
- package/dist/assets/models.yaml +7 -0
- package/dist/catalog/registry.js +24 -3
- package/dist/catalog/runtime-models.js +1 -1
- package/dist/catalog/schema.js +10 -1
- package/dist/catalog/validate.js +17 -2
- package/dist/edge/events/hub.js +11 -0
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/backups.js +40 -0
- package/dist/edge/http/host-cli.js +17 -2
- package/dist/edge/http/problem.js +1 -0
- package/dist/edge/http/schedules.js +1 -1
- package/dist/edge/http/settings.js +44 -0
- package/dist/host-cli/runtime.js +3 -2
- package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
- package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
- package/dist/kernel/ports/host-cli.js +17 -2
- package/dist/kernel/ports/image.js +3 -1
- package/dist/kernel/ports/llm.js +3 -1
- package/dist/kernel/runner/attempt.js +1 -1
- package/dist/kernel/runner/providers.js +22 -2
- package/dist/main.js +52 -7
- package/dist/slices/admission/model.js +8 -0
- package/dist/slices/admission/rules.js +47 -0
- package/dist/slices/admission/schema.js +11 -1
- package/dist/slices/admission/start.js +53 -15
- package/dist/slices/backups/files.js +135 -0
- package/dist/slices/backups/folder.js +35 -0
- package/dist/slices/backups/model.js +65 -0
- package/dist/slices/backups/repo.js +28 -0
- package/dist/slices/backups/schedule.js +60 -0
- package/dist/slices/backups/service.js +311 -0
- package/dist/slices/batch/index.js +2 -0
- package/dist/slices/estimate/index.js +9 -1
- package/dist/slices/library/slots.js +8 -2
- package/dist/slices/library/snapshot.js +8 -2
- package/dist/slices/notifications/notifier.js +76 -0
- package/dist/slices/notifications/rules.js +81 -0
- package/dist/slices/notifications/send.js +40 -0
- package/dist/slices/notifications/settings.js +34 -0
- package/dist/slices/play-drafts/convert.js +35 -3
- package/dist/slices/play-drafts/repo.js +7 -1
- package/dist/slices/play-drafts/review-inputs.js +2 -0
- package/dist/slices/play-drafts/schema.js +20 -2
- package/dist/slices/play-drafts/service.js +6 -0
- package/dist/slices/play-drafts/uploads.js +2 -1
- package/dist/slices/project-templates/from-project.js +20 -0
- package/dist/slices/project-templates/setup.js +11 -0
- package/dist/slices/rebuild/preview-details.js +24 -2
- package/dist/slices/rebuild/recipe-build.js +9 -3
- package/dist/slices/rebuild/recipe-input-schema.js +8 -1
- package/dist/slices/rebuild/recipe-legacy.js +3 -0
- package/dist/slices/rebuild/recipe-reference.js +44 -0
- package/dist/slices/rebuild/recipe-shorts.js +11 -4
- package/dist/slices/rebuild/recipe-validation.js +15 -1
- package/dist/slices/rebuild/recipe-visual.js +20 -8
- package/dist/slices/rebuild/runtime-actions.js +9 -2
- package/dist/slices/rebuild/runtime-image.js +50 -0
- package/dist/slices/rebuild/runtime-local.js +6 -3
- package/dist/slices/rebuild/runtime-piece-label.js +2 -0
- package/dist/slices/rebuild/runtime-provider.js +17 -10
- package/dist/slices/rebuild/runtime-shorts.js +4 -6
- package/dist/slices/rebuild/service-readiness.js +15 -1
- package/dist/slices/rebuild/transition-repo.js +1 -1
- package/dist/slices/revisions/adopt-content.js +7 -0
- package/dist/slices/revisions/adopt.js +7 -2
- package/dist/slices/revisions/mutation-assets.js +33 -9
- package/dist/slices/revisions/mutation-prepare.js +8 -10
- package/dist/slices/revisions/schema.js +5 -1
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/service.js +6 -1
- package/dist/slices/settings/model.js +5 -0
- package/dist/slices/storage/layout.js +10 -0
- package/dist/slices/storage/model.js +3 -0
- package/dist/slices/storage/portable.js +6 -3
- package/dist/slices/storage/reconcile.js +5 -1
- package/dist/web/assets/index-CktpxlJd.js +171 -0
- package/dist/web/assets/index-DB-vOnGU.css +1 -0
- package/dist/web/assets/{pdf-CAJNT254.js → pdf-BLF2i8c2.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BGgYMm2u.css +0 -1
- 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
|
|
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
|
-
...
|
|
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)
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
}
|