@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.
Files changed (103) hide show
  1. package/SUBTITLES.md +1 -1
  2. package/dist/adapters/alignment/threads.js +11 -0
  3. package/dist/adapters/alignment/worker.js +38 -4
  4. package/dist/adapters/host-cli/index.js +25 -1
  5. package/dist/assets/document/SOURCE.txt +7 -0
  6. package/dist/assets/document/background.jpg +0 -0
  7. package/dist/assets/document/fonts/Cinzel-Black.ttf +0 -0
  8. package/dist/assets/document/fonts/Cinzel-Bold.ttf +0 -0
  9. package/dist/assets/document/fonts/Cinzel-Medium.ttf +0 -0
  10. package/dist/assets/document/fonts/Cinzel-Regular.ttf +0 -0
  11. package/dist/assets/document/fonts/OFL.txt +93 -0
  12. package/dist/assets/document/fonts/SOURCE.txt +11 -0
  13. package/dist/catalog/validate.js +3 -2
  14. package/dist/edge/docker-projects/state.js +5 -1
  15. package/dist/edge/host-cli.js +14 -0
  16. package/dist/edge/http/folder-location-schema.js +5 -8
  17. package/dist/edge/http/folder-location.js +10 -5
  18. package/dist/edge/http/host-cli.js +27 -2
  19. package/dist/edge/http/revision-files.js +5 -1
  20. package/dist/host-cli/open-folder.js +95 -0
  21. package/dist/host-cli/runtime.js +2 -0
  22. package/dist/kernel/db/migrate.js +25 -1
  23. package/dist/kernel/db/migrations/0014-document-stage.sql +44 -0
  24. package/dist/kernel/db/migrations/0015-description-prompts.sql +13 -0
  25. package/dist/kernel/pipeline.js +11 -1
  26. package/dist/kernel/ports/host-cli.js +14 -0
  27. package/dist/kernel/runner/graph.js +6 -1
  28. package/dist/main.js +6 -2
  29. package/dist/slices/admission/model.js +7 -1
  30. package/dist/slices/admission/rules.js +67 -3
  31. package/dist/slices/admission/schema.js +16 -3
  32. package/dist/slices/admission/start.js +3 -2
  33. package/dist/slices/document/blocks.js +129 -0
  34. package/dist/slices/document/flow.js +117 -0
  35. package/dist/slices/document/fonts.js +54 -0
  36. package/dist/slices/document/model.js +11 -0
  37. package/dist/slices/document/pages.js +198 -0
  38. package/dist/slices/document/render.js +242 -0
  39. package/dist/slices/document/sources.js +35 -0
  40. package/dist/slices/document/theme.js +158 -0
  41. package/dist/slices/document/writer.js +66 -0
  42. package/dist/slices/estimate/index.js +14 -2
  43. package/dist/slices/library/model.js +1 -1
  44. package/dist/slices/library/slots.js +4 -1
  45. package/dist/slices/play-drafts/convert.js +30 -1
  46. package/dist/slices/play-drafts/readiness.js +3 -2
  47. package/dist/slices/play-drafts/schema.js +22 -5
  48. package/dist/slices/project-templates/from-project.js +10 -0
  49. package/dist/slices/project-templates/setup.js +1 -0
  50. package/dist/slices/rebuild/preview-details.js +11 -1
  51. package/dist/slices/rebuild/recipe-audio.js +3 -0
  52. package/dist/slices/rebuild/recipe-build.js +6 -1
  53. package/dist/slices/rebuild/recipe-document.js +36 -0
  54. package/dist/slices/rebuild/recipe-exports.js +14 -2
  55. package/dist/slices/rebuild/recipe-fixture.js +4 -0
  56. package/dist/slices/rebuild/recipe-legacy.js +5 -0
  57. package/dist/slices/rebuild/recipe-model.js +2 -0
  58. package/dist/slices/rebuild/recipe-provider-choice.js +3 -0
  59. package/dist/slices/rebuild/recipe-save.js +15 -3
  60. package/dist/slices/rebuild/recipe-validation.js +15 -2
  61. package/dist/slices/rebuild/recipe-visual.js +10 -1
  62. package/dist/slices/rebuild/recipe-work.js +15 -0
  63. package/dist/slices/rebuild/recipe-youtube.js +33 -0
  64. package/dist/slices/rebuild/recovery-selection.js +2 -0
  65. package/dist/slices/rebuild/recovery.js +10 -5
  66. package/dist/slices/rebuild/runtime-actions.js +2 -0
  67. package/dist/slices/rebuild/runtime-document.js +85 -0
  68. package/dist/slices/rebuild/runtime-export-inputs.js +1 -0
  69. package/dist/slices/rebuild/runtime-export.js +35 -19
  70. package/dist/slices/rebuild/runtime-piece-label.js +4 -0
  71. package/dist/slices/rebuild/runtime-publication.js +1 -0
  72. package/dist/slices/rebuild/runtime-run.js +11 -3
  73. package/dist/slices/rebuild/runtime-store.js +7 -2
  74. package/dist/slices/rebuild/runtime-subtitles.js +6 -3
  75. package/dist/slices/rebuild/runtime-youtube.js +84 -0
  76. package/dist/slices/rebuild/transition-repo.js +2 -0
  77. package/dist/slices/reruns/cascade.js +6 -4
  78. package/dist/slices/revisions/mutation-cues.js +3 -1
  79. package/dist/slices/revisions/projection.js +2 -1
  80. package/dist/slices/storage/downloads.js +1 -0
  81. package/dist/slices/storage/layout.js +6 -0
  82. package/dist/slices/storage/model.js +3 -0
  83. package/dist/slices/subtitles/prepare.js +2 -1
  84. package/dist/slices/telemetry/model.js +1 -1
  85. package/dist/slices/video/audio-export.js +11 -3
  86. package/dist/slices/video/edit-list.js +73 -0
  87. package/dist/slices/video/ffmpeg.js +123 -57
  88. package/dist/slices/video/motion.js +51 -0
  89. package/dist/slices/video/plan.js +61 -27
  90. package/dist/slices/video/reuse-audio.js +2 -0
  91. package/dist/slices/video/run.js +21 -10
  92. package/dist/slices/video/slideshow.js +48 -0
  93. package/dist/slices/video/write-export.js +17 -25
  94. package/dist/slices/youtube/answer.js +199 -0
  95. package/dist/slices/youtube/model.js +27 -0
  96. package/dist/slices/youtube/timestamps.js +24 -0
  97. package/dist/slices/youtube/transcript.js +47 -0
  98. package/dist/web/assets/index-B6NbhpjD.css +1 -0
  99. package/dist/web/assets/index-ComF5gwP.js +142 -0
  100. package/dist/web/index.html +2 -2
  101. package/package.json +4 -2
  102. package/dist/web/assets/index-Bwtmu9ei.css +0 -1
  103. 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 { usesNarrationPreparation } from "../admission/rules.js";
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[kind], after.sources[kind]);
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
- ...thumbnailRecipes(context, text.recipes),
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
- if (config.subtitles === undefined || config.subtitles.mode === "off")
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
- values: [audio.timeline, config.silenceGapSeconds, config.subtitles.language],
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":
@@ -15,6 +15,8 @@ export const localOperations = [
15
15
  "entry-text",
16
16
  "concat-narration",
17
17
  "narration-files-v1",
18
+ "render-document",
19
+ "youtube-description-v1",
18
20
  ];
19
21
  export const deferredOperations = [
20
22
  "narration-preparation",
@@ -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 { allowedSources, normaliseDraft, silenceGapSecondsMax, } from "../admission/rules.js";
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[kind]))
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[kind]}.`,
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 = [...narrationPreparationFields(config)];
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
- "slideshow-zoom-v1",
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,