@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.
- package/dist/adapter-registry.js +9 -2
- 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 +99 -8
- 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 +48 -2
- package/dist/adapters/image/video.js +28 -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/fonts/Barlow-Bold.ttf +0 -0
- package/dist/assets/fonts/SOURCE.txt +6 -0
- package/dist/assets/models.yaml +89 -1
- package/dist/catalog/registry.js +24 -3
- package/dist/catalog/runtime-models.js +1 -1
- package/dist/catalog/schema.js +20 -1
- package/dist/catalog/store.js +2 -2
- package/dist/catalog/validate.js +47 -4
- package/dist/edge/docker-launch.js +4 -0
- package/dist/edge/docker-projects/engine.js +57 -1
- package/dist/edge/docker-projects/install.js +10 -2
- package/dist/edge/docker-projects/prune.js +66 -0
- 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/providers.js +8 -0
- package/dist/edge/http/schedules.js +1 -1
- package/dist/edge/http/settings.js +44 -0
- package/dist/edge/http/timeouts.js +38 -0
- package/dist/host-cli/runtime.js +3 -2
- package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
- 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 +5 -2
- package/dist/kernel/runner/providers.js +42 -2
- package/dist/main.js +55 -7
- package/dist/slices/admission/model.js +8 -0
- package/dist/slices/admission/rules.js +91 -2
- package/dist/slices/admission/schema.js +42 -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 +65 -2
- package/dist/slices/fonts/catalog.js +23 -0
- package/dist/slices/fonts/index.js +1 -1
- package/dist/slices/library/model.js +8 -1
- package/dist/slices/library/slots.js +13 -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 +72 -4
- package/dist/slices/play-drafts/readiness.js +7 -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 +44 -3
- 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 +42 -0
- package/dist/slices/project-templates/setup.js +13 -0
- package/dist/slices/rebuild/preview-details.js +53 -3
- package/dist/slices/rebuild/preview-plan.js +2 -2
- package/dist/slices/rebuild/recipe-build.js +13 -3
- package/dist/slices/rebuild/recipe-edit.js +178 -0
- package/dist/slices/rebuild/recipe-exports.js +9 -4
- package/dist/slices/rebuild/recipe-input-schema.js +9 -1
- package/dist/slices/rebuild/recipe-legacy.js +12 -0
- package/dist/slices/rebuild/recipe-model.js +7 -1
- package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
- package/dist/slices/rebuild/recipe-reference.js +44 -0
- package/dist/slices/rebuild/recipe-save.js +25 -1
- package/dist/slices/rebuild/recipe-shorts.js +186 -0
- package/dist/slices/rebuild/recipe-validation.js +33 -2
- package/dist/slices/rebuild/recipe-visual.js +29 -8
- package/dist/slices/rebuild/recipe-work.js +83 -1
- package/dist/slices/rebuild/runtime-actions.js +9 -2
- package/dist/slices/rebuild/runtime-animate.js +110 -0
- package/dist/slices/rebuild/runtime-export-edit.js +119 -0
- package/dist/slices/rebuild/runtime-export.js +13 -1
- package/dist/slices/rebuild/runtime-image.js +50 -0
- package/dist/slices/rebuild/runtime-local.js +6 -3
- package/dist/slices/rebuild/runtime-materialize.js +48 -0
- package/dist/slices/rebuild/runtime-piece-label.js +17 -0
- package/dist/slices/rebuild/runtime-provider.js +17 -10
- package/dist/slices/rebuild/runtime-publication.js +25 -4
- package/dist/slices/rebuild/runtime-run.js +11 -5
- package/dist/slices/rebuild/runtime-shorts.js +328 -0
- package/dist/slices/rebuild/runtime-store.js +7 -3
- package/dist/slices/rebuild/service-readiness.js +15 -1
- package/dist/slices/rebuild/transition-repo.js +40 -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 +51 -9
- package/dist/slices/revisions/mutation-prepare.js +14 -14
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/publish.js +20 -2
- package/dist/slices/revisions/schema.js +16 -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/shorts/captions.js +272 -0
- package/dist/slices/shorts/clips.js +89 -0
- package/dist/slices/shorts/model.js +186 -0
- package/dist/slices/shorts/pick.js +232 -0
- package/dist/slices/shorts/prompts.js +68 -0
- package/dist/slices/shorts/render.js +163 -0
- package/dist/slices/storage/asset-name.js +9 -2
- package/dist/slices/storage/layout.js +18 -0
- package/dist/slices/storage/model.js +9 -0
- package/dist/slices/storage/portable.js +6 -3
- package/dist/slices/storage/reconcile.js +5 -1
- package/dist/slices/storage/schema.js +3 -0
- package/dist/slices/telemetry/model.js +3 -0
- package/dist/slices/video/cards.js +72 -0
- package/dist/slices/video/chapters.js +89 -0
- package/dist/slices/video/cuts.js +141 -0
- package/dist/slices/video/edit-list.js +36 -6
- package/dist/slices/video/edit-settings.js +191 -0
- package/dist/slices/video/ffmpeg.js +155 -29
- package/dist/slices/video/look.js +164 -0
- package/dist/slices/video/plan.js +68 -11
- package/dist/slices/video/slideshow.js +22 -6
- package/dist/slices/video/transitions.js +51 -0
- package/dist/slices/youtube/transcript.js +54 -0
- package/dist/web/assets/index-CktpxlJd.js +171 -0
- package/dist/web/assets/index-DB-vOnGU.css +1 -0
- package/dist/web/assets/{pdf-DR97Byps.js → pdf-BLF2i8c2.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DfXmBzCk.js +0 -168
- package/dist/web/assets/index-Y3RhfTEh.css +0 -1
package/dist/main.js
CHANGED
|
@@ -12,10 +12,11 @@ import { nodeRunCli } from "./adapters/llm/run-cli.js";
|
|
|
12
12
|
import { curateRegistry } from "./catalog/registry.js";
|
|
13
13
|
import { createCatalogueStore } from "./catalog/store.js";
|
|
14
14
|
import { dockerActivationCommitted, dockerFolderConfiguration, } from "./edge/docker-projects/activation.js";
|
|
15
|
-
import { createHub } from "./edge/events/hub.js";
|
|
15
|
+
import { createHub, observedHub } from "./edge/events/hub.js";
|
|
16
16
|
import { currentProjectEvent } from "./edge/events/visibility.js";
|
|
17
17
|
import { createApp } from "./edge/http/app.js";
|
|
18
18
|
import { createMutationLifecycle, drainMutationsWithDeadline } from "./edge/http/mutations.js";
|
|
19
|
+
import { limitRequestTimes } from "./edge/http/timeouts.js";
|
|
19
20
|
import { openFolder } from "./edge/open-folder.js";
|
|
20
21
|
import { createAudioPreviewStore } from "./kernel/audio-preview.js";
|
|
21
22
|
import { systemClock } from "./kernel/clock.js";
|
|
@@ -35,11 +36,15 @@ import { stageProviders } from "./kernel/runner/providers.js";
|
|
|
35
36
|
import { createProviderQueue } from "./kernel/runner/queue.js";
|
|
36
37
|
import { readVersion } from "./kernel/version.js";
|
|
37
38
|
import { modelSources } from "./model-catalog.js";
|
|
38
|
-
import { projectPaused } from "./slices/admission/repo.js";
|
|
39
|
+
import { projectById, projectPaused } from "./slices/admission/repo.js";
|
|
40
|
+
import { createBackupService } from "./slices/backups/service.js";
|
|
39
41
|
import { pumpQueue, queueWaiting } from "./slices/batch/index.js";
|
|
40
42
|
import { approveCheckpoint } from "./slices/checkpoints/index.js";
|
|
41
43
|
import { checkpointDecisionForWork, recoverCheckpointWork, settleReleasedCheckpoints, } from "./slices/checkpoints/recovery.js";
|
|
42
44
|
import { resolveFont } from "./slices/fonts/index.js";
|
|
45
|
+
import { createRunNotifier } from "./slices/notifications/notifier.js";
|
|
46
|
+
import { createNotificationSender } from "./slices/notifications/send.js";
|
|
47
|
+
import { readNotificationUrl } from "./slices/notifications/settings.js";
|
|
43
48
|
import { templateById } from "./slices/project-templates/repo.js";
|
|
44
49
|
import { claimWork, finishWork, maySubmit } from "./slices/rebuild/repo.js";
|
|
45
50
|
import { materializeAdmittedWork } from "./slices/rebuild/runtime-materialize.js";
|
|
@@ -48,7 +53,7 @@ import { executionStages, executionStandings, invocationReady, projectStandings,
|
|
|
48
53
|
import { settleTerminalScheduleRuns } from "./slices/schedules/repo.js";
|
|
49
54
|
import { createScheduleRunner } from "./slices/schedules/scheduler.js";
|
|
50
55
|
import { nodeCliProbe } from "./slices/settings/cli-status.js";
|
|
51
|
-
import { isLocalCliProvider } from "./slices/settings/model.js";
|
|
56
|
+
import { isLocalCliProvider, localCliConcurrency } from "./slices/settings/model.js";
|
|
52
57
|
import { providerStatuses } from "./slices/settings/readiness.js";
|
|
53
58
|
import { reconcileStorage } from "./slices/storage/reconcile.js";
|
|
54
59
|
import { collectorEndpoint, httpPostEvents } from "./slices/telemetry/collector-client.js";
|
|
@@ -150,11 +155,23 @@ export async function boot(config, options = {}) {
|
|
|
150
155
|
: `interrupted stages ${interrupted}, settled schedules ${settledSchedules}, orphan files ${reconciled.orphanFiles}, staged files ${reconciled.stagedFiles}`,
|
|
151
156
|
});
|
|
152
157
|
const eventDb = db;
|
|
153
|
-
const
|
|
158
|
+
const sendNotification = createNotificationSender(globalThis.fetch);
|
|
159
|
+
const notifier = createRunNotifier({
|
|
160
|
+
url: () => readNotificationUrl(eventDb),
|
|
161
|
+
subject: (projectId) => {
|
|
162
|
+
const project = projectById(eventDb, projectId);
|
|
163
|
+
return project === undefined
|
|
164
|
+
? undefined
|
|
165
|
+
: { title: project.title, makesVideo: project.config.sources.video !== "off" };
|
|
166
|
+
},
|
|
167
|
+
send: sendNotification,
|
|
168
|
+
log,
|
|
169
|
+
});
|
|
170
|
+
const hub = observedHub(createHub({
|
|
154
171
|
ids,
|
|
155
172
|
log,
|
|
156
173
|
acceptEvent: (event) => currentProjectEvent(eventDb, event),
|
|
157
|
-
});
|
|
174
|
+
}), notifier.observe);
|
|
158
175
|
const version = readVersion();
|
|
159
176
|
const telemetry = { db, ids, clock, log, appVersion: version };
|
|
160
177
|
const flusher = createFlusher({
|
|
@@ -318,14 +335,28 @@ export async function boot(config, options = {}) {
|
|
|
318
335
|
}),
|
|
319
336
|
});
|
|
320
337
|
const mutations = createMutationLifecycle();
|
|
338
|
+
const folderConfiguration = await dockerFolderConfiguration(process.env, paths.projects);
|
|
339
|
+
const backups = createBackupService({
|
|
340
|
+
db,
|
|
341
|
+
paths,
|
|
342
|
+
clock,
|
|
343
|
+
ids,
|
|
344
|
+
log,
|
|
345
|
+
appVersion: version,
|
|
346
|
+
hasInflight: runner.hasInflight,
|
|
347
|
+
location: folderConfiguration,
|
|
348
|
+
bootedAt: clock.now(),
|
|
349
|
+
beginMutation: updater.beginMutation,
|
|
350
|
+
});
|
|
321
351
|
const app = createApp({
|
|
322
352
|
rebuild,
|
|
323
353
|
drafts: draftDeps,
|
|
324
354
|
schedules: scheduleDeps,
|
|
355
|
+
backups,
|
|
325
356
|
...(rebuild.measureAudio === undefined ? {} : { measureAudio: rebuild.measureAudio }),
|
|
326
357
|
openFolder,
|
|
327
358
|
folderLocation: {
|
|
328
|
-
...
|
|
359
|
+
...folderConfiguration,
|
|
329
360
|
...(hostCli === undefined ? {} : { openOnHost: hostCli.openFolder }),
|
|
330
361
|
},
|
|
331
362
|
...(dockerState === undefined ? {} : { installationPending: () => updater.locked() }),
|
|
@@ -335,6 +366,7 @@ export async function boot(config, options = {}) {
|
|
|
335
366
|
runner,
|
|
336
367
|
updater,
|
|
337
368
|
mutations,
|
|
369
|
+
sendNotification,
|
|
338
370
|
audioPreviews,
|
|
339
371
|
...modelSources(registry),
|
|
340
372
|
catalogue,
|
|
@@ -366,6 +398,13 @@ export async function boot(config, options = {}) {
|
|
|
366
398
|
void scheduleTicks.tick();
|
|
367
399
|
}, 15_000);
|
|
368
400
|
void scheduleTicks.tick();
|
|
401
|
+
// Once a minute is plenty for a daily slot; the decision itself (slices/backups/schedule)
|
|
402
|
+
// holds the first run back for a couple of minutes after a start.
|
|
403
|
+
const backupTimer = setInterval(() => {
|
|
404
|
+
backups.tick().catch((error) => {
|
|
405
|
+
log.write("error", "backups.tick", { detail: causedBy(error) });
|
|
406
|
+
});
|
|
407
|
+
}, 60_000);
|
|
369
408
|
listeningPort = portOf(server) ?? config.port;
|
|
370
409
|
// Whatever last run left queued goes out at start. Nothing waits for
|
|
371
410
|
// it, and an unreachable collector costs one refused socket.
|
|
@@ -387,10 +426,14 @@ export async function boot(config, options = {}) {
|
|
|
387
426
|
let stopActivation = () => { };
|
|
388
427
|
shutdown = () => {
|
|
389
428
|
stopping ??= (async () => {
|
|
429
|
+
notifier.close();
|
|
390
430
|
clearInterval(queueTimer);
|
|
391
431
|
clearInterval(scheduleTimer);
|
|
432
|
+
clearInterval(backupTimer);
|
|
392
433
|
const mutationDrain = mutations.stop();
|
|
393
434
|
const scheduleDrain = scheduleTicks.stop();
|
|
435
|
+
// A backup being written is stopped and its partial file removed, not waited for.
|
|
436
|
+
const backupDrain = backups.stop();
|
|
394
437
|
const serverClose = beginServerClose(server);
|
|
395
438
|
stopActivation();
|
|
396
439
|
audioPreviews.close();
|
|
@@ -400,6 +443,7 @@ export async function boot(config, options = {}) {
|
|
|
400
443
|
// database until their response settles; anything racing shutdown receives 503.
|
|
401
444
|
await drainMutationsWithDeadline(mutationDrain, serverClose.terminate, mutationDrainTimeoutMs);
|
|
402
445
|
await scheduleDrain;
|
|
446
|
+
await backupDrain;
|
|
403
447
|
await runner.abortAll();
|
|
404
448
|
}
|
|
405
449
|
finally {
|
|
@@ -448,7 +492,9 @@ export function wireRunner({ audioPreviews, db, paths, clock, ids, log, hub, tel
|
|
|
448
492
|
attempts: sqliteAttempts(db, ids),
|
|
449
493
|
clock,
|
|
450
494
|
log,
|
|
451
|
-
queue: createProviderQueue((provider) => isLocalCliProvider(provider)
|
|
495
|
+
queue: createProviderQueue((provider) => isLocalCliProvider(provider)
|
|
496
|
+
? localCliConcurrency(provider)
|
|
497
|
+
: (catalogue.read().providers[provider]?.maxConcurrent ?? 1)),
|
|
452
498
|
};
|
|
453
499
|
const checkpoints = createCheckpointAuthority({
|
|
454
500
|
decide: (work) => checkpointDecisionForWork(execution, work),
|
|
@@ -517,6 +563,8 @@ function listen(app, config, log) {
|
|
|
517
563
|
});
|
|
518
564
|
resolve(server);
|
|
519
565
|
});
|
|
566
|
+
// Before the first connection: a backup import may outlast Node's 5-minute request limit.
|
|
567
|
+
limitRequestTimes(server);
|
|
520
568
|
server.once("error", reject);
|
|
521
569
|
});
|
|
522
570
|
}
|
|
@@ -10,3 +10,11 @@ export function sourceOf(sources, kind) {
|
|
|
10
10
|
// Mix of both taking turns, or Still. `video/motion.ts` has the rules.
|
|
11
11
|
export const motionStyles = ["zoom", "pan", "mixed", "still"];
|
|
12
12
|
export const entryModes = ["text", "llm"];
|
|
13
|
+
// The Images stage's establishing image: made first (from a library image prompt) or uploaded,
|
|
14
|
+
// then every other image of the run - the video's, the shorts' and, unless `thumbnail` is
|
|
15
|
+
// false, the thumbnail - is drawn with it as a visual reference for characters, style and
|
|
16
|
+
// palette. It is never shown in the video. Absent is Off, which is what every project saved
|
|
17
|
+
// before it existed was.
|
|
18
|
+
export const referenceSources = ["prompt", "provide"];
|
|
19
|
+
// The work key of the establishing image's step, in the Images stage.
|
|
20
|
+
export const referenceKey = "reference:image";
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
2
|
+
import { shortsSettingsProblems } from "../shorts/model.js";
|
|
3
|
+
import { usesAnimation, videoEditProblems } from "../video/edit-settings.js";
|
|
2
4
|
import { sourceOf } from "./model.js";
|
|
3
5
|
export const titleMax = 200;
|
|
4
6
|
export const valueMax = 200;
|
|
@@ -81,7 +83,8 @@ export function admit(input) {
|
|
|
81
83
|
draft.intro?.mode === "llm" ||
|
|
82
84
|
draft.outro?.mode === "llm" ||
|
|
83
85
|
usesNarrationPreparation(draft) ||
|
|
84
|
-
usesYoutubeDescription(draft)
|
|
86
|
+
usesYoutubeDescription(draft) ||
|
|
87
|
+
usesShorts(draft);
|
|
85
88
|
if (needsLlm && !chosen(draft.llm)) {
|
|
86
89
|
fields.push({ field: "llm", message: "Choose a text (LLM) provider and model." });
|
|
87
90
|
}
|
|
@@ -103,7 +106,9 @@ export function admit(input) {
|
|
|
103
106
|
}
|
|
104
107
|
if ((sources.images === "generate" ||
|
|
105
108
|
sources.thumbnail === "from_prompt" ||
|
|
106
|
-
sources.thumbnail === "prompt_by_llm"
|
|
109
|
+
sources.thumbnail === "prompt_by_llm" ||
|
|
110
|
+
usesShorts(draft) ||
|
|
111
|
+
usesAnimation(draft)) &&
|
|
107
112
|
!chosen(draft.images)) {
|
|
108
113
|
fields.push({ field: "images", message: "Choose an image provider and model." });
|
|
109
114
|
}
|
|
@@ -118,6 +123,11 @@ export function admit(input) {
|
|
|
118
123
|
});
|
|
119
124
|
}
|
|
120
125
|
fields.push(...youtubeDescriptionFields(draft));
|
|
126
|
+
fields.push(...shortsFields(draft));
|
|
127
|
+
fields.push(...videoEditFields(draft));
|
|
128
|
+
fields.push(...referenceFields(draft));
|
|
129
|
+
if (usesReference(draft) && draft.reference?.source === "provide")
|
|
130
|
+
checkFile(input.staged, draft.provided.reference, "images", "provided.reference", "Upload the establishing image, or set Establishing image to Off in the Images section.", fields);
|
|
121
131
|
checkProvided(draft, input.staged, fields);
|
|
122
132
|
checkValues(draft, input.requiredSlots, fields);
|
|
123
133
|
if (!Number.isFinite(draft.silenceGapSeconds) ||
|
|
@@ -236,6 +246,26 @@ function checkProvided(draft, staged, fields) {
|
|
|
236
246
|
checkFile(staged, id, "images", `provided.images.${index}`, "Pick an image.", fields);
|
|
237
247
|
}
|
|
238
248
|
}
|
|
249
|
+
checkShortsMusic(draft, staged, fields);
|
|
250
|
+
}
|
|
251
|
+
// The shorts' background music is optional, so only a file that was picked is checked, and
|
|
252
|
+
// only while Shorts is on. Named by where the control sits: Play's Export rail.
|
|
253
|
+
function checkShortsMusic(draft, staged, fields) {
|
|
254
|
+
const id = draft.provided.shortsMusic;
|
|
255
|
+
if (draft.shorts?.enabled !== true || id === undefined)
|
|
256
|
+
return;
|
|
257
|
+
const where = "under Outputs → Export → More shorts options → Background music";
|
|
258
|
+
const file = staged.find((candidate) => candidate.id === id);
|
|
259
|
+
if (file === undefined || file.stageKind !== "audio")
|
|
260
|
+
fields.push({
|
|
261
|
+
field: "shorts.music",
|
|
262
|
+
message: `The shorts' background music file is no longer available, so it can't be added to the project. Choose the file again ${where}, or remove it.`,
|
|
263
|
+
});
|
|
264
|
+
else if (file.state !== "staged")
|
|
265
|
+
fields.push({
|
|
266
|
+
field: "shorts.music",
|
|
267
|
+
message: `The shorts' background music is still uploading, so the run can't start yet. Wait for it to finish, or remove it ${where}.`,
|
|
268
|
+
});
|
|
239
269
|
}
|
|
240
270
|
// A run never starts with provided content that is missing or still copying.
|
|
241
271
|
function checkFile(staged, id, kind, field, missing, fields) {
|
|
@@ -305,6 +335,65 @@ export function youtubeDescriptionFields(draft) {
|
|
|
305
335
|
]
|
|
306
336
|
: [];
|
|
307
337
|
}
|
|
338
|
+
// The establishing image belongs to the Images stage: it is made, and the images drawn with
|
|
339
|
+
// it, only while images are Generate.
|
|
340
|
+
export function usesReference(draft) {
|
|
341
|
+
return draft.reference !== undefined && draft.sources.images === "generate";
|
|
342
|
+
}
|
|
343
|
+
// Whether the thumbnail is drawn with it too: on unless turned off, and only for a thumbnail
|
|
344
|
+
// the image provider makes.
|
|
345
|
+
export function referenceForThumbnail(draft) {
|
|
346
|
+
return (usesReference(draft) &&
|
|
347
|
+
draft.reference?.thumbnail !== false &&
|
|
348
|
+
(draft.sources.thumbnail === "from_prompt" || draft.sources.thumbnail === "prompt_by_llm"));
|
|
349
|
+
}
|
|
350
|
+
export function referenceFields(draft) {
|
|
351
|
+
return usesReference(draft) &&
|
|
352
|
+
draft.reference?.source === "prompt" &&
|
|
353
|
+
(draft.reference.prompt === undefined || draft.reference.prompt.trim() === "")
|
|
354
|
+
? [
|
|
355
|
+
{
|
|
356
|
+
field: "reference.prompt",
|
|
357
|
+
message: "Pick an image prompt for the establishing image, upload one instead, or set Establishing image to Off in the Images section.",
|
|
358
|
+
},
|
|
359
|
+
]
|
|
360
|
+
: [];
|
|
361
|
+
}
|
|
362
|
+
// Shorts are cut from the narration and captioned from its word timings, so they need
|
|
363
|
+
// narration, like the YouTube description; they run whether the video renders or not.
|
|
364
|
+
export function usesShorts(draft) {
|
|
365
|
+
return draft.shorts?.enabled === true && draft.sources.audio !== "off";
|
|
366
|
+
}
|
|
367
|
+
export function shortsFields(draft) {
|
|
368
|
+
const shorts = draft.shorts;
|
|
369
|
+
if (shorts?.enabled !== true)
|
|
370
|
+
return [];
|
|
371
|
+
if (draft.sources.audio === "off")
|
|
372
|
+
return [
|
|
373
|
+
{
|
|
374
|
+
field: "shorts.enabled",
|
|
375
|
+
message: "Shorts are cut from the narration. Turn narration on, or turn Shorts off.",
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
const fields = shortsSettingsProblems(shorts).map((problem) => ({
|
|
379
|
+
field: `shorts.${problem.field}`,
|
|
380
|
+
message: problem.message,
|
|
381
|
+
}));
|
|
382
|
+
// The shorts hold each image as long as the video does. With the video off nothing else
|
|
383
|
+
// checks the number.
|
|
384
|
+
const imageProblem = draft.sources.video === "generate" ? undefined : imageSecondsProblem(draft.imageSeconds);
|
|
385
|
+
if (imageProblem !== undefined)
|
|
386
|
+
fields.push({ field: "imageSeconds", message: imageProblem });
|
|
387
|
+
return fields;
|
|
388
|
+
}
|
|
389
|
+
// The Video stage's edit settings, checked only while the video renders: a hidden setting
|
|
390
|
+
// cannot hold up a run.
|
|
391
|
+
export function videoEditFields(draft) {
|
|
392
|
+
return videoEditProblems(draft).map((problem) => ({
|
|
393
|
+
field: `videoEdit.${problem.field}`,
|
|
394
|
+
message: problem.message,
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
308
397
|
function chosen(choice) {
|
|
309
398
|
return choice !== undefined && choice.provider.trim() !== "" && choice.model.trim() !== "";
|
|
310
399
|
}
|
|
@@ -4,7 +4,8 @@ import { checkpointStageSchema } from "../checkpoints/schema.js";
|
|
|
4
4
|
import { documentSettingsSchema } from "../document/theme-schema.js";
|
|
5
5
|
import { chunkModes } from "../narration/chunk.js";
|
|
6
6
|
import { subtitleConfigSchema } from "../subtitles/model.js";
|
|
7
|
-
import {
|
|
7
|
+
import { animateModes, atmospheres, colorGrades, cutModes, lookLevels, transitionKinds, } from "../video/edit-settings.js";
|
|
8
|
+
import { entryModes, formats, motionStyles, referenceSources, stageSources } from "./model.js";
|
|
8
9
|
import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "./rules.js";
|
|
9
10
|
const providerChoice = z.object({
|
|
10
11
|
provider: z.string(),
|
|
@@ -12,6 +13,19 @@ const providerChoice = z.object({
|
|
|
12
13
|
thinking: z.enum(thinkingModes).optional(),
|
|
13
14
|
});
|
|
14
15
|
const entryChoice = z.object({ name: z.string(), mode: z.enum(entryModes) });
|
|
16
|
+
export const videoEditSchema = z.object({
|
|
17
|
+
cuts: z.enum(cutModes),
|
|
18
|
+
transition: z.enum(transitionKinds),
|
|
19
|
+
transitionSeconds: z.number(),
|
|
20
|
+
vignette: z.enum(lookLevels),
|
|
21
|
+
grain: z.enum(lookLevels),
|
|
22
|
+
grade: z.enum(colorGrades),
|
|
23
|
+
atmosphere: z.enum(atmospheres),
|
|
24
|
+
chapterCards: z.boolean(),
|
|
25
|
+
animate: z.enum(animateModes),
|
|
26
|
+
animateEvery: z.number(),
|
|
27
|
+
animateModel: z.string(),
|
|
28
|
+
});
|
|
15
29
|
// The shape Play posts and the shape `projects.config` holds, in one place: the second
|
|
16
30
|
// is the first plus the rendered prompt texts.
|
|
17
31
|
export const runDraftSchema = z.object({
|
|
@@ -52,6 +66,13 @@ export const runDraftSchema = z.object({
|
|
|
52
66
|
.readonly()
|
|
53
67
|
.optional(),
|
|
54
68
|
images: providerChoice.optional(),
|
|
69
|
+
reference: z
|
|
70
|
+
.object({
|
|
71
|
+
source: z.enum(referenceSources),
|
|
72
|
+
prompt: z.string().optional(),
|
|
73
|
+
thumbnail: z.boolean().optional(),
|
|
74
|
+
})
|
|
75
|
+
.optional(),
|
|
55
76
|
articlePrompt: z.string().optional(),
|
|
56
77
|
narrationPrompt: z.string().optional(),
|
|
57
78
|
imagePrompts: z.array(z.object({ name: z.string(), number: z.number() })),
|
|
@@ -65,6 +86,9 @@ export const runDraftSchema = z.object({
|
|
|
65
86
|
audio: z.string().optional(),
|
|
66
87
|
images: z.array(z.string()).optional(),
|
|
67
88
|
thumbnail: z.string().optional(),
|
|
89
|
+
reference: z.string().optional(),
|
|
90
|
+
// The staged file of the shorts' background music; used only while Shorts is on.
|
|
91
|
+
shortsMusic: z.string().optional(),
|
|
68
92
|
}),
|
|
69
93
|
// Optional until Play carries the control; unknown keys are stripped by this schema, so
|
|
70
94
|
// a mode not listed here would never reach the audio stage.
|
|
@@ -86,6 +110,23 @@ export const runDraftSchema = z.object({
|
|
|
86
110
|
document: documentSettingsSchema.optional(),
|
|
87
111
|
youtubeDescription: z.boolean().optional(),
|
|
88
112
|
descriptionPrompt: z.string().optional(),
|
|
113
|
+
// The ranges are `slices/shorts/model.ts`'s, checked by admission, not the schema's.
|
|
114
|
+
shorts: z
|
|
115
|
+
.object({
|
|
116
|
+
enabled: z.boolean(),
|
|
117
|
+
count: z.number(),
|
|
118
|
+
minSeconds: z.number(),
|
|
119
|
+
maxSeconds: z.number(),
|
|
120
|
+
prompt: z.string().optional(),
|
|
121
|
+
imagePrompt: z.string().optional(),
|
|
122
|
+
titleOnScreen: z.boolean().optional(),
|
|
123
|
+
fullVideoLink: z.string().optional(),
|
|
124
|
+
musicVolume: z.number().optional(),
|
|
125
|
+
speed: z.number().optional(),
|
|
126
|
+
})
|
|
127
|
+
.optional(),
|
|
128
|
+
// The ranges are `slices/video/edit-settings.ts`'s, checked by admission, not the schema's.
|
|
129
|
+
videoEdit: videoEditSchema.optional(),
|
|
89
130
|
});
|
|
90
131
|
export const runConfigSchema = runDraftSchema.extend({
|
|
91
132
|
rendered: z.record(z.string(), z.string()),
|
|
@@ -5,11 +5,15 @@ import { defaultDocumentTheme } from "../document/model.js";
|
|
|
5
5
|
import { collectSharedGlossary } from "../narration/shared-glossary.js";
|
|
6
6
|
import { admitInitialRevision } from "../rebuild/runtime-admission.js";
|
|
7
7
|
import { adoptBaseline } from "../revisions/adopt.js";
|
|
8
|
+
import { insertAsset } from "../revisions/repo.js";
|
|
9
|
+
import { discardPreparedAssets } from "../storage/assets.js";
|
|
10
|
+
import { prepareStagedFile } from "../storage/prepare.js";
|
|
8
11
|
import { attachStagedFile, storeText } from "../storage/staging.js";
|
|
9
12
|
import { releaseStagedFile } from "../storage/staging-refs.js";
|
|
10
13
|
import { sourceOf, } from "./model.js";
|
|
11
14
|
import { insertProject, insertStage } from "./repo.js";
|
|
12
|
-
import { usesPronunciationGlossary } from "./rules.js";
|
|
15
|
+
import { usesPronunciationGlossary, usesReference } from "./rules.js";
|
|
16
|
+
import { render } from "./substitute.js";
|
|
13
17
|
// Provide → `provided` with its output attached; Off → `skipped`;
|
|
14
18
|
// everything else → `pending`.
|
|
15
19
|
export function initialState(source) {
|
|
@@ -26,8 +30,12 @@ export function startRun(deps, draft, rendered, retainStaged = false, templates)
|
|
|
26
30
|
const shared = usesPronunciationGlossary(draft) && draft.audio?.shareGlossary === true
|
|
27
31
|
? collectSharedGlossary(deps).entries
|
|
28
32
|
: [];
|
|
33
|
+
// The title may name keywords too ("D&D Lore: {{Topic}}"), filled like a prompt's, so a
|
|
34
|
+
// template or Play keeps the pattern and each project gets its own title.
|
|
35
|
+
const title = render(draft.title, draft.values).trim() || draft.title;
|
|
29
36
|
const config = {
|
|
30
37
|
...draft,
|
|
38
|
+
title,
|
|
31
39
|
// A new project always names its document theme: a config without one reads as the
|
|
32
40
|
// DiceMaster of older projects (see documentThemeOf), which no new project should get.
|
|
33
41
|
...(sourceOf(draft.sources, "document") === "generate" && draft.document === undefined
|
|
@@ -38,7 +46,7 @@ export function startRun(deps, draft, rendered, retainStaged = false, templates)
|
|
|
38
46
|
};
|
|
39
47
|
const project = {
|
|
40
48
|
id,
|
|
41
|
-
title
|
|
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
|
+
}
|