videobook 0.1.0 → 0.2.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,200 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ export const OP_VAULT_SETTING = "VIDEOBOOK_OP_VAULT";
7
+ export const OP_TOKEN_SETTING = "VIDEOBOOK_OP_SERVICE_TOKEN";
8
+ const OP_TIMEOUT_MS = 10_000;
9
+ const TEMPLATE_UNSAFE = /[\r\n]|\{\{|\}\}/u;
10
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
11
+ export function isOpReference(value) {
12
+ return typeof value === "string" && value.trim().startsWith("op://");
13
+ }
14
+ export function vaultReference(vault, key) {
15
+ return `op://${vault}/${key}/password`;
16
+ }
17
+ export function resolveServiceToken(raw, env) {
18
+ const value = raw?.trim();
19
+ if (!value)
20
+ return { value: null, source: null };
21
+ const name = value.startsWith("env:") ? value.slice(4) : value.startsWith("$") ? value.slice(1) : value;
22
+ if (value !== name || /^[A-Z_][A-Z0-9_]*$/u.test(value)) {
23
+ if (!ENV_NAME.test(name))
24
+ return { value: null, source: `env:${name}` };
25
+ return { value: env[name]?.trim() || null, source: `env:${name}` };
26
+ }
27
+ return { value, source: "literal" };
28
+ }
29
+ export function readOnePasswordSettings(env) {
30
+ return {
31
+ vault: env[OP_VAULT_SETTING]?.trim() || null,
32
+ token: resolveServiceToken(env[OP_TOKEN_SETTING], env),
33
+ };
34
+ }
35
+ export function redactToken(text, token) {
36
+ return token ? text.split(token).join("***") : text;
37
+ }
38
+ export function planReferences(env, managedKeys, vault, vaultTitles) {
39
+ const explicit = Object.entries(env)
40
+ .filter((entry) => isOpReference(entry[1]))
41
+ .map(([key, reference]) => [key, reference.trim()]);
42
+ const covered = new Set(explicit.map(([key]) => key));
43
+ const convention = vault
44
+ ? managedKeys
45
+ .filter((key) => !covered.has(key) && !env[key]?.trim() && vaultTitles.has(key))
46
+ .map((key) => [key, vaultReference(vault, key)])
47
+ : [];
48
+ return { explicit, convention };
49
+ }
50
+ function startMarker(nonce, key) {
51
+ return `<<<${nonce}:${key}>>>`;
52
+ }
53
+ function endMarker(nonce, key) {
54
+ return `<<<${nonce}/${key}>>>`;
55
+ }
56
+ export function injectionTemplate(entries, nonce) {
57
+ return entries.map(([key, reference]) => `${startMarker(nonce, key)}{{ ${reference} }}${endMarker(nonce, key)}`).join("\n");
58
+ }
59
+ export function parseInjection(output, keys, nonce) {
60
+ const values = new Map();
61
+ for (const key of keys) {
62
+ const start = output.indexOf(startMarker(nonce, key));
63
+ if (start < 0)
64
+ continue;
65
+ const from = start + startMarker(nonce, key).length;
66
+ const end = output.indexOf(endMarker(nonce, key), from);
67
+ if (end < 0)
68
+ continue;
69
+ const value = output.slice(from, end);
70
+ if (value !== "")
71
+ values.set(key, value);
72
+ }
73
+ return values;
74
+ }
75
+ function opEnvironment(env, token) {
76
+ return token ? { ...env, OP_SERVICE_ACCOUNT_TOKEN: token } : env;
77
+ }
78
+ export function opErrorMessage(error, token) {
79
+ const failure = error;
80
+ if (failure.code === "ENOENT")
81
+ return "the 1Password CLI (op) is not on PATH";
82
+ if (failure.code === "ETIMEDOUT" || failure.signal === "SIGTERM")
83
+ return "op timed out";
84
+ const line = String(failure.stderr ?? "").trim().split("\n")[0]?.trim().replace(/^\[ERROR\]\s+\S+\s+\S+\s+/u, "");
85
+ return redactToken(line || failure.message || "op failed", token);
86
+ }
87
+ function runOp(args, env, token) {
88
+ return execFileSync("op", args, {
89
+ env: opEnvironment(env, token),
90
+ encoding: "utf8",
91
+ timeout: OP_TIMEOUT_MS,
92
+ maxBuffer: 8 * 1024 * 1024,
93
+ stdio: ["ignore", "pipe", "pipe"],
94
+ });
95
+ }
96
+ export function listVaultItemTitles(vault, env, token) {
97
+ const parsed = JSON.parse(runOp(["item", "list", "--vault", vault, "--format", "json"], env, token));
98
+ if (!Array.isArray(parsed))
99
+ throw new Error("op item list did not return a list");
100
+ return new Set(parsed.flatMap((item) => {
101
+ const title = item && typeof item === "object" ? item.title : undefined;
102
+ return typeof title === "string" ? [title] : [];
103
+ }));
104
+ }
105
+ function injectBatch(entries, env, token) {
106
+ const nonce = randomUUID().replaceAll("-", "");
107
+ const directory = mkdtempSync(join(tmpdir(), "videobook-op-"));
108
+ try {
109
+ const template = join(directory, "template");
110
+ writeFileSync(template, injectionTemplate(entries, nonce), { mode: 0o600 });
111
+ const output = runOp(["inject", "--in-file", template], env, token);
112
+ return parseInjection(output, entries.map(([key]) => key), nonce);
113
+ }
114
+ finally {
115
+ rmSync(directory, { recursive: true, force: true });
116
+ }
117
+ }
118
+ function readOne(key, reference, env, token) {
119
+ try {
120
+ const value = runOp(["read", "--no-newline", reference], env, token);
121
+ return value === ""
122
+ ? { values: new Map(), failures: [{ key, reference, reason: "the item has no value" }] }
123
+ : { values: new Map([[key, value]]), failures: [] };
124
+ }
125
+ catch (error) {
126
+ return { values: new Map(), failures: [{ key, reference, reason: opErrorMessage(error, token) }] };
127
+ }
128
+ }
129
+ function mergeResults(results) {
130
+ return {
131
+ values: new Map(results.flatMap((result) => [...result.values])),
132
+ failures: results.flatMap((result) => result.failures),
133
+ };
134
+ }
135
+ function resolveEntries(entries, env, token) {
136
+ const unsafe = entries.filter(([, reference]) => TEMPLATE_UNSAFE.test(reference));
137
+ const safe = entries.filter(([, reference]) => !TEMPLATE_UNSAFE.test(reference));
138
+ const refused = unsafe.map(([key, reference]) => ({ key, reference, reason: "malformed op:// reference" }));
139
+ if (safe.length === 0)
140
+ return { values: new Map(), failures: refused };
141
+ try {
142
+ const values = injectBatch(safe, env, token);
143
+ const missing = safe.filter(([key]) => !values.has(key)).map(([key, reference]) => ({ key, reference, reason: "op inject returned no value" }));
144
+ return { values, failures: [...refused, ...missing] };
145
+ }
146
+ catch {
147
+ const individual = mergeResults(safe.map(([key, reference]) => readOne(key, reference, env, token)));
148
+ return { values: individual.values, failures: [...refused, ...individual.failures] };
149
+ }
150
+ }
151
+ function vaultTitles(settings, env) {
152
+ if (!settings.vault)
153
+ return { titles: new Set(), error: null };
154
+ try {
155
+ return { titles: listVaultItemTitles(settings.vault, env, settings.token.value), error: null };
156
+ }
157
+ catch (error) {
158
+ return { titles: new Set(), error: `could not list vault ${settings.vault}: ${opErrorMessage(error, settings.token.value)}` };
159
+ }
160
+ }
161
+ function emptyResolution(settings) {
162
+ return {
163
+ attempted: false,
164
+ vault: settings.vault,
165
+ tokenSource: settings.token.source,
166
+ fromReferences: [],
167
+ fromVault: [],
168
+ unresolved: [],
169
+ error: null,
170
+ };
171
+ }
172
+ export function resolveOnePasswordEnv(env, managedKeys) {
173
+ const settings = readOnePasswordSettings(env);
174
+ const hasReferences = Object.values(env).some((value) => isOpReference(value));
175
+ if (!hasReferences && !settings.vault)
176
+ return emptyResolution(settings);
177
+ const listing = vaultTitles(settings, env);
178
+ const plan = planReferences(env, managedKeys, settings.vault, listing.titles);
179
+ const result = resolveEntries([...plan.explicit, ...plan.convention], env, settings.token.value);
180
+ for (const [key] of plan.explicit) {
181
+ const value = result.values.get(key);
182
+ if (value === undefined)
183
+ delete env[key];
184
+ else
185
+ env[key] = value;
186
+ }
187
+ for (const [key] of plan.convention) {
188
+ const value = result.values.get(key);
189
+ if (value !== undefined)
190
+ env[key] = value;
191
+ }
192
+ return {
193
+ ...emptyResolution(settings),
194
+ attempted: true,
195
+ fromReferences: plan.explicit.map(([key]) => key).filter((key) => result.values.has(key)),
196
+ fromVault: plan.convention.map(([key]) => key).filter((key) => result.values.has(key)),
197
+ unresolved: result.failures,
198
+ error: listing.error,
199
+ };
200
+ }
package/dist/queue.js CHANGED
@@ -517,6 +517,10 @@ export function enqueueOptions(jobId, payload) {
517
517
  artifactWorkOrientation: classified?.orientation ?? null,
518
518
  };
