@gentbajko/slopify 0.4.2 → 0.5.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.
- package/README.md +12 -0
- package/dist/adapters/ffmpeg.js +78 -0
- package/dist/adapters/image/google.js +30 -4
- package/dist/adapters/llm/claude-code.js +11 -0
- package/dist/main.js +9 -6
- package/dist/slices/video/ffmpeg.js +4 -1
- package/dist/web/assets/index-BZvZKvIv.js +81 -0
- package/dist/web/assets/index-cpjfvs5E.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-D_sIXvT7.js +0 -81
- package/dist/web/assets/index-sG_6cp6k.css +0 -1
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
|
|
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
|
|
148
|
-
message:
|
|
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], {
|
|
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
|