@gentbajko/slopify 3.12.0 → 3.13.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.
@@ -0,0 +1,5 @@
1
+ # Slopify 3.12.1
2
+
3
+ Released 8 October 2026.
4
+
5
+ - **Finished narration no longer waits forever.** A project whose narration was already made could show Audio as waiting after an edit, so its run never read as done. Its finished narration is now recognised, so those projects show done; nothing is made again.
@@ -0,0 +1,5 @@
1
+ # Slopify 3.13.0
2
+
3
+ Released 8 October 2026.
4
+
5
+ - **Editing the article re-voices only what you changed.** Slopify splits the narration into chunks by length, filling each one sentence by sentence from the top. Removing or adding a sentence near the start moved every chunk boundary after it, so every chunk looked new and the whole narration was voiced again. A save that changes the article or narration text now keeps the chunks it had wherever their text is unchanged, and only cuts the edited stretch again. Dropping one line from the top of a 29-chunk narration now re-voices 1 chunk instead of 29. Changing the chunk size in settings still splits the whole narration again.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.13.0",
4
+ "title": "Slopify 3.13.0",
5
+ "version": "3.13.0",
6
+ "date": "2026-10-08"
7
+ },
8
+ {
9
+ "id": "3.12.1",
10
+ "title": "Slopify 3.12.1",
11
+ "version": "3.12.1",
12
+ "date": "2026-10-08"
13
+ },
2
14
  {
3
15
  "id": "3.12.0",
4
16
  "title": "Slopify 3.12.0",
@@ -19,6 +19,28 @@ export function chunkNarration(text, chunking) {
19
19
  return sentenceRuns(text, chunking.characters ?? defaultChunkCharacters, (part) => Array.from(part.trim()).length);
20
20
  }
21
21
  }
22
+ // The chunks of an edited text, keeping the previous version's chunks wherever their text is
23
+ // still there unchanged: only the stretches between them are cut again. Cutting the whole text
24
+ // afresh packs sentences greedily from the start, so one sentence removed near the top moved
25
+ // every boundary after it and every chunk was voiced again (a 3,800-character edit re-voiced
26
+ // 113,000). `anchors` are the previous chunks in order; without them this is `chunkNarration`.
27
+ export function anchoredChunks(text, chunking, anchors) {
28
+ if (anchors === undefined || anchors.length === 0)
29
+ return chunkNarration(text, chunking);
30
+ const chunks = [];
31
+ let offset = 0;
32
+ for (const anchor of anchors) {
33
+ if (anchor === "")
34
+ continue;
35
+ const at = text.indexOf(anchor, offset);
36
+ if (at < 0)
37
+ continue;
38
+ chunks.push(...chunkNarration(text.slice(offset, at), chunking), anchor);
39
+ offset = at + anchor.length;
40
+ }
41
+ chunks.push(...chunkNarration(text.slice(offset), chunking));
42
+ return chunks;
43
+ }
22
44
  // What "N words" counts: runs of non-space. Exported because it is half of the rule -
23
45
  // a test that asserts a chunk fits in N has to count the same way the cut did. Chinese,
24
46
  // Japanese, Thai and the other scripts written without spaces are counted by the platform's
@@ -1,7 +1,21 @@
1
1
  import { fingerprint } from "../../kernel/runner/work.js";
2
- import { chunkNarration, defaultChunkCharacters, defaultChunkWords, } from "./chunk.js";
2
+ import { anchoredChunks, defaultChunkCharacters, defaultChunkWords, } from "./chunk.js";
3
3
  import { pronunciationMatches } from "./pronunciation.js";