519
519
  }
520
+ export function jobIdForKey(jobKey, bookId) {
521
+ const entry = jobIdIndex.get(jobKey);
522
+ return entry && entry.bookId === bookId ? entry.internalId : null;
523
+ }
520
524
  function rememberJob(jobId, entry) {
521
525
  jobIdIndex.set(jobId, entry);
522
526
  while (jobIdIndex.size > JOB_INDEX_MAX) {
package/dist/serve.js CHANGED
@@ -3,45 +3,19 @@ import { setTimeout as delay } from "node:timers/promises";
3
3
  import { stopAceServer } from "./acestep/process.js";
4
4
  import { createApp } from "./app.js";
5
5
  import { bookManager, scopedEngine } from "./engine.js";
6
+ import { getDefaultSecretStore } from "./env-secrets.js";
7
+ import { hydrateLibrary } from "./hydration.js";
6
8
  import { registerAllJobHandlers } from "./jobs/index.js";
7
9
  import { logger } from "./logger.js";
8
- import { startPostMetricsScheduler, stopPostMetricsScheduler } from "./posts/post-metrics.js";
10
+ import { stopPostMetricsScheduler } from "./posts/post-metrics.js";
9
11
  import { queue } from "./queue.js";
10
- import { loadAndArmAll, stopScheduler } from "./scheduled-posts.js";
12
+ import { stopScheduler } from "./scheduled-posts.js";
11
13
  import { SERVER_VERSION } from "./server.js";
12
14
  import { ServerAlreadyRunningError, acquireServerLock, inspectServerOwner, } from "./server-lock.js";
13
15
  import { attachVoiceAudio } from "./voice-audio.js";
14
16
  const LOOPBACK_HOST = "127.0.0.1";
15
17
  const SHUTDOWN_WATCHDOG_MS = 20_000;
16
18
  const HTTP_CLOSE_GRACE_MS = 2_000;
17
- function openBooks(books) {
18
- return books.list().flatMap((book) => {
19
- const selected = books.get(book.bookId);
20
- return selected.ok ? [selected.value] : [];
21
- });
22
- }
23
- async function hydrateLibrary(books, options) {
24
- await books.ready();
25
- if (options.devFixtures) {
26
- const { ensureDancingWorkflow } = await import("./dancing-workflow.js");
27
- await ensureDancingWorkflow(books);
28
- }
29
- const imageSubjects = await import("./jobs/image-subjects.js");
30
- const runNotebook = await import("./jobs/run-notebook.js");
31
- for (const book of openBooks(books)) {
32
- await book.engine.jobs.queue.reap();
33
- await imageSubjects.reconcileImageSubjectJobFailures(book.engine, book.bookId);
34
- await runNotebook.abortLeasedNotebookRunsOnStartup(book.engine);
35
- await runNotebook.reconcileGenerationMediaOutputs(book.engine);
36
- await runNotebook.reconcileQueuedNotebookCells(book.engine);
37
- }
38
- await queue.start();
39
- for (const book of openBooks(books)) {
40
- await runNotebook.reconcileAwaitingNotebookOutputs(book.engine, book.bookId);
41
- }
42
- await loadAndArmAll();
43
- startPostMetricsScheduler();
44
- }
45
19
  async function shutdownServices(services) {
46
20
  stopScheduler();
47
21
  stopPostMetricsScheduler();
@@ -110,6 +84,7 @@ async function claimLibrary(server, libraryDir, port, url) {
110
84
  export async function startServer(options) {
111
85
  const libraryDir = bookManager.rootDir;
112
86
  await refuseRunningOwner(libraryDir);
87
+ getDefaultSecretStore().resolveOnePassword();
113
88
  registerAllJobHandlers(scopedEngine);
114
89
  const services = createApp({ ui: options.ui, uiDir: options.uiDir });
115
90
  const server = services.app.listen(options.port, LOOPBACK_HOST);
@@ -6,7 +6,7 @@ import { z } from "zod";
6
6
  import { v7 as uuidv7 } from "uuid";
7
7
  import { ensureGenerateAudioOutput, normalizeLegacyExtractCells, } from "../notebook-cell-outputs.js";
8
8
  import { mutateNotebook, withNotebookMutation } from "../notebook-mutations.js";
9
- import { queue, stopBookQueue } from "../queue.js";
9
+ import { jobIdForKey, queue, stopBookQueue } from "../queue.js";
10
10
  import { claimNotebookGenerationPlan, failQueuedNotebookCells, generationPlanClaimOutputCellId, notebookCellDefinitionFingerprint, notebookCellIsPaid, notebookCellIsRunnable, notebookCellMediaType, markNotebookCellQueued, prepareNotebookGeneration, } from "../jobs/run-notebook.js";
11
11
  import { buildGenerationInsertPlan, continueQueuedInsert, } from "./command-plan.js";
12
12
  import { assessToolCallEstimate, logUnavailableCostEstimate, } from "../jobs/cost-estimate-assessment.js";
@@ -271,6 +271,24 @@ function pinnedResultFromInput(input) {
271
271
  createdAt: input.created_at,
272
272
  };
273
273
  }
274
+ function jobsByIdOrKey(engine, bookId, ids, keys) {
275
+ const byId = ids.map((id) => ({ id, jobKey: null }));
276
+ const byKey = keys.flatMap((jobKey) => {
277
+ const id = jobIdForKey(jobKey, bookId);
278
+ return id === null ? [] : [{ id, jobKey }];
279
+ });
280
+ return [...byId, ...byKey].flatMap(({ id, jobKey }) => {
281
+ const job = engine.jobs.queue.get(id);
282
+ return job ? [{ job, jobKey }] : [];
283
+ });
284
+ }
285
+ function selectJobs(engine, args) {
286
+ const ids = Array.isArray(args.job_ids) ? args.job_ids.map(Number) : null;
287
+ const keys = Array.isArray(args.job_keys) ? args.job_keys.map(String) : null;
288
+ if (!ids && !keys)
289
+ return engine.jobs.queue.list({ limit: Number(args.limit) || 200 }).map((job) => ({ job, jobKey: null }));
290
+ return jobsByIdOrKey(engine, String(args.book_id), ids ?? [], keys ?? []);
291
+ }
274
292
  function json(value) {
275
293
  return { content: [{ type: "text", text: JSON.stringify(value) }] };
276
294
  }
@@ -1898,21 +1916,18 @@ export function videobookDomainDefs(engine, books) {
1898
1916
  },
1899
1917
  {
1900
1918
  name: "list_jobs",
1901
- description: "List persistent jobs for a book",
1919
+ description: "List persistent jobs for a book. Filter by numeric job_ids, or by job_keys, the job_id strings other tools return while the server that queued them is running.",
1902
1920
  scope: "book",
1903
1921
  schema: {
1904
1922
  ...bookSchema,
1905
1923
  limit: z.number().int().positive().max(500).optional(),
1906
1924
  job_ids: z.array(z.number().int().positive()).min(1).max(100).optional(),
1925
+ job_keys: z.array(z.string().min(1).max(500)).min(1).max(100).optional(),
1907
1926
  },
1908
1927
  handler: async (args) => {
1909
- const jobs = Array.isArray(args.job_ids) ? args.job_ids.flatMap((id) => {
1910
- const job = engine.jobs.queue.get(Number(id));
1911
- return job ? [job] : [];
1912
- })
1913
- : engine.jobs.queue.list({ limit: Number(args.limit) || 200 });
1914
- return json(await Promise.all(jobs.map(async (job) => ({
1915
- ...job, providerTask: await jobProviderTaskStatus(engine, job),
1928
+ const jobs = selectJobs(engine, args);
1929
+ return json(await Promise.all(jobs.map(async ({ job, jobKey }) => ({
1930
+ ...job, ...(jobKey ? { jobKey } : {}), providerTask: await jobProviderTaskStatus(engine, job),
1916
1931
  }))));
1917
1932
  },
1918
1933
  },
@@ -0,0 +1,65 @@
1
+ import { accessSync, constants, statSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { delimiter, isAbsolute, join } from "node:path";
4
+ import { FFMPEG } from "./ffmpeg-binary.js";
5
+ const YTDLP_OVERRIDE = "VIDEOBOOK_YTDLP_PATH";
6
+ const require = createRequire(import.meta.url);
7
+ export const YTDLP_MISSING_MESSAGE = "yt-dlp is not installed. Put yt-dlp on PATH (for example brew install yt-dlp), set VIDEOBOOK_YTDLP_PATH, "
8
+ + "or reinstall with npm install -g videobook --allow-scripts=ffmpeg-static,ytdlp-nodejs,@dolthub/doltlite,usearch,onnxruntime-node,esbuild";
9
+ function isExecutableFile(path) {
10
+ try {
11
+ if (!statSync(path).isFile())
12
+ return false;
13
+ accessSync(path, constants.X_OK);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ function executableNames(name, env) {
21
+ if (process.platform !== "win32")
22
+ return [name];
23
+ const extensions = (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
24
+ return [name, ...extensions.map((extension) => `${name}${extension.toLowerCase()}`)];
25
+ }
26
+ export function findOnPath(name, env = process.env) {
27
+ const directories = (env.PATH ?? "").split(delimiter).filter((directory) => isAbsolute(directory));
28
+ for (const directory of directories) {
29
+ const match = executableNames(name, env).map((candidate) => join(directory, candidate)).find(isExecutableFile);
30
+ if (match)
31
+ return match;
32
+ }
33
+ return null;
34
+ }
35
+ export function bundledYtdlp() {
36
+ try {
37
+ const { helpers } = require("ytdlp-nodejs");
38
+ const path = helpers.findYtdlpBinary();
39
+ return path && isExecutableFile(path) ? path : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ export function resolveYtdlp(env = process.env, bundled = bundledYtdlp()) {
46
+ const override = env[YTDLP_OVERRIDE]?.trim();
47
+ if (override)
48
+ return { path: override, source: "VIDEOBOOK_YTDLP_PATH" };
49
+ if (bundled)
50
+ return { path: bundled, source: "bundled" };
51
+ const onPath = findOnPath("yt-dlp", env);
52
+ return onPath ? { path: onPath, source: "PATH" } : null;
53
+ }
54
+ function ffmpegForYtdlp(env) {
55
+ if (isAbsolute(FFMPEG))
56
+ return isExecutableFile(FFMPEG) ? FFMPEG : null;
57
+ return findOnPath(FFMPEG, env);
58
+ }
59
+ export function ytdlpRunOptions(env = process.env, bundled = bundledYtdlp()) {
60
+ const binary = resolveYtdlp(env, bundled);
61
+ if (!binary)
62
+ throw new Error(YTDLP_MISSING_MESSAGE);
63
+ const ffmpeg = ffmpegForYtdlp(env);
64
+ return { binaryPath: binary.path, ...(ffmpeg ? { ffmpegPath: ffmpeg } : {}) };
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "videobook",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Local-first MCP server and web UI for AI video creation: notebooks, generation, editing, rendering and publishing.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -13,15 +13,7 @@
13
13
  "cli"
14
14
  ],
15
15
  "license": "MIT",
16
- "homepage": "https://github.com/justintanner/videobook#readme",
17
- "repository": {
18
- "type": "git",
19
- "url": "git+https://github.com/justintanner/videobook.git",
20
- "directory": "server"
21
- },
22
- "bugs": {
23
- "url": "https://github.com/justintanner/videobook/issues"
24
- },
16
+ "homepage": "https://www.npmjs.com/package/videobook",
25
17
  "type": "module",
26
18
  "bin": {
27
19
  "videobook": "bin/videobook.js"
@@ -57,6 +49,7 @@
57
49
  "@apicity/x": "^0.16.1",
58
50
  "@apicity/xai": "^0.16.1",
59
51
  "@devmehq/open-graph-extractor": "^1.1.1",
52
+ "@ffprobe-installer/ffprobe": "2.1.2",
60
53
  "@modelcontextprotocol/client": "^2.2.0",
61
54
  "@modelcontextprotocol/node": "2.0.0",
62
55
  "@modelcontextprotocol/server": "^2.0.0",
@@ -68,7 +61,6 @@
68
61
  "express": "^5.2.1",
69
62
  "ffmpeg-static": "^5.3.0",
70
63
  "ffmpeggy": "^3.1.3",
71
- "ffprobe-static": "^3.1.0",
72
64
  "heic-decode": "^2.1.0",
73
65
  "hono": "^4.12.31",
74
66
  "qrcode": "^1.5.4",