@gentbajko/slopify 2.0.1 → 2.0.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.
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { createReadStream, createWriteStream } from "node:fs";
3
- import { mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
3
+ import { chmod, copyFile, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { Readable } from "node:stream";
6
6
  import { pipeline } from "node:stream/promises";
@@ -18,6 +18,8 @@ export async function prepareModel(input, deps = { model: alignmentModel, fetch:
18
18
  const target = join(input.cacheDir, deps.model.filename);
19
19
  if (await verified(target, deps.model, input.signal))
20
20
  return target;
21
+ if (input.seed !== undefined && (await verified(input.seed, deps.model, input.signal)))
22
+ return seedModel(input, input.seed, target);
21
23
  let lastFailure;
22
24
  for (let attempt = 1; attempt <= 3; attempt += 1) {
23
25
  try {
@@ -127,6 +129,20 @@ async function downloadModel(input, deps, target) {
127
129
  await rm(staging, { recursive: true, force: true });
128
130
  }
129
131
  }
132
+ async function seedModel(input, seed, target) {
133
+ const staging = await mkdtemp(join(input.cacheDir, ".alignment-seed-"));
134
+ try {
135
+ const part = join(staging, "model.part");
136
+ await copyFile(seed, part);
137
+ await chmod(part, 0o600);
138
+ input.signal.throwIfAborted();
139
+ await rename(part, target);
140
+ return target;
141
+ }
142
+ finally {
143
+ await rm(staging, { recursive: true, force: true });
144
+ }
145
+ }
130
146
  function interrupted(cause) {
131
147
  const error = new Error("The subtitle model download was interrupted.", { cause });
132
148
  error.name = "SubtitleModelDownloadInterrupted";
@@ -0,0 +1,35 @@
1
+ import { mkdir, readFile, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { prepareModel } from "./cache.js";
4
+ import { claimWorker } from "./lock.js";
5
+ // Readies the subtitle model when the app starts, so the first captioned render never waits
6
+ // on a 95 MB download in the middle of a run. It takes the worker lock, so a render that
7
+ // starts meanwhile waits for this copy rather than fetching a second one.
8
+ export async function prefetchModel(input, deps) {
9
+ await mkdir(input.cacheDir, { recursive: true, mode: 0o700 });
10
+ await clearOwnLock(input.cacheDir);
11
+ const release = await claimWorker(input.cacheDir, input.signal);
12
+ try {
13
+ return await prepareModel({ cacheDir: input.cacheDir, seed: input.seed, signal: input.signal }, deps);
14
+ }
15
+ finally {
16
+ await release();
17
+ }
18
+ }
19
+ // The lock names its holder by pid. A lock that names this process when the app has only
20
+ // just started was left by an earlier run: in a container the app is always pid 1, so the
21
+ // liveness check in claimWorker would take it for a live holder and wait forever.
22
+ async function clearOwnLock(cacheDir) {
23
+ const path = join(cacheDir, "alignment-worker.lock");
24
+ let owner;
25
+ try {
26
+ owner = await readFile(path, "utf8");
27
+ }
28
+ catch (error) {
29
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
30
+ return;
31
+ throw error;
32
+ }
33
+ if (Number(owner) === process.pid)
34
+ await rm(path, { force: true });
35
+ }
package/dist/edge/cli.js CHANGED
@@ -80,7 +80,10 @@ try {
80
80
  const forwarded = await forwardManagedUpdate(config.dataDir, readVersion(), process.argv.slice(2));
81
81
  if (forwarded !== undefined)
82
82
  process.exit(forwarded);
83
- const { paths, url, stop } = await boot(config);
83
+ const { paths, url, stop } = await boot(config, {
84
+ prefetchSubtitleModel: ["", "0", "false"].includes((process.env.SLOPIFY_NO_MODEL_PREFETCH ?? "").trim().toLowerCase()),
85
+ subtitleModelSeed: process.env.SLOPIFY_SUBTITLE_MODEL_SEED?.trim() || undefined,
86
+ });
84
87
  console.log(`Slopify is running at ${url}`);
85
88
  console.log(`Slopify data directory: ${paths.dataDir}`);
86
89
  console.log(`Database: ${paths.db}`);
@@ -0,0 +1,12 @@
1
+ // A failure's own sentence and every cause it wraps, for the log. The page shows only the
2
+ // sentence; the chain is the only place a "download interrupted" says the server closed the
3
+ // stream.
4
+ export function causedBy(error) {
5
+ const chain = [error instanceof Error ? error.message : String(error)];
6
+ let cause = error instanceof Error ? error.cause : undefined;
7
+ while (cause !== undefined && cause !== null && chain.length < 6) {
8
+ chain.push(cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause));
9
+ cause = cause instanceof Error ? cause.cause : undefined;
10
+ }
11
+ return chain.join(" <- ");
12
+ }
@@ -19,3 +19,8 @@ export function ensureDirs(paths, options) {
19
19
  chmodSync(dir, options.mode);
20
20
  }
21
21
  }
22
+ // The subtitle-timing model's cache. The app readies it at start and every captioned render
23
+ // reads it, so both name it through here.
24
+ export function subtitleModelDir(dataDir) {
25
+ return join(dataDir, "models", "english-subtitles");
26
+ }
@@ -1,4 +1,6 @@
1
+ import { causedBy } from "../errors.js";
1
2
  import { derive, deps as graph, satisfied } from "./graph.js";
3
+ import { progressGate } from "./progress.js";
2
4
  const pauseReason = new Error("paused by user");
3
5
  export function createRunner(deps) {
4
6
  const inflight = new Map();
@@ -60,6 +62,12 @@ export function createRunner(deps) {
60
62
  emitStage(stage, state, reason);
61
63
  };
62
64
  async function execute(stage, controller) {
65
+ const tag = (event) => ({
66
+ ...event,
67
+ revisionId: stage.work.revisionId,
68
+ workId: stage.work.workId,
69
+ });
70
+ const progress = progressGate((event) => deps.emit(stage.projectId, tag(event)));
63
71
  try {
64
72
  const run = deps.runs[stage.kind];
65
73
  if (run === undefined) {
@@ -76,16 +84,18 @@ export function createRunner(deps) {
76
84
  deps.stages.maySubmit(stage.work, pieceId),
77
85
  signal: controller.signal,
78
86
  emit: (event) => {
79
- deps.emit(stage.projectId, {
80
- ...event,
81
- revisionId: stage.work.revisionId,
82
- workId: stage.work.workId,
83
- });
87
+ if (event.type === "stage.progress")
88
+ progress.offer(event);
89
+ else
90
+ deps.emit(stage.projectId, tag(event));
84
91
  },
85
92
  });
93
+ progress.flush();
94
+ progress.close();
86
95
  conclude(stage, result === "held" ? "pending" : "done", null);
87
96
  }
88
97
  catch (error) {
98
+ progress.close();
89
99
  if (controller.signal.aborted) {
90
100
  // A late rejection from the aborted call is how a stage learns it was canceled, so
91
101
  // it is not logged as a fault.
@@ -96,7 +106,7 @@ export function createRunner(deps) {
96
106
  deps.log.write("error", "stage.failed", {
97
107
  projectId: stage.projectId,
98
108
  stage: stage.kind,
99
- detail: reasonOf(error),
109
+ detail: causedBy(error),
100
110
  });
101
111
  conclude(stage, "failed", reasonOf(error));
102
112
  }
@@ -0,0 +1,56 @@
1
+ // The most progress a single stage invocation reports per second. A stage may call emit on
2
+ // every chunk it reads (a 95 MB download is ~6,000 of them) and each report writes the
3
+ // database and reaches every open page; nobody can read a meter faster than this.
4
+ export const progressIntervalMs = 500;
5
+ // Repeats of the last value are dropped. The first report of a window goes out at once and
6
+ // the newest one offered inside it goes out when the window ends, so a meter never sits on
7
+ // a stale value longer than one interval.
8
+ export function progressGate(send, intervalMs = progressIntervalMs) {
9
+ let last;
10
+ let held;
11
+ let timer;
12
+ let closed = false;
13
+ const same = (a, b) => a !== undefined && a.current === b.current && a.total === b.total;
14
+ const deliver = (event) => {
15
+ last = event;
16
+ send(event);
17
+ timer = setTimeout(() => {
18
+ timer = undefined;
19
+ const next = held;
20
+ held = undefined;
21
+ if (next !== undefined && !closed && !same(last, next))
22
+ deliver(next);
23
+ }, intervalMs);
24
+ timer.unref?.();
25
+ };
26
+ return {
27
+ offer: (event) => {
28
+ if (closed)
29
+ return;
30
+ if (timer !== undefined) {
31
+ held = event;
32
+ return;
33
+ }
34
+ if (!same(last, event))
35
+ deliver(event);
36
+ },
37
+ flush: () => {
38
+ if (timer !== undefined)
39
+ clearTimeout(timer);
40
+ timer = undefined;
41
+ const next = held;
42
+ held = undefined;
43
+ if (next !== undefined && !closed && !same(last, next)) {
44
+ last = next;
45
+ send(next);
46
+ }
47
+ },
48
+ close: () => {
49
+ closed = true;
50
+ held = undefined;
51
+ if (timer !== undefined)
52
+ clearTimeout(timer);
53
+ timer = undefined;
54
+ },
55
+ };
56
+ }
package/dist/main.js CHANGED
@@ -5,6 +5,7 @@ import { serve } from "@hono/node-server";
5
5
  import ffmpegStatic from "ffmpeg-static";
6
6
  import { buildRegistry } from "./adapter-registry.js";
7
7
  import { alignSubtitles } from "./adapters/alignment/index.js";
8
+ import { prefetchModel } from "./adapters/alignment/prefetch.js";
8
9
  import { prepareFfmpeg } from "./adapters/ffmpeg.js";
9
10
  import { createHostCliClient } from "./adapters/host-cli/index.js";
10
11
  import { nodeRunCli } from "./adapters/llm/run-cli.js";
@@ -21,10 +22,11 @@ import { systemClock } from "./kernel/clock.js";
21
22
  import { openDb } from "./kernel/db/index.js";
22
23
  import { migrate } from "./kernel/db/migrate.js";
23
24
  import { transact } from "./kernel/db/tx.js";
25
+ import { causedBy } from "./kernel/errors.js";
24
26
  import { ulidIds } from "./kernel/ids.js";
25
27
  import { acquireInstanceLock } from "./kernel/lock.js";
26
28
  import { openLog } from "./kernel/log.js";
27
- import { ensureDirs, layout } from "./kernel/paths.js";
29
+ import { ensureDirs, layout, subtitleModelDir } from "./kernel/paths.js";
28
30
  import { sqliteAttempts } from "./kernel/runner/attempt-repo.js";
29
31
  import { createCheckpointAuthority, } from "./kernel/runner/checkpoint-authority.js";
30
32
  import { createRunner } from "./kernel/runner/index.js";
@@ -109,7 +111,7 @@ export function createScheduleTickLifecycle(deps) {
109
111
  },
110
112
  };
111
113
  }
112
- export async function boot(config) {
114
+ export async function boot(config, options = {}) {
113
115
  const clock = systemClock;
114
116
  const ids = ulidIds;
115
117
  const paths = layout(config.dataDir);
@@ -364,6 +366,18 @@ export async function boot(config) {
364
366
  // Whatever last run left queued goes out at start. Nothing waits for
365
367
  // it, and an unreachable collector costs one refused socket.
366
368
  flusher.soon();
369
+ const modelPrefetch = new AbortController();
370
+ const prefetching = options.prefetchSubtitleModel
371
+ ? prefetchModel({
372
+ cacheDir: subtitleModelDir(paths.dataDir),
373
+ seed: options.subtitleModelSeed,
374
+ signal: modelPrefetch.signal,
375
+ }).then(() => log.write("info", "subtitle-model.ready"), (error) => {
376
+ // Not fatal: the first captioned render tries again and says why on its row.
377
+ if (!modelPrefetch.signal.aborted)
378
+ log.write("warn", "subtitle-model.prefetch", { detail: causedBy(error) });
379
+ })
380
+ : Promise.resolve();
367
381
  const open = db;
368
382
  let stopping;
369
383
  let stopActivation = () => { };
@@ -376,6 +390,7 @@ export async function boot(config) {
376
390
  const serverClose = beginServerClose(server);
377
391
  stopActivation();
378
392
  audioPreviews.close();
393
+ modelPrefetch.abort();
379
394
  try {
380
395
  // Admission closes before the listener. Requests already admitted keep the
381
396
  // database until their response settles; anything racing shutdown receives 503.
@@ -394,6 +409,7 @@ export async function boot(config) {
394
409
  // closes and fail to mark its batch delivered, which costs one re-send that
395
410
  // the collector deduplicates by event id.
396
411
  flusher.stop();
412
+ await prefetching;
397
413
  log.write("info", "shutdown");
398
414
  open.close();
399
415
  lock.release();
@@ -94,12 +94,12 @@ export function invocationReady(deps, work) {
94
94
  });
95
95
  });
96
96
  }
97
- export function executionStandings(deps, projectId) {
97
+ export function executionStandings(deps, projectId, derived) {
98
98
  const revisionId = currentRevisionId(deps.db, projectId);
99
99
  const view = revisionId === undefined ? undefined : getRevisionView(deps, projectId, revisionId);
100
100
  if (view === undefined)
101
101
  return [];
102
- const work = executionStages(deps, projectId);
102
+ const work = derived ?? executionStages(deps, projectId);
103
103
  return stageKinds.map((kind) => {
104
104
  const source = view.revision.config.sources[kind];
105
105
  const group = work
@@ -136,20 +136,18 @@ export function executionStandings(deps, projectId) {
136
136
  });
137
137
  }
138
138
  export function projectStandings(deps, projectId) {
139
- const standings = executionStandings(deps, projectId);
139
+ // One derivation serves every stage: each builds the whole execution plan.
140
+ const work = executionStages(deps, projectId);
141
+ const standings = executionStandings(deps, projectId, work);
140
142
  for (const stage of standings) {
141
- const invocations = executionStages(deps, projectId).filter((work) => work.kind === stage.kind);
142
- const current = invocations.reduce((total, work) => {
143
- if (work.state === "done")
143
+ const invocations = work.filter((entry) => entry.kind === stage.kind);
144
+ const current = invocations.reduce((total, entry) => {
145
+ if (entry.state === "done")
144
146
  return total + 1;
145
147
  const progress = deps.db
146
148
  .prepare("SELECT progress_current,progress_total FROM revision_work WHERE id=?")
147
- .get(work.work.workId);
148
- if (typeof progress?.progress_current !== "number" ||
149
- typeof progress.progress_total !== "number" ||
150
- progress.progress_total <= 0)
151
- return total;
152
- return total + Math.max(0, Math.min(1, progress.progress_current / progress.progress_total));
149
+ .get(entry.work.workId);
150
+ return total + fraction(progress?.progress_current, progress?.progress_total);
153
151
  }, 0);
154
152
  const failed = invocations.find((work) => work.state === "failed");
155
153
  const failure = failed === undefined
@@ -163,6 +161,11 @@ export function projectStandings(deps, projectId) {
163
161
  }
164
162
  settleScheduleRunsForProject(deps.db, projectId, deps.clock.now().toISOString());
165
163
  }
164
+ // Progress moves one number and nothing else. The stage row already counts this invocation's
165
+ // last fraction, from the claim or the previous event, so it takes the difference instead
166
+ // of re-deriving the project: that derivation rebuilds every stage's plan and ran to seconds
167
+ // on a long article, which froze the process once per downloaded chunk. State changes still
168
+ // go through projectStandings.
166
169
  export function recordWorkProgress(deps, event) {
167
170
  if (event.workId === undefined ||
168
171
  event.revisionId === undefined ||
@@ -170,8 +173,35 @@ export function recordWorkProgress(deps, event) {
170
173
  !Number.isFinite(event.total) ||
171
174
  event.total <= 0)
172
175
  return;
176
+ const before = deps.db
177
+ .prepare("SELECT progress_current,progress_total FROM revision_work WHERE id=? AND revision_id=? AND project_id=? AND kind=? AND state='running'")
178
+ .get(event.workId, event.revisionId, event.projectId, event.stage);
179
+ if (before === undefined)
180
+ return;
181
+ const current = Math.max(0, Math.min(event.current, event.total));
173
182
  deps.db
174
- .prepare("UPDATE revision_work SET progress_current=?,progress_total=? WHERE id=? AND revision_id=? AND project_id=? AND kind=? AND state='running'")
175
- .run(Math.max(0, Math.min(event.current, event.total)), event.total, event.workId, event.revisionId, event.projectId, event.stage);
176
- projectStandings(deps, event.projectId);
183
+ .prepare("UPDATE revision_work SET progress_current=?,progress_total=? WHERE id=?")
184
+ .run(current, event.total, event.workId);
185
+ // Work an edit retired keeps running until it notices, but the stage row belongs to the
186
+ // current revision's work and must not move with it.
187
+ const head = currentRevisionId(deps.db, event.projectId);
188
+ if (head === undefined ||
189
+ deps.db
190
+ .prepare("SELECT 1 FROM revision_work_reservations WHERE work_id=? AND revision_id=? LIMIT 1")
191
+ .get(event.workId, head) === undefined)
192
+ return;
193
+ const delta = current / event.total - fraction(before.progress_current, before.progress_total);
194
+ if (delta === 0)
195
+ return;
196
+ const moved = deps.db
197
+ .prepare("UPDATE stages SET progress_current=MIN(progress_total,MAX(0,progress_current+?)) WHERE project_id=? AND kind=? AND progress_current IS NOT NULL AND progress_total IS NOT NULL")
198
+ .run(delta, event.projectId, event.stage);
199
+ // A row no derivation has counted yet has nothing to adjust: derive it once.
200
+ if (moved.changes === 0)
201
+ projectStandings(deps, event.projectId);
202
+ }
203
+ function fraction(current, total) {
204
+ if (typeof current !== "number" || typeof total !== "number" || total <= 0)
205
+ return 0;
206
+ return Math.max(0, Math.min(1, current / total));
177
207
  }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
2
  import { z } from "zod";
3
+ import { subtitleModelDir } from "../../kernel/paths.js";
4
4
  import { resolveFont } from "../fonts/index.js";
5
5
  import { validateCues } from "../revisions/rules.js";
6
6
  import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
@@ -59,7 +59,7 @@ async function timing(deps, context, piece, snapshot) {
59
59
  const aligned = await deps.alignSubtitles({
60
60
  audioPath: segment.path,
61
61
  text: revisionTranscript(deps, snapshot, segment.kind),
62
- cacheDir: join(deps.paths.dataDir, "models", "english-subtitles"),
62
+ cacheDir: subtitleModelDir(deps.paths.dataDir),
63
63
  ffmpeg: deps.ffmpeg,
64
64
  signal: context.signal,
65
65
  onOmission: (value) => omissions.push({ ...value, start: value.start + offset }),
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { copyFileSync, createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import { join, relative, sep } from "node:path";
4
4
  import { z } from "zod";
5
+ import { subtitleModelDir } from "../../kernel/paths.js";
5
6
  import { projectById, setStageProgress } from "../admission/repo.js";
6
7
  import { resolveFont } from "../fonts/index.js";
7
8
  import { outputPath, projectDir } from "../storage/layout.js";
@@ -128,7 +129,7 @@ async function alignSegments(deps, context, segments, omissions) {
128
129
  audioPath: segment.path,
129
130
  onOmission: (omission) => omissions.push({ ...omission, start: omission.start + offset }),
130
131
  text: segment.text,
131
- cacheDir: join(deps.paths.dataDir, "models", "english-subtitles"),
132
+ cacheDir: subtitleModelDir(deps.paths.dataDir),
132
133
  ffmpeg: deps.ffmpeg,
133
134
  signal: context.signal,
134
135
  onProgress: (current, maximum) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gentbajko/slopify",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Turn a prompt into a finished video, locally, with your own provider keys",
5
5
  "license": "MIT",
6
6
  "author": "Gent Bajko",