@gentbajko/slopify 0.4.2 → 0.4.3

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.
package/README.md CHANGED
@@ -35,6 +35,11 @@ Everything lives in one SQLite file and one directory tree under the data direct
35
35
  Slopify renders video with ffmpeg. The `ffmpeg-static` dependency downloads a
36
36
  prebuilt binary to `node_modules/ffmpeg-static/` at install time.
37
37
 
38
+ Slopify checks that ffmpeg runs before starting. If the install-time download is
39
+ missing, it downloads the same platform build into `<data-dir>/bin/`, keeping the
40
+ licence and source notice beside it. Later launches reuse that copy. If recovery
41
+ fails, check your connection and antivirus quarantine, or choose your own executable.
42
+
38
43
  That binary is a separate program, run as a child process with an argument array. It
39
44
  is licensed under the **GPL-3.0-or-later**. Slopify does not link against it, does not
40
45
  embed it, and does not distribute it inside this package; its licence text and the
@@ -44,6 +49,13 @@ GPL's obligations for it, including offering that corresponding source.
44
49
 
45
50
  Point `SLOPIFY_FFMPEG` at your own build to use that instead.
46
51
 
52
+ In Windows PowerShell:
53
+
54
+ ```powershell
55
+ $env:SLOPIFY_FFMPEG = 'C:\tools\ffmpeg\bin\ffmpeg.exe'
56
+ npx @gentbajko/slopify@latest
57
+ ```
58
+
47
59
  ## Telemetry
48
60
 
49
61
  Slopify sends anonymous counters to a collector: installs, projects created, stages
@@ -0,0 +1,78 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { z } from "zod";
7
+ import { redact } from "../kernel/log.js";
8
+ // ffmpeg-static exports a path even when npm skipped its install script. Recover using
9
+ // that dependency's own downloader, including its source notice and license, without
10
+ // writing into an npx cache or requiring dependency lifecycle scripts to be enabled.
11
+ export async function prepareFfmpeg(deps) {
12
+ const override = deps.env.SLOPIFY_FFMPEG?.trim() || deps.env.FFMPEG_BIN?.trim();
13
+ if (override) {
14
+ await verify(override);
15
+ return override;
16
+ }
17
+ if (typeof deps.bundled !== "string" || deps.bundled === "") {
18
+ throw new Error(`No ffmpeg build is available for this platform. ${remedy}`);
19
+ }
20
+ if (existsSync(deps.bundled)) {
21
+ await verify(deps.bundled);
22
+ return deps.bundled;
23
+ }
24
+ const require = createRequire(import.meta.url);
25
+ const version = deps.version ??
26
+ z.object({ version: z.string() }).parse(require("ffmpeg-static/package.json")).version;
27
+ const installer = deps.installer ?? require.resolve("ffmpeg-static/install.js");
28
+ const root = join(deps.dataDir, "bin");
29
+ const cached = join(root, `ffmpeg-static-${version}-${process.platform}-${process.arch}`);
30
+ const name = process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
31
+ const bin = join(cached, name);
32
+ if (existsSync(bin)) {
33
+ await verify(bin);
34
+ return bin;
35
+ }
36
+ deps.report?.("Slopify's ffmpeg download is missing. Downloading it before starting…");
37
+ mkdirSync(root, { recursive: true, mode: 0o700 });
38
+ const staging = mkdtempSync(join(root, ".ffmpeg-"));
39
+ try {
40
+ const target = join(staging, name);
41
+ await promisify(execFile)(process.execPath, [installer], {
42
+ env: { ...deps.env, FFMPEG_BIN: target },
43
+ windowsHide: true,
44
+ timeout: 180_000,
45
+ maxBuffer: 1024 * 1024,
46
+ });
47
+ await verify(target);
48
+ // Antivirus may remove just the cached executable, leaving its notices behind.
49
+ rmSync(cached, { recursive: true, force: true });
50
+ renameSync(staging, cached);
51
+ return bin;
52
+ }
53
+ catch (error) {
54
+ throw new Error(`Slopify could not download and start ffmpeg. Check your connection and antivirus quarantine, then restart Slopify. ${remedy} ${detail(error)}`);
55
+ }
56
+ finally {
57
+ // A failed or interrupted download must never be mistaken for an installed binary.
58
+ rmSync(staging, { recursive: true, force: true });
59
+ }
60
+ }
61
+ const remedy = "You can also set SLOPIFY_FFMPEG (or FFMPEG_BIN) to a working ffmpeg executable.";
62
+ async function verify(bin) {
63
+ try {
64
+ const { stdout } = await promisify(execFile)(bin, ["-version"], {
65
+ windowsHide: true,
66
+ timeout: 10_000,
67
+ maxBuffer: 1024 * 1024,
68
+ });
69
+ if (!stdout.startsWith("ffmpeg version "))
70
+ throw new Error("the executable did not identify itself as ffmpeg");
71
+ }
72
+ catch (error) {
73
+ throw new Error(`ffmpeg at ${bin} could not be started. ${remedy} ${detail(error)}`);
74
+ }
75
+ }
76
+ function detail(error) {
77
+ return redact(error instanceof Error ? error.message : String(error));
78
+ }
@@ -42,7 +42,13 @@ const errorBody = z.object({
42
42
  error: z.object({
43
43
  message: z.string().nullish(),
44
44
  status: z.string().nullish(),
45
- code: z.number().nullish(),
45
+ code: z.union([z.number(), z.string()]).nullish(),
46
+ details: z
47
+ .array(z.object({
48
+ "@type": z.string().optional(),
49
+ retryDelay: z.string().optional(),
50
+ }))
51
+ .nullish(),
46
52
  }),
47
53
  });
