@gentbajko/slopify 1.5.0 → 2.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 +34 -8
- package/dist/adapters/alignment/cache.js +67 -7
- package/dist/edge/cli.js +10 -2
- package/dist/edge/docker-launch.js +77 -0
- package/dist/edge/docker-projects/activation.js +35 -0
- package/dist/edge/docker-projects/claims.js +85 -0
- package/dist/edge/docker-projects/committed.js +40 -0
- package/dist/edge/docker-projects/engine.js +384 -0
- package/dist/edge/docker-projects/install.js +246 -0
- package/dist/edge/docker-projects/recover.js +100 -0
- package/dist/edge/docker-projects/state.js +221 -0
- package/dist/edge/docker-projects/tree.js +170 -0
- package/dist/edge/docker-projects/volume.js +95 -0
- package/dist/edge/docker.js +6 -0
- package/dist/edge/http/actions.js +64 -15
- package/dist/edge/http/app.js +6 -0
- package/dist/edge/http/folder-location-schema.js +11 -0
- package/dist/edge/http/folder-location.js +65 -0
- package/dist/edge/http/open-folder.js +2 -14
- package/dist/edge/http/projects.js +2 -0
- package/dist/edge/http/revision-files.js +2 -18
- package/dist/kernel/db/migrations/0012-project-recovery.sql +17 -0
- package/dist/kernel/lock.js +20 -6
- package/dist/main.js +11 -1
- package/dist/slices/admission/rules.js +6 -0
- package/dist/slices/admission/schema.js +3 -1
- package/dist/slices/article/plain.js +16 -9
- package/dist/slices/control/revision-control.js +2 -2
- package/dist/slices/narration/pronunciation-chunks.js +105 -0
- package/dist/slices/narration/pronunciation.js +126 -0
- package/dist/slices/narration/steering.js +82 -24
- package/dist/slices/play-drafts/convert.js +3 -1
- package/dist/slices/play-drafts/schema.js +3 -1
- package/dist/slices/rebuild/admission-repo.js +7 -4
- package/dist/slices/rebuild/preview-plan.js +1 -1
- package/dist/slices/rebuild/preview-retained.js +16 -3
- package/dist/slices/rebuild/recipe-audio-parts.js +70 -14
- package/dist/slices/rebuild/recipe-audio.js +61 -40
- package/dist/slices/rebuild/recipe-model.js +5 -3
- package/dist/slices/rebuild/recipe-preparation.js +6 -4
- package/dist/slices/rebuild/recipe-text.js +11 -3
- package/dist/slices/rebuild/recovery-conflict.js +30 -0
- package/dist/slices/rebuild/recovery-model.js +44 -0
- package/dist/slices/rebuild/recovery-repo.js +84 -0
- package/dist/slices/rebuild/recovery-selection.js +68 -0
- package/dist/slices/rebuild/recovery.js +257 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +3 -2
- package/dist/slices/rebuild/runtime-narration-publication.js +30 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +20 -2
- package/dist/slices/rebuild/runtime-plan.js +8 -2
- package/dist/slices/rebuild/runtime-publication.js +9 -4
- package/dist/slices/rebuild/service.js +44 -22
- package/dist/slices/rebuild/transition-repo.js +1 -1
- package/dist/slices/revisions/mutation-assets.js +6 -1
- package/dist/slices/revisions/mutation-request.js +4 -0
- package/dist/slices/revisions/mutations.js +10 -5
- package/dist/slices/revisions/rules.js +43 -0
- package/dist/slices/revisions/schema.js +12 -0
- package/dist/slices/settings/repo.js +4 -2
- package/dist/web/assets/index-CZNiHm8P.css +1 -0
- package/dist/web/assets/index-DPgGLX5m.js +137 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/scripts/docker-run.sh +1 -74
- package/dist/web/assets/index-CbGJtCio.js +0 -137
- package/dist/web/assets/index-DX1_chKs.css +0 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
2
|
+
import { withProjectControl } from "../control/lock.js";
|
|
3
|
+
import { requestHash } from "../revisions/mutation-request.js";
|
|
4
|
+
import { saveRevision } from "../revisions/mutations.js";
|
|
5
|
+
import { currentRevisionId } from "../revisions/repo.js";
|
|
6
|
+
import { getRevisionView } from "../revisions/view.js";
|
|
7
|
+
import { planPreview } from "./preview-plan.js";
|
|
8
|
+
import { retainedPreviewPlan } from "./preview-retained.js";
|
|
9
|
+
import { recipeProviderChoice } from "./recipe-provider-choice.js";
|
|
10
|
+
import { acceptedJobMessage, activeConflict, conflictRefusal } from "./recovery-conflict.js";
|
|
11
|
+
import { recoveryRequestSchema, } from "./recovery-model.js";
|
|
12
|
+
import { readRecovery, recoveryAuthority, recoveryKey, rememberRecovery, reserveRecovery, } from "./recovery-repo.js";
|
|
13
|
+
import { dependentClosure, recoverySelection, regenerationEdit, sectionRoots, } from "./recovery-selection.js";
|
|
14
|
+
import { previewById, storePreview } from "./repo.js";
|
|
15
|
+
import { admitCheckedPreview, wakeRebuild } from "./service.js";
|
|
16
|
+
import { checkReadiness } from "./service-readiness.js";
|
|
17
|
+
function credentialsStamp(deps, plan, view) {
|
|
18
|
+
const providers = new Set(plan.execution.recipes.flatMap((recipe) => {
|
|
19
|
+
if (!plan.execution.submissions.some((row) => row.key === recipe.key && row.submit))
|
|
20
|
+
return [];
|
|
21
|
+
const choice = recipeProviderChoice(recipe, view.revision.config);
|
|
22
|
+
return choice === undefined ? [] : [choice.provider];
|
|
23
|
+
}));
|
|
24
|
+
const metadata = deps.db
|
|
25
|
+
.prepare("SELECT provider,credential_generation FROM provider_keys ORDER BY provider")
|
|
26
|
+
.all()
|
|
27
|
+
.filter((row) => providers.has(String(row.provider)));
|
|
28
|
+
return requestHash("recovery-credentials", view.revision.id, metadata);
|
|
29
|
+
}
|
|
30
|
+
export async function recoverProject(deps, projectId, raw) {
|
|
31
|
+
const parsed = recoveryRequestSchema.safeParse(raw);
|
|
32
|
+
if (!parsed.success)
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
reason: "invalid-selection",
|
|
36
|
+
fields: parsed.error.issues.map((issue) => ({
|
|
37
|
+
field: issue.path.join("."),
|
|
38
|
+
message: issue.message,
|
|
39
|
+
})),
|
|
40
|
+
};
|
|
41
|
+
const input = parsed.data;
|
|
42
|
+
const prepared = await withProjectControl(deps.db, projectId, async () => {
|
|
43
|
+
const found = readRecovery(deps, projectId, input);
|
|
44
|
+
if (found !== undefined && "ok" in found)
|
|
45
|
+
return found;
|
|
46
|
+
const base = getRevisionView(deps, projectId, input.baseRevisionId);
|
|
47
|
+
if (base === undefined)
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
reason: currentRevisionId(deps.db, projectId) === undefined ? "no-project" : "conflict",
|
|
51
|
+
};
|
|
52
|
+
let record = found;
|
|
53
|
+
if (record === undefined) {
|
|
54
|
+
if (!base.current)
|
|
55
|
+
return { ok: false, reason: "conflict" };
|
|
56
|
+
const plan = retainedPreviewPlan(deps, base, deps.catalogue.read());
|
|
57
|
+
const edit = input.action.kind === "rerun" ? regenerationEdit(base, plan, input.action.stage) : null;
|
|
58
|
+
if (edit === undefined)
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
reason: "invalid-selection",
|
|
62
|
+
fields: [
|
|
63
|
+
{
|
|
64
|
+
field: "stage",
|
|
65
|
+
message: "This section has no generated work to rerun. Use Edit project to change its source.",
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
record = reserveRecovery(deps, projectId, input, edit);
|
|
70
|
+
}
|
|
71
|
+
const authority = record.authority;
|
|
72
|
+
let intentRevisionId = record.intentRevisionId;
|
|
73
|
+
const finish = (result) => rememberRecovery(deps, projectId, input, !result.ok && intentRevisionId !== null ? { ...result, intentRevisionId } : result);
|
|
74
|
+
if (recoveryAuthority(deps, projectId) !== authority)
|
|
75
|
+
return finish({ ok: false, reason: "control-changed" });
|
|
76
|
+
let view = base;
|
|
77
|
+
if (record.edit !== null) {
|
|
78
|
+
const prior = retainedPreviewPlan(deps, base, deps.catalogue.read());
|
|
79
|
+
const action = input.action;
|
|
80
|
+
if (action.kind !== "rerun")
|
|
81
|
+
throw new Error("Recovery edit has no rerun action");
|
|
82
|
+
const affected = dependentClosure(prior.recipes, sectionRoots(base, prior, action.stage));
|
|
83
|
+
const savedIntent = deps.db
|
|
84
|
+
.prepare("SELECT result_revision_id FROM revision_mutations WHERE project_id=? AND idempotency_key=?")
|
|
85
|
+
.get(projectId, recoveryKey(input, "save"));
|
|
86
|
+
const conflict = savedIntent === undefined ? activeConflict(deps, projectId, affected) : undefined;
|
|
87
|
+
if (conflict !== undefined)
|
|
88
|
+
return finish(conflictRefusal(conflict));
|
|
89
|
+
const saved = await saveRevision(deps, {
|
|
90
|
+
projectId,
|
|
91
|
+
baseRevisionId: input.baseRevisionId,
|
|
92
|
+
idempotencyKey: recoveryKey(input, "save"),
|
|
93
|
+
edit: record.edit,
|
|
94
|
+
beforeCommit: () => {
|
|
95
|
+
const late = activeConflict(deps, projectId, affected);
|
|
96
|
+
return late === undefined
|
|
97
|
+
? undefined
|
|
98
|
+
: {
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: "invalid-edit",
|
|
101
|
+
currentRevisionId: currentRevisionId(deps.db, projectId) ?? null,
|
|
102
|
+
fields: [
|
|
103
|
+
{
|
|
104
|
+
field: "stage",
|
|
105
|
+
message: late === "running"
|
|
106
|
+
? "Wait for this section to finish, or Pause the project before rerunning it."
|
|
107
|
+
: acceptedJobMessage,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
if (!saved.ok)
|
|
114
|
+
return finish(saved);
|
|
115
|
+
view = saved.view;
|
|
116
|
+
intentRevisionId = view.revision.id;
|
|
117
|
+
deps.db
|
|
118
|
+
.prepare("UPDATE project_recovery_requests SET intent_revision_id=? WHERE project_id=? AND idempotency_key=?")
|
|
119
|
+
.run(view.revision.id, projectId, input.idempotencyKey);
|
|
120
|
+
}
|
|
121
|
+
if (currentRevisionId(deps.db, projectId) !== view.revision.id)
|
|
122
|
+
return finish({ ok: false, reason: "conflict" });
|
|
123
|
+
const plan = retainedPreviewPlan(deps, view, deps.catalogue.read());
|
|
124
|
+
const rerunKeys = input.action.kind === "rerun"
|
|
125
|
+
? dependentClosure(plan.recipes, sectionRoots(view, plan, input.action.stage))
|
|
126
|
+
: [];
|
|
127
|
+
const selection = input.action.kind === "rerun"
|
|
128
|
+
? { kind: "selected", workKeys: rerunKeys }
|
|
129
|
+
: recoverySelection(plan, input.action.kind === "retry" ? input.action.stage : undefined);
|
|
130
|
+
if (selection.kind === "selected" && selection.workKeys.length === 0)
|
|
131
|
+
return finish({
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: "invalid-selection",
|
|
134
|
+
fields: [
|
|
135
|
+
{
|
|
136
|
+
field: "stage",
|
|
137
|
+
message: "This section has no enabled work. Use Edit project to change its source.",
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
const preview = planPreview(deps, view, deps.catalogue.read(), selection, deps.ids.next());
|
|
142
|
+
if (!preview.ok)
|
|
143
|
+
return finish(preview);
|
|
144
|
+
if (preview.value.preview.providedReuseRequired.length > 0)
|
|
145
|
+
return finish({
|
|
146
|
+
ok: false,
|
|
147
|
+
reason: "review-required",
|
|
148
|
+
fields: preview.value.preview.providedReuseRequired.map((key) => ({
|
|
149
|
+
field: key,
|
|
150
|
+
message: "Review changed supplied content or manual captions in Edit project or Advanced rebuild review.",
|
|
151
|
+
})),
|
|
152
|
+
});
|
|
153
|
+
const blocked = preview.value.preview.work.filter((row) => row.disposition === "blocked");
|
|
154
|
+
if (blocked.length > 0)
|
|
155
|
+
return finish({
|
|
156
|
+
ok: false,
|
|
157
|
+
reason: "readiness",
|
|
158
|
+
fields: blocked.map((row) => ({ field: row.key, message: row.reason })),
|
|
159
|
+
});
|
|
160
|
+
if (input.action.kind === "rerun" &&
|
|
161
|
+
input.action.stage === "video" &&
|
|
162
|
+
preview.value.preview.work.some((row) => row.kind === "provider" && row.disposition !== "reuse"))
|
|
163
|
+
return finish({
|
|
164
|
+
ok: false,
|
|
165
|
+
reason: "readiness",
|
|
166
|
+
fields: [
|
|
167
|
+
{
|
|
168
|
+
field: "video",
|
|
169
|
+
message: "Source media is incomplete. Use Resume to recover it before rerunning the export.",
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
});
|
|
173
|
+
const conflict = rerunKeys.length > 0 ? activeConflict(deps, projectId, rerunKeys) : undefined;
|
|
174
|
+
if (conflict !== undefined)
|
|
175
|
+
return finish(conflictRefusal(conflict));
|
|
176
|
+
storePreview(deps, preview.value.preview, preview.value.execution);
|
|
177
|
+
const stored = previewById(deps.db, projectId, preview.value.preview.id);
|
|
178
|
+
if (stored === undefined)
|
|
179
|
+
throw new Error("Stored recovery preview is missing.");
|
|
180
|
+
return {
|
|
181
|
+
plan: { preview: stored, execution: preview.value.execution },
|
|
182
|
+
view,
|
|
183
|
+
authority,
|
|
184
|
+
rerunKeys,
|
|
185
|
+
credentials: credentialsStamp(deps, preview.value, view),
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
if ("ok" in prepared)
|
|
189
|
+
return prepared;
|
|
190
|
+
let ready;
|
|
191
|
+
try {
|
|
192
|
+
ready = await checkReadiness(deps, prepared.plan.execution, prepared.view);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
deps.log.write("warn", "project.recovery", {
|
|
196
|
+
projectId,
|
|
197
|
+
...(input.action.kind === "resume" ? {} : { stage: input.action.stage }),
|
|
198
|
+
detail: "Provider readiness could not be loaded.",
|
|
199
|
+
});
|
|
200
|
+
ready = {
|
|
201
|
+
fields: [{ field: "provider", message: "Check provider readiness and try Resume." }],
|
|
202
|
+
providers: [],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
let admittedHere = false;
|
|
206
|
+
const result = await withProjectControl(deps.db, projectId, () => transact(deps.db, () => {
|
|
207
|
+
const receipt = readRecovery(deps, projectId, input);
|
|
208
|
+
if (receipt !== undefined && "ok" in receipt)
|
|
209
|
+
return receipt;
|
|
210
|
+
if (receipt === undefined)
|
|
211
|
+
return { ok: false, reason: "no-project" };
|
|
212
|
+
const finish = (value) => rememberRecovery(deps, projectId, input, !value.ok && receipt.intentRevisionId !== null
|
|
213
|
+
? { ...value, intentRevisionId: receipt.intentRevisionId }
|
|
214
|
+
: value);
|
|
215
|
+
if (recoveryAuthority(deps, projectId) !== prepared.authority)
|
|
216
|
+
return finish({ ok: false, reason: "control-changed" });
|
|
217
|
+
if (currentRevisionId(deps.db, projectId) !== prepared.view.revision.id)
|
|
218
|
+
return finish({ ok: false, reason: "conflict" });
|
|
219
|
+
if (credentialsStamp(deps, prepared.plan, prepared.view) !== prepared.credentials)
|
|
220
|
+
return finish({
|
|
221
|
+
ok: false,
|
|
222
|
+
reason: "readiness",
|
|
223
|
+
fields: [
|
|
224
|
+
{ field: "provider", message: "Provider credentials changed. Try Resume again." },
|
|
225
|
+
],
|
|
226
|
+
});
|
|
227
|
+
if (ready.fields.length > 0)
|
|
228
|
+
return finish({ ok: false, reason: "readiness", fields: [...ready.fields] });
|
|
229
|
+
const conflict = prepared.rerunKeys.length > 0
|
|
230
|
+
? activeConflict(deps, projectId, prepared.rerunKeys)
|
|
231
|
+
: undefined;
|
|
232
|
+
if (conflict !== undefined)
|
|
233
|
+
return finish(conflictRefusal(conflict));
|
|
234
|
+
const admitted = admitCheckedPreview(deps, prepared.plan.preview, prepared.plan.execution, ready.providers, recoveryKey(input, "admit"), requestHash("direct-recovery", input.baseRevisionId, input.action));
|
|
235
|
+
if (!admitted.ok)
|
|
236
|
+
return finish(admitted);
|
|
237
|
+
admittedHere = true;
|
|
238
|
+
return finish({
|
|
239
|
+
ok: true,
|
|
240
|
+
value: {
|
|
241
|
+
...admitted.value,
|
|
242
|
+
workIds: [...admitted.value.workIds],
|
|
243
|
+
warnings: [
|
|
244
|
+
...prepared.plan.preview.warnings,
|
|
245
|
+
...(prepared.plan.preview.costs.unknown > 0
|
|
246
|
+
? ["Some generation prices are unknown; actual usage is recorded."]
|
|
247
|
+
: []),
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}));
|
|
252
|
+
if (result.ok && admittedHere) {
|
|
253
|
+
deps.emit(projectId, { type: "project.updated", projectId });
|
|
254
|
+
wakeRebuild(deps, projectId, result.value);
|
|
255
|
+
}
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { usesNarrationPreparation } from "../admission/rules.js";
|
|
3
|
+
import { usesNarrationPreparation, usesPronunciationGlossary } from "../admission/rules.js";
|
|
4
4
|
import { plainText } from "../article/plain.js";
|
|
5
5
|
import { splitEndMatter } from "../article/split.js";
|
|
6
6
|
import { outputPath } from "../storage/layout.js";
|
|
@@ -53,7 +53,8 @@ export async function revisionAudio(deps, context, view) {
|
|
|
53
53
|
}
|
|
54
54
|
export function revisionTranscript(deps, snapshot, kind) {
|
|
55
55
|
const { view, plan } = snapshot;
|
|
56
|
-
if (usesNarrationPreparation(view.revision.config)
|
|
56
|
+
if (usesNarrationPreparation(view.revision.config) ||
|
|
57
|
+
usesPronunciationGlossary(view.revision.config))
|
|
57
58
|
return joinedNarration(narrationTextParts(view, plan, kind));
|
|
58
59
|
const concat = plan.recipes.find((one) => one.key === (kind === "body" ? "audio:body:concat" : `audio:${kind}`));
|
|
59
60
|
if (concat !== undefined) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { projectSelected } from "../revisions/projection.js";
|
|
3
|
+
import { getRevisionView } from "../revisions/view.js";
|
|
4
|
+
import { bindNarrationReuse, narrationOrdinal } from "./runtime-narration-reuse.js";
|
|
5
|
+
import { executionPlan, savedCatalogue } from "./runtime-plan.js";
|
|
6
|
+
export function rebindPublishedNarration(deps, work, piece) {
|
|
7
|
+
if (piece.input.kind !== "tts")
|
|
8
|
+
return;
|
|
9
|
+
const targets = deps.db
|
|
10
|
+
.prepare("SELECT revision_id FROM revision_pieces WHERE project_id=? AND publication_id=? AND piece_key=? AND selected=1 AND revision_id!=?")
|
|
11
|
+
.all(work.projectId, piece.id, piece.key, work.revisionId);
|
|
12
|
+
if (targets.length === 0)
|
|
13
|
+
return;
|
|
14
|
+
const row = deps.db
|
|
15
|
+
.prepare("SELECT recipe_context FROM revision_work WHERE id=?")
|
|
16
|
+
.get(work.workId);
|
|
17
|
+
const catalogue = savedCatalogue(row?.recipe_context);
|
|
18
|
+
for (const target of targets) {
|
|
19
|
+
const view = getRevisionView(deps, work.projectId, z.string().parse(target.revision_id));
|
|
20
|
+
if (view === undefined)
|
|
21
|
+
continue;
|
|
22
|
+
const plan = executionPlan(deps, view, catalogue);
|
|
23
|
+
const recipe = plan.recipes.find((value) => value.key === piece.key && value.fingerprint === piece.fingerprint);
|
|
24
|
+
if (recipe === undefined || recipe.unresolved || recipe.deferred)
|
|
25
|
+
continue;
|
|
26
|
+
bindNarrationReuse(deps, view, recipe, narrationOrdinal(plan.recipes, recipe.key));
|
|
27
|
+
if (view.current)
|
|
28
|
+
projectSelected(deps.db, view.revision);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -83,12 +83,14 @@ export function bindNarrationReuse(deps, view, recipe, ordinal) {
|
|
|
83
83
|
}
|
|
84
84
|
if (recipe.input.kind !== "tts")
|
|
85
85
|
return;
|
|
86
|
+
const input = recipe.input;
|
|
86
87
|
if (view.pieces.some((one) => one.selected &&
|
|
87
88
|
one.available &&
|
|
88
89
|
one.key === recipe.key &&
|
|
89
90
|
one.fingerprint === recipe.fingerprint &&
|
|
90
91
|
one.piece.state === "done" &&
|
|
91
|
-
one.piece.idx === ordinal
|
|
92
|
+
one.piece.idx === ordinal &&
|
|
93
|
+
narrationMetadataMatches(one.piece.payload, input)))
|
|
92
94
|
return;
|
|
93
95
|
const history = [...view.pieces, ...retainedNarrationPieces(deps, view.revision.projectId)];
|
|
94
96
|
const available = new Set(history
|
|
@@ -97,7 +99,6 @@ export function bindNarrationReuse(deps, view, recipe, ordinal) {
|
|
|
97
99
|
const old = matchingNarrationPiece(recipe, history, available);
|
|
98
100
|
if (old === undefined)
|
|
99
101
|
return;
|
|
100
|
-
const input = recipe.input;
|
|
101
102
|
const recordId = deps.ids.next();
|
|
102
103
|
const payload = JSON.parse(old.piece.payload ?? "{}");
|
|
103
104
|
insertManifestPiece(deps.db, view.revision, {
|
|
@@ -125,6 +126,23 @@ export function bindNarrationReuse(deps, view, recipe, ordinal) {
|
|
|
125
126
|
}, recordId);
|
|
126
127
|
selectPieceRecord(deps.db, view.revision.id, recipe.key, recordId);
|
|
127
128
|
}
|
|
129
|
+
function narrationMetadataMatches(payload, input) {
|
|
130
|
+
const parsed = z
|
|
131
|
+
.object({
|
|
132
|
+
text: z.string().optional(),
|
|
133
|
+
spokenText: z.string().optional(),
|
|
134
|
+
logicalKey: z.string().optional(),
|
|
135
|
+
logicalText: z.string().optional(),
|
|
136
|
+
segment: z.string().optional(),
|
|
137
|
+
})
|
|
138
|
+
.safeParse(JSON.parse(payload ?? "{}"));
|
|
139
|
+
return (parsed.success &&
|
|
140
|
+
parsed.data.text === input.text &&
|
|
141
|
+
parsed.data.spokenText === input.spokenText &&
|
|
142
|
+
parsed.data.logicalKey === input.logicalKey &&
|
|
143
|
+
parsed.data.logicalText === input.logicalText &&
|
|
144
|
+
parsed.data.segment === input.segment);
|
|
145
|
+
}
|
|
128
146
|
export function narrationOrdinal(recipes, key) {
|
|
129
147
|
const entry = /^audio:(intro|outro):([0-9]+)$/.exec(key);
|
|
130
148
|
if (entry !== null)
|
|
@@ -24,14 +24,20 @@ export function savedCatalogue(value) {
|
|
|
24
24
|
}
|
|
25
25
|
export function executionPlan(deps, view, catalogue) {
|
|
26
26
|
const textOutput = (role) => {
|
|
27
|
-
const row = view.outputs.find((output) => output.selected &&
|
|
27
|
+
const row = view.outputs.find((output) => output.selected &&
|
|
28
|
+
output.available &&
|
|
29
|
+
output.state === "ready" &&
|
|
30
|
+
output.output.role === role);
|
|
28
31
|
return row === undefined
|
|
29
32
|
? null
|
|
30
33
|
: readFileSync(outputPath(deps.paths, view.revision.projectId, row.output.path), "utf8");
|
|
31
34
|
};
|
|
32
35
|
const payloads = view.pieces.filter((piece) => piece.selected && piece.piece.state === "done" && piece.piece.payload !== null);
|
|
33
36
|
const resolved = {
|
|
34
|
-
articleMarkdown: view.
|
|
37
|
+
articleMarkdown: view.revision.content.articleEdited === true ||
|
|
38
|
+
view.revision.config.sources.article === "provide"
|
|
39
|
+
? view.articleMarkdown
|
|
40
|
+
: textOutput("article_md"),
|
|
35
41
|
researchNotes: textOutput("notes"),
|
|
36
42
|
};
|
|
37
43
|
const research = matchingResearch({
|
|
@@ -4,6 +4,7 @@ import { publicationAuthority } from "../revisions/publication-rules.js";
|
|
|
4
4
|
import { commitRevisionOutputs } from "../revisions/publish.js";
|
|
5
5
|
import { insertAsset, insertManifestOutput, revisionById } from "../revisions/repo.js";
|
|
6
6
|
import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
|
|
7
|
+
import { rebindPublishedNarration } from "./runtime-narration-publication.js";
|
|
7
8
|
import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
|
|
8
9
|
export function preparedResult(deps, context, piece, role, asset, durationMs = null, meta = {}) {
|
|
9
10
|
return {
|
|
@@ -65,10 +66,14 @@ export async function publishResult(deps, context, piece, outputs, payload, asse
|
|
|
65
66
|
}),
|
|
66
67
|
},
|
|
67
68
|
};
|
|
68
|
-
const result =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
.
|
|
69
|
+
const result = transact(deps.db, () => {
|
|
70
|
+
const committed = commitRevisionOutputs(deps, { work: context.work, pieceId: piece.id, publicationId: piece.id }, outputs, [record]);
|
|
71
|
+
rebindPublishedNarration(deps, context.work, piece);
|
|
72
|
+
deps.db
|
|
73
|
+
.prepare("UPDATE revision_work_pieces SET state='done' WHERE id=? AND work_id=?")
|
|
74
|
+
.run(piece.id, context.work.workId);
|
|
75
|
+
return committed;
|
|
76
|
+
});
|
|
72
77
|
if (result.currentAttached)
|
|
73
78
|
context.emit({ type: "project.updated", projectId: context.work.projectId });
|
|
74
79
|
}
|
|
@@ -3,7 +3,7 @@ import { requestHash } from "../revisions/mutation-request.js";
|
|
|
3
3
|
import { currentRevisionId } from "../revisions/repo.js";
|
|
4
4
|
import { getRevisionView } from "../revisions/view.js";
|
|
5
5
|
import { previewRebuildSchema, startRebuildSchema, } from "./model.js";
|
|
6
|
-
import { executionSnapshotSchema, planPreview, reviewStillCovers } from "./preview-plan.js";
|
|
6
|
+
import { executionSnapshotSchema, planPreview, reviewStillCovers, } from "./preview-plan.js";
|
|
7
7
|
import { admissionReceipt, admitPreview, previewById, storePreview } from "./repo.js";
|
|
8
8
|
import { checkReadiness, localReadiness } from "./service-readiness.js";
|
|
9
9
|
export function rebuildConfirmations(required, confirmed, unknownCosts, acknowledged) {
|
|
@@ -60,7 +60,7 @@ export async function startRebuild(deps, input) {
|
|
|
60
60
|
const replay = admissionReceipt(deps.db, projectId, input.idempotencyKey, hash);
|
|
61
61
|
if (replay !== undefined) {
|
|
62
62
|
if (replay.ok)
|
|
63
|
-
|
|
63
|
+
wakeRebuild(deps, projectId, replay.value);
|
|
64
64
|
return replay;
|
|
65
65
|
}
|
|
66
66
|
const preview = previewById(deps.db, projectId, input.previewId);
|
|
@@ -100,34 +100,56 @@ export async function startRebuild(deps, input) {
|
|
|
100
100
|
const again = admissionReceipt(deps.db, projectId, input.idempotencyKey, hash);
|
|
101
101
|
if (again !== undefined) {
|
|
102
102
|
if (again.ok)
|
|
103
|
-
|
|
103
|
+
wakeRebuild(deps, projectId, again.value);
|
|
104
104
|
return again;
|
|
105
105
|
}
|
|
106
|
-
|
|
107
|
-
return { ok: false, reason: "conflict" };
|
|
108
|
-
const current = getRevisionView(deps, projectId, input.baseRevisionId);
|
|
109
|
-
if (current === undefined)
|
|
110
|
-
return { ok: false, reason: "no-project" };
|
|
111
|
-
const catalogue = deps.catalogue.read();
|
|
112
|
-
const fresh = planPreview(deps, current, catalogue, preview.selection, preview.id);
|
|
113
|
-
if (!fresh.ok || !reviewStillCovers(preview, snapshot, fresh.value))
|
|
114
|
-
return { ok: false, reason: "stale-preview" };
|
|
115
|
-
const fields = localReadiness(deps, snapshot, current, ready.providers, catalogue);
|
|
116
|
-
if (fields.length > 0)
|
|
117
|
-
return { ok: false, reason: "readiness", fields };
|
|
118
|
-
const admitted = admitPreview(deps, {
|
|
119
|
-
preview,
|
|
120
|
-
idempotencyKey: input.idempotencyKey,
|
|
121
|
-
requestHash: hash,
|
|
122
|
-
});
|
|
106
|
+
const admitted = admitCheckedPreview(deps, preview, snapshot, ready.providers, input.idempotencyKey, hash);
|
|
123
107
|
if (admitted.ok) {
|
|
124
108
|
deps.emit(projectId, { type: "project.updated", projectId });
|
|
125
|
-
|
|
109
|
+
wakeRebuild(deps, projectId, admitted.value);
|
|
126
110
|
}
|
|
127
111
|
return admitted;
|
|
128
112
|
});
|
|
129
113
|
}
|
|
130
|
-
function
|
|
114
|
+
export function admitCheckedPreview(deps, preview, snapshot, providers, idempotencyKey, hash) {
|
|
115
|
+
const replay = admissionReceipt(deps.db, preview.projectId, idempotencyKey, hash);
|
|
116
|
+
if (replay !== undefined)
|
|
117
|
+
return replay;
|
|
118
|
+
if (currentRevisionId(deps.db, preview.projectId) !== preview.baseRevisionId)
|
|
119
|
+
return { ok: false, reason: "conflict" };
|
|
120
|
+
const current = getRevisionView(deps, preview.projectId, preview.baseRevisionId);
|
|
121
|
+
if (current === undefined)
|
|
122
|
+
return { ok: false, reason: "no-project" };
|
|
123
|
+
const catalogue = deps.catalogue.read();
|
|
124
|
+
const fresh = planPreview(deps, current, catalogue, preview.selection, preview.id);
|
|
125
|
+
if (!fresh.ok || !reviewStillCovers(preview, snapshot, fresh.value))
|
|
126
|
+
return { ok: false, reason: "stale-preview" };
|
|
127
|
+
const fields = localReadiness(deps, snapshot, current, providers, catalogue);
|
|
128
|
+
if (fields.length > 0)
|
|
129
|
+
return { ok: false, reason: "readiness", fields };
|
|
130
|
+
for (const recipe of snapshot.recipes) {
|
|
131
|
+
const running = deps.db
|
|
132
|
+
.prepare("SELECT r.fingerprint FROM revision_work_reservations r " +
|
|
133
|
+
"JOIN revision_work w ON w.id=r.work_id WHERE r.revision_id=? " +
|
|
134
|
+
"AND r.work_key=? AND w.state='running'")
|
|
135
|
+
.get(preview.baseRevisionId, recipe.key);
|
|
136
|
+
if (running !== undefined && running.fingerprint !== recipe.fingerprint)
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
reason: "conflict",
|
|
140
|
+
fields: [
|
|
141
|
+
{ field: recipe.key, message: "Wait for this work to finish, or Pause before retrying." },
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return admitPreview(deps, {
|
|
146
|
+
preview,
|
|
147
|
+
planningCatalogue: catalogue,
|
|
148
|
+
idempotencyKey,
|
|
149
|
+
requestHash: hash,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export function wakeRebuild(deps, projectId, admission) {
|
|
131
153
|
const owned = deps.db
|
|
132
154
|
.prepare("SELECT 1 FROM revision_work WHERE project_id=? AND admission_id=? AND state!='done' AND dispatch_state='allowed' LIMIT 1")
|
|
133
155
|
.get(projectId, admission.admissionId) !== undefined;
|
|
@@ -96,7 +96,7 @@ function stageForKey(key) {
|
|
|
96
96
|
return "research";
|
|
97
97
|
if (prefix === "article" || prefix === "entry")
|
|
98
98
|
return "article";
|
|
99
|
-
if (prefix === "audio")
|
|
99
|
+
if (prefix === "audio" || prefix === "narration")
|
|
100
100
|
return "audio";
|
|
101
101
|
if (prefix === "image")
|
|
102
102
|
return "images";
|
|
@@ -89,7 +89,12 @@ export function validateAssetReferences(deps, projectId, content, prepared) {
|
|
|
89
89
|
(!allowPiece ||
|
|
90
90
|
deps.db
|
|
91
91
|
.prepare("SELECT 1 FROM revision_pieces WHERE project_id=? AND asset_id=? AND stage_kind=? AND json_extract(descriptor,'$.kind') IN (SELECT value FROM json_each(?))")
|
|
92
|
-
.get(projectId, id, kind, JSON.stringify(kind === "audio" ? ["chunk", "segment"] : ["image"])) === undefined)
|
|
92
|
+
.get(projectId, id, kind, JSON.stringify(kind === "audio" ? ["chunk", "segment"] : ["image"])) === undefined) &&
|
|
93
|
+
!(kind === "audio" &&
|
|
94
|
+
allowPiece &&
|
|
95
|
+
deps.db
|
|
96
|
+
.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
|
+
.get(projectId, id) !== undefined))
|
|
93
98
|
fields.push({ field, message: "Choose an asset for this content stage." });
|
|
94
99
|
}
|
|
95
100
|
return fields;
|
|
@@ -39,6 +39,10 @@ export function checkMutation(deps, input) {
|
|
|
39
39
|
.prepare("SELECT 1 FROM project_control_receipts WHERE project_id=? AND idempotency_key=?")
|
|
40
40
|
.get(input.projectId, input.idempotencyKey) !== undefined)
|
|
41
41
|
return refusal(deps, input.projectId, "idempotency-conflict");
|
|
42
|
+
if (db
|
|
43
|
+
.prepare("SELECT 1 FROM project_recovery_requests WHERE project_id=? AND idempotency_key=?")
|
|
44
|
+
.get(input.projectId, input.idempotencyKey) !== undefined)
|
|
45
|
+
return refusal(deps, input.projectId, "idempotency-conflict");
|
|
42
46
|
if (db.prepare("SELECT 1 FROM projects WHERE id=?").get(input.projectId) === undefined)
|
|
43
47
|
return refusal(deps, input.projectId, "no-project");
|
|
44
48
|
if (currentRevisionId(db, input.projectId) !== input.baseRevisionId)
|
|
@@ -16,7 +16,7 @@ import { checkMutation, insertReceipt, requestHash, requiredView } from "./mutat
|
|
|
16
16
|
import { preserveDeferredIntent } from "./mutation-work.js";
|
|
17
17
|
import { cloneManifest, ensureRevisionStages, projectSelected } from "./projection.js";
|
|
18
18
|
import { insertAsset, insertRevision } from "./repo.js";
|
|
19
|
-
import { validateNarrationIntent, validateTemplateIntent } from "./rules.js";
|
|
19
|
+
import { bindNarrationSources, validateNarrationIntent, validateTemplateIntent } from "./rules.js";
|
|
20
20
|
import { revisionEditSchema } from "./schema.js";
|
|
21
21
|
export { bindUpload } from "./mutation-assets.js";
|
|
22
22
|
export { restoreRevision } from "./restore.js";
|
|
@@ -32,16 +32,18 @@ export async function saveRevision(deps, input) {
|
|
|
32
32
|
message: row.message,
|
|
33
33
|
})),
|
|
34
34
|
};
|
|
35
|
-
const
|
|
35
|
+
const { narrationSources: _submitted, ...content } = parsed.data.content;
|
|
36
|
+
const submitted = { ...parsed.data, content };
|
|
36
37
|
const identity = {
|
|
37
38
|
...input,
|
|
38
39
|
operation: "save",
|
|
39
|
-
hash: requestHash("save", input.baseRevisionId,
|
|
40
|
+
hash: requestHash("save", input.baseRevisionId, submitted),
|
|
40
41
|
};
|
|
41
42
|
const checked = checkMutation(deps, identity);
|
|
42
43
|
if (checked !== undefined)
|
|
43
44
|
return checked;
|
|
44
45
|
const base = requiredView(deps, input.projectId, input.baseRevisionId);
|
|
46
|
+
const edit = bindNarrationSources(base, submitted);
|
|
45
47
|
const fields = [
|
|
46
48
|
...validateUploads(deps, edit),
|
|
47
49
|
...validateTemplateIntent(base, edit),
|
|
@@ -65,14 +67,17 @@ export async function saveRevision(deps, input) {
|
|
|
65
67
|
const checked = checkMutation(deps, identity);
|
|
66
68
|
if (checked !== undefined)
|
|
67
69
|
return checked;
|
|
70
|
+
const blocked = input.beforeCommit?.();
|
|
71
|
+
if (blocked !== undefined)
|
|
72
|
+
return blocked;
|
|
68
73
|
const fresh = requiredView(deps, input.projectId, input.baseRevisionId);
|
|
69
74
|
const tokens = { ...fresh.revision.content.regenerationTokens };
|
|
70
75
|
for (const key of new Set((edit.regenerate ?? []).map(narrationRegenerationKey)))
|
|
71
76
|
tokens[key] = deps.ids.next();
|
|
72
|
-
const finalEdit = {
|
|
77
|
+
const finalEdit = bindNarrationSources(fresh, {
|
|
73
78
|
...supplied,
|
|
74
79
|
content: { ...supplied.content, regenerationTokens: tokens },
|
|
75
|
-
};
|
|
80
|
+
});
|
|
76
81
|
const outputAssets = prepared.filter((row) => row.upload?.destination.kind !== "narration");
|
|
77
82
|
const inspected = measuredOutputs(deps, fresh, durations);
|
|
78
83
|
const preparedManifest = {
|
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { narrationRegenerationKey } from "../narration/plan.js";
|
|
3
|
+
import { bodyNarrationGroups } from "../rebuild/recipe-audio.js";
|
|
2
4
|
import { buildRecipes } from "../rebuild/recipe-build.js";
|
|
3
5
|
import { normalizeArticleIntent, validateRevisionEdit } from "../rebuild/recipe-save.js";
|
|
6
|
+
import { textRecipes } from "../rebuild/recipe-text.js";
|
|
7
|
+
export function bindNarrationSources(base, edit) {
|
|
8
|
+
const { narrationSources: _submitted, ...content } = normalizeArticleIntent(base, edit);
|
|
9
|
+
const keys = new Set([
|
|
10
|
+
...Object.keys(content.narrationOverrides),
|
|
11
|
+
...Object.keys(base.revision.content.regenerationTokens),
|
|
12
|
+
...(edit.regenerate ?? []).map(narrationRegenerationKey),
|
|
13
|
+
...(edit.uploads ?? []).flatMap((upload) => upload.destination.kind === "narration" ? [upload.destination.key] : []),
|
|
14
|
+
]);
|
|
15
|
+
if (keys.size === 0)
|
|
16
|
+
return { ...edit, content };
|
|
17
|
+
const previous = {
|
|
18
|
+
config: base.revision.config,
|
|
19
|
+
content: base.revision.content,
|
|
20
|
+
manifest: base,
|
|
21
|
+
resolved: {
|
|
22
|
+
articleMarkdown: base.articleMarkdown,
|
|
23
|
+
researchNotes: base.revision.config.provided.research ?? null,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const sources = {
|
|
27
|
+
...base.revision.content.narrationSources,
|
|
28
|
+
...Object.fromEntries(bodyNarrationGroups(previous, textRecipes(previous)).flatMap((group) => group.source === undefined ? [] : [[group.key, group.source]])),
|
|
29
|
+
};
|
|
30
|
+
const proposed = {
|
|
31
|
+
...previous,
|
|
32
|
+
config: edit.config,
|
|
33
|
+
content: {
|
|
34
|
+
...content,
|
|
35
|
+
narrationSources: Object.fromEntries(Object.entries(sources).filter(([key]) => keys.has(key))),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const bindings = Object.fromEntries(bodyNarrationGroups(proposed, textRecipes(proposed)).flatMap((group) => group.source !== undefined && keys.has(group.key) ? [[group.key, group.source]] : []));
|
|
39
|
+
return {
|
|
40
|
+
...edit,
|
|
41
|
+
content: {
|
|
42
|
+
...content,
|
|
43
|
+
...(Object.keys(bindings).length > 0 ? { narrationSources: bindings } : {}),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
4
47
|
export function validateCues(cues, duration) {
|
|
5
48
|
const fields = [];
|
|
6
49
|
const seen = new Set();
|
|
@@ -47,6 +47,18 @@ export const revisionContentSchema = z
|
|
|
47
47
|
z.object({ kind: z.literal("asset"), assetId: id }).strict(),
|
|
48
48
|
z.object({ kind: z.literal("text"), text: z.string().trim().min(1).max(500000) }).strict(),
|
|
49
49
|
])),
|
|
50
|
+
narrationSources: z
|
|
51
|
+
.record(workKey, z
|
|
52
|
+
.object({
|
|
53
|
+
text: z.string().min(1).max(500000),
|
|
54
|
+
start: z.number().int().nonnegative().max(500000),
|
|
55
|
+
bodyFingerprint: z.string().regex(/^[0-9a-f]{64}$/u),
|
|
56
|
+
chunkingFingerprint: z.string().regex(/^[0-9a-f]{64}$/u),
|
|
57
|
+
})
|
|
58
|
+
.strict())
|
|
59
|
+
.refine((rows) => Object.keys(rows).length <= 10000 &&
|
|
60
|
+
Object.values(rows).reduce((length, row) => length + row.text.length, 0) <= 500000, "Narration source bindings exceed the article limit.")
|
|
61
|
+
.optional(),
|
|
50
62
|
subtitleCues: z
|
|
51
63
|
.object({
|
|
52
64
|
audioFingerprint: z.string().min(1),
|