@gentbajko/slopify 0.6.1 → 0.8.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 (80) hide show
  1. package/README.md +36 -1
  2. package/SUBTITLES.md +3 -3
  3. package/dist/adapter-registry.js +12 -2
  4. package/dist/adapters/image/google.js +12 -15
  5. package/dist/adapters/image/models.js +72 -0
  6. package/dist/adapters/image/openai.js +3 -5
  7. package/dist/adapters/llm/catalogue-files.js +23 -0
  8. package/dist/adapters/llm/claude-code.js +3 -4
  9. package/dist/adapters/llm/codex-models.js +46 -0
  10. package/dist/adapters/llm/codex.js +7 -10
  11. package/dist/adapters/llm/gemini-models.js +85 -0
  12. package/dist/adapters/llm/gemini-workspace.js +24 -1
  13. package/dist/adapters/llm/gemini.js +4 -7
  14. package/dist/adapters/llm/openrouter.js +12 -2
  15. package/dist/adapters/tts/cartesia.js +9 -1
  16. package/dist/adapters/tts/elevenlabs.js +33 -2
  17. package/dist/adapters/tts/inworld-async.js +150 -0
  18. package/dist/adapters/tts/inworld-text.js +32 -0
  19. package/dist/adapters/tts/inworld.js +170 -0
  20. package/dist/adapters/tts/openai.js +31 -4
  21. package/dist/assets/models.yaml +470 -0
  22. package/dist/catalog/registry.js +147 -0
  23. package/dist/catalog/schema.js +91 -0
  24. package/dist/catalog/store.js +90 -0
  25. package/dist/catalog/validate.js +43 -0
  26. package/dist/edge/cli.js +5 -0
  27. package/dist/edge/events/hub.js +9 -2
  28. package/dist/edge/events/preview-cache.js +40 -0
  29. package/dist/edge/http/actions.js +2 -0
  30. package/dist/edge/http/app.js +28 -1
  31. package/dist/edge/http/audio-preview.js +46 -0
  32. package/dist/edge/http/planning.js +101 -0
  33. package/dist/edge/http/projects.js +4 -2
  34. package/dist/edge/http/providers.js +44 -0
  35. package/dist/edge/http/update.js +57 -0
  36. package/dist/edge/update-worker.js +44 -0
  37. package/dist/kernel/audio-preview.js +188 -0
  38. package/dist/kernel/db/migrations/0003-batch-queue.sql +11 -0
  39. package/dist/kernel/ports/llm.js +1 -0
  40. package/dist/kernel/ports/text.js +26 -0
  41. package/dist/kernel/runner/providers.js +80 -28
  42. package/dist/kernel/runner/queue.js +65 -0
  43. package/dist/main.js +142 -27
  44. package/dist/model-catalog.js +37 -0
  45. package/dist/slices/admission/repo.js +6 -1
  46. package/dist/slices/admission/start.js +9 -8
  47. package/dist/slices/article/continuation.js +8 -1
  48. package/dist/slices/article/segments.js +1 -0
  49. package/dist/slices/batch/index.js +82 -0
  50. package/dist/slices/control/index.js +6 -1
  51. package/dist/slices/control/providers.js +16 -1
  52. package/dist/slices/estimate/index.js +81 -0
  53. package/dist/slices/narration/live.js +21 -0
  54. package/dist/slices/narration/plan.js +58 -0
  55. package/dist/slices/narration/run.js +15 -36
  56. package/dist/slices/research/run.js +7 -0
  57. package/dist/slices/settings/model.js +2 -0
  58. package/dist/slices/settings/models.js +89 -0
  59. package/dist/slices/storage/staging.js +2 -1
  60. package/dist/slices/subtitles/captions.js +4 -1
  61. package/dist/slices/subtitles/layout.js +19 -0
  62. package/dist/slices/subtitles/model.js +9 -0
  63. package/dist/slices/subtitles/prepare.js +6 -1
  64. package/dist/slices/thumbnail/run.js +3 -0
  65. package/dist/updater/candidate.js +28 -0
  66. package/dist/updater/forward.js +35 -0
  67. package/dist/updater/install-flow.js +26 -0
  68. package/dist/updater/install.js +50 -0
  69. package/dist/updater/model.js +24 -0
  70. package/dist/updater/plan.js +135 -0
  71. package/dist/updater/readiness.js +15 -0
  72. package/dist/updater/registry.js +19 -0
  73. package/dist/updater/service.js +128 -0
  74. package/dist/updater/worker.js +136 -0
  75. package/dist/web/assets/index-6zz8telY.css +1 -0
  76. package/dist/web/assets/index-CbYEcBOa.js +130 -0
  77. package/dist/web/index.html +2 -2
  78. package/package.json +2 -1
  79. package/dist/web/assets/index-CNYs0noB.js +0 -130
  80. package/dist/web/assets/index-Mk0bvBg-.css +0 -1
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { splitText } from "../../kernel/ports/text.js";
4
+ import { insertPiece, piecesOf } from "../../kernel/runner/piece-repo.js";
5
+ const payload = z.object({ text: z.string(), file: z.string().optional() });
6
+ // Old whole-text plans can exceed a newly known account limit. Preserve finished
7
+ // pieces and their paths, and split only unfinished work before any request starts.
8
+ export function planNarration(deps, stageId, texts, maxCharacters, finished) {
9
+ const old = piecesOf(deps.db, stageId, "chunk");
10
+ const source = old.length
11
+ ? old
12
+ : texts.map((text, idx) => ({
13
+ id: deps.ids.next(),
14
+ stageId,
15
+ kind: "chunk",
16
+ idx: idx + 1,
17
+ state: "pending",
18
+ payload: JSON.stringify({ text }),
19
+ }));
20
+ const planned = [];
21
+ for (const piece of source) {
22
+ const data = payload.parse(JSON.parse(piece.payload ?? "null"));
23
+ if (finished(piece)) {
24
+ planned.push({ ...piece, idx: planned.length + 1 });
25
+ continue;
26
+ }
27
+ for (const [part, text] of splitText(data.text, maxCharacters).entries())
28
+ planned.push({
29
+ ...piece,
30
+ id: part === 0 ? piece.id : deps.ids.next(),
31
+ idx: planned.length + 1,
32
+ state: "pending",
33
+ payload: JSON.stringify({ text }),
34
+ });
35
+ }
36
+ if (old.length &&
37
+ planned.length === old.length &&
38
+ planned.every((p, i) => p.id === old[i]?.id &&
39
+ payload.parse(JSON.parse(p.payload ?? "null")).text ===
40
+ payload.parse(JSON.parse(old[i]?.payload ?? "null")).text))
41
+ return old;
42
+ transact(deps.db, () => {
43
+ // Negative temporary indices avoid colliding with a retained completed piece.
44
+ deps.db
45
+ .prepare("UPDATE stage_pieces SET idx=-idx WHERE stage_id=? AND kind='chunk'")
46
+ .run(stageId);
47
+ const known = new Set(old.map((p) => p.id));
48
+ for (const piece of planned) {
49
+ if (known.has(piece.id))
50
+ deps.db
51
+ .prepare("UPDATE stage_pieces SET idx=?,state=?,payload=? WHERE id=?")
52
+ .run(piece.idx, piece.state, piece.payload, piece.id);
53
+ else
54
+ insertPiece(deps.db, piece);
55
+ }
56
+ });
57
+ return planned;
58
+ }
@@ -1,8 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { z } from "zod";
4
- import { transact } from "../../kernel/db/tx.js";
5
- import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
4
+ import { piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
6
5
  import { entryModes } from "../admission/model.js";
7
6
  import { projectById, setStageProgress, stagesOf } from "../admission/repo.js";
8
7
  import { entryCategories } from "../library/model.js";
@@ -11,6 +10,8 @@ import { insertOutput, outputsOf } from "../storage/repo.js";
11
10
  import { probeDurationMs } from "../video/ffmpeg.js";
12
11
  import { chunkNarration, defaultChunking } from "./chunk.js";
13
12
  import { joinNarration } from "./concat.js";
13
+ import { observeNarration } from "./live.js";
14
+ import { planNarration } from "./plan.js";
14
15
  // An empty narration source fails the stage immediately with no retries. Thrown from the
15
16
  // slice rather than from a provider call, so the attempt wrapper never sees it.
16
17
  export const nothingToNarrate = "nothing to narrate";
@@ -31,6 +32,7 @@ const segmentPayload = z.object({
31
32
  const chunkDir = "audio-chunks";
32
33
  export async function runNarration(deps, context, providers) {
33
34
  const { projectId } = context.stage;
35
+ deps.audioPreviews?.clear(projectId);
34
36
  const project = projectById(deps.db, projectId);
35
37
  if (project === undefined) {
36
38
  throw new Error(`project ${projectId} has no row`);
@@ -51,7 +53,8 @@ export async function runNarration(deps, context, providers) {
51
53
  if (texts.length === 0) {
52
54
  throw new Error(nothingToNarrate);
53
55
  }
54
- const files = await speakChunks(deps, context, providers, choice, plan(deps, context, texts));
56
+ const planned = planNarration(deps, context.stage.id, texts, deps.maxCharacters?.(choice.provider, choice.model) ?? 1000000, (piece) => finished(deps, projectId, piece) !== undefined);
57
+ const files = await speakChunks(deps, context, providers, choice, planned);
55
58
  await storeBody(deps, context, choice, files, outputs);
56
59
  await speakSegments(deps, context, providers, choice, outputs);
57
60
  deps.log.write("info", "narration.done", {
@@ -69,29 +72,7 @@ function narrationSource(deps, outputs, projectId) {
69
72
  }
70
73
  return readFileSync(outputPath(deps.paths, projectId, article.path), "utf8");
71
74
  }
72
- // The chunk list, planned once and kept. A retry after a failure finds the rows the first
73
- // run wrote and narrates only the ones that did not finish.
74
- function plan(deps, context, texts) {
75
- const existing = piecesOf(deps.db, context.stage.id, "chunk");
76
- if (existing.length > 0) {
77
- return existing;
78
- }
79
- const planned = texts.map((text, index) => ({
80
- id: deps.ids.next(),
81
- stageId: context.stage.id,
82
- kind: "chunk",
83
- idx: index + 1,
84
- state: "pending",
85
- payload: JSON.stringify({ text }),
86
- }));
87
- transact(deps.db, () => {
88
- for (const piece of planned) {
89
- insertPiece(deps.db, piece);
90
- }
91
- });
92
- return planned;
93
- }
94
- // Every chunk is synthesized in parallel. A chunk a previous run finished is not spoken
75
+ // Chunks are scheduled through the shared provider queue. A chunk a previous run finished is not spoken
95
76
  // again, and one that fails takes down the whole stage while its siblings finish and keep their
96
77
  // audio for the next resume.
97
78
  async function speakChunks(deps, context, providers, choice, pieces) {
@@ -105,14 +86,15 @@ async function speakChunks(deps, context, providers, choice, pieces) {
105
86
  if (kept !== undefined) {
106
87
  return { ok: true, file: kept };
107
88
  }
108
- const file = `${chunkDir}/${String(piece.idx).padStart(3, "0")}.mp3`;
89
+ const file = `${chunkDir}/${piece.id}.mp3`;
109
90
  setPiece(deps.db, piece.id, "running", piece.payload);
110
91
  try {
111
92
  const spoken = await providers.forPiece(piece.id).tts({
112
93
  provider: choice.provider,
94
+ model: choice.model,
113
95
  voiceId: choice.voice,
114
96
  text: carried.text,
115
- });
97
+ }, observeNarration(deps.audioPreviews, projectId, piece.id, total === 1 ? "Body" : `Body part ${piece.idx} of ${total}`));
116
98
  write(deps, projectId, file, spoken.bytes);
117
99
  // The file is on disk before the row says so: a crash between the two leaves a
118
100
  // file the reconcile collects, where the other order would leave a `done` chunk
@@ -180,9 +162,10 @@ async function speakSegments(deps, context, providers, choice, outputs) {
180
162
  }
181
163
  const spoken = await providers.forPiece(piece.id).tts({
182
164
  provider: choice.provider,
165
+ model: choice.model,
183
166
  voiceId: choice.voice,
184
167
  text,
185
- });
168
+ }, observeNarration(deps.audioPreviews, projectId, piece.id, segment.category === "intro" ? "Intro" : "Outro"));
186
169
  const name = outputFileName(role, 1, ".mp3", "audio");
187
170
  write(deps, projectId, name, spoken.bytes);
188
171
  // Measured the same way the body is, because the render adds all three and the gaps to
@@ -208,14 +191,13 @@ function segmentPieces(deps, projectId) {
208
191
  }
209
192
  return piecesOf(deps.db, article.id, "segment");
210
193
  }
211
- // Audio seconds come from the measured duration per segment, never from the text's length:
212
- // the real duration goes on the row and the video timeline is built from it. No model is
213
- // named because the TTS port carries none - the same reason `store` above leaves it off.
194
+ // Audio seconds come from the measured duration per segment, never from the text's length.
214
195
  function counted(deps, segment, durationMs, choice) {
215
196
  deps.count("stage.completed", {
216
197
  stage: "audio",
217
198
  segment,
218
199
  provider: choice.provider,
200
+ model: choice.model,
219
201
  audioSeconds: durationMs / 1000,
220
202
  });
221
203
  }
@@ -246,9 +228,6 @@ function write(deps, projectId, path, bytes) {
246
228
  mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
247
229
  writeFileSync(target, bytes, { mode: 0o600 });
248
230
  }
249
- // The provider and the voice are stored with the audio they made. The model is not:
250
- // the TTS port carries no model, so every request went to the adapter's own, and writing
251
- // the run's dropdown value here would record something that was never sent.
252
231
  function store(deps, projectId, role, path, durationMs, choice) {
253
232
  insertOutput(deps.db, {
254
233
  id: deps.ids.next(),
@@ -259,7 +238,7 @@ function store(deps, projectId, role, path, durationMs, choice) {
259
238
  originalFilename: null,
260
239
  bytes: statSync(outputPath(deps.paths, projectId, path)).size,
261
240
  durationMs,
262
- meta: { provider: choice.provider, voice: choice.voice },
241
+ meta: { provider: choice.provider, model: choice.model, voice: choice.voice },
263
242
  createdAt: deps.clock.now().toISOString(),
264
243
  });
265
244
  }
@@ -52,7 +52,9 @@ async function research(deps, context, providers, choice, brief) {
52
52
  const answer = await providers.llm({
53
53
  provider: choice.provider,
54
54
  model: choice.model,
55
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
55
56
  messages: synthesisMessages(brief, findings),
57
+ previewLabel: "Writing research notes",
56
58
  check: (given) => sourcedAnswer("the synthesis", given.text),
57
59
  });
58
60
  add(answer.usage);
@@ -70,6 +72,7 @@ async function research(deps, context, providers, choice, brief) {
70
72
  stage: "research",
71
73
  provider: choice.provider,
72
74
  model: choice.model,
75
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
73
76
  ...tokens,
74
77
  });
75
78
  deps.log.write("info", "research.done", {
@@ -88,7 +91,9 @@ async function plan(deps, context, providers, choice, brief, add) {
88
91
  const answer = await providers.llm({
89
92
  provider: choice.provider,
90
93
  model: choice.model,
94
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
91
95
  messages: plannerMessages(brief),
96
+ previewLabel: "Planning research",
92
97
  // An empty answer, or one with no chapter in it, is a failed attempt.
93
98
  check: (given) => chaptersFrom(given.text).length === 0 ? "the planner named no chapters" : undefined,
94
99
  });
@@ -128,7 +133,9 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
128
133
  const answer = await providers.forPiece(piece.id).llm({
129
134
  provider: choice.provider,
130
135
  model: choice.model,
136
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
131
137
  messages: subAgentMessages(brief, kept.title, outline),
138
+ previewLabel: kept.title,
132
139
  // Grounding is asked for explicitly, so a model without it says so
133
140
  // instead of answering from what it already knows.
134
141
  webSearch: true,
@@ -12,6 +12,7 @@ export const providerIds = [
12
12
  "elevenlabs",
13
13
  "openai-tts",
14
14
  "cartesia",
15
+ "inworld",
15
16
  "fal",
16
17
  "replicate",
17
18
  "openai-image",
@@ -46,6 +47,7 @@ export const providers = [
46
47
  { id: "elevenlabs", family: "tts", displayName: "ElevenLabs", auth: "key" },
47
48
  { id: "openai-tts", family: "tts", displayName: "OpenAI", auth: "key" },
48
49
  { id: "cartesia", family: "tts", displayName: "Cartesia", auth: "key" },
50
+ { id: "inworld", family: "tts", displayName: "Inworld", auth: "key" },
49
51
  { id: "fal", family: "image", displayName: "fal.ai", auth: "key" },
50
52
  { id: "replicate", family: "image", displayName: "Replicate", auth: "key" },
51
53
  { id: "openai-image", family: "image", displayName: "OpenAI", auth: "key" },
@@ -0,0 +1,89 @@
1
+ // These marketplaces host models with different input schemas. Discovery alone cannot
2
+ // make an arbitrary endpoint compatible with our image request.
3
+ export function allowsCustomModel(provider) {
4
+ return provider !== "fal" && provider !== "replicate";
5
+ }
6
+ const ttlMs = 5 * 60_000;
7
+ const retryMs = 30_000;
8
+ export function createModelCatalog(deps) {
9
+ const cache = new Map();
10
+ const pending = new Map();
11
+ const generations = new Map();
12
+ async function get(provider, family, refresh = false) {
13
+ const active = pending.get(provider);
14
+ if (active !== undefined)
15
+ return active;
16
+ const previous = cache.get(provider);
17
+ if (!refresh && previous !== undefined && previous.expires > deps.now())
18
+ return previous.value;
19
+ const generation = generations.get(provider) ?? 0;
20
+ const task = Promise.resolve()
21
+ .then(async () => {
22
+ let value;
23
+ let lifetime = ttlMs;
24
+ try {
25
+ const models = await deps.load(provider, family);
26
+ value = {
27
+ models: uniqueModels(models),
28
+ allowsCustom: allowsCustomModel(provider),
29
+ ...catalogNotice(provider),
30
+ };
31
+ }
32
+ catch {
33
+ // A catalogue is metadata, not generation. Never expose an upstream response
34
+ // here: it can quote credentials that belong only inside its adapter.
35
+ deps.report(provider);
36
+ const saved = previous?.value.models;
37
+ value = {
38
+ models: saved ?? deps.fallback(provider),
39
+ ...catalogNotice(provider),
40
+ allowsCustom: allowsCustomModel(provider),
41
+ warning: saved === undefined
42
+ ? "Could not load the model list. Showing bundled choices; refresh to try again."
43
+ : "Could not refresh the model list. Showing the last loaded choices.",
44
+ };
45
+ lifetime = retryMs;
46
+ }
47
+ if ((generations.get(provider) ?? 0) === generation) {
48
+ cache.set(provider, { value, expires: deps.now() + lifetime });
49
+ }
50
+ return value;
51
+ })
52
+ .finally(() => {
53
+ if (pending.get(provider) === task)
54
+ pending.delete(provider);
55
+ });
56
+ pending.set(provider, task);
57
+ return task;
58
+ }
59
+ return {
60
+ get,
61
+ invalidate: (provider) => {
62
+ generations.set(provider, (generations.get(provider) ?? 0) + 1);
63
+ cache.delete(provider);
64
+ pending.delete(provider);
65
+ },
66
+ };
67
+ }
68
+ function uniqueModels(models) {
69
+ const seen = new Set();
70
+ return models.filter((model) => {
71
+ if (model.id.trim() === "" || seen.has(model.id))
72
+ return false;
73
+ seen.add(model.id);
74
+ return true;
75
+ });
76
+ }
77
+ function catalogNotice(provider) {
78
+ const notices = {
79
+ codex: "Models come from Codex's local catalogue. Open Codex to refresh that catalogue, then refresh this list.",
80
+ gemini: "Models and aliases come from the installed Gemini CLI. Update that CLI to discover newer models.",
81
+ "claude-code": "These aliases follow Claude Code's model families. You can also enter an exact model ID.",
82
+ inworld: "These are Inworld’s documented TTS models. You can also enter a compatible model ID.",
83
+ cartesia: "Cartesia has no model-list API. These are bundled compatible choices; you can enter a newer model ID.",
84
+ fal: "These models have input formats supported by Slopify's fal adapter.",
85
+ replicate: "These models have input formats supported by Slopify's Replicate adapter.",
86
+ };
87
+ const notice = Object.hasOwn(notices, provider) ? notices[provider] : undefined;
88
+ return notice === undefined ? {} : { notice };
89
+ }
@@ -140,7 +140,8 @@ export function attachStagedFile(deps, input) {
140
140
  try {
141
141
  transact(deps.db, () => {
142
142
  insertOutput(deps.db, output);
143
- deleteStagedFile(deps.db, staged.id);
143
+ if (!input.retainStaged)
144
+ deleteStagedFile(deps.db, staged.id);
144
145
  });
145
146
  }
146
147
  catch (error) {
@@ -1,3 +1,4 @@
1
+ import { subtitlePlacement } from "./layout.js";
1
2
  // Keep spoken spelling and punctuation. Word times come only from acoustic alignment.
2
3
  export function captionCues(words) {
3
4
  const cues = [];
@@ -61,10 +62,12 @@ export function serializeVtt(cues) {
61
62
  }
62
63
  export function serializeAss(cues, style) {
63
64
  const name = style.fontName.replace(/[\p{Cc},]/gu, " ").trim();
65
+ const placement = subtitlePlacement(style.position ?? "bottom", style.height);
66
+ const position = `{\\an${placement.alignment}\\pos(${style.width / 2},${placement.y})}`;
64
67
  const header = `[Script Info]\nScriptType: v4.00+\nPlayResX: ${style.width}\nPlayResY: ${style.height}\nWrapStyle: 0\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,${name},${style.fontSize},&H00FFFFFF,&H00FFFFFF,&H00101010,&H80000000,0,0,0,0,100,100,0,0,1,2.5,1,2,60,60,60,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n`;
65
68
  return (header +
66
69
  cues
67
- .map((cue) => `Dialogue: 0,${assStamp(cue.start)},${assStamp(cue.end)},Default,,0,0,0,,${assText(cue.text)}\n`)
70
+ .map((cue) => `Dialogue: 0,${assStamp(cue.start)},${assStamp(cue.end)},Default,,0,0,0,,${position}${assText(cue.text)}\n`)
68
71
  .join(""));
69
72
  }
70
73
  function markup(text) {
@@ -0,0 +1,19 @@
1
+ export function subtitleFrame(format) {
2
+ return format === "9:16" ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
3
+ }
4
+ // ASS and the browser preview share anchors. Edge positions keep the existing
5
+ // 60-pixel safe margin; interior positions center both single and multiline cues.
6
+ export function subtitlePlacement(position, height) {
7
+ switch (position) {
8
+ case "top":
9
+ return { y: 60, alignment: 8, translateY: 0 };
10
+ case "upper-middle":
11
+ return { y: height / 4, alignment: 5, translateY: -50 };
12
+ case "center":
13
+ return { y: height / 2, alignment: 5, translateY: -50 };
14
+ case "lower-middle":
15
+ return { y: (height * 3) / 4, alignment: 5, translateY: -50 };
16
+ case "bottom":
17
+ return { y: height - 60, alignment: 2, translateY: -100 };
18
+ }
19
+ }
@@ -1,5 +1,12 @@
1
1
  import { z } from "zod";
2
2
  export const subtitleModes = ["off", "files", "burn-in"];
3
+ export const subtitlePositions = [
4
+ "top",
5
+ "upper-middle",
6
+ "center",
7
+ "lower-middle",
8
+ "bottom",
9
+ ];
3
10
  export const subtitleConfigSchema = z.object({
4
11
  mode: z.enum(subtitleModes),
5
12
  language: z.literal("en").default("en"),
@@ -8,6 +15,7 @@ export const subtitleConfigSchema = z.object({
8
15
  .max(160)
9
16
  .regex(/^[A-Za-z0-9_-]+$/)
10
17
  .default("default"),
18
+ position: z.enum(subtitlePositions).default("bottom"),
11
19
  fontSize: z.number().int().min(16).max(120).default(48),
12
20
  });
13
21
  export const defaultSubtitles = {
@@ -15,4 +23,5 @@ export const defaultSubtitles = {
15
23
  language: "en",
16
24
  fontId: "default",
17
25
  fontSize: 48,
26
+ position: "bottom",
18
27
  };
@@ -59,7 +59,12 @@ export async function prepareSubtitles(deps, context, audio, frame) {
59
59
  throw new Error("No spoken words could be aligned. Check that the English article matches the audio.");
60
60
  writeFileSync(join(directory, "subtitles.srt"), serializeSrt(cues), { mode: 0o600 });
61
61
  writeFileSync(join(directory, "subtitles.vtt"), serializeVtt(cues), { mode: 0o600 });
62
- writeFileSync(join(directory, "subtitles.ass"), serializeAss(cues, { ...frame, fontSize: config.fontSize, fontName: font.assName }), { mode: 0o600 });
62
+ writeFileSync(join(directory, "subtitles.ass"), serializeAss(cues, {
63
+ ...frame,
64
+ fontSize: config.fontSize,
65
+ fontName: font.assName,
66
+ position: config.position,
67
+ }), { mode: 0o600 });
63
68
  writeFileSync(join(directory, "subtitles.json"), JSON.stringify({ key, words, font }), {
64
69
  mode: 0o600,
65
70
  });
@@ -81,6 +81,7 @@ async function byLlm(deps, context, providers, project) {
81
81
  const answer = await providers.llm({
82
82
  provider: llm.provider,
83
83
  model: llm.model,
84
+ ...(llm.thinking === undefined ? {} : { thinking: llm.thinking }),
84
85
  messages,
85
86
  // An empty answer is a failed attempt, and the wrapper is what retries it.
86
87
  check: (given) => writtenPrompt(given.text),
@@ -151,6 +152,7 @@ async function make(deps, context, providers, project, choice, prompt) {
151
152
  const made = await providers.image({
152
153
  provider: choice.provider,
153
154
  model: choice.model,
155
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
154
156
  prompt,
155
157
  aspect: project.format,
156
158
  });
@@ -175,6 +177,7 @@ async function make(deps, context, providers, project, choice, prompt) {
175
177
  prompt,
176
178
  provider: choice.provider,
177
179
  model: choice.model,
180
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
178
181
  },
179
182
  createdAt: deps.clock.now().toISOString(),
180
183
  };
@@ -0,0 +1,28 @@
1
+ export function watchActivation(updater, token, expired, report) {
2
+ let checking = false;
3
+ const until = Date.now() + 120_000;
4
+ const timer = setInterval(() => {
5
+ if (checking)
6
+ return;
7
+ checking = true;
8
+ void updater
9
+ .activate(token)
10
+ .then(async (activated) => {
11
+ if (activated)
12
+ clearInterval(timer);
13
+ else if (Date.now() >= until) {
14
+ clearInterval(timer);
15
+ report("The update was not committed; stopping the candidate server.");
16
+ await expired();
17
+ }
18
+ })
19
+ .catch(() => {
20
+ report("The update activation marker could not be checked.");
21
+ })
22
+ .finally(() => {
23
+ checking = false;
24
+ });
25
+ }, 250);
26
+ timer.unref();
27
+ return () => clearInterval(timer);
28
+ }
@@ -0,0 +1,35 @@
1
+ import { spawn } from "node:child_process";
2
+ import { activeUpdateEntry } from "./plan.js";
3
+ export async function forwardManagedUpdate(dataDir, version, args) {
4
+ if (process.env.SLOPIFY_SKIP_MANAGED_UPDATE === "1")
5
+ return undefined;
6
+ const entry = await activeUpdateEntry(dataDir, version);
7
+ if (entry === undefined)
8
+ return undefined;
9
+ return new Promise((resolve) => {
10
+ const child = spawn(process.execPath, [entry, ...args], {
11
+ stdio: "inherit",
12
+ windowsHide: true,
13
+ });
14
+ const interrupt = () => {
15
+ child.kill("SIGINT");
16
+ };
17
+ const terminate = () => {
18
+ child.kill("SIGTERM");
19
+ };
20
+ process.once("SIGINT", interrupt);
21
+ process.once("SIGTERM", terminate);
22
+ const cleanup = () => {
23
+ process.off("SIGINT", interrupt);
24
+ process.off("SIGTERM", terminate);
25
+ };
26
+ child.once("error", () => {
27
+ cleanup();
28
+ resolve(undefined);
29
+ });
30
+ child.once("close", (code) => {
31
+ cleanup();
32
+ resolve(code ?? 1);
33
+ });
34
+ });
35
+ }
@@ -0,0 +1,26 @@
1
+ export async function runUpdateFlow(plan, deps) {
2
+ const entry = await deps.install();
3
+ // npm or package verification failures leave the currently serving app entirely alone.
4
+ await deps.handoff();
5
+ let backedUp = false;
6
+ try {
7
+ await deps.backup();
8
+ backedUp = true;
9
+ await deps.start(entry);
10
+ await deps.healthy(plan.version);
11
+ await deps.activate();
12
+ }
13
+ catch {
14
+ await deps.stopCandidate();
15
+ if (backedUp)
16
+ await deps.restore();
17
+ await deps.start(plan.oldEntry);
18
+ await deps.healthy(plan.previousVersion);
19
+ deps.report("The update did not start; the previous version and database were restored.");
20
+ throw new Error("The previous Slopify version was restored.");
21
+ }
22
+ // The pointer rename commits the new database. The candidate may already observe
23
+ // it and accept writes, so a lost unlock acknowledgement can never trigger rollback.
24
+ await deps.release();
25
+ deps.report(`Slopify ${plan.version} started successfully.`);
26
+ }
@@ -0,0 +1,50 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { updatePlanSchema } from "./plan.js";
6
+ export async function launchUpdate(worker, plan, handoff) {
7
+ updatePlanSchema.parse(plan);
8
+ const directory = join(plan.dataDir, "updates");
9
+ await mkdir(directory, { recursive: true, mode: 0o700 });
10
+ const planPath = join(directory, `plan-${randomUUID()}.json`);
11
+ await writeFile(planPath, JSON.stringify(plan), { mode: 0o600 });
12
+ const child = spawn(process.execPath, [worker, planPath], {
13
+ cwd: plan.cwd,
14
+ detached: true,
15
+ windowsHide: true,
16
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
17
+ });
18
+ try {
19
+ await new Promise((resolve, reject) => {
20
+ let restarting = false;
21
+ const send = (type) => {
22
+ if (!child.connected) {
23
+ reject(new Error("The update worker disconnected."));
24
+ return;
25
+ }
26
+ child.send({ type }, (error) => {
27
+ if (error !== null)
28
+ reject(new Error("The update handoff failed."));
29
+ });
30
+ };
31
+ child.once("error", () => reject(new Error("The update worker could not start.")));
32
+ child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("The update worker could not finish.")));
33
+ child.on("message", (message) => {
34
+ if (typeof message !== "object" || message === null || !("type" in message))
35
+ return;
36
+ if (message.type === "installed" && !restarting) {
37
+ restarting = true;
38
+ void handoff().then(() => send("handoff"), () => {
39
+ send("abort");
40
+ reject(new Error("Slopify could not release its server for the update."));
41
+ });
42
+ }
43
+ });
44
+ });
45
+ }
46
+ finally {
47
+ child.unref();
48
+ await rm(planPath, { force: true });
49
+ }
50
+ }
@@ -0,0 +1,24 @@
1
+ export const updatePackage = "@gentbajko/slopify";
2
+ export const npmRegistry = "https://registry.npmjs.org/";
3
+ const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
4
+ export function isStableVersion(value) {
5
+ return value.length <= 50 && stableVersion.test(value);
6
+ }
7
+ export function newerVersion(candidate, current) {
8
+ if (!isStableVersion(candidate) || !isStableVersion(current))
9
+ return false;
10
+ const left = candidate.split(".").map(BigInt);
11
+ const right = current.split(".").map(BigInt);
12
+ for (let index = 0; index < 3; index++) {
13
+ const a = left[index];
14
+ const b = right[index];
15
+ if (a === undefined || b === undefined)
16
+ return false;
17
+ if (a !== b)
18
+ return a > b;
19
+ }
20
+ return false;
21
+ }
22
+ export function isUpdateToken(value) {
23
+ return /^[a-f0-9]{64}$/.test(value);
24
+ }