48
54
  // A content-policy refusal is the provider's final answer and is never retried. Google
@@ -129,6 +135,11 @@ function kindOf(status, named, message) {
129
135
  return "auth";
130
136
  }
131
137
  if (status === 429) {
138
+ // Interactions wraps quota failures in a string-coded error and exposes the limit
139
+ // in the message. Zero allocated capacity cannot be fixed by waiting and retrying.
140
+ if (/quota exceeded for metric:[^\r\n]*?\blimit:\s*0\s*(?:,|$)/im.test(message)) {
141
+ return "unsupported";
142
+ }
132
143
  return "rate_limit";
133
144
  }
134
145
  // ceiling: everything else is `other` and is retried, so a prompt past the model's limit
@@ -142,13 +153,28 @@ async function failure(response) {
142
153
  // The provider's own words, through the redactor - an error body may quote the key back.
143
154
  const named = parsed.success ? parsed.data.error.status : undefined;
144
155
  const message = redact((parsed.success ? parsed.data.error.message : undefined) ?? text.trim() ?? response.statusText);
145
- const retryAfterMs = retryAfter(response.headers.get("retry-after"));
156
+ const kind = kindOf(response.status, named, message);
157
+ const delay = parsed.success
158
+ ? parsed.data.error.details?.find((one) => one["@type"] === "type.googleapis.com/google.rpc.RetryInfo")?.retryDelay
159
+ : undefined;
160
+ const retryAfterMs = retryAfter(response.headers.get("retry-after")) ?? googleRetryDelay(delay, message);
161
+ const guidance = kind === "unsupported"
162
+ ? "Google reports zero image quota for this model. Check the API key's project, billing and model quota in Google AI Studio, or choose another image provider. Retrying will not help until quota is available. "
163
+ : "";
146
164
  return providerError({
147
- kind: kindOf(response.status, named, message),
148
- message: `Google answered ${String(response.status)}: ${message || response.statusText}`,
165
+ kind,
166
+ message: `${guidance}Google answered ${String(response.status)}: ${message || response.statusText}`,
149
167
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
150
168
  });
151
169
  }
