@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,109 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { plainText } from "../article/plain.js";
|
|
4
|
+
import { splitEndMatter } from "../article/split.js";
|
|
5
|
+
import { outputPath } from "../storage/layout.js";
|
|
6
|
+
import { probeDurationMs } from "../video/ffmpeg.js";
|
|
7
|
+
import { audioTimeline } from "../video/plan.js";
|
|
8
|
+
import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
|
|
9
|
+
export function exportSnapshot(deps, context, piece) {
|
|
10
|
+
const view = executionView(deps, context.work.projectId, context.work.revisionId);
|
|
11
|
+
const row = deps.db
|
|
12
|
+
.prepare("SELECT recipe_context FROM revision_work WHERE id=?")
|
|
13
|
+
.get(context.work.workId);
|
|
14
|
+
if (view === undefined || row === undefined)
|
|
15
|
+
throw new Error("The export revision no longer exists.");
|
|
16
|
+
const plan = executionPlan(deps, view, savedCatalogue(row.recipe_context));
|
|
17
|
+
const recipe = plan.recipes.find((one) => one.key === piece.key && one.fingerprint === piece.fingerprint);
|
|
18
|
+
if (recipe === undefined || recipe.deferred || recipe.unresolved)
|
|
19
|
+
throw new Error("The export inputs do not match the admitted recipe.");
|
|
20
|
+
return { view, plan };
|
|
21
|
+
}
|
|
22
|
+
export async function revisionAudio(deps, context, view) {
|
|
23
|
+
const config = view.revision.config;
|
|
24
|
+
if (config.sources.audio === "off")
|
|
25
|
+
return [];
|
|
26
|
+
const input = async (role) => {
|
|
27
|
+
const row = view.outputs.find((one) => one.selected && one.available && one.state === "ready" && one.output.role === role);
|
|
28
|
+
if (row === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
const path = outputPath(deps.paths, context.work.projectId, row.output.path);
|
|
31
|
+
const duration = row.output.durationMs ??
|
|
32
|
+
(await (deps.measureAudio?.(path, context.signal) ??
|
|
33
|
+
probeDurationMs(deps.ffmpeg, path, context.signal, deps.log)));
|
|
34
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
35
|
+
throw new Error("The narration duration could not be measured.");
|
|
36
|
+
return { path, seconds: duration / 1000 };
|
|
37
|
+
};
|
|
38
|
+
const body = await input("audio_body");
|
|
39
|
+
if (body === undefined)
|
|
40
|
+
throw new Error("The revision has no complete narration.");
|
|
41
|
+
return audioTimeline({
|
|
42
|
+
body,
|
|
43
|
+
intro: config.sources.audio === "generate" && config.intro !== undefined
|
|
44
|
+
? await input("audio_intro")
|
|
45
|
+
: undefined,
|
|
46
|
+
outro: config.sources.audio === "generate" && config.outro !== undefined
|
|
47
|
+
? await input("audio_outro")
|
|
48
|
+
: undefined,
|
|
49
|
+
gapSeconds: config.silenceGapSeconds,
|
|
50
|
+
}, config.sources.video === "off" ? 1 / 48000 : 1 / 30);
|
|
51
|
+
}
|
|
52
|
+
export function revisionTranscript(deps, snapshot, kind) {
|
|
53
|
+
const { view, plan } = snapshot;
|
|
54
|
+
const concat = plan.recipes.find((one) => one.key === (kind === "body" ? "audio:body:concat" : `audio:${kind}`));
|
|
55
|
+
if (concat !== undefined) {
|
|
56
|
+
const texts = concat.dependsOn.map((key) => {
|
|
57
|
+
const row = view.pieces.find((one) => one.key === key && one.selected && one.available && one.piece.state === "done");
|
|
58
|
+
const payload = row?.piece.payload === null || row?.piece.payload === undefined
|
|
59
|
+
? undefined
|
|
60
|
+
: z.object({ text: z.string().optional() }).parse(JSON.parse(row.piece.payload));
|
|
61
|
+
if (payload?.text !== undefined)
|
|
62
|
+
return payload.text;
|
|
63
|
+
const recipe = plan.recipes.find((one) => one.key === key);
|
|
64
|
+
if (recipe?.input.kind === "tts")
|
|
65
|
+
return recipe.input.text;
|
|
66
|
+
if (recipe?.input.kind === "provided" &&
|
|
67
|
+
Array.isArray(recipe.input.semantic) &&
|
|
68
|
+
typeof recipe.input.semantic[0] === "string")
|
|
69
|
+
return recipe.input.semantic[0];
|
|
70
|
+
throw new Error("The saved narration transcript is incomplete.");
|
|
71
|
+
});
|
|
72
|
+
if (texts.length > 0)
|
|
73
|
+
return texts.join("\n");
|
|
74
|
+
}
|
|
75
|
+
if (kind === "body") {
|
|
76
|
+
const row = view.outputs.find((one) => one.selected && one.available && one.state === "ready" && one.output.role === "article_txt");
|
|
77
|
+
if (row !== undefined)
|
|
78
|
+
return readFileSync(outputPath(deps.paths, view.revision.projectId, row.output.path), "utf8");
|
|
79
|
+
if (view.articleMarkdown !== null)
|
|
80
|
+
return plainText(splitEndMatter(view.articleMarkdown).body);
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`The saved ${kind} narration transcript is missing.`);
|
|
83
|
+
}
|
|
84
|
+
export function retainedOutput(deps, view, row) {
|
|
85
|
+
const asset = z
|
|
86
|
+
.object({
|
|
87
|
+
id: z.string(),
|
|
88
|
+
project_id: z.string(),
|
|
89
|
+
path: z.string(),
|
|
90
|
+
bytes: z.number(),
|
|
91
|
+
created_at: z.string(),
|
|
92
|
+
})
|
|
93
|
+
.parse(deps.db
|
|
94
|
+
.prepare("SELECT * FROM project_assets WHERE id=? AND project_id=?")
|
|
95
|
+
.get(row.assetId, view.revision.projectId));
|
|
96
|
+
return {
|
|
97
|
+
slot: row.slot,
|
|
98
|
+
workKey: row.workKey,
|
|
99
|
+
fingerprint: row.fingerprint,
|
|
100
|
+
asset: {
|
|
101
|
+
id: asset.id,
|
|
102
|
+
projectId: asset.project_id,
|
|
103
|
+
path: asset.path,
|
|
104
|
+
bytes: asset.bytes,
|
|
105
|
+
createdAt: asset.created_at,
|
|
106
|
+
},
|
|
107
|
+
output: { ...row.output, id: deps.ids.next(), createdAt: deps.clock.now().toISOString() },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { extname, join, relative, sep } from "node:path";
|
|
3
|
+
import { allocateAsset, discardPreparedAssets, sealAsset } from "../storage/assets.js";
|
|
4
|
+
import { outputPath, projectDir } from "../storage/layout.js";
|
|
5
|
+
import { audioExportArgs } from "../video/audio-export.js";
|
|
6
|
+
import { renderArgs, runFfmpeg } from "../video/ffmpeg.js";
|
|
7
|
+
import { planRender } from "../video/plan.js";
|
|
8
|
+
import { exportSnapshot, retainedOutput, revisionAudio, } from "./runtime-export-inputs.js";
|
|
9
|
+
import { preparedResult, preparedText, publishResult } from "./runtime-publication.js";
|
|
10
|
+
export async function executeExportRecipe(deps, context, piece) {
|
|
11
|
+
if (deps.db
|
|
12
|
+
.prepare("SELECT state FROM revision_work_pieces WHERE id=? AND work_id=?")
|
|
13
|
+
.get(piece.id, context.work.workId)?.state === "done")
|
|
14
|
+
return "done";
|
|
15
|
+
if (!context.maySubmit(piece.id))
|
|
16
|
+
return "held";
|
|
17
|
+
context.signal.throwIfAborted();
|
|
18
|
+
const snapshot = exportSnapshot(deps, context, piece);
|
|
19
|
+
const { view } = snapshot;
|
|
20
|
+
const audio = await revisionAudio(deps, context, view);
|
|
21
|
+
const wav = piece.key === "export:wav";
|
|
22
|
+
if (!wav && piece.key !== "export:video")
|
|
23
|
+
throw new Error("Unknown media export recipe.");
|
|
24
|
+
if (wav && audio.length === 0)
|
|
25
|
+
throw new Error("Audio export needs complete narration.");
|
|
26
|
+
const filename = wav ? "audio.wav" : "video.mp4";
|
|
27
|
+
const pending = allocateAsset(deps, context.work.projectId, filename);
|
|
28
|
+
const prepared = [];
|
|
29
|
+
let directory;
|
|
30
|
+
try {
|
|
31
|
+
const captions = snapshot.view.outputs.filter((row) => row.selected && row.available && row.state === "ready" && row.workKey === "subtitles:files");
|
|
32
|
+
const burn = !wav && view.revision.config.subtitles?.mode === "burn-in";
|
|
33
|
+
if (burn)
|
|
34
|
+
directory = captionDirectory(deps, context, snapshot);
|
|
35
|
+
const mode = burn
|
|
36
|
+
? "burn-in"
|
|
37
|
+
: captions.some((row) => row.output.role === "subtitles_srt") &&
|
|
38
|
+
captions.some((row) => row.output.role === "subtitles_vtt")
|
|
39
|
+
? "files"
|
|
40
|
+
: "off";
|
|
41
|
+
const config = view.revision.config;
|
|
42
|
+
const segment = (kind) => {
|
|
43
|
+
const row = audio.find((one) => one.kind === kind);
|
|
44
|
+
return row?.path === null || row === undefined
|
|
45
|
+
? undefined
|
|
46
|
+
: { path: row.path, seconds: row.seconds };
|
|
47
|
+
};
|
|
48
|
+
const images = (wav ? [] : view.revision.content.imageOrder).map((key) => {
|
|
49
|
+
const row = view.outputs.find((one) => one.workKey === `image:${key}` && one.selected && one.available && one.state === "ready");
|
|
50
|
+
if (row === undefined)
|
|
51
|
+
throw new Error("The revision is missing an image selected for its slideshow.");
|
|
52
|
+
return outputPath(deps.paths, context.work.projectId, row.output.path);
|
|
53
|
+
});
|
|
54
|
+
const plan = wav
|
|
55
|
+
? undefined
|
|
56
|
+
: planRender({
|
|
57
|
+
format: config.format,
|
|
58
|
+
gapSeconds: config.silenceGapSeconds,
|
|
59
|
+
body: segment("body"),
|
|
60
|
+
intro: segment("intro"),
|
|
61
|
+
outro: segment("outro"),
|
|
62
|
+
images,
|
|
63
|
+
output: pending.absolutePath,
|
|
64
|
+
});
|
|
65
|
+
const totalSeconds = plan?.totalSeconds ?? audio.reduce((sum, row) => sum + row.seconds, 0);
|
|
66
|
+
const projectDirectory = projectDir(deps.paths, context.work.projectId);
|
|
67
|
+
const relativePath = (path) => relative(projectDirectory, path).split(sep).join("/");
|
|
68
|
+
const record = plan === undefined
|
|
69
|
+
? {
|
|
70
|
+
sampleRate: 48000,
|
|
71
|
+
channels: 2,
|
|
72
|
+
codec: "pcm_s16le",
|
|
73
|
+
gapSeconds: config.silenceGapSeconds,
|
|
74
|
+
totalSeconds,
|
|
75
|
+
audio: audio.map((row) => ({
|
|
76
|
+
...row,
|
|
77
|
+
path: row.path === null ? null : relativePath(row.path),
|
|
78
|
+
})),
|
|
79
|
+
output: pending.path,
|
|
80
|
+
}
|
|
81
|
+
: {
|
|
82
|
+
...plan,
|
|
83
|
+
output: pending.path,
|
|
84
|
+
audio: plan.audio.map((row) => ({
|
|
85
|
+
...row,
|
|
86
|
+
path: row.path === null ? null : relativePath(row.path),
|
|
87
|
+
})),
|
|
88
|
+
images: plan.images.map((row) => ({ ...row, path: relativePath(row.path) })),
|
|
89
|
+
subtitles: config.subtitles,
|
|
90
|
+
};
|
|
91
|
+
if (!context.maySubmit(piece.id))
|
|
92
|
+
return "held";
|
|
93
|
+
await runFfmpeg({
|
|
94
|
+
bin: deps.ffmpeg,
|
|
95
|
+
args: plan === undefined ? audioExportArgs(audio, pending.absolutePath) : renderArgs(plan, burn),
|
|
96
|
+
cwd: directory,
|
|
97
|
+
signal: context.signal,
|
|
98
|
+
log: deps.log,
|
|
99
|
+
onProgress: (elapsedMs) => context.emit({
|
|
100
|
+
type: "stage.progress",
|
|
101
|
+
projectId: context.work.projectId,
|
|
102
|
+
stage: "video",
|
|
103
|
+
current: Math.min(100, Math.round(elapsedMs / (totalSeconds * 10))),
|
|
104
|
+
total: 100,
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
context.signal.throwIfAborted();
|
|
108
|
+
const asset = sealAsset(deps, pending);
|
|
109
|
+
prepared.push(preparedResult(deps, context, piece, wav ? "audio_export" : "video", asset, Math.round(totalSeconds * 1000), { subtitlesMode: mode }));
|
|
110
|
+
prepared.push(preparedText(deps, context, piece, "render_params", "render.json", JSON.stringify(record)));
|
|
111
|
+
prepared.push(...captions.map((row) => retainedOutput(deps, view, row)));
|
|
112
|
+
await publishResult(deps, context, piece, prepared, { totalSeconds }, asset);
|
|
113
|
+
deps.count?.("stage.completed", { stage: "video" });
|
|
114
|
+
return "done";
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
discardPreparedAssets(deps, [pending, ...prepared.map((one) => one.asset)]);
|
|
118
|
+
if (directory !== undefined)
|
|
119
|
+
rmSync(directory, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function captionDirectory(deps, context, snapshot) {
|
|
123
|
+
const find = (role) => snapshot.view.outputs.find((row) => row.selected && row.available && row.state === "ready" && row.output.role === role);
|
|
124
|
+
const ass = find("subtitle_ass");
|
|
125
|
+
const font = find("subtitle_font");
|
|
126
|
+
if (ass === undefined || font === undefined)
|
|
127
|
+
throw new Error("Burned subtitles need prepared captions and their saved font.");
|
|
128
|
+
const directory = mkdtempSync(join(projectDir(deps.paths, context.work.projectId), "render-"));
|
|
129
|
+
try {
|
|
130
|
+
mkdirSync(join(directory, "fonts"), { mode: 0o700 });
|
|
131
|
+
copyFileSync(outputPath(deps.paths, context.work.projectId, ass.output.path), join(directory, "subtitles.ass"));
|
|
132
|
+
copyFileSync(outputPath(deps.paths, context.work.projectId, font.output.path), join(directory, "fonts", `selected${extname(font.output.path)}`));
|
|
133
|
+
return directory;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
rmSync(directory, { recursive: true, force: true });
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { recipeInputSchema } from "./recipe-input-schema.js";
|
|
3
|
+
import { executionView } from "./runtime-plan.js";
|
|
4
|
+
export function frozenInstructions(deps, context, current) {
|
|
5
|
+
const view = executionView(deps, context.work.projectId, context.work.revisionId);
|
|
6
|
+
const selected = new Set(view?.pieces
|
|
7
|
+
.filter((row) => row.selected && row.piece.state === "done")
|
|
8
|
+
.map((row) => row.piece.id));
|
|
9
|
+
const rows = deps.db
|
|
10
|
+
.prepare(`SELECT p.id,p.work_id,p.work_key,p.input_json,p.result_json,p.state FROM revision_work_pieces p JOIN revision_work w ON w.id=p.work_id WHERE w.project_id=? AND w.kind=? ORDER BY w.rowid,p.rowid`)
|
|
11
|
+
.all(context.work.projectId, context.work.kind);
|
|
12
|
+
const sections = [];
|
|
13
|
+
for (const raw of rows) {
|
|
14
|
+
const row = z
|
|
15
|
+
.object({
|
|
16
|
+
id: z.string(),
|
|
17
|
+
work_id: z.string(),
|
|
18
|
+
work_key: z.string(),
|
|
19
|
+
input_json: z.string(),
|
|
20
|
+
result_json: z.string().nullable(),
|
|
21
|
+
state: z.string(),
|
|
22
|
+
})
|
|
23
|
+
.parse(raw);
|
|
24
|
+
if (row.id !== current.id &&
|
|
25
|
+
!selected.has(row.id) &&
|
|
26
|
+
!(row.work_id === current.workId && row.result_json !== null))
|
|
27
|
+
continue;
|
|
28
|
+
const input = recipeInputSchema.parse(JSON.parse(row.input_json));
|
|
29
|
+
if (input.kind !== "llm")
|
|
30
|
+
continue;
|
|
31
|
+
sections.push(`## ${label(row.work_key)}\n\n${input.messages.map((message) => `### ${message.role}\n\n${message.content}`).join("\n\n")}`);
|
|
32
|
+
}
|
|
33
|
+
return `${sections.join("\n\n")}\n`;
|
|
34
|
+
}
|
|
35
|
+
function label(key) {
|
|
36
|
+
if (key === "research:planner")
|
|
37
|
+
return "Research outline";
|
|
38
|
+
if (key === "research:notes")
|
|
39
|
+
return "Research synthesis";
|
|
40
|
+
if (key.startsWith("research:chapter:"))
|
|
41
|
+
return `Research chapter ${key.split(":").at(-1)}`;
|
|
42
|
+
if (key === "article:body")
|
|
43
|
+
return "Article";
|
|
44
|
+
if (key.startsWith("article:continuation:"))
|
|
45
|
+
return `Article continuation ${key.split(":").at(-1)}`;
|
|
46
|
+
if (key === "entry:intro:text")
|
|
47
|
+
return "Intro";
|
|
48
|
+
if (key === "entry:outro:text")
|
|
49
|
+
return "Outro";
|
|
50
|
+
return "Thumbnail instructions";
|
|
51
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { plainText } from "../article/plain.js";
|
|
5
|
+
import { splitEndMatter } from "../article/split.js";
|
|
6
|
+
import { joinNarration } from "../narration/concat.js";
|
|
7
|
+
import { allocateAsset, discardPreparedAssets, sealAsset } from "../storage/assets.js";
|
|
8
|
+
import { outputPath } from "../storage/layout.js";
|
|
9
|
+
import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
|
|
10
|
+
import { preparedResult, preparedText, preparedTexts, publishResult, } from "./runtime-publication.js";
|
|
11
|
+
export async function executeLocalRecipe(deps, context, piece) {
|
|
12
|
+
if (deps.db
|
|
13
|
+
.prepare("SELECT state FROM revision_work_pieces WHERE id=? AND work_id=?")
|
|
14
|
+
.get(piece.id, context.work.workId)?.state === "done")
|
|
15
|
+
return "done";
|
|
16
|
+
if (!context.maySubmit(piece.id))
|
|
17
|
+
return "held";
|
|
18
|
+
const input = piece.input;
|
|
19
|
+
if (input.kind === "provided") {
|
|
20
|
+
if (input.assetId === null)
|
|
21
|
+
throw new Error("The provided recipe has no retained asset.");
|
|
22
|
+
const row = z
|
|
23
|
+
.object({ id: z.string(), project_id: z.string(), path: z.string(), created_at: z.string() })
|
|
24
|
+
.parse(deps.db
|
|
25
|
+
.prepare("SELECT * FROM project_assets WHERE id=? AND project_id=?")
|
|
26
|
+
.get(input.assetId, context.work.projectId));
|
|
27
|
+
const asset = {
|
|
28
|
+
id: row.id,
|
|
29
|
+
projectId: row.project_id,
|
|
30
|
+
path: row.path,
|
|
31
|
+
createdAt: row.created_at,
|
|
32
|
+
bytes: statSync(outputPath(deps.paths, row.project_id, row.path)).size,
|
|
33
|
+
};
|
|
34
|
+
const role = piece.key === "audio:provided"
|
|
35
|
+
? "audio_body"
|
|
36
|
+
: piece.key === "thumbnail:image"
|
|
37
|
+
? "thumbnail"
|
|
38
|
+
: piece.key.startsWith("image:")
|
|
39
|
+
? "image"
|
|
40
|
+
: undefined;
|
|
41
|
+
await publishResult(deps, context, piece, role === undefined ? [] : [preparedResult(deps, context, piece, role, asset)], {}, asset);
|
|
42
|
+
return "done";
|
|
43
|
+
}
|
|
44
|
+
if (input.kind !== "local")
|
|
45
|
+
throw new Error("Expected an exact local recipe.");
|
|
46
|
+
if (input.operation === "concat-narration") {
|
|
47
|
+
await concatenate(deps, context, piece);
|
|
48
|
+
return "done";
|
|
49
|
+
}
|
|
50
|
+
if (input.operation === "provided-article" || input.operation === "manual-article") {
|
|
51
|
+
const text = z.string().parse(input.values);
|
|
52
|
+
const parts = splitEndMatter(text);
|
|
53
|
+
await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
|
|
54
|
+
["article_md", "article.md", text],
|
|
55
|
+
["article_txt", "article.txt", plainText(parts.body)],
|
|
56
|
+
...(parts.sources ? [["sources", "sources.md", parts.sources]] : []),
|
|
57
|
+
...(parts.glossary ? [["glossary", "glossary.md", parts.glossary]] : []),
|
|
58
|
+
]), { text });
|
|
59
|
+
return "done";
|
|
60
|
+
}
|
|
61
|
+
if (input.operation === "provided-notes") {
|
|
62
|
+
const text = z.string().parse(input.values);
|
|
63
|
+
await publishResult(deps, context, piece, [preparedText(deps, context, piece, "notes", "notes.md", text)], { text });
|
|
64
|
+
return "done";
|
|
65
|
+
}
|
|
66
|
+
if (input.operation === "entry-text") {
|
|
67
|
+
await publishResult(deps, context, piece, [], {
|
|
68
|
+
text: z.string().parse(input.values),
|
|
69
|
+
category: piece.key.includes(":intro:") ? "intro" : "outro",
|
|
70
|
+
});
|
|
71
|
+
return "done";
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`No local executor for ${input.operation}.`);
|
|
74
|
+
}
|
|
75
|
+
async function concatenate(deps, context, piece) {
|
|
76
|
+
const view = executionView(deps, context.work.projectId, context.work.revisionId);
|
|
77
|
+
if (view === undefined)
|
|
78
|
+
throw new Error("The pinned revision is missing.");
|
|
79
|
+
const row = deps.db
|
|
80
|
+
.prepare("SELECT recipe_context FROM revision_work WHERE id=?")
|
|
81
|
+
.get(context.work.workId);
|
|
82
|
+
const plan = executionPlan(deps, view, savedCatalogue(row?.recipe_context));
|
|
83
|
+
const recipe = plan.recipes.find((candidate) => candidate.key === piece.key && candidate.fingerprint === piece.fingerprint);
|
|
84
|
+
if (recipe === undefined)
|
|
85
|
+
throw new Error("The pinned concatenation no longer matches its inputs.");
|
|
86
|
+
const files = recipe.dependsOn.map((key) => {
|
|
87
|
+
const input = view.pieces.find((candidate) => candidate.key === key &&
|
|
88
|
+
candidate.selected &&
|
|
89
|
+
candidate.available &&
|
|
90
|
+
candidate.piece.state === "done" &&
|
|
91
|
+
candidate.fingerprint === plan.recipes.find((value) => value.key === key)?.fingerprint);
|
|
92
|
+
if (input?.assetId === null || input === undefined)
|
|
93
|
+
throw new Error("A narration part has no retained audio.");
|
|
94
|
+
const asset = deps.db
|
|
95
|
+
.prepare("SELECT path FROM project_assets WHERE id=? AND project_id=?")
|
|
96
|
+
.get(input.assetId, context.work.projectId);
|
|
97
|
+
return outputPath(deps.paths, context.work.projectId, z.string().parse(asset?.path));
|
|
98
|
+
});
|
|
99
|
+
const pending = allocateAsset(deps, context.work.projectId, "narration.mp3");
|
|
100
|
+
try {
|
|
101
|
+
const durationMs = await joinNarration({ bin: deps.ffmpeg, log: deps.log }, {
|
|
102
|
+
files,
|
|
103
|
+
output: pending.absolutePath,
|
|
104
|
+
listPath: join(dirname(pending.absolutePath), "parts.txt"),
|
|
105
|
+
signal: context.signal,
|
|
106
|
+
});
|
|
107
|
+
context.signal.throwIfAborted();
|
|
108
|
+
const asset = sealAsset(deps, pending);
|
|
109
|
+
const role = piece.key === "audio:intro"
|
|
110
|
+
? "audio_intro"
|
|
111
|
+
: piece.key === "audio:outro"
|
|
112
|
+
? "audio_outro"
|
|
113
|
+
: "audio_body";
|
|
114
|
+
await publishResult(deps, context, piece, [
|
|
115
|
+
preparedResult(deps, context, piece, role, asset, durationMs, view.revision.config.audio === undefined
|
|
116
|
+
? {}
|
|
117
|
+
: {
|
|
118
|
+
provider: view.revision.config.audio.provider,
|
|
119
|
+
model: view.revision.config.audio.model,
|
|
120
|
+
voice: view.revision.config.audio.voice,
|
|
121
|
+
}),
|
|
122
|
+
], { durationMs }, asset);
|
|
123
|
+
for (const key of recipe.dependsOn) {
|
|
124
|
+
const retained = view.pieces.find((candidate) => candidate.key === key && candidate.selected && candidate.available);
|
|
125
|
+
if (retained?.publicationId === null || retained === undefined)
|
|
126
|
+
continue;
|
|
127
|
+
const owner = deps.db
|
|
128
|
+
.prepare("SELECT work_id FROM revision_work_pieces WHERE id=?")
|
|
129
|
+
.get(retained.publicationId);
|
|
130
|
+
if (typeof owner?.work_id === "string")
|
|
131
|
+
deps.audioPreviews?.clear(context.work.projectId, owner.work_id);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
discardPreparedAssets(deps, [pending]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
3
|
+
import { currentRevisionId } from "../revisions/repo.js";
|
|
4
|
+
import { getRevisionView } from "../revisions/view.js";
|
|
5
|
+
import { insertInvocation } from "./runtime-admission.js";
|
|
6
|
+
import { bindNarrationReuse, narrationOrdinal } from "./runtime-narration-reuse.js";
|
|
7
|
+
import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
|
|
8
|
+
import { workPieces } from "./work-records.js";
|
|
9
|
+
const reservation = z.object({
|
|
10
|
+
revision_id: z.string(),
|
|
11
|
+
work_id: z.string(),
|
|
12
|
+
work_key: z.string(),
|
|
13
|
+
fingerprint: z.string(),
|
|
14
|
+
logical_key: z.string().nullable(),
|
|
15
|
+
desired_fingerprint: z.string().nullable(),
|
|
16
|
+
origin_revision: z.string(),
|
|
17
|
+
recipe_context: z.string(),
|
|
18
|
+
admission_id: z.string().nullable(),
|
|
19
|
+
});
|
|
20
|
+
export function materializeAdmittedWork(deps, projectId) {
|
|
21
|
+
transact(deps.db, () => {
|
|
22
|
+
const head = currentRevisionId(deps.db, projectId);
|
|
23
|
+
if (head === undefined)
|
|
24
|
+
return;
|
|
25
|
+
const reservations = deps.db
|
|
26
|
+
.prepare(`SELECT r.*,w.revision_id AS origin_revision,w.recipe_context,w.admission_id FROM revision_work_reservations r JOIN revision_work w ON w.id=r.work_id WHERE r.project_id=? AND r.revision_id=? AND w.dispatch_state='allowed' AND w.recipe_context IS NOT NULL`)
|
|
27
|
+
.all(projectId, head)
|
|
28
|
+
.map((row) => reservation.parse(row));
|
|
29
|
+
const origins = new Map(reservations.map((row) => [row.origin_revision, row]));
|
|
30
|
+
for (const origin of origins.values()) {
|
|
31
|
+
const view = executionView(deps, projectId, origin.origin_revision);
|
|
32
|
+
if (view === undefined)
|
|
33
|
+
continue;
|
|
34
|
+
const catalogue = savedCatalogue(origin.recipe_context);
|
|
35
|
+
const plan = executionPlan(deps, view, catalogue);
|
|
36
|
+
for (const recipe of plan.recipes) {
|
|
37
|
+
if (!plan.work.some((row) => row.key === recipe.key))
|
|
38
|
+
continue;
|
|
39
|
+
if (recipe.deferred || recipe.unresolved)
|
|
40
|
+
continue;
|
|
41
|
+
if (recipe.input.kind === "tts") {
|
|
42
|
+
const current = getRevisionView(deps, projectId, head);
|
|
43
|
+
const desired = current?.revision.fingerprints[`${recipe.input.logicalKey}:1`];
|
|
44
|
+
if (desired !== undefined && desired !== recipe.logicalFingerprint)
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const own = reservations.find((row) => row.work_key === recipe.key && row.origin_revision === origin.origin_revision);
|
|
48
|
+
if (own?.fingerprint === recipe.fingerprint)
|
|
49
|
+
continue;
|
|
50
|
+
const anchor = own ??
|
|
51
|
+
reservations.find((row) => row.origin_revision === origin.origin_revision && row.work_key === futureKey(recipe));
|
|
52
|
+
if (anchor === undefined || !desiredAnchor(deps, projectId, head, anchor))
|
|
53
|
+
continue;
|
|
54
|
+
if (own !== undefined &&
|
|
55
|
+
workPieces(deps.db, own.work_id).some((piece) => piece.submittedAt !== null || piece.state === "done"))
|
|
56
|
+
continue;
|
|
57
|
+
if (plan.work.find((row) => row.key === recipe.key)?.disposition === "review")
|
|
58
|
+
continue;
|
|
59
|
+
if (own !== undefined) {
|
|
60
|
+
deps.db
|
|
61
|
+
.prepare("UPDATE revision_work SET state='done' WHERE id=? AND state='pending'")
|
|
62
|
+
.run(own.work_id);
|
|
63
|
+
deps.db
|
|
64
|
+
.prepare("DELETE FROM revision_work_reservations WHERE revision_id=? AND work_key=?")
|
|
65
|
+
.run(head, recipe.key);
|
|
66
|
+
}
|
|
67
|
+
const placeholder = deps.db
|
|
68
|
+
.prepare("SELECT w.dispatch_state FROM revision_work_reservations r JOIN revision_work w ON w.id=r.work_id WHERE r.revision_id=? AND r.work_key=?")
|
|
69
|
+
.get(head, recipe.key);
|
|
70
|
+
if (placeholder !== undefined && placeholder.dispatch_state !== "held")
|
|
71
|
+
continue;
|
|
72
|
+
deps.db
|
|
73
|
+
.prepare("DELETE FROM revision_work_reservations WHERE revision_id=? AND work_key=?")
|
|
74
|
+
.run(head, recipe.key);
|
|
75
|
+
if (plan.work.find((row) => row.key === recipe.key)?.disposition === "reuse") {
|
|
76
|
+
const ordinal = narrationOrdinal(plan.recipes, recipe.key);
|
|
77
|
+
bindNarrationReuse(deps, view, recipe, ordinal);
|
|
78
|
+
if (head !== view.revision.id) {
|
|
79
|
+
const current = getRevisionView(deps, projectId, head);
|
|
80
|
+
if (current !== undefined)
|
|
81
|
+
bindNarrationReuse(deps, current, recipe, ordinal);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
insertInvocation(deps, view, recipe, catalogue, {
|
|
85
|
+
key: anchor.logical_key ?? anchor.work_key,
|
|
86
|
+
fingerprint: anchor.desired_fingerprint ?? anchor.fingerprint,
|
|
87
|
+
}, plan.work.find((row) => row.key === recipe.key)?.disposition === "reuse", head, anchor.admission_id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function desiredAnchor(deps, projectId, head, row) {
|
|
93
|
+
const view = getRevisionView(deps, projectId, head);
|
|
94
|
+
return (view?.revision.fingerprints[row.logical_key ?? row.work_key] ===
|
|
95
|
+
(row.desired_fingerprint ?? row.fingerprint));
|
|
96
|
+
}
|
|
97
|
+
function futureKey(recipe) {
|
|
98
|
+
if (recipe.input.kind === "tts")
|
|
99
|
+
return `audio:${recipe.input.segment}:future`;
|
|
100
|
+
if (recipe.key.startsWith("research:chapter:"))
|
|
101
|
+
return "research:planner";
|
|
102
|
+
if (recipe.key === "article:continuation")
|
|
103
|
+
return "article:body";
|
|
104
|
+
return recipe.key;
|
|
105
|
+
}
|