@gentbajko/slopify 0.8.3 → 1.0.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/README.md +28 -6
- package/SUBTITLES.md +2 -0
- package/dist/adapters/alignment/window.js +46 -5
- package/dist/adapters/alignment/worker.js +2 -2
- package/dist/adapters/llm/claude-code.js +6 -3
- package/dist/adapters/llm/codex.js +102 -12
- package/dist/adapters/llm/gemini.js +11 -8
- package/dist/adapters/llm/run-cli.js +61 -6
- package/dist/adapters/tts/inworld-async.js +4 -4
- package/dist/adapters/tts/inworld.js +73 -68
- package/dist/catalog/registry.js +13 -73
- package/dist/edge/cli.js +5 -2
- package/dist/edge/events/hub.js +7 -2
- package/dist/edge/events/preview-cache.js +7 -4
- package/dist/edge/events/visibility.js +33 -0
- package/dist/edge/http/actions.js +74 -79
- package/dist/edge/http/app.js +39 -6
- package/dist/edge/http/audio-preview.js +9 -1
- package/dist/edge/http/checkpoints.js +95 -0
- package/dist/edge/http/diagnostics.js +31 -0
- package/dist/edge/http/draft-files.js +69 -0
- package/dist/edge/http/draft-problem.js +15 -0
- package/dist/edge/http/drafts.js +95 -0
- package/dist/edge/http/multipart.js +86 -0
- package/dist/edge/http/mutations.js +49 -0
- package/dist/edge/http/planning.js +12 -1
- package/dist/edge/http/project-templates.js +74 -0
- package/dist/edge/http/projects.js +19 -2
- package/dist/edge/http/revision-files.js +84 -0
- package/dist/edge/http/revisions.js +103 -0
- package/dist/edge/http/schedules.js +100 -0
- package/dist/edge/http/staging.js +11 -52
- package/dist/edge/http/storage.js +110 -0
- package/dist/edge/http/subtitles.js +9 -79
- package/dist/edge/http/tutorial.js +26 -0
- package/dist/edge/signal-shutdown.js +23 -0
- package/dist/kernel/audio-preview.js +11 -5
- package/dist/kernel/db/migrations/0004-project-revisions.sql +91 -0
- package/dist/kernel/db/migrations/0005-revision-work.sql +103 -0
- package/dist/kernel/db/migrations/0006-play-drafts.sql +38 -0
- package/dist/kernel/db/migrations/0007-review-checkpoints.sql +28 -0
- package/dist/kernel/db/migrations/0008-project-templates.sql +21 -0
- package/dist/kernel/db/migrations/0009-scheduled-jobs.sql +37 -0
- package/dist/kernel/db/migrations/0010-retain-schedule-history.sql +62 -0
- package/dist/kernel/ports/model.js +5 -0
- package/dist/kernel/runner/attempt-repo.js +15 -0
- package/dist/kernel/runner/attempt.js +14 -2
- package/dist/kernel/runner/checkpoint-authority.js +23 -0
- package/dist/kernel/runner/index.js +35 -11
- package/dist/kernel/runner/providers.js +18 -8
- package/dist/kernel/runner/work-attempt.js +47 -0
- package/dist/kernel/runner/work-authority.js +37 -0
- package/dist/kernel/runner/work.js +23 -0
- package/dist/main.js +227 -69
- package/dist/slices/admission/repo.js +5 -56
- package/dist/slices/admission/schema.js +64 -0
- package/dist/slices/admission/start.js +13 -4
- package/dist/slices/article/continuation.js +9 -3
- package/dist/slices/article/provided-entries.js +7 -3
- package/dist/slices/article/run.js +20 -7
- package/dist/slices/article/segments.js +13 -4
- package/dist/slices/batch/index.js +8 -11
- package/dist/slices/cancel/index.js +78 -34
- package/dist/slices/checkpoints/change.js +250 -0
- package/dist/slices/checkpoints/fingerprint.js +33 -0
- package/dist/slices/checkpoints/index.js +2 -0
- package/dist/slices/checkpoints/model.js +1 -0
- package/dist/slices/checkpoints/recovery.js +116 -0
- package/dist/slices/checkpoints/repo.js +132 -0
- package/dist/slices/checkpoints/rules.js +30 -0
- package/dist/slices/checkpoints/schema.js +59 -0
- package/dist/slices/control/index.js +88 -22
- package/dist/slices/control/providers.js +3 -2
- package/dist/slices/control/revision-control-schema.js +7 -0
- package/dist/slices/control/revision-control.js +59 -0
- package/dist/slices/estimate/index.js +77 -59
- package/dist/slices/estimate/requests.js +102 -0
- package/dist/slices/images/run.js +14 -3
- package/dist/slices/library/slots.js +11 -11
- package/dist/slices/library/snapshot.js +33 -0
- package/dist/slices/narration/legacy-plan.js +58 -0
- package/dist/slices/narration/live.js +2 -2
- package/dist/slices/narration/plan.js +48 -54
- package/dist/slices/narration/run.js +21 -8
- package/dist/slices/play-drafts/convert.js +120 -0
- package/dist/slices/play-drafts/model.js +1 -0
- package/dist/slices/play-drafts/readiness.js +80 -0
- package/dist/slices/play-drafts/repo.js +76 -0
- package/dist/slices/play-drafts/review-inputs.js +150 -0
- package/dist/slices/play-drafts/review.js +109 -0
- package/dist/slices/play-drafts/schema.js +218 -0
- package/dist/slices/play-drafts/service.js +242 -0
- package/dist/slices/play-drafts/start-repo.js +113 -0
- package/dist/slices/play-drafts/start.js +148 -0
- package/dist/slices/play-drafts/uploads.js +67 -0
- package/dist/slices/project-templates/from-project.js +159 -0
- package/dist/slices/project-templates/model.js +1 -0
- package/dist/slices/project-templates/repo.js +41 -0
- package/dist/slices/project-templates/schema.js +37 -0
- package/dist/slices/project-templates/service.js +136 -0
- package/dist/slices/project-templates/setup.js +55 -0
- package/dist/slices/rebuild/admission-repo.js +120 -0
- package/dist/slices/rebuild/dependencies.js +33 -0
- package/dist/slices/rebuild/model.js +94 -0
- package/dist/slices/rebuild/narration-history.js +43 -0
- package/dist/slices/rebuild/narration-reuse.js +20 -0
- package/dist/slices/rebuild/preview-details.js +144 -0
- package/dist/slices/rebuild/preview-plan.js +223 -0
- package/dist/slices/rebuild/preview-retained.js +128 -0
- package/dist/slices/rebuild/provided-review.js +32 -0
- package/dist/slices/rebuild/recipe-audio.js +172 -0
- package/dist/slices/rebuild/recipe-build.js +17 -0
- package/dist/slices/rebuild/recipe-checkpoints.js +75 -0
- package/dist/slices/rebuild/recipe-exports.js +65 -0
- package/dist/slices/rebuild/recipe-fixture.js +150 -0
- package/dist/slices/rebuild/recipe-input-schema.js +82 -0
- package/dist/slices/rebuild/recipe-legacy.js +135 -0
- package/dist/slices/rebuild/recipe-model.js +77 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +14 -0
- package/dist/slices/rebuild/recipe-save.js +221 -0
- package/dist/slices/rebuild/recipe-text.js +170 -0
- package/dist/slices/rebuild/recipe-validation.js +86 -0
- package/dist/slices/rebuild/recipe-visual.js +111 -0
- package/dist/slices/rebuild/recipe-work.js +212 -0
- package/dist/slices/rebuild/recipes.js +4 -0
- package/dist/slices/rebuild/repo.js +64 -0
- package/dist/slices/rebuild/runtime-actions.js +76 -0
- package/dist/slices/rebuild/runtime-admission.js +71 -0
- package/dist/slices/rebuild/runtime-article.js +117 -0
- package/dist/slices/rebuild/runtime-checkpoints.js +45 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +109 -0
- package/dist/slices/rebuild/runtime-export.js +139 -0
- package/dist/slices/rebuild/runtime-instructions.js +51 -0
- package/dist/slices/rebuild/runtime-local.js +137 -0
- package/dist/slices/rebuild/runtime-materialize.js +105 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +134 -0
- package/dist/slices/rebuild/runtime-plan.js +103 -0
- package/dist/slices/rebuild/runtime-provider.js +184 -0
- package/dist/slices/rebuild/runtime-publication.js +172 -0
- package/dist/slices/rebuild/runtime-run.js +35 -0
- package/dist/slices/rebuild/runtime-store.js +166 -0
- package/dist/slices/rebuild/runtime-subtitles.js +202 -0
- package/dist/slices/rebuild/service-readiness.js +110 -0
- package/dist/slices/rebuild/service.js +149 -0
- package/dist/slices/rebuild/transition-repo.js +108 -0
- package/dist/slices/rebuild/work-records.js +44 -0
- package/dist/slices/reruns/index.js +5 -0
- package/dist/slices/research/run.js +33 -12
- package/dist/slices/revisions/adopt-assets.js +31 -0
- package/dist/slices/revisions/adopt-content.js +112 -0
- package/dist/slices/revisions/adopt.js +100 -0
- package/dist/slices/revisions/downloads.js +80 -0
- package/dist/slices/revisions/index.js +5 -0
- package/dist/slices/revisions/manifest-repo.js +125 -0
- package/dist/slices/revisions/model.js +1 -0
- package/dist/slices/revisions/mutation-assets.js +143 -0
- package/dist/slices/revisions/mutation-cues.js +95 -0
- package/dist/slices/revisions/mutation-prepare.js +182 -0
- package/dist/slices/revisions/mutation-request.js +58 -0
- package/dist/slices/revisions/mutation-work.js +47 -0
- package/dist/slices/revisions/mutations.js +194 -0
- package/dist/slices/revisions/projection.js +82 -0
- package/dist/slices/revisions/publication-model.js +1 -0
- package/dist/slices/revisions/publication-rules.js +166 -0
- package/dist/slices/revisions/publish.js +115 -0
- package/dist/slices/revisions/repo.js +83 -0
- package/dist/slices/revisions/restore.js +60 -0
- package/dist/slices/revisions/rules.js +99 -0
- package/dist/slices/revisions/schema.js +160 -0
- package/dist/slices/revisions/view.js +44 -0
- package/dist/slices/schedules/calendar.js +47 -0
- package/dist/slices/schedules/model.js +1 -0
- package/dist/slices/schedules/repo.js +281 -0
- package/dist/slices/schedules/scheduler.js +144 -0
- package/dist/slices/schedules/schema.js +80 -0
- package/dist/slices/schedules/service.js +178 -0
- package/dist/slices/settings/cli-paths.js +1 -1
- package/dist/slices/settings/cli-status.js +45 -5
- package/dist/slices/settings/tutorial-schema.js +39 -0
- package/dist/slices/settings/tutorial.js +66 -0
- package/dist/slices/storage/assets.js +108 -0
- package/dist/slices/storage/delete-project.js +2 -1
- package/dist/slices/storage/downloads.js +4 -1
- package/dist/slices/storage/portable.js +871 -0
- package/dist/slices/storage/prepare.js +67 -0
- package/dist/slices/storage/reconcile.js +25 -5
- package/dist/slices/storage/repo.js +2 -15
- package/dist/slices/storage/schema.js +35 -0
- package/dist/slices/storage/staging-refs.js +26 -0
- package/dist/slices/storage/staging.js +16 -11
- package/dist/slices/thumbnail/run.js +26 -9
- package/dist/slices/video/audio-export-args.js +33 -0
- package/dist/slices/video/audio-export.js +13 -36
- package/dist/slices/video/audio-inputs.js +4 -3
- package/dist/slices/video/ffmpeg.js +6 -0
- package/dist/slices/video/reuse-audio.js +11 -4
- package/dist/updater/install-flow.js +27 -2
- package/dist/updater/plan.js +28 -2
- package/dist/updater/service.js +27 -2
- package/dist/updater/worker.js +2 -1
- package/dist/web/assets/index-CO7DnVX9.js +137 -0
- package/dist/web/assets/index-DX1_chKs.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/adapters/tts/inworld-text.js +0 -32
- package/dist/web/assets/index-Ce2HApus.css +0 -1
- package/dist/web/assets/index-xbD0MZOY.js +0 -130
|
@@ -10,8 +10,8 @@ import { insertOutput, outputsOf } from "../storage/repo.js";
|
|
|
10
10
|
import { probeDurationMs } from "../video/ffmpeg.js";
|
|
11
11
|
import { chunkNarration, defaultChunking } from "./chunk.js";
|
|
12
12
|
import { joinNarration } from "./concat.js";
|
|
13
|
+
import { planLegacyNarration } from "./legacy-plan.js";
|
|
13
14
|
import { observeNarration } from "./live.js";
|
|
14
|
-
import { planNarration } from "./plan.js";
|
|
15
15
|
// An empty narration source fails the stage immediately with no retries. Thrown from the
|
|
16
16
|
// slice rather than from a provider call, so the attempt wrapper never sees it.
|
|
17
17
|
export const nothingToNarrate = "nothing to narrate";
|
|
@@ -53,15 +53,19 @@ export async function runNarration(deps, context, providers) {
|
|
|
53
53
|
if (texts.length === 0) {
|
|
54
54
|
throw new Error(nothingToNarrate);
|
|
55
55
|
}
|
|
56
|
-
const planned =
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
const planned = planLegacyNarration(deps, context.stage.id, texts, deps.maxCharacters?.(choice.provider, choice.model) ?? 1000000, (piece) => finished(deps, projectId, piece) !== undefined);
|
|
57
|
+
const result = await speakChunks(deps, context, providers, choice, planned);
|
|
58
|
+
if (!result.ok)
|
|
59
|
+
return "held";
|
|
60
|
+
await storeBody(deps, context, choice, result.value, outputs);
|
|
61
|
+
if ((await speakSegments(deps, context, providers, choice, outputs)) === "held")
|
|
62
|
+
return "held";
|
|
60
63
|
deps.log.write("info", "narration.done", {
|
|
61
64
|
projectId,
|
|
62
65
|
stage: "audio",
|
|
63
66
|
detail: `${String(texts.length)} chunks in ${chunking.mode} mode`,
|
|
64
67
|
});
|
|
68
|
+
return "done";
|
|
65
69
|
}
|
|
66
70
|
// The narration source: the plain-text article, written by the article stage or pasted in
|
|
67
71
|
// by a user whose article stage was set to Provide.
|
|
@@ -95,7 +99,11 @@ async function speakChunks(deps, context, providers, choice, pieces) {
|
|
|
95
99
|
voiceId: choice.voice,
|
|
96
100
|
text: carried.text,
|
|
97
101
|
}, observeNarration(deps.audioPreviews, projectId, piece.id, total === 1 ? "Body" : `Body part ${piece.idx} of ${total}`));
|
|
98
|
-
|
|
102
|
+
if (!spoken.ok) {
|
|
103
|
+
setPiece(deps.db, piece.id, "pending", piece.payload);
|
|
104
|
+
return spoken;
|
|
105
|
+
}
|
|
106
|
+
write(deps, projectId, file, spoken.value.bytes);
|
|
99
107
|
// The file is on disk before the row says so: a crash between the two leaves a
|
|
100
108
|
// file the reconcile collects, where the other order would leave a `done` chunk
|
|
101
109
|
// whose audio never existed and a concat that fails on every retry.
|
|
@@ -114,11 +122,13 @@ async function speakChunks(deps, context, providers, choice, pieces) {
|
|
|
114
122
|
const files = [];
|
|
115
123
|
for (const outcome of outcomes) {
|
|
116
124
|
if (!outcome.ok) {
|
|
125
|
+
if ("reason" in outcome)
|
|
126
|
+
return outcome;
|
|
117
127
|
throw outcome.error;
|
|
118
128
|
}
|
|
119
129
|
files.push(outcome.file);
|
|
120
130
|
}
|
|
121
|
-
return files;
|
|
131
|
+
return { ok: true, value: files };
|
|
122
132
|
}
|
|
123
133
|
// One body file, in chunk order, with its measured duration on the row.
|
|
124
134
|
async function storeBody(deps, context, choice, files, outputs) {
|
|
@@ -166,8 +176,10 @@ async function speakSegments(deps, context, providers, choice, outputs) {
|
|
|
166
176
|
voiceId: choice.voice,
|
|
167
177
|
text,
|
|
168
178
|
}, observeNarration(deps.audioPreviews, projectId, piece.id, segment.category === "intro" ? "Intro" : "Outro"));
|
|
179
|
+
if (!spoken.ok)
|
|
180
|
+
return "held";
|
|
169
181
|
const name = outputFileName(role, 1, ".mp3", "audio");
|
|
170
|
-
write(deps, projectId, name, spoken.bytes);
|
|
182
|
+
write(deps, projectId, name, spoken.value.bytes);
|
|
171
183
|
// Measured the same way the body is, because the render adds all three and the gaps to
|
|
172
184
|
// get the length of the video.
|
|
173
185
|
const durationMs = await probeDurationMs(deps.ffmpeg, outputPath(deps.paths, projectId, name), context.signal, deps.log);
|
|
@@ -175,6 +187,7 @@ async function speakSegments(deps, context, providers, choice, outputs) {
|
|
|
175
187
|
store(deps, projectId, role, name, durationMs, choice);
|
|
176
188
|
counted(deps, segment.category, durationMs, choice);
|
|
177
189
|
}
|
|
190
|
+
return "done";
|
|
178
191
|
}
|
|
179
192
|
// The segment pieces belong to the *article* stage, which is where they are written, so
|
|
180
193
|
// they are read under its id and never under this stage's own. They stay there because the
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { numberPerPromptMax } from "../admission/rules.js";
|
|
3
|
+
import { runDraftSchema } from "../admission/schema.js";
|
|
4
|
+
import { defaultSubtitles } from "../subtitles/model.js";
|
|
5
|
+
export function toAdmissionDraft(input) {
|
|
6
|
+
const { form } = input.document;
|
|
7
|
+
const fields = [];
|
|
8
|
+
const sources = {
|
|
9
|
+
...form.sources,
|
|
10
|
+
...(form.sources.article === "provide" ? { research: "off" } : {}),
|
|
11
|
+
...(form.sources.images === "off" ? { video: "off" } : {}),
|
|
12
|
+
};
|
|
13
|
+
const number = (raw, field, max, min = 1) => {
|
|
14
|
+
const parsed = z
|
|
15
|
+
.number()
|
|
16
|
+
.int()
|
|
17
|
+
.min(min)
|
|
18
|
+
.max(max)
|
|
19
|
+
.safeParse(raw.trim() === "" ? Number.NaN : Number(raw));
|
|
20
|
+
if (parsed.success)
|
|
21
|
+
return parsed.data;
|
|
22
|
+
fields.push({ field, message: `Enter a whole number between ${min} and ${max}.` });
|
|
23
|
+
return min;
|
|
24
|
+
};
|
|
25
|
+
const entry = (category) => {
|
|
26
|
+
const name = form[category];
|
|
27
|
+
if (sources.audio !== "generate" || name === "")
|
|
28
|
+
return undefined;
|
|
29
|
+
const saved = input.entries.find((candidate) => candidate.category === category && candidate.name.toLowerCase() === name.toLowerCase());
|
|
30
|
+
if (saved)
|
|
31
|
+
return { name: saved.name, mode: saved.mode };
|
|
32
|
+
fields.push({
|
|
33
|
+
field: category,
|
|
34
|
+
message: `That ${category} entry no longer exists; pick another.`,
|
|
35
|
+
});
|
|
36
|
+
return undefined;
|
|
37
|
+
};
|
|
38
|
+
const file = (ref, kind, field) => {
|
|
39
|
+
const attachment = input.attachments.find((item) => item.id === ref?.attachmentId && item.kind === kind && item.name === ref.name);
|
|
40
|
+
if (attachment?.state === "ready" && attachment.stagedFileId !== null)
|
|
41
|
+
return attachment.stagedFileId;
|
|
42
|
+
fields.push({
|
|
43
|
+
field,
|
|
44
|
+
message: attachment?.state === "copying" || attachment?.state === "pending"
|
|
45
|
+
? "This upload is still copying."
|
|
46
|
+
: "That upload is no longer available; pick the file again.",
|
|
47
|
+
});
|
|
48
|
+
return undefined;
|
|
49
|
+
};
|
|
50
|
+
const mode = sources.audio === "off"
|
|
51
|
+
? "off"
|
|
52
|
+
: sources.video === "off" && form.subtitles.mode === "burn-in"
|
|
53
|
+
? "files"
|
|
54
|
+
: form.subtitles.mode;
|
|
55
|
+
const chunkMode = sources.audio === "generate" ? form.chunking.mode : "whole";
|
|
56
|
+
const draft = {
|
|
57
|
+
...(form.checkpoints === undefined ? {} : { checkpoints: form.checkpoints }),
|
|
58
|
+
title: form.title,
|
|
59
|
+
format: form.format,
|
|
60
|
+
sources,
|
|
61
|
+
llm: form.llm,
|
|
62
|
+
audio: sources.audio === "generate" ? form.audio : undefined,
|
|
63
|
+
images: form.images,
|
|
64
|
+
articlePrompt: sources.article === "generate" ? form.articlePrompt : undefined,
|
|
65
|
+
imagePrompts: sources.images === "generate"
|
|
66
|
+
? form.imagePrompts.map((prompt, index) => ({
|
|
67
|
+
name: prompt.name,
|
|
68
|
+
number: number(prompt.number, `imagePrompts.${index}.number`, numberPerPromptMax),
|
|
69
|
+
}))
|
|
70
|
+
: [],
|
|
71
|
+
thumbnailPrompt: ["from_prompt", "prompt_by_llm"].includes(sources.thumbnail)
|
|
72
|
+
? form.thumbnailPrompt
|
|
73
|
+
: undefined,
|
|
74
|
+
intro: entry("intro"),
|
|
75
|
+
outro: entry("outro"),
|
|
76
|
+
values: form.values,
|
|
77
|
+
provided: {
|
|
78
|
+
research: sources.research === "provide" ? form.provided.research : undefined,
|
|
79
|
+
article: sources.article === "provide" ? form.provided.article : undefined,
|
|
80
|
+
audio: sources.audio === "provide"
|
|
81
|
+
? file(form.provided.audio, "audio", "provided.audio")
|
|
82
|
+
: undefined,
|
|
83
|
+
thumbnail: sources.thumbnail === "provide"
|
|
84
|
+
? file(form.provided.thumbnail, "thumbnail", "provided.thumbnail")
|
|
85
|
+
: undefined,
|
|
86
|
+
images: sources.images === "provide"
|
|
87
|
+
? form.provided.images.flatMap((ref, index) => {
|
|
88
|
+
const id = file(ref, "images", `provided.images.${index}`);
|
|
89
|
+
return id === undefined ? [] : [id];
|
|
90
|
+
})
|
|
91
|
+
: [],
|
|
92
|
+
},
|
|
93
|
+
chunking: {
|
|
94
|
+
mode: chunkMode,
|
|
95
|
+
...(chunkMode === "words"
|
|
96
|
+
? { words: number(form.chunking.words, "chunking.words", 10000) }
|
|
97
|
+
: {}),
|
|
98
|
+
...(chunkMode === "characters"
|
|
99
|
+
? { characters: number(form.chunking.characters, "chunking.characters", 1000000) }
|
|
100
|
+
: {}),
|
|
101
|
+
},
|
|
102
|
+
subtitles: mode === "off"
|
|
103
|
+
? defaultSubtitles
|
|
104
|
+
: {
|
|
105
|
+
...form.subtitles,
|
|
106
|
+
mode,
|
|
107
|
+
fontSize: number(form.subtitles.fontSize, "subtitles.fontSize", 120, 16),
|
|
108
|
+
},
|
|
109
|
+
silenceGapSeconds: input.silenceGapSeconds,
|
|
110
|
+
};
|
|
111
|
+
const parsed = runDraftSchema.safeParse(draft);
|
|
112
|
+
if (!parsed.success)
|
|
113
|
+
fields.push(...parsed.error.issues.map((issue) => ({
|
|
114
|
+
field: issue.path.join("."),
|
|
115
|
+
message: issue.message,
|
|
116
|
+
})));
|
|
117
|
+
return fields.length || !parsed.success
|
|
118
|
+
? { ok: false, fields }
|
|
119
|
+
: { ok: true, draft: parsed.data };
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { modelFields } from "../../catalog/validate.js";
|
|
2
|
+
import { readinessIsUsable } from "../../kernel/ports/model.js";
|
|
3
|
+
import { cliPathStatus } from "../settings/cli-paths.js";
|
|
4
|
+
import { hasKey, listVoices } from "../settings/repo.js";
|
|
5
|
+
function choices(runs) {
|
|
6
|
+
const selected = runs
|
|
7
|
+
.flatMap(({ draft: d }) => [
|
|
8
|
+
{
|
|
9
|
+
field: "llm",
|
|
10
|
+
family: "llm",
|
|
11
|
+
choice: d.llm,
|
|
12
|
+
needed: d.sources.research === "generate" ||
|
|
13
|
+
d.sources.article === "generate" ||
|
|
14
|
+
d.sources.thumbnail === "prompt_by_llm" ||
|
|
15
|
+
d.intro?.mode === "llm" ||
|
|
16
|
+
d.outro?.mode === "llm",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
field: "audio",
|
|
20
|
+
family: "tts",
|
|
21
|
+
choice: d.audio,
|
|
22
|
+
needed: d.sources.audio === "generate",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
field: "images",
|
|
26
|
+
family: "image",
|
|
27
|
+
choice: d.images,
|
|
28
|
+
needed: d.sources.images === "generate" ||
|
|
29
|
+
["from_prompt", "prompt_by_llm"].includes(d.sources.thumbnail),
|
|
30
|
+
},
|
|
31
|
+
])
|
|
32
|
+
.flatMap((c) => (c.needed && c.choice ? [{ ...c, ...c.choice }] : []));
|
|
33
|
+
return [...new Map(selected.map((c) => [`${c.family}:${c.provider}:${c.model}`, c])).values()];
|
|
34
|
+
}
|
|
35
|
+
export async function checkDraftReadiness(deps, runs) {
|
|
36
|
+
const selected = choices(runs);
|
|
37
|
+
if (selected.length === 0)
|
|
38
|
+
return { fields: [], providers: [] };
|
|
39
|
+
const providers = await deps.providers();
|
|
40
|
+
const fields = [];
|
|
41
|
+
for (const c of selected) {
|
|
42
|
+
const provider = providers.find((p) => p.id === c.provider && p.family === c.family);
|
|
43
|
+
if (!provider || !readinessIsUsable(provider.readiness)) {
|
|
44
|
+
fields.push({
|
|
45
|
+
field: c.field,
|
|
46
|
+
message: provider?.readiness.kind === "cli" && provider.readiness.issue
|
|
47
|
+
? provider.readiness.issue
|
|
48
|
+
: "Configure this provider before starting.",
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const models = await deps.modelsFor(c.provider, c.family);
|
|
53
|
+
if (!models.some((m) => m.id === c.model))
|
|
54
|
+
fields.push({
|
|
55
|
+
field: `${c.field}.model`,
|
|
56
|
+
message: "Choose an available model before starting.",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return { fields: [...fields, ...localDraftReadiness(deps, runs, providers)], providers };
|
|
60
|
+
}
|
|
61
|
+
export function localDraftReadiness(deps, runs, providers) {
|
|
62
|
+
const fields = runs.flatMap((run) => modelFields(run.draft, deps.catalogue));
|
|
63
|
+
for (const c of choices(runs)) {
|
|
64
|
+
const provider = providers.find((p) => p.id === c.provider && p.family === c.family);
|
|
65
|
+
if (provider?.cliPath !== undefined &&
|
|
66
|
+
cliPathStatus(deps.db, provider.id).command !== provider.cliPath.command)
|
|
67
|
+
fields.push({
|
|
68
|
+
field: `${c.field}.cliPath`,
|
|
69
|
+
message: "The configured CLI command changed. Check readiness again.",
|
|
70
|
+
});
|
|
71
|
+
if (provider?.readiness.kind === "keyed" && !hasKey(deps.db, provider.id))
|
|
72
|
+
fields.push({ field: c.field, message: "Save the provider key before starting." });
|
|
73
|
+
}
|
|
74
|
+
for (const { draft } of runs)
|
|
75
|
+
if (draft.sources.audio === "generate" &&
|
|
76
|
+
draft.audio &&
|
|
77
|
+
!listVoices(deps.db).some((v) => v.provider === draft.audio?.provider && v.voiceId === draft.audio.voice))
|
|
78
|
+
fields.push({ field: "audio.voice", message: "Choose a saved voice before starting." });
|
|
79
|
+
return fields;
|
|
80
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { fingerprint } from "../../kernel/runner/work.js";
|
|
3
|
+
const rowSchema = z.object({
|
|
4
|
+
id: z.string(),
|
|
5
|
+
schema_version: z.number(),
|
|
6
|
+
version: z.number(),
|
|
7
|
+
title: z.string(),
|
|
8
|
+
document_json: z.string(),
|
|
9
|
+
creation_hash: z.string(),
|
|
10
|
+
save_mutation_id: z.string().nullable(),
|
|
11
|
+
save_request_hash: z.string().nullable(),
|
|
12
|
+
created_at: z.string(),
|
|
13
|
+
updated_at: z.string(),
|
|
14
|
+
state: z.enum(["active", "starting", "started"]),
|
|
15
|
+
review_id: z.string().nullable(),
|
|
16
|
+
review_json: z.string().nullable(),
|
|
17
|
+
review_fingerprint: z.string().nullable(),
|
|
18
|
+
start_id: z.string().nullable(),
|
|
19
|
+
});
|
|
20
|
+
const attachmentSchema = z.object({
|
|
21
|
+
id: z.string(),
|
|
22
|
+
draft_id: z.string(),
|
|
23
|
+
staged_file_id: z.string().nullable(),
|
|
24
|
+
kind: z.enum(["audio", "images", "thumbnail"]),
|
|
25
|
+
original_filename: z.string(),
|
|
26
|
+
status: z.enum(["pending", "ready", "reattach"]),
|
|
27
|
+
error: z.string().nullable(),
|
|
28
|
+
});
|
|
29
|
+
export function draftRow(db, id) {
|
|
30
|
+
const row = db.prepare("SELECT * FROM play_drafts WHERE id=?").get(id);
|
|
31
|
+
return row === undefined ? undefined : rowSchema.parse(row);
|
|
32
|
+
}
|
|
33
|
+
export function draftRows(db) {
|
|
34
|
+
return db
|
|
35
|
+
.prepare("SELECT * FROM play_drafts WHERE state IN ('active','starting') ORDER BY updated_at DESC,id")
|
|
36
|
+
.all()
|
|
37
|
+
.map((row) => rowSchema.parse(row));
|
|
38
|
+
}
|
|
39
|
+
export function attachmentRow(db, id) {
|
|
40
|
+
const row = db.prepare("SELECT * FROM play_draft_attachments WHERE id=?").get(id);
|
|
41
|
+
return row === undefined ? undefined : attachmentSchema.parse(row);
|
|
42
|
+
}
|
|
43
|
+
export function attachmentRows(db, id) {
|
|
44
|
+
return db
|
|
45
|
+
.prepare("SELECT * FROM play_draft_attachments WHERE draft_id=? ORDER BY rowid")
|
|
46
|
+
.all(id)
|
|
47
|
+
.map((row) => attachmentSchema.parse(row));
|
|
48
|
+
}
|
|
49
|
+
export function attachmentRefs(document) {
|
|
50
|
+
const p = document.form.provided;
|
|
51
|
+
return [
|
|
52
|
+
...(p.audio === null ? [] : [{ ...p.audio, kind: "audio" }]),
|
|
53
|
+
...p.images.map((file) => ({ ...file, kind: "images" })),
|
|
54
|
+
...(p.thumbnail === null ? [] : [{ ...p.thumbnail, kind: "thumbnail" }]),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
export function requestHash(value) {
|
|
58
|
+
return fingerprint(z.json().parse(JSON.parse(JSON.stringify(value))));
|
|
59
|
+
}
|
|
60
|
+
export function insertDraft(db, id, document, hash, at) {
|
|
61
|
+
db.prepare("INSERT INTO play_drafts (id,schema_version,version,title,document_json,creation_hash,created_at,updated_at,state) VALUES (?,1,1,?,?,?,?,?,'active')").run(id, document.form.title, JSON.stringify(document), hash, at, at);
|
|
62
|
+
}
|
|
63
|
+
export function insertAttachment(db, id, ref, status, stagedId = null) {
|
|
64
|
+
db.prepare("INSERT INTO play_draft_attachments (id,draft_id,kind,original_filename,status,staged_file_id) VALUES (?,?,?,?,?,?)").run(ref.attachmentId, id, ref.kind, ref.name, status, stagedId);
|
|
65
|
+
}
|
|
66
|
+
export function syncAttachments(db, id, document) {
|
|
67
|
+
const refs = attachmentRefs(document);
|
|
68
|
+
const retained = new Set(refs.map((ref) => ref.attachmentId));
|
|
69
|
+
for (const old of attachmentRows(db, id))
|
|
70
|
+
if (!retained.has(old.id))
|
|
71
|
+
db.prepare("DELETE FROM play_draft_attachments WHERE id=?").run(old.id);
|
|
72
|
+
// Removed staged bytes remain until reference-aware storage cleanup runs.
|
|
73
|
+
for (const ref of refs)
|
|
74
|
+
if (attachmentRow(db, ref.attachmentId) === undefined)
|
|
75
|
+
insertAttachment(db, id, ref, "pending");
|
|
76
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { modelFields } from "../../catalog/validate.js";
|
|
5
|
+
import { admit } from "../admission/rules.js";
|
|
6
|
+
import { estimateRun } from "../estimate/index.js";
|
|
7
|
+
import { listEntries } from "../library/repo.js";
|
|
8
|
+
import { pickTemplates, renderPicked } from "../library/slots.js";
|
|
9
|
+
import { reviewCheckpointSet } from "../rebuild/recipe-checkpoints.js";
|
|
10
|
+
import { readSettings } from "../settings/playback.js";
|
|
11
|
+
import { stagedFiles } from "../storage/repo.js";
|
|
12
|
+
import { toAdmissionDraft } from "./convert.js";
|
|
13
|
+
import { requestHash } from "./repo.js";
|
|
14
|
+
import { readDraft } from "./service.js";
|
|
15
|
+
export function reviewRefusal(view, fields, reason = "readiness") {
|
|
16
|
+
return { ok: false, reason, currentVersion: view.draft.version, fields };
|
|
17
|
+
}
|
|
18
|
+
export function reviewBinding(value) {
|
|
19
|
+
return requestHash({
|
|
20
|
+
...(value.checkpointSet === undefined ? {} : { checkpointSet: value.checkpointSet }),
|
|
21
|
+
runs: value.runs,
|
|
22
|
+
estimates: value.estimates,
|
|
23
|
+
catalogue: value.catalogue,
|
|
24
|
+
attachmentIdentity: value.attachmentIdentity,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function resolveReviewInputs(deps, view, font) {
|
|
28
|
+
const fresh = readDraft(deps, view.draft.id);
|
|
29
|
+
if (!fresh.ok)
|
|
30
|
+
return fresh;
|
|
31
|
+
if (fresh.value.draft.version !== view.draft.version ||
|
|
32
|
+
requestHash(fresh.value.draft.document) !== requestHash(view.draft.document))
|
|
33
|
+
return reviewRefusal(fresh.value, [], "stale-review");
|
|
34
|
+
const { document } = view.draft;
|
|
35
|
+
const fields = [];
|
|
36
|
+
if (document.fontUpload !== null)
|
|
37
|
+
fields.push({
|
|
38
|
+
field: "fontUpload",
|
|
39
|
+
message: "Wait for the font upload to finish or choose another font.",
|
|
40
|
+
});
|
|
41
|
+
const words = z
|
|
42
|
+
.number()
|
|
43
|
+
.int()
|
|
44
|
+
.min(1)
|
|
45
|
+
.max(100000)
|
|
46
|
+
.safeParse(document.expectedWords.trim() === "" ? Number.NaN : Number(document.expectedWords));
|
|
47
|
+
if (!words.success)
|
|
48
|
+
fields.push({ field: "expectedWords", message: "Enter a whole number between 1 and 100000." });
|
|
49
|
+
if (document.variants.length > 49)
|
|
50
|
+
fields.push({ field: "variants", message: "Review at most 50 runs including the base run." });
|
|
51
|
+
const converted = toAdmissionDraft({
|
|
52
|
+
document,
|
|
53
|
+
attachments: fresh.value.attachments,
|
|
54
|
+
entries: [...(document.librarySnapshot?.entries ?? []), ...listEntries(deps.db)],
|
|
55
|
+
silenceGapSeconds: readSettings(deps).silenceGapSeconds,
|
|
56
|
+
});
|
|
57
|
+
if (!converted.ok)
|
|
58
|
+
fields.push(...converted.fields);
|
|
59
|
+
if (!converted.ok || !words.success || fields.length)
|
|
60
|
+
return reviewRefusal(view, fields);
|
|
61
|
+
const catalogue = deps.catalogue.read();
|
|
62
|
+
const captured = {
|
|
63
|
+
...deps.catalogue,
|
|
64
|
+
read: () => catalogue,
|
|
65
|
+
models: (provider, family) => catalogue[family].filter((model) => model.provider === provider && model.enabled && !model.deprecated),
|
|
66
|
+
};
|
|
67
|
+
const staged = stagedFiles(deps.db);
|
|
68
|
+
const basePicked = pickTemplates(deps.db, converted.draft, document.librarySnapshot);
|
|
69
|
+
const drafts = [
|
|
70
|
+
basePicked.draft,
|
|
71
|
+
...document.variants.map((variant) => ({
|
|
72
|
+
...basePicked.draft,
|
|
73
|
+
title: variant.title,
|
|
74
|
+
values: { ...basePicked.draft.values, ...variant.values },
|
|
75
|
+
})),
|
|
76
|
+
];
|
|
77
|
+
const runs = [];
|
|
78
|
+
for (const [index, draft] of drafts.entries()) {
|
|
79
|
+
const values = Object.fromEntries(basePicked.requiredSlots.map((name) => [
|
|
80
|
+
name,
|
|
81
|
+
Object.hasOwn(draft.values, name) ? (draft.values[name] ?? "") : "",
|
|
82
|
+
]));
|
|
83
|
+
const admitted = admit({
|
|
84
|
+
draft: { ...draft, values },
|
|
85
|
+
staged,
|
|
86
|
+
requiredSlots: basePicked.requiredSlots,
|
|
87
|
+
});
|
|
88
|
+
const accepted = admitted.ok ? admitted.draft : draft;
|
|
89
|
+
const issues = [
|
|
90
|
+
...basePicked.missing,
|
|
91
|
+
...(admitted.ok ? [] : admitted.fields),
|
|
92
|
+
...modelFields(accepted, captured),
|
|
93
|
+
];
|
|
94
|
+
fields.push(...issues.map((issue) => ({
|
|
95
|
+
...issue,
|
|
96
|
+
field: index > 0 && (issue.field === "title" || issue.field.startsWith("values."))
|
|
97
|
+
? `variants.${index - 1}.${issue.field}`
|
|
98
|
+
: issue.field,
|
|
99
|
+
message: `${index > 0 ? `Run ${index + 1}: ` : ""}${issue.message}`,
|
|
100
|
+
})));
|
|
101
|
+
runs.push({
|
|
102
|
+
draft: accepted,
|
|
103
|
+
rendered: renderPicked(basePicked, accepted.values),
|
|
104
|
+
templates: Object.fromEntries(basePicked.bodies.map(({ key, body }) => [key, body])),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const activeIds = new Set(runs.flatMap((run) => [
|
|
108
|
+
run.draft.provided.audio,
|
|
109
|
+
run.draft.provided.thumbnail,
|
|
110
|
+
...(run.draft.provided.images ?? []),
|
|
111
|
+
]));
|
|
112
|
+
const attachmentIdentity = fresh.value.attachments.flatMap((file) => file.stagedFileId !== null && activeIds.has(file.stagedFileId)
|
|
113
|
+
? [{ id: file.id, stagedFileId: file.stagedFileId, bytes: file.bytes }]
|
|
114
|
+
: []);
|
|
115
|
+
let fontHash = null;
|
|
116
|
+
if (font !== null) {
|
|
117
|
+
const stat = statSync(font.path, { throwIfNoEntry: false });
|
|
118
|
+
if (!stat?.isFile() || font.id !== converted.draft.subtitles?.fontId)
|
|
119
|
+
fields.push({
|
|
120
|
+
field: "subtitles.fontId",
|
|
121
|
+
message: "The selected font is no longer available. Choose another font.",
|
|
122
|
+
});
|
|
123
|
+
else
|
|
124
|
+
fontHash = createHash("sha256").update(readFileSync(font.path)).digest("hex");
|
|
125
|
+
}
|
|
126
|
+
if (fields.length)
|
|
127
|
+
return reviewRefusal(view, fields);
|
|
128
|
+
const estimates = runs.map((run) => estimateRun(run.draft, run.rendered, words.data, captured));
|
|
129
|
+
for (const run of runs)
|
|
130
|
+
for (const stage of run.draft.checkpoints ?? [])
|
|
131
|
+
if (stage === "video"
|
|
132
|
+
? run.draft.sources.video === "off" && run.draft.sources.audio === "off"
|
|
133
|
+
: run.draft.sources[stage] !== "generate")
|
|
134
|
+
fields.push({
|
|
135
|
+
field: `checkpoints.${stage}`,
|
|
136
|
+
message: "Choose a checkpoint only before an enabled generated stage or export.",
|
|
137
|
+
});
|
|
138
|
+
if (fields.length)
|
|
139
|
+
return reviewRefusal(view, fields);
|
|
140
|
+
const checkpointSet = reviewCheckpointSet(runs, catalogue, attachmentIdentity, fontHash);
|
|
141
|
+
const resolved = {
|
|
142
|
+
runs,
|
|
143
|
+
estimates,
|
|
144
|
+
catalogue,
|
|
145
|
+
attachmentIdentity,
|
|
146
|
+
font,
|
|
147
|
+
...(checkpointSet.length ? { checkpointSet } : {}),
|
|
148
|
+
};
|
|
149
|
+
return { ok: true, value: { ...resolved, fingerprint: requestHash({ ...resolved, fontHash }) } };
|
|
150
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
3
|
+
import { isMissingFont } from "../fonts/index.js";
|
|
4
|
+
import { draftRow } from "./repo.js";
|
|
5
|
+
import { resolveReviewInputs, reviewBinding, reviewRefusal } from "./review-inputs.js";
|
|
6
|
+
import { draftViewSchema, playReviewSchema } from "./schema.js";
|
|
7
|
+
import { readDraft } from "./service.js";
|
|
8
|
+
export { resolveReviewInputs } from "./review-inputs.js";
|
|
9
|
+
export async function resolvePlayReview(deps, view) {
|
|
10
|
+
const parsed = draftViewSchema.safeParse(view);
|
|
11
|
+
if (!parsed.success)
|
|
12
|
+
return { ok: false, reason: "invalid-edit", currentVersion: null, fields: [] };
|
|
13
|
+
const before = resolveReviewInputs(deps, parsed.data, null);
|
|
14
|
+
if (!before.ok)
|
|
15
|
+
return before;
|
|
16
|
+
let font = null;
|
|
17
|
+
const subtitles = before.value.runs[0]?.draft.subtitles;
|
|
18
|
+
if (subtitles !== undefined && subtitles.mode !== "off") {
|
|
19
|
+
try {
|
|
20
|
+
font = await deps.resolveFont(subtitles.fontId);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (!isMissingFont(error))
|
|
24
|
+
throw error;
|
|
25
|
+
return reviewRefusal(view, [
|
|
26
|
+
{
|
|
27
|
+
field: "subtitles.fontId",
|
|
28
|
+
message: "The selected font is no longer available. Choose another font.",
|
|
29
|
+
},
|
|
30
|
+
]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const after = resolveReviewInputs(deps, parsed.data, font);
|
|
34
|
+
if (!after.ok)
|
|
35
|
+
return after;
|
|
36
|
+
return reviewBinding(before.value) === reviewBinding(after.value)
|
|
37
|
+
? after
|
|
38
|
+
: reviewRefusal(view, [], "stale-review");
|
|
39
|
+
}
|
|
40
|
+
const inputSchema = z.object({ id: z.uuid(), baseVersion: z.number().int().positive() }).strict();
|
|
41
|
+
export async function reviewDraft(deps, input) {
|
|
42
|
+
const parsed = inputSchema.safeParse(input);
|
|
43
|
+
if (!parsed.success)
|
|
44
|
+
return { ok: false, reason: "invalid-edit", currentVersion: null, fields: [] };
|
|
45
|
+
const view = readDraft(deps, parsed.data.id);
|
|
46
|
+
if (!view.ok)
|
|
47
|
+
return view;
|
|
48
|
+
const gate = reviewGate(view.value, parsed.data.baseVersion);
|
|
49
|
+
if (gate !== null)
|
|
50
|
+
return gate;
|
|
51
|
+
const resolved = await resolvePlayReview(deps, view.value);
|
|
52
|
+
if (!resolved.ok)
|
|
53
|
+
return resolved;
|
|
54
|
+
return transact(deps.db, () => {
|
|
55
|
+
const current = readDraft(deps, parsed.data.id);
|
|
56
|
+
if (!current.ok)
|
|
57
|
+
return current;
|
|
58
|
+
const denied = reviewGate(current.value, parsed.data.baseVersion);
|
|
59
|
+
if (denied !== null)
|
|
60
|
+
return denied;
|
|
61
|
+
const final = resolveReviewInputs(deps, current.value, resolved.value.font);
|
|
62
|
+
if (!final.ok)
|
|
63
|
+
return final;
|
|
64
|
+
if (final.value.fingerprint !== resolved.value.fingerprint)
|
|
65
|
+
return reviewRefusal(current.value, [], "stale-review");
|
|
66
|
+
const old = current.value.review;
|
|
67
|
+
if (old?.draftVersion === current.value.draft.version &&
|
|
68
|
+
old.fingerprint === final.value.fingerprint)
|
|
69
|
+
return { ok: true, value: old };
|
|
70
|
+
const review = playReviewSchema.parse({
|
|
71
|
+
...(final.value.checkpointSet === undefined
|
|
72
|
+
? {}
|
|
73
|
+
: { checkpointSet: final.value.checkpointSet }),
|
|
74
|
+
id: deps.uuid(),
|
|
75
|
+
draftId: parsed.data.id,
|
|
76
|
+
draftVersion: parsed.data.baseVersion,
|
|
77
|
+
fingerprint: final.value.fingerprint,
|
|
78
|
+
runs: final.value.runs,
|
|
79
|
+
estimates: final.value.estimates,
|
|
80
|
+
});
|
|
81
|
+
const { catalogue, attachmentIdentity, font } = final.value;
|
|
82
|
+
const changed = deps.db
|
|
83
|
+
.prepare("UPDATE play_drafts SET review_id=?,review_json=?,review_fingerprint=? WHERE id=? AND version=? AND state='active'")
|
|
84
|
+
.run(review.id, JSON.stringify({ review, execution: { catalogue, attachmentIdentity, font } }), review.fingerprint, parsed.data.id, parsed.data.baseVersion);
|
|
85
|
+
if (Number(changed.changes) !== 1)
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: "conflict",
|
|
89
|
+
currentVersion: draftRow(deps.db, parsed.data.id)?.version ?? null,
|
|
90
|
+
fields: [],
|
|
91
|
+
};
|
|
92
|
+
return { ok: true, value: review };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function reviewGate(view, baseVersion) {
|
|
96
|
+
if (view.pendingStart !== null)
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
reason: "pending-start",
|
|
100
|
+
currentVersion: view.draft.version,
|
|
101
|
+
reviewId: view.pendingStart.reviewId,
|
|
102
|
+
fields: [],
|
|
103
|
+
};
|
|
104
|
+
if (view.start !== null)
|
|
105
|
+
return { ok: false, reason: "already-started", currentVersion: view.draft.version, fields: [] };
|
|
106
|
+
if (view.draft.version !== baseVersion)
|
|
107
|
+
return { ok: false, reason: "conflict", currentVersion: view.draft.version, fields: [] };
|
|
108
|
+
return null;
|
|
109
|
+
}
|