@gentbajko/slopify 2.0.3 → 2.1.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/SUBTITLES.md +1 -1
- package/dist/adapters/alignment/threads.js +11 -0
- package/dist/adapters/alignment/worker.js +38 -4
- package/dist/adapters/host-cli/index.js +25 -1
- package/dist/assets/document/SOURCE.txt +7 -0
- package/dist/assets/document/background.jpg +0 -0
- package/dist/assets/document/fonts/Cinzel-Black.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Bold.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Medium.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Regular.ttf +0 -0
- package/dist/assets/document/fonts/OFL.txt +93 -0
- package/dist/assets/document/fonts/SOURCE.txt +11 -0
- package/dist/catalog/validate.js +3 -2
- package/dist/edge/docker-projects/state.js +5 -1
- package/dist/edge/host-cli.js +14 -0
- package/dist/edge/http/folder-location-schema.js +5 -8
- package/dist/edge/http/folder-location.js +10 -5
- package/dist/edge/http/host-cli.js +27 -2
- package/dist/edge/http/revision-files.js +5 -1
- package/dist/host-cli/open-folder.js +95 -0
- package/dist/host-cli/runtime.js +2 -0
- package/dist/kernel/db/migrate.js +25 -1
- package/dist/kernel/db/migrations/0014-document-stage.sql +44 -0
- package/dist/kernel/db/migrations/0015-description-prompts.sql +13 -0
- package/dist/kernel/pipeline.js +11 -1
- package/dist/kernel/ports/host-cli.js +14 -0
- package/dist/kernel/runner/graph.js +6 -1
- package/dist/main.js +6 -2
- package/dist/slices/admission/model.js +7 -1
- package/dist/slices/admission/rules.js +67 -3
- package/dist/slices/admission/schema.js +16 -3
- package/dist/slices/admission/start.js +3 -2
- package/dist/slices/document/blocks.js +129 -0
- package/dist/slices/document/flow.js +117 -0
- package/dist/slices/document/fonts.js +54 -0
- package/dist/slices/document/model.js +11 -0
- package/dist/slices/document/pages.js +198 -0
- package/dist/slices/document/render.js +242 -0
- package/dist/slices/document/sources.js +35 -0
- package/dist/slices/document/theme.js +158 -0
- package/dist/slices/document/writer.js +66 -0
- package/dist/slices/estimate/index.js +14 -2
- package/dist/slices/library/model.js +1 -1
- package/dist/slices/library/slots.js +4 -1
- package/dist/slices/play-drafts/convert.js +30 -1
- package/dist/slices/play-drafts/readiness.js +3 -2
- package/dist/slices/play-drafts/schema.js +22 -5
- package/dist/slices/project-templates/from-project.js +10 -0
- package/dist/slices/project-templates/setup.js +1 -0
- package/dist/slices/rebuild/preview-details.js +11 -1
- package/dist/slices/rebuild/recipe-audio.js +3 -0
- package/dist/slices/rebuild/recipe-build.js +6 -1
- package/dist/slices/rebuild/recipe-document.js +36 -0
- package/dist/slices/rebuild/recipe-exports.js +14 -2
- package/dist/slices/rebuild/recipe-fixture.js +4 -0
- package/dist/slices/rebuild/recipe-legacy.js +5 -0
- package/dist/slices/rebuild/recipe-model.js +2 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +3 -0
- package/dist/slices/rebuild/recipe-save.js +15 -3
- package/dist/slices/rebuild/recipe-validation.js +15 -2
- package/dist/slices/rebuild/recipe-visual.js +10 -1
- package/dist/slices/rebuild/recipe-work.js +15 -0
- package/dist/slices/rebuild/recipe-youtube.js +33 -0
- package/dist/slices/rebuild/recovery-selection.js +2 -0
- package/dist/slices/rebuild/recovery.js +10 -5
- package/dist/slices/rebuild/runtime-actions.js +2 -0
- package/dist/slices/rebuild/runtime-document.js +85 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +1 -0
- package/dist/slices/rebuild/runtime-export.js +35 -19
- package/dist/slices/rebuild/runtime-piece-label.js +4 -0
- package/dist/slices/rebuild/runtime-publication.js +1 -0
- package/dist/slices/rebuild/runtime-run.js +11 -3
- package/dist/slices/rebuild/runtime-store.js +7 -2
- package/dist/slices/rebuild/runtime-subtitles.js +6 -3
- package/dist/slices/rebuild/runtime-youtube.js +84 -0
- package/dist/slices/rebuild/transition-repo.js +2 -0
- package/dist/slices/reruns/cascade.js +6 -4
- package/dist/slices/revisions/mutation-cues.js +3 -1
- package/dist/slices/revisions/projection.js +2 -1
- package/dist/slices/storage/downloads.js +1 -0
- package/dist/slices/storage/layout.js +6 -0
- package/dist/slices/storage/model.js +3 -0
- package/dist/slices/subtitles/prepare.js +2 -1
- package/dist/slices/telemetry/model.js +1 -1
- package/dist/slices/video/audio-export.js +11 -3
- package/dist/slices/video/edit-list.js +73 -0
- package/dist/slices/video/ffmpeg.js +123 -57
- package/dist/slices/video/motion.js +51 -0
- package/dist/slices/video/plan.js +61 -27
- package/dist/slices/video/reuse-audio.js +2 -0
- package/dist/slices/video/run.js +21 -10
- package/dist/slices/video/slideshow.js +48 -0
- package/dist/slices/video/write-export.js +17 -25
- package/dist/slices/youtube/answer.js +199 -0
- package/dist/slices/youtube/model.js +27 -0
- package/dist/slices/youtube/timestamps.js +24 -0
- package/dist/slices/youtube/transcript.js +47 -0
- package/dist/web/assets/index-B6NbhpjD.css +1 -0
- package/dist/web/assets/index-ComF5gwP.js +142 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-Bwtmu9ei.css +0 -1
- package/dist/web/assets/index-DjhMJqMN.js +0 -141
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { usesYoutubeDescription } from "../admission/rules.js";
|
|
2
|
+
import { recipe, resourceIdentity, } from "./recipe-model.js";
|
|
3
|
+
import { renderedPrompt } from "./recipe-text.js";
|
|
4
|
+
// The YouTube description is written from the word timing, so it waits for
|
|
5
|
+
// `subtitles:timing` and nothing else: it runs beside the render, and a change to the
|
|
6
|
+
// motion, zoom, images or captions' look leaves it current. The transcript is only known once
|
|
7
|
+
// the timing lands, so the request is built when the step runs (`runtime-youtube.ts`); what
|
|
8
|
+
// identifies it here is the timing it reads, the prompt, the model and the title.
|
|
9
|
+
export function youtubeRecipes(context, exports) {
|
|
10
|
+
const { config } = context;
|
|
11
|
+
const timing = exports.find((value) => value.key === "subtitles:timing");
|
|
12
|
+
if (!usesYoutubeDescription(config) || timing === undefined)
|
|
13
|
+
return [];
|
|
14
|
+
return [
|
|
15
|
+
recipe(context, "youtube:description", "video", {
|
|
16
|
+
kind: "local",
|
|
17
|
+
version: 1,
|
|
18
|
+
operation: "youtube-description-v1",
|
|
19
|
+
values: [
|
|
20
|
+
resourceIdentity(context, timing),
|
|
21
|
+
config.llm?.provider ?? "",
|
|
22
|
+
config.llm?.model ?? "",
|
|
23
|
+
config.llm?.thinking ?? null,
|
|
24
|
+
// Blank means the built-in prompt; the step fills it in, so a new built-in wording
|
|
25
|
+
// ships as a new operation version rather than a silent change here.
|
|
26
|
+
config.descriptionPrompt?.trim() ? renderedPrompt(context, "description") : "",
|
|
27
|
+
config.title,
|
|
28
|
+
],
|
|
29
|
+
}, [timing.key],
|
|
30
|
+
// A paid LLM call: readiness, the charge warning and the estimate treat it like one.
|
|
31
|
+
{ kind: "provider" }),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
@@ -16,6 +16,8 @@ export function sectionRoots(view, plan, stage) {
|
|
|
16
16
|
["body-narration", "intro-narration", "outro-narration"].includes(row.input.operation)));
|
|
17
17
|
if (stage === "article")
|
|
18
18
|
return row.key === "article:body" && row.kind === "provider";
|
|
19
|
+
if (stage === "document")
|
|
20
|
+
return row.key === "document:pdf";
|
|
19
21
|
return row.kind === "provider";
|
|
20
22
|
})
|
|
21
23
|
.map((row) => row.key);
|
|
@@ -158,16 +158,21 @@ export async function recoverProject(deps, projectId, raw) {
|
|
|
158
158
|
fields: blocked.map((row) => ({ field: row.key, message: row.reason })),
|
|
159
159
|
});
|
|
160
160
|
if (input.action.kind === "rerun" &&
|
|
161
|
-
input.action.stage === "video" &&
|
|
161
|
+
(input.action.stage === "video" || input.action.stage === "document") &&
|
|
162
162
|
preview.value.preview.work.some((row) => row.kind === "provider" && row.disposition !== "reuse"))
|
|
163
163
|
return finish({
|
|
164
164
|
ok: false,
|
|
165
165
|
reason: "readiness",
|
|
166
166
|
fields: [
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
input.action.stage === "video"
|
|
168
|
+
? {
|
|
169
|
+
field: "video",
|
|
170
|
+
message: "Source media is incomplete. Use Resume to recover it before rerunning the export.",
|
|
171
|
+
}
|
|
172
|
+
: {
|
|
173
|
+
field: "document",
|
|
174
|
+
message: "The article or thumbnail the document is made from isn't finished. Use Resume to finish it, then Re-run section on Document.",
|
|
175
|
+
},
|
|
171
176
|
],
|
|
172
177
|
});
|
|
173
178
|
const conflict = rerunKeys.length > 0 ? activeConflict(deps, projectId, rerunKeys) : undefined;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readDocumentAssets } from "../document/fonts.js";
|
|
3
|
+
import { documentThemeOf } from "../document/model.js";
|
|
4
|
+
import { renderDocument } from "../document/render.js";
|
|
5
|
+
import { builtInTheme } from "../document/theme.js";
|
|
6
|
+
import { writeAsset } from "../storage/assets.js";
|
|
7
|
+
import { outputPath } from "../storage/layout.js";
|
|
8
|
+
import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
|
|
9
|
+
import { preparedResult, publishResult } from "./runtime-publication.js";
|
|
10
|
+
// Lays the saved article out as a PDF. Local and quick: no provider, no charge.
|
|
11
|
+
export async function executeDocumentRecipe(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
|
+
context.signal.throwIfAborted();
|
|
19
|
+
const view = documentView(deps, context, piece);
|
|
20
|
+
const { projectId } = context.work;
|
|
21
|
+
const text = (role, workKey) => {
|
|
22
|
+
const row = ready(view, role, workKey);
|
|
23
|
+
return row === undefined
|
|
24
|
+
? null
|
|
25
|
+
: readFileSync(outputPath(deps.paths, projectId, row.output.path), "utf8");
|
|
26
|
+
};
|
|
27
|
+
const config = view.revision.config;
|
|
28
|
+
const article = view.revision.content.articleEdited === true || config.sources.article === "provide"
|
|
29
|
+
? view.articleMarkdown
|
|
30
|
+
: text("article_md", "article:body");
|
|
31
|
+
if (article === null)
|
|
32
|
+
throw new Error("The article isn't finished yet, so there's nothing to make the document from. Let the Article stage finish (Resume, or Retry stage on Article), then use Retry stage on Document.");
|
|
33
|
+
const thumbnail = ready(view, "thumbnail", "thumbnail:image");
|
|
34
|
+
let cover = null;
|
|
35
|
+
if (config.sources.thumbnail !== "off" && thumbnail !== undefined) {
|
|
36
|
+
try {
|
|
37
|
+
cover = new Uint8Array(readFileSync(outputPath(deps.paths, projectId, thumbnail.output.path)));
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new Error(`The document's cover is the project's thumbnail, and its file can't be read (${error instanceof Error ? error.message : String(error)}). Regenerate or upload the thumbnail again (Re-run section on Thumbnail, or Edit project → Thumbnail), then use Retry stage on Document.`, { cause: error });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const rendered = renderDocument({
|
|
44
|
+
title: config.title,
|
|
45
|
+
articleMarkdown: article,
|
|
46
|
+
researchNotes: config.sources.research === "off" ? null : text("notes", "research:notes"),
|
|
47
|
+
cover,
|
|
48
|
+
theme: builtInTheme(documentThemeOf(config.document)),
|
|
49
|
+
writtenOn: deps.clock.now(),
|
|
50
|
+
assets: readDocumentAssets(),
|
|
51
|
+
});
|
|
52
|
+
if (rendered.cover === "unsupported")
|
|
53
|
+
deps.log.write("warn", "document.cover", {
|
|
54
|
+
projectId,
|
|
55
|
+
stage: "document",
|
|
56
|
+
detail: "The thumbnail is not a PNG, JPEG or WebP image, so the document has no cover.",
|
|
57
|
+
});
|
|
58
|
+
context.signal.throwIfAborted();
|
|
59
|
+
if (!context.maySubmit(piece.id))
|
|
60
|
+
return "held";
|
|
61
|
+
const asset = writeAsset(deps, projectId, "document.pdf", rendered.bytes);
|
|
62
|
+
// publishResult discards the file itself when the publication doesn't commit.
|
|
63
|
+
await publishResult(deps, context, piece, [preparedResult(deps, context, piece, "document_pdf", asset)], { pages: rendered.pages, words: rendered.words, cover: rendered.cover });
|
|
64
|
+
deps.count?.("stage.completed", { stage: "document" });
|
|
65
|
+
return "done";
|
|
66
|
+
}
|
|
67
|
+
function documentView(deps, context, piece) {
|
|
68
|
+
const view = executionView(deps, context.work.projectId, context.work.revisionId);
|
|
69
|
+
const row = deps.db
|
|
70
|
+
.prepare("SELECT recipe_context FROM revision_work WHERE id=?")
|
|
71
|
+
.get(context.work.workId);
|
|
72
|
+
if (view === undefined || row === undefined)
|
|
73
|
+
throw new Error("Slopify hit an internal error (the project version the document belongs to is missing). Use Retry stage on Document; if it happens again, use Download diagnostics in Settings and report it.");
|
|
74
|
+
const recipe = executionPlan(deps, view, savedCatalogue(row.recipe_context)).recipes.find((one) => one.key === piece.key && one.fingerprint === piece.fingerprint);
|
|
75
|
+
if (recipe === undefined || recipe.unresolved)
|
|
76
|
+
throw new Error("The project changed after this document was queued, so it no longer matches the saved article. Use Rebuild affected outputs (or Retry stage on Document) to make it from the current version.");
|
|
77
|
+
return view;
|
|
78
|
+
}
|
|
79
|
+
function ready(view, role, workKey) {
|
|
80
|
+
return view.outputs.find((row) => row.selected &&
|
|
81
|
+
row.available &&
|
|
82
|
+
row.state === "ready" &&
|
|
83
|
+
row.workKey === workKey &&
|
|
84
|
+
row.output.role === role);
|
|
85
|
+
}
|
|
@@ -49,6 +49,7 @@ export async function revisionAudio(deps, context, view) {
|
|
|
49
49
|
? await input("audio_outro")
|
|
50
50
|
: undefined,
|
|
51
51
|
gapSeconds: config.silenceGapSeconds,
|
|
52
|
+
edgeSeconds: config.edgeSilenceSeconds,
|
|
52
53
|
}, config.sources.video === "off" ? 1 / 48000 : 1 / 30);
|
|
53
54
|
}
|
|
54
55
|
export function revisionTranscript(deps, snapshot, kind) {
|
|
@@ -3,8 +3,10 @@ import { extname, join, relative, sep } from "node:path";
|
|
|
3
3
|
import { allocateAsset, discardPreparedAssets, sealAsset } from "../storage/assets.js";
|
|
4
4
|
import { outputPath, projectDir } from "../storage/layout.js";
|
|
5
5
|
import { audioExportArgs } from "../video/audio-export.js";
|
|
6
|
-
import {
|
|
6
|
+
import { withPaths } from "../video/edit-list.js";
|
|
7
|
+
import { runFfmpeg } from "../video/ffmpeg.js";
|
|
7
8
|
import { planRender } from "../video/plan.js";
|
|
9
|
+
import { renderSlideshow } from "../video/slideshow.js";
|
|
8
10
|
import { exportSnapshot, retainedOutput, revisionAudio, } from "./runtime-export-inputs.js";
|
|
9
11
|
import { preparedResult, preparedText, publishResult } from "./runtime-publication.js";
|
|
10
12
|
export async function executeExportRecipe(deps, context, piece) {
|
|
@@ -56,6 +58,10 @@ export async function executeExportRecipe(deps, context, piece) {
|
|
|
56
58
|
: planRender({
|
|
57
59
|
format: config.format,
|
|
58
60
|
gapSeconds: config.silenceGapSeconds,
|
|
61
|
+
edgeSeconds: config.edgeSilenceSeconds,
|
|
62
|
+
imageSeconds: config.imageSeconds,
|
|
63
|
+
zoomPercent: config.zoomPercent,
|
|
64
|
+
motionStyle: config.motionStyle,
|
|
59
65
|
body: segment("body"),
|
|
60
66
|
intro: segment("intro"),
|
|
61
67
|
outro: segment("outro"),
|
|
@@ -71,6 +77,7 @@ export async function executeExportRecipe(deps, context, piece) {
|
|
|
71
77
|
channels: 2,
|
|
72
78
|
codec: "pcm_s16le",
|
|
73
79
|
gapSeconds: config.silenceGapSeconds,
|
|
80
|
+
edgeSeconds: config.edgeSilenceSeconds,
|
|
74
81
|
totalSeconds,
|
|
75
82
|
audio: audio.map((row) => ({
|
|
76
83
|
...row,
|
|
@@ -81,29 +88,38 @@ export async function executeExportRecipe(deps, context, piece) {
|
|
|
81
88
|
: {
|
|
82
89
|
...plan,
|
|
83
90
|
output: pending.path,
|
|
84
|
-
|
|
85
|
-
...row,
|
|
86
|
-
path: row.path === null ? null : relativePath(row.path),
|
|
87
|
-
})),
|
|
88
|
-
images: plan.images.map((row) => ({ ...row, path: relativePath(row.path) })),
|
|
91
|
+
editList: withPaths(plan.editList, relativePath),
|
|
89
92
|
subtitles: config.subtitles,
|
|
90
93
|
};
|
|
91
94
|
if (!context.maySubmit(piece.id))
|
|
92
95
|
return "held";
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
}),
|
|
96
|
+
const onProgress = (elapsedMs) => context.emit({
|
|
97
|
+
type: "stage.progress",
|
|
98
|
+
projectId: context.work.projectId,
|
|
99
|
+
stage: "video",
|
|
100
|
+
current: Math.min(100, Math.round(elapsedMs / (totalSeconds * 10))),
|
|
101
|
+
total: 100,
|
|
106
102
|
});
|
|
103
|
+
if (plan === undefined)
|
|
104
|
+
await runFfmpeg({
|
|
105
|
+
bin: deps.ffmpeg,
|
|
106
|
+
args: audioExportArgs(audio, pending.absolutePath),
|
|
107
|
+
signal: context.signal,
|
|
108
|
+
log: deps.log,
|
|
109
|
+
onProgress,
|
|
110
|
+
});
|
|
111
|
+
else
|
|
112
|
+
await renderSlideshow({
|
|
113
|
+
bin: deps.ffmpeg,
|
|
114
|
+
edit: plan.editList,
|
|
115
|
+
output: plan.output,
|
|
116
|
+
burnSubtitles: burn,
|
|
117
|
+
cwd: directory,
|
|
118
|
+
scratch: projectDirectory,
|
|
119
|
+
signal: context.signal,
|
|
120
|
+
log: deps.log,
|
|
121
|
+
onProgress,
|
|
122
|
+
});
|
|
107
123
|
context.signal.throwIfAborted();
|
|
108
124
|
const asset = sealAsset(deps, pending);
|
|
109
125
|
prepared.push(preparedResult(deps, context, piece, wav ? "audio_export" : "video", asset, Math.round(totalSeconds * 1000), { subtitlesMode: mode }));
|
|
@@ -8,6 +8,10 @@ export function pieceLabel(deps, context, piece) {
|
|
|
8
8
|
return "Thumbnail";
|
|
9
9
|
if (key === "article:body")
|
|
10
10
|
return "Article";
|
|
11
|
+
if (key === "document:pdf")
|
|
12
|
+
return "Document";
|
|
13
|
+
if (key === "youtube:description")
|
|
14
|
+
return "YouTube description";
|
|
11
15
|
if (key === "entry:intro:text")
|
|
12
16
|
return "Intro text";
|
|
13
17
|
if (key === "entry:outro:text")
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { executeDocumentRecipe } from "./runtime-document.js";
|
|
1
2
|
import { executeExportRecipe } from "./runtime-export.js";
|
|
2
3
|
import { executeLocalRecipe } from "./runtime-local.js";
|
|
3
4
|
import { pieceLabel } from "./runtime-piece-label.js";
|
|
4
5
|
import { executeProviderRecipe } from "./runtime-provider.js";
|
|
5
6
|
import { executeSubtitleRecipe } from "./runtime-subtitles.js";
|
|
7
|
+
import { executeYoutubeRecipe } from "./runtime-youtube.js";
|
|
6
8
|
import { workPieces } from "./work-records.js";
|
|
7
9
|
export async function runRevisionInvocation(deps, context, providers) {
|
|
8
10
|
const pieces = workPieces(deps.db, context.work.workId).filter((piece) => !piece.key.startsWith("article:continuation:"));
|
|
@@ -16,9 +18,15 @@ export async function runRevisionInvocation(deps, context, providers) {
|
|
|
16
18
|
? await executeSubtitleRecipe(deps, context, piece)
|
|
17
19
|
: piece.key.startsWith("export:")
|
|
18
20
|
? await executeExportRecipe(deps, context, piece)
|
|
19
|
-
: piece.
|
|
20
|
-
? await
|
|
21
|
-
:
|
|
21
|
+
: piece.key.startsWith("document:")
|
|
22
|
+
? await executeDocumentRecipe(deps, context, piece)
|
|
23
|
+
: piece.key.startsWith("youtube:")
|
|
24
|
+
? await executeYoutubeRecipe(deps, context, providers, piece)
|
|
25
|
+
: piece.input.kind === "llm" ||
|
|
26
|
+
piece.input.kind === "tts" ||
|
|
27
|
+
piece.input.kind === "image"
|
|
28
|
+
? await executeProviderRecipe(deps, context, providers, piece)
|
|
29
|
+
: await executeLocalRecipe(deps, context, piece);
|
|
22
30
|
if (outcome === "held")
|
|
23
31
|
return outcome;
|
|
24
32
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { stageKinds, stageStates } from "../../kernel/pipeline.js";
|
|
3
|
+
import { sourceOf } from "../admission/model.js";
|
|
4
|
+
import { usesYoutubeDescription } from "../admission/rules.js";
|
|
3
5
|
import { currentRevisionId } from "../revisions/repo.js";
|
|
4
6
|
import { getRevisionView } from "../revisions/view.js";
|
|
5
7
|
import { settleScheduleRunsForProject } from "../schedules/repo.js";
|
|
@@ -31,7 +33,10 @@ export function executionStages(deps, projectId) {
|
|
|
31
33
|
.filter((row) => workPieces(deps.db, String(row.id)).some((piece) => {
|
|
32
34
|
if (!keys.has(piece.key))
|
|
33
35
|
return false;
|
|
34
|
-
|
|
36
|
+
// Hand-edited captions need no timing, unless the YouTube description reads it.
|
|
37
|
+
if (piece.key === "subtitles:timing" &&
|
|
38
|
+
view?.revision.content.subtitleCues !== undefined &&
|
|
39
|
+
!usesYoutubeDescription(view.revision.config))
|
|
35
40
|
return false;
|
|
36
41
|
if (piece.input.kind === "deferred" && piece.key.endsWith(":future")) {
|
|
37
42
|
const cacheKey = String(row.id);
|
|
@@ -101,7 +106,7 @@ export function executionStandings(deps, projectId, derived) {
|
|
|
101
106
|
return [];
|
|
102
107
|
const work = derived ?? executionStages(deps, projectId);
|
|
103
108
|
return stageKinds.map((kind) => {
|
|
104
|
-
const source = view.revision.config.sources
|
|
109
|
+
const source = sourceOf(view.revision.config.sources, kind);
|
|
105
110
|
const group = work
|
|
106
111
|
.filter((entry) => entry.kind === kind)
|
|
107
112
|
.map((entry) => {
|
|
@@ -7,10 +7,13 @@ import { validateCues } from "../revisions/rules.js";
|
|
|
7
7
|
import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
|
|
8
8
|
import { outputPath } from "../storage/layout.js";
|
|
9
9
|
import { captionCues, serializeAss, serializeSrt, serializeVtt } from "../subtitles/captions.js";
|
|
10
|
+
import { spoken } from "../video/plan.js";
|
|
10
11
|
import { exportSnapshot, retainedOutput, revisionAudio, revisionTranscript, } from "./runtime-export-inputs.js";
|
|
11
12
|
import { narrationChunks, narrationTextParts, } from "./runtime-narration-text.js";
|
|
12
13
|
import { preparedResult, preparedText, publishResult } from "./runtime-publication.js";
|
|
13
|
-
|
|
14
|
+
// The saved `subtitles.json`: every word at its time in the final video. The YouTube
|
|
15
|
+
// description reads it too (`runtime-youtube.ts`).
|
|
16
|
+
export const wordsSchema = z.object({
|
|
14
17
|
key: z.string(),
|
|
15
18
|
words: z.array(z.object({
|
|
16
19
|
text: z.string(),
|
|
@@ -46,7 +49,7 @@ export async function executeSubtitleRecipe(deps, context, piece) {
|
|
|
46
49
|
async function timing(deps, context, piece, snapshot) {
|
|
47
50
|
const audio = await revisionAudio(deps, context, snapshot.view);
|
|
48
51
|
if (audio.length === 0)
|
|
49
|
-
throw new Error("Captions need narration audio, but this project has none. Turn captions off in Edit project
|
|
52
|
+
throw new Error("Captions and the YouTube description need narration audio, but this project has none. Turn captions and the YouTube description off in Edit project, or turn narration on, then Retry stage.");
|
|
50
53
|
const alignSubtitles = deps.alignSubtitles;
|
|
51
54
|
if (alignSubtitles === undefined)
|
|
52
55
|
throw new Error("Slopify hit an internal error (the caption timing tool is missing from this build). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
@@ -59,7 +62,7 @@ async function timing(deps, context, piece, snapshot) {
|
|
|
59
62
|
for (const segment of audio) {
|
|
60
63
|
context.signal.throwIfAborted();
|
|
61
64
|
const { path, kind } = segment;
|
|
62
|
-
if (path !== null && kind
|
|
65
|
+
if (path !== null && spoken(kind)) {
|
|
63
66
|
const aligned = await located(snapshot, kind, () => alignSubtitles({
|
|
64
67
|
audioPath: path,
|
|
65
68
|
text: revisionTranscript(deps, snapshot, kind),
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { outputPath } from "../storage/layout.js";
|
|
3
|
+
import { assembleDescription, checkDescriptionAnswer, descriptionMessages, tagsText, } from "../youtube/answer.js";
|
|
4
|
+
import { defaultDescriptionPrompt } from "../youtube/model.js";
|
|
5
|
+
import { transcriptPassages, transcriptText } from "../youtube/transcript.js";
|
|
6
|
+
import { exportSnapshot, revisionAudio } from "./runtime-export-inputs.js";
|
|
7
|
+
import { savedCatalogue } from "./runtime-plan.js";
|
|
8
|
+
import { preparedTexts, publishResult } from "./runtime-publication.js";
|
|
9
|
+
import { wordsSchema } from "./runtime-subtitles.js";
|
|
10
|
+
// Writes the YouTube description and tags from the saved word timing: the transcript's
|
|
11
|
+
// passages carry their start in the final video, so the chapters the model picks are real
|
|
12
|
+
// times. An answer that breaks YouTube's chapter or tag rules is a failed attempt, which the
|
|
13
|
+
// provider wrapper asks again while attempts remain.
|
|
14
|
+
export async function executeYoutubeRecipe(deps, context, providers, piece) {
|
|
15
|
+
if (deps.db
|
|
16
|
+
.prepare("SELECT state FROM revision_work_pieces WHERE id=? AND work_id=?")
|
|
17
|
+
.get(piece.id, context.work.workId)?.state === "done")
|
|
18
|
+
return "done";
|
|
19
|
+
if (!context.maySubmit(piece.id))
|
|
20
|
+
return "held";
|
|
21
|
+
context.signal.throwIfAborted();
|
|
22
|
+
const input = piece.input;
|
|
23
|
+
if (input.kind !== "local" || input.operation !== "youtube-description-v1")
|
|
24
|
+
throw new Error("Slopify hit an internal error (the YouTube description step was set up wrongly). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
25
|
+
const { view } = exportSnapshot(deps, context, piece);
|
|
26
|
+
const config = view.revision.config;
|
|
27
|
+
const llm = config.llm;
|
|
28
|
+
if (llm === undefined || llm.provider.trim() === "" || llm.model.trim() === "")
|
|
29
|
+
throw new Error("The YouTube description needs an AI text model, and none is chosen. Choose one in Edit project → Providers, then Retry stage.");
|
|
30
|
+
const timing = view.outputs.find((row) => row.selected &&
|
|
31
|
+
row.available &&
|
|
32
|
+
row.state === "ready" &&
|
|
33
|
+
row.workKey === "subtitles:timing" &&
|
|
34
|
+
row.output.role === "subtitle_words");
|
|
35
|
+
if (timing === undefined)
|
|
36
|
+
throw new Error("The narration's word timing, which the YouTube description's chapters come from, is missing. Use Re-run section on Video, then Retry stage.");
|
|
37
|
+
const { words } = wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, context.work.projectId, timing.output.path), "utf8")));
|
|
38
|
+
const passages = transcriptPassages(words);
|
|
39
|
+
if (passages.length === 0)
|
|
40
|
+
throw new Error("The narration's word timing holds no words, so there is nothing to write the YouTube description from. Use Re-run section on Video, then Retry stage.");
|
|
41
|
+
// The same timeline the timing walked: silence at the start, intro, gaps, body, outro and
|
|
42
|
+
// silence at the end.
|
|
43
|
+
const audio = await revisionAudio(deps, context, view);
|
|
44
|
+
const durationSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
45
|
+
const saved = Array.isArray(input.values) ? input.values[4] : undefined;
|
|
46
|
+
const instruction = typeof saved === "string" && saved.trim() !== "" ? saved : defaultDescriptionPrompt;
|
|
47
|
+
const messages = descriptionMessages({
|
|
48
|
+
instruction,
|
|
49
|
+
title: config.title,
|
|
50
|
+
durationSeconds,
|
|
51
|
+
transcript: transcriptText(passages),
|
|
52
|
+
});
|
|
53
|
+
const row = deps.db
|
|
54
|
+
.prepare("SELECT recipe_context FROM revision_work WHERE id=?")
|
|
55
|
+
.get(context.work.workId);
|
|
56
|
+
const model = savedCatalogue(row?.recipe_context).llm.find((one) => one.provider === llm.provider && one.id === llm.model);
|
|
57
|
+
if (!context.maySubmit(piece.id))
|
|
58
|
+
return "held";
|
|
59
|
+
const answer = await providers.forPiece(piece.id).llm({
|
|
60
|
+
provider: llm.provider,
|
|
61
|
+
model: llm.model,
|
|
62
|
+
...(llm.thinking === undefined ? {} : { thinking: llm.thinking }),
|
|
63
|
+
thinkingConfig: llm.thinking === undefined ? null : (model?.llm.thinking?.[llm.thinking] ?? null),
|
|
64
|
+
messages,
|
|
65
|
+
webSearch: false,
|
|
66
|
+
previewLabel: piece.key,
|
|
67
|
+
check: (value) => {
|
|
68
|
+
const checked = checkDescriptionAnswer(value.text, durationSeconds);
|
|
69
|
+
return checked.ok ? undefined : checked.reason;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
if (!answer.ok)
|
|
73
|
+
return "held";
|
|
74
|
+
const checked = checkDescriptionAnswer(answer.value.text, durationSeconds);
|
|
75
|
+
if (!checked.ok)
|
|
76
|
+
throw new Error(checked.reason);
|
|
77
|
+
const description = assembleDescription(checked.value);
|
|
78
|
+
const tags = tagsText(checked.value.tags);
|
|
79
|
+
await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
|
|
80
|
+
["youtube_description", "description.txt", description],
|
|
81
|
+
["youtube_tags", "tags.txt", tags],
|
|
82
|
+
]), { description, tags: checked.value.tags, chapters: checked.value.chapters, durationSeconds });
|
|
83
|
+
return "done";
|
|
84
|
+
}
|
|
@@ -104,5 +104,7 @@ function stageForKey(key) {
|
|
|
104
104
|
return "thumbnail";
|
|
105
105
|
if (prefix === "export" || prefix === "subtitles" || prefix === "video")
|
|
106
106
|
return "video";
|
|
107
|
+
if (prefix === "document")
|
|
108
|
+
return "document";
|
|
107
109
|
throw new Error("Slopify hit an internal error (unknown kind of step). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
108
110
|
}
|
|
@@ -14,9 +14,9 @@ function staleStages(input) {
|
|
|
14
14
|
return withDependents([action.stage], input);
|
|
15
15
|
case "article-edit": {
|
|
16
16
|
// An article edit re-runs audio, LLM-mode intro/outro text, the LLM-written
|
|
17
|
-
// thumbnail and the video, leaving prompt-based images untouched. The
|
|
18
|
-
// along as a dependent of the audio, so it is never named here.
|
|
19
|
-
const roots = ["audio"];
|
|
17
|
+
// thumbnail, the document and the video, leaving prompt-based images untouched. The
|
|
18
|
+
// video comes along as a dependent of the audio, so it is never named here.
|
|
19
|
+
const roots = ["audio", "document"];
|
|
20
20
|
if (input.thumbnailSource === "prompt_by_llm") {
|
|
21
21
|
roots.push("thumbnail");
|
|
22
22
|
}
|
|
@@ -64,6 +64,8 @@ function withDependents(roots, input) {
|
|
|
64
64
|
}
|
|
65
65
|
return found;
|
|
66
66
|
}
|
|
67
|
+
// A stage with no row is not part of the run (a project from before the Document stage
|
|
68
|
+
// may lack one), so it is stepped over like a skipped one.
|
|
67
69
|
function stateOf(stages, kind) {
|
|
68
|
-
return stages.find((stage) => stage.kind === kind)?.state ?? "
|
|
70
|
+
return stages.find((stage) => stage.kind === kind)?.state ?? "skipped";
|
|
69
71
|
}
|
|
@@ -82,7 +82,9 @@ export function validateProposedCues(base, edit, plan, prepared, durations) {
|
|
|
82
82
|
if (parts.some((one) => one === undefined))
|
|
83
83
|
return fail();
|
|
84
84
|
const seconds = parts.reduce((sum, one) => sum + (one ?? 0), 0) / 1000 +
|
|
85
|
-
Math.max(0, keys.length - 1) * plan.config.silenceGapSeconds
|
|
85
|
+
Math.max(0, keys.length - 1) * plan.config.silenceGapSeconds +
|
|
86
|
+
// The lead-in and the tail are part of the timeline the cues are timed against.
|
|
87
|
+
2 * plan.config.edgeSilenceSeconds;
|
|
86
88
|
return validateCues(cues.cues, seconds);
|
|
87
89
|
}
|
|
88
90
|
export function measuredOutputs(deps, base, durations) {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
3
3
|
import { insertPiece } from "../../kernel/runner/piece-repo.js";
|
|
4
|
+
import { sourceOf } from "../admission/model.js";
|
|
4
5
|
import { recipeInputSchema } from "../rebuild/recipe-input-schema.js";
|
|
5
6
|
import { insertOutput } from "../storage/repo.js";
|
|
6
7
|
import { insertManifestOutput, insertManifestPiece, outputsForRevision, piecesForRevision, selectOutputRecord, selectPieceRecord, } from "./repo.js";
|
|
7
8
|
export function ensureRevisionStages(deps, revision) {
|
|
8
9
|
for (const kind of stageKinds) {
|
|
9
|
-
const source = revision.config.sources
|
|
10
|
+
const source = sourceOf(revision.config.sources, kind);
|
|
10
11
|
deps.db
|
|
11
12
|
.prepare("INSERT INTO stages(id,project_id,kind,source,state) VALUES (?,?,?,?,?) ON CONFLICT(project_id,kind) DO UPDATE SET source=excluded.source")
|
|
12
13
|
.run(deps.ids.next(), revision.projectId, kind, source, source === "off" ? "skipped" : "pending");
|
|
@@ -54,6 +54,12 @@ export function outputFileName(role, index, extension, stageKind) {
|
|
|
54
54
|
return `instructions-${stageKind}.txt`;
|
|
55
55
|
case "video":
|
|
56
56
|
return "video.mp4";
|
|
57
|
+
case "document_pdf":
|
|
58
|
+
return "document.pdf";
|
|
59
|
+
case "youtube_description":
|
|
60
|
+
return "description.txt";
|
|
61
|
+
case "youtube_tags":
|
|
62
|
+
return "tags.txt";
|
|
57
63
|
case "audio_body":
|
|
58
64
|
return `audio-body${extension}`;
|
|
59
65
|
case "audio_export":
|
|
@@ -7,6 +7,7 @@ import { projectById, setStageProgress } from "../admission/repo.js";
|
|
|
7
7
|
import { resolveFont } from "../fonts/index.js";
|
|
8
8
|
import { outputPath, projectDir } from "../storage/layout.js";
|
|
9
9
|
import { outputsOf } from "../storage/repo.js";
|
|
10
|
+
import { spoken } from "../video/plan.js";
|
|
10
11
|
import { captionCues, serializeAss, serializeSrt, serializeVtt } from "./captions.js";
|
|
11
12
|
import { defaultSubtitles } from "./model.js";
|
|
12
13
|
import { spokenText } from "./transcript.js";
|
|
@@ -55,7 +56,7 @@ export async function prepareSubtitles(deps, context, audio, frame) {
|
|
|
55
56
|
const outputs = outputsOf(deps.db, projectId);
|
|
56
57
|
const segments = audio.map((segment) => ({
|
|
57
58
|
...segment,
|
|
58
|
-
text: segment.kind
|
|
59
|
+
text: spoken(segment.kind) ? spokenText(deps, projectId, segment.kind, outputs) : "",
|
|
59
60
|
}));
|
|
60
61
|
const key = await timingKey(segments, context.signal);
|
|
61
62
|
const cache = readCache(deps, projectId, outputs);
|
|
@@ -3,7 +3,7 @@ import { stageKinds } from "../../kernel/pipeline.js";
|
|
|
3
3
|
// One install event, one per project created, one per stage completing.
|
|
4
4
|
export const telemetryEventTypes = ["install", "project.created", "stage.completed"];
|
|
5
5
|
// Counting is finer than a stage: one event per research, article, intro/outro text, audio
|
|
6
|
-
// segment (body, intro, outro), images, thumbnail and
|
|
6
|
+
// segment (body, intro, outro), images, thumbnail, video and document. The three that are not whole
|
|
7
7
|
// stages are named by `segment` beside the stage that produced them - the article stage
|
|
8
8
|
// writes the intro and outro texts, the audio stage narrates all three - so the event type
|
|
9
9
|
// stays `stage.completed` and the pair (stage, segment) says which unit it is.
|
|
@@ -6,17 +6,18 @@ import { outputsOf } from "../storage/repo.js";
|
|
|
6
6
|
import { prepareSubtitles } from "../subtitles/prepare.js";
|
|
7
7
|
import { audioExportArgs } from "./audio-export-args.js";
|
|
8
8
|
import { audioInputs } from "./audio-inputs.js";
|
|
9
|
+
import { runFfmpeg } from "./ffmpeg.js";
|
|
9
10
|
import { audioTimeline } from "./plan.js";
|
|
10
11
|
import { reusableAudioExport } from "./reuse-audio.js";
|
|
11
12
|
import { writeExport } from "./write-export.js";
|
|
12
13
|
import { writeSubtitles } from "./write-subtitles.js";
|
|
13
14
|
export { audioExportArgs } from "./audio-export-args.js";
|
|
14
15
|
const rate = 48000;
|
|
15
|
-
export async function exportAudioWav(deps, context, gapSeconds) {
|
|
16
|
+
export async function exportAudioWav(deps, context, gapSeconds, edgeSeconds) {
|
|
16
17
|
const { projectId } = context.stage;
|
|
17
18
|
const outputs = outputsOf(deps.db, projectId);
|
|
18
19
|
const input = await audioInputs(deps, projectId, outputs, context.signal);
|
|
19
|
-
const audio = audioTimeline({ ...input, gapSeconds }, 1 / rate);
|
|
20
|
+
const audio = audioTimeline({ ...input, gapSeconds, edgeSeconds }, 1 / rate);
|
|
20
21
|
const totalSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
21
22
|
const dir = projectDir(deps.paths, projectId);
|
|
22
23
|
const sources = outputs.filter((output) => ["audio_body", "audio_intro", "audio_outro"].includes(output.role));
|
|
@@ -25,6 +26,7 @@ export async function exportAudioWav(deps, context, gapSeconds) {
|
|
|
25
26
|
channels: 2,
|
|
26
27
|
codec: "pcm_s16le",
|
|
27
28
|
gapSeconds,
|
|
29
|
+
edgeSeconds,
|
|
28
30
|
totalSeconds,
|
|
29
31
|
audio: audio.map((segment) => ({
|
|
30
32
|
...segment,
|
|
@@ -52,6 +54,12 @@ export async function exportAudioWav(deps, context, gapSeconds) {
|
|
|
52
54
|
totalSeconds,
|
|
53
55
|
record: plan,
|
|
54
56
|
subtitles,
|
|
55
|
-
|
|
57
|
+
render: (part, onProgress) => runFfmpeg({
|
|
58
|
+
bin: deps.ffmpeg,
|
|
59
|
+
args: audioExportArgs(audio, part),
|
|
60
|
+
signal: context.signal,
|
|
61
|
+
log: deps.log,
|
|
62
|
+
onProgress,
|
|
63
|
+
}),
|
|
56
64
|
});
|
|
57
65
|
}
|