@gentbajko/slopify 3.13.0 → 3.13.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.
@@ -17,7 +17,7 @@ import { limitWaitsByProject, listingWait } from "../../slices/run-cost/panel.js
17
17
  import { outputsOf } from "../../slices/storage/repo.js";
18
18
  import { longVideoStats } from "../../slices/studio/stats.js";
19
19
  import { trashProject } from "../../slices/trash/service.js";
20
- import { setAsideProjects, uploadedProjects } from "../../slices/uploads/repo.js";
20
+ import { setAsideProjects, uploadedAndSettled, uploadedProjects, } from "../../slices/uploads/repo.js";
21
21
  import { onInvalid, problem, titleOf } from "./problem.js";
22
22
  import { createProject } from "./project-create.js";
23
23
  const idParam = z.object({
@@ -37,9 +37,11 @@ export function projectRoutes(deps) {
37
37
  log: deps.log,
38
38
  hasInflight: deps.runner.hasInflight,
39
39
  };
40
+ // An uploaded project with nothing admitted reads as done (`uploadedAndSettled`).
41
+ const statusOf = (status, settled) => settled && (status === "paused" || status === "pending") ? "done" : status;
40
42
  const summarise = (project) => ({
41
43
  ...project,
42
- status: derive(stagesOf(deps.db, project.id), project.paused),
44
+ status: statusOf(derive(stagesOf(deps.db, project.id), project.paused), uploadedAndSettled(deps.db).has(project.id)),
43
45
  });
44
46
  return (new Hono()
45
47
  .post("/", zValidator("json", runDraftSchema, onInvalid), async (c) => {
@@ -65,6 +67,7 @@ export function projectRoutes(deps) {
65
67
  const channels = projectChannels(deps.db);
66
68
  const uploads = uploadedProjects(deps.db);
67
69
  const aside = setAsideProjects(deps.db);
70
+ const settled = uploadedAndSettled(deps.db);
68
71
  const numbers = longVideoStats(deps.db);
69
72
  const waits = limitWaitsByProject(deps.db);
70
73
  const projects = listProjectHeads(deps.db).map((project) => {
@@ -72,7 +75,7 @@ export function projectRoutes(deps) {
72
75
  const waiting = waits.get(project.id);
73
76
  return {
74
77
  ...project,
75
- status: derive(stages, project.paused),
78
+ status: statusOf(derive(stages, project.paused), settled.has(project.id)),
76
79
  progress: progressOf(stages),
77
80
  channelId: channels.get(project.id) ?? defaultChannelId,
78
81
  uploadedAt: uploads.get(project.id) ?? null,
@@ -0,0 +1,23 @@
1
+ // The planner rebuilds a project's whole plan several times a runner tick, on the server's one
2
+ // thread, and each rebuild ran the same pure text work again over the full narration: matching
3
+ // the pronunciation glossary, cutting chunks, parsing the glossary. On a 2-hour narration that
4
+ // held the thread for seconds at a time, so the app stopped answering while a project ran.
5
+ // A pure function's last few results, by its exact input. Not a service: nothing is injected or
6
+ // shared but answers the function would give anyway. Results are frozen data, never mutated.
7
+ export function remembered(size, keyOf, work) {
8
+ const results = new Map();
9
+ return (...args) => {
10
+ const key = keyOf(...args);
11
+ const known = results.get(key);
12
+ if (known !== undefined || results.has(key)) {
13
+ results.delete(key);
14
+ results.set(key, known);
15
+ return known;
16
+ }
17
+ const value = work(...args);
18
+ results.set(key, value);
19
+ if (results.size > size)
20
+ results.delete(results.keys().next().value);
21
+ return value;
22
+ };
23
+ }
package/dist/main.js CHANGED
@@ -62,7 +62,7 @@ import { waitToRetry, wakeRetries } from "./slices/rebuild/retry.js";
62
62
  import { createReviewRedos, reviewHold } from "./slices/rebuild/review-redo.js";
63
63
  import { materializeAdmittedWork } from "./slices/rebuild/runtime-materialize.js";
64
64
  import { runRevisionInvocation } from "./slices/rebuild/runtime-run.js";
65
- import { executionStages, executionStandings, invocationReady, projectStandings, recordWorkProgress, } from "./slices/rebuild/runtime-store.js";
65
+ import { executionStandings, invocationReady, projectStandings, recordWorkProgress, } from "./slices/rebuild/runtime-store.js";
66
66
  import { currentRevisionId } from "./slices/revisions/repo.js";
67
67
  import { createLimitGate, resumeAfterRestart } from "./slices/run-cost/limits.js";
68
68
  import { createStandaloneMeter, createUsageMeter } from "./slices/run-cost/meter.js";
@@ -759,8 +759,7 @@ export function wireRunner({ onFinished, audioPreviews, db, paths, clock, ids, l
759
759
  stages: {
760
760
  stagesOf: (projectId) => {
761
761
  materializeAdmittedWork(execution, projectId);
762
- projectStandings(execution, projectId);
763
- return executionStages(execution, projectId);
762
+ return projectStandings(execution, projectId);
764
763
  },
765
764
  standingsOf: (projectId) => executionStandings(execution, projectId),
766
765
  ready: (work) => invocationReady(execution, work),
@@ -0,0 +1,5 @@
1
+ # Slopify 3.13.1
2
+
3
+ Released 8 October 2026.
4
+
5
+ - **Slopify keeps answering while a long project runs.** Each time a step started or finished, Slopify worked out the project's whole plan again several times over, on the same thread that answers the app. On a two-hour narration that took about 7 seconds per step, so with steps finishing back to back the app stopped responding for minutes. The repeated text work (matching the pronunciation glossary, cutting chunks, reading the glossary) is now done once and remembered, and the plan is worked out once per step instead of several times. The same project now takes about 1 second per step.
@@ -0,0 +1,5 @@
1
+ # Slopify 3.13.2
2
+
3
+ Released 8 October 2026.
4
+
5
+ - **Uploaded videos no longer show as Paused.** A project you uploaded that was paused, or had a leftover step from an edit after the upload, showed as Paused or Waiting in the project list and on its page. An uploaded project with nothing queued to run now shows as Done. A remake you start on an uploaded video still shows as running until it finishes.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.13.2",
4
+ "title": "Slopify 3.13.2",
5
+ "version": "3.13.2",
6
+ "date": "2026-10-08"
7
+ },
8
+ {
9
+ "id": "3.13.1",
10
+ "title": "Slopify 3.13.1",
11
+ "version": "3.13.1",
12
+ "date": "2026-10-08"
13
+ },
2
14
  {
3
15
  "id": "3.13.0",
4
16
  "title": "Slopify 3.13.0",
@@ -1,3 +1,4 @@
1
+ import { remembered } from "../../kernel/remembered.js";
1
2
  export const chunkModes = ["whole", "paragraph", "words", "characters"];
2
3
  export const defaultChunkWords = 500;
3
4
  export const defaultChunkCharacters = 3000;
@@ -7,7 +8,9 @@ export const defaultChunking = { mode: "whole" };
7
8
  // A run of blank lines is one paragraph break, not several, and a line of nothing but
8
9
  // spaces between two paragraphs is still a break: strip-markdown output is full of both.
9
10
  const paragraphBreak = /\r?\n[ \t\r]*(?:\r?\n[ \t\r]*)+/;
10
- export function chunkNarration(text, chunking) {
11
+ // Remembered (`kernel/remembered.ts`): every plan rebuild cuts the whole narration again.
12
+ export const chunkNarration = remembered(16, (text, chunking) => `${JSON.stringify(chunking)}\u0000${text}`, cutNarration);
13
+ function cutNarration(text, chunking) {
11
14
  switch (chunking.mode) {
12
15
  case "whole":
13
16
  return nonEmpty([text]);
@@ -1,5 +1,6 @@
1
1
  import { remark } from "remark";
2
2
  import remarkGfm from "remark-gfm";
3
+ import { remembered } from "../../kernel/remembered.js";
3
4
  // Inworld requires English IPA, not every symbol in Unicode's IPA Extensions block.
4
5
  // https://docs.inworld.ai/tts/capabilities/custom-pronunciation
5
6
  const ipaAtom = "[ˈˌ]?[abdefghijklmnoprstuvwxzæðŋθɑɒɔəɚɛɜɝɡɪɹʃʊʌʒʔɫɾ][\\u0303\\u031a\\u0325\\u0329\\u032a\\u032c\\u032f\\u035c\\u0361ʰʲʷ]*[ːˑ]?";
@@ -49,7 +50,8 @@ function termIdentity(term) {
49
50
  }
50
51
  // `language` is the project's (`kernel/ports/languages.ts`): absent or English accepts the
51
52
  // standard-English IPA Inworld asks for, as it always did; any other accepts full IPA.
52
- export function parsePronunciationGlossary(markdown, language) {
53
+ export const parsePronunciationGlossary = remembered(8, (markdown, language) => `${language ?? ""}\u0000${markdown}`, parseGlossary);
54
+ function parseGlossary(markdown, language) {
53
55
  const english = language === undefined || language === "en";
54
56
  const symbols = english ? ipaSymbols : worldSymbols;
55
57
  const kind = english ? "standard-English IPA" : "IPA";
@@ -127,7 +129,9 @@ export function skippedSpeakerPronunciationsNotice(rows) {
127
129
  return (`Speaker pronunciations: some entries are skipped and read as ordinary text (${parts.join(". ")}). ` +
128
130
  "The rest are used. To use them, fix those entries under Speakers → Pronunciations for that speaker (Play → Narration, or Edit project → Providers).");
129
131
  }
130
- export function pronunciationMatches(source, entries) {
132
+ // Remembered (`kernel/remembered.ts`): every plan rebuild matches the whole narration again.
133
+ export const pronunciationMatches = remembered(1024, (source, entries) => `${source}\u0000${JSON.stringify(entries)}`, matchPronunciations);
134
+ function matchPronunciations(source, entries) {
131
135
  if (entries.length === 0)
132
136
  return [];
133
137
  const ordered = [...entries].sort((a, b) => b.term.length - a.term.length);
@@ -28,6 +28,13 @@ export function materializeAdmittedWork(deps, projectId) {
28
28
  .all(projectId, head)
29
29
  .map((row) => reservation.parse(row));
30
30
  const origins = new Map(reservations.map((row) => [row.origin_revision, row]));
31
+ // The head's view, read once: each narration part used to read the whole version again.
32
+ let headView = null;
33
+ const headOnce = () => {
34
+ if (headView === null)
35
+ headView = getRevisionView(deps, projectId, head);
36
+ return headView;
37
+ };
31
38
  for (const origin of origins.values()) {
32
39
  const view = executionView(deps, projectId, origin.origin_revision);
33
40
  if (view === undefined)
@@ -40,7 +47,7 @@ export function materializeAdmittedWork(deps, projectId) {
40
47
  if (recipe.deferred || recipe.unresolved)
41
48
  continue;
42
49
  if (recipe.input.kind === "tts") {
43
- const current = getRevisionView(deps, projectId, head);
50
+ const current = headOnce();
44
51
  const desired = current?.revision.fingerprints[`${recipe.input.logicalKey}:1`];
45
52
  if (desired !== undefined && desired !== recipe.logicalFingerprint)
46
53
  continue;
@@ -46,7 +46,9 @@ export function executionStages(deps, projectId) {
46
46
  !reviewsNarration(view.revision.config))
47
47
  return false;
48
48
  if (piece.input.kind === "deferred" && piece.key.endsWith(":future")) {
49
- const cacheKey = String(row.id);
49
+ // One plan per version and saved catalogue: every placeholder of the same version
50
+ // asked for the same plan again, a whole-project rebuild each time.
51
+ const cacheKey = `${String(row.revision_id)}\u0000${String(row.recipe_context)}`;
50
52
  let deferred = futureKeys.get(cacheKey);
51
53
  if (deferred === undefined) {
52
54
  const origin = executionView(deps, projectId, String(row.revision_id));
@@ -197,6 +199,8 @@ function latestRetry(deps, group) {
197
199
  }
198
200
  return latest;
199
201
  }
202
+ // The stage rows brought up to date, and the steps they were derived from, so the runner's
203
+ // tick uses them rather than deriving the whole plan again.
200
204
  export function projectStandings(deps, projectId) {
201
205
  // One derivation serves every stage: each builds the whole execution plan.
202
206
  const work = executionStages(deps, projectId);
@@ -227,6 +231,7 @@ export function projectStandings(deps, projectId) {
227
231
  .run(stage.state, textOf(failure?.failure_reason), textOf(failure?.failure_kind), stage.retryAt ?? null, invocations.length === 0 ? null : current, invocations.length === 0 ? null : invocations.length, stage.state, deps.clock.now().toISOString(), projectId, stage.kind);
228
232
  }
229
233
  settleScheduleRunsForProject(deps.db, projectId, deps.clock.now().toISOString());
234
+ return work;
230
235
  }
231
236
  // Progress moves one number and nothing else. The stage row already counts this invocation's
232
237
  // last fraction, from the claim or the previous event, so it takes the difference instead
@@ -18,6 +18,17 @@ export function uploadedProjects(db) {
18
18
  .all()
19
19
  .map((row) => [String(row.project_id), String(row.uploaded_at)]));
20
20
  }
21
+ // Uploaded projects with nothing admitted still to run: their leftover steps (a placeholder
22
+ // from an edit after the upload, a pause from a bulk Pause) are not work the person is
23
+ // waiting for, so the listing calls them done rather than "Paused" or "Waiting".
24
+ export function uploadedAndSettled(db) {
25
+ return new Set(db
26
+ .prepare(`SELECT u.project_id FROM project_uploads u WHERE NOT EXISTS(
27
+ SELECT 1 FROM revision_work w WHERE w.project_id=u.project_id
28
+ AND w.state IN ('pending','running') AND w.dispatch_state!='held')`)
29
+ .all()
30
+ .map((row) => String(row.project_id)));
31
+ }
21
32
  // "Keep as is": a waiting run the person leaves as it is, until the project's next edit. Set on
22
33
  // the current head revision; the listing counts it only while that revision is still the head.
23
34
  export function setAside(db, projectId, aside, at) {