@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,66 @@
|
|
|
1
|
+
import { useFace } from "./fonts.js";
|
|
2
|
+
import { parseHexColor } from "./theme.js";
|
|
3
|
+
export function createWriter(doc, theme, assets) {
|
|
4
|
+
const width = doc.internal.pageSize.getWidth();
|
|
5
|
+
const height = doc.internal.pageSize.getHeight();
|
|
6
|
+
const colors = {
|
|
7
|
+
heading: parseHexColor(theme.colors.heading),
|
|
8
|
+
text: parseHexColor(theme.colors.text),
|
|
9
|
+
muted: parseHexColor(theme.colors.muted),
|
|
10
|
+
faint: parseHexColor(theme.colors.faint),
|
|
11
|
+
};
|
|
12
|
+
const writer = {
|
|
13
|
+
doc,
|
|
14
|
+
theme,
|
|
15
|
+
colors,
|
|
16
|
+
width,
|
|
17
|
+
height,
|
|
18
|
+
left: theme.page.margin,
|
|
19
|
+
contentWidth: width - theme.page.margin * 2,
|
|
20
|
+
bottom: height - theme.footer.bottom - theme.footer.reserve,
|
|
21
|
+
y: theme.page.contentTop,
|
|
22
|
+
addPage() {
|
|
23
|
+
doc.addPage();
|
|
24
|
+
paintBackground(doc, theme, assets, width, height);
|
|
25
|
+
writer.y = theme.page.contentTop;
|
|
26
|
+
},
|
|
27
|
+
ensure(needed) {
|
|
28
|
+
if (writer.y + needed > writer.bottom)
|
|
29
|
+
writer.addPage();
|
|
30
|
+
},
|
|
31
|
+
page() {
|
|
32
|
+
return doc.getCurrentPageInfo().pageNumber;
|
|
33
|
+
},
|
|
34
|
+
write(text, x, y, face, size, color, align = "left") {
|
|
35
|
+
useFace(doc, face, size);
|
|
36
|
+
const rgb = colors[color];
|
|
37
|
+
doc.setTextColor(rgb.r, rgb.g, rgb.b);
|
|
38
|
+
doc.text(text, x, y, align === "left" ? {} : { align });
|
|
39
|
+
doc.setCharSpace(0);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
paintBackground(doc, theme, assets, width, height);
|
|
43
|
+
return writer;
|
|
44
|
+
}
|
|
45
|
+
// The texture when the theme has one, otherwise the flat colour with slightly darker edges.
|
|
46
|
+
function paintBackground(doc, theme, assets, width, height) {
|
|
47
|
+
if (theme.background.image === "parchment") {
|
|
48
|
+
// The alias embeds the image once and points every page at it.
|
|
49
|
+
doc.addImage(assets.parchment, "JPEG", 0, 0, width, height, "parchment");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const base = parseHexColor(theme.background.color);
|
|
53
|
+
doc.setFillColor(base.r, base.g, base.b);
|
|
54
|
+
doc.rect(0, 0, width, height, "F");
|
|
55
|
+
const steps = 4;
|
|
56
|
+
const thickness = 2;
|
|
57
|
+
for (let step = 0; step < steps; step += 1) {
|
|
58
|
+
const inset = step * 2;
|
|
59
|
+
const shade = (channel) => Math.max(0, Math.round(channel - 15 * (1 - step / steps)));
|
|
60
|
+
doc.setFillColor(shade(base.r), shade(base.g), shade(base.b));
|
|
61
|
+
doc.rect(inset, inset, width - 2 * inset, thickness, "F");
|
|
62
|
+
doc.rect(inset, height - inset - thickness, width - 2 * inset, thickness, "F");
|
|
63
|
+
doc.rect(inset, inset, thickness, height - 2 * inset, "F");
|
|
64
|
+
doc.rect(width - inset - thickness, inset, thickness, height - 2 * inset, "F");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { sourceOf } from "../admission/model.js";
|
|
3
|
+
import { usesNarrationPreparation, usesYoutubeDescription } from "../admission/rules.js";
|
|
3
4
|
import { plainText } from "../article/plain.js";
|
|
4
5
|
import { splitEndMatter } from "../article/split.js";
|
|
5
6
|
import { chunkNarration, defaultChunking } from "../narration/chunk.js";
|
|
6
7
|
import { normalizeNarrationText } from "../narration/plan.js";
|
|
7
8
|
import { preparationMessages } from "../narration/preparation.js";
|
|
9
|
+
import { defaultDescriptionPrompt } from "../youtube/model.js";
|
|
8
10
|
import { estimateRequests, groupEstimateRows } from "./requests.js";
|
|
9
11
|
export { estimateRequests } from "./requests.js";
|
|
10
12
|
export function estimateRun(draft, rendered, expectedWords, catalogue) {
|
|
@@ -32,7 +34,7 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
|
|
|
32
34
|
!model.deprecated)?.pricing.note ?? "Model or account pricing is unknown.";
|
|
33
35
|
const generatedArticle = draft.sources.article === "generate";
|
|
34
36
|
const articleChars = generatedArticle ? expectedWords * 6 : (draft.provided.article?.length ?? 0);
|
|
35
|
-
const promptChars = Object.entries(rendered).reduce((sum, [key, value]) => sum + (key === "narration" ? 0 : value.length), 0);
|
|
37
|
+
const promptChars = Object.entries(rendered).reduce((sum, [key, value]) => sum + (key === "narration" || key === "description" ? 0 : value.length), 0);
|
|
36
38
|
const local = (stage, detail) => {
|
|
37
39
|
requests.push({ kind: "local", stage, detail });
|
|
38
40
|
};
|
|
@@ -122,6 +124,16 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
|
|
|
122
124
|
if (draft.sources.thumbnail === "prompt_by_llm")
|
|
123
125
|
text("Thumbnail prompt", promptChars + articleChars, 1200);
|
|
124
126
|
local("Export / subtitles", "Local processing; no API fee.");
|
|
127
|
+
// The timed transcript is the narration with a time before every passage of about 20 s,
|
|
128
|
+
// plus the fixed rules of the answer.
|
|
129
|
+
if (usesYoutubeDescription(draft))
|
|
130
|
+
text("YouTube description", (rendered.description?.trim()
|
|
131
|
+
? rendered.description.length
|
|
132
|
+
: defaultDescriptionPrompt.length) +
|
|
133
|
+
Math.round(articleChars * 1.05) +
|
|
134
|
+
1500, 2400, "One LLM call on the timed transcript.");
|
|
135
|
+
if (sourceOf(draft.sources, "document") === "generate")
|
|
136
|
+
local("Document", "Laid out locally from the article; no API fee.");
|
|
125
137
|
return {
|
|
126
138
|
...groupEstimateRows(estimateRequests(requests, data)),
|
|
127
139
|
expectedWords,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// set covers both, which is why the drafts, the lint and the save path below are shared
|
|
3
3
|
// rather than mirrored.
|
|
4
4
|
export { entryModes } from "../admission/model.js";
|
|
5
|
-
export const promptKinds = ["article", "image", "thumbnail", "narration"];
|
|
5
|
+
export const promptKinds = ["article", "image", "thumbnail", "narration", "description"];
|
|
6
6
|
export const entryCategories = ["intro", "outro"];
|
|
7
7
|
// The name is what the Play pickers show, so it shares admission's title bound.
|
|
8
8
|
export const nameMax = 200;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the picked templates, the slot names they ask for, and the rendered text stored on the
|
|
3
3
|
// project. The bodies are read at the click, not at selection, so an edit made in between
|
|
4
4
|
// is the one that runs.
|
|
5
|
-
import { normaliseDraft, usesNarrationPreparation } from "../admission/rules.js";
|
|
5
|
+
import { normaliseDraft, usesNarrationPreparation, usesYoutubeDescription, } from "../admission/rules.js";
|
|
6
6
|
import { collectFields, render } from "../admission/substitute.js";
|
|
7
7
|
import { snapshotEntry, snapshotPrompt } from "./snapshot.js";
|
|
8
8
|
export function pickTemplates(db, input, snapshot) {
|
|
@@ -22,6 +22,9 @@ export function pickTemplates(db, input, snapshot) {
|
|
|
22
22
|
const outro = pickEntry(db, "outro", draft.outro, missing, snapshot);
|
|
23
23
|
push(text, "intro", intro);
|
|
24
24
|
push(text, "outro", outro);
|
|
25
|
+
// None picked is the built-in prompt, which asks for no keyword.
|
|
26
|
+
if (usesYoutubeDescription(draft))
|
|
27
|
+
body(db, "description", draft.descriptionPrompt, "descriptionPrompt", missing, text, "description", snapshot);
|
|
25
28
|
if (sources.images === "generate") {
|
|
26
29
|
for (const [index, picked] of draft.imagePrompts.entries()) {
|
|
27
30
|
const field = `imagePrompts.${String(index)}.name`;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { numberPerPromptMax } from "../admission/rules.js";
|
|
2
|
+
import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultZoomPercent, edgeSilenceSecondsProblem, imageSecondsProblem, numberPerPromptMax, zoomPercentProblem, } from "../admission/rules.js";
|
|
3
3
|
import { runDraftSchema } from "../admission/schema.js";
|
|
4
|
+
import { documentThemeOf } from "../document/model.js";
|
|
4
5
|
import { defaultSubtitles } from "../subtitles/model.js";
|
|
5
6
|
export function toAdmissionDraft(input) {
|
|
6
7
|
const { form } = input.document;
|
|
@@ -22,6 +23,18 @@ export function toAdmissionDraft(input) {
|
|
|
22
23
|
fields.push({ field, message: `Enter a whole number between ${min} and ${max}.` });
|
|
23
24
|
return min;
|
|
24
25
|
};
|
|
26
|
+
// A value the run uses is refused in the rule's words; one it ignores (the control is
|
|
27
|
+
// hidden) falls back to the default rather than holding up the run.
|
|
28
|
+
const measure = (field, used, fallback, problem) => {
|
|
29
|
+
const raw = form[field].trim();
|
|
30
|
+
const value = raw === "" ? Number.NaN : Number(raw);
|
|
31
|
+
const refused = problem(value);
|
|
32
|
+
if (refused === undefined)
|
|
33
|
+
return value;
|
|
34
|
+
if (used)
|
|
35
|
+
fields.push({ field, message: refused });
|
|
36
|
+
return fallback;
|
|
37
|
+
};
|
|
25
38
|
const entry = (category) => {
|
|
26
39
|
const name = form[category];
|
|
27
40
|
if (sources.audio !== "generate" || name === "")
|
|
@@ -58,6 +71,9 @@ export function toAdmissionDraft(input) {
|
|
|
58
71
|
title: form.title,
|
|
59
72
|
format: form.format,
|
|
60
73
|
sources,
|
|
74
|
+
...(sources.document === "generate"
|
|
75
|
+
? { document: { theme: documentThemeOf(form.document) } }
|
|
76
|
+
: {}),
|
|
61
77
|
llm: form.llm,
|
|
62
78
|
audio: sources.audio === "generate" || form.audio.usePronunciationGlossary !== undefined
|
|
63
79
|
? form.audio
|
|
@@ -67,6 +83,13 @@ export function toAdmissionDraft(input) {
|
|
|
67
83
|
...(sources.audio === "generate" && form.narrationPrompt?.trim()
|
|
68
84
|
? { narrationPrompt: form.narrationPrompt }
|
|
69
85
|
: {}),
|
|
86
|
+
// Timed from the narration, so a switch left on with narration Off asks for nothing.
|
|
87
|
+
...(sources.audio !== "off" && form.youtubeDescription === true
|
|
88
|
+
? {
|
|
89
|
+
youtubeDescription: true,
|
|
90
|
+
...(form.descriptionPrompt?.trim() ? { descriptionPrompt: form.descriptionPrompt } : {}),
|
|
91
|
+
}
|
|
92
|
+
: {}),
|
|
70
93
|
imagePrompts: sources.images === "generate"
|
|
71
94
|
? form.imagePrompts.map((prompt, index) => ({
|
|
72
95
|
name: prompt.name,
|
|
@@ -112,6 +135,12 @@ export function toAdmissionDraft(input) {
|
|
|
112
135
|
fontSize: number(form.subtitles.fontSize, "subtitles.fontSize", 120, 16),
|
|
113
136
|
},
|
|
114
137
|
silenceGapSeconds: input.silenceGapSeconds,
|
|
138
|
+
imageSeconds: measure("imageSeconds", sources.video === "generate", defaultImageSeconds, imageSecondsProblem),
|
|
139
|
+
zoomPercent: measure("zoomPercent", sources.video === "generate", defaultZoomPercent, zoomPercentProblem),
|
|
140
|
+
// A choice from a list, so there is nothing to refuse; a run without a video keeps it
|
|
141
|
+
// for when the video is turned back on.
|
|
142
|
+
motionStyle: form.motionStyle,
|
|
143
|
+
edgeSilenceSeconds: measure("edgeSilenceSeconds", sources.audio !== "off", defaultEdgeSilenceSeconds, edgeSilenceSecondsProblem),
|
|
115
144
|
};
|
|
116
145
|
const parsed = runDraftSchema.safeParse(draft);
|
|
117
146
|
if (!parsed.success)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { checkRuntimeModel } from "../../catalog/runtime-models.js";
|
|
2
2
|
import { modelFields } from "../../catalog/validate.js";
|
|
3
3
|
import { readinessIsUsable } from "../../kernel/ports/model.js";
|
|
4
|
-
import { usesNarrationPreparation } from "../admission/rules.js";
|
|
4
|
+
import { usesNarrationPreparation, usesYoutubeDescription, } from "../admission/rules.js";
|
|
5
5
|
import { cliPathChanged } from "../settings/cli-paths.js";
|
|
6
6
|
import { hasKey, listVoices } from "../settings/repo.js";
|
|
7
7
|
export function choices(runs) {
|
|
@@ -16,7 +16,8 @@ export function choices(runs) {
|
|
|
16
16
|
d.sources.thumbnail === "prompt_by_llm" ||
|
|
17
17
|
d.intro?.mode === "llm" ||
|
|
18
18
|
d.outro?.mode === "llm" ||
|
|
19
|
-
usesNarrationPreparation(d)
|
|
19
|
+
usesNarrationPreparation(d) ||
|
|
20
|
+
usesYoutubeDescription(d),
|
|
20
21
|
},
|
|
21
22
|
{
|
|
22
23
|
field: "audio",
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { formats } from "../../kernel/pipeline.js";
|
|
2
|
+
import { formats, stageKinds } from "../../kernel/pipeline.js";
|
|
3
3
|
import { thinkingModes } from "../../kernel/ports/llm.js";
|
|
4
|
-
import { stageSources } from "../admission/model.js";
|
|
4
|
+
import { motionStyles, stageSources } from "../admission/model.js";
|
|
5
|
+
import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "../admission/rules.js";
|
|
5
6
|
import { runDraftSchema } from "../admission/schema.js";
|
|
6
7
|
import { checkpointRowSchema, checkpointStageSchema } from "../checkpoints/schema.js";
|
|
8
|
+
import { documentThemes } from "../document/model.js";
|
|
7
9
|
import { librarySnapshotSchema } from "../library/snapshot.js";
|
|
8
10
|
import { chunkModes } from "../narration/chunk.js";
|
|
9
11
|
import { subtitleModes, subtitlePositions } from "../subtitles/model.js";
|
|
@@ -27,9 +29,17 @@ export const playDraftFormSchema = z
|
|
|
27
29
|
images: z.enum(stageSources),
|
|
28
30
|
thumbnail: z.enum(stageSources),
|
|
29
31
|
video: z.enum(stageSources),
|
|
32
|
+
// Absent on drafts and templates saved before the Document stage: Off.
|
|
33
|
+
document: z.enum(stageSources).optional(),
|
|
30
34
|
})
|
|
31
35
|
.strict()
|
|
32
36
|
.readonly(),
|
|
37
|
+
// Absent until the theme is first chosen: the default theme.
|
|
38
|
+
document: z
|
|
39
|
+
.object({ theme: z.enum(documentThemes) })
|
|
40
|
+
.strict()
|
|
41
|
+
.readonly()
|
|
42
|
+
.optional(),
|
|
33
43
|
llm: provider.readonly(),
|
|
34
44
|
audio: provider
|
|
35
45
|
.extend({ voice: text, usePronunciationGlossary: z.boolean().optional() })
|
|
@@ -37,6 +47,9 @@ export const playDraftFormSchema = z
|
|
|
37
47
|
images: provider.readonly(),
|
|
38
48
|
articlePrompt: text,
|
|
39
49
|
narrationPrompt: text.optional(),
|
|
50
|
+
// Absent on drafts and templates saved before the YouTube description: off, built-in prompt.
|
|
51
|
+
youtubeDescription: z.boolean().optional(),
|
|
52
|
+
descriptionPrompt: text.optional(),
|
|
40
53
|
imagePrompts: z.array(z.object({ name: text, number: text }).strict().readonly()).readonly(),
|
|
41
54
|
thumbnailPrompt: text,
|
|
42
55
|
intro: text,
|
|
@@ -55,6 +68,12 @@ export const playDraftFormSchema = z
|
|
|
55
68
|
})
|
|
56
69
|
.strict()
|
|
57
70
|
.readonly(),
|
|
71
|
+
// Raw text, like every other number on Play, so what was typed survives a reload.
|
|
72
|
+
// Defaulted for drafts and templates saved before these controls existed.
|
|
73
|
+
imageSeconds: text.default(String(defaultImageSeconds)),
|
|
74
|
+
edgeSilenceSeconds: text.default(String(defaultEdgeSilenceSeconds)),
|
|
75
|
+
zoomPercent: text.default(String(defaultZoomPercent)),
|
|
76
|
+
motionStyle: z.enum(motionStyles).default(defaultMotionStyle),
|
|
58
77
|
values,
|
|
59
78
|
provided: z
|
|
60
79
|
.object({
|
|
@@ -183,9 +202,7 @@ export const playReviewSchema = z
|
|
|
183
202
|
stage: checkpointStageSchema,
|
|
184
203
|
fingerprint: text,
|
|
185
204
|
workKeys: z.array(text).readonly(),
|
|
186
|
-
dependents: z
|
|
187
|
-
.array(z.enum(["research", "article", "audio", "images", "thumbnail", "video"]))
|
|
188
|
-
.readonly(),
|
|
205
|
+
dependents: z.array(z.enum(stageKinds)).readonly(),
|
|
189
206
|
})
|
|
190
207
|
.strict()
|
|
191
208
|
.readonly())
|
|
@@ -74,6 +74,7 @@ function documentFromProject(deps, revision) {
|
|
|
74
74
|
};
|
|
75
75
|
addPrompt("article", config.articlePrompt, "article");
|
|
76
76
|
addPrompt("narration", config.narrationPrompt, "narration");
|
|
77
|
+
addPrompt("description", config.descriptionPrompt, "description");
|
|
77
78
|
const definitions = revision.content.imageOrder.flatMap((key) => {
|
|
78
79
|
const definition = revision.content.imageDefinitions[key];
|
|
79
80
|
return definition === undefined ? [] : [definition];
|
|
@@ -127,12 +128,17 @@ function documentFromProject(deps, revision) {
|
|
|
127
128
|
title: config.title,
|
|
128
129
|
format: config.format,
|
|
129
130
|
sources: config.sources,
|
|
131
|
+
...(config.document === undefined ? {} : { document: config.document }),
|
|
130
132
|
checkpoints: listCheckpoints(deps.db, revision.projectId, revision.id).map((gate) => gate.stage),
|
|
131
133
|
llm: config.llm ?? { provider: "", model: "" },
|
|
132
134
|
audio: config.audio ?? { provider: "", model: "", voice: "" },
|
|
133
135
|
images: config.images ?? { provider: "", model: "" },
|
|
134
136
|
articlePrompt: config.articlePrompt ?? "",
|
|
135
137
|
...(config.narrationPrompt === undefined ? {} : { narrationPrompt: config.narrationPrompt }),
|
|
138
|
+
...(config.youtubeDescription === true ? { youtubeDescription: true } : {}),
|
|
139
|
+
...(config.descriptionPrompt === undefined
|
|
140
|
+
? {}
|
|
141
|
+
: { descriptionPrompt: config.descriptionPrompt }),
|
|
136
142
|
imagePrompts,
|
|
137
143
|
thumbnailPrompt: config.thumbnailPrompt ?? "",
|
|
138
144
|
intro: config.intro?.name ?? "",
|
|
@@ -149,6 +155,10 @@ function documentFromProject(deps, revision) {
|
|
|
149
155
|
fontSize: config.subtitles?.fontSize === undefined ? "48" : String(config.subtitles.fontSize),
|
|
150
156
|
position: config.subtitles?.position ?? "bottom",
|
|
151
157
|
},
|
|
158
|
+
imageSeconds: String(config.imageSeconds),
|
|
159
|
+
edgeSilenceSeconds: String(config.edgeSilenceSeconds),
|
|
160
|
+
zoomPercent: String(config.zoomPercent),
|
|
161
|
+
motionStyle: config.motionStyle,
|
|
152
162
|
values: config.values,
|
|
153
163
|
provided,
|
|
154
164
|
},
|
|
@@ -5,6 +5,7 @@ export function templateSetup(deps, input) {
|
|
|
5
5
|
const choices = [
|
|
6
6
|
{ kind: "article", name: input.form.articlePrompt },
|
|
7
7
|
{ kind: "narration", name: input.form.narrationPrompt ?? "" },
|
|
8
|
+
{ kind: "description", name: input.form.descriptionPrompt ?? "" },
|
|
8
9
|
...input.form.imagePrompts.map((row) => ({ kind: "image", name: row.name })),
|
|
9
10
|
{ kind: "thumbnail", name: input.form.thumbnailPrompt },
|
|
10
11
|
];
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
2
|
+
import { sourceOf } from "../admission/model.js";
|
|
3
|
+
import { motionStyleLabels } from "../admission/rules.js";
|
|
4
|
+
import { documentThemeLabels, documentThemeOf } from "../document/model.js";
|
|
2
5
|
import { getRevisionView } from "../revisions/view.js";
|
|
3
6
|
export function previewDetails(deps, view, allRecipes, selected) {
|
|
4
7
|
const parent = view.revision.parentId === null
|
|
@@ -111,7 +114,7 @@ function inputChanges(parent, view) {
|
|
|
111
114
|
add("Project title", before.title, after.title);
|
|
112
115
|
add("Aspect ratio", before.format, after.format);
|
|
113
116
|
for (const kind of stageKinds)
|
|
114
|
-
add(`${kind[0]?.toUpperCase()}${kind.slice(1)} source`, before.sources
|
|
117
|
+
add(`${kind[0]?.toUpperCase()}${kind.slice(1)} source`, sourceOf(before.sources, kind), sourceOf(after.sources, kind));
|
|
115
118
|
for (const [name, a, b] of [
|
|
116
119
|
["Text", before.llm, after.llm],
|
|
117
120
|
["Narration", before.audio, after.audio],
|
|
@@ -124,10 +127,17 @@ function inputChanges(parent, view) {
|
|
|
124
127
|
add("Narration voice", before.audio?.voice, after.audio?.voice);
|
|
125
128
|
add("Narration Preparation", before.narrationPrompt, after.narrationPrompt);
|
|
126
129
|
add("Narration chunking", before.chunking, after.chunking);
|
|
130
|
+
add("YouTube description", before.youtubeDescription === true ? "On" : "Off", after.youtubeDescription === true ? "On" : "Off");
|
|
131
|
+
add("Description prompt", before.descriptionPrompt, after.descriptionPrompt);
|
|
127
132
|
add("Silence gap (seconds)", before.silenceGapSeconds, after.silenceGapSeconds);
|
|
133
|
+
add("Silence at start and end (seconds)", before.edgeSilenceSeconds, after.edgeSilenceSeconds);
|
|
134
|
+
add("Seconds per image", before.imageSeconds, after.imageSeconds);
|
|
135
|
+
add("Zoom (%)", before.zoomPercent, after.zoomPercent);
|
|
136
|
+
add("Motion", motionStyleLabels[before.motionStyle], motionStyleLabels[after.motionStyle]);
|
|
128
137
|
add("Intro", before.intro, after.intro);
|
|
129
138
|
add("Outro", before.outro, after.outro);
|
|
130
139
|
add("Subtitle settings", before.subtitles, after.subtitles);
|
|
140
|
+
add("Document theme", documentThemeLabels[documentThemeOf(before.document)], documentThemeLabels[documentThemeOf(after.document)]);
|
|
131
141
|
add("Article text", parent.articleMarkdown, view.articleMarkdown);
|
|
132
142
|
add("Provided research", before.provided.research, after.provided.research);
|
|
133
143
|
for (const key of new Set([...Object.keys(before.values), ...Object.keys(after.values)]))
|
|
@@ -152,6 +152,9 @@ export function audioRecipes(context, text) {
|
|
|
152
152
|
"audio-media-v1",
|
|
153
153
|
ordered.map(({ value }) => resourceIdentity(context, value)),
|
|
154
154
|
config.silenceGapSeconds,
|
|
155
|
+
// The exported timeline, not a narration piece: the silence only reaches the
|
|
156
|
+
// exports that read this fingerprint.
|
|
157
|
+
config.edgeSilenceSeconds,
|
|
155
158
|
]),
|
|
156
159
|
timeline,
|
|
157
160
|
keys: ordered.map(({ value }) => value.key),
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { audioRecipes } from "./recipe-audio.js";
|
|
2
|
+
import { documentRecipes } from "./recipe-document.js";
|
|
2
3
|
import { exportRecipes } from "./recipe-exports.js";
|
|
3
4
|
import { textRecipes } from "./recipe-text.js";
|
|
4
5
|
import { thumbnailRecipes, visualAssets, visualRecipes } from "./recipe-visual.js";
|
|
6
|
+
import { youtubeRecipes } from "./recipe-youtube.js";
|
|
5
7
|
export function buildRecipes(context) {
|
|
6
8
|
const text = textRecipes(context);
|
|
7
9
|
const audio = audioRecipes(context, text);
|
|
8
10
|
const exports = exportRecipes(context, audio);
|
|
9
11
|
const captions = exports.find((value) => value.key === "subtitles:files");
|
|
12
|
+
const thumbnail = thumbnailRecipes(context, text.recipes);
|
|
10
13
|
return [
|
|
11
14
|
...text.recipes,
|
|
12
15
|
...audio.recipes,
|
|
13
16
|
...exports,
|
|
14
|
-
...
|
|
17
|
+
...youtubeRecipes(context, exports),
|
|
18
|
+
...thumbnail,
|
|
19
|
+
...documentRecipes(context, text, thumbnail),
|
|
15
20
|
...visualAssets(context, visualRecipes(context.config, context.content, audio.mediaFingerprint, captions?.fingerprint ?? null)),
|
|
16
21
|
];
|
|
17
22
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { sourceOf } from "../admission/model.js";
|
|
2
|
+
import { documentThemeOf } from "../document/model.js";
|
|
3
|
+
import { builtInTheme } from "../document/theme.js";
|
|
4
|
+
import { recipe, resourceIdentity, } from "./recipe-model.js";
|
|
5
|
+
// Bumped when the renderer's output changes for the same inputs, so documents made by an
|
|
6
|
+
// older one read as outdated rather than current.
|
|
7
|
+
const renderer = "document-v1";
|
|
8
|
+
// The PDF is made from the saved article (whose end matter feeds the Sources page), the
|
|
9
|
+
// research notes' links, the thumbnail as a cover and the resolved theme. It waits for
|
|
10
|
+
// nothing else, so narration and images run beside it.
|
|
11
|
+
export function documentRecipes(context, text, thumbnail) {
|
|
12
|
+
const { config } = context;
|
|
13
|
+
if (sourceOf(config.sources, "document") !== "generate")
|
|
14
|
+
return [];
|
|
15
|
+
const notes = text.recipes.find((value) => value.key === "research:notes");
|
|
16
|
+
const cover = thumbnail.find((value) => value.key === "thumbnail:image");
|
|
17
|
+
const theme = builtInTheme(documentThemeOf(config.document));
|
|
18
|
+
return [
|
|
19
|
+
recipe(context, "document:pdf", "document", {
|
|
20
|
+
kind: "local",
|
|
21
|
+
version: 1,
|
|
22
|
+
operation: "render-document",
|
|
23
|
+
values: [
|
|
24
|
+
renderer,
|
|
25
|
+
config.title,
|
|
26
|
+
JSON.parse(JSON.stringify(theme)),
|
|
27
|
+
resourceIdentity(context, text.article),
|
|
28
|
+
notes === undefined ? null : resourceIdentity(context, notes),
|
|
29
|
+
cover === undefined ? null : resourceIdentity(context, cover),
|
|
30
|
+
],
|
|
31
|
+
}, [
|
|
32
|
+
text.article.key,
|
|
33
|
+
...[notes, cover].flatMap((value) => (value === undefined ? [] : [value.key])),
|
|
34
|
+
]),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { usesYoutubeDescription } from "../admission/rules.js";
|
|
1
2
|
import { audioExportArgs } from "../video/audio-export-args.js";
|
|
2
3
|
import { recipe, resourceIdentity, } from "./recipe-model.js";
|
|
3
4
|
export function exportRecipes(context, audio) {
|
|
@@ -15,15 +16,26 @@ export function exportRecipes(context, audio) {
|
|
|
15
16
|
audioExportArgs([{ kind: "body", path: "$body", seconds: 0 }], "$output"),
|
|
16
17
|
],
|
|
17
18
|
}, audio.keys));
|
|
18
|
-
|
|
19
|
+
const captions = config.subtitles !== undefined && config.subtitles.mode !== "off";
|
|
20
|
+
// The YouTube description's chapters use the same word timing, so it runs for them even
|
|
21
|
+
// with captions off; only the caption files below wait for captions.
|
|
22
|
+
if (!captions && !usesYoutubeDescription(config))
|
|
19
23
|
return recipes;
|
|
20
24
|
const timing = recipe(context, "subtitles:timing", "video", {
|
|
21
25
|
kind: "local",
|
|
22
26
|
version: 1,
|
|
23
27
|
operation: "wav2vec2-en-a19f851-v2-omissions",
|
|
24
|
-
|
|
28
|
+
// The lead-in moves every word, so the edge silence is part of the timing.
|
|
29
|
+
values: [
|
|
30
|
+
audio.timeline,
|
|
31
|
+
config.silenceGapSeconds,
|
|
32
|
+
config.subtitles?.language ?? "en",
|
|
33
|
+
config.edgeSilenceSeconds,
|
|
34
|
+
],
|
|
25
35
|
}, audio.keys);
|
|
26
36
|
recipes.push(timing);
|
|
37
|
+
if (config.subtitles === undefined || config.subtitles.mode === "off")
|
|
38
|
+
return recipes;
|
|
27
39
|
const cues = content.subtitleCues;
|
|
28
40
|
const cueRecipe = cues === undefined
|
|
29
41
|
? recipe(context, "subtitles:cues", "video", {
|
|
@@ -17,6 +17,10 @@ export const config = {
|
|
|
17
17
|
values: {},
|
|
18
18
|
provided: { article: "First paragraph.\n\nSecond paragraph." },
|
|
19
19
|
silenceGapSeconds: 0,
|
|
20
|
+
imageSeconds: 15,
|
|
21
|
+
zoomPercent: 22.5,
|
|
22
|
+
motionStyle: "zoom",
|
|
23
|
+
edgeSilenceSeconds: 0,
|
|
20
24
|
rendered: { article: "Write an article." },
|
|
21
25
|
};
|
|
22
26
|
export const content = {
|
|
@@ -54,6 +54,11 @@ export function legacyOutputWorkKey(output, config) {
|
|
|
54
54
|
return "export:wav";
|
|
55
55
|
case "video":
|
|
56
56
|
return "export:video";
|
|
57
|
+
case "document_pdf":
|
|
58
|
+
return "document:pdf";
|
|
59
|
+
case "youtube_description":
|
|
60
|
+
case "youtube_tags":
|
|
61
|
+
return "youtube:description";
|
|
57
62
|
case "subtitles_srt":
|
|
58
63
|
case "subtitles_vtt":
|
|
59
64
|
case "subtitle_ass":
|
|
@@ -2,6 +2,9 @@ export function recipeProviderChoice(recipe, config) {
|
|
|
2
2
|
const input = recipe.input;
|
|
3
3
|
if (input.kind === "llm" || input.kind === "tts" || input.kind === "image")
|
|
4
4
|
return { ...input, family: input.kind };
|
|
5
|
+
// The YouTube description's request is built when it runs, from the project's LLM row.
|
|
6
|
+
if (input.kind === "local" && input.operation === "youtube-description-v1")
|
|
7
|
+
return config.llm === undefined ? undefined : { ...config.llm, family: "llm" };
|
|
5
8
|
if (input.kind !== "deferred")
|
|
6
9
|
return undefined;
|
|
7
10
|
const family = input.operation === "narration-preparation"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
2
|
-
import {
|
|
2
|
+
import { sourceOf } from "../admission/model.js";
|
|
3
|
+
import { allowedSources, edgeSilenceSecondsProblem, imageSecondsProblem, normaliseDraft, silenceGapSecondsMax, zoomPercentProblem, } from "../admission/rules.js";
|
|
3
4
|
import { render } from "../admission/substitute.js";
|
|
4
5
|
import { buildRecipes } from "./recipe-build.js";
|
|
5
6
|
import { selectedReference } from "./recipe-model.js";
|
|
@@ -153,10 +154,10 @@ function normalizeImages(content) {
|
|
|
153
154
|
export function validateRevisionEdit(config, content) {
|
|
154
155
|
const fields = [];
|
|
155
156
|
for (const kind of stageKinds)
|
|
156
|
-
if (!allowedSources[kind].includes(config.sources
|
|
157
|
+
if (!allowedSources[kind].includes(sourceOf(config.sources, kind)))
|
|
157
158
|
fields.push({
|
|
158
159
|
field: `sources.${kind}`,
|
|
159
|
-
message: `The ${kind} stage cannot be set to ${config.sources
|
|
160
|
+
message: `The ${kind} stage cannot be set to ${sourceOf(config.sources, kind)}.`,
|
|
160
161
|
});
|
|
161
162
|
if (config.sources.images === "provide" &&
|
|
162
163
|
content.imageOrder.some((key) => content.imageDefinitions[key]?.source === "generate"))
|
|
@@ -187,6 +188,17 @@ export function validateRevisionEdit(config, content) {
|
|
|
187
188
|
field: "silenceGapSeconds",
|
|
188
189
|
message: `Use a gap between 0 and ${silenceGapSecondsMax} seconds.`,
|
|
189
190
|
});
|
|
191
|
+
const imageProblem = config.sources.video === "off" ? undefined : imageSecondsProblem(config.imageSeconds);
|
|
192
|
+
if (imageProblem !== undefined)
|
|
193
|
+
fields.push({ field: "imageSeconds", message: imageProblem });
|
|
194
|
+
const zoomProblem = config.sources.video === "off" ? undefined : zoomPercentProblem(config.zoomPercent);
|
|
195
|
+
if (zoomProblem !== undefined)
|
|
196
|
+
fields.push({ field: "zoomPercent", message: zoomProblem });
|
|
197
|
+
const edgeProblem = config.sources.audio === "off"
|
|
198
|
+
? undefined
|
|
199
|
+
: edgeSilenceSecondsProblem(config.edgeSilenceSeconds);
|
|
200
|
+
if (edgeProblem !== undefined)
|
|
201
|
+
fields.push({ field: "edgeSilenceSeconds", message: edgeProblem });
|
|
190
202
|
if (config.sources.article === "generate" &&
|
|
191
203
|
content.articleEdited === true &&
|
|
192
204
|
!content.articleMarkdown?.trim())
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { narrationPreparationFields, usesNarrationPreparation, } from "../admission/rules.js";
|
|
1
|
+
import { narrationPreparationFields, usesNarrationPreparation, usesYoutubeDescription, youtubeDescriptionFields, } from "../admission/rules.js";
|
|
2
2
|
import { detectSlots, render } from "../admission/substitute.js";
|
|
3
3
|
export function validateRecipeInputs(config, content) {
|
|
4
|
-
const fields = [
|
|
4
|
+
const fields = [
|
|
5
|
+
...narrationPreparationFields(config),
|
|
6
|
+
...youtubeDescriptionFields(config),
|
|
7
|
+
];
|
|
5
8
|
const llm = (config.sources.research === "generate" && config.sources.article !== "provide") ||
|
|
6
9
|
(config.sources.article === "generate" && !content.articleEdited) ||
|
|
7
10
|
config.sources.thumbnail === "prompt_by_llm" ||
|
|
8
11
|
usesNarrationPreparation(config) ||
|
|
12
|
+
usesYoutubeDescription(config) ||
|
|
9
13
|
(config.sources.audio === "generate" &&
|
|
10
14
|
(config.intro?.mode === "llm" || config.outro?.mode === "llm"));
|
|
11
15
|
if (llm && (!config.llm?.provider.trim() || !config.llm.model.trim()))
|
|
@@ -43,6 +47,15 @@ export function validateRecipeInputs(config, content) {
|
|
|
43
47
|
raw: content.promptTemplates.narration ?? null,
|
|
44
48
|
literal: config.rendered.narration,
|
|
45
49
|
});
|
|
50
|
+
// Only a picked Description prompt is checked: none picked uses the built-in one.
|
|
51
|
+
if (usesYoutubeDescription(config) &&
|
|
52
|
+
config.descriptionPrompt?.trim() &&
|
|
53
|
+
content.promptTemplates.description != null)
|
|
54
|
+
prompts.push({
|
|
55
|
+
field: "rendered.description",
|
|
56
|
+
raw: content.promptTemplates.description,
|
|
57
|
+
literal: config.rendered.description,
|
|
58
|
+
});
|
|
46
59
|
if ((config.sources.article === "generate" && !content.articleEdited) ||
|
|
47
60
|
(config.sources.research === "generate" && config.sources.article !== "provide"))
|
|
48
61
|
prompts.push({
|
|
@@ -45,7 +45,16 @@ export function visualRecipes(config, content, audioFingerprint, captionFingerpr
|
|
|
45
45
|
recipes.map((value) => value.fingerprint),
|
|
46
46
|
audioFingerprint,
|
|
47
47
|
config.subtitles?.mode === "burn-in" ? captionFingerprint : null,
|
|
48
|
-
|
|
48
|
+
// Only here: how long a still is held and how it moves change the render,
|
|
49
|
+
// never an image.
|
|
50
|
+
config.imageSeconds,
|
|
51
|
+
config.zoomPercent,
|
|
52
|
+
// Left out for "zoom", which is what every video did before the setting
|
|
53
|
+
// existed, so a project that keeps it keeps its render; the other styles
|
|
54
|
+
// each render differently.
|
|
55
|
+
...(config.motionStyle === "zoom" ? [] : [config.motionStyle]),
|
|
56
|
+
// v1 split the timeline evenly across the images; v2 cycles them.
|
|
57
|
+
"slideshow-zoom-v2",
|
|
49
58
|
],
|
|
50
59
|
}, [
|
|
51
60
|
...recipes.map((value) => value.key),
|
|
@@ -177,6 +177,9 @@ function retainedFor(row, logical, manifest, available) {
|
|
|
177
177
|
pieceIds: [],
|
|
178
178
|
}));
|
|
179
179
|
}
|
|
180
|
+
// ceiling: the timed transcript of a 20-minute narration, about 3,000 words. The real one
|
|
181
|
+
// is only built once the word timing lands.
|
|
182
|
+
const youtubeTranscriptEstimate = 20000;
|
|
180
183
|
export function priceRecipe(work, value) {
|
|
181
184
|
if (value === undefined || work.disposition !== "generate")
|
|
182
185
|
return {
|
|
@@ -214,6 +217,18 @@ export function priceRecipe(work, value) {
|
|
|
214
217
|
};
|
|
215
218
|
if (input.kind === "image")
|
|
216
219
|
return { kind: "image", stage: work.key, provider: input.provider, model: input.model };
|
|
220
|
+
if (input.kind === "local" && input.operation === "youtube-description-v1") {
|
|
221
|
+
const [, provider, model, , prompt] = Array.isArray(input.values) ? input.values : [];
|
|
222
|
+
return {
|
|
223
|
+
kind: "llm",
|
|
224
|
+
stage: work.key,
|
|
225
|
+
provider: typeof provider === "string" ? provider : "",
|
|
226
|
+
model: typeof model === "string" ? model : "",
|
|
227
|
+
inputCharacters: (typeof prompt === "string" ? prompt.length : 0) + youtubeTranscriptEstimate,
|
|
228
|
+
outputCharacters: 2400,
|
|
229
|
+
detail: "The timed transcript is built when the step runs; its length is estimated. Retries are excluded.",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
217
232
|
return {
|
|
218
233
|
kind: "tts-estimate",
|
|
219
234
|
stage: work.key,
|