4
- export function pronunciationChunks(source, chunking, entries, overriddenKeys = new Set(), sources = {}) {
4
+ export function chunkingFingerprintOf(chunking) {
5
+ return fingerprint([
6
+ chunking.mode,
7
+ chunking.mode === "words"
8
+ ? (chunking.words ?? defaultChunkWords)
9
+ : chunking.mode === "characters"
10
+ ? (chunking.characters ?? defaultChunkCharacters)
11
+ : null,
12
+ ]);
13
+ }
14
+ // The plain chunks of a narration text, before pronunciation merges: what anchors record.
15
+ export function narrationChunkTexts(source, chunking, anchors) {
16
+ return anchoredChunks(source, chunking, anchors?.chunkingFingerprint === chunkingFingerprintOf(chunking) ? anchors.texts : undefined);
17
+ }
18
+ export function pronunciationChunks(source, chunking, entries, overriddenKeys = new Set(), sources = {}, anchors) {
5
19
  const occurrences = new Map();
6
20
  const usedKeys = new Set();
7
21
  const nextKey = (text) => {
@@ -15,7 +29,7 @@ export function pronunciationChunks(source, chunking, entries, overriddenKeys =
15
29
  return key;
16
30
  };
17
31
  let offset = 0;
18
- const chunks = chunkNarration(source, chunking).map((text) => {
32
+ const chunks = narrationChunkTexts(source, chunking, anchors).map((text) => {
19
33
  const start = source.indexOf(text, offset);
20
34
  if (start < 0)
21
35
  throw new Error("Slopify hit an internal error (a narration chunk isn't in the article text). Try again; if it happens again, use Download diagnostics in Settings and report it.");
@@ -23,14 +37,7 @@ export function pronunciationChunks(source, chunking, entries, overriddenKeys =
23
37
  return { key: nextKey(text), text, start, end: offset };
24
38
  });
25
39
  const bodyFingerprint = fingerprint(source);
26
- const chunkingFingerprint = fingerprint([
27
- chunking.mode,
28
- chunking.mode === "words"
29
- ? (chunking.words ?? defaultChunkWords)
30
- : chunking.mode === "characters"
31
- ? (chunking.characters ?? defaultChunkCharacters)
32
- : null,
33
- ]);
40
+ const chunkingFingerprint = chunkingFingerprintOf(chunking);
34
41
  const starts = new Map(chunks.map((chunk, index) => [chunk.start, index]));
35
42
  const ends = new Map(chunks.map((chunk, index) => [chunk.end, index]));
36
43
  const pinned = new Map();
@@ -14,7 +14,7 @@ import { voiceBodyRecipes } from "./recipe-voices.js";
14
14
  export function bodyNarrationGroups(context, text) {
15
15
  if (text.narrationText === null)
16
16
  return [];
17
- const groups = pronunciationChunks(normalizeNarrationText(text.narrationText), context.config.chunking ?? defaultChunking, usesPronunciationGlossary(context.config) && text.glossary?.ok ? text.glossary.entries : [], new Set(Object.keys(context.content.narrationOverrides)), context.content.narrationSources);
17
+ const groups = pronunciationChunks(normalizeNarrationText(text.narrationText), context.config.chunking ?? defaultChunking, usesPronunciationGlossary(context.config) && text.glossary?.ok ? text.glossary.entries : [], new Set(Object.keys(context.content.narrationOverrides)), context.content.narrationSources, context.content.narrationAnchors);
18
18
  // A chunk's key is its text's, so an article edit that makes a chunk say what its narration
19
19
  // edit already said (a line dropped from both) would make a new chunk to voice again. It
20
20
  // keeps the edited chunk's key instead, and with it the narration already made.
@@ -151,7 +151,8 @@ export function executionStandings(deps, projectId, derived) {
151
151
  output.selected &&
152
152
  output.available &&
153
153
  output.piece.state === "done" &&
154
- output.fingerprint === piece.fingerprint));
154
+ (output.fingerprint === piece.fingerprint ||
155
+ madeFrom(deps, projectId, piece.key, output.fingerprint, piece.fingerprint))));
155
156
  // Save can bind an existing asset without granting its placeholder invocation.
156
157
  return retained ? { ...entry, state: "done" } : entry;
157
158
  });
@@ -174,6 +175,14 @@ export function executionStandings(deps, projectId, derived) {
174
175
  return state === "pending" && retryAt !== null ? { kind, state, retryAt } : { kind, state };
175
176
  });
176
177
  }
178
+ // A finished piece whose request was planned under this fingerprint: a placeholder is planned
179
+ // under the logical fingerprint, the finished piece carries the one its request resolved to.
180
+ function madeFrom(deps, projectId, key, done, planned) {
181
+ return (deps.db
182
+ .prepare(`SELECT 1 FROM revision_work_pieces p JOIN revision_work w ON w.id=p.work_id
183
+ WHERE w.project_id=? AND p.work_key=? AND p.state='done' AND p.fingerprint=? AND p.logical_fingerprint=? LIMIT 1`)
184
+ .get(projectId, key, done, planned) !== undefined);
185
+ }
177
186
  // The latest wait among a stage's steps that went back to wait after a failure time can fix.
178
187
  function latestRetry(deps, group) {
179
188
  let latest = null;
@@ -1,11 +1,45 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
- import { narrationRegenerationKey } from "../narration/plan.js";
2
+ import { defaultChunking } from "../narration/chunk.js";
3
+ import { narrationRegenerationKey, normalizeNarrationText } from "../narration/plan.js";
4
+ import { chunkingFingerprintOf, narrationChunkTexts, } from "../narration/pronunciation-chunks.js";
3
5
  import { bodyNarrationGroups } from "../rebuild/recipe-audio.js";
4
6
  import { buildRecipes } from "../rebuild/recipe-build.js";
5
7
  import { normalizeArticleIntent, validateRevisionEdit } from "../rebuild/recipe-save.js";
6
8
  import { textRecipes } from "../rebuild/recipe-text.js";
9
+ // The chunks to keep through this save: when the narration text changes, the chunks it had
10
+ // before (themselves kept from earlier edits), so only the edited stretches are cut and voiced
11
+ // again; when it doesn't, the ones already kept. None once the chunk size changes.
12
+ function narrationAnchorsFor(base, edit, content) {
13
+ const before = base.revision.config.chunking ?? defaultChunking;
14
+ const after = edit.config.chunking ?? defaultChunking;
15
+ const chunkingFingerprint = chunkingFingerprintOf(after);
16
+ if (chunkingFingerprint !== chunkingFingerprintOf(before))
17
+ return undefined;
18
+ const previous = {
19
+ config: base.revision.config,
20
+ content: base.revision.content,
21
+ manifest: base,
22
+ resolved: {
23
+ articleMarkdown: base.articleMarkdown,
24
+ researchNotes: base.revision.config.provided.research ?? null,
25
+ },
26
+ };
27
+ const old = textRecipes(previous).narrationText;
28
+ const next = textRecipes({ ...previous, config: edit.config, content }).narrationText;
29
+ if (old === null || next === null)
30
+ return undefined;
31
+ const kept = base.revision.content.narrationAnchors;
32
+ if (normalizeNarrationText(old) === normalizeNarrationText(next))
33
+ return kept?.chunkingFingerprint === chunkingFingerprint ? kept : undefined;
34
+ return {
35
+ chunkingFingerprint,
36
+ texts: narrationChunkTexts(normalizeNarrationText(old), before, kept),
37
+ };
38
+ }
7
39
  export function bindNarrationSources(base, edit) {
8
- const { narrationSources: _submitted, ...content } = normalizeArticleIntent(base, edit);
40
+ const { narrationSources: _submitted, narrationAnchors: _sent, ...intent } = normalizeArticleIntent(base, edit);
41
+ const anchors = narrationAnchorsFor(base, edit, intent);
42
+ const content = anchors === undefined ? intent : { ...intent, narrationAnchors: anchors };
9
43
  const keys = new Set([
10
44
  ...Object.keys(content.narrationOverrides),
11
45
  ...Object.keys(base.revision.content.regenerationTokens),
@@ -91,6 +91,14 @@ export const revisionContentSchema = z
91
91
  .refine((rows) => Object.keys(rows).length <= 10000 &&
92
92
  Object.values(rows).reduce((length, row) => length + row.text.length, 0) <= 500000, "Narration source bindings exceed the article limit.")
93
93
  .optional(),
94
+ narrationAnchors: z
95
+ .object({
96
+ chunkingFingerprint: z.string().regex(/^[0-9a-f]{64}$/u),
97
+ texts: z.array(z.string().min(1).max(500000)).max(10000),
98
+ })
99
+ .strict()
100
+ .refine((row) => row.texts.reduce((length, text) => length + text.length, 0) <= 500000, "Narration anchors exceed the article limit.")
101
+ .optional(),
94
102
  subtitleCues: z
95
103
  .object({
96
104
  audioFingerprint: z.string().min(1),