@gentbajko/slopify 2.0.1 → 2.0.3
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/adapters/alignment/cache.js +17 -1
- package/dist/adapters/alignment/prefetch.js +35 -0
- package/dist/adapters/alignment/protocol.js +11 -1
- package/dist/adapters/alignment/runner.js +4 -1
- package/dist/adapters/alignment/worker.js +28 -5
- package/dist/adapters/explain.js +93 -0
- package/dist/adapters/ffmpeg.js +3 -3
- package/dist/adapters/host-cli/index.js +9 -2
- package/dist/adapters/host-cli/transport.js +4 -4
- package/dist/adapters/image/bytes.js +2 -2
- package/dist/adapters/image/codex-output.js +12 -12
- package/dist/adapters/image/codex.js +24 -8
- package/dist/adapters/image/fal.js +12 -6
- package/dist/adapters/image/google.js +18 -8
- package/dist/adapters/image/models.js +8 -2
- package/dist/adapters/image/openai.js +15 -6
- package/dist/adapters/image/replicate.js +16 -7
- package/dist/adapters/llm/claude-code-models.js +1 -1
- package/dist/adapters/llm/claude-code.js +16 -7
- package/dist/adapters/llm/cli-login-error.js +2 -1
- package/dist/adapters/llm/codex-models.js +1 -1
- package/dist/adapters/llm/codex.js +5 -6
- package/dist/adapters/llm/document-workspace.js +2 -2
- package/dist/adapters/llm/gemini-models.js +1 -1
- package/dist/adapters/llm/gemini-workspace.js +1 -1
- package/dist/adapters/llm/gemini.js +15 -11
- package/dist/adapters/llm/openrouter.js +21 -9
- package/dist/adapters/llm/run-cli.js +14 -11
- package/dist/adapters/tts/cartesia.js +10 -3
- package/dist/adapters/tts/elevenlabs.js +18 -6
- package/dist/adapters/tts/inworld-async.js +47 -16
- package/dist/adapters/tts/inworld.js +25 -15
- package/dist/adapters/tts/openai.js +18 -6
- package/dist/catalog/registry.js +13 -10
- package/dist/catalog/store.js +1 -1
- package/dist/catalog/validate.js +13 -4
- package/dist/edge/cli.js +37 -8
- package/dist/edge/docker-launch.js +6 -6
- package/dist/edge/docker-projects/engine.js +34 -13
- package/dist/edge/docker-projects/install.js +8 -8
- package/dist/edge/docker-projects/state.js +6 -6
- package/dist/edge/docker-projects/tree.js +6 -6
- package/dist/edge/docker.js +13 -13
- package/dist/edge/http/actions.js +27 -25
- package/dist/edge/http/app.js +3 -3
- package/dist/edge/http/audio-preview.js +1 -1
- package/dist/edge/http/checkpoints.js +5 -1
- package/dist/edge/http/diagnostics.js +3 -1
- package/dist/edge/http/draft-files.js +2 -2
- package/dist/edge/http/draft-problem.js +11 -1
- package/dist/edge/http/files.js +4 -4
- package/dist/edge/http/folder-location.js +3 -3
- package/dist/edge/http/fonts.js +22 -10
- package/dist/edge/http/host-cli.js +34 -12
- package/dist/edge/http/multipart.js +7 -5
- package/dist/edge/http/open-folder.js +2 -2
- package/dist/edge/http/planning.js +4 -4
- package/dist/edge/http/problem.js +2 -2
- package/dist/edge/http/project-templates.js +7 -3
- package/dist/edge/http/projects.js +8 -8
- package/dist/edge/http/prompts.js +9 -4
- package/dist/edge/http/providers.js +15 -7
- package/dist/edge/http/revision-files.js +3 -3
- package/dist/edge/http/revisions.js +30 -4
- package/dist/edge/http/schedules.js +9 -9
- package/dist/edge/http/settings.js +15 -9
- package/dist/edge/http/staging.js +5 -5
- package/dist/edge/http/storage.js +4 -4
- package/dist/edge/http/subtitles.js +1 -1
- package/dist/edge/http/tutorial.js +2 -2
- package/dist/edge/http/update.js +3 -3
- package/dist/host-cli/install.js +28 -6
- package/dist/host-cli/paths.js +6 -6
- package/dist/host-cli/server.js +1 -1
- package/dist/host-cli/service.js +11 -11
- package/dist/host-cli/status.js +7 -3
- package/dist/kernel/config/index.js +6 -4
- package/dist/kernel/db/migrate.js +2 -1
- package/dist/kernel/db/migrations/0013-schedule-topic-queue.sql +4 -0
- package/dist/kernel/errors.js +12 -0
- package/dist/kernel/lock.js +4 -3
- package/dist/kernel/paths.js +5 -0
- package/dist/kernel/ports/subtitles.js +15 -1
- package/dist/kernel/runner/attempt.js +26 -2
- package/dist/kernel/runner/index.js +16 -6
- package/dist/kernel/runner/progress.js +56 -0
- package/dist/main.js +20 -4
- package/dist/slices/admission/rules.js +32 -20
- package/dist/slices/article/continuation.js +4 -2
- package/dist/slices/article/plain.js +2 -2
- package/dist/slices/article/provided-entries.js +3 -3
- package/dist/slices/article/run.js +2 -2
- package/dist/slices/article/segments.js +5 -3
- package/dist/slices/control/providers.js +20 -7
- package/dist/slices/fonts/catalog.js +1 -1
- package/dist/slices/fonts/upload.js +1 -1
- package/dist/slices/images/run.js +5 -5
- package/dist/slices/library/lint.js +13 -7
- package/dist/slices/library/slots.js +2 -2
- package/dist/slices/narration/concat.js +1 -1
- package/dist/slices/narration/pronunciation-chunks.js +3 -3
- package/dist/slices/narration/pronunciation.js +2 -2
- package/dist/slices/narration/run.js +7 -7
- package/dist/slices/narration/steering.js +3 -3
- package/dist/slices/play-drafts/convert.js +5 -4
- package/dist/slices/play-drafts/readiness.js +12 -6
- package/dist/slices/play-drafts/review-inputs.js +7 -4
- package/dist/slices/play-drafts/review.js +3 -3
- package/dist/slices/play-drafts/service.js +3 -1
- package/dist/slices/play-drafts/uploads.js +1 -1
- package/dist/slices/rebuild/admission-repo.js +2 -2
- package/dist/slices/rebuild/recipe-legacy.js +1 -1
- package/dist/slices/rebuild/recipe-narration-text.js +1 -1
- package/dist/slices/rebuild/recipe-visual.js +1 -1
- package/dist/slices/rebuild/recovery.js +2 -2
- package/dist/slices/rebuild/repo.js +3 -3
- package/dist/slices/rebuild/runtime-admission.js +4 -4
- package/dist/slices/rebuild/runtime-article.js +5 -5
- package/dist/slices/rebuild/runtime-checkpoints.js +4 -4
- package/dist/slices/rebuild/runtime-export-inputs.js +6 -6
- package/dist/slices/rebuild/runtime-export.js +4 -4
- package/dist/slices/rebuild/runtime-local.js +6 -6
- package/dist/slices/rebuild/runtime-narration-text.js +46 -7
- package/dist/slices/rebuild/runtime-piece-label.js +62 -0
- package/dist/slices/rebuild/runtime-provider.js +6 -4
- package/dist/slices/rebuild/runtime-publication.js +3 -3
- package/dist/slices/rebuild/runtime-run.js +11 -1
- package/dist/slices/rebuild/runtime-store.js +45 -15
- package/dist/slices/rebuild/runtime-subtitles.js +80 -17
- package/dist/slices/rebuild/transition-repo.js +6 -6
- package/dist/slices/reruns/index.js +1 -1
- package/dist/slices/research/run.js +9 -7
- package/dist/slices/research/synthesis.js +4 -2
- package/dist/slices/revisions/mutation-assets.js +26 -8
- package/dist/slices/revisions/mutation-cues.js +1 -1
- package/dist/slices/revisions/rules.js +18 -9
- package/dist/slices/revisions/schema.js +1 -1
- package/dist/slices/schedules/repo.js +55 -11
- package/dist/slices/schedules/scheduler.js +25 -8
- package/dist/slices/schedules/schema.js +19 -4
- package/dist/slices/schedules/service.js +2 -0
- package/dist/slices/settings/cli-paths.js +7 -4
- package/dist/slices/settings/models.js +4 -4
- package/dist/slices/settings/playback.js +2 -2
- package/dist/slices/storage/assets.js +5 -5
- package/dist/slices/storage/layout.js +1 -1
- package/dist/slices/subtitles/captions.js +1 -1
- package/dist/slices/subtitles/prepare.js +7 -6
- package/dist/slices/subtitles/transcript.js +1 -1
- package/dist/slices/thumbnail/run.js +8 -8
- package/dist/slices/video/audio-inputs.js +2 -2
- package/dist/slices/video/ffmpeg.js +6 -4
- package/dist/slices/video/plan.js +1 -1
- package/dist/slices/video/run.js +2 -2
- package/dist/updater/plan.js +1 -1
- package/dist/updater/service.js +4 -3
- package/dist/updater/worker.js +6 -4
- package/dist/web/assets/{index-CZNiHm8P.css → index-Bwtmu9ei.css} +1 -1
- package/dist/web/assets/index-DjhMJqMN.js +141 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DPgGLX5m.js +0 -137
package/dist/kernel/paths.js
CHANGED
|
@@ -19,3 +19,8 @@ export function ensureDirs(paths, options) {
|
|
|
19
19
|
chmodSync(dir, options.mode);
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
// The subtitle-timing model's cache. The app readies it at start and every captioned render
|
|
23
|
+
// reads it, so both name it through here.
|
|
24
|
+
export function subtitleModelDir(dataDir) {
|
|
25
|
+
return join(dataDir, "models", "english-subtitles");
|
|
26
|
+
}
|
|
@@ -1 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
// Where the audio stopped matching its transcript: seconds into the aligned file, the
|
|
2
|
+
// transcript words it expected there and what the model heard instead (either may be empty).
|
|
3
|
+
// The aligner throws it so the caller can say which part of the narration to redo.
|
|
4
|
+
export class SubtitleMismatch extends Error {
|
|
5
|
+
at;
|
|
6
|
+
expected;
|
|
7
|
+
heard;
|
|
8
|
+
constructor(message, at, expected, heard) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.at = at;
|
|
11
|
+
this.expected = expected;
|
|
12
|
+
this.heard = heard;
|
|
13
|
+
this.name = "SubtitleMismatch";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -100,8 +100,8 @@ function classify(error, expired, opts, limit) {
|
|
|
100
100
|
return providerError({
|
|
101
101
|
kind: "timeout",
|
|
102
102
|
message: opts.streaming === true
|
|
103
|
-
? `
|
|
104
|
-
: `
|
|
103
|
+
? `The ${callee[opts.kind]} sent nothing for ${seconds} seconds, so Slopify stopped waiting. It may be overloaded: wait a few minutes, then use Retry stage.`
|
|
104
|
+
: `The ${callee[opts.kind]} did not answer within ${seconds} seconds. It may be overloaded: wait a few minutes, then use Retry stage, or choose another model in the Providers section of Edit project.`,
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
107
|
if (isProviderError(error)) {
|
|
@@ -113,11 +113,35 @@ function classify(error, expired, opts, limit) {
|
|
|
113
113
|
...(error.fault.retryAfterMs === undefined ? {} : { retryAfterMs: error.fault.retryAfterMs }),
|
|
114
114
|
});
|
|
115
115
|
}
|
|
116
|
+
if (unreachable(error)) {
|
|
117
|
+
// `fetch` names only itself; the cause's code (ENOTFOUND, ECONNREFUSED) is the clue.
|
|
118
|
+
const code = causeCode(error);
|
|
119
|
+
return providerError({
|
|
120
|
+
kind: "other",
|
|
121
|
+
message: `Slopify could not reach the ${callee[opts.kind]} over the internet${code === undefined ? "" : ` (${code})`}. Check your internet connection, firewall or VPN, then use Retry stage.`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
116
124
|
return providerError({
|
|
117
125
|
kind: "other",
|
|
118
126
|
message: redact(error instanceof Error ? error.message : String(error)),
|
|
119
127
|
});
|
|
120
128
|
}
|
|
129
|
+
// What the user calls the other end of each kind of call.
|
|
130
|
+
const callee = {
|
|
131
|
+
llm: "AI model",
|
|
132
|
+
tts: "narration provider",
|
|
133
|
+
image: "image provider",
|
|
134
|
+
};
|
|
135
|
+
// Node's `fetch` rejects a request that never reached the server with this TypeError.
|
|
136
|
+
function unreachable(error) {
|
|
137
|
+
return error instanceof TypeError && error.message === "fetch failed";
|
|
138
|
+
}
|
|
139
|
+
function causeCode(error) {
|
|
140
|
+
const cause = error.cause;
|
|
141
|
+
if (typeof cause !== "object" || cause === null || !("code" in cause))
|
|
142
|
+
return undefined;
|
|
143
|
+
return typeof cause.code === "string" && /^[A-Z_]+$/.test(cause.code) ? cause.code : undefined;
|
|
144
|
+
}
|
|
121
145
|
// The same milliseconds, measured two ways. `idle` restarts the count on every chunk, so a
|
|
122
146
|
// stalled stream is cut off and a long answer is not. Cancel still wins.
|
|
123
147
|
function deadline(clock, parent, ms, idle) {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { causedBy } from "../errors.js";
|
|
1
2
|
import { derive, deps as graph, satisfied } from "./graph.js";
|
|
3
|
+
import { progressGate } from "./progress.js";
|
|
2
4
|
const pauseReason = new Error("paused by user");
|
|
3
5
|
export function createRunner(deps) {
|
|
4
6
|
const inflight = new Map();
|
|
@@ -60,6 +62,12 @@ export function createRunner(deps) {
|
|
|
60
62
|
emitStage(stage, state, reason);
|
|
61
63
|
};
|
|
62
64
|
async function execute(stage, controller) {
|
|
65
|
+
const tag = (event) => ({
|
|
66
|
+
...event,
|
|
67
|
+
revisionId: stage.work.revisionId,
|
|
68
|
+
workId: stage.work.workId,
|
|
69
|
+
});
|
|
70
|
+
const progress = progressGate((event) => deps.emit(stage.projectId, tag(event)));
|
|
63
71
|
try {
|
|
64
72
|
const run = deps.runs[stage.kind];
|
|
65
73
|
if (run === undefined) {
|
|
@@ -76,16 +84,18 @@ export function createRunner(deps) {
|
|
|
76
84
|
deps.stages.maySubmit(stage.work, pieceId),
|
|
77
85
|
signal: controller.signal,
|
|
78
86
|
emit: (event) => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
});
|
|
87
|
+
if (event.type === "stage.progress")
|
|
88
|
+
progress.offer(event);
|
|
89
|
+
else
|
|
90
|
+
deps.emit(stage.projectId, tag(event));
|
|
84
91
|
},
|
|
85
92
|
});
|
|
93
|
+
progress.flush();
|
|
94
|
+
progress.close();
|
|
86
95
|
conclude(stage, result === "held" ? "pending" : "done", null);
|
|
87
96
|
}
|
|
88
97
|
catch (error) {
|
|
98
|
+
progress.close();
|
|
89
99
|
if (controller.signal.aborted) {
|
|
90
100
|
// A late rejection from the aborted call is how a stage learns it was canceled, so
|
|
91
101
|
// it is not logged as a fault.
|
|
@@ -96,7 +106,7 @@ export function createRunner(deps) {
|
|
|
96
106
|
deps.log.write("error", "stage.failed", {
|
|
97
107
|
projectId: stage.projectId,
|
|
98
108
|
stage: stage.kind,
|
|
99
|
-
detail:
|
|
109
|
+
detail: causedBy(error),
|
|
100
110
|
});
|
|
101
111
|
conclude(stage, "failed", reasonOf(error));
|
|
102
112
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The most progress a single stage invocation reports per second. A stage may call emit on
|
|
2
|
+
// every chunk it reads (a 95 MB download is ~6,000 of them) and each report writes the
|
|
3
|
+
// database and reaches every open page; nobody can read a meter faster than this.
|
|
4
|
+
export const progressIntervalMs = 500;
|
|
5
|
+
// Repeats of the last value are dropped. The first report of a window goes out at once and
|
|
6
|
+
// the newest one offered inside it goes out when the window ends, so a meter never sits on
|
|
7
|
+
// a stale value longer than one interval.
|
|
8
|
+
export function progressGate(send, intervalMs = progressIntervalMs) {
|
|
9
|
+
let last;
|
|
10
|
+
let held;
|
|
11
|
+
let timer;
|
|
12
|
+
let closed = false;
|
|
13
|
+
const same = (a, b) => a !== undefined && a.current === b.current && a.total === b.total;
|
|
14
|
+
const deliver = (event) => {
|
|
15
|
+
last = event;
|
|
16
|
+
send(event);
|
|
17
|
+
timer = setTimeout(() => {
|
|
18
|
+
timer = undefined;
|
|
19
|
+
const next = held;
|
|
20
|
+
held = undefined;
|
|
21
|
+
if (next !== undefined && !closed && !same(last, next))
|
|
22
|
+
deliver(next);
|
|
23
|
+
}, intervalMs);
|
|
24
|
+
timer.unref?.();
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
offer: (event) => {
|
|
28
|
+
if (closed)
|
|
29
|
+
return;
|
|
30
|
+
if (timer !== undefined) {
|
|
31
|
+
held = event;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!same(last, event))
|
|
35
|
+
deliver(event);
|
|
36
|
+
},
|
|
37
|
+
flush: () => {
|
|
38
|
+
if (timer !== undefined)
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
timer = undefined;
|
|
41
|
+
const next = held;
|
|
42
|
+
held = undefined;
|
|
43
|
+
if (next !== undefined && !closed && !same(last, next)) {
|
|
44
|
+
last = next;
|
|
45
|
+
send(next);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
close: () => {
|
|
49
|
+
closed = true;
|
|
50
|
+
held = undefined;
|
|
51
|
+
if (timer !== undefined)
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
timer = undefined;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -5,6 +5,7 @@ import { serve } from "@hono/node-server";
|
|
|
5
5
|
import ffmpegStatic from "ffmpeg-static";
|
|
6
6
|
import { buildRegistry } from "./adapter-registry.js";
|
|
7
7
|
import { alignSubtitles } from "./adapters/alignment/index.js";
|
|
8
|
+
import { prefetchModel } from "./adapters/alignment/prefetch.js";
|
|
8
9
|
import { prepareFfmpeg } from "./adapters/ffmpeg.js";
|
|
9
10
|
import { createHostCliClient } from "./adapters/host-cli/index.js";
|
|
10
11
|
import { nodeRunCli } from "./adapters/llm/run-cli.js";
|
|
@@ -21,10 +22,11 @@ import { systemClock } from "./kernel/clock.js";
|
|
|
21
22
|
import { openDb } from "./kernel/db/index.js";
|
|
22
23
|
import { migrate } from "./kernel/db/migrate.js";
|
|
23
24
|
import { transact } from "./kernel/db/tx.js";
|
|
25
|
+
import { causedBy } from "./kernel/errors.js";
|
|
24
26
|
import { ulidIds } from "./kernel/ids.js";
|
|
25
27
|
import { acquireInstanceLock } from "./kernel/lock.js";
|
|
26
28
|
import { openLog } from "./kernel/log.js";
|
|
27
|
-
import { ensureDirs, layout } from "./kernel/paths.js";
|
|
29
|
+
import { ensureDirs, layout, subtitleModelDir } from "./kernel/paths.js";
|
|
28
30
|
import { sqliteAttempts } from "./kernel/runner/attempt-repo.js";
|
|
29
31
|
import { createCheckpointAuthority, } from "./kernel/runner/checkpoint-authority.js";
|
|
30
32
|
import { createRunner } from "./kernel/runner/index.js";
|
|
@@ -109,7 +111,7 @@ export function createScheduleTickLifecycle(deps) {
|
|
|
109
111
|
},
|
|
110
112
|
};
|
|
111
113
|
}
|
|
112
|
-
export async function boot(config) {
|
|
114
|
+
export async function boot(config, options = {}) {
|
|
113
115
|
const clock = systemClock;
|
|
114
116
|
const ids = ulidIds;
|
|
115
117
|
const paths = layout(config.dataDir);
|
|
@@ -117,7 +119,7 @@ export async function boot(config) {
|
|
|
117
119
|
if (dockerState !== undefined &&
|
|
118
120
|
(process.env.SLOPIFY_CONTAINER !== "1" ||
|
|
119
121
|
dockerState !== "/opt/slopify-install/activation.json"))
|
|
120
|
-
throw new Error("
|
|
122
|
+
throw new Error("This container was started with Slopify's Docker settings in the wrong place (SLOPIFY_DOCKER_INSTALL_STATE). Start it with the launcher instead: npx @gentbajko/slopify --docker");
|
|
121
123
|
const candidateToken = process.env.SLOPIFY_UPDATE_TOKEN ?? "";
|
|
122
124
|
const pendingActivation = isUpdateToken(candidateToken) && process.env.SLOPIFY_UPDATE_PENDING === "1";
|
|
123
125
|
ensureDirs(paths, { mode: 0o700 });
|
|
@@ -241,7 +243,7 @@ export async function boot(config) {
|
|
|
241
243
|
: !existsSync(oldEntry) || !existsSync(workerEntry)
|
|
242
244
|
? "Run Slopify from its installed package to use in-app updates."
|
|
243
245
|
: npm === undefined
|
|
244
|
-
? "npm
|
|
246
|
+
? "npm was not found, so in-app updates are off. Install Node.js with npm (https://nodejs.org), or update from the terminal: npx @gentbajko/slopify@latest"
|
|
245
247
|
: undefined,
|
|
246
248
|
busy: () => updateDb.prepare("SELECT 1 FROM stages WHERE state = 'running' LIMIT 1").get() !==
|
|
247
249
|
undefined ||
|
|
@@ -364,6 +366,18 @@ export async function boot(config) {
|
|
|
364
366
|
// Whatever last run left queued goes out at start. Nothing waits for
|
|
365
367
|
// it, and an unreachable collector costs one refused socket.
|
|
366
368
|
flusher.soon();
|
|
369
|
+
const modelPrefetch = new AbortController();
|
|
370
|
+
const prefetching = options.prefetchSubtitleModel
|
|
371
|
+
? prefetchModel({
|
|
372
|
+
cacheDir: subtitleModelDir(paths.dataDir),
|
|
373
|
+
seed: options.subtitleModelSeed,
|
|
374
|
+
signal: modelPrefetch.signal,
|
|
375
|
+
}).then(() => log.write("info", "subtitle-model.ready"), (error) => {
|
|
376
|
+
// Not fatal: the first captioned render tries again and says why on its row.
|
|
377
|
+
if (!modelPrefetch.signal.aborted)
|
|
378
|
+
log.write("warn", "subtitle-model.prefetch", { detail: causedBy(error) });
|
|
379
|
+
})
|
|
380
|
+
: Promise.resolve();
|
|
367
381
|
const open = db;
|
|
368
382
|
let stopping;
|
|
369
383
|
let stopActivation = () => { };
|
|
@@ -376,6 +390,7 @@ export async function boot(config) {
|
|
|
376
390
|
const serverClose = beginServerClose(server);
|
|
377
391
|
stopActivation();
|
|
378
392
|
audioPreviews.close();
|
|
393
|
+
modelPrefetch.abort();
|
|
379
394
|
try {
|
|
380
395
|
// Admission closes before the listener. Requests already admitted keep the
|
|
381
396
|
// database until their response settles; anything racing shutdown receives 503.
|
|
@@ -394,6 +409,7 @@ export async function boot(config) {
|
|
|
394
409
|
// closes and fail to mark its batch delivered, which costs one re-send that
|
|
395
410
|
// the collector deduplicates by event id.
|
|
396
411
|
flusher.stop();
|
|
412
|
+
await prefetching;
|
|
397
413
|
log.write("info", "shutdown");
|
|
398
414
|
open.close();
|
|
399
415
|
lock.release();
|
|
@@ -24,16 +24,16 @@ export function admit(input) {
|
|
|
24
24
|
const draft = normaliseDraft(input.draft);
|
|
25
25
|
const { sources } = draft;
|
|
26
26
|
if (draft.title === "") {
|
|
27
|
-
fields.push({ field: "title", message: "
|
|
27
|
+
fields.push({ field: "title", message: "Enter a title for this video." });
|
|
28
28
|
}
|
|
29
29
|
else if (draft.title.length > titleMax) {
|
|
30
|
-
fields.push({ field: "title", message: `
|
|
30
|
+
fields.push({ field: "title", message: `Keep the title to ${titleMax} characters or fewer.` });
|
|
31
31
|
}
|
|
32
32
|
for (const kind of stageKinds) {
|
|
33
33
|
if (!allowedSources[kind].includes(sources[kind])) {
|
|
34
34
|
fields.push({
|
|
35
35
|
field: `sources.${kind}`,
|
|
36
|
-
message: `
|
|
36
|
+
message: `"${sources[kind]}" is not an option for ${kind}. Reload the page and choose again.`,
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -45,7 +45,7 @@ export function admit(input) {
|
|
|
45
45
|
draft.outro?.mode === "llm" ||
|
|
46
46
|
usesNarrationPreparation(draft);
|
|
47
47
|
if (needsLlm && !chosen(draft.llm)) {
|
|
48
|
-
fields.push({ field: "llm", message: "
|
|
48
|
+
fields.push({ field: "llm", message: "Choose a text (LLM) provider and model." });
|
|
49
49
|
}
|
|
50
50
|
if (sources.article === "generate" && blank(draft.articlePrompt)) {
|
|
51
51
|
fields.push({ field: "articlePrompt", message: "Pick an article prompt." });
|
|
@@ -54,10 +54,10 @@ export function admit(input) {
|
|
|
54
54
|
fields.push(...narrationPreparationFields(draft));
|
|
55
55
|
if (sources.audio === "generate") {
|
|
56
56
|
if (voiced === undefined || !chosen(voiced)) {
|
|
57
|
-
fields.push({ field: "audio", message: "
|
|
57
|
+
fields.push({ field: "audio", message: "Choose a narration provider and model." });
|
|
58
58
|
}
|
|
59
59
|
else if (voiced.voice.trim() === "") {
|
|
60
|
-
fields.push({ field: "audio.voice", message: "
|
|
60
|
+
fields.push({ field: "audio.voice", message: "Choose a voice for the narration." });
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
if (sources.images === "generate") {
|
|
@@ -67,14 +67,17 @@ export function admit(input) {
|
|
|
67
67
|
sources.thumbnail === "from_prompt" ||
|
|
68
68
|
sources.thumbnail === "prompt_by_llm") &&
|
|
69
69
|
!chosen(draft.images)) {
|
|
70
|
-
fields.push({ field: "images", message: "
|
|
70
|
+
fields.push({ field: "images", message: "Choose an image provider and model." });
|
|
71
71
|
}
|
|
72
72
|
if ((sources.thumbnail === "from_prompt" || sources.thumbnail === "prompt_by_llm") &&
|
|
73
73
|
blank(draft.thumbnailPrompt)) {
|
|
74
74
|
fields.push({ field: "thumbnailPrompt", message: "Pick a thumbnail prompt." });
|
|
75
75
|
}
|
|
76
76
|
if (sources.audio === "off" && draft.subtitles !== undefined && draft.subtitles.mode !== "off") {
|
|
77
|
-
fields.push({
|
|
77
|
+
fields.push({
|
|
78
|
+
field: "subtitles.mode",
|
|
79
|
+
message: "Subtitles need narration. Turn narration on, or turn subtitles off.",
|
|
80
|
+
});
|
|
78
81
|
}
|
|
79
82
|
checkProvided(draft, input.staged, fields);
|
|
80
83
|
checkValues(draft, input.requiredSlots, fields);
|
|
@@ -83,7 +86,7 @@ export function admit(input) {
|
|
|
83
86
|
draft.silenceGapSeconds > silenceGapSecondsMax) {
|
|
84
87
|
fields.push({
|
|
85
88
|
field: "silenceGapSeconds",
|
|
86
|
-
message: `
|
|
89
|
+
message: `Enter a silence gap between 0 and ${silenceGapSecondsMax} seconds.`,
|
|
87
90
|
});
|
|
88
91
|
}
|
|
89
92
|
return fields.length === 0 ? { ok: true, draft } : { ok: false, fields };
|
|
@@ -121,7 +124,10 @@ export function normaliseDraft(draft) {
|
|
|
121
124
|
function checkImagePrompts(draft, fields) {
|
|
122
125
|
if (draft.imagePrompts.length === 0) {
|
|
123
126
|
// A run always has an image source.
|
|
124
|
-
fields.push({
|
|
127
|
+
fields.push({
|
|
128
|
+
field: "imagePrompts",
|
|
129
|
+
message: "Tick at least one image prompt, or turn images off.",
|
|
130
|
+
});
|
|
125
131
|
return;
|
|
126
132
|
}
|
|
127
133
|
let total = 0;
|
|
@@ -135,14 +141,14 @@ function checkImagePrompts(draft, fields) {
|
|
|
135
141
|
prompt.number > numberPerPromptMax) {
|
|
136
142
|
fields.push({
|
|
137
143
|
field: `imagePrompts.${index}.number`,
|
|
138
|
-
message: `
|
|
144
|
+
message: `Enter a whole number between 1 and ${numberPerPromptMax}.`,
|
|
139
145
|
});
|
|
140
146
|
}
|
|
141
147
|
}
|
|
142
148
|
if (total > imagesPerRunMax) {
|
|
143
149
|
fields.push({
|
|
144
150
|
field: "imagePrompts",
|
|
145
|
-
message: `A
|
|
151
|
+
message: `A video can have at most ${imagesPerRunMax} images; these prompts ask for ${total}. Lower the numbers.`,
|
|
146
152
|
});
|
|
147
153
|
}
|
|
148
154
|
}
|
|
@@ -168,11 +174,14 @@ function checkProvided(draft, staged, fields) {
|
|
|
168
174
|
else if (ids.length > imagesPerRunMax) {
|
|
169
175
|
fields.push({
|
|
170
176
|
field: "provided.images",
|
|
171
|
-
message: `A
|
|
177
|
+
message: `A video can have at most ${imagesPerRunMax} images. Remove some.`,
|
|
172
178
|
});
|
|
173
179
|
}
|
|
174
180
|
if (new Set(ids).size !== ids.length) {
|
|
175
|
-
fields.push({
|
|
181
|
+
fields.push({
|
|
182
|
+
field: "provided.images",
|
|
183
|
+
message: "The same image is picked twice. Remove the duplicate.",
|
|
184
|
+
});
|
|
176
185
|
}
|
|
177
186
|
for (const [index, id] of ids.entries()) {
|
|
178
187
|
checkFile(staged, id, "images", `provided.images.${index}`, "Pick an image.", fields);
|
|
@@ -187,28 +196,31 @@ function checkFile(staged, id, kind, field, missing, fields) {
|
|
|
187
196
|
}
|
|
188
197
|
const file = staged.find((candidate) => candidate.id === id);
|
|
189
198
|
if (file === undefined || file.stageKind !== kind) {
|
|
190
|
-
fields.push({ field, message: "That upload is no longer available
|
|
199
|
+
fields.push({ field, message: "That upload is no longer available. Choose the file again." });
|
|
191
200
|
return;
|
|
192
201
|
}
|
|
193
202
|
if (file.state !== "staged") {
|
|
194
|
-
fields.push({ field, message: "This
|
|
203
|
+
fields.push({ field, message: "This file is still uploading. Wait for it to finish." });
|
|
195
204
|
}
|
|
196
205
|
}
|
|
197
206
|
function checkValues(draft, requiredSlots, fields) {
|
|
198
207
|
for (const name of requiredSlots) {
|
|
199
208
|
const value = Object.hasOwn(draft.values, name) ? draft.values[name] : undefined;
|
|
200
209
|
if (value === undefined || value === "") {
|
|
201
|
-
fields.push({ field: `values.${name}`, message: "
|
|
210
|
+
fields.push({ field: `values.${name}`, message: "Fill in this field." });
|
|
202
211
|
continue;
|
|
203
212
|
}
|
|
204
213
|
if (value.length > valueMax) {
|
|
205
214
|
fields.push({
|
|
206
215
|
field: `values.${name}`,
|
|
207
|
-
message: `
|
|
216
|
+
message: `Keep this to ${valueMax} characters or fewer.`,
|
|
208
217
|
});
|
|
209
218
|
}
|
|
210
219
|
if (/[\n\r]/.test(value)) {
|
|
211
|
-
fields.push({
|
|
220
|
+
fields.push({
|
|
221
|
+
field: `values.${name}`,
|
|
222
|
+
message: "Keep this to a single line, without line breaks.",
|
|
223
|
+
});
|
|
212
224
|
}
|
|
213
225
|
}
|
|
214
226
|
}
|
|
@@ -224,7 +236,7 @@ export function narrationPreparationFields(draft) {
|
|
|
224
236
|
? [
|
|
225
237
|
{
|
|
226
238
|
field: "narrationPrompt",
|
|
227
|
-
message: "Narration
|
|
239
|
+
message: "Narration preparation only works with the Inworld TTS-2 model. Choose that model under Narration, or turn preparation Off.",
|
|
228
240
|
},
|
|
229
241
|
]
|
|
230
242
|
: [];
|
|
@@ -81,7 +81,9 @@ function truncated(answer) {
|
|
|
81
81
|
}
|
|
82
82
|
// Empty response → failed attempt.
|
|
83
83
|
function written(answer) {
|
|
84
|
-
return answer.text.trim() === ""
|
|
84
|
+
return answer.text.trim() === ""
|
|
85
|
+
? "The AI model returned an empty article. Retry stage; if it keeps happening, choose a different model in Edit project → Providers."
|
|
86
|
+
: undefined;
|
|
85
87
|
}
|
|
86
88
|
function finished(answer) {
|
|
87
89
|
const empty = written(answer);
|
|
@@ -89,6 +91,6 @@ function finished(answer) {
|
|
|
89
91
|
return empty;
|
|
90
92
|
}
|
|
91
93
|
return truncated(answer)
|
|
92
|
-
? `
|
|
94
|
+
? `The article was still unfinished after ${String(continuationLimit)} continuations because the AI model kept stopping at its length limit. Ask for a shorter article in Edit project → Prompts, or choose a model with a larger output limit in Edit project → Providers, then Retry stage.`
|
|
93
95
|
: undefined;
|
|
94
96
|
}
|
|
@@ -7,11 +7,11 @@ export function plainText(markdown) {
|
|
|
7
7
|
const paragraphs = tree.children
|
|
8
8
|
.map((node) => {
|
|
9
9
|
if (node.type !== "paragraph")
|
|
10
|
-
throw new Error("
|
|
10
|
+
throw new Error("Slopify hit an internal error (the article couldn't be turned into plain text). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
11
11
|
return node.children
|
|
12
12
|
.map((child) => {
|
|
13
13
|
if (child.type !== "text")
|
|
14
|
-
throw new Error("
|
|
14
|
+
throw new Error("Slopify hit an internal error (the article couldn't be turned into plain text). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
15
15
|
return child.value;
|
|
16
16
|
})
|
|
17
17
|
.join("");
|
|
@@ -11,7 +11,7 @@ export async function prepareProvidedArticleSegments(deps, audioContext, provide
|
|
|
11
11
|
const { projectId } = audioContext.stage;
|
|
12
12
|
const project = projectById(deps.db, projectId);
|
|
13
13
|
if (!project)
|
|
14
|
-
throw new Error(
|
|
14
|
+
throw new Error("This project no longer exists; it may have been deleted while it was running.");
|
|
15
15
|
const { config } = project;
|
|
16
16
|
if (config.sources.article !== "provide" || config.sources.audio !== "generate")
|
|
17
17
|
return "done";
|
|
@@ -20,11 +20,11 @@ export async function prepareProvidedArticleSegments(deps, audioContext, provide
|
|
|
20
20
|
audioContext.signal.throwIfAborted();
|
|
21
21
|
const article = stagesOf(deps.db, projectId).find((stage) => stage.kind === "article");
|
|
22
22
|
if (!article)
|
|
23
|
-
throw new Error(
|
|
23
|
+
throw new Error("Slopify hit an internal error (the project has no article stage). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
24
24
|
const outputs = outputsOf(deps.db, projectId);
|
|
25
25
|
const plain = outputs.find((output) => output.role === "article_txt");
|
|
26
26
|
if (!plain)
|
|
27
|
-
throw new Error("
|
|
27
|
+
throw new Error("The article text you provided is missing, so the intro and outro can't be written from it. Paste the article again in Edit project → Article, then Retry stage.");
|
|
28
28
|
const narration = readFileSync(outputPath(deps.paths, projectId, plain.path), "utf8");
|
|
29
29
|
const saved = outputs.find((output) => output.stageKind === "article" && output.role === "instructions");
|
|
30
30
|
let instructions = saved
|
|
@@ -13,14 +13,14 @@ export async function runArticle(deps, context, providers) {
|
|
|
13
13
|
const { projectId } = context.stage;
|
|
14
14
|
const project = projectById(deps.db, projectId);
|
|
15
15
|
if (project === undefined) {
|
|
16
|
-
throw new Error(
|
|
16
|
+
throw new Error("This project no longer exists; it may have been deleted while it was running.");
|
|
17
17
|
}
|
|
18
18
|
const choice = project.config.llm;
|
|
19
19
|
const articlePrompt = project.config.rendered.article;
|
|
20
20
|
if (choice === undefined || articlePrompt === undefined) {
|
|
21
21
|
// Admission refuses a run whose article is Generate without both, so
|
|
22
22
|
// reaching here is a bug in admission rather than something the user did.
|
|
23
|
-
throw new Error("
|
|
23
|
+
throw new Error("No AI model or article prompt is set for writing the article. Choose a model in Edit project → Providers and check Edit project → Prompts, then Retry stage.");
|
|
24
24
|
}
|
|
25
25
|
const notes = researchNotes(deps, projectId);
|
|
26
26
|
const brief = { articlePrompt, ...(notes === undefined ? {} : { notes }) };
|
|
@@ -7,12 +7,12 @@ export async function writeSegment(providers, choice, config, category, article,
|
|
|
7
7
|
return { ok: true, value: undefined };
|
|
8
8
|
const body = config.rendered[category];
|
|
9
9
|
if (body === undefined)
|
|
10
|
-
throw new Error(`
|
|
10
|
+
throw new Error(`Slopify hit an internal error (the ${category} text was never filled in). Retry stage; if it happens again, use Download diagnostics in Settings and report it.`);
|
|
11
11
|
const common = { category, name: picked.name, mode: picked.mode };
|
|
12
12
|
if (picked.mode === "text")
|
|
13
13
|
return { ok: true, value: { ...common, text: body, tokens: noTokens } };
|
|
14
14
|
if (choice === undefined)
|
|
15
|
-
throw new Error(`the ${category}
|
|
15
|
+
throw new Error(`No AI model is set for writing the ${category}. Choose one in Edit project → Providers, then Retry stage.`);
|
|
16
16
|
const messages = segmentMessages(body, config, article);
|
|
17
17
|
sent.push({ label: category === "intro" ? "Intro" : "Outro", messages });
|
|
18
18
|
const answer = await providers.llm({
|
|
@@ -20,7 +20,9 @@ export async function writeSegment(providers, choice, config, category, article,
|
|
|
20
20
|
model: choice.model,
|
|
21
21
|
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
22
22
|
messages,
|
|
23
|
-
check: (given) => given.text.trim() === ""
|
|
23
|
+
check: (given) => given.text.trim() === ""
|
|
24
|
+
? `The AI model returned an empty ${category}. Retry stage; if it keeps happening, choose a different model in Edit project → Providers.`
|
|
25
|
+
: undefined,
|
|
24
26
|
});
|
|
25
27
|
if (!answer.ok)
|
|
26
28
|
return answer;
|
|
@@ -25,7 +25,7 @@ export const providerChangesSchema = z
|
|
|
25
25
|
.optional(),
|
|
26
26
|
})
|
|
27
27
|
.strict()
|
|
28
|
-
.refine((value) => Object.keys(value).length > 0, "
|
|
28
|
+
.refine((value) => Object.keys(value).length > 0, "Change at least one provider before saving.");
|
|
29
29
|
export function validateLocalProviderChanges(changes, current) {
|
|
30
30
|
const fields = [];
|
|
31
31
|
const families = { llm: "llm", audio: "tts", images: "image" };
|
|
@@ -35,7 +35,10 @@ export function validateLocalProviderChanges(changes, current) {
|
|
|
35
35
|
continue;
|
|
36
36
|
const provider = current.providers.find((one) => one.id === picked.provider && one.family === families[key]);
|
|
37
37
|
if (provider === undefined) {
|
|
38
|
-
fields.push({
|
|
38
|
+
fields.push({
|
|
39
|
+
field: key,
|
|
40
|
+
message: "This provider cannot do this kind of work. Choose another provider.",
|
|
41
|
+
});
|
|
39
42
|
continue;
|
|
40
43
|
}
|
|
41
44
|
const usable = readinessIsUsable(provider.readiness);
|
|
@@ -43,14 +46,18 @@ export function validateLocalProviderChanges(changes, current) {
|
|
|
43
46
|
fields.push({
|
|
44
47
|
field: key,
|
|
45
48
|
message: provider.readiness.kind === "cli"
|
|
46
|
-
? (provider.readiness.issue ??
|
|
47
|
-
|
|
49
|
+
? (provider.readiness.issue ??
|
|
50
|
+
"Install this command-line tool on your computer and sign in to it, then try again.")
|
|
51
|
+
: "Add this provider's API key in Settings → Providers first.",
|
|
48
52
|
});
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
55
|
if (changes.audio !== undefined &&
|
|
52
56
|
!current.voices.some((voice) => voice.provider === changes.audio?.provider && voice.voiceId === changes.audio.voice)) {
|
|
53
|
-
fields.push({
|
|
57
|
+
fields.push({
|
|
58
|
+
field: "audio.voice",
|
|
59
|
+
message: "Choose one of your saved voices for this provider. Add voices in Settings → Voices.",
|
|
60
|
+
});
|
|
54
61
|
}
|
|
55
62
|
return fields;
|
|
56
63
|
}
|
|
@@ -67,10 +74,16 @@ export async function validateProviderChanges(changes, current) {
|
|
|
67
74
|
continue;
|
|
68
75
|
const result = await checkRuntimeModel(current.modelsFor, picked.provider, families[key], picked.model, picked.thinking);
|
|
69
76
|
if (result === "missing") {
|
|
70
|
-
fields.push({
|
|
77
|
+
fields.push({
|
|
78
|
+
field: `${key}.model`,
|
|
79
|
+
message: "This provider does not offer that model. Choose another model.",
|
|
80
|
+
});
|
|
71
81
|
}
|
|
72
82
|
else if (result === "thinking") {
|
|
73
|
-
fields.push({
|
|
83
|
+
fields.push({
|
|
84
|
+
field: `${key}.thinking`,
|
|
85
|
+
message: "This model does not support that thinking setting. Choose another.",
|
|
86
|
+
});
|
|
74
87
|
}
|
|
75
88
|
}
|
|
76
89
|
return fields;
|
|
@@ -31,7 +31,7 @@ export async function resolveFont(paths, id) {
|
|
|
31
31
|
return font;
|
|
32
32
|
}
|
|
33
33
|
export function missingFont() {
|
|
34
|
-
return new Error("The
|
|
34
|
+
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
35
|
cause: "font-not-found",
|
|
36
36
|
});
|
|
37
37
|
}
|
|
@@ -27,7 +27,7 @@ export async function uploadFont(paths, input) {
|
|
|
27
27
|
const root = join(paths.dataDir, "fonts");
|
|
28
28
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
29
29
|
if (!(await lstat(root)).isDirectory())
|
|
30
|
-
throw new Error(
|
|
30
|
+
throw new Error(`Slopify can't save uploaded fonts because ${root} is not a folder. Move or rename that file, then upload the font again.`);
|
|
31
31
|
const temporary = join(root, `.upload-${randomUUID()}`);
|
|
32
32
|
try {
|
|
33
33
|
await writeFile(temporary, input.content, { mode: 0o600, flag: "wx" });
|