@gentbajko/slopify 2.0.3 → 2.1.1

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 +91 -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 +4 -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-DV01Cyjx.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,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
- field: "video",
169
- message: "Source media is incomplete. Use Resume to recover it before rerunning the export.",
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;
@@ -72,5 +72,7 @@ function keyStage(key) {
72
72
  return "thumbnail";
73
73
  if (prefix === "research")
74
74
  return "research";
75
+ if (prefix === "document")
76
+ return "document";
75
77
  return "video";
76
78
  }
@@ -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 { renderArgs, runFfmpeg } from "../video/ffmpeg.js";
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
- audio: plan.audio.map((row) => ({
85
- ...row,
86
- path: row.path === null ? null : relativePath(row.path),
87
- })),
88
- images: plan.images.map((row) => ({ ...row, path: relativePath(row.path) })),
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
- await runFfmpeg({
94
- bin: deps.ffmpeg,
95
- args: plan === undefined ? audioExportArgs(audio, pending.absolutePath) : renderArgs(plan, burn),
96
- cwd: directory,
97
- signal: context.signal,
98
- log: deps.log,
99
- onProgress: (elapsedMs) => context.emit({
100
- type: "stage.progress",
101
- projectId: context.work.projectId,
102
- stage: "video",
103
- current: Math.min(100, Math.round(elapsedMs / (totalSeconds * 10))),
104
- total: 100,
105
- }),
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")
@@ -127,6 +127,7 @@ function pieceIndex(deps, context, piece) {
127
127
  "subtitles:files",
128
128
  "export:wav",
129
129
  "export:video",
130
+ "document:pdf",
130
131
  "narration:files:body",
131
132
  "narration:files:intro",
132
133
  "narration:files:outro",
@@ -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.input.kind === "llm" || piece.input.kind === "tts" || piece.input.kind === "image"
20
- ? await executeProviderRecipe(deps, context, providers, piece)
21
- : await executeLocalRecipe(deps, context, piece);
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
- if (piece.key === "subtitles:timing" && view?.revision.content.subtitleCues !== undefined)
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[kind];
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
- const wordsSchema = z.object({
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 → Subtitles, or turn narration on, then Retry stage.");
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 !== "gap") {
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,91 @@
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
+ deps.count?.("stage.completed", {
84
+ provider: llm.provider,
85
+ model: llm.model,
86
+ tokensIn: answer.value.usage?.inputTokens ?? 0,
87
+ tokensOut: answer.value.usage?.outputTokens ?? 0,
88
+ descriptions: 1,
89
+ });
90
+ return "done";
91
+ }
@@ -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 video comes
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 ?? "pending";
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[kind];
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");
@@ -19,6 +19,7 @@ const contentTypes = {
19
19
  ".ogg": "audio/ogg",
20
20
  ".opus": "audio/opus",
21
21
  ".mp4": "video/mp4",
22
+ ".pdf": "application/pdf",
22
23
  ".srt": "application/x-subrip; charset=utf-8",
23
24
  ".vtt": "text/vtt; charset=utf-8",
24
25
  };
@@ -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":
@@ -24,5 +24,8 @@ export const outputRoles = [
24
24
  "subtitle_ass",
25
25
  "subtitle_font",
26
26
  "instructions",
27
+ "document_pdf",
28
+ "youtube_description",
29
+ "youtube_tags",
27
30
  ];
28
31
  export const stagedFileStates = ["copying", "staged"];
@@ -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 === "gap" ? "" : spokenText(deps, projectId, segment.kind, outputs),
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 video. The three that are not whole
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.
@@ -34,4 +34,7 @@ export const payloadSchema = z.strictObject({
34
34
  audioSeconds: z.number().nonnegative().finite().optional(),
35
35
  images: z.number().int().nonnegative().optional(),
36
36
  thumbnails: z.number().int().nonnegative().optional(),
37
+ // A written YouTube description. Its event names no stage: it is made inside the Video
38
+ // stage, and a collector counting stage "video" as a finished video must not count it.
39
+ descriptions: z.number().int().nonnegative().optional(),
37
40
  });