@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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { stageKinds, stageStates } from "../../kernel/pipeline.js";
|
|
3
|
+
import { currentRevisionId } from "../revisions/repo.js";
|
|
4
|
+
import { getRevisionView } from "../revisions/view.js";
|
|
5
|
+
import { settleScheduleRunsForProject } from "../schedules/repo.js";
|
|
6
|
+
import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
|
|
7
|
+
import { workPieces } from "./work-records.js";
|
|
8
|
+
const workRow = z.object({
|
|
9
|
+
id: z.string(),
|
|
10
|
+
project_id: z.string(),
|
|
11
|
+
revision_id: z.string(),
|
|
12
|
+
stage_id: z.string(),
|
|
13
|
+
kind: z.enum(stageKinds),
|
|
14
|
+
state: z.enum(stageStates),
|
|
15
|
+
fingerprint: z.string(),
|
|
16
|
+
});
|
|
17
|
+
export function executionStages(deps, projectId) {
|
|
18
|
+
const head = currentRevisionId(deps.db, projectId);
|
|
19
|
+
if (head === undefined)
|
|
20
|
+
return [];
|
|
21
|
+
const view = getRevisionView(deps, projectId, head);
|
|
22
|
+
const rows = deps.db
|
|
23
|
+
.prepare(`SELECT DISTINCT w.* FROM revision_work w JOIN revision_work_reservations r ON r.work_id=w.id WHERE r.project_id=? AND r.revision_id=?`)
|
|
24
|
+
.all(projectId, head);
|
|
25
|
+
const keys = new Set(deps.db
|
|
26
|
+
.prepare("SELECT work_key FROM revision_work_reservations WHERE revision_id=?")
|
|
27
|
+
.all(head)
|
|
28
|
+
.map((row) => String(row.work_key)));
|
|
29
|
+
return rows
|
|
30
|
+
.filter((row) => workPieces(deps.db, String(row.id)).some((piece) => {
|
|
31
|
+
if (!keys.has(piece.key))
|
|
32
|
+
return false;
|
|
33
|
+
if (piece.key === "subtitles:timing" && view?.revision.content.subtitleCues !== undefined)
|
|
34
|
+
return false;
|
|
35
|
+
if (piece.input.kind === "deferred" && piece.key.endsWith(":future")) {
|
|
36
|
+
const prefix = piece.key.slice(0, -"future".length);
|
|
37
|
+
return !rows.some((candidate) => workPieces(deps.db, String(candidate.id)).some((part) => part.input.kind === "tts" && part.key.startsWith(prefix)));
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}))
|
|
41
|
+
.map((value) => {
|
|
42
|
+
const row = workRow.parse(value);
|
|
43
|
+
return {
|
|
44
|
+
id: row.stage_id,
|
|
45
|
+
projectId,
|
|
46
|
+
kind: row.kind,
|
|
47
|
+
state: row.state,
|
|
48
|
+
work: {
|
|
49
|
+
projectId,
|
|
50
|
+
revisionId: row.revision_id,
|
|
51
|
+
workId: row.id,
|
|
52
|
+
stageId: row.stage_id,
|
|
53
|
+
kind: row.kind,
|
|
54
|
+
fingerprint: row.fingerprint,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export function invocationReady(deps, work) {
|
|
60
|
+
const row = deps.db
|
|
61
|
+
.prepare("SELECT recipe_context FROM revision_work WHERE id=? AND project_id=? AND revision_id=?")
|
|
62
|
+
.get(work.workId, work.projectId, work.revisionId);
|
|
63
|
+
if (row?.recipe_context === null || row === undefined)
|
|
64
|
+
return false;
|
|
65
|
+
const view = executionView(deps, work.projectId, work.revisionId);
|
|
66
|
+
if (view === undefined)
|
|
67
|
+
return false;
|
|
68
|
+
const plan = executionPlan(deps, view, savedCatalogue(row.recipe_context));
|
|
69
|
+
const pieces = workPieces(deps.db, work.workId);
|
|
70
|
+
return pieces.some((piece) => {
|
|
71
|
+
if (piece.state === "done" ||
|
|
72
|
+
piece.dispatchState !== "allowed" ||
|
|
73
|
+
piece.input.kind === "deferred")
|
|
74
|
+
return false;
|
|
75
|
+
if (piece.continuation !== null)
|
|
76
|
+
return true;
|
|
77
|
+
const recipe = plan.recipes.find((value) => value.key === piece.key && value.fingerprint === piece.fingerprint);
|
|
78
|
+
if (recipe === undefined || recipe.unresolved || recipe.deferred)
|
|
79
|
+
return false;
|
|
80
|
+
return recipe.dependsOn.every((key) => {
|
|
81
|
+
const dependency = plan.work.find((value) => value.key === key);
|
|
82
|
+
return dependency?.disposition === "reuse" && !dependency.inflight;
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
export function executionStandings(deps, projectId) {
|
|
87
|
+
const revisionId = currentRevisionId(deps.db, projectId);
|
|
88
|
+
const view = revisionId === undefined ? undefined : getRevisionView(deps, projectId, revisionId);
|
|
89
|
+
if (view === undefined)
|
|
90
|
+
return [];
|
|
91
|
+
const work = executionStages(deps, projectId);
|
|
92
|
+
return stageKinds.map((kind) => {
|
|
93
|
+
const source = view.revision.config.sources[kind];
|
|
94
|
+
const group = work
|
|
95
|
+
.filter((entry) => entry.kind === kind)
|
|
96
|
+
.map((entry) => {
|
|
97
|
+
const pieces = workPieces(deps.db, entry.work.workId);
|
|
98
|
+
const retained = pieces.length > 0 &&
|
|
99
|
+
pieces.every((piece) => view.outputs.some((output) => output.workKey === piece.key &&
|
|
100
|
+
output.selected &&
|
|
101
|
+
output.available &&
|
|
102
|
+
output.state === "ready") ||
|
|
103
|
+
view.pieces.some((output) => output.key === piece.key &&
|
|
104
|
+
output.selected &&
|
|
105
|
+
output.available &&
|
|
106
|
+
output.piece.state === "done" &&
|
|
107
|
+
output.fingerprint === piece.fingerprint));
|
|
108
|
+
// Save can bind an existing asset without granting its placeholder invocation.
|
|
109
|
+
return retained ? { ...entry, state: "done" } : entry;
|
|
110
|
+
});
|
|
111
|
+
let state = source === "off" ? "skipped" : source === "provide" ? "provided" : "pending";
|
|
112
|
+
if (group.some((entry) => entry.state === "running"))
|
|
113
|
+
state = "running";
|
|
114
|
+
else if (group.some((entry) => entry.state === "failed"))
|
|
115
|
+
state = "failed";
|
|
116
|
+
else if (group.some((entry) => entry.state === "canceled"))
|
|
117
|
+
state = "canceled";
|
|
118
|
+
else if (group.length > 0)
|
|
119
|
+
state = group.every((entry) => entry.state === "done")
|
|
120
|
+
? source === "provide"
|
|
121
|
+
? "provided"
|
|
122
|
+
: "done"
|
|
123
|
+
: "pending";
|
|
124
|
+
return { kind, state };
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
export function projectStandings(deps, projectId) {
|
|
128
|
+
const standings = executionStandings(deps, projectId);
|
|
129
|
+
for (const stage of standings) {
|
|
130
|
+
const invocations = executionStages(deps, projectId).filter((work) => work.kind === stage.kind);
|
|
131
|
+
const current = invocations.reduce((total, work) => {
|
|
132
|
+
if (work.state === "done")
|
|
133
|
+
return total + 1;
|
|
134
|
+
const progress = deps.db
|
|
135
|
+
.prepare("SELECT progress_current,progress_total FROM revision_work WHERE id=?")
|
|
136
|
+
.get(work.work.workId);
|
|
137
|
+
if (typeof progress?.progress_current !== "number" ||
|
|
138
|
+
typeof progress.progress_total !== "number" ||
|
|
139
|
+
progress.progress_total <= 0)
|
|
140
|
+
return total;
|
|
141
|
+
return total + Math.max(0, Math.min(1, progress.progress_current / progress.progress_total));
|
|
142
|
+
}, 0);
|
|
143
|
+
const failed = invocations.find((work) => work.state === "failed");
|
|
144
|
+
const failure = failed === undefined
|
|
145
|
+
? null
|
|
146
|
+
: (deps.db
|
|
147
|
+
.prepare("SELECT failure_reason FROM revision_work WHERE id=?")
|
|
148
|
+
.get(failed.work.workId)?.failure_reason ?? null);
|
|
149
|
+
deps.db
|
|
150
|
+
.prepare("UPDATE stages SET state=?,failure_reason=?,progress_current=?,progress_total=?,finished_at=CASE WHEN ?='done' THEN ? ELSE NULL END WHERE project_id=? AND kind=?")
|
|
151
|
+
.run(stage.state, failure, invocations.length === 0 ? null : current, invocations.length === 0 ? null : invocations.length, stage.state, deps.clock.now().toISOString(), projectId, stage.kind);
|
|
152
|
+
}
|
|
153
|
+
settleScheduleRunsForProject(deps.db, projectId, deps.clock.now().toISOString());
|
|
154
|
+
}
|
|
155
|
+
export function recordWorkProgress(deps, event) {
|
|
156
|
+
if (event.workId === undefined ||
|
|
157
|
+
event.revisionId === undefined ||
|
|
158
|
+
!Number.isFinite(event.current) ||
|
|
159
|
+
!Number.isFinite(event.total) ||
|
|
160
|
+
event.total <= 0)
|
|
161
|
+
return;
|
|
162
|
+
deps.db
|
|
163
|
+
.prepare("UPDATE revision_work SET progress_current=?,progress_total=? WHERE id=? AND revision_id=? AND project_id=? AND kind=? AND state='running'")
|
|
164
|
+
.run(Math.max(0, Math.min(event.current, event.total)), event.total, event.workId, event.revisionId, event.projectId, event.stage);
|
|
165
|
+
projectStandings(deps, event.projectId);
|
|
166
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { resolveFont } from "../fonts/index.js";
|
|
5
|
+
import { validateCues } from "../revisions/rules.js";
|
|
6
|
+
import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
|
|
7
|
+
import { outputPath } from "../storage/layout.js";
|
|
8
|
+
import { captionCues, serializeAss, serializeSrt, serializeVtt } from "../subtitles/captions.js";
|
|
9
|
+
import { exportSnapshot, retainedOutput, revisionAudio, revisionTranscript, } from "./runtime-export-inputs.js";
|
|
10
|
+
import { preparedResult, preparedText, publishResult } from "./runtime-publication.js";
|
|
11
|
+
const wordsSchema = z.object({
|
|
12
|
+
key: z.string(),
|
|
13
|
+
words: z.array(z.object({
|
|
14
|
+
text: z.string(),
|
|
15
|
+
start: z.number().finite().nonnegative(),
|
|
16
|
+
end: z.number().finite().positive(),
|
|
17
|
+
confidence: z.number().optional(),
|
|
18
|
+
})),
|
|
19
|
+
omissions: z
|
|
20
|
+
.array(z.object({ start: z.number().finite().nonnegative(), text: z.string() }))
|
|
21
|
+
.default([]),
|
|
22
|
+
});
|
|
23
|
+
const cuesSchema = z.object({
|
|
24
|
+
cues: z.array(z.object({
|
|
25
|
+
text: z.string(),
|
|
26
|
+
start: z.number().finite().nonnegative(),
|
|
27
|
+
end: z.number().finite().positive(),
|
|
28
|
+
})),
|
|
29
|
+
omissions: wordsSchema.shape.omissions,
|
|
30
|
+
});
|
|
31
|
+
export async function executeSubtitleRecipe(deps, context, piece) {
|
|
32
|
+
if (!context.maySubmit(piece.id))
|
|
33
|
+
return "held";
|
|
34
|
+
context.signal.throwIfAborted();
|
|
35
|
+
const snapshot = exportSnapshot(deps, context, piece);
|
|
36
|
+
if (piece.key === "subtitles:timing")
|
|
37
|
+
return timing(deps, context, piece, snapshot);
|
|
38
|
+
if (piece.key === "subtitles:cues")
|
|
39
|
+
return cues(deps, context, piece, snapshot);
|
|
40
|
+
if (piece.key === "subtitles:files")
|
|
41
|
+
return files(deps, context, piece, snapshot);
|
|
42
|
+
throw new Error("Unknown subtitle recipe.");
|
|
43
|
+
}
|
|
44
|
+
async function timing(deps, context, piece, snapshot) {
|
|
45
|
+
const audio = await revisionAudio(deps, context, snapshot.view);
|
|
46
|
+
if (audio.length === 0)
|
|
47
|
+
throw new Error("Subtitles need narration audio.");
|
|
48
|
+
if (deps.alignSubtitles === undefined)
|
|
49
|
+
throw new Error("Local subtitle alignment is unavailable in this build.");
|
|
50
|
+
if (!context.maySubmit(piece.id))
|
|
51
|
+
return "held";
|
|
52
|
+
const words = [];
|
|
53
|
+
const omissions = [];
|
|
54
|
+
let offset = 0;
|
|
55
|
+
const total = audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
56
|
+
for (const segment of audio) {
|
|
57
|
+
context.signal.throwIfAborted();
|
|
58
|
+
if (segment.path !== null && segment.kind !== "gap") {
|
|
59
|
+
const aligned = await deps.alignSubtitles({
|
|
60
|
+
audioPath: segment.path,
|
|
61
|
+
text: revisionTranscript(deps, snapshot, segment.kind),
|
|
62
|
+
cacheDir: join(deps.paths.dataDir, "models", "english-subtitles"),
|
|
63
|
+
ffmpeg: deps.ffmpeg,
|
|
64
|
+
signal: context.signal,
|
|
65
|
+
onOmission: (value) => omissions.push({ ...value, start: value.start + offset }),
|
|
66
|
+
onProgress: (current, maximum) => context.emit({
|
|
67
|
+
type: "stage.progress",
|
|
68
|
+
projectId: context.work.projectId,
|
|
69
|
+
stage: "video",
|
|
70
|
+
current: Math.round((100 * (offset + segment.seconds * (maximum > 0 ? current / maximum : 0))) / total),
|
|
71
|
+
total: 100,
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
for (const word of aligned) {
|
|
75
|
+
if (word.end > segment.seconds + 0.1)
|
|
76
|
+
throw new Error("Subtitle timing exceeds the narration duration.");
|
|
77
|
+
words.push({
|
|
78
|
+
...word,
|
|
79
|
+
start: word.start + offset,
|
|
80
|
+
end: Math.min(word.end, segment.seconds) + offset,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
offset += segment.seconds;
|
|
85
|
+
}
|
|
86
|
+
if (captionCues(words).length === 0)
|
|
87
|
+
throw new Error("No spoken words could be aligned. Check that the English article matches the audio.");
|
|
88
|
+
context.signal.throwIfAborted();
|
|
89
|
+
const output = preparedText(deps, context, piece, "subtitle_words", "subtitles.json", JSON.stringify({ key: piece.logicalFingerprint ?? piece.fingerprint, words, omissions }));
|
|
90
|
+
await publishResult(deps, context, piece, [output], { words, omissions }, output.asset);
|
|
91
|
+
return "done";
|
|
92
|
+
}
|
|
93
|
+
async function cues(deps, context, piece, snapshot) {
|
|
94
|
+
const { view, plan } = snapshot;
|
|
95
|
+
const manual = view.revision.content.subtitleCues;
|
|
96
|
+
let value;
|
|
97
|
+
if (manual !== undefined) {
|
|
98
|
+
if (plan.work.find((one) => one.key === piece.key)?.disposition === "review")
|
|
99
|
+
throw new Error("Review the saved captions against the current narration before rebuilding.");
|
|
100
|
+
const audio = await revisionAudio(deps, context, view);
|
|
101
|
+
const errors = validateCues(manual.cues, audio.reduce((sum, segment) => sum + segment.seconds, 0));
|
|
102
|
+
if (errors.length > 0)
|
|
103
|
+
throw new Error(errors.map((error) => error.message).join(" "));
|
|
104
|
+
value = { cues: [...manual.cues], omissions: [] };
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const output = view.outputs.find((one) => one.selected &&
|
|
108
|
+
one.available &&
|
|
109
|
+
one.state === "ready" &&
|
|
110
|
+
one.workKey === "subtitles:timing");
|
|
111
|
+
if (output === undefined)
|
|
112
|
+
throw new Error("The revision has no aligned subtitle timing.");
|
|
113
|
+
const timed = wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, context.work.projectId, output.output.path), "utf8")));
|
|
114
|
+
value = { cues: [...captionCues(timed.words)], omissions: timed.omissions };
|
|
115
|
+
}
|
|
116
|
+
if (!context.maySubmit(piece.id))
|
|
117
|
+
return "held";
|
|
118
|
+
const asset = writeAsset(deps, context.work.projectId, "cues.json", Buffer.from(JSON.stringify(value)));
|
|
119
|
+
await publishResult(deps, context, piece, [], value, asset);
|
|
120
|
+
return "done";
|
|
121
|
+
}
|
|
122
|
+
async function files(deps, context, piece, snapshot) {
|
|
123
|
+
const { view } = snapshot;
|
|
124
|
+
const config = view.revision.config.subtitles;
|
|
125
|
+
if (config === undefined || config.mode === "off")
|
|
126
|
+
throw new Error("The admitted revision has no subtitles.");
|
|
127
|
+
const row = view.pieces.find((one) => one.key === "subtitles:cues" && one.selected && one.available && one.piece.state === "done");
|
|
128
|
+
if (row?.piece.payload === null || row?.piece.payload === undefined)
|
|
129
|
+
throw new Error("The revision has no prepared captions.");
|
|
130
|
+
const value = cuesSchema.parse(JSON.parse(row.piece.payload));
|
|
131
|
+
const previous = view.outputs.find((one) => one.output.role === "subtitle_font" && one.selected && one.available);
|
|
132
|
+
const previousFiles = view.pieces.find((one) => one.key === "subtitles:files" &&
|
|
133
|
+
one.piece.state === "done" &&
|
|
134
|
+
(one.selected ||
|
|
135
|
+
(one.publicationId !== null && one.publicationId === previous?.publicationId)));
|
|
136
|
+
const savedFont = z
|
|
137
|
+
.object({
|
|
138
|
+
font: z
|
|
139
|
+
.object({
|
|
140
|
+
id: z.string(),
|
|
141
|
+
name: z.string(),
|
|
142
|
+
assName: z.string(),
|
|
143
|
+
extension: z.enum([".ttf", ".otf", ".ttc"]),
|
|
144
|
+
})
|
|
145
|
+
.optional(),
|
|
146
|
+
})
|
|
147
|
+
.parse(JSON.parse(previousFiles?.piece.payload ?? "{}")).font;
|
|
148
|
+
const font = savedFont?.id === config.fontId && previous !== undefined
|
|
149
|
+
? { ...savedFont, path: outputPath(deps.paths, context.work.projectId, previous.output.path) }
|
|
150
|
+
: await resolveFont(deps.paths, config.fontId);
|
|
151
|
+
if (!context.maySubmit(piece.id))
|
|
152
|
+
return "held";
|
|
153
|
+
const prepared = [];
|
|
154
|
+
try {
|
|
155
|
+
const frame = view.revision.config.format === "16:9"
|
|
156
|
+
? { width: 1920, height: 1080 }
|
|
157
|
+
: { width: 1080, height: 1920 };
|
|
158
|
+
for (const [role, filename, text] of [
|
|
159
|
+
["subtitles_srt", "subtitles.srt", serializeSrt(value.cues)],
|
|
160
|
+
["subtitles_vtt", "subtitles.vtt", serializeVtt(value.cues)],
|
|
161
|
+
[
|
|
162
|
+
"subtitle_ass",
|
|
163
|
+
"subtitles.ass",
|
|
164
|
+
serializeAss(value.cues, {
|
|
165
|
+
...frame,
|
|
166
|
+
fontName: font.assName,
|
|
167
|
+
fontSize: config.fontSize,
|
|
168
|
+
position: config.position,
|
|
169
|
+
}),
|
|
170
|
+
],
|
|
171
|
+
])
|
|
172
|
+
prepared.push(preparedText(deps, context, piece, role, filename, text));
|
|
173
|
+
prepared.push(preparedResult(deps, context, piece, "subtitle_font", writeAsset(deps, context.work.projectId, `selected${font.extension}`, readFileSync(font.path))));
|
|
174
|
+
const media = view.outputs.find((one) => one.selected &&
|
|
175
|
+
one.available &&
|
|
176
|
+
one.state === "ready" &&
|
|
177
|
+
(one.output.role === "audio_export" ||
|
|
178
|
+
(one.output.role === "video" && config.mode === "files")));
|
|
179
|
+
if (media !== undefined) {
|
|
180
|
+
const retained = retainedOutput(deps, view, media);
|
|
181
|
+
prepared.push({
|
|
182
|
+
...retained,
|
|
183
|
+
output: {
|
|
184
|
+
...retained.output,
|
|
185
|
+
meta: {
|
|
186
|
+
...retained.output.meta,
|
|
187
|
+
subtitlesMode: "files",
|
|
188
|
+
subtitleOmissions: value.omissions,
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
await publishResult(deps, context, piece, prepared, {
|
|
194
|
+
...value,
|
|
195
|
+
font: { id: font.id, name: font.name, assName: font.assName, extension: font.extension },
|
|
196
|
+
});
|
|
197
|
+
return "done";
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
discardPreparedAssets(deps, prepared.map((one) => one.asset));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readinessIsUsable } from "../../kernel/ports/model.js";
|
|
2
|
+
import { cliPathStatus } from "../settings/cli-paths.js";
|
|
3
|
+
import { hasKey, listVoices } from "../settings/repo.js";
|
|
4
|
+
import { requiresNewSubmission } from "./preview-retained.js";
|
|
5
|
+
import { recipeProviderChoice } from "./recipe-provider-choice.js";
|
|
6
|
+
export function paidChoices(snapshot, view, deps) {
|
|
7
|
+
const choices = [];
|
|
8
|
+
for (const recipe of snapshot.recipes) {
|
|
9
|
+
if (recipe.kind !== "provider" ||
|
|
10
|
+
snapshot.dispositions.find((row) => row.key === recipe.key)?.disposition === "reuse")
|
|
11
|
+
continue;
|
|
12
|
+
if (!requiresNewSubmission(deps, view.revision.id, recipe.key, recipe.fingerprint))
|
|
13
|
+
continue;
|
|
14
|
+
const choice = recipeProviderChoice(recipe, view.revision.config);
|
|
15
|
+
if (choice !== undefined)
|
|
16
|
+
choices.push(choice);
|
|
17
|
+
}
|
|
18
|
+
return [
|
|
19
|
+
...new Map(choices.map((choice) => [`${choice.family}:${choice.provider}:${choice.model}`, choice])).values(),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
export async function checkReadiness(deps, snapshot, view) {
|
|
23
|
+
const choices = paidChoices(snapshot, view, deps);
|
|
24
|
+
if (choices.length === 0)
|
|
25
|
+
return { fields: [], providers: [] };
|
|
26
|
+
const providers = await deps.providers();
|
|
27
|
+
const fields = [];
|
|
28
|
+
for (const choice of choices) {
|
|
29
|
+
const provider = providers.find((row) => row.id === choice.provider && row.family === choice.family);
|
|
30
|
+
if (provider === undefined || !readinessIsUsable(provider.readiness)) {
|
|
31
|
+
fields.push({
|
|
32
|
+
field: choice.family,
|
|
33
|
+
message: provider?.readiness.kind === "cli" && provider.readiness.issue
|
|
34
|
+
? provider.readiness.issue
|
|
35
|
+
: "Configure this provider before rebuilding.",
|
|
36
|
+
});
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const models = await deps.modelsFor(choice.provider, choice.family);
|
|
40
|
+
if (!models.some((row) => row.id === choice.model))
|
|
41
|
+
fields.push({
|
|
42
|
+
field: `${choice.family}.model`,
|
|
43
|
+
message: "Choose an available model before rebuilding.",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return { fields, providers };
|
|
47
|
+
}
|
|
48
|
+
export function localReadiness(deps, snapshot, view, providers, catalogue) {
|
|
49
|
+
const fields = [];
|
|
50
|
+
for (const choice of paidChoices(snapshot, view, deps)) {
|
|
51
|
+
const model = catalogue[choice.family].find((row) => row.provider === choice.provider &&
|
|
52
|
+
row.id === choice.model &&
|
|
53
|
+
row.enabled &&
|
|
54
|
+
!row.deprecated);
|
|
55
|
+
if (model === undefined)
|
|
56
|
+
fields.push({
|
|
57
|
+
field: `${choice.family}.model`,
|
|
58
|
+
message: "The selected model is no longer available.",
|
|
59
|
+
});
|
|
60
|
+
const provider = providers.find((row) => row.id === choice.provider && row.family === choice.family);
|
|
61
|
+
if (provider?.cliPath !== undefined &&
|
|
62
|
+
cliPathStatus(deps.db, provider.id).command !== provider.cliPath.command)
|
|
63
|
+
fields.push({
|
|
64
|
+
field: `${choice.family}.cliPath`,
|
|
65
|
+
message: "The configured CLI command changed. Check readiness again before rebuilding.",
|
|
66
|
+
});
|
|
67
|
+
if (provider?.readiness.kind === "keyed" && !hasKey(deps.db, provider.id))
|
|
68
|
+
fields.push({ field: choice.family, message: "Save the provider key before rebuilding." });
|
|
69
|
+
if (choice.voice !== undefined &&
|
|
70
|
+
!listVoices(deps.db).some((row) => row.provider === choice.provider && row.voiceId === choice.voice))
|
|
71
|
+
fields.push({ field: "audio.voice", message: "Choose a saved voice before rebuilding." });
|
|
72
|
+
}
|
|
73
|
+
for (const recipe of snapshot.recipes) {
|
|
74
|
+
if (snapshot.dispositions.find((row) => row.key === recipe.key)?.disposition === "reuse" ||
|
|
75
|
+
!requiresNewSubmission(deps, view.revision.id, recipe.key, recipe.fingerprint))
|
|
76
|
+
continue;
|
|
77
|
+
const input = recipe.input;
|
|
78
|
+
if (input.kind === "tts") {
|
|
79
|
+
const model = catalogue.tts.find((row) => row.provider === input.provider && row.id === input.model);
|
|
80
|
+
const maximum = model?.tts.maxCharacters;
|
|
81
|
+
if (maximum !== undefined && input.text.length > maximum)
|
|
82
|
+
fields.push({
|
|
83
|
+
field: `work.${recipe.key}.text`,
|
|
84
|
+
message: "This saved physical request exceeds the current model limit. Regenerate this narration group to plan new parts.",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
else if (input.kind === "llm") {
|
|
88
|
+
const model = catalogue.llm.find((row) => row.provider === input.provider && row.id === input.model);
|
|
89
|
+
if (input.thinking !== null && model?.llm.thinking?.[input.thinking] === undefined)
|
|
90
|
+
fields.push({
|
|
91
|
+
field: `work.${recipe.key}.thinking`,
|
|
92
|
+
message: "The selected thinking setting is no longer supported.",
|
|
93
|
+
});
|
|
94
|
+
if (input.webSearch && model?.llm.webSearch !== true)
|
|
95
|
+
fields.push({
|
|
96
|
+
field: `work.${recipe.key}.webSearch`,
|
|
97
|
+
message: "This model no longer supports the required web search.",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
else if (input.kind === "image") {
|
|
101
|
+
const model = catalogue.image.find((row) => row.provider === input.provider && row.id === input.model);
|
|
102
|
+
if (model !== undefined && !model.image.aspectRatios.includes(input.aspect))
|
|
103
|
+
fields.push({
|
|
104
|
+
field: `work.${recipe.key}.aspect`,
|
|
105
|
+
message: "The model does not support this output shape.",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return fields;
|
|
110
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { withProjectControl } from "../control/lock.js";
|
|
2
|
+
import { requestHash } from "../revisions/mutation-request.js";
|
|
3
|
+
import { currentRevisionId } from "../revisions/repo.js";
|
|
4
|
+
import { getRevisionView } from "../revisions/view.js";
|
|
5
|
+
import { previewRebuildSchema, startRebuildSchema, } from "./model.js";
|
|
6
|
+
import { executionSnapshotSchema, planPreview, reviewStillCovers } from "./preview-plan.js";
|
|
7
|
+
import { admissionReceipt, admitPreview, previewById, storePreview } from "./repo.js";
|
|
8
|
+
import { checkReadiness, localReadiness } from "./service-readiness.js";
|
|
9
|
+
export function rebuildConfirmations(required, confirmed, unknownCosts, acknowledged) {
|
|
10
|
+
const given = new Set(confirmed);
|
|
11
|
+
if (given.size !== confirmed.length ||
|
|
12
|
+
required.length !== given.size ||
|
|
13
|
+
required.some((key) => !given.has(key)))
|
|
14
|
+
return "review-required";
|
|
15
|
+
if (unknownCosts > 0 && !acknowledged)
|
|
16
|
+
return "cost-ack-required";
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
export async function previewRebuild(deps, input) {
|
|
20
|
+
const parsed = previewRebuildSchema.safeParse({
|
|
21
|
+
baseRevisionId: input.baseRevisionId,
|
|
22
|
+
request: input.request,
|
|
23
|
+
});
|
|
24
|
+
if (!parsed.success)
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
reason: "invalid-selection",
|
|
28
|
+
fields: parsed.error.issues.map((issue) => ({
|
|
29
|
+
field: issue.path.join("."),
|
|
30
|
+
message: issue.message,
|
|
31
|
+
})),
|
|
32
|
+
};
|
|
33
|
+
const view = getRevisionView(deps, input.projectId, input.baseRevisionId);
|
|
34
|
+
if (view === undefined)
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason: currentRevisionId(deps.db, input.projectId) === undefined ? "no-project" : "conflict",
|
|
38
|
+
};
|
|
39
|
+
if (!view.current)
|
|
40
|
+
return { ok: false, reason: "conflict" };
|
|
41
|
+
const plan = planPreview(deps, view, deps.catalogue.read(), input.request, deps.ids.next());
|
|
42
|
+
if (!plan.ok)
|
|
43
|
+
return plan;
|
|
44
|
+
storePreview(deps, plan.value.preview, plan.value.execution);
|
|
45
|
+
return { ok: true, value: plan.value.preview };
|
|
46
|
+
}
|
|
47
|
+
export async function startRebuild(deps, input) {
|
|
48
|
+
const { projectId, ...body } = input;
|
|
49
|
+
const parsed = startRebuildSchema.safeParse(body);
|
|
50
|
+
if (!parsed.success)
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
reason: "invalid-selection",
|
|
54
|
+
fields: parsed.error.issues.map((issue) => ({
|
|
55
|
+
field: issue.path.join("."),
|
|
56
|
+
message: issue.message,
|
|
57
|
+
})),
|
|
58
|
+
};
|
|
59
|
+
const hash = requestHash("rebuild", input.baseRevisionId, parsed.data);
|
|
60
|
+
const replay = admissionReceipt(deps.db, projectId, input.idempotencyKey, hash);
|
|
61
|
+
if (replay !== undefined) {
|
|
62
|
+
if (replay.ok)
|
|
63
|
+
wake(deps, projectId, replay.value);
|
|
64
|
+
return replay;
|
|
65
|
+
}
|
|
66
|
+
const preview = previewById(deps.db, projectId, input.previewId);
|
|
67
|
+
const view = getRevisionView(deps, projectId, input.baseRevisionId);
|
|
68
|
+
if (view === undefined)
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
reason: currentRevisionId(deps.db, projectId) === undefined ? "no-project" : "conflict",
|
|
72
|
+
};
|
|
73
|
+
if (!view.current)
|
|
74
|
+
return { ok: false, reason: "conflict" };
|
|
75
|
+
if (preview === undefined || preview.baseRevisionId !== input.baseRevisionId)
|
|
76
|
+
return { ok: false, reason: "stale-preview" };
|
|
77
|
+
const confirmation = rebuildConfirmations(preview.providedReuseRequired, input.confirmedProvidedWorkKeys, preview.costs.unknown, input.acknowledgeUnknownCosts);
|
|
78
|
+
if (confirmation !== undefined)
|
|
79
|
+
return { ok: false, reason: confirmation };
|
|
80
|
+
const row = deps.db
|
|
81
|
+
.prepare("SELECT execution_json FROM rebuild_previews WHERE id=? AND project_id=?")
|
|
82
|
+
.get(preview.id, projectId);
|
|
83
|
+
if (typeof row?.execution_json !== "string")
|
|
84
|
+
return { ok: false, reason: "stale-preview" };
|
|
85
|
+
const snapshot = executionSnapshotSchema.parse(JSON.parse(row.execution_json));
|
|
86
|
+
let ready;
|
|
87
|
+
try {
|
|
88
|
+
ready = await checkReadiness(deps, snapshot, view);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
deps.log.write("warn", "project.rebuild", {
|
|
92
|
+
projectId,
|
|
93
|
+
detail: "Provider readiness could not be loaded.",
|
|
94
|
+
});
|
|
95
|
+
return { ok: false, reason: "readiness" };
|
|
96
|
+
}
|
|
97
|
+
if (ready.fields.length > 0)
|
|
98
|
+
return { ok: false, reason: "readiness", fields: ready.fields };
|
|
99
|
+
return withProjectControl(deps.db, projectId, () => {
|
|
100
|
+
const again = admissionReceipt(deps.db, projectId, input.idempotencyKey, hash);
|
|
101
|
+
if (again !== undefined) {
|
|
102
|
+
if (again.ok)
|
|
103
|
+
wake(deps, projectId, again.value);
|
|
104
|
+
return again;
|
|
105
|
+
}
|
|
106
|
+
if (currentRevisionId(deps.db, projectId) !== input.baseRevisionId)
|
|
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
|
+
});
|
|
123
|
+
if (admitted.ok) {
|
|
124
|
+
deps.emit(projectId, { type: "project.updated", projectId });
|
|
125
|
+
wake(deps, projectId, admitted.value);
|
|
126
|
+
}
|
|
127
|
+
return admitted;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function wake(deps, projectId, admission) {
|
|
131
|
+
const owned = deps.db
|
|
132
|
+
.prepare("SELECT 1 FROM revision_work WHERE project_id=? AND admission_id=? AND state!='done' AND dispatch_state='allowed' LIMIT 1")
|
|
133
|
+
.get(projectId, admission.admissionId) !== undefined;
|
|
134
|
+
const unfinished = owned ||
|
|
135
|
+
admission.workIds.some((id) => deps.db
|
|
136
|
+
.prepare("SELECT 1 FROM revision_work WHERE id=? AND project_id=? AND state!='done' AND dispatch_state='allowed'")
|
|
137
|
+
.get(id, projectId) !== undefined);
|
|
138
|
+
if (!unfinished && admission.replayed)
|
|
139
|
+
return;
|
|
140
|
+
try {
|
|
141
|
+
deps.runner.tick(projectId);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
deps.log.write("warn", "project.rebuild", {
|
|
145
|
+
projectId,
|
|
146
|
+
detail: "Rebuild was admitted but the runner could not be notified.",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|