170
+ function googleRetryDelay(delay, message) {
171
+ const seconds = delay?.match(/^(\d+(?:\.\d+)?)s$/)?.[1] ??
172
+ message.match(/\bPlease retry in (\d+(?:\.\d+)?)s\b/i)?.[1];
173
+ if (seconds === undefined)
174
+ return undefined;
175
+ const ms = Math.ceil(Number(seconds) * 1000);
176
+ return Number.isFinite(ms) ? ms : undefined;
177
+ }
152
178
  function parse(text) {
153
179
  const parsed = interaction.safeParse(safeJson(text));
154
180
  if (!parsed.success) {
@@ -18,6 +18,12 @@ export const claudeCodeModels = [
18
18
  { id: "sonnet", name: "Claude Sonnet (latest)" },
19
19
  { id: "haiku", name: "Claude Haiku (latest)" },
20
20
  ];
21
+ const writingRole = "You are the writing and research component of Slopify, a video creation app. " +
22
+ "Produce the text requested in the supplied conversation: articles, narration scripts, " +
23
+ "research notes or image prompts. Follow the requested topic, language, tone, length and format. " +
24
+ "Return only the requested content, without progress updates or introductory remarks. " +
25
+ "Use web search when it is available and needed for the requested research. " +
26
+ "Do not inspect or modify local files.";
21
27
  // Measured on 2.1.258, not assumed: with the built-in tools left alone, a `-p` run of this CLI
22
28
  // still reached for ToolSearch and WebFetch, and the machine's MCP servers were loaded into the
23
29
  // session. Neither belongs in a content pipeline - grounding on the web is an explicit ask,
@@ -31,6 +37,11 @@ export function claudeCodeArgs(req) {
31
37
  "stream-json",
32
38
  // stream-json output is refused without it.
33
39
  "--verbose",
40
+ // `claude --help` 2.1.263: safe mode excludes CLAUDE.md, output styles, skills and
41
+ // hooks while retaining login and managed policy. --bare would discard OAuth.
42
+ "--safe-mode",
43
+ "--system-prompt",
44
+ writingRole,
34
45
  "--strict-mcp-config",
35
46
  ...(req.webSearch === true
36
47
  ? ["--tools", "WebSearch", "--allowedTools", "WebSearch"]
package/dist/main.js CHANGED
@@ -2,6 +2,7 @@ import { fileURLToPath } from "node:url";
2
2
  import { serve } from "@hono/node-server";
3
3
  import ffmpegStatic from "ffmpeg-static";
4
4
  import { buildRegistry } from "./adapter-registry.js";
5
+ import { prepareFfmpeg } from "./adapters/ffmpeg.js";
5
6
  import { nodeRunCli } from "./adapters/llm/run-cli.js";
6
7
  import { createHub } from "./edge/events/hub.js";
7
8
  import { createApp } from "./edge/http/app.js";
@@ -27,7 +28,6 @@ import { collectorEndpoint, httpPostEvents } from "./slices/telemetry/collector-
27
28
  import { createFlusher } from "./slices/telemetry/flush.js";
28
29
  import { record } from "./slices/telemetry/record.js";
29
30
  import { runThumbnail } from "./slices/thumbnail/run.js";
30
- import { resolveFfmpeg } from "./slices/video/ffmpeg.js";
31
31
  import { renderVideo } from "./slices/video/run.js";
32
32
  // ceiling: a burst of finished stages coalesces into one delivery a second later, and the
33
33
  // collector gets ten seconds to answer before the attempt is abandoned and the events
@@ -42,6 +42,12 @@ export async function boot(config) {
42
42
  const lock = acquireInstanceLock(paths.lock);
43
43
  let db;
44
44
  try {
45
+ const ffmpeg = await prepareFfmpeg({
46
+ dataDir: paths.dataDir,
47
+ env: process.env,
48
+ bundled: ffmpegStatic,
49
+ report: (message) => console.warn(message),
50
+ });
45
51
  db = openDb(paths.db);
46
52
  migrate(db, clock);
47
53
  const interrupted = markInterruptedStages(db, clock);
@@ -66,7 +72,7 @@ export async function boot(config) {
66
72
  clock,
67
73
  probe: nodeCliProbe,
68
74
  });
69
- const runner = wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry });
75
+ const runner = wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry, ffmpeg });
70
76
  const app = createApp({
71
77
  db,
72
78
  paths,
@@ -116,10 +122,7 @@ export async function boot(config) {
116
122
  throw error;
117
123
  }
118
124
  }
119
- function wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry }) {
120
- // Resolved once at boot rather than per render, so a machine with no usable binary
121
- // fails at start with one message instead of on every project's last stage.
122
- const ffmpeg = resolveFfmpeg(process.env, ffmpegStatic);
125
+ function wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry, ffmpeg, }) {
123
126
  // A stage counts what it did and the queue is flushed after each new event. `record`
124
127
  // swallows its own failures, so this can neither fail a stage nor widen what leaves the
125
128
  // machine - the payload allow-list is checked inside it.
@@ -120,7 +120,10 @@ export function runFfmpeg(run) {
120
120
  reject(new Error("the render was canceled before it started"));
121
121
  return;
122
122
  }
123
- const child = spawn(run.bin, [...run.args], { stdio: ["ignore", "pipe", "pipe"] });
123
+ const child = spawn(run.bin, [...run.args], {
124
+ stdio: ["ignore", "pipe", "pipe"],
125
+ windowsHide: true,
126
+ });
124
127
  const errors = [];
125
128
  let pending = "";
126
129
  // A throw out of a stream listener is an uncaughtException, not a rejection: it would
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gentbajko/slopify",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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",