@gentbajko/slopify 0.8.4 → 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/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,112 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { legacyImageOutput } from "../rebuild/recipe-legacy.js";
|
|
4
|
+
import { outputPath } from "../storage/layout.js";
|
|
5
|
+
import { pieceFile } from "../storage/reconcile.js";
|
|
6
|
+
const imagePayload = z.object({
|
|
7
|
+
prompt: z.string().optional(),
|
|
8
|
+
promptIndex: z.number().int().positive().optional(),
|
|
9
|
+
});
|
|
10
|
+
export function baselineContent(deps, project, outputs, pieces, assets, templates = {}) {
|
|
11
|
+
const article = outputs.find((row) => row.role === "article_md");
|
|
12
|
+
const articlePath = article === undefined ? undefined : outputPath(deps.paths, project.id, article.path);
|
|
13
|
+
const articleMarkdown = articlePath !== undefined && statSync(articlePath, { throwIfNoEntry: false })?.isFile() === true
|
|
14
|
+
? readFileSync(articlePath, "utf8")
|
|
15
|
+
: undefined;
|
|
16
|
+
const provided = {};
|
|
17
|
+
const roles = {
|
|
18
|
+
research: "notes",
|
|
19
|
+
article: "article_md",
|
|
20
|
+
audio: "audio_body",
|
|
21
|
+
thumbnail: "thumbnail",
|
|
22
|
+
};
|
|
23
|
+
for (const [stage, role] of Object.entries(roles)) {
|
|
24
|
+
if (stage !== "research" && stage !== "article" && stage !== "audio" && stage !== "thumbnail")
|
|
25
|
+
continue;
|
|
26
|
+
if (project.config.sources[stage] !== "provide")
|
|
27
|
+
continue;
|
|
28
|
+
const output = outputs.find((row) => row.role === role);
|
|
29
|
+
const asset = output === undefined ? undefined : assets.get(output.path);
|
|
30
|
+
if (asset !== undefined)
|
|
31
|
+
provided[stage] = asset.id;
|
|
32
|
+
}
|
|
33
|
+
const imageDefinitions = {};
|
|
34
|
+
const order = [];
|
|
35
|
+
const imagePieces = pieces.filter((row) => row.kind === "image");
|
|
36
|
+
const images = outputs.filter((row) => row.role === "image");
|
|
37
|
+
for (const output of images) {
|
|
38
|
+
const piece = imagePieces.find((row) => legacyImageOutput(row, images)?.id === output.id);
|
|
39
|
+
const payload = parsedImage(piece?.payload ?? null);
|
|
40
|
+
const supplied = project.config.sources.images === "provide" ||
|
|
41
|
+
(project.config.sources.images === "off" &&
|
|
42
|
+
piece === undefined &&
|
|
43
|
+
output.originalFilename !== null &&
|
|
44
|
+
output.meta.provider === undefined &&
|
|
45
|
+
output.meta.prompt === undefined);
|
|
46
|
+
imageDefinitions[output.id] = {
|
|
47
|
+
source: supplied ? "provide" : "generate",
|
|
48
|
+
assetId: assets.get(output.path)?.id ?? null,
|
|
49
|
+
prompt: supplied ? null : (payload?.prompt ?? output.meta.prompt ?? null),
|
|
50
|
+
templateKey: supplied ? null : templateKey(payload?.promptIndex),
|
|
51
|
+
};
|
|
52
|
+
order.push({ key: output.id, index: output.meta.index ?? 0 });
|
|
53
|
+
}
|
|
54
|
+
for (const piece of imagePieces) {
|
|
55
|
+
if (legacyImageOutput(piece, images) !== undefined)
|
|
56
|
+
continue;
|
|
57
|
+
const payload = parsedImage(piece.payload);
|
|
58
|
+
const path = pieceFile(piece.payload);
|
|
59
|
+
imageDefinitions[piece.id] = {
|
|
60
|
+
source: "generate",
|
|
61
|
+
assetId: path === undefined ? null : (assets.get(path)?.id ?? null),
|
|
62
|
+
prompt: payload?.prompt ?? null,
|
|
63
|
+
templateKey: templateKey(payload?.promptIndex),
|
|
64
|
+
};
|
|
65
|
+
order.push({ key: piece.id, index: piece.idx });
|
|
66
|
+
}
|
|
67
|
+
if (project.config.sources.images === "generate" &&
|
|
68
|
+
images.length === 0 &&
|
|
69
|
+
imagePieces.length === 0) {
|
|
70
|
+
for (const [index, prompt] of project.config.imagePrompts.entries()) {
|
|
71
|
+
const key = `imagePrompts.${index}`;
|
|
72
|
+
for (let send = 0; send < prompt.number; send += 1) {
|
|
73
|
+
const id = deps.ids.next();
|
|
74
|
+
imageDefinitions[id] = {
|
|
75
|
+
source: "generate",
|
|
76
|
+
assetId: null,
|
|
77
|
+
prompt: project.config.rendered[key] ?? null,
|
|
78
|
+
templateKey: templates[key] === undefined ? null : key,
|
|
79
|
+
};
|
|
80
|
+
order.push({ key: id, index: order.length });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
...(articleMarkdown === undefined ? {} : { articleMarkdown }),
|
|
86
|
+
articleEdited: false,
|
|
87
|
+
provided,
|
|
88
|
+
imageOrder: order.sort((a, b) => a.index - b.index).map((row) => row.key),
|
|
89
|
+
imageDefinitions,
|
|
90
|
+
narrationOverrides: {},
|
|
91
|
+
regenerationTokens: {},
|
|
92
|
+
promptTemplates: Object.fromEntries(Object.keys(project.config.rendered).map((key) => [key, templates[key] ?? null])),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function parsedImage(payload) {
|
|
96
|
+
if (payload === null)
|
|
97
|
+
return undefined;
|
|
98
|
+
let value;
|
|
99
|
+
try {
|
|
100
|
+
value = JSON.parse(payload);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error instanceof SyntaxError)
|
|
104
|
+
return undefined;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
const result = imagePayload.safeParse(value);
|
|
108
|
+
return result.success ? result.data : undefined;
|
|
109
|
+
}
|
|
110
|
+
function templateKey(index) {
|
|
111
|
+
return index === undefined ? null : `imagePrompts.${index - 1}`;
|
|
112
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
2
|
+
import { allPiecesOf } from "../../kernel/runner/piece-repo.js";
|
|
3
|
+
import { projectById, stagesOf } from "../admission/repo.js";
|
|
4
|
+
import { withProjectControl } from "../control/lock.js";
|
|
5
|
+
import { legacyImageOutput } from "../rebuild/recipe-legacy.js";
|
|
6
|
+
import { baselineFingerprints, legacyOutputSlot, legacyOutputWorkKey, legacyPieceKey, } from "../rebuild/recipes.js";
|
|
7
|
+
import { pieceFile } from "../storage/reconcile.js";
|
|
8
|
+
import { outputsOf } from "../storage/repo.js";
|
|
9
|
+
import { adoptAssets } from "./adopt-assets.js";
|
|
10
|
+
import { baselineContent } from "./adopt-content.js";
|
|
11
|
+
import { currentRevisionId, insertManifestOutput, insertManifestPiece, insertRevision, selectOutputRecord, selectPieceRecord, } from "./repo.js";
|
|
12
|
+
import { getRevisionView } from "./view.js";
|
|
13
|
+
export function ensureBaseline(deps, projectId) {
|
|
14
|
+
return withProjectControl(deps.db, projectId, () => adoptBaseline(deps, projectId));
|
|
15
|
+
}
|
|
16
|
+
export function adoptBaseline(deps, projectId, templates) {
|
|
17
|
+
return transact(deps.db, () => {
|
|
18
|
+
const project = projectById(deps.db, projectId);
|
|
19
|
+
if (project === undefined)
|
|
20
|
+
return { ok: false, reason: "no-project" };
|
|
21
|
+
const head = currentRevisionId(deps.db, projectId);
|
|
22
|
+
if (head !== undefined) {
|
|
23
|
+
const view = getRevisionView(deps, projectId, head);
|
|
24
|
+
if (view === undefined)
|
|
25
|
+
throw new Error("The project head has no revision.");
|
|
26
|
+
return { ok: true, created: false, view };
|
|
27
|
+
}
|
|
28
|
+
const outputs = outputsOf(deps.db, projectId);
|
|
29
|
+
const stages = stagesOf(deps.db, projectId);
|
|
30
|
+
const pieces = stages.flatMap((stage) => allPiecesOf(deps.db, stage.id));
|
|
31
|
+
const assets = adoptAssets(deps, projectId, outputs, pieces);
|
|
32
|
+
const content = baselineContent(deps, project, outputs, pieces, assets, templates);
|
|
33
|
+
const fingerprints = baselineFingerprints(project, outputs, pieces, content, stages);
|
|
34
|
+
const revision = {
|
|
35
|
+
id: deps.ids.next(),
|
|
36
|
+
projectId,
|
|
37
|
+
parentId: null,
|
|
38
|
+
restoredFromId: null,
|
|
39
|
+
config: project.config,
|
|
40
|
+
content,
|
|
41
|
+
fingerprints,
|
|
42
|
+
createdAt: deps.clock.now().toISOString(),
|
|
43
|
+
};
|
|
44
|
+
insertRevision(deps.db, revision);
|
|
45
|
+
const selectedSlots = new Set();
|
|
46
|
+
const selectedPieces = new Set();
|
|
47
|
+
const recordedFingerprint = (key) => {
|
|
48
|
+
const value = fingerprints[key];
|
|
49
|
+
if (value === undefined)
|
|
50
|
+
throw new Error(`No baseline fingerprint for retained work ${key}`);
|
|
51
|
+
return value;
|
|
52
|
+
};
|
|
53
|
+
for (const output of outputs) {
|
|
54
|
+
const asset = assets.get(output.path);
|
|
55
|
+
if (asset === undefined)
|
|
56
|
+
throw new Error("The retained output has no registered asset.");
|
|
57
|
+
const slot = legacyOutputSlot(output);
|
|
58
|
+
const workKey = legacyOutputWorkKey(output, project.config);
|
|
59
|
+
const recordId = deps.ids.next();
|
|
60
|
+
insertManifestOutput(deps.db, revision, {
|
|
61
|
+
slot,
|
|
62
|
+
workKey,
|
|
63
|
+
assetId: asset.id,
|
|
64
|
+
output,
|
|
65
|
+
fingerprint: recordedFingerprint(workKey),
|
|
66
|
+
state: "ready",
|
|
67
|
+
}, recordId);
|
|
68
|
+
if (!selectedSlots.has(slot)) {
|
|
69
|
+
selectOutputRecord(deps.db, revision.id, slot, recordId);
|
|
70
|
+
selectedSlots.add(slot);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const stage of stages) {
|
|
74
|
+
for (const piece of pieces.filter((row) => row.stageId === stage.id)) {
|
|
75
|
+
const image = piece.kind === "image" ? legacyImageOutput(piece, outputs) : undefined;
|
|
76
|
+
const path = pieceFile(piece.payload) ?? image?.path;
|
|
77
|
+
const key = legacyPieceKey(piece, stage.kind, outputs);
|
|
78
|
+
const recordId = deps.ids.next();
|
|
79
|
+
insertManifestPiece(deps.db, revision, {
|
|
80
|
+
key,
|
|
81
|
+
stageKind: stage.kind,
|
|
82
|
+
piece,
|
|
83
|
+
assetId: path === undefined ? null : (assets.get(path)?.id ?? null),
|
|
84
|
+
fingerprint: recordedFingerprint(key),
|
|
85
|
+
}, recordId);
|
|
86
|
+
if (!selectedPieces.has(key)) {
|
|
87
|
+
selectPieceRecord(deps.db, revision.id, key, recordId);
|
|
88
|
+
selectedPieces.add(key);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
deps.db
|
|
93
|
+
.prepare("INSERT INTO project_heads(project_id,revision_id) VALUES (?,?)")
|
|
94
|
+
.run(projectId, revision.id);
|
|
95
|
+
const view = getRevisionView(deps, projectId, revision.id);
|
|
96
|
+
if (view === undefined)
|
|
97
|
+
throw new Error("The adopted revision was not retained.");
|
|
98
|
+
return { ok: true, created: true, view };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
import { zipSync } from "fflate";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { stageKinds } from "../../kernel/pipeline.js";
|
|
6
|
+
import { contentTypeOf, downloadName, slugOf } from "../storage/downloads.js";
|
|
7
|
+
import { outputPath } from "../storage/layout.js";
|
|
8
|
+
import { projectTitle } from "../storage/repo.js";
|
|
9
|
+
import { outputSchema } from "../storage/schema.js";
|
|
10
|
+
import { positionedOutput } from "./projection.js";
|
|
11
|
+
import { outputsForRevision, revisionById } from "./repo.js";
|
|
12
|
+
import { stagePieceSchema } from "./schema.js";
|
|
13
|
+
export function findRevisionDownload(deps, projectId, revisionId, recordId) {
|
|
14
|
+
if (projectTitle(deps.db, projectId) === undefined)
|
|
15
|
+
return { ok: false, reason: "unknown-project" };
|
|
16
|
+
const revision = revisionById(deps.db, projectId, revisionId);
|
|
17
|
+
if (revision === undefined)
|
|
18
|
+
return { ok: false, reason: "unknown-asset" };
|
|
19
|
+
const output = deps.db
|
|
20
|
+
.prepare(`SELECT o.descriptor, a.path FROM revision_outputs o
|
|
21
|
+
JOIN project_assets a ON a.project_id=o.project_id AND a.id=o.asset_id
|
|
22
|
+
WHERE o.project_id=? AND o.revision_id=? AND o.id=?`)
|
|
23
|
+
.get(projectId, revisionId, recordId);
|
|
24
|
+
if (output !== undefined) {
|
|
25
|
+
const path = outputPath(deps.paths, projectId, z.string().parse(output.path));
|
|
26
|
+
const descriptor = outputSchema.parse(JSON.parse(z.string().parse(output.descriptor)));
|
|
27
|
+
return fileDownload(path, downloadName(slugOf(revision.config.title), { ...descriptor, path }));
|
|
28
|
+
}
|
|
29
|
+
const piece = deps.db
|
|
30
|
+
.prepare(`SELECT p.descriptor, p.stage_kind, a.path FROM revision_pieces p
|
|
31
|
+
JOIN project_assets a ON a.project_id=p.project_id AND a.id=p.asset_id
|
|
32
|
+
WHERE p.project_id=? AND p.revision_id=? AND p.id=?`)
|
|
33
|
+
.get(projectId, revisionId, recordId);
|
|
34
|
+
if (piece === undefined)
|
|
35
|
+
return { ok: false, reason: "unknown-asset" };
|
|
36
|
+
const path = outputPath(deps.paths, projectId, z.string().parse(piece.path));
|
|
37
|
+
const descriptor = stagePieceSchema.parse(JSON.parse(z.string().parse(piece.descriptor)));
|
|
38
|
+
const stageKind = z.enum(stageKinds).parse(piece.stage_kind);
|
|
39
|
+
return fileDownload(path, `${slugOf(revision.config.title)}-${stageKind}-${descriptor.kind}-${descriptor.idx}${extname(path)}`);
|
|
40
|
+
}
|
|
41
|
+
export function revisionImagesZip(deps, projectId, revisionId) {
|
|
42
|
+
if (projectTitle(deps.db, projectId) === undefined)
|
|
43
|
+
return { ok: false, reason: "unknown-project" };
|
|
44
|
+
const revision = revisionById(deps.db, projectId, revisionId);
|
|
45
|
+
if (revision === undefined)
|
|
46
|
+
return { ok: false, reason: "no-images" };
|
|
47
|
+
const slug = slugOf(revision.config.title);
|
|
48
|
+
const entries = {};
|
|
49
|
+
const images = outputsForRevision(deps.db, projectId, revisionId)
|
|
50
|
+
.filter((row) => row.selected && (row.output.role === "image" || row.output.role === "thumbnail"))
|
|
51
|
+
.map((row) => positionedOutput(revision, row))
|
|
52
|
+
.sort((a, b) => imageOrder(a.output.role, a.output.meta.index) -
|
|
53
|
+
imageOrder(b.output.role, b.output.meta.index));
|
|
54
|
+
for (const row of images) {
|
|
55
|
+
const asset = deps.db
|
|
56
|
+
.prepare("SELECT path FROM project_assets WHERE project_id=? AND id=?")
|
|
57
|
+
.get(projectId, row.assetId);
|
|
58
|
+
if (asset === undefined)
|
|
59
|
+
throw new Error("Revision image asset is not registered.");
|
|
60
|
+
const path = outputPath(deps.paths, projectId, z.string().parse(asset.path));
|
|
61
|
+
if (statSync(path, { throwIfNoEntry: false })?.isFile() !== true)
|
|
62
|
+
continue;
|
|
63
|
+
entries[downloadName(slug, { ...row.output, path })] = [readFileSync(path), { level: 0 }];
|
|
64
|
+
}
|
|
65
|
+
return Object.keys(entries).length === 0
|
|
66
|
+
? { ok: false, reason: "no-images" }
|
|
67
|
+
: { ok: true, filename: `${slug}-images.zip`, bytes: zipSync(entries) };
|
|
68
|
+
}
|
|
69
|
+
function fileDownload(path, filename) {
|
|
70
|
+
const stats = statSync(path, { throwIfNoEntry: false });
|
|
71
|
+
return stats === undefined || !stats.isFile()
|
|
72
|
+
? { ok: false, reason: "missing-file" }
|
|
73
|
+
: {
|
|
74
|
+
ok: true,
|
|
75
|
+
download: { path, filename, bytes: stats.size, contentType: contentTypeOf(path) },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function imageOrder(role, index) {
|
|
79
|
+
return role === "thumbnail" ? Number.MAX_SAFE_INTEGER : typeof index === "number" ? index : 0;
|
|
80
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
4
|
+
import { stageKinds } from "../../kernel/pipeline.js";
|
|
5
|
+
import { outputSchema } from "../storage/schema.js";
|
|
6
|
+
import { stagePieceSchema } from "./schema.js";
|
|
7
|
+
const selected = z.union([z.literal(0), z.literal(1)]).transform((value) => value === 1);
|
|
8
|
+
const commonRow = z.object({
|
|
9
|
+
id: z.string(),
|
|
10
|
+
asset_id: z.string().nullable(),
|
|
11
|
+
fingerprint: z.string(),
|
|
12
|
+
descriptor: z.string(),
|
|
13
|
+
publication_id: z.string().nullable(),
|
|
14
|
+
selected,
|
|
15
|
+
});
|
|
16
|
+
const outputRow = commonRow.extend({
|
|
17
|
+
slot: z.string(),
|
|
18
|
+
work_key: z.string(),
|
|
19
|
+
asset_id: z.string(),
|
|
20
|
+
state: z.enum(["ready", "outdated", "review"]),
|
|
21
|
+
});
|
|
22
|
+
const pieceRow = commonRow.extend({ piece_key: z.string(), stage_kind: z.enum(stageKinds) });
|
|
23
|
+
export function insertManifestOutput(db, revision, output, recordId, publicationId = null) {
|
|
24
|
+
if (publicationId !== null) {
|
|
25
|
+
const row = db
|
|
26
|
+
.prepare("SELECT * FROM revision_outputs WHERE project_id=? AND revision_id=? AND publication_id=? AND slot=?")
|
|
27
|
+
.get(revision.projectId, revision.id, publicationId, output.slot);
|
|
28
|
+
if (row !== undefined) {
|
|
29
|
+
const previous = toOutput(row);
|
|
30
|
+
if (!isDeepStrictEqual(previous, {
|
|
31
|
+
...output,
|
|
32
|
+
output: outputSchema.parse(JSON.parse(JSON.stringify(output.output))),
|
|
33
|
+
recordId: previous.recordId,
|
|
34
|
+
publicationId,
|
|
35
|
+
selected: previous.selected,
|
|
36
|
+
}))
|
|
37
|
+
throw new Error("Output publication conflicts with its retained result.");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
db.prepare(`INSERT INTO revision_outputs
|
|
42
|
+
(id,project_id,revision_id,slot,work_key,asset_id,fingerprint,state,descriptor,publication_id,selected,created_at)
|
|
43
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,0,?)`).run(recordId, revision.projectId, revision.id, output.slot, output.workKey, output.assetId, output.fingerprint, output.state, JSON.stringify(output.output), publicationId, revision.createdAt);
|
|
44
|
+
}
|
|
45
|
+
export function insertManifestPiece(db, revision, piece, recordId, publicationId = null) {
|
|
46
|
+
if (publicationId !== null) {
|
|
47
|
+
const row = db
|
|
48
|
+
.prepare("SELECT * FROM revision_pieces WHERE project_id=? AND revision_id=? AND publication_id=? AND piece_key=?")
|
|
49
|
+
.get(revision.projectId, revision.id, publicationId, piece.key);
|
|
50
|
+
if (row !== undefined) {
|
|
51
|
+
const previous = toPiece(row);
|
|
52
|
+
if (!isDeepStrictEqual(previous, {
|
|
53
|
+
...piece,
|
|
54
|
+
piece: stagePieceSchema.parse(JSON.parse(JSON.stringify(piece.piece))),
|
|
55
|
+
recordId: previous.recordId,
|
|
56
|
+
publicationId,
|
|
57
|
+
selected: previous.selected,
|
|
58
|
+
}))
|
|
59
|
+
throw new Error("Piece publication conflicts with its retained result.");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
db.prepare(`INSERT INTO revision_pieces
|
|
64
|
+
(id,project_id,revision_id,piece_key,stage_kind,asset_id,fingerprint,descriptor,publication_id,selected,created_at)
|
|
65
|
+
VALUES (?,?,?,?,?,?,?,?,?,0,?)`).run(recordId, revision.projectId, revision.id, piece.key, piece.stageKind, piece.assetId, piece.fingerprint, JSON.stringify(piece.piece), publicationId, revision.createdAt);
|
|
66
|
+
}
|
|
67
|
+
export function outputsForRevision(db, projectId, revisionId) {
|
|
68
|
+
return db
|
|
69
|
+
.prepare("SELECT * FROM revision_outputs WHERE project_id=? AND revision_id=? ORDER BY created_at,id")
|
|
70
|
+
.all(projectId, revisionId)
|
|
71
|
+
.map(toOutput);
|
|
72
|
+
}
|
|
73
|
+
export function piecesForRevision(db, projectId, revisionId) {
|
|
74
|
+
return db
|
|
75
|
+
.prepare("SELECT * FROM revision_pieces WHERE project_id=? AND revision_id=? ORDER BY created_at,id")
|
|
76
|
+
.all(projectId, revisionId)
|
|
77
|
+
.map(toPiece);
|
|
78
|
+
}
|
|
79
|
+
export function selectOutputRecord(db, revisionId, slot, recordId) {
|
|
80
|
+
transact(db, () => {
|
|
81
|
+
db.prepare("UPDATE revision_outputs SET selected=0 WHERE revision_id=? AND slot=? AND selected=1").run(revisionId, slot);
|
|
82
|
+
const result = db
|
|
83
|
+
.prepare("UPDATE revision_outputs SET selected=1 WHERE id=? AND revision_id=? AND slot=?")
|
|
84
|
+
.run(recordId, revisionId, slot);
|
|
85
|
+
if (Number(result.changes) !== 1)
|
|
86
|
+
throw new Error("Published output record was not found.");
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export function selectPieceRecord(db, revisionId, key, recordId) {
|
|
90
|
+
transact(db, () => {
|
|
91
|
+
db.prepare("UPDATE revision_pieces SET selected=0 WHERE revision_id=? AND piece_key=? AND selected=1").run(revisionId, key);
|
|
92
|
+
const result = db
|
|
93
|
+
.prepare("UPDATE revision_pieces SET selected=1 WHERE id=? AND revision_id=? AND piece_key=?")
|
|
94
|
+
.run(recordId, revisionId, key);
|
|
95
|
+
if (Number(result.changes) !== 1)
|
|
96
|
+
throw new Error("Published piece record was not found.");
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function toOutput(value) {
|
|
100
|
+
const row = outputRow.parse(value);
|
|
101
|
+
return {
|
|
102
|
+
recordId: row.id,
|
|
103
|
+
publicationId: row.publication_id,
|
|
104
|
+
selected: row.selected,
|
|
105
|
+
slot: row.slot,
|
|
106
|
+
workKey: row.work_key,
|
|
107
|
+
assetId: row.asset_id,
|
|
108
|
+
output: outputSchema.parse(JSON.parse(row.descriptor)),
|
|
109
|
+
fingerprint: row.fingerprint,
|
|
110
|
+
state: row.state,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function toPiece(value) {
|
|
114
|
+
const row = pieceRow.parse(value);
|
|
115
|
+
return {
|
|
116
|
+
recordId: row.id,
|
|
117
|
+
publicationId: row.publication_id,
|
|
118
|
+
selected: row.selected,
|
|
119
|
+
key: row.piece_key,
|
|
120
|
+
stageKind: row.stage_kind,
|
|
121
|
+
piece: stagePieceSchema.parse(JSON.parse(row.descriptor)),
|
|
122
|
+
assetId: row.asset_id,
|
|
123
|
+
fingerprint: row.fingerprint,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { outputPath, stagingPath } from "../storage/layout.js";
|
|
4
|
+
import { stagedFileById } from "../storage/repo.js";
|
|
5
|
+
export function bindUpload(content, upload, assetId) {
|
|
6
|
+
const to = upload.destination;
|
|
7
|
+
if (to.kind === "provided")
|
|
8
|
+
return { ...content, provided: { ...content.provided, [to.stage]: assetId } };
|
|
9
|
+
if (to.kind === "narration")
|
|
10
|
+
return {
|
|
11
|
+
...content,
|
|
12
|
+
narrationOverrides: { ...content.narrationOverrides, [to.key]: { kind: "asset", assetId } },
|
|
13
|
+
};
|
|
14
|
+
const image = content.imageDefinitions[to.imageKey];
|
|
15
|
+
if (image === undefined)
|
|
16
|
+
throw new Error("Validated image destination disappeared.");
|
|
17
|
+
return {
|
|
18
|
+
...content,
|
|
19
|
+
imageDefinitions: {
|
|
20
|
+
...content.imageDefinitions,
|
|
21
|
+
[to.imageKey]: { ...image, source: "provide", assetId, prompt: null, templateKey: null },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function validateUploads(deps, edit) {
|
|
26
|
+
const fields = [];
|
|
27
|
+
const staged = new Set();
|
|
28
|
+
const destinations = new Set();
|
|
29
|
+
for (const [index, upload] of (edit.uploads ?? []).entries()) {
|
|
30
|
+
const field = `uploads.${index}`;
|
|
31
|
+
const to = upload.destination;
|
|
32
|
+
if ((to.kind === "provided" && edit.config.sources[to.stage] !== "provide") ||
|
|
33
|
+
(to.kind === "narration" && edit.config.sources.audio !== "generate") ||
|
|
34
|
+
(to.kind === "image" && edit.config.sources.images === "off"))
|
|
35
|
+
fields.push({
|
|
36
|
+
field,
|
|
37
|
+
message: "Enable the corresponding provided or generated content before importing this file.",
|
|
38
|
+
});
|
|
39
|
+
const destination = JSON.stringify(to);
|
|
40
|
+
if (staged.has(upload.stagedFileId) || destinations.has(destination))
|
|
41
|
+
fields.push({ field, message: "Each upload and destination must be unique." });
|
|
42
|
+
staged.add(upload.stagedFileId);
|
|
43
|
+
destinations.add(destination);
|
|
44
|
+
const file = stagedFileById(deps.db, upload.stagedFileId);
|
|
45
|
+
const kind = to.kind === "provided" ? to.stage : to.kind === "image" ? "images" : "audio";
|
|
46
|
+
if (file === undefined ||
|
|
47
|
+
file.state !== "staged" ||
|
|
48
|
+
file.stageKind !== kind ||
|
|
49
|
+
!statSync(stagingPath(deps.paths, file.path), { throwIfNoEntry: false })?.isFile())
|
|
50
|
+
fields.push({ field, message: "Choose a completed upload for this stage." });
|
|
51
|
+
if (to.kind === "image" && edit.content.imageDefinitions[to.imageKey] === undefined)
|
|
52
|
+
fields.push({ field, message: "Choose an image in this revision." });
|
|
53
|
+
if (to.kind === "narration" && !to.key.startsWith("audio:"))
|
|
54
|
+
fields.push({ field, message: "Choose a narration segment." });
|
|
55
|
+
}
|
|
56
|
+
return fields;
|
|
57
|
+
}
|
|
58
|
+
export function validateAssetReferences(deps, projectId, content, prepared) {
|
|
59
|
+
const fields = [];
|
|
60
|
+
const refs = [];
|
|
61
|
+
for (const key of ["research", "article", "audio", "thumbnail"]) {
|
|
62
|
+
const id = content.provided[key];
|
|
63
|
+
if (id !== undefined)
|
|
64
|
+
refs.push([`content.provided.${key}`, id, key, false]);
|
|
65
|
+
}
|
|
66
|
+
for (const [key, row] of Object.entries(content.imageDefinitions))
|
|
67
|
+
if (row.assetId !== null)
|
|
68
|
+
refs.push([`content.imageDefinitions.${key}.assetId`, row.assetId, "images", true]);
|
|
69
|
+
for (const [key, row] of Object.entries(content.narrationOverrides))
|
|
70
|
+
if (row.kind === "asset")
|
|
71
|
+
refs.push([`content.narrationOverrides.${key}.assetId`, row.assetId, "audio", true]);
|
|
72
|
+
const roles = {
|
|
73
|
+
research: ["notes"],
|
|
74
|
+
article: ["article_md", "article_txt"],
|
|
75
|
+
audio: ["audio_body", "audio_intro", "audio_outro", "audio_export"],
|
|
76
|
+
images: ["image"],
|
|
77
|
+
thumbnail: ["thumbnail"],
|
|
78
|
+
};
|
|
79
|
+
for (const [field, id, kind, allowPiece] of refs) {
|
|
80
|
+
if (!prepared.some((row) => row.id === id && row.projectId === projectId) &&
|
|
81
|
+
deps.db
|
|
82
|
+
.prepare("SELECT 1 FROM project_assets WHERE project_id=? AND id=?")
|
|
83
|
+
.get(projectId, id) === undefined)
|
|
84
|
+
fields.push({ field, message: "Choose an asset belonging to this project." });
|
|
85
|
+
if (!prepared.some((row) => row.id === id) &&
|
|
86
|
+
deps.db
|
|
87
|
+
.prepare("SELECT 1 FROM revision_outputs WHERE project_id=? AND asset_id=? AND json_extract(descriptor,'$.stageKind')=? AND json_extract(descriptor,'$.role') IN (SELECT value FROM json_each(?))")
|
|
88
|
+
.get(projectId, id, kind, JSON.stringify(roles[kind])) === undefined &&
|
|
89
|
+
(!allowPiece ||
|
|
90
|
+
deps.db
|
|
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))
|
|
93
|
+
fields.push({ field, message: "Choose an asset for this content stage." });
|
|
94
|
+
}
|
|
95
|
+
return fields;
|
|
96
|
+
}
|
|
97
|
+
export async function measureAudio(deps, projectId, path) {
|
|
98
|
+
if (deps.measureAudio === undefined)
|
|
99
|
+
throw new Error("Audio inspection is unavailable.");
|
|
100
|
+
const duration = await deps.measureAudio(outputPath(deps.paths, projectId, path));
|
|
101
|
+
if (!Number.isFinite(duration) || duration < 0)
|
|
102
|
+
throw new Error("Audio inspection returned an invalid duration.");
|
|
103
|
+
return duration;
|
|
104
|
+
}
|
|
105
|
+
export function assetPath(deps, projectId, id) {
|
|
106
|
+
return z
|
|
107
|
+
.object({ path: z.string() })
|
|
108
|
+
.parse(deps.db
|
|
109
|
+
.prepare("SELECT path FROM project_assets WHERE project_id=? AND id=?")
|
|
110
|
+
.get(projectId, id)).path;
|
|
111
|
+
}
|
|
112
|
+
export function providedAssetSelected(base, kind, assetId) {
|
|
113
|
+
return (base.revision.config.sources[kind] === "provide" &&
|
|
114
|
+
base.outputs.some((row) => row.selected &&
|
|
115
|
+
row.assetId === assetId &&
|
|
116
|
+
row.workKey === (kind === "audio" ? "audio:provided" : "thumbnail:image") &&
|
|
117
|
+
row.output.role === (kind === "audio" ? "audio_body" : "thumbnail")));
|
|
118
|
+
}
|
|
119
|
+
export function validateReplacementAvailability(deps, base, edit) {
|
|
120
|
+
const previous = new Set([
|
|
121
|
+
...Object.values(base.revision.content.provided),
|
|
122
|
+
...Object.values(base.revision.content.imageDefinitions).map((row) => row.assetId),
|
|
123
|
+
...Object.values(base.revision.content.narrationOverrides).flatMap((row) => row.kind === "asset" ? [row.assetId] : []),
|
|
124
|
+
]);
|
|
125
|
+
for (const kind of ["audio", "thumbnail"]) {
|
|
126
|
+
const assetId = edit.content.provided[kind];
|
|
127
|
+
if (assetId !== undefined &&
|
|
128
|
+
edit.config.sources[kind] === "provide" &&
|
|
129
|
+
!providedAssetSelected(base, kind, assetId) &&
|
|
130
|
+
!edit.uploads?.some((row) => row.destination.kind === "provided" && row.destination.stage === kind))
|
|
131
|
+
previous.delete(assetId);
|
|
132
|
+
}
|
|
133
|
+
const next = [
|
|
134
|
+
...Object.values(edit.content.provided),
|
|
135
|
+
...Object.values(edit.content.imageDefinitions).map((row) => row.assetId),
|
|
136
|
+
...Object.values(edit.content.narrationOverrides).flatMap((row) => row.kind === "asset" ? [row.assetId] : []),
|
|
137
|
+
];
|
|
138
|
+
return next.flatMap((id) => id == null ||
|
|
139
|
+
previous.has(id) ||
|
|
140
|
+
statSync(outputPath(deps.paths, base.revision.projectId, assetPath(deps, base.revision.projectId, id)), { throwIfNoEntry: false })?.isFile()
|
|
141
|
+
? []
|
|
142
|
+
: [{ field: "content", message: "The replacement asset is missing. Upload it again." }]);
|
|
143
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { assetPath, measureAudio } from "./mutation-assets.js";
|
|
3
|
+
import { validateCues } from "./rules.js";
|
|
4
|
+
export function cuesChanged(base, edit) {
|
|
5
|
+
return (edit.content.subtitleCues !== undefined &&
|
|
6
|
+
!isDeepStrictEqual(base.revision.content.subtitleCues, edit.content.subtitleCues));
|
|
7
|
+
}
|
|
8
|
+
export async function inspectCueAudio(deps, base, edit, prepared) {
|
|
9
|
+
const durations = new Map();
|
|
10
|
+
if (!cuesChanged(base, edit))
|
|
11
|
+
return durations;
|
|
12
|
+
for (const row of [
|
|
13
|
+
...base.outputs
|
|
14
|
+
.filter((row) => row.selected && row.available)
|
|
15
|
+
.map((row) => ({ assetId: row.assetId, output: row.output })),
|
|
16
|
+
...prepared.map((row) => ({ assetId: row.asset.id, output: row.output })),
|
|
17
|
+
]) {
|
|
18
|
+
if (!row.output.role.startsWith("audio_") || row.output.role === "audio_export")
|
|
19
|
+
continue;
|
|
20
|
+
durations.set(row.assetId, row.output.durationMs ?? (await measureAudio(deps, base.revision.projectId, row.output.path)));
|
|
21
|
+
}
|
|
22
|
+
for (const row of base.pieces.filter((row) => row.selected && row.available && row.stageKind === "audio" && row.assetId !== null))
|
|
23
|
+
if (row.assetId !== null && !durations.has(row.assetId))
|
|
24
|
+
durations.set(row.assetId, await measureAudio(deps, base.revision.projectId, assetPath(deps, base.revision.projectId, row.assetId)));
|
|
25
|
+
for (const row of Object.values(edit.content.narrationOverrides))
|
|
26
|
+
if (row.kind === "asset" && !durations.has(row.assetId))
|
|
27
|
+
durations.set(row.assetId, await measureAudio(deps, base.revision.projectId, assetPath(deps, base.revision.projectId, row.assetId)));
|
|
28
|
+
return durations;
|
|
29
|
+
}
|
|
30
|
+
export function validateProposedCues(base, edit, plan, prepared, durations) {
|
|
31
|
+
const cues = edit.content.subtitleCues;
|
|
32
|
+
if (cues === undefined || !cuesChanged(base, edit))
|
|
33
|
+
return [];
|
|
34
|
+
const fail = () => [
|
|
35
|
+
{
|
|
36
|
+
field: "content.subtitleCues",
|
|
37
|
+
message: "Save captions against the current, complete narration timeline.",
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
const candidates = [
|
|
41
|
+
...plan.manifest.outputs
|
|
42
|
+
.filter((row) => row.state === "ready")
|
|
43
|
+
.map((row) => ({ key: row.workKey, assetId: row.assetId, duration: row.output.durationMs })),
|
|
44
|
+
...prepared
|
|
45
|
+
.filter((row) => row.upload?.destination.kind !== "narration")
|
|
46
|
+
.map((row) => ({ key: row.workKey, assetId: row.asset.id, duration: row.output.durationMs })),
|
|
47
|
+
];
|
|
48
|
+
const durationFor = (key) => {
|
|
49
|
+
const selected = candidates.findLast((row) => row.key === key);
|
|
50
|
+
if (selected !== undefined)
|
|
51
|
+
return selected.duration ?? durations.get(selected.assetId);
|
|
52
|
+
const recipe = plan.recipes.find((row) => row.key === key);
|
|
53
|
+
if (recipe === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (recipe.input.kind === "provided" && recipe.input.assetId !== null)
|
|
56
|
+
return durations.get(recipe.input.assetId);
|
|
57
|
+
if (recipe.input.kind === "local" && recipe.input.operation === "concat-narration") {
|
|
58
|
+
const parts = recipe.dependsOn.map(durationFor);
|
|
59
|
+
return parts.some((one) => one === undefined)
|
|
60
|
+
? undefined
|
|
61
|
+
: parts.reduce((sum, one) => sum + (one ?? 0), 0);
|
|
62
|
+
}
|
|
63
|
+
if (plan.baseFingerprints[key] !== plan.fingerprints[key])
|
|
64
|
+
return undefined;
|
|
65
|
+
const prefix = key.replace(/:1$/, ":");
|
|
66
|
+
const parts = plan.manifest.pieces.filter((row) => row.stageKind === "audio" &&
|
|
67
|
+
row.piece.state === "done" &&
|
|
68
|
+
(row.key === key || row.key.startsWith(prefix)));
|
|
69
|
+
if (parts.length === 0)
|
|
70
|
+
return undefined;
|
|
71
|
+
const values = parts.map((row) => row.assetId === null ? undefined : durations.get(row.assetId));
|
|
72
|
+
return values.some((one) => one === undefined)
|
|
73
|
+
? undefined
|
|
74
|
+
: values.reduce((sum, one) => sum + (one ?? 0), 0);
|
|
75
|
+
};
|
|
76
|
+
const keys = plan.config.sources.audio === "provide"
|
|
77
|
+
? ["audio:provided"]
|
|
78
|
+
: ["audio:intro", "audio:body:concat", "audio:outro"].filter((key) => plan.fingerprints[key] !== undefined);
|
|
79
|
+
if (keys.length === 0)
|
|
80
|
+
return fail();
|
|
81
|
+
const parts = keys.map(durationFor);
|
|
82
|
+
if (parts.some((one) => one === undefined))
|
|
83
|
+
return fail();
|
|
84
|
+
const seconds = parts.reduce((sum, one) => sum + (one ?? 0), 0) / 1000 +
|
|
85
|
+
Math.max(0, keys.length - 1) * plan.config.silenceGapSeconds;
|
|
86
|
+
return validateCues(cues.cues, seconds);
|
|
87
|
+
}
|
|
88
|
+
export function measuredOutputs(deps, base, durations) {
|
|
89
|
+
return base.outputs.flatMap((row) => {
|
|
90
|
+
const durationMs = durations.get(row.assetId);
|
|
91
|
+
return row.selected && row.output.durationMs === null && durationMs !== undefined
|
|
92
|
+
? [{ ...row, output: { ...row.output, id: deps.ids.next(), durationMs } }]
|
|
93
|
+
: [];
|
|
94
|
+
});
|
|
95
|
+
}
|