@gentbajko/slopify 3.0.7 → 3.0.8

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.
@@ -77,6 +77,10 @@ export function createNativeAutostart(o) {
77
77
  const ours = text.includes(platform === "linux" ? desktopMarker : launcherMarker);
78
78
  return { state: ours ? "ours" : "foreign", text };
79
79
  }
80
+ const thisLauncher = launcherRef(platform, at.launcher);
81
+ function runsThisFolder(now) {
82
+ return now.state === "ours" && (now.text?.includes(thisLauncher) ?? false);
83
+ }
80
84
  function state(enabled) {
81
85
  return {
82
86
  available: true,
@@ -113,7 +117,9 @@ export function createNativeAutostart(o) {
113
117
  await o.fs.write(at.entry, entryText, 0o644);
114
118
  }
115
119
  return {
116
- status: async () => state((await entryNow()).state === "ours"),
120
+ // On only when the entry starts this data folder: a second Slopify (another --data-dir,
121
+ // a test copy) sees the switch off, so turning it off there can't remove this one's entry.
122
+ status: async () => state(runsThisFolder(await entryNow())),
117
123
  enable: async () => {
118
124
  if (platform === "win32") {
119
125
  const problem = windowsPathProblem(o.dataDir);
@@ -121,6 +127,8 @@ export function createNativeAutostart(o) {
121
127
  throw new Error(problem);
122
128
  }
123
129
  const now = await entryNow();
130
+ if (now.state === "ours" && !runsThisFolder(now))
131
+ throw new Error(`Another Slopify, with a different data folder, already starts when you log in (${at.where}). Slopify left it alone. Turn it off in that Slopify's Settings → General, or delete ${platform === "win32" ? `the ${runValue} entry in Task Manager → Startup apps` : "that file"}, then turn the switch on here again.`);
124
132
  if (now.state === "foreign")
125
133
  throw new Error(platform === "win32"
126
134
  ? `Your start-up list already has an entry named ${runValue} that Slopify didn't make (${runKey}). Slopify left it alone. Remove it in Task Manager → Startup apps, or rename it, then turn the switch on again.`
@@ -131,7 +139,8 @@ export function createNativeAutostart(o) {
131
139
  },
132
140
  disable: async () => {
133
141
  const now = await entryNow();
134
- if (now.state === "ours") {
142
+ // Only this data folder's entry: another Slopify's is not this switch's to remove.
143
+ if (runsThisFolder(now)) {
135
144
  if (platform === "win32") {
136
145
  const result = await o.exec("reg", ["delete", runKey, "/v", runValue, "/f"]);
137
146
  if (result.code !== 0)
@@ -151,7 +160,7 @@ export function createNativeAutostart(o) {
151
160
  const now = await entryNow();
152
161
  // Only the entry that runs this data folder's launcher: a Slopify started with another
153
162
  // --data-dir must not take over the one the switch was turned on from.
154
- if (now.state !== "ours" || !now.text?.includes(launcherRef(platform, at.launcher)))
163
+ if (!runsThisFolder(now))
155
164
  return;
156
165
  await writeLauncher();
157
166
  await writeEntry(now.text);
@@ -0,0 +1,13 @@
1
+ -- A narration chunk whose audio stopped matching its text is recorded again by itself: the
2
+ -- caption step asks for it here, and the retry starts once that step's failure is saved. The
3
+ -- count caps it at two tries per chunk; `state` is what the last try did.
4
+ CREATE TABLE narration_retries (
5
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
6
+ chunk_key TEXT NOT NULL,
7
+ tries INTEGER NOT NULL CHECK (tries > 0),
8
+ state TEXT NOT NULL CHECK (state IN ('pending', 'started', 'failed')),
9
+ detail TEXT,
10
+ updated_at TEXT NOT NULL,
11
+ PRIMARY KEY(project_id, chunk_key)
12
+ );
13
+ CREATE INDEX narration_retries_pending ON narration_retries(state) WHERE state = 'pending';
package/dist/main.js CHANGED
@@ -55,6 +55,7 @@ import { createNotificationSender } from "./slices/notifications/send.js";
55
55
  import { readNotificationUrl } from "./slices/notifications/settings.js";
56
56
  import { seedSamples } from "./slices/onboarding/sample.js";
57
57
  import { templateById } from "./slices/project-templates/repo.js";
58
+ import { createNarrationRetries } from "./slices/rebuild/narration-retry.js";
58
59
  import { recoverProject } from "./slices/rebuild/recovery.js";
59
60
  import { claimWork, finishWork, maySubmit } from "./slices/rebuild/repo.js";
60
61
  import { waitToRetry, wakeRetries } from "./slices/rebuild/retry.js";
@@ -261,8 +262,12 @@ export async function boot(config, options = {}) {
261
262
  });
262
263
  const audioPreviews = createAudioPreviewStore();
263
264
  const reviewRedos = createReviewRedos();
265
+ const narrationRetries = createNarrationRetries();
264
266
  const runner = wireRunner({
265
- onFinished: (work) => reviewRedos.kick(work.projectId),
267
+ onFinished: (work) => {
268
+ reviewRedos.kick(work.projectId);
269
+ narrationRetries.kick(work.projectId);
270
+ },
266
271
  db,
267
272
  paths,
268
273
  clock,
@@ -386,6 +391,9 @@ export async function boot(config, options = {}) {
386
391
  // Redos a review asked for before the last shutdown start now.
387
392
  reviewRedos.bind(rebuild);
388
393
  reviewRedos.kick();
394
+ // So do narration chunks the captions asked to record again.
395
+ narrationRetries.bind(rebuild);
396
+ narrationRetries.kick();
389
397
  // A stage that was waiting for a CLI's plan limits when the app stopped carries on waiting.
390
398
  void resumeAfterRestart(db, async (projectId) => {
391
399
  const baseRevisionId = currentRevisionId(runtimeDb, projectId);
@@ -0,0 +1,23 @@
1
+ # Slopify 3.0.8
2
+
3
+ Released 29 September 2026.
4
+
5
+ 3.0.8 fixes a garbled narration sentence by itself instead of stopping the run.
6
+
7
+ ## Highlights
8
+
9
+ - **Garbled narration is recorded again by itself.** A run no longer stops for a sentence the voice mumbled or skipped.
10
+
11
+ ## Narration
12
+
13
+ - When captions find the narration no longer matches the text (the voice garbled or skipped a sentence), Slopify records that narration chunk again by itself, rebuilds the narration, captions and video, and carries on. It used to stop the run until you pressed Regenerate on the chunk in Edit project → Narration and continued.
14
+ - Each chunk gets two tries. The step says which try is running; if the audio still doesn't match after both, the voice reads something its own way (a year, an abbreviation or a name), and the step says to reword that sentence in Edit project → Narration.
15
+ - Uploaded narration, captions you edited by hand and multi-voice turns aren't retried: they stop and ask, as before.
16
+
17
+ ## Everywhere
18
+
19
+ - **Start Slopify when I log in** belongs to one data folder. A second Slopify on the same account (another `--data-dir`) sees the switch off and can no longer remove or take over the first one's login entry.
20
+
21
+ ## Upgrading from 3.0.7
22
+
23
+ - **Nothing becomes outdated.** Retries start with the next run that finds a mismatch.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "id": "3.0.8",
4
+ "title": "Slopify 3.0.8",
5
+ "version": "3.0.8",
6
+ "date": "2026-09-29"
7
+ },
2
8
  {
3
9
  "id": "3.0.7",
4
10
  "title": "Slopify 3.0.7",
@@ -0,0 +1,128 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { transact } from "../../kernel/db/tx.js";
4
+ import { currentRevisionId } from "../revisions/repo.js";
5
+ import { recoverProject } from "./recovery.js";
6
+ // How many times one narration chunk is recorded again by itself. A voice that garbles a
7
+ // sentence rarely does it twice; one that reads a name its own way does it every time, so
8
+ // after this the run stops and asks the person.
9
+ export const narrationRetryLimit = 2;
10
+ const row = z.object({
11
+ project_id: z.string(),
12
+ chunk_key: z.string(),
13
+ tries: z.number(),
14
+ state: z.enum(["pending", "started", "failed"]),
15
+ });
16
+ // Asked by the caption step when the audio stopped matching the text in a chunk. Returns the
17
+ // try this is (1 or 2), or undefined once the chunk has had its tries.
18
+ export function requestNarrationRetry(db, input) {
19
+ return transact(db, () => {
20
+ const saved = db
21
+ .prepare("SELECT tries FROM narration_retries WHERE project_id=? AND chunk_key=?")
22
+ .get(input.projectId, input.chunkKey);
23
+ const tries = saved === undefined ? 0 : z.number().parse(saved.tries);
24
+ if (tries >= narrationRetryLimit)
25
+ return undefined;
26
+ db.prepare(`INSERT INTO narration_retries(project_id, chunk_key, tries, state, detail, updated_at)
27
+ VALUES (?, ?, 1, 'pending', NULL, ?)
28
+ ON CONFLICT(project_id, chunk_key)
29
+ DO UPDATE SET tries=tries+1, state='pending', detail=NULL, updated_at=excluded.updated_at`).run(input.projectId, input.chunkKey, input.now);
30
+ return tries + 1;
31
+ });
32
+ }
33
+ export function pendingNarrationRetries(db, projectId) {
34
+ const rows = projectId === undefined
35
+ ? db.prepare("SELECT * FROM narration_retries WHERE state='pending'").all()
36
+ : db
37
+ .prepare("SELECT * FROM narration_retries WHERE state='pending' AND project_id=?")
38
+ .all(projectId);
39
+ return rows.map((value) => {
40
+ const parsed = row.parse(value);
41
+ return { projectId: parsed.project_id, chunkKey: parsed.chunk_key, tries: parsed.tries };
42
+ });
43
+ }
44
+ function settle(db, retry, state, detail, now) {
45
+ db.prepare("UPDATE narration_retries SET state=?, detail=?, updated_at=? WHERE project_id=? AND chunk_key=? AND tries=?").run(state, detail, now, retry.projectId, retry.chunkKey, retry.tries);
46
+ }
47
+ // Starts the retries the caption step asked for, once its failure is saved (the runner's
48
+ // `onFinished`). The chunk is made again through Redo's path: a new regeneration token saved as
49
+ // a new project version, then the chunk and everything built on it (the joined narration,
50
+ // the captions, the video) rebuilt as ordinary work.
51
+ export function createNarrationRetries() {
52
+ let bound;
53
+ const running = new Set();
54
+ const kick = (projectId) => {
55
+ const deps = bound;
56
+ if (deps === undefined)
57
+ return;
58
+ for (const retry of pendingNarrationRetries(deps.db, projectId)) {
59
+ const id = `${retry.projectId}\n${retry.chunkKey}`;
60
+ if (running.has(id))
61
+ continue;
62
+ running.add(id);
63
+ void recordAgain(deps, retry)
64
+ .catch((error) => {
65
+ deps.log.write("error", "narration.retry", {
66
+ projectId: retry.projectId,
67
+ detail: error instanceof Error ? error.message : String(error),
68
+ });
69
+ settle(deps.db, retry, "failed", "internal error", deps.clock.now().toISOString());
70
+ })
71
+ .finally(() => {
72
+ running.delete(id);
73
+ try {
74
+ deps.runner.tick(retry.projectId);
75
+ }
76
+ catch {
77
+ // The next tick of this project picks the released work up.
78
+ }
79
+ });
80
+ }
81
+ };
82
+ return {
83
+ bind: (deps) => {
84
+ bound = deps;
85
+ },
86
+ kick,
87
+ };
88
+ }
89
+ async function recordAgain(deps, retry) {
90
+ const base = currentRevisionId(deps.db, retry.projectId);
91
+ if (base === undefined) {
92
+ settle(deps.db, retry, "failed", "no project", deps.clock.now().toISOString());
93
+ return;
94
+ }
95
+ const result = await recoverProject(deps, retry.projectId, {
96
+ baseRevisionId: base,
97
+ idempotencyKey: retryKey(retry),
98
+ action: { kind: "redo", item: retry.chunkKey },
99
+ }, { pendingSuperseded: true });
100
+ const now = deps.clock.now().toISOString();
101
+ if (result.ok) {
102
+ settle(deps.db, retry, "started", null, now);
103
+ deps.log.write("info", "narration.retry", {
104
+ projectId: retry.projectId,
105
+ detail: `Recording ${retry.chunkKey} again (try ${String(retry.tries)} of ${String(narrationRetryLimit)}).`,
106
+ });
107
+ }
108
+ else {
109
+ settle(deps.db, retry, "failed", refusal(result), now);
110
+ deps.log.write("warn", "narration.retry", {
111
+ projectId: retry.projectId,
112
+ detail: `Could not record ${retry.chunkKey} again: ${refusal(result)}`,
113
+ });
114
+ }
115
+ deps.emit(retry.projectId, { type: "project.updated", projectId: retry.projectId });
116
+ }
117
+ function refusal(result) {
118
+ const detail = result.fields?.map((field) => field.message).join(" ");
119
+ return detail ? `${result.reason}: ${detail}` : result.reason;
120
+ }
121
+ // One request identity per try, so a restart that starts the same try again gets the first
122
+ // start's answer instead of a second recording.
123
+ export function retryKey(retry) {
124
+ const hex = createHash("sha256")
125
+ .update(`narration-retry:${retry.projectId}:${retry.chunkKey}:${String(retry.tries)}`)
126
+ .digest("hex");
127
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
128
+ }
@@ -44,6 +44,13 @@ export function redoTarget(view, plan, item) {
44
44
  return plan.recipes.some((row) => row.key === `${item}:prompts`)
45
45
  ? own([`${item}:prompts`])
46
46
  : undefined;
47
+ // One narration chunk, by the key the Narration editor regenerates it by: its TTS requests
48
+ // are the roots (`narration-retry.ts`).
49
+ const chunk = plan.recipes
50
+ .filter((row) => row.input.kind === "tts" && row.input.logicalKey === item)
51
+ .map((row) => row.key);
52
+ if (chunk.length > 0)
53
+ return own(chunk);
47
54
  return plan.recipes.some((row) => row.key === item && row.kind === "provider")
48
55
  ? own([item])
49
56
  : undefined;
@@ -16,6 +16,7 @@ import { speakerColour } from "../voices/palette.js";
16
16
  import { speakerPanelEvents, usesSpeakerPanel } from "../voices/panel.js";
17
17
  import { panelPortraits } from "../voices/portraits.js";
18
18
  import { attributeWords } from "../voices/timing.js";
19
+ import { narrationRetryLimit, requestNarrationRetry } from "./narration-retry.js";
19
20
  import { exportSnapshot, retainedOutput, revisionAudio, revisionTranscript, revisionTurns, } from "./runtime-export-inputs.js";
20
21
  import { narrationChunks, narrationTextParts, } from "./runtime-narration-text.js";
21
22
  import { preparedResult, preparedText, publishResult } from "./runtime-publication.js";
@@ -77,7 +78,7 @@ async function timing(deps, context, piece, snapshot) {
77
78
  context.signal.throwIfAborted();
78
79
  const { path, kind } = segment;
79
80
  if (path !== null && spoken(kind)) {
80
- const aligned = await located(snapshot, kind, () => alignSubtitles({
81
+ const aligned = await located(deps, context, snapshot, kind, () => alignSubtitles({
81
82
  audioPath: path,
82
83
  text: revisionTranscript(deps, snapshot, kind),
83
84
  aliases: narrationAliasesOf(snapshot.view.revision.config),
@@ -250,45 +251,86 @@ async function files(deps, context, piece, snapshot) {
250
251
  }
251
252
  // A mismatch names the place to fix: the time in the narration, the chunk that holds it (as
252
253
  // the Narration editor numbers them) and what was expected against what was heard. The
253
- // usual cause is a TTS request that skipped or reworded a sentence.
254
- async function located(snapshot, segment, align) {
254
+ // usual cause is a TTS request that skipped or reworded a sentence, which a second request
255
+ // almost always reads right, so a chunk made by a voice is first recorded again by itself
256
+ // (`narration-retry.ts`); the message says so, and says when its tries are used up.
257
+ async function located(deps, context, snapshot, segment, align) {
255
258
  try {
256
259
  return await align();
257
260
  }
258
261
  catch (error) {
259
262
  if (!(error instanceof SubtitleMismatch))
260
263
  throw error;
261
- throw new Error(mismatchMessage(snapshot, segment, error), { cause: error });
264
+ const chunks = mismatchChunks(snapshot, segment);
265
+ const at = mismatchChunk(chunks, error);
266
+ const chunk = at === -1 ? undefined : chunks[at];
267
+ const retry = chunk !== undefined && retryable(snapshot, chunk.key)
268
+ ? {
269
+ chunk: at + 1,
270
+ try: requestNarrationRetry(deps.db, {
271
+ projectId: context.work.projectId,
272
+ chunkKey: chunk.key,
273
+ now: deps.clock.now().toISOString(),
274
+ }),
275
+ }
276
+ : undefined;
277
+ throw new Error(describeMismatch(chunks, segment, error, retry), { cause: error });
262
278
  }
263
279
  }
264
- export function mismatchMessage(snapshot, segment, mismatch) {
265
- let chunks = [];
280
+ // A chunk a voice made from the text: not uploaded audio, not captions edited by hand (their
281
+ // remake waits for the person) and not a multi-voice turn, whose key the regeneration list
282
+ // can't hold yet.
283
+ function retryable(snapshot, key) {
284
+ if (snapshot.view.revision.content.subtitleCues !== undefined)
285
+ return false;
286
+ if (/:turn:\d+$/.test(key))
287
+ return false;
288
+ return snapshot.plan.recipes.some((row) => row.input.kind === "tts" && row.input.logicalKey === key);
289
+ }
290
+ function mismatchChunks(snapshot, segment) {
266
291
  try {
267
- chunks = narrationChunks(narrationTextParts(snapshot.view, snapshot.plan, segment));
292
+ return narrationChunks(narrationTextParts(snapshot.view, snapshot.plan, segment));
268
293
  }
269
294
  catch {
270
295
  // Without a readable plan the message still gives the time and the words.
296
+ return [];
271
297
  }
272
- return describeMismatch(chunks, segment, mismatch);
273
298
  }
274
- export function describeMismatch(chunks, segment, mismatch) {
299
+ export function mismatchMessage(snapshot, segment, mismatch) {
300
+ return describeMismatch(mismatchChunks(snapshot, segment), segment, mismatch);
301
+ }
302
+ // The chunk that holds the words the audio stopped matching, by index; -1 when none does.
303
+ export function mismatchChunk(chunks, mismatch) {
275
304
  const probe = comparable(mismatch.expected).split(" ").slice(0, 6).join(" ");
276
- const at = probe === ""
305
+ return probe === ""
277
306
  ? chunks.length - 1
278
307
  : chunks.findIndex((chunk) => comparable(chunk.spokenText).includes(probe));
308
+ }
309
+ export function describeMismatch(chunks, segment, mismatch,
310
+ // Set when the chunk is one a voice made: `try` is the automatic try just asked for, or
311
+ // undefined once its tries are used up.
312
+ retry) {
313
+ const at = mismatchChunk(chunks, mismatch);
279
314
  const chunk = at === -1 ? undefined : chunks[at];
280
315
  const where = chunk === undefined
281
316
  ? `${clock(mismatch.at)} into the ${segment} narration`
282
317
  : `${clock(mismatch.at)} into the ${segment} narration, in narration chunk ${String(at + 1)} of ${String(chunks.length)} (it starts "${opening(chunk.spokenText)}")`;
283
318
  const expected = mismatch.expected === "" ? "the end of the text" : `"${mismatch.expected}…"`;
284
319
  const heard = mismatch.heard === "" ? "no more speech" : `"${mismatch.heard.toLowerCase()}…"`;
320
+ const stopped = `Subtitles stopped matching the audio at ${where}. The text expected ${expected} but the audio has ${heard}`;
321
+ if (retry !== undefined) {
322
+ const n = String(retry.chunk);
323
+ return retry.try === undefined
324
+ ? `${stopped} Slopify already recorded narration chunk ${n} again ${String(narrationRetryLimit)} times and the audio still doesn't match there, so the voice probably reads something its own way (a year, an abbreviation or a name). Listen at ${clock(mismatch.at)}: if words are missing or wrong, reword that sentence in Edit project → Narration → narration chunk ${n}, save, then Continue the run. If it says them right, use Download diagnostics in Settings and report it.`
325
+ : `${stopped} The voice probably skipped or garbled words there, so Slopify is recording narration chunk ${n} again by itself (try ${String(retry.try)} of ${String(narrationRetryLimit)}) and carries on with the video when it's done. If this message is still here in a few minutes, regenerate narration chunk ${n} in Edit project → Narration, then Continue the run.`;
326
+ }
285
327
  const fix = chunk === undefined
286
328
  ? `Listen at ${clock(mismatch.at)}: if words are missing or wrong, regenerate that part in Edit project → Narration, then Continue the run.`
287
329
  : `Listen at ${clock(mismatch.at)}: if words are missing or wrong, regenerate narration chunk ${String(at + 1)} in Edit project → Narration, then Continue the run.`;
288
330
  // A voice that reads a year, an abbreviation or a name its own way reads it the same way
289
331
  // again, so a remake would cost a chunk and fail here once more.
290
332
  const same = "If it says them right, only another way (a year, an abbreviation or a name read its own way), regenerating gives the same reading: use Download diagnostics in Settings and report it.";
291
- return `Subtitles stopped matching the audio at ${where}. The text expected ${expected} but the audio has ${heard} The recording there probably skips or changes words. ${fix} ${same}`;
333
+ return `${stopped} The recording there probably skips or changes words. ${fix} ${same}`;
292
334
  }
293
335
  function comparable(text) {
294
336
  return text
@@ -57,6 +57,7 @@ export const projectTables = [
57
57
  "project_uploads",
58
58
  // Since 2.5.0. Left out on purpose: plan_limit_waits and plan_limit_waiters (an account's
59
59
  // current wait, a lease of this install) and prompt_softening (a pending one-off request).
60
+ // Since 3.0.8, narration_retries is left out too: the tries of a run, not of the project.
60
61
  "review_verdicts",
61
62
  "provider_usage",
62
63
  "plan_limit_readings",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gentbajko/slopify",
3
- "version": "3.0.7",
3
+ "version": "3.0.8",
4
4
  "description": "Turn a prompt into a finished video, locally, with your own provider keys",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Gent Bajko",