@gentbajko/slopify 3.0.13 → 3.0.15

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.
@@ -3,6 +3,7 @@ import { Hono } from "hono";
3
3
  import { z } from "zod";
4
4
  import { versionsOrCurrent } from "../../slices/library/history.js";
5
5
  import { entryCategories, entryModes } from "../../slices/library/model.js";
6
+ import { formerNames } from "../../slices/library/rename.js";
6
7
  import { entryById, listEntries } from "../../slices/library/repo.js";
7
8
  import { createEntry, removeEntry, restoreEntry, updateEntry } from "../../slices/library/save.js";
8
9
  import { usedBy } from "../../slices/library/used-by.js";
@@ -51,7 +52,7 @@ export function entryRoutes(deps) {
51
52
  const entry = entryById(deps.db, c.req.valid("param").id);
52
53
  return entry === undefined
53
54
  ? refused(c, { ok: false, reason: "not-found" }, "entry")
54
- : c.json(usedBy(deps.db, { item: "entry", category: entry.category, name: entry.name }));
55
+ : c.json(usedBy(deps.db, { item: "entry", category: entry.category, name: entry.name }, formerNames(deps.db, "entry", entry.id, entry.name)));
55
56
  })
56
57
  .delete("/:id", zValidator("param", idParam, onInvalid), (c) => {
57
58
  const result = removeEntry(library, c.req.valid("param").id);
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { versionsOrCurrent } from "../../slices/library/history.js";
5
5
  import { promptKinds } from "../../slices/library/model.js";
6
6
  import { setPromptPhotorealistic, withPhotorealistic, } from "../../slices/library/photorealistic.js";
7
+ import { formerNames } from "../../slices/library/rename.js";
7
8
  import { listPrompts, promptById } from "../../slices/library/repo.js";
8
9
  import { createPrompt, removePrompt, restorePrompt, updatePrompt, } from "../../slices/library/save.js";
9
10
  import { usedBy } from "../../slices/library/used-by.js";
@@ -70,7 +71,7 @@ export function promptRoutes(deps) {
70
71
  const prompt = promptById(deps.db, c.req.valid("param").id);
71
72
  return prompt === undefined
72
73
  ? refused(c, { ok: false, reason: "not-found" }, "prompt")
73
- : c.json(usedBy(deps.db, { item: "prompt", kind: prompt.kind, name: prompt.name }));
74
+ : c.json(usedBy(deps.db, { item: "prompt", kind: prompt.kind, name: prompt.name }, formerNames(deps.db, "prompt", prompt.id, prompt.name)));
74
75
  })
75
76
  // A project holds its own rendered text, so nothing cascades and a
76
77
  // template used by past projects is deleted like any other.
@@ -0,0 +1,24 @@
1
+ # Slopify 3.0.14
2
+
3
+ Released 30 September 2026.
4
+
5
+ 3.0.14 fixes two things in the Library: **Used by** on Review and Script prompts, and renaming a prompt or an intro/outro that templates and drafts still use.
6
+
7
+ ## Highlights
8
+
9
+ - **Renaming a prompt no longer breaks what uses it.** Templates, schedules and open Play drafts take the new name.
10
+ - **Used by is right for Review and Script prompts.**
11
+
12
+ ## Library
13
+
14
+ - Renaming a prompt or an intro/outro now renames it in every template and every Play draft you are still editing, so they keep using it. Schedules follow their template. Before, they kept the old name and fell back to the copy saved with the template.
15
+ - A project keeps the name its run used. **Used by** still lists it after a rename.
16
+
17
+ ## Fixes
18
+
19
+ - **Used by** on a Review or Script prompt listed the templates and projects that used *image* prompts of the same name. It now lists the automatic reviews that pick the Review prompt, and the multi-voice script runs that pick the Script prompt.
20
+ - **Used by** now lists a schedule by the template as it is now, which is what the schedule runs, instead of the version it was saved with.
21
+
22
+ ## Upgrading from 3.0.13
23
+
24
+ - **Nothing becomes outdated.** Templates and drafts that already name an old name keep working as before; the next rename carries them along.
@@ -0,0 +1,17 @@
1
+ # Slopify 3.0.15
2
+
3
+ Released 30 September 2026.
4
+
5
+ 3.0.15 makes a garbled narration chunk get recorded again even when the project is busy.
6
+
7
+ ## Fixes
8
+
9
+ - When the captions caught a garbled sentence, Slopify said it was recording that narration chunk again, but if another step of the project was still running at that moment (its PDF, say), the retry gave up and the video stayed failed. The retry now waits for that step and starts as soon as it finishes.
10
+
11
+ ## Under the hood
12
+
13
+ - Faster CI and releases: Docker images build beside the npm publish, the Windows install check uses one warm cache, and unchanged Windows and Docker checks are skipped.
14
+
15
+ ## Upgrading from 3.0.14
16
+
17
+ - **Nothing becomes outdated.**
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.0.15",
4
+ "title": "Slopify 3.0.15",
5
+ "version": "3.0.15",
6
+ "date": "2026-09-30"
7
+ },
8
+ {
9
+ "id": "3.0.14",
10
+ "title": "Slopify 3.0.14",
11
+ "version": "3.0.14",
12
+ "date": "2026-09-30"
13
+ },
2
14
  {
3
15
  "id": "3.0.13",
4
16
  "title": "Slopify 3.0.13",
@@ -0,0 +1,94 @@
1
+ // Renaming a Library prompt or intro/outro. Everything names the Library by name, so a rename
2
+ // rewrites the name where a future run reads it: each live template's current version (its
3
+ // form and the copy it saved of the Library) and every Play draft still being edited.
4
+ // Schedules run their template as it is now, so they follow. A project revision is the record
5
+ // of what its run used and keeps the old name; "Used by" finds it through `formerNames`.
6
+ import { z } from "zod";
7
+ import { renamedIn } from "./used-by.js";
8
+ const documentRow = z.object({ key: z.string(), version: z.number(), document_json: z.string() });
9
+ // The template document or Play draft document with the form, and the Library copy it saved,
10
+ // naming `to`; undefined when neither named the old name.
11
+ function renamedDocument(json, ref, id, to) {
12
+ const document = JSON.parse(json);
13
+ if (typeof document !== "object" || document === null)
14
+ return undefined;
15
+ const next = { ...document };
16
+ let changed = false;
17
+ const form = renamedIn(next.form, ref, to);
18
+ if (form !== undefined) {
19
+ next.form = form;
20
+ changed = true;
21
+ }
22
+ const snapshot = renamedSnapshot(next.librarySnapshot, ref, id, to);
23
+ if (snapshot !== undefined) {
24
+ next.librarySnapshot = snapshot;
25
+ changed = true;
26
+ }
27
+ return changed ? JSON.stringify(next) : undefined;
28
+ }
29
+ // The saved copy of a renamed item is found by the new name once the Library row is gone.
30
+ function renamedSnapshot(value, ref, id, to) {
31
+ if (typeof value !== "object" || value === null)
32
+ return undefined;
33
+ const snapshot = value;
34
+ const listKey = ref.item === "prompt" ? "prompts" : "entries";
35
+ const rows = snapshot[listKey];
36
+ if (!Array.isArray(rows))
37
+ return undefined;
38
+ const from = ref.name.trim().toLowerCase();
39
+ let changed = false;
40
+ const next = rows.map((row) => {
41
+ if (typeof row !== "object" || row === null)
42
+ return row;
43
+ const one = row;
44
+ const sameKind = ref.item === "prompt" ? one.kind === ref.kind : one.category === ref.category;
45
+ const named = typeof one.name === "string" && one.name.trim().toLowerCase() === from;
46
+ if (!sameKind || one.name === to || (one.id !== id && !named))
47
+ return row;
48
+ changed = true;
49
+ return { ...one, name: to };
50
+ });
51
+ return changed ? { ...snapshot, [listKey]: next } : undefined;
52
+ }
53
+ // Runs inside the save's transaction, so the rename and the rewritten references land
54
+ // together or not at all.
55
+ export function renameReferences(db, ref, id, to) {
56
+ if (ref.name === to)
57
+ return;
58
+ const templates = db
59
+ .prepare(`SELECT t.id AS key,r.version,r.document_json FROM project_templates t
60
+ JOIN project_template_revisions r ON r.template_id=t.id AND r.version=t.head_version
61
+ WHERE t.deleted_at IS NULL`)
62
+ .all()
63
+ .map((row) => documentRow.parse(row));
64
+ const updateTemplate = db.prepare("UPDATE project_template_revisions SET document_json=? WHERE template_id=? AND version=?");
65
+ for (const row of templates) {
66
+ const next = renamedDocument(row.document_json, ref, id, to);
67
+ if (next !== undefined)
68
+ updateTemplate.run(next, row.key, row.version);
69
+ }
70
+ const drafts = db
71
+ .prepare("SELECT id AS key,version,document_json FROM play_drafts WHERE state='active'")
72
+ .all()
73
+ .map((row) => documentRow.parse(row));
74
+ const updateDraft = db.prepare("UPDATE play_drafts SET document_json=? WHERE id=?");
75
+ for (const row of drafts) {
76
+ const next = renamedDocument(row.document_json, ref, id, to);
77
+ if (next !== undefined)
78
+ updateDraft.run(next, row.key);
79
+ }
80
+ }
81
+ // The names an item had before, other than its current one, that no other live item of the
82
+ // same kind or category holds now.
83
+ export function formerNames(db, itemKind, id, current) {
84
+ const table = itemKind === "prompt" ? "prompts" : "entries";
85
+ const group = itemKind === "prompt" ? "kind" : "category";
86
+ const rows = db
87
+ .prepare(`SELECT DISTINCT v.name FROM library_versions v
88
+ WHERE v.item_kind=? AND v.item_id=? AND lower(v.name)<>lower(?)
89
+ AND NOT EXISTS (
90
+ SELECT 1 FROM ${table} o WHERE o.id<>v.item_id AND o.${group}=v.kind
91
+ AND lower(o.name)=lower(v.name) AND o.deleted_at IS NULL)`)
92
+ .all(itemKind, id, current);
93
+ return rows.map((row) => z.object({ name: z.string() }).parse(row).name);
94
+ }
@@ -6,6 +6,7 @@ import { detectSlots } from "../admission/substitute.js";
6
6
  import { recordVersion, versionOf } from "./history.js";
7
7
  import { lintEntry, lintPrompt } from "./lint.js";
8
8
  import { entryCategories, promptKinds } from "./model.js";
9
+ import { renameReferences } from "./rename.js";
9
10
  import { entryById, insertEntry, insertPrompt, promptById, replaceEntry, replacePrompt, trashEntry, trashPrompt, } from "./repo.js";
10
11
  export function createPrompt(deps, draft) {
11
12
  const fields = lintPrompt(draft);
@@ -30,6 +31,8 @@ export function updatePrompt(deps, id, draft, restoredFrom) {
30
31
  const previous = promptById(deps.db, id);
31
32
  if (!replacePrompt(deps.db, prompt))
32
33
  return false;
34
+ if (previous !== undefined)
35
+ renameReferences(deps.db, { item: "prompt", kind: previous.kind, name: previous.name }, id, prompt.name);
33
36
  recordVersion(deps.db, "prompt", prompt, {
34
37
  previous,
35
38
  ...(restoredFrom === undefined ? {} : { restoredFrom }),
@@ -75,6 +78,8 @@ export function updateEntry(deps, id, draft, restoredFrom) {
75
78
  const previous = entryById(deps.db, id);
76
79
  if (!replaceEntry(deps.db, entry))
77
80
  return false;
81
+ if (previous !== undefined)
82
+ renameReferences(deps.db, { item: "entry", category: previous.category, name: previous.name }, id, entry.name);
78
83
  recordVersion(deps.db, "entry", entry, {
79
84
  previous,
80
85
  ...(restoredFrom === undefined ? {} : { restoredFrom }),
@@ -5,92 +5,119 @@
5
5
  // `snapshot.ts` use.
6
6
  import { z } from "zod";
7
7
  import { liveProject } from "../admission/repo.js";
8
- // The fields of a Play form or a run config that name Library items. Both shapes carry the
9
- // same names; a run config holds the intro and outro as `{ name }` and a form as the name.
10
- const named = z.string().optional().catch(undefined);
11
- const refsShape = z
12
- .object({
13
- articlePrompt: named,
14
- narrationPrompt: named,
15
- thumbnailPrompt: named,
16
- descriptionPrompt: named,
17
- imagePrompts: z
18
- .array(z.object({ name: z.string() }).loose())
19
- .optional()
20
- .catch(undefined),
21
- reference: z.object({ prompt: named }).loose().optional().catch(undefined),
22
- shorts: z.object({ prompt: named, imagePrompt: named }).loose().optional().catch(undefined),
23
- intro: z
24
- .union([z.string(), z.object({ name: z.string() }).loose()])
25
- .optional()
26
- .catch(undefined),
27
- outro: z
28
- .union([z.string(), z.object({ name: z.string() }).loose()])
29
- .optional()
30
- .catch(undefined),
31
- })
32
- .loose();
8
+ function record(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value)
10
+ ? value
11
+ : undefined;
12
+ }
13
+ function field(owner, key) {
14
+ return owner === undefined ? [] : [{ get: () => owner[key], set: (name) => (owner[key] = name) }];
15
+ }
16
+ // An intro or outro: the name in a form, `{ name, mode }` in a run config.
17
+ function entrySlot(owner, key) {
18
+ const held = record(owner[key]);
19
+ return held === undefined ? field(owner, key) : field(held, "name");
20
+ }
21
+ function scriptRun(value) {
22
+ return record(value.sources)?.audio === "generate" && record(value.voices)?.source === "script";
23
+ }
24
+ function slotsIn(value, ref) {
25
+ if (ref.item === "entry")
26
+ return entrySlot(value, ref.category);
27
+ const shorts = record(value.shorts);
28
+ switch (ref.kind) {
29
+ case "article":
30
+ return scriptRun(value) ? [] : field(value, "articlePrompt");
31
+ case "script":
32
+ return scriptRun(value) ? field(value, "articlePrompt") : [];
33
+ case "narration":
34
+ return field(value, "narrationPrompt");
35
+ case "thumbnail":
36
+ return field(value, "thumbnailPrompt");
37
+ case "description":
38
+ return field(value, "descriptionPrompt");
39
+ case "shorts":
40
+ return field(shorts, "prompt");
41
+ case "review":
42
+ return Object.values(record(record(value.reviews)?.stages) ?? {}).flatMap((stage) => field(record(stage), "prompt"));
43
+ case "image":
44
+ return [
45
+ ...(Array.isArray(value.imagePrompts) ? value.imagePrompts : []).flatMap((one) => field(record(one), "name")),
46
+ ...field(record(value.reference), "prompt"),
47
+ ...field(shorts, "imagePrompt"),
48
+ ];
49
+ }
50
+ }
33
51
  // The names a form or config holds for one kind of prompt or category of entry.
34
52
  export function namesIn(value, ref) {
35
- const parsed = refsShape.safeParse(value);
36
- if (!parsed.success)
53
+ const form = record(value);
54
+ if (form === undefined)
37
55
  return [];
38
- const form = parsed.data;
39
- const entryName = (one) => one === undefined ? undefined : typeof one === "string" ? one : one.name;
40
- const names = ref.item === "entry"
41
- ? [ref.category === "intro" ? entryName(form.intro) : entryName(form.outro)]
42
- : ref.kind === "article"
43
- ? [form.articlePrompt]
44
- : ref.kind === "narration"
45
- ? [form.narrationPrompt]
46
- : ref.kind === "thumbnail"
47
- ? [form.thumbnailPrompt]
48
- : ref.kind === "description"
49
- ? [form.descriptionPrompt]
50
- : ref.kind === "shorts"
51
- ? [form.shorts?.prompt]
52
- : [
53
- ...(form.imagePrompts ?? []).map((one) => one.name),
54
- form.reference?.prompt,
55
- form.shorts?.imagePrompt,
56
- ];
57
- return names.filter((name) => name !== undefined && name.trim() !== "");
56
+ return slotsIn(form, ref)
57
+ .map((slot) => slot.get())
58
+ .filter((name) => typeof name === "string" && name.trim() !== "");
58
59
  }
59
- export function uses(value, ref) {
60
- const wanted = ref.name.trim().toLowerCase();
61
- return namesIn(value, ref).some((name) => name.trim().toLowerCase() === wanted);
60
+ // A copy of the form or config with every place that names `from` naming `to` instead, or
61
+ // undefined when nothing names it.
62
+ export function renamedIn(value, ref, to) {
63
+ const form = record(value);
64
+ if (form === undefined)
65
+ return undefined;
66
+ const copy = structuredClone(form);
67
+ const from = ref.name.trim().toLowerCase();
68
+ let changed = false;
69
+ for (const slot of slotsIn(copy, ref)) {
70
+ const name = slot.get();
71
+ if (typeof name !== "string" || name.trim().toLowerCase() !== from || name === to)
72
+ continue;
73
+ slot.set(to);
74
+ changed = true;
75
+ }
76
+ return changed ? copy : undefined;
77
+ }
78
+ export function uses(value, ref, formerNames = []) {
79
+ const wanted = new Set([ref.name, ...formerNames].map((name) => name.trim().toLowerCase()));
80
+ return namesIn(value, ref).some((name) => wanted.has(name.trim().toLowerCase()));
62
81
  }
63
82
  const documentShape = z.object({ form: z.unknown() }).loose();
64
83
  function formOf(json) {
65
84
  const parsed = documentShape.safeParse(JSON.parse(json));
66
85
  return parsed.success ? parsed.data.form : undefined;
67
86
  }
68
- export function usedBy(db, ref) {
87
+ // `formerNames` are names the item had before a rename that no other item holds now: a
88
+ // project revision keeps the name its run used (`rename.ts` leaves revisions alone), so a
89
+ // renamed prompt still finds the projects that ran it.
90
+ export function usedBy(db, ref, formerNames = []) {
69
91
  // A name that appears nowhere in the JSON cannot match, so SQL skips those rows before any
70
92
  // is parsed. JSON escapes quotes and backslashes, so only a plain name is pre-filtered.
71
93
  // SQLite's lower() folds ASCII only, so a name with anything else is not pre-filtered.
72
- const plain = /^[\x20-\x7e]*$/u.test(ref.name) && !/["\\]/u.test(ref.name)
73
- ? ref.name.trim().toLowerCase()
74
- : "";
94
+ const names = [ref.name, ...formerNames];
95
+ const plain = names.every((name) => /^[\x20-\x7e]*$/u.test(name) && !/["\\]/u.test(name))
96
+ ? names.map((name) => name.trim().toLowerCase())
97
+ : [""];
98
+ const mentions = (column) => `(${plain.map(() => `instr(lower(${column}),?)>0`).join(" OR ")})`;
99
+ const matches = (value) => uses(value, ref, formerNames);
75
100
  const templates = db
76
101
  .prepare(`SELECT t.id,r.name,r.document_json FROM project_templates t
77
102
  JOIN project_template_revisions r ON r.template_id=t.id AND r.version=t.head_version
78
- WHERE t.deleted_at IS NULL AND instr(lower(r.document_json),?)>0 ORDER BY lower(r.name),t.id`)
79
- .all(plain)
103
+ WHERE t.deleted_at IS NULL AND ${mentions("r.document_json")} ORDER BY lower(r.name),t.id`)
104
+ .all(...plain)
80
105
  .flatMap((row) => {
81
106
  const parsed = templateRow.parse(row);
82
- return uses(formOf(parsed.document_json), ref) ? [{ id: parsed.id, name: parsed.name }] : [];
107
+ return matches(formOf(parsed.document_json)) ? [{ id: parsed.id, name: parsed.name }] : [];
83
108
  });
84
- // A schedule runs the template version it was saved with, so that version is what it uses.
109
+ // A schedule runs its template as it is now (`schedules/scheduler.ts`), so the template's
110
+ // current version is what it uses.
85
111
  const schedules = db
86
112
  .prepare(`SELECT s.id,s.name,s.status,r.document_json FROM schedules s
87
- JOIN project_template_revisions r ON r.template_id=s.template_id AND r.version=s.template_version
88
- WHERE s.deleted_at IS NULL AND s.status IN ('active','paused') AND instr(lower(r.document_json),?)>0
113
+ JOIN project_templates t ON t.id=s.template_id
114
+ JOIN project_template_revisions r ON r.template_id=t.id AND r.version=t.head_version
115
+ WHERE s.deleted_at IS NULL AND s.status IN ('active','paused') AND ${mentions("r.document_json")}
89
116
  ORDER BY lower(s.name),s.id`)
90
- .all(plain)
117
+ .all(...plain)
91
118
  .flatMap((row) => {
92
119
  const parsed = scheduleRow.parse(row);
93
- return uses(formOf(parsed.document_json), ref)
120
+ return matches(formOf(parsed.document_json))
94
121
  ? [{ id: parsed.id, name: parsed.name, status: parsed.status }]
95
122
  : [];
96
123
  });
@@ -99,13 +126,13 @@ export function usedBy(db, ref) {
99
126
  (SELECT count(*) FROM project_revisions x WHERE x.project_id=r.project_id) AS total
100
127
  FROM project_revisions r JOIN projects p ON p.id=r.project_id
101
128
  LEFT JOIN project_heads h ON h.project_id=r.project_id
102
- WHERE ${liveProject("p")} AND instr(lower(r.config),?)>0
129
+ WHERE ${liveProject("p")} AND ${mentions("r.config")}
103
130
  ORDER BY p.created_at DESC,p.id,r.created_at`)
104
- .all(plain)
131
+ .all(...plain)
105
132
  .map((row) => revisionRow.parse(row));
106
133
  const projects = new Map();
107
134
  for (const row of revisionRows) {
108
- if (!uses(JSON.parse(row.config), ref))
135
+ if (!matches(JSON.parse(row.config)))
109
136
  continue;
110
137
  const seen = projects.get(row.project_id);
111
138
  projects.set(row.project_id, {
@@ -121,10 +148,10 @@ export function usedBy(db, ref) {
121
148
  .prepare(`SELECT p.id,p.title,p.config FROM projects p
122
149
  WHERE ${liveProject("p")}
123
150
  AND NOT EXISTS (SELECT 1 FROM project_revisions r WHERE r.project_id=p.id)
124
- AND instr(lower(p.config),?)>0 ORDER BY p.created_at DESC,p.id`)
125
- .all(plain)
151
+ AND ${mentions("p.config")} ORDER BY p.created_at DESC,p.id`)
152
+ .all(...plain)
126
153
  .map((row) => legacyRow.parse(row))) {
127
- if (uses(JSON.parse(row.config), ref))
154
+ if (matches(JSON.parse(row.config)))
128
155
  projects.set(row.id, {
129
156
  id: row.id,
130
157
  title: row.title,
@@ -105,6 +105,16 @@ async function recordAgain(deps, retry) {
105
105
  detail: `Recording ${retry.chunkKey} again (try ${String(retry.tries)} of ${String(narrationRetryLimit)}).`,
106
106
  });
107
107
  }
108
+ else if (result.reason === "running") {
109
+ // Another step of the project is still going (its PDF, say): the retry waits for it and
110
+ // starts when that step finishes, which kicks this again (`onFinished`). Giving up here
111
+ // left the video failed with a message promising a retry that never came.
112
+ deps.log.write("info", "narration.retry", {
113
+ projectId: retry.projectId,
114
+ detail: `Recording ${retry.chunkKey} again once the project's running steps finish.`,
115
+ });
116
+ return;
117
+ }
108
118
  else {
109
119
  settle(deps.db, retry, "failed", refusal(result), now);
110
120
  deps.log.write("warn", "narration.retry", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gentbajko/slopify",
3
- "version": "3.0.13",
3
+ "version": "3.0.15",
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",