@gentbajko/slopify 0.8.4 → 1.0.1
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/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-DX1_chKs.css +1 -0
- package/dist/web/assets/index-DwM-Y6nw.js +137 -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 { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { workExists } from "../../kernel/runner/work-authority.js";
|
|
4
|
+
import { pieceFile } from "../storage/reconcile.js";
|
|
5
|
+
import { outputSchema } from "../storage/schema.js";
|
|
6
|
+
import { outputsForRevision, piecesForRevision, revisionById } from "./repo.js";
|
|
7
|
+
import { stagePieceSchema } from "./schema.js";
|
|
8
|
+
export function publicationAuthority(db, publication) {
|
|
9
|
+
const { work, pieceId, publicationId } = publication;
|
|
10
|
+
if (publicationId !== (pieceId ?? work.workId))
|
|
11
|
+
throw new Error("Publication identity does not match its durable work.");
|
|
12
|
+
if (!workExists(db, work))
|
|
13
|
+
return undefined;
|
|
14
|
+
const row = pieceId === null
|
|
15
|
+
? db
|
|
16
|
+
.prepare("SELECT work_key,fingerprint FROM revision_work_reservations WHERE work_id=? AND piece_id IS NULL")
|
|
17
|
+
.get(work.workId)
|
|
18
|
+
: db
|
|
19
|
+
.prepare("SELECT work_key,fingerprint FROM revision_work_pieces WHERE work_id=? AND id=?")
|
|
20
|
+
.get(work.workId, pieceId);
|
|
21
|
+
if (row === undefined && pieceId === null) {
|
|
22
|
+
const candidates = Object.entries(revisionById(db, work.projectId, work.revisionId)?.fingerprints ?? {}).filter(([, value]) => value === work.fingerprint);
|
|
23
|
+
const candidate = candidates.length === 1 ? candidates[0] : undefined;
|
|
24
|
+
if (candidate !== undefined)
|
|
25
|
+
return { key: candidate[0], fingerprint: candidate[1] };
|
|
26
|
+
}
|
|
27
|
+
if (row === undefined)
|
|
28
|
+
throw new Error("Publication has no unambiguous durable authority.");
|
|
29
|
+
const parsed = z.object({ work_key: z.string(), fingerprint: z.string() }).parse(row);
|
|
30
|
+
return { key: parsed.work_key, fingerprint: parsed.fingerprint };
|
|
31
|
+
}
|
|
32
|
+
export function validatePublication(db, publication, outputs, pieces) {
|
|
33
|
+
if (outputs.length + pieces.length === 0)
|
|
34
|
+
throw new Error("A publication must contain a result.");
|
|
35
|
+
if (new Set(outputs.map((row) => row.slot)).size !== outputs.length ||
|
|
36
|
+
new Set(pieces.map((row) => row.key)).size !== pieces.length)
|
|
37
|
+
throw new Error("Publication destinations must be unique.");
|
|
38
|
+
const authority = publicationAuthority(db, publication);
|
|
39
|
+
const durablePiece = publication.pieceId === null
|
|
40
|
+
? undefined
|
|
41
|
+
: db
|
|
42
|
+
.prepare("SELECT logical_fingerprint FROM revision_work_pieces WHERE id=? AND work_id=?")
|
|
43
|
+
.get(publication.pieceId, publication.work.workId);
|
|
44
|
+
const revision = revisionById(db, publication.work.projectId, publication.work.revisionId);
|
|
45
|
+
for (const row of outputs) {
|
|
46
|
+
const retainedMember = ((authority?.key === "subtitles:files" &&
|
|
47
|
+
((row.workKey === "export:wav" && row.output.role === "audio_export") ||
|
|
48
|
+
(row.workKey === "export:video" &&
|
|
49
|
+
row.output.role === "video" &&
|
|
50
|
+
row.output.meta.subtitlesMode !== "burn-in"))) ||
|
|
51
|
+
((authority?.key === "export:wav" || authority?.key === "export:video") &&
|
|
52
|
+
row.workKey === "subtitles:files" &&
|
|
53
|
+
["subtitles_srt", "subtitles_vtt", "subtitle_ass", "subtitle_font"].includes(row.output.role))) &&
|
|
54
|
+
outputsForRevision(db, publication.work.projectId, publication.work.revisionId).some((old) => old.selected &&
|
|
55
|
+
old.state === "ready" &&
|
|
56
|
+
old.workKey === row.workKey &&
|
|
57
|
+
old.output.role === row.output.role &&
|
|
58
|
+
old.assetId === row.asset.id &&
|
|
59
|
+
old.fingerprint === row.fingerprint);
|
|
60
|
+
if (!retainedMember &&
|
|
61
|
+
row.fingerprint !== revision?.fingerprints[row.workKey] &&
|
|
62
|
+
!(row.workKey === authority?.key &&
|
|
63
|
+
(row.fingerprint === authority.fingerprint ||
|
|
64
|
+
row.fingerprint === durablePiece?.logical_fingerprint)))
|
|
65
|
+
throw new Error("Output fingerprint differs from its authorized recipe.");
|
|
66
|
+
outputSchema.parse(row.output);
|
|
67
|
+
if (row.output.projectId !== publication.work.projectId ||
|
|
68
|
+
row.asset.projectId !== publication.work.projectId ||
|
|
69
|
+
row.output.stageKind !== publication.work.kind ||
|
|
70
|
+
row.output.path !== row.asset.path ||
|
|
71
|
+
row.output.bytes !== row.asset.bytes)
|
|
72
|
+
throw new Error("Output descriptor does not match its owned asset and stage.");
|
|
73
|
+
}
|
|
74
|
+
for (const row of pieces) {
|
|
75
|
+
stagePieceSchema.parse(row.piece);
|
|
76
|
+
const owned = db
|
|
77
|
+
.prepare("SELECT work_key,fingerprint FROM revision_work_pieces WHERE id=? AND work_id=?")
|
|
78
|
+
.get(row.piece.id, publication.work.workId);
|
|
79
|
+
const file = pieceFile(row.piece.payload);
|
|
80
|
+
if (row.stageKind !== publication.work.kind ||
|
|
81
|
+
row.piece.stageId !== publication.work.stageId ||
|
|
82
|
+
owned === undefined ||
|
|
83
|
+
owned.work_key !== row.key ||
|
|
84
|
+
owned.fingerprint !== row.fingerprint ||
|
|
85
|
+
(publication.pieceId !== null && row.piece.id !== publication.pieceId) ||
|
|
86
|
+
(row.asset !== null &&
|
|
87
|
+
(row.asset.projectId !== publication.work.projectId || file !== row.asset.path)) ||
|
|
88
|
+
(row.asset === null && file !== undefined))
|
|
89
|
+
throw new Error("Piece descriptor does not match its owned work and asset.");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function isPublicationReplay(db, publication, outputs, pieces) {
|
|
93
|
+
const oldOutputs = outputsForRevision(db, publication.work.projectId, publication.work.revisionId).filter((row) => row.publicationId === publication.publicationId);
|
|
94
|
+
const oldPieces = piecesForRevision(db, publication.work.projectId, publication.work.revisionId).filter((row) => row.publicationId === publication.publicationId);
|
|
95
|
+
if (oldOutputs.length + oldPieces.length === 0)
|
|
96
|
+
return false;
|
|
97
|
+
const descriptors = outputs.map((row) => ({
|
|
98
|
+
slot: row.slot,
|
|
99
|
+
workKey: row.workKey,
|
|
100
|
+
assetId: row.asset.id,
|
|
101
|
+
output: row.output,
|
|
102
|
+
fingerprint: row.fingerprint,
|
|
103
|
+
state: "ready",
|
|
104
|
+
}));
|
|
105
|
+
const pieceDescriptors = pieces.map((row) => ({
|
|
106
|
+
key: row.key,
|
|
107
|
+
stageKind: row.stageKind,
|
|
108
|
+
assetId: row.asset?.id ?? null,
|
|
109
|
+
piece: row.piece,
|
|
110
|
+
fingerprint: row.fingerprint,
|
|
111
|
+
}));
|
|
112
|
+
const normalize = (rows) => rows
|
|
113
|
+
.map((row) => JSON.parse(JSON.stringify(row)))
|
|
114
|
+
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
115
|
+
if (!isDeepStrictEqual(normalize(oldOutputs.map(outputDescriptor)), normalize(descriptors)) ||
|
|
116
|
+
!isDeepStrictEqual(normalize(oldPieces.map(pieceDescriptor)), normalize(pieceDescriptors)))
|
|
117
|
+
throw new Error("Publication conflicts with its complete retained result.");
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
function outputDescriptor({ recordId: _recordId, publicationId: _publicationId, selected: _selected, ...row }) {
|
|
121
|
+
return row;
|
|
122
|
+
}
|
|
123
|
+
function pieceDescriptor({ recordId: _recordId, publicationId: _publicationId, selected: _selected, ...row }) {
|
|
124
|
+
return row;
|
|
125
|
+
}
|
|
126
|
+
export function replacementSlots(authority, outputs) {
|
|
127
|
+
if (authority === "article:body")
|
|
128
|
+
return ["article:article_md", "article:article_txt", "article:sources", "article:glossary"];
|
|
129
|
+
if (authority === "subtitles:files" || authority === "export:wav" || authority === "export:video")
|
|
130
|
+
return [
|
|
131
|
+
"video:subtitles_srt",
|
|
132
|
+
"video:subtitles_vtt",
|
|
133
|
+
"video:subtitle_ass",
|
|
134
|
+
"video:subtitle_font",
|
|
135
|
+
...(authority === "subtitles:files"
|
|
136
|
+
? []
|
|
137
|
+
: ["video:video", "video:audio_export", "video:render_params"]),
|
|
138
|
+
];
|
|
139
|
+
return outputs.map((row) => row.slot);
|
|
140
|
+
}
|
|
141
|
+
export function validateBundle(authority, outputs) {
|
|
142
|
+
const allowed = (row) => row.workKey === authority ||
|
|
143
|
+
((authority === "export:wav" || authority === "export:video") &&
|
|
144
|
+
["subtitles:files", "subtitles:cues"].includes(row.workKey)) ||
|
|
145
|
+
(authority === "subtitles:files" &&
|
|
146
|
+
((row.workKey === "export:wav" && row.output.role === "audio_export") ||
|
|
147
|
+
(row.workKey === "export:video" &&
|
|
148
|
+
row.output.role === "video" &&
|
|
149
|
+
row.output.meta.subtitlesMode !== "burn-in")));
|
|
150
|
+
if (outputs.some((row) => !allowed(row)))
|
|
151
|
+
throw new Error("Publication member is outside its authorized result bundle.");
|
|
152
|
+
const roles = new Set(outputs.map((row) => row.output.role));
|
|
153
|
+
const media = outputs.find((row) => row.output.role === "video" || row.output.role === "audio_export");
|
|
154
|
+
if ((authority === "export:wav" && (!roles.has("audio_export") || !roles.has("render_params"))) ||
|
|
155
|
+
(authority === "export:video" && (!roles.has("video") || !roles.has("render_params"))))
|
|
156
|
+
throw new Error("Media and render parameters must publish as one complete bundle.");
|
|
157
|
+
if (media !== undefined &&
|
|
158
|
+
media.output.meta.subtitlesMode !== undefined &&
|
|
159
|
+
media.output.meta.subtitlesMode !== "off") {
|
|
160
|
+
if (!roles.has("subtitles_srt") ||
|
|
161
|
+
!roles.has("subtitles_vtt") ||
|
|
162
|
+
(media.output.meta.subtitlesMode === "burn-in" &&
|
|
163
|
+
(!roles.has("subtitle_ass") || !roles.has("subtitle_font"))))
|
|
164
|
+
throw new Error("Declared subtitle files must be committed with their media descriptor.");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
2
|
+
import { publicationTargets } from "../rebuild/repo.js";
|
|
3
|
+
import { discardPreparedAssets } from "../storage/assets.js";
|
|
4
|
+
import { positionedOutput, positionedPiece, projectSelected } from "./projection.js";
|
|
5
|
+
import { isPublicationReplay, publicationAuthority, replacementSlots, validateBundle, validatePublication, } from "./publication-rules.js";
|
|
6
|
+
import { insertAsset, insertManifestOutput, insertManifestPiece, outputsForRevision, revisionById, selectOutputRecord, selectPieceRecord, } from "./repo.js";
|
|
7
|
+
export function commitRevisionOutputs(deps, publication, outputs, pieces) {
|
|
8
|
+
const assets = [
|
|
9
|
+
...outputs.map((row) => row.asset),
|
|
10
|
+
...pieces.flatMap((row) => (row.asset === null ? [] : [row.asset])),
|
|
11
|
+
];
|
|
12
|
+
try {
|
|
13
|
+
return transact(deps.db, () => {
|
|
14
|
+
const result = { originRevisionId: publication.work.revisionId, currentAttached: false };
|
|
15
|
+
const authority = publicationAuthority(deps.db, publication);
|
|
16
|
+
if (authority === undefined)
|
|
17
|
+
return result;
|
|
18
|
+
validatePublication(deps.db, publication, outputs, pieces);
|
|
19
|
+
validateBundle(authority.key, outputs);
|
|
20
|
+
if (isPublicationReplay(deps.db, publication, outputs, pieces))
|
|
21
|
+
return result;
|
|
22
|
+
const targets = publicationTargets(deps.db, publication, authority.key, authority.fingerprint);
|
|
23
|
+
if (targets.length === 0)
|
|
24
|
+
return result;
|
|
25
|
+
for (const asset of assets)
|
|
26
|
+
insertAsset(deps.db, asset);
|
|
27
|
+
let currentAttached = false;
|
|
28
|
+
for (const target of targets) {
|
|
29
|
+
const revision = revisionById(deps.db, publication.work.projectId, target.revisionId);
|
|
30
|
+
if (revision === undefined)
|
|
31
|
+
throw new Error("Publication target disappeared inside its transaction.");
|
|
32
|
+
const selected = target.selected;
|
|
33
|
+
const before = outputsForRevision(deps.db, revision.projectId, revision.id);
|
|
34
|
+
const exportAuthority = authority.key === "export:wav" || authority.key === "export:video";
|
|
35
|
+
const selectsMember = (output) => {
|
|
36
|
+
if (!selected)
|
|
37
|
+
return false;
|
|
38
|
+
if (authority.key === "subtitles:files" &&
|
|
39
|
+
(output.output.role === "audio_export" || output.output.role === "video"))
|
|
40
|
+
return before.some((row) => row.selected &&
|
|
41
|
+
row.state === "ready" &&
|
|
42
|
+
row.workKey === output.workKey &&
|
|
43
|
+
row.assetId === output.asset.id &&
|
|
44
|
+
row.fingerprint === output.fingerprint);
|
|
45
|
+
if (!exportAuthority || output.workKey !== "subtitles:files")
|
|
46
|
+
return true;
|
|
47
|
+
if ((revision.config.subtitles?.mode ?? "off") === "off")
|
|
48
|
+
return false;
|
|
49
|
+
return (revision.fingerprints[output.workKey] === output.fingerprint ||
|
|
50
|
+
before.some((row) => row.selected &&
|
|
51
|
+
row.state === "ready" &&
|
|
52
|
+
row.slot === output.slot &&
|
|
53
|
+
row.assetId === output.asset.id &&
|
|
54
|
+
row.fingerprint === output.fingerprint));
|
|
55
|
+
};
|
|
56
|
+
if (selected)
|
|
57
|
+
for (const slot of replacementSlots(authority.key, outputs)) {
|
|
58
|
+
const captionSlot = [
|
|
59
|
+
"video:subtitles_srt",
|
|
60
|
+
"video:subtitles_vtt",
|
|
61
|
+
"video:subtitle_ass",
|
|
62
|
+
"video:subtitle_font",
|
|
63
|
+
].includes(slot);
|
|
64
|
+
if (exportAuthority &&
|
|
65
|
+
captionSlot &&
|
|
66
|
+
(revision.config.subtitles?.mode ?? "off") !== "off" &&
|
|
67
|
+
!outputs.some((output) => output.slot === slot && selectsMember(output)))
|
|
68
|
+
continue;
|
|
69
|
+
deps.db
|
|
70
|
+
.prepare("UPDATE revision_outputs SET selected=0 WHERE revision_id=? AND slot=?")
|
|
71
|
+
.run(revision.id, slot);
|
|
72
|
+
}
|
|
73
|
+
for (const output of outputs) {
|
|
74
|
+
const row = {
|
|
75
|
+
slot: output.slot,
|
|
76
|
+
workKey: output.workKey,
|
|
77
|
+
assetId: output.asset.id,
|
|
78
|
+
output: output.output,
|
|
79
|
+
fingerprint: output.fingerprint,
|
|
80
|
+
state: "ready",
|
|
81
|
+
};
|
|
82
|
+
const recordId = deps.ids.next();
|
|
83
|
+
insertManifestOutput(deps.db, revision, target.revisionId === publication.work.revisionId
|
|
84
|
+
? row
|
|
85
|
+
: positionedOutput(revision, row), recordId, publication.publicationId);
|
|
86
|
+
if (selectsMember(output))
|
|
87
|
+
selectOutputRecord(deps.db, revision.id, row.slot, recordId);
|
|
88
|
+
}
|
|
89
|
+
for (const piece of pieces) {
|
|
90
|
+
const row = {
|
|
91
|
+
key: piece.key,
|
|
92
|
+
stageKind: piece.stageKind,
|
|
93
|
+
assetId: piece.asset?.id ?? null,
|
|
94
|
+
piece: piece.piece,
|
|
95
|
+
fingerprint: piece.fingerprint,
|
|
96
|
+
};
|
|
97
|
+
const recordId = deps.ids.next();
|
|
98
|
+
insertManifestPiece(deps.db, revision, target.revisionId === publication.work.revisionId
|
|
99
|
+
? row
|
|
100
|
+
: positionedPiece(revision, row), recordId, publication.publicationId);
|
|
101
|
+
if (selected)
|
|
102
|
+
selectPieceRecord(deps.db, revision.id, row.key, recordId);
|
|
103
|
+
}
|
|
104
|
+
if (selected && target.current) {
|
|
105
|
+
projectSelected(deps.db, revision);
|
|
106
|
+
currentAttached = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { ...result, currentAttached };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
discardPreparedAssets(deps, assets);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { projectRevisionSchema } from "./schema.js";
|
|
3
|
+
export { insertManifestOutput, insertManifestPiece, outputsForRevision, piecesForRevision, selectOutputRecord, selectPieceRecord, } from "./manifest-repo.js";
|
|
4
|
+
const revisionRow = z.object({
|
|
5
|
+
id: z.string(),
|
|
6
|
+
project_id: z.string(),
|
|
7
|
+
parent_id: z.string().nullable(),
|
|
8
|
+
restored_from_id: z.string().nullable(),
|
|
9
|
+
config: z.string(),
|
|
10
|
+
content: z.string(),
|
|
11
|
+
fingerprints: z.string(),
|
|
12
|
+
created_at: z.string(),
|
|
13
|
+
});
|
|
14
|
+
const assetRow = z.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
project_id: z.string(),
|
|
17
|
+
path: z.string(),
|
|
18
|
+
bytes: z.number().nullable(),
|
|
19
|
+
created_at: z.string(),
|
|
20
|
+
});
|
|
21
|
+
export function insertRevision(db, revision) {
|
|
22
|
+
db.prepare(`INSERT INTO project_revisions
|
|
23
|
+
(id,project_id,parent_id,restored_from_id,config,content,fingerprints,created_at)
|
|
24
|
+
VALUES (?,?,?,?,?,?,?,?)`).run(revision.id, revision.projectId, revision.parentId, revision.restoredFromId, JSON.stringify(revision.config), JSON.stringify(revision.content), JSON.stringify(revision.fingerprints), revision.createdAt);
|
|
25
|
+
}
|
|
26
|
+
export function revisionById(db, projectId, revisionId) {
|
|
27
|
+
const row = db
|
|
28
|
+
.prepare("SELECT * FROM project_revisions WHERE project_id=? AND id=?")
|
|
29
|
+
.get(projectId, revisionId);
|
|
30
|
+
return row === undefined ? undefined : toRevision(row);
|
|
31
|
+
}
|
|
32
|
+
export function currentRevisionId(db, projectId) {
|
|
33
|
+
const row = db.prepare("SELECT revision_id FROM project_heads WHERE project_id=?").get(projectId);
|
|
34
|
+
return row === undefined
|
|
35
|
+
? undefined
|
|
36
|
+
: z.object({ revision_id: z.string() }).parse(row).revision_id;
|
|
37
|
+
}
|
|
38
|
+
export function listRevisionHistory(db, projectId) {
|
|
39
|
+
const head = currentRevisionId(db, projectId);
|
|
40
|
+
return db
|
|
41
|
+
.prepare("SELECT * FROM project_revisions WHERE project_id=? ORDER BY created_at DESC,id DESC")
|
|
42
|
+
.all(projectId)
|
|
43
|
+
.map((row) => {
|
|
44
|
+
const revision = toRevision(row);
|
|
45
|
+
return {
|
|
46
|
+
id: revision.id,
|
|
47
|
+
parentId: revision.parentId,
|
|
48
|
+
restoredFromId: revision.restoredFromId,
|
|
49
|
+
title: revision.config.title,
|
|
50
|
+
createdAt: revision.createdAt,
|
|
51
|
+
current: revision.id === head,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export function insertAsset(db, asset) {
|
|
56
|
+
const existing = db
|
|
57
|
+
.prepare("SELECT * FROM project_assets WHERE id=? OR (project_id=? AND path=?)")
|
|
58
|
+
.all(asset.id, asset.projectId, asset.path);
|
|
59
|
+
if (existing.length > 0) {
|
|
60
|
+
const row = assetRow.parse(existing[0]);
|
|
61
|
+
if (existing.length !== 1 ||
|
|
62
|
+
row.id !== asset.id ||
|
|
63
|
+
row.project_id !== asset.projectId ||
|
|
64
|
+
row.path !== asset.path ||
|
|
65
|
+
row.bytes !== asset.bytes)
|
|
66
|
+
throw new Error("Asset identity conflicts with a registered file.");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
db.prepare("INSERT INTO project_assets(id,project_id,path,bytes,created_at) VALUES (?,?,?,?,?)").run(asset.id, asset.projectId, asset.path, asset.bytes, asset.createdAt);
|
|
70
|
+
}
|
|
71
|
+
function toRevision(value) {
|
|
72
|
+
const row = revisionRow.parse(value);
|
|
73
|
+
return projectRevisionSchema.parse({
|
|
74
|
+
id: row.id,
|
|
75
|
+
projectId: row.project_id,
|
|
76
|
+
parentId: row.parent_id,
|
|
77
|
+
restoredFromId: row.restored_from_id,
|
|
78
|
+
config: JSON.parse(row.config),
|
|
79
|
+
content: JSON.parse(row.content),
|
|
80
|
+
fingerprints: JSON.parse(row.fingerprints),
|
|
81
|
+
createdAt: row.created_at,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
2
|
+
import { carryCheckpointGates } from "../checkpoints/recovery.js";
|
|
3
|
+
import { planRevision } from "../rebuild/recipe-save.js";
|
|
4
|
+
import { transitionRevisionWork } from "../rebuild/repo.js";
|
|
5
|
+
import { projectStandings } from "../rebuild/runtime-store.js";
|
|
6
|
+
import { checkMutation, insertReceipt, refusal, requestHash, requiredView, } from "./mutation-request.js";
|
|
7
|
+
import { advanceHead, logicalKeys } from "./mutations.js";
|
|
8
|
+
import { cloneManifest, ensureRevisionStages, projectSelected } from "./projection.js";
|
|
9
|
+
import { insertRevision } from "./repo.js";
|
|
10
|
+
import { getRevisionView } from "./view.js";
|
|
11
|
+
export async function restoreRevision(deps, input) {
|
|
12
|
+
const identity = {
|
|
13
|
+
...input,
|
|
14
|
+
operation: "restore",
|
|
15
|
+
hash: requestHash("restore", input.baseRevisionId, {
|
|
16
|
+
targetRevisionId: input.targetRevisionId,
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
return transact(deps.db, () => {
|
|
20
|
+
const checked = checkMutation(deps, identity);
|
|
21
|
+
if (checked !== undefined)
|
|
22
|
+
return checked;
|
|
23
|
+
const target = getRevisionView(deps, input.projectId, input.targetRevisionId);
|
|
24
|
+
if (target === undefined)
|
|
25
|
+
return refusal(deps, input.projectId, "no-revision");
|
|
26
|
+
const base = requiredView(deps, input.projectId, input.baseRevisionId);
|
|
27
|
+
const comparison = planRevision(base, {
|
|
28
|
+
config: target.revision.config,
|
|
29
|
+
content: target.revision.content,
|
|
30
|
+
});
|
|
31
|
+
const revision = {
|
|
32
|
+
...target.revision,
|
|
33
|
+
id: deps.ids.next(),
|
|
34
|
+
parentId: base.revision.id,
|
|
35
|
+
restoredFromId: target.revision.id,
|
|
36
|
+
createdAt: deps.clock.now().toISOString(),
|
|
37
|
+
};
|
|
38
|
+
insertRevision(deps.db, revision);
|
|
39
|
+
ensureRevisionStages(deps, revision);
|
|
40
|
+
cloneManifest(deps, revision, {
|
|
41
|
+
outputs: target.outputs.filter((row) => row.selected),
|
|
42
|
+
pieces: target.pieces.filter((row) => row.selected),
|
|
43
|
+
});
|
|
44
|
+
if (!advanceHead(deps.db, input.projectId, input.baseRevisionId, revision))
|
|
45
|
+
throw new Error("Project head changed during the restore transaction.");
|
|
46
|
+
transitionRevisionWork(deps, {
|
|
47
|
+
projectId: input.projectId,
|
|
48
|
+
baseRevisionId: input.baseRevisionId,
|
|
49
|
+
revisionId: revision.id,
|
|
50
|
+
fingerprints: revision.fingerprints,
|
|
51
|
+
...(comparison.ok ? { baseFingerprints: comparison.baseFingerprints } : {}),
|
|
52
|
+
logicalKeys: logicalKeys(deps, input.projectId, input.baseRevisionId, revision.fingerprints),
|
|
53
|
+
});
|
|
54
|
+
carryCheckpointGates(deps, input.baseRevisionId, revision);
|
|
55
|
+
projectSelected(deps.db, revision);
|
|
56
|
+
projectStandings(deps, revision.projectId);
|
|
57
|
+
insertReceipt(deps, identity, revision.id);
|
|
58
|
+
return { ok: true, view: requiredView(deps, input.projectId, revision.id), duplicate: false };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { buildRecipes } from "../rebuild/recipe-build.js";
|
|
3
|
+
import { normalizeArticleIntent, validateRevisionEdit } from "../rebuild/recipe-save.js";
|
|
4
|
+
export function validateCues(cues, duration) {
|
|
5
|
+
const fields = [];
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
let end = 0;
|
|
8
|
+
for (const [index, cue] of cues.entries()) {
|
|
9
|
+
const field = `content.subtitleCues.cues.${index}`;
|
|
10
|
+
if (seen.has(cue.id))
|
|
11
|
+
fields.push({ field: `${field}.id`, message: "Caption IDs must be unique." });
|
|
12
|
+
seen.add(cue.id);
|
|
13
|
+
if (cue.text.trim() === "")
|
|
14
|
+
fields.push({ field: `${field}.text`, message: "Caption text is required." });
|
|
15
|
+
if (!Number.isFinite(cue.start) || cue.start < end)
|
|
16
|
+
fields.push({ field: `${field}.start`, message: "Start must follow the previous caption." });
|
|
17
|
+
if (!Number.isFinite(cue.end) || cue.end <= cue.start || cue.end > duration)
|
|
18
|
+
fields.push({
|
|
19
|
+
field: `${field}.end`,
|
|
20
|
+
message: "End must follow start and fit the narration.",
|
|
21
|
+
});
|
|
22
|
+
end = cue.end;
|
|
23
|
+
}
|
|
24
|
+
return fields;
|
|
25
|
+
}
|
|
26
|
+
export function validateTemplateIntent(base, edit) {
|
|
27
|
+
const fields = [];
|
|
28
|
+
if (!isDeepStrictEqual(base.revision.config.values, edit.config.values)) {
|
|
29
|
+
for (const [key, raw] of Object.entries(base.revision.content.promptTemplates)) {
|
|
30
|
+
if (raw === null &&
|
|
31
|
+
edit.content.promptTemplates[key] == null &&
|
|
32
|
+
base.revision.config.rendered[key] === edit.config.rendered[key])
|
|
33
|
+
fields.push({
|
|
34
|
+
field: `content.promptTemplates.${key}`,
|
|
35
|
+
message: "Choose the raw template or explicitly keep the current prompt before changing keywords.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
for (const [key, image] of Object.entries(base.revision.content.imageDefinitions)) {
|
|
39
|
+
const next = edit.content.imageDefinitions[key];
|
|
40
|
+
if (image.source === "generate" &&
|
|
41
|
+
image.templateKey == null &&
|
|
42
|
+
next?.source === "generate" &&
|
|
43
|
+
next.templateKey == null &&
|
|
44
|
+
next.prompt === image.prompt)
|
|
45
|
+
fields.push({
|
|
46
|
+
field: `content.imageDefinitions.${key}.templateKey`,
|
|
47
|
+
message: "Choose the raw template or explicitly keep the current image prompt before changing keywords.",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const [key, image] of Object.entries(edit.content.imageDefinitions))
|
|
52
|
+
if (image.source === "generate" &&
|
|
53
|
+
image.templateKey != null &&
|
|
54
|
+
edit.content.promptTemplates[image.templateKey] == null &&
|
|
55
|
+
!(base.revision.content.promptTemplates[image.templateKey] == null &&
|
|
56
|
+
isDeepStrictEqual(base.revision.content.imageDefinitions[key], image)))
|
|
57
|
+
fields.push({
|
|
58
|
+
field: `content.imageDefinitions.${key}.templateKey`,
|
|
59
|
+
message: "The linked raw image template is missing.",
|
|
60
|
+
});
|
|
61
|
+
return fields;
|
|
62
|
+
}
|
|
63
|
+
export function validateNarrationIntent(base, edit) {
|
|
64
|
+
const content = normalizeArticleIntent(base, edit);
|
|
65
|
+
const invalid = validateRevisionEdit(edit.config, content);
|
|
66
|
+
if (invalid.length > 0)
|
|
67
|
+
return invalid;
|
|
68
|
+
const recipes = buildRecipes({
|
|
69
|
+
config: edit.config,
|
|
70
|
+
content,
|
|
71
|
+
manifest: {
|
|
72
|
+
outputs: base.outputs.filter((row) => row.selected),
|
|
73
|
+
pieces: base.pieces.filter((row) => row.selected),
|
|
74
|
+
},
|
|
75
|
+
resolved: {
|
|
76
|
+
articleMarkdown: base.articleMarkdown,
|
|
77
|
+
researchNotes: edit.config.provided.research ?? null,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const keys = new Set(recipes.flatMap((row) => row.input.kind === "tts"
|
|
81
|
+
? [row.input.logicalKey]
|
|
82
|
+
: row.stage === "audio" && row.key.endsWith(":1")
|
|
83
|
+
? [row.key.slice(0, -2)]
|
|
84
|
+
: []));
|
|
85
|
+
const fields = [];
|
|
86
|
+
for (const [key, value] of Object.entries(edit.content.narrationOverrides))
|
|
87
|
+
if (!isDeepStrictEqual(value, base.revision.content.narrationOverrides[key]) && !keys.has(key))
|
|
88
|
+
fields.push({
|
|
89
|
+
field: `content.narrationOverrides.${key}`,
|
|
90
|
+
message: "Choose an active narration group or entry.",
|
|
91
|
+
});
|
|
92
|
+
for (const [index, upload] of (edit.uploads ?? []).entries())
|
|
93
|
+
if (upload.destination.kind === "narration" && !keys.has(upload.destination.key))
|
|
94
|
+
fields.push({
|
|
95
|
+
field: `uploads.${index}.destination.key`,
|
|
96
|
+
message: "Choose an active narration group or entry.",
|
|
97
|
+
});
|
|
98
|
+
return fields;
|
|
99
|
+
}
|