@gentbajko/slopify 2.0.2 → 2.1.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/SUBTITLES.md +1 -1
- package/dist/adapters/alignment/protocol.js +11 -1
- package/dist/adapters/alignment/runner.js +4 -1
- package/dist/adapters/alignment/threads.js +11 -0
- package/dist/adapters/alignment/worker.js +66 -9
- package/dist/adapters/explain.js +93 -0
- package/dist/adapters/ffmpeg.js +3 -3
- package/dist/adapters/host-cli/index.js +34 -3
- 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/assets/document/SOURCE.txt +7 -0
- package/dist/assets/document/background.jpg +0 -0
- package/dist/assets/document/fonts/Cinzel-Black.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Bold.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Medium.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Regular.ttf +0 -0
- package/dist/assets/document/fonts/OFL.txt +93 -0
- package/dist/assets/document/fonts/SOURCE.txt +11 -0
- package/dist/catalog/registry.js +13 -10
- package/dist/catalog/store.js +1 -1
- package/dist/catalog/validate.js +16 -6
- package/dist/edge/cli.js +33 -7
- 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 +11 -7
- package/dist/edge/docker-projects/tree.js +6 -6
- package/dist/edge/docker.js +13 -13
- package/dist/edge/host-cli.js +14 -0
- 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-schema.js +5 -8
- package/dist/edge/http/folder-location.js +13 -8
- package/dist/edge/http/fonts.js +22 -10
- package/dist/edge/http/host-cli.js +61 -14
- 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 +8 -4
- 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/open-folder.js +95 -0
- package/dist/host-cli/paths.js +6 -6
- package/dist/host-cli/runtime.js +2 -0
- 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 +27 -2
- package/dist/kernel/db/migrations/0013-schedule-topic-queue.sql +4 -0
- package/dist/kernel/db/migrations/0014-document-stage.sql +44 -0
- package/dist/kernel/db/migrations/0015-description-prompts.sql +13 -0
- package/dist/kernel/lock.js +4 -3
- package/dist/kernel/pipeline.js +11 -1
- package/dist/kernel/ports/host-cli.js +14 -0
- package/dist/kernel/ports/subtitles.js +15 -1
- package/dist/kernel/runner/attempt.js +26 -2
- package/dist/kernel/runner/graph.js +6 -1
- package/dist/main.js +8 -4
- package/dist/slices/admission/model.js +7 -1
- package/dist/slices/admission/rules.js +98 -22
- package/dist/slices/admission/schema.js +16 -3
- package/dist/slices/admission/start.js +3 -2
- 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/document/blocks.js +129 -0
- package/dist/slices/document/flow.js +117 -0
- package/dist/slices/document/fonts.js +54 -0
- package/dist/slices/document/model.js +11 -0
- package/dist/slices/document/pages.js +198 -0
- package/dist/slices/document/render.js +242 -0
- package/dist/slices/document/sources.js +35 -0
- package/dist/slices/document/theme.js +158 -0
- package/dist/slices/document/writer.js +66 -0
- package/dist/slices/estimate/index.js +14 -2
- 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/model.js +1 -1
- package/dist/slices/library/slots.js +6 -3
- 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 +35 -5
- package/dist/slices/play-drafts/readiness.js +15 -8
- package/dist/slices/play-drafts/review-inputs.js +7 -4
- package/dist/slices/play-drafts/review.js +3 -3
- package/dist/slices/play-drafts/schema.js +22 -5
- package/dist/slices/play-drafts/service.js +3 -1
- package/dist/slices/play-drafts/uploads.js +1 -1
- package/dist/slices/project-templates/from-project.js +10 -0
- package/dist/slices/project-templates/setup.js +1 -0
- package/dist/slices/rebuild/admission-repo.js +2 -2
- package/dist/slices/rebuild/preview-details.js +11 -1
- package/dist/slices/rebuild/recipe-audio.js +3 -0
- package/dist/slices/rebuild/recipe-build.js +6 -1
- package/dist/slices/rebuild/recipe-document.js +36 -0
- package/dist/slices/rebuild/recipe-exports.js +14 -2
- package/dist/slices/rebuild/recipe-fixture.js +4 -0
- package/dist/slices/rebuild/recipe-legacy.js +6 -1
- package/dist/slices/rebuild/recipe-model.js +2 -0
- package/dist/slices/rebuild/recipe-narration-text.js +1 -1
- package/dist/slices/rebuild/recipe-provider-choice.js +3 -0
- package/dist/slices/rebuild/recipe-save.js +15 -3
- package/dist/slices/rebuild/recipe-validation.js +15 -2
- package/dist/slices/rebuild/recipe-visual.js +11 -2
- package/dist/slices/rebuild/recipe-work.js +15 -0
- package/dist/slices/rebuild/recipe-youtube.js +33 -0
- package/dist/slices/rebuild/recovery-selection.js +2 -0
- package/dist/slices/rebuild/recovery.js +12 -7
- package/dist/slices/rebuild/repo.js +3 -3
- package/dist/slices/rebuild/runtime-actions.js +2 -0
- 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-document.js +85 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +7 -6
- package/dist/slices/rebuild/runtime-export.js +39 -23
- 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 +66 -0
- package/dist/slices/rebuild/runtime-provider.js +6 -4
- package/dist/slices/rebuild/runtime-publication.js +4 -3
- package/dist/slices/rebuild/runtime-run.js +22 -4
- package/dist/slices/rebuild/runtime-store.js +7 -2
- package/dist/slices/rebuild/runtime-subtitles.js +82 -16
- package/dist/slices/rebuild/runtime-youtube.js +84 -0
- package/dist/slices/rebuild/transition-repo.js +8 -6
- package/dist/slices/reruns/cascade.js +6 -4
- 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 +4 -2
- package/dist/slices/revisions/projection.js +2 -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/downloads.js +1 -0
- package/dist/slices/storage/layout.js +7 -1
- package/dist/slices/storage/model.js +3 -0
- 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/telemetry/model.js +1 -1
- package/dist/slices/thumbnail/run.js +8 -8
- package/dist/slices/video/audio-export.js +11 -3
- package/dist/slices/video/audio-inputs.js +2 -2
- package/dist/slices/video/edit-list.js +73 -0
- package/dist/slices/video/ffmpeg.js +129 -61
- package/dist/slices/video/motion.js +51 -0
- package/dist/slices/video/plan.js +62 -28
- package/dist/slices/video/reuse-audio.js +2 -0
- package/dist/slices/video/run.js +23 -12
- package/dist/slices/video/slideshow.js +48 -0
- package/dist/slices/video/write-export.js +17 -25
- package/dist/slices/youtube/answer.js +199 -0
- package/dist/slices/youtube/model.js +27 -0
- package/dist/slices/youtube/timestamps.js +24 -0
- package/dist/slices/youtube/transcript.js +47 -0
- 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-B6NbhpjD.css +1 -0
- package/dist/web/assets/index-ComF5gwP.js +142 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-CZNiHm8P.css +0 -1
- package/dist/web/assets/index-DPgGLX5m.js +0 -137
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { subtitleModelDir } from "../../kernel/paths.js";
|
|
4
|
+
import { SubtitleMismatch } from "../../kernel/ports/subtitles.js";
|
|
4
5
|
import { resolveFont } from "../fonts/index.js";
|
|
5
6
|
import { validateCues } from "../revisions/rules.js";
|
|
6
7
|
import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
|
|
7
8
|
import { outputPath } from "../storage/layout.js";
|
|
8
9
|
import { captionCues, serializeAss, serializeSrt, serializeVtt } from "../subtitles/captions.js";
|
|
10
|
+
import { spoken } from "../video/plan.js";
|
|
9
11
|
import { exportSnapshot, retainedOutput, revisionAudio, revisionTranscript, } from "./runtime-export-inputs.js";
|
|
12
|
+
import { narrationChunks, narrationTextParts, } from "./runtime-narration-text.js";
|
|
10
13
|
import { preparedResult, preparedText, publishResult } from "./runtime-publication.js";
|
|
11
|
-
|
|
14
|
+
// The saved `subtitles.json`: every word at its time in the final video. The YouTube
|
|
15
|
+
// description reads it too (`runtime-youtube.ts`).
|
|
16
|
+
export const wordsSchema = z.object({
|
|
12
17
|
key: z.string(),
|
|
13
18
|
words: z.array(z.object({
|
|
14
19
|
text: z.string(),
|
|
@@ -39,14 +44,15 @@ export async function executeSubtitleRecipe(deps, context, piece) {
|
|
|
39
44
|
return cues(deps, context, piece, snapshot);
|
|
40
45
|
if (piece.key === "subtitles:files")
|
|
41
46
|
return files(deps, context, piece, snapshot);
|
|
42
|
-
throw new Error("
|
|
47
|
+
throw new Error("Slopify hit an internal error (unknown kind of caption step). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
43
48
|
}
|
|
44
49
|
async function timing(deps, context, piece, snapshot) {
|
|
45
50
|
const audio = await revisionAudio(deps, context, snapshot.view);
|
|
46
51
|
if (audio.length === 0)
|
|
47
|
-
throw new Error("
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
throw new Error("Captions and the YouTube description need narration audio, but this project has none. Turn captions and the YouTube description off in Edit project, or turn narration on, then Retry stage.");
|
|
53
|
+
const alignSubtitles = deps.alignSubtitles;
|
|
54
|
+
if (alignSubtitles === undefined)
|
|
55
|
+
throw new Error("Slopify hit an internal error (the caption timing tool is missing from this build). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
50
56
|
if (!context.maySubmit(piece.id))
|
|
51
57
|
return "held";
|
|
52
58
|
const words = [];
|
|
@@ -55,10 +61,11 @@ async function timing(deps, context, piece, snapshot) {
|
|
|
55
61
|
const total = audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
56
62
|
for (const segment of audio) {
|
|
57
63
|
context.signal.throwIfAborted();
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
const { path, kind } = segment;
|
|
65
|
+
if (path !== null && spoken(kind)) {
|
|
66
|
+
const aligned = await located(snapshot, kind, () => alignSubtitles({
|
|
67
|
+
audioPath: path,
|
|
68
|
+
text: revisionTranscript(deps, snapshot, kind),
|
|
62
69
|
cacheDir: subtitleModelDir(deps.paths.dataDir),
|
|
63
70
|
ffmpeg: deps.ffmpeg,
|
|
64
71
|
signal: context.signal,
|
|
@@ -70,10 +77,10 @@ async function timing(deps, context, piece, snapshot) {
|
|
|
70
77
|
current: Math.round((100 * (offset + segment.seconds * (maximum > 0 ? current / maximum : 0))) / total),
|
|
71
78
|
total: 100,
|
|
72
79
|
}),
|
|
73
|
-
});
|
|
80
|
+
}));
|
|
74
81
|
for (const word of aligned) {
|
|
75
82
|
if (word.end > segment.seconds + 0.1)
|
|
76
|
-
throw new Error("
|
|
83
|
+
throw new Error("Caption timing came out longer than the narration audio. Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
77
84
|
words.push({
|
|
78
85
|
...word,
|
|
79
86
|
start: word.start + offset,
|
|
@@ -84,7 +91,7 @@ async function timing(deps, context, piece, snapshot) {
|
|
|
84
91
|
offset += segment.seconds;
|
|
85
92
|
}
|
|
86
93
|
if (captionCues(words).length === 0)
|
|
87
|
-
throw new Error("
|
|
94
|
+
throw new Error("None of the narration could be matched to the article text, so captions can't be timed. Captions only work for English narration; if you uploaded your own audio, make sure it reads the article text, then Retry stage.");
|
|
88
95
|
context.signal.throwIfAborted();
|
|
89
96
|
const output = preparedText(deps, context, piece, "subtitle_words", "subtitles.json", JSON.stringify({ key: piece.logicalFingerprint ?? piece.fingerprint, words, omissions }));
|
|
90
97
|
await publishResult(deps, context, piece, [output], { words, omissions }, output.asset);
|
|
@@ -96,7 +103,7 @@ async function cues(deps, context, piece, snapshot) {
|
|
|
96
103
|
let value;
|
|
97
104
|
if (manual !== undefined) {
|
|
98
105
|
if (plan.work.find((one) => one.key === piece.key)?.disposition === "review")
|
|
99
|
-
throw new Error("
|
|
106
|
+
throw new Error("Your edited captions were written for an older version of the narration. Review them in Edit project → Captions and save, then Retry stage.");
|
|
100
107
|
const audio = await revisionAudio(deps, context, view);
|
|
101
108
|
const errors = validateCues(manual.cues, audio.reduce((sum, segment) => sum + segment.seconds, 0));
|
|
102
109
|
if (errors.length > 0)
|
|
@@ -109,7 +116,7 @@ async function cues(deps, context, piece, snapshot) {
|
|
|
109
116
|
one.state === "ready" &&
|
|
110
117
|
one.workKey === "subtitles:timing");
|
|
111
118
|
if (output === undefined)
|
|
112
|
-
throw new Error("
|
|
119
|
+
throw new Error("Caption timing is missing. Use Re-run section on Video, then Retry stage.");
|
|
113
120
|
const timed = wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, context.work.projectId, output.output.path), "utf8")));
|
|
114
121
|
value = { cues: [...captionCues(timed.words)], omissions: timed.omissions };
|
|
115
122
|
}
|
|
@@ -123,10 +130,10 @@ async function files(deps, context, piece, snapshot) {
|
|
|
123
130
|
const { view } = snapshot;
|
|
124
131
|
const config = view.revision.config.subtitles;
|
|
125
132
|
if (config === undefined || config.mode === "off")
|
|
126
|
-
throw new Error("
|
|
133
|
+
throw new Error("Slopify hit an internal error (captions are off for this version of the project). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
127
134
|
const row = view.pieces.find((one) => one.key === "subtitles:cues" && one.selected && one.available && one.piece.state === "done");
|
|
128
135
|
if (row?.piece.payload === null || row?.piece.payload === undefined)
|
|
129
|
-
throw new Error("The
|
|
136
|
+
throw new Error("The captions haven't been prepared yet. Use Re-run section on Video, then Retry stage.");
|
|
130
137
|
const value = cuesSchema.parse(JSON.parse(row.piece.payload));
|
|
131
138
|
const previous = view.outputs.find((one) => one.output.role === "subtitle_font" && one.selected && one.available);
|
|
132
139
|
const previousFiles = view.pieces.find((one) => one.key === "subtitles:files" &&
|
|
@@ -200,3 +207,62 @@ async function files(deps, context, piece, snapshot) {
|
|
|
200
207
|
discardPreparedAssets(deps, prepared.map((one) => one.asset));
|
|
201
208
|
}
|
|
202
209
|
}
|
|
210
|
+
// A mismatch names the place to fix: the time in the narration, the chunk that holds it (as
|
|
211
|
+
// the Narration editor numbers them) and what was expected against what was heard. The
|
|
212
|
+
// usual cause is a TTS request that skipped or reworded a sentence.
|
|
213
|
+
async function located(snapshot, segment, align) {
|
|
214
|
+
try {
|
|
215
|
+
return await align();
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (!(error instanceof SubtitleMismatch))
|
|
219
|
+
throw error;
|
|
220
|
+
throw new Error(mismatchMessage(snapshot, segment, error), { cause: error });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
export function mismatchMessage(snapshot, segment, mismatch) {
|
|
224
|
+
let chunks = [];
|
|
225
|
+
try {
|
|
226
|
+
chunks = narrationChunks(narrationTextParts(snapshot.view, snapshot.plan, segment));
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Without a readable plan the message still gives the time and the words.
|
|
230
|
+
}
|
|
231
|
+
return describeMismatch(chunks, segment, mismatch);
|
|
232
|
+
}
|
|
233
|
+
export function describeMismatch(chunks, segment, mismatch) {
|
|
234
|
+
const probe = comparable(mismatch.expected).split(" ").slice(0, 6).join(" ");
|
|
235
|
+
const at = probe === ""
|
|
236
|
+
? chunks.length - 1
|
|
237
|
+
: chunks.findIndex((chunk) => comparable(chunk.spokenText).includes(probe));
|
|
238
|
+
const chunk = at === -1 ? undefined : chunks[at];
|
|
239
|
+
const where = chunk === undefined
|
|
240
|
+
? `${clock(mismatch.at)} into the ${segment} narration`
|
|
241
|
+
: `${clock(mismatch.at)} into the ${segment} narration, in narration chunk ${String(at + 1)} of ${String(chunks.length)} (it starts "${opening(chunk.spokenText)}")`;
|
|
242
|
+
const expected = mismatch.expected === "" ? "the end of the text" : `"${mismatch.expected}…"`;
|
|
243
|
+
const heard = mismatch.heard === "" ? "no more speech" : `"${mismatch.heard.toLowerCase()}…"`;
|
|
244
|
+
const fix = chunk === undefined
|
|
245
|
+
? "Check that part of the narration, regenerate it in Edit project → Narration, then Resume."
|
|
246
|
+
: `In Edit project → Narration, regenerate narration chunk ${String(at + 1)}, then Resume.`;
|
|
247
|
+
return `Subtitles stopped matching the audio at ${where}. The text expected ${expected} but the audio has ${heard} The recording there probably skips or changes words. ${fix}`;
|
|
248
|
+
}
|
|
249
|
+
function comparable(text) {
|
|
250
|
+
return text
|
|
251
|
+
.toUpperCase()
|
|
252
|
+
.replace(/&/g, " AND ")
|
|
253
|
+
.replace(/[^A-Z0-9' ]+/g, " ")
|
|
254
|
+
.replace(/\s+/g, " ")
|
|
255
|
+
.trim();
|
|
256
|
+
}
|
|
257
|
+
function opening(text) {
|
|
258
|
+
return text.trim().split(/\s+/).slice(0, 6).join(" ");
|
|
259
|
+
}
|
|
260
|
+
function clock(seconds) {
|
|
261
|
+
const whole = Math.floor(seconds);
|
|
262
|
+
const hours = Math.floor(whole / 3600);
|
|
263
|
+
const minutes = Math.floor((whole % 3600) / 60);
|
|
264
|
+
const rest = String(whole % 60).padStart(2, "0");
|
|
265
|
+
return hours > 0
|
|
266
|
+
? `${String(hours)}:${String(minutes).padStart(2, "0")}:${rest}`
|
|
267
|
+
: `${String(minutes)}:${rest}`;
|
|
268
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { outputPath } from "../storage/layout.js";
|
|
3
|
+
import { assembleDescription, checkDescriptionAnswer, descriptionMessages, tagsText, } from "../youtube/answer.js";
|
|
4
|
+
import { defaultDescriptionPrompt } from "../youtube/model.js";
|
|
5
|
+
import { transcriptPassages, transcriptText } from "../youtube/transcript.js";
|
|
6
|
+
import { exportSnapshot, revisionAudio } from "./runtime-export-inputs.js";
|
|
7
|
+
import { savedCatalogue } from "./runtime-plan.js";
|
|
8
|
+
import { preparedTexts, publishResult } from "./runtime-publication.js";
|
|
9
|
+
import { wordsSchema } from "./runtime-subtitles.js";
|
|
10
|
+
// Writes the YouTube description and tags from the saved word timing: the transcript's
|
|
11
|
+
// passages carry their start in the final video, so the chapters the model picks are real
|
|
12
|
+
// times. An answer that breaks YouTube's chapter or tag rules is a failed attempt, which the
|
|
13
|
+
// provider wrapper asks again while attempts remain.
|
|
14
|
+
export async function executeYoutubeRecipe(deps, context, providers, piece) {
|
|
15
|
+
if (deps.db
|
|
16
|
+
.prepare("SELECT state FROM revision_work_pieces WHERE id=? AND work_id=?")
|
|
17
|
+
.get(piece.id, context.work.workId)?.state === "done")
|
|
18
|
+
return "done";
|
|
19
|
+
if (!context.maySubmit(piece.id))
|
|
20
|
+
return "held";
|
|
21
|
+
context.signal.throwIfAborted();
|
|
22
|
+
const input = piece.input;
|
|
23
|
+
if (input.kind !== "local" || input.operation !== "youtube-description-v1")
|
|
24
|
+
throw new Error("Slopify hit an internal error (the YouTube description step was set up wrongly). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
25
|
+
const { view } = exportSnapshot(deps, context, piece);
|
|
26
|
+
const config = view.revision.config;
|
|
27
|
+
const llm = config.llm;
|
|
28
|
+
if (llm === undefined || llm.provider.trim() === "" || llm.model.trim() === "")
|
|
29
|
+
throw new Error("The YouTube description needs an AI text model, and none is chosen. Choose one in Edit project → Providers, then Retry stage.");
|
|
30
|
+
const timing = view.outputs.find((row) => row.selected &&
|
|
31
|
+
row.available &&
|
|
32
|
+
row.state === "ready" &&
|
|
33
|
+
row.workKey === "subtitles:timing" &&
|
|
34
|
+
row.output.role === "subtitle_words");
|
|
35
|
+
if (timing === undefined)
|
|
36
|
+
throw new Error("The narration's word timing, which the YouTube description's chapters come from, is missing. Use Re-run section on Video, then Retry stage.");
|
|
37
|
+
const { words } = wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, context.work.projectId, timing.output.path), "utf8")));
|
|
38
|
+
const passages = transcriptPassages(words);
|
|
39
|
+
if (passages.length === 0)
|
|
40
|
+
throw new Error("The narration's word timing holds no words, so there is nothing to write the YouTube description from. Use Re-run section on Video, then Retry stage.");
|
|
41
|
+
// The same timeline the timing walked: silence at the start, intro, gaps, body, outro and
|
|
42
|
+
// silence at the end.
|
|
43
|
+
const audio = await revisionAudio(deps, context, view);
|
|
44
|
+
const durationSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
45
|
+
const saved = Array.isArray(input.values) ? input.values[4] : undefined;
|
|
46
|
+
const instruction = typeof saved === "string" && saved.trim() !== "" ? saved : defaultDescriptionPrompt;
|
|
47
|
+
const messages = descriptionMessages({
|
|
48
|
+
instruction,
|
|
49
|
+
title: config.title,
|
|
50
|
+
durationSeconds,
|
|
51
|
+
transcript: transcriptText(passages),
|
|
52
|
+
});
|
|
53
|
+
const row = deps.db
|
|
54
|
+
.prepare("SELECT recipe_context FROM revision_work WHERE id=?")
|
|
55
|
+
.get(context.work.workId);
|
|
56
|
+
const model = savedCatalogue(row?.recipe_context).llm.find((one) => one.provider === llm.provider && one.id === llm.model);
|
|
57
|
+
if (!context.maySubmit(piece.id))
|
|
58
|
+
return "held";
|
|
59
|
+
const answer = await providers.forPiece(piece.id).llm({
|
|
60
|
+
provider: llm.provider,
|
|
61
|
+
model: llm.model,
|
|
62
|
+
...(llm.thinking === undefined ? {} : { thinking: llm.thinking }),
|
|
63
|
+
thinkingConfig: llm.thinking === undefined ? null : (model?.llm.thinking?.[llm.thinking] ?? null),
|
|
64
|
+
messages,
|
|
65
|
+
webSearch: false,
|
|
66
|
+
previewLabel: piece.key,
|
|
67
|
+
check: (value) => {
|
|
68
|
+
const checked = checkDescriptionAnswer(value.text, durationSeconds);
|
|
69
|
+
return checked.ok ? undefined : checked.reason;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
if (!answer.ok)
|
|
73
|
+
return "held";
|
|
74
|
+
const checked = checkDescriptionAnswer(answer.value.text, durationSeconds);
|
|
75
|
+
if (!checked.ok)
|
|
76
|
+
throw new Error(checked.reason);
|
|
77
|
+
const description = assembleDescription(checked.value);
|
|
78
|
+
const tags = tagsText(checked.value.tags);
|
|
79
|
+
await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
|
|
80
|
+
["youtube_description", "description.txt", description],
|
|
81
|
+
["youtube_tags", "tags.txt", tags],
|
|
82
|
+
]), { description, tags: checked.value.tags, chapters: checked.value.chapters, durationSeconds });
|
|
83
|
+
return "done";
|
|
84
|
+
}
|
|
@@ -15,14 +15,14 @@ export function transitionRevisionWork(deps, input) {
|
|
|
15
15
|
transact(db, () => {
|
|
16
16
|
const current = currentRevisionId(db, input.projectId);
|
|
17
17
|
if (current !== input.baseRevisionId && current !== input.revisionId)
|
|
18
|
-
throw new Error("
|
|
18
|
+
throw new Error("The project was changed while this was being saved. Try again.");
|
|
19
19
|
const base = revisionById(db, input.projectId, input.baseRevisionId);
|
|
20
20
|
const next = revisionById(db, input.projectId, input.revisionId);
|
|
21
21
|
if (base === undefined || next === undefined)
|
|
22
|
-
throw new Error("
|
|
22
|
+
throw new Error("Slopify hit an internal error (a project version to switch between is missing). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
23
23
|
if (JSON.stringify(Object.entries(input.fingerprints).sort()) !==
|
|
24
24
|
JSON.stringify(Object.entries(next.fingerprints).sort()))
|
|
25
|
-
throw new Error("
|
|
25
|
+
throw new Error("Slopify hit an internal error (the planned steps don't match the saved project version). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
26
26
|
const reserved = db
|
|
27
27
|
.prepare(`SELECT r.* FROM revision_work_reservations r JOIN revision_work w ON w.id=r.work_id WHERE r.project_id=? AND r.revision_id=? AND w.state IN ('pending','running','done')`)
|
|
28
28
|
.all(input.projectId, input.baseRevisionId)
|
|
@@ -57,13 +57,13 @@ export function transitionRevisionWork(deps, input) {
|
|
|
57
57
|
continue;
|
|
58
58
|
const exact = input.recipes?.find((row) => row.key === key);
|
|
59
59
|
if (exact !== undefined && exact.fingerprint !== fp)
|
|
60
|
-
throw new Error("
|
|
60
|
+
throw new Error("Slopify hit an internal error (a planned step doesn't match the saved project version). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
61
61
|
const kind = exact?.stage ?? stageForKey(key);
|
|
62
62
|
const stage = db
|
|
63
63
|
.prepare("SELECT id FROM stages WHERE project_id=? AND kind=?")
|
|
64
64
|
.get(input.projectId, kind);
|
|
65
65
|
if (stage === undefined)
|
|
66
|
-
throw new Error("
|
|
66
|
+
throw new Error("Slopify hit an internal error (a planned step has no matching stage). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
67
67
|
const workId = deps.ids.next();
|
|
68
68
|
const pieceId = deps.ids.next();
|
|
69
69
|
db.prepare(`INSERT INTO revision_work(id,project_id,revision_id,stage_id,kind,fingerprint,state,dispatch_state,created_at) VALUES (?,?,?,?,?,?,'pending','held',?)`).run(workId, input.projectId, input.revisionId, z.string().parse(stage.id), kind, fp, deps.clock.now().toISOString());
|
|
@@ -104,5 +104,7 @@ function stageForKey(key) {
|
|
|
104
104
|
return "thumbnail";
|
|
105
105
|
if (prefix === "export" || prefix === "subtitles" || prefix === "video")
|
|
106
106
|
return "video";
|
|
107
|
-
|
|
107
|
+
if (prefix === "document")
|
|
108
|
+
return "document";
|
|
109
|
+
throw new Error("Slopify hit an internal error (unknown kind of step). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
108
110
|
}
|
|
@@ -14,9 +14,9 @@ function staleStages(input) {
|
|
|
14
14
|
return withDependents([action.stage], input);
|
|
15
15
|
case "article-edit": {
|
|
16
16
|
// An article edit re-runs audio, LLM-mode intro/outro text, the LLM-written
|
|
17
|
-
// thumbnail and the video, leaving prompt-based images untouched. The
|
|
18
|
-
// along as a dependent of the audio, so it is never named here.
|
|
19
|
-
const roots = ["audio"];
|
|
17
|
+
// thumbnail, the document and the video, leaving prompt-based images untouched. The
|
|
18
|
+
// video comes along as a dependent of the audio, so it is never named here.
|
|
19
|
+
const roots = ["audio", "document"];
|
|
20
20
|
if (input.thumbnailSource === "prompt_by_llm") {
|
|
21
21
|
roots.push("thumbnail");
|
|
22
22
|
}
|
|
@@ -64,6 +64,8 @@ function withDependents(roots, input) {
|
|
|
64
64
|
}
|
|
65
65
|
return found;
|
|
66
66
|
}
|
|
67
|
+
// A stage with no row is not part of the run (a project from before the Document stage
|
|
68
|
+
// may lack one), so it is stepped over like a skipped one.
|
|
67
69
|
function stateOf(stages, kind) {
|
|
68
|
-
return stages.find((stage) => stage.kind === kind)?.state ?? "
|
|
70
|
+
return stages.find((stage) => stage.kind === kind)?.state ?? "skipped";
|
|
69
71
|
}
|
|
@@ -204,7 +204,7 @@ function clearStage(deps, stage, outputs) {
|
|
|
204
204
|
// runs the returned cleanup only after commit. Finished narration is left intact.
|
|
205
205
|
export function clearUnfinishedAudio(deps, projectId) {
|
|
206
206
|
if (currentRevisionId(deps.db, projectId) !== undefined)
|
|
207
|
-
throw new Error("
|
|
207
|
+
throw new Error("Slopify hit an internal error (narration was changed the old way on a project that uses versions). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
208
208
|
const stage = stagesOf(deps.db, projectId).find((one) => one.kind === "audio");
|
|
209
209
|
if (stage === undefined ||
|
|
210
210
|
stage.state === "done" ||
|
|
@@ -10,7 +10,7 @@ import { chaptersFrom, plannerMessages, subAgentMessages } from "./planner.js";
|
|
|
10
10
|
import { sourcedAnswer, synthesisMessages } from "./synthesis.js";
|
|
11
11
|
// A model that cannot ground on the web fails the stage immediately, with no fallback to
|
|
12
12
|
// what the model already knows.
|
|
13
|
-
export const webResearchUnsupported = "web research
|
|
13
|
+
export const webResearchUnsupported = "The chosen AI model can't search the web, which research needs. Choose a model with web search in Edit project → Providers, or turn research off, then Retry stage.";
|
|
14
14
|
// What a chapter piece carries between runs: its title, and its notes once a sub-agent
|
|
15
15
|
// has answered. That is the whole of the resume.
|
|
16
16
|
const chapterPayload = z.object({ title: z.string(), notes: z.string().optional() });
|
|
@@ -18,14 +18,14 @@ export async function runResearch(deps, context, providers) {
|
|
|
18
18
|
const { projectId } = context.stage;
|
|
19
19
|
const project = projectById(deps.db, projectId);
|
|
20
20
|
if (project === undefined) {
|
|
21
|
-
throw new Error(
|
|
21
|
+
throw new Error("This project no longer exists; it may have been deleted while it was running.");
|
|
22
22
|
}
|
|
23
23
|
const choice = project.config.llm;
|
|
24
24
|
const articlePrompt = project.config.rendered.article;
|
|
25
25
|
if (choice === undefined || articlePrompt === undefined) {
|
|
26
26
|
// Admission refuses a run whose research is Generate without both, so
|
|
27
27
|
// reaching here is a bug in admission rather than something the user did.
|
|
28
|
-
throw new Error("
|
|
28
|
+
throw new Error("No AI model or article prompt is set for research. Choose a model in Edit project → Providers and check Edit project → Prompts, then Retry stage.");
|
|
29
29
|
}
|
|
30
30
|
const brief = { articlePrompt, values: project.config.values };
|
|
31
31
|
try {
|
|
@@ -62,7 +62,7 @@ async function research(deps, context, providers, choice, brief) {
|
|
|
62
62
|
messages: synthesisMessages(brief, findings),
|
|
63
63
|
documents: researchDocuments(findings),
|
|
64
64
|
previewLabel: "Writing research notes",
|
|
65
|
-
check: (given) => sourcedAnswer("the
|
|
65
|
+
check: (given) => sourcedAnswer("the research summary", given.text),
|
|
66
66
|
});
|
|
67
67
|
if (!answer.ok)
|
|
68
68
|
return "held";
|
|
@@ -110,7 +110,9 @@ async function plan(deps, context, providers, choice, brief, add) {
|
|
|
110
110
|
messages: plannerMessages(brief),
|
|
111
111
|
previewLabel: "Planning research",
|
|
112
112
|
// An empty answer, or one with no chapter in it, is a failed attempt.
|
|
113
|
-
check: (given) => chaptersFrom(given.text).length === 0
|
|
113
|
+
check: (given) => chaptersFrom(given.text).length === 0
|
|
114
|
+
? "The AI model didn't return any research topics. Retry stage; if it keeps happening, choose a different model in Edit project → Providers."
|
|
115
|
+
: undefined,
|
|
114
116
|
});
|
|
115
117
|
if (!answer.ok)
|
|
116
118
|
return answer;
|
|
@@ -156,7 +158,7 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
|
|
|
156
158
|
// Grounding is asked for explicitly, so a model without it says so
|
|
157
159
|
// instead of answering from what it already knows.
|
|
158
160
|
webSearch: true,
|
|
159
|
-
check: (given) => sourcedAnswer(`the
|
|
161
|
+
check: (given) => sourcedAnswer(`the research topic "${kept.title}"`, given.text),
|
|
160
162
|
});
|
|
161
163
|
if (!answer.ok) {
|
|
162
164
|
setPiece(deps.db, piece.id, "pending", piece.payload);
|
|
@@ -215,7 +217,7 @@ function section(label, messages) {
|
|
|
215
217
|
}
|
|
216
218
|
function payloadOf(piece) {
|
|
217
219
|
if (piece.payload === null) {
|
|
218
|
-
throw new Error(
|
|
220
|
+
throw new Error("Slopify hit an internal error (a saved research topic is empty). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
219
221
|
}
|
|
220
222
|
return chapterPayload.parse(JSON.parse(piece.payload));
|
|
221
223
|
}
|
|
@@ -41,7 +41,9 @@ export function endsWithSources(text) {
|
|
|
41
41
|
// but cannot be used is a failed attempt, so the wrapper retries it.
|
|
42
42
|
export function sourcedAnswer(who, text) {
|
|
43
43
|
if (text.trim() === "") {
|
|
44
|
-
return
|
|
44
|
+
return `The AI model returned an empty answer for ${who}. Retry stage; if it keeps happening, choose a different model in Edit project → Providers.`;
|
|
45
45
|
}
|
|
46
|
-
return endsWithSources(text)
|
|
46
|
+
return endsWithSources(text)
|
|
47
|
+
? undefined
|
|
48
|
+
: `The AI model's answer for ${who} had no sources. Research needs web sources: Retry stage, or choose a model with web search in Edit project → Providers.`;
|
|
47
49
|
}
|
|
@@ -34,11 +34,14 @@ export function validateUploads(deps, edit) {
|
|
|
34
34
|
(to.kind === "image" && edit.config.sources.images === "off"))
|
|
35
35
|
fields.push({
|
|
36
36
|
field,
|
|
37
|
-
message: "
|
|
37
|
+
message: "This section is off or not set to use your own file. Change the section's setting first, then add the file.",
|
|
38
38
|
});
|
|
39
39
|
const destination = JSON.stringify(to);
|
|
40
40
|
if (staged.has(upload.stagedFileId) || destinations.has(destination))
|
|
41
|
-
fields.push({
|
|
41
|
+
fields.push({
|
|
42
|
+
field,
|
|
43
|
+
message: "The same file or place is used twice. Remove the duplicate.",
|
|
44
|
+
});
|
|
42
45
|
staged.add(upload.stagedFileId);
|
|
43
46
|
destinations.add(destination);
|
|
44
47
|
const file = stagedFileById(deps.db, upload.stagedFileId);
|
|
@@ -47,11 +50,20 @@ export function validateUploads(deps, edit) {
|
|
|
47
50
|
file.state !== "staged" ||
|
|
48
51
|
file.stageKind !== kind ||
|
|
49
52
|
!statSync(stagingPath(deps.paths, file.path), { throwIfNoEntry: false })?.isFile())
|
|
50
|
-
fields.push({
|
|
53
|
+
fields.push({
|
|
54
|
+
field,
|
|
55
|
+
message: "This upload is missing or has not finished. Upload the file again.",
|
|
56
|
+
});
|
|
51
57
|
if (to.kind === "image" && edit.content.imageDefinitions[to.imageKey] === undefined)
|
|
52
|
-
fields.push({
|
|
58
|
+
fields.push({
|
|
59
|
+
field,
|
|
60
|
+
message: "This image is no longer in the project. Reload the page and choose again.",
|
|
61
|
+
});
|
|
53
62
|
if (to.kind === "narration" && !to.key.startsWith("audio:"))
|
|
54
|
-
fields.push({
|
|
63
|
+
fields.push({
|
|
64
|
+
field,
|
|
65
|
+
message: "Choose which part of the narration this audio file replaces.",
|
|
66
|
+
});
|
|
55
67
|
}
|
|
56
68
|
return fields;
|
|
57
69
|
}
|
|
@@ -81,7 +93,10 @@ export function validateAssetReferences(deps, projectId, content, prepared) {
|
|
|
81
93
|
deps.db
|
|
82
94
|
.prepare("SELECT 1 FROM project_assets WHERE project_id=? AND id=?")
|
|
83
95
|
.get(projectId, id) === undefined)
|
|
84
|
-
fields.push({
|
|
96
|
+
fields.push({
|
|
97
|
+
field,
|
|
98
|
+
message: "This file does not belong to this project. Reload the page and choose again.",
|
|
99
|
+
});
|
|
85
100
|
if (!prepared.some((row) => row.id === id) &&
|
|
86
101
|
deps.db
|
|
87
102
|
.prepare("SELECT 1 FROM revision_outputs WHERE project_id=? AND asset_id=? AND json_extract(descriptor,'$.stageKind')=? AND json_extract(descriptor,'$.role') IN (SELECT value FROM json_each(?))")
|
|
@@ -95,7 +110,10 @@ export function validateAssetReferences(deps, projectId, content, prepared) {
|
|
|
95
110
|
deps.db
|
|
96
111
|
.prepare("SELECT 1 FROM project_revisions r, json_each(r.content, '$.narrationOverrides') o WHERE r.project_id=? AND json_extract(o.value,'$.kind')='asset' AND json_extract(o.value,'$.assetId')=? LIMIT 1")
|
|
97
112
|
.get(projectId, id) !== undefined))
|
|
98
|
-
fields.push({
|
|
113
|
+
fields.push({
|
|
114
|
+
field,
|
|
115
|
+
message: "This file cannot be used in this section. Choose a file of the right kind.",
|
|
116
|
+
});
|
|
99
117
|
}
|
|
100
118
|
return fields;
|
|
101
119
|
}
|
|
@@ -144,5 +162,5 @@ export function validateReplacementAvailability(deps, base, edit) {
|
|
|
144
162
|
previous.has(id) ||
|
|
145
163
|
statSync(outputPath(deps.paths, base.revision.projectId, assetPath(deps, base.revision.projectId, id)), { throwIfNoEntry: false })?.isFile()
|
|
146
164
|
? []
|
|
147
|
-
: [{ field: "content", message: "The replacement
|
|
165
|
+
: [{ field: "content", message: "The replacement file is missing. Upload it again." }]);
|
|
148
166
|
}
|
|
@@ -34,7 +34,7 @@ export function validateProposedCues(base, edit, plan, prepared, durations) {
|
|
|
34
34
|
const fail = () => [
|
|
35
35
|
{
|
|
36
36
|
field: "content.subtitleCues",
|
|
37
|
-
message: "
|
|
37
|
+
message: "The narration changed or is not finished, so these captions no longer line up. Reload the page and edit the captions again once narration is complete.",
|
|
38
38
|
},
|
|
39
39
|
];
|
|
40
40
|
const candidates = [
|
|
@@ -82,7 +82,9 @@ export function validateProposedCues(base, edit, plan, prepared, durations) {
|
|
|
82
82
|
if (parts.some((one) => one === undefined))
|
|
83
83
|
return fail();
|
|
84
84
|
const seconds = parts.reduce((sum, one) => sum + (one ?? 0), 0) / 1000 +
|
|
85
|
-
Math.max(0, keys.length - 1) * plan.config.silenceGapSeconds
|
|
85
|
+
Math.max(0, keys.length - 1) * plan.config.silenceGapSeconds +
|
|
86
|
+
// The lead-in and the tail are part of the timeline the cues are timed against.
|
|
87
|
+
2 * plan.config.edgeSilenceSeconds;
|
|
86
88
|
return validateCues(cues.cues, seconds);
|
|
87
89
|
}
|
|
88
90
|
export function measuredOutputs(deps, base, durations) {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
3
3
|
import { insertPiece } from "../../kernel/runner/piece-repo.js";
|
|
4
|
+
import { sourceOf } from "../admission/model.js";
|
|
4
5
|
import { recipeInputSchema } from "../rebuild/recipe-input-schema.js";
|
|
5
6
|
import { insertOutput } from "../storage/repo.js";
|
|
6
7
|
import { insertManifestOutput, insertManifestPiece, outputsForRevision, piecesForRevision, selectOutputRecord, selectPieceRecord, } from "./repo.js";
|
|
7
8
|
export function ensureRevisionStages(deps, revision) {
|
|
8
9
|
for (const kind of stageKinds) {
|
|
9
|
-
const source = revision.config.sources
|
|
10
|
+
const source = sourceOf(revision.config.sources, kind);
|
|
10
11
|
deps.db
|
|
11
12
|
.prepare("INSERT INTO stages(id,project_id,kind,source,state) VALUES (?,?,?,?,?) ON CONFLICT(project_id,kind) DO UPDATE SET source=excluded.source")
|
|
12
13
|
.run(deps.ids.next(), revision.projectId, kind, source, source === "off" ? "skipped" : "pending");
|
|
@@ -51,16 +51,25 @@ export function validateCues(cues, duration) {
|
|
|
51
51
|
for (const [index, cue] of cues.entries()) {
|
|
52
52
|
const field = `content.subtitleCues.cues.${index}`;
|
|
53
53
|
if (seen.has(cue.id))
|
|
54
|
-
fields.push({
|
|
54
|
+
fields.push({
|
|
55
|
+
field: `${field}.id`,
|
|
56
|
+
message: "Two captions have the same ID. Reload the page and edit the captions again.",
|
|
57
|
+
});
|
|
55
58
|
seen.add(cue.id);
|
|
56
59
|
if (cue.text.trim() === "")
|
|
57
|
-
fields.push({
|
|
60
|
+
fields.push({
|
|
61
|
+
field: `${field}.text`,
|
|
62
|
+
message: "Enter the caption text, or delete this caption.",
|
|
63
|
+
});
|
|
58
64
|
if (!Number.isFinite(cue.start) || cue.start < end)
|
|
59
|
-
fields.push({
|
|
65
|
+
fields.push({
|
|
66
|
+
field: `${field}.start`,
|
|
67
|
+
message: "This caption must start after the previous one ends.",
|
|
68
|
+
});
|
|
60
69
|
if (!Number.isFinite(cue.end) || cue.end <= cue.start || cue.end > duration)
|
|
61
70
|
fields.push({
|
|
62
71
|
field: `${field}.end`,
|
|
63
|
-
message: "
|
|
72
|
+
message: "This caption must end after it starts and before the narration ends.",
|
|
64
73
|
});
|
|
65
74
|
end = cue.end;
|
|
66
75
|
}
|
|
@@ -75,7 +84,7 @@ export function validateTemplateIntent(base, edit) {
|
|
|
75
84
|
base.revision.config.rendered[key] === edit.config.rendered[key])
|
|
76
85
|
fields.push({
|
|
77
86
|
field: `content.promptTemplates.${key}`,
|
|
78
|
-
message: "
|
|
87
|
+
message: "This prompt was edited by hand, so new keywords will not change it. Switch it back to its template, or choose to keep your edited prompt, first.",
|
|
79
88
|
});
|
|
80
89
|
}
|
|
81
90
|
for (const [key, image] of Object.entries(base.revision.content.imageDefinitions)) {
|
|
@@ -87,7 +96,7 @@ export function validateTemplateIntent(base, edit) {
|
|
|
87
96
|
next.prompt === image.prompt)
|
|
88
97
|
fields.push({
|
|
89
98
|
field: `content.imageDefinitions.${key}.templateKey`,
|
|
90
|
-
message: "
|
|
99
|
+
message: "This image prompt was edited by hand, so new keywords will not change it. Switch it back to its template, or choose to keep your edited prompt, first.",
|
|
91
100
|
});
|
|
92
101
|
}
|
|
93
102
|
}
|
|
@@ -99,7 +108,7 @@ export function validateTemplateIntent(base, edit) {
|
|
|
99
108
|
isDeepStrictEqual(base.revision.content.imageDefinitions[key], image)))
|
|
100
109
|
fields.push({
|
|
101
110
|
field: `content.imageDefinitions.${key}.templateKey`,
|
|
102
|
-
message: "The
|
|
111
|
+
message: "The prompt template this image uses is missing. Choose another image prompt.",
|
|
103
112
|
});
|
|
104
113
|
return fields;
|
|
105
114
|
}
|
|
@@ -132,13 +141,13 @@ export function validateNarrationIntent(base, edit) {
|
|
|
132
141
|
if (!isDeepStrictEqual(value, base.revision.content.narrationOverrides[key]) && !keys.has(key))
|
|
133
142
|
fields.push({
|
|
134
143
|
field: `content.narrationOverrides.${key}`,
|
|
135
|
-
message: "
|
|
144
|
+
message: "This narration part no longer exists. Reload the page and choose again.",
|
|
136
145
|
});
|
|
137
146
|
for (const [index, upload] of (edit.uploads ?? []).entries())
|
|
138
147
|
if (upload.destination.kind === "narration" && !keys.has(upload.destination.key))
|
|
139
148
|
fields.push({
|
|
140
149
|
field: `uploads.${index}.destination.key`,
|
|
141
|
-
message: "
|
|
150
|
+
message: "This narration part no longer exists. Reload the page and choose again.",
|
|
142
151
|
});
|
|
143
152
|
return fields;
|
|
144
153
|
}
|