@gentbajko/slopify 3.11.0 → 3.11.2

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,14 @@
1
+ -- How long each local step ran (3.11.1): levelling, renders, captions, documents. Only provider
2
+ -- calls left a record (`attempts`), so a project's working time missed every render and its
3
+ -- clock stood still while one ran. Kept apart from attempts, whose presence says a step has
4
+ -- started submitting and changes how a pause or an edit treats it.
5
+ CREATE TABLE local_work_times (
6
+ id TEXT PRIMARY KEY,
7
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
8
+ stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
9
+ revision_id TEXT,
10
+ work_id TEXT,
11
+ started_at TEXT NOT NULL,
12
+ ended_at TEXT
13
+ );
14
+ CREATE INDEX local_work_times_project ON local_work_times(project_id);
@@ -0,0 +1,8 @@
1
+ # Slopify 3.11.1
2
+
3
+ Released 6 October 2026.
4
+
5
+ - **Working time counts renders.** Only the steps that call an AI model recorded how long they ran, so a project's working time left out every render, levelling and document, and while one ran the clock counted up and dropped back every 20 seconds. Slopify's own steps now record their time too, and the clock only moves while a step actually runs. Time from renders made before this update can't be recovered.
6
+ - **No more words run together in the PDF.** A word starting with a letter like V or T was measured narrower than it is drawn, so the next word ran into it ("Vecna.Later"). Words are now measured exactly as they are drawn.
7
+ - **A readable Sources page.** A source's long address is shown as the site's name, still linking to the page; a bare link gets a title read from its address ("Who is Vecna and why must he die — dndbeyond.com"); a site cited several times in one source is numbered.
8
+ - **Fixing the article by hand no longer remakes the narration it didn't change.** Dropping a line from the article that the narration had already dropped (by editing that chunk's narration) gave the chunk a new identity, so it was voiced again and the captions, shorts, description and video all followed. Such a chunk now keeps its narration. An outro or intro the AI model wrote from the article also keeps the article it was written from, as the pictures do since 3.11.0, so it isn't written again either.
@@ -0,0 +1,5 @@
1
+ # Slopify 3.11.2
2
+
3
+ Released 6 October 2026.
4
+
5
+ - **An article fix the narration already had keeps the video.** After 3.11.1 kept such a chunk's narration, the word timing still counted as changed, so the captions, shorts, description and video were marked outdated anyway. The timing now reads the spoken text the same way before and after, so only what the article itself feeds (the PDF) is remade.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.11.2",
4
+ "title": "Slopify 3.11.2",
5
+ "version": "3.11.2",
6
+ "date": "2026-10-06"
7
+ },
8
+ {
9
+ "id": "3.11.1",
10
+ "title": "Slopify 3.11.1",
11
+ "version": "3.11.1",
12
+ "date": "2026-10-06"
13
+ },
2
14
  {
3
15
  "id": "3.11.0",
4
16
  "title": "Slopify 3.11.0",
@@ -52,9 +52,13 @@ export function useFace(doc, face, size) {
52
52
  doc.setFontSize(size);
53
53
  doc.setCharSpace(face.letterSpacing);
54
54
  }
55
+ // Measured as drawn: `getTextWidth` takes off the kerning between pairs like "Ve" for the
56
+ // standard fonts, but `text` draws without it, so a word starting "V" or "T" came out wider than
57
+ // measured and ran into the next one ("Vecna.Later").
55
58
  export function faceWidth(doc, face, size, text) {
56
59
  useFace(doc, face, size);
57
- return doc.getTextWidth(text) + face.letterSpacing * [...text].length;
60
+ const width = (doc.getStringUnitWidth(text, { doKerning: false }) * size) / doc.internal.scaleFactor;
61
+ return width + face.letterSpacing * [...text].length;
58
62
  }
59
63
  function styleFor(face) {
60
64
  if (face.family === "Cinzel") {
@@ -127,14 +127,27 @@ export function sourcesPage(w, items) {
127
127
  const lines = doc.splitTextToSize(item.text, w.contentWidth - bullet - 5);
128
128
  w.ensure(sources.line);
129
129
  w.write("•", w.left, w.y, fonts.body, sizes.body, "muted");
130
- // The entry reads as text; only its addresses are links, each in the link colour and
131
- // pointing at itself. An entry whose address isn't in its words (a titled link) is
130
+ // The entry reads as text; only its site names (or addresses, when it wasn't tidied) are
131
+ // links, each in the link colour and pointing at its page. An entry with neither is
132
132
  // clickable as a whole, still in text colour.
133
- const addresses = [...item.text.matchAll(/https?:\/\/[^\s)>\]]+/g)].map((match) => ({
134
- from: match.index,
135
- to: match.index + match[0].replace(/[.,;:]+$/, "").length,
136
- url: match[0].replace(/[.,;:]+$/, ""),
137
- }));
133
+ const addresses = [];
134
+ if (item.links !== undefined) {
135
+ let from = 0;
136
+ for (const link of item.links) {
137
+ const at = item.text.indexOf(link.label, from);
138
+ if (at < 0)
139
+ continue;
140
+ addresses.push({ from: at, to: at + link.label.length, url: link.href });
141
+ from = at + link.label.length;
142
+ }
143
+ }
144
+ else
145
+ for (const match of item.text.matchAll(/https?:\/\/[^\s)>\]]+/g))
146
+ addresses.push({
147
+ from: match.index,
148
+ to: match.index + match[0].replace(/[.,;:]+$/, "").length,
149
+ url: match[0].replace(/[.,;:]+$/, ""),
150
+ });
138
151
  const width = (text) => faceWidth(doc, fonts.body, sizes.body, text);
139
152
  let cursor = 0;
140
153
  for (const line of lines) {
@@ -14,7 +14,99 @@ export function documentText(articleMarkdown, researchNotes) {
14
14
  seen.add(link.href);
15
15
  cited.push(link.text === link.href ? { text: link.href, href: link.href } : link);
16
16
  }
17
- return { blocks: markdownBlocks(parts.body), sources: [...listed, ...cited] };
17
+ return { blocks: markdownBlocks(parts.body), sources: [...listed, ...cited].map(tidySource) };
18
+ }
19
+ const addressPattern = /https?:\/\/[^\s)>\]]+/g;
20
+ // A source as a reader wants it: an entry's long address replaced by the site's name (the entry
21
+ // still links to it), and a bare address given a title read from the address itself.
22
+ export function tidySource(item) {
23
+ const bare = item.text.trim();
24
+ if (item.href !== null && (bare === item.href || /^https?:\/\/\S+$/.test(bare))) {
25
+ const site = siteOf(bare);
26
+ const title = titleOf(bare);
27
+ return {
28
+ text: title === undefined ? site : `${title} — ${site}`,
29
+ href: item.href,
30
+ links: [{ label: site, href: item.href }],
31
+ };
32
+ }
33
+ const addresses = [...item.text.matchAll(addressPattern)].map((match) => match[0].replace(/[.,;:]+$/, ""));
34
+ if (addresses.length === 0)
35
+ return item;
36
+ // A site cited more than once in an entry is numbered, so each name still says which page.
37
+ const seen = new Map();
38
+ const links = addresses.map((address) => {
39
+ const site = siteOf(address);
40
+ const count = (seen.get(site) ?? 0) + 1;
41
+ seen.set(site, count);
42
+ return { label: count === 1 ? site : `${site} (${String(count)})`, href: address };
43
+ });
44
+ let text = item.text;
45
+ for (const [at, address] of addresses.entries())
46
+ text = text.replace(address, links[at]?.label ?? address);
47
+ return { text, href: item.href ?? addresses[0] ?? null, links };
48
+ }
49
+ // "en.wikipedia.org" from an address; the address itself when it isn't one.
50
+ function siteOf(address) {
51
+ try {
52
+ return new URL(address).hostname.replace(/^www\./, "");
53
+ }
54
+ catch {
55
+ return address;
56
+ }
57
+ }
58
+ // Path parts that name a kind of page rather than the page.
59
+ const genericParts = new Set([
60
+ "article",
61
+ "articles",
62
+ "blog",
63
+ "card",
64
+ "cards",
65
+ "claim",
66
+ "index",
67
+ "item",
68
+ "news",
69
+ "page",
70
+ "pages",
71
+ "post",
72
+ "posts",
73
+ "product",
74
+ "products",
75
+ "source",
76
+ "view",
77
+ "watch",
78
+ "wiki",
79
+ ]);
80
+ // A page's title as its address spells it: the last path part that reads as words
81
+ // ("1700-who-is-vecna-and-why-must-he-die" → "Who is vecna and why must he die").
82
+ function titleOf(address) {
83
+ let parts;
84
+ try {
85
+ parts = new URL(address).pathname.split("/").filter((part) => part !== "");
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ for (const part of parts.toReversed()) {
91
+ let decoded = part;
92
+ try {
93
+ decoded = decodeURIComponent(part);
94
+ }
95
+ catch {
96
+ // A malformed escape: the part as written.
97
+ }
98
+ const words = decoded
99
+ .replace(/\.(html?|php|aspx?|pdf)$/i, "")
100
+ .replace(/[-_+]+/g, " ")
101
+ .replace(/^\d+\s+/, "")
102
+ .trim();
103
+ if (!/[a-z]{3}/i.test(words) ||
104
+ /^[a-z]{1,3}\d*$/i.test(words) ||
105
+ genericParts.has(words.toLowerCase()))
106
+ continue;
107
+ return words.charAt(0).toUpperCase() + words.slice(1);
108
+ }
109
+ return undefined;
18
110
  }
19
111
  function sourceItems(section) {
20
112
  return sourceEntries(section).flatMap((entry) => {
@@ -12,9 +12,29 @@ import { pauseValues } from "./recipe-pauses.js";
12
12
  import { preparationFuture, preparationTemplate } from "./recipe-preparation.js";
13
13
  import { voiceBodyRecipes } from "./recipe-voices.js";
14
14
  export function bodyNarrationGroups(context, text) {
15
- return text.narrationText === null
16
- ? []
17
- : 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);
15
+ if (text.narrationText === null)
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);
18
+ // A chunk's key is its text's, so an article edit that makes a chunk say what its narration
19
+ // edit already said (a line dropped from both) would make a new chunk to voice again. It
20
+ // keeps the edited chunk's key instead, and with it the narration already made.
21
+ const overrides = context.content.narrationOverrides;
22
+ const present = new Set(groups.map((group) => group.key));
23
+ const orphans = new Map();
24
+ for (const [key, override] of Object.entries(overrides))
25
+ if (override.kind === "text" && key.startsWith("audio:body:") && !present.has(key))
26
+ orphans.set(normalizeNarrationText(override.text), key);
27
+ if (orphans.size === 0)
28
+ return groups;
29
+ return groups.map((group) => {
30
+ if (overrides[group.key] !== undefined || group.source !== undefined)
31
+ return group;
32
+ const key = orphans.get(group.text);
33
+ if (key === undefined)
34
+ return group;
35
+ orphans.delete(group.text);
36
+ return { ...group, key };
37
+ });
18
38
  }
19
39
  export function audioRecipes(context, text) {
20
40
  const { config, content } = context;
@@ -74,7 +94,10 @@ export function audioRecipes(context, text) {
74
94
  pending = true;
75
95
  parts.push(...group);
76
96
  }
77
- if (transcripts.some((row) => row.effective !== row.original))
97
+ // A chunk with its own narration edit keeps the transcript in this form even once the article
98
+ // says the same (`bodyNarrationGroups`), so the word timing made from it stays current.
99
+ if (transcripts.some((row) => row.effective !== row.original) ||
100
+ groups.some((group) => content.narrationOverrides[group.key]?.kind === "text"))
78
101
  bodyTranscript = ["narration-transcript-v1", transcripts.map((row) => row.effective)];
79
102
  if (text.narrationText === null) {
80
103
  futurePronunciation.push(...pronunciationFutureValues(context, text.glossary, text.article, "audio:body:future", null), ...aliasFutureValues(context, "audio:body:future", null));
@@ -44,16 +44,18 @@ export function normalizeArticleIntent(base, edit) {
44
44
  : (base.revision.content.articleEdited ?? false),
45
45
  };
46
46
  }
47
- // The steps that draw from the article: each image's scene, the looks, the thumbnails' scenes
48
- // and the thumbnail prompt written from it.
47
+ // The steps that are made from the article: each image's scene, the looks, the thumbnails'
48
+ // scenes, the thumbnail prompt and an intro or outro the AI model writes from it.
49
49
  export const pictureWorkKeys = [
50
50
  "images:scenes",
51
51
  "thumbnail:scenes",
52
52
  "images:appearance",
53
53
  "thumbnail:prompt",
54
+ "entry:intro:text",
55
+ "entry:outro:text",
54
56
  ];
55
- // An article edited by hand keeps the pictures already drawn: the version remembers the article
56
- // they were drawn from and those steps go on reading it, until the edit asks for them again or
57
+ // An article edited by hand keeps the pictures already drawn, and an intro or outro already
58
+ // written: the version remembers the article they were made from and those steps go on reading it, until the edit asks for them again or
57
59
  // the article is written anew. Before any picture is drawn there is nothing to keep.
58
60
  function picturesArticleOf(base, edit, changed) {
59
61
  const keys = pictureWorkKeys;
@@ -152,7 +152,8 @@ export function textRecipes(context) {
152
152
  recipes.push(recipe(context, "article:continuation", "article", llmInput(context, continuationMessages(messages, resolved.articleContinuation), false, documents), [article.key]));
153
153
  const endMatter = articleMarkdown === null ? null : splitEndMatter(articleMarkdown);
154
154
  const articleText = endMatter === null ? null : plainText(endMatter.body);
155
- // The pictures go on reading the article they were drawn from while the version keeps it.
155
+ // The pictures, and an intro or outro written from the article, go on reading the article
156
+ // they were made from while the version keeps it (`picturesArticle`).
156
157
  const pictureText = content.picturesArticle === undefined
157
158
  ? articleText
158
159
  : plainText(splitEndMatter(content.picturesArticle).body);
@@ -213,7 +214,7 @@ export function textRecipes(context) {
213
214
  const key = `entry:${category}:text`;
214
215
  const input = choice.mode === "text"
215
216
  ? { kind: "local", version: 1, operation: "entry-text", values: prompt }
216
- : articleText === null
217
+ : pictureText === null
217
218
  ? {
218
219
  kind: "deferred",
219
220
  version: 1,
@@ -223,7 +224,7 @@ export function textRecipes(context) {
223
224
  article.fingerprint,
224
225
  ],
225
226
  }
226
- : llmInput(context, withLanguage(segmentMessages(prompt, config, articleText), config.language));
227
+ : llmInput(context, withLanguage(segmentMessages(prompt, config, pictureText), config.language));
227
228
  const value = recipe(context, key, "article", input, choice.mode === "llm" ? [article.key] : []);
228
229
  recipes.push(value);
229
230
  entries[category] = {
@@ -17,6 +17,15 @@ export async function runRevisionInvocation(deps, context, providers) {
17
17
  continue;
18
18
  if (piece.input.kind === "deferred")
19
19
  return "held";
20
+ // A step Slopify does itself (no provider call) records how long it ran, for the working
21
+ // time; a provider call records its own attempts.
22
+ const local = /^(subtitles|export|document|voices):/.test(piece.key) ||
23
+ /^shorts:\d+:render$/.test(piece.key) ||
24
+ (!/^(review|youtube|shorts|animate):/.test(piece.key) &&
25
+ piece.input.kind !== "llm" &&
26
+ piece.input.kind !== "tts" &&
27
+ piece.input.kind !== "image");
28
+ const timed = local ? startLocalTime(deps, context) : undefined;
20
29
  try {
21
30
  const outcome = piece.key.startsWith("subtitles:")
22
31
  ? await executeSubtitleRecipe(deps, context, piece)
@@ -57,9 +66,25 @@ export async function runRevisionInvocation(deps, context, providers) {
57
66
  }
58
67
  throw label === undefined ? error : new Error(`${label}: ${error.message}`, { cause: error });
59
68
  }
69
+ finally {
70
+ if (timed !== undefined)
71
+ endLocalTime(deps, timed);
72
+ }
60
73
  }
61
74
  deps.db
62
75
  .prepare("UPDATE revision_work_pieces SET state='done' WHERE work_id=? AND result_json IS NOT NULL")
63
76
  .run(context.work.workId);
64
77
  return "done";
65
78
  }
79
+ function startLocalTime(deps, context) {
80
+ const id = deps.ids.next();
81
+ deps.db
82
+ .prepare("INSERT INTO local_work_times(id,project_id,stage_id,revision_id,work_id,started_at) VALUES (?,?,?,?,?,?)")
83
+ .run(id, context.work.projectId, context.work.stageId, context.work.revisionId, context.work.workId, deps.clock.now().toISOString());
84
+ return id;
85
+ }
86
+ function endLocalTime(deps, id) {
87
+ deps.db
88
+ .prepare("UPDATE local_work_times SET ended_at=? WHERE id=?")
89
+ .run(deps.clock.now().toISOString(), id);
90
+ }
@@ -31,8 +31,13 @@ export function projectTiming(db, projectId, now) {
31
31
  a.started_at AS started_at, a.ended_at AS ended_at, w.state AS work_state
32
32
  FROM attempts a JOIN stages s ON s.id = a.stage_id
33
33
  LEFT JOIN revision_work w ON w.id = a.work_id
34
- WHERE s.project_id = ?`)
35
- .all(projectId)
34
+ WHERE s.project_id = ?
35
+ UNION ALL
36
+ SELECT s.kind, t.revision_id, t.work_id, t.started_at, t.ended_at, w.state
37
+ FROM local_work_times t JOIN stages s ON s.id = t.stage_id
38
+ LEFT JOIN revision_work w ON w.id = t.work_id
39
+ WHERE t.project_id = ?`)
40
+ .all(projectId, projectId)
36
41
  .map((row) => attemptRow.parse(row));
37
42
  const head = currentRevisionId(db, projectId);
38
43
  // Work an edit carried over to the current revision is still its run's work: a video
@@ -35,6 +35,8 @@ export const projectTables = [
35
35
  "project_controls",
36
36
  "stages",
37
37
  "attempts",
38
+ // Since 3.11.1: how long each local step (a render, the levelling) ran.
39
+ "local_work_times",
38
40
  "stage_pieces",
39
41
  "outputs",
40
42
  "project_revisions",