@gentbajko/slopify 0.1.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/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/adapter-registry.js +68 -0
- package/dist/adapters/image/bytes.js +64 -0
- package/dist/adapters/image/fal.js +164 -0
- package/dist/adapters/image/openai.js +155 -0
- package/dist/adapters/image/replicate.js +183 -0
- package/dist/adapters/llm/claude-code.js +158 -0
- package/dist/adapters/llm/codex.js +126 -0
- package/dist/adapters/llm/openrouter.js +193 -0
- package/dist/adapters/llm/run-cli.js +134 -0
- package/dist/adapters/llm/sse-lines.js +46 -0
- package/dist/adapters/retry-after.js +21 -0
- package/dist/adapters/tts/cartesia.js +105 -0
- package/dist/adapters/tts/elevenlabs.js +107 -0
- package/dist/adapters/tts/openai.js +95 -0
- package/dist/edge/cli.js +36 -0
- package/dist/edge/events/hub.js +76 -0
- package/dist/edge/http/actions.js +125 -0
- package/dist/edge/http/app.js +72 -0
- package/dist/edge/http/entries.js +41 -0
- package/dist/edge/http/files.js +67 -0
- package/dist/edge/http/problem.js +65 -0
- package/dist/edge/http/projects.js +128 -0
- package/dist/edge/http/prompts.js +73 -0
- package/dist/edge/http/providers.js +62 -0
- package/dist/edge/http/settings.js +90 -0
- package/dist/edge/http/staging.js +104 -0
- package/dist/edge/http/telemetry.js +29 -0
- package/dist/edge/http/usage.js +19 -0
- package/dist/edge/open-browser.js +20 -0
- package/dist/kernel/clock.js +18 -0
- package/dist/kernel/config/index.js +32 -0
- package/dist/kernel/db/index.js +24 -0
- package/dist/kernel/db/migrate.js +59 -0
- package/dist/kernel/db/migrations/0001-init.sql +17 -0
- package/dist/kernel/db/tx.js +53 -0
- package/dist/kernel/events.js +1 -0
- package/dist/kernel/ids.js +4 -0
- package/dist/kernel/lock.js +73 -0
- package/dist/kernel/log.js +34 -0
- package/dist/kernel/paths.js +21 -0
- package/dist/kernel/pipeline.js +22 -0
- package/dist/kernel/ports/image.js +1 -0
- package/dist/kernel/ports/llm.js +1 -0
- package/dist/kernel/ports/model.js +34 -0
- package/dist/kernel/ports/registry.js +1 -0
- package/dist/kernel/ports/tts.js +1 -0
- package/dist/kernel/runner/attempt-repo.js +50 -0
- package/dist/kernel/runner/attempt.js +146 -0
- package/dist/kernel/runner/graph.js +82 -0
- package/dist/kernel/runner/index.js +205 -0
- package/dist/kernel/runner/piece-repo.js +58 -0
- package/dist/kernel/runner/providers.js +92 -0
- package/dist/kernel/version.js +14 -0
- package/dist/main.js +212 -0
- package/dist/slices/admission/model.js +6 -0
- package/dist/slices/admission/repo.js +178 -0
- package/dist/slices/admission/rules.js +201 -0
- package/dist/slices/admission/start.js +103 -0
- package/dist/slices/admission/substitute.js +80 -0
- package/dist/slices/article/continuation.js +81 -0
- package/dist/slices/article/plain.js +14 -0
- package/dist/slices/article/run.js +166 -0
- package/dist/slices/article/split.js +51 -0
- package/dist/slices/article/store.js +21 -0
- package/dist/slices/cancel/index.js +61 -0
- package/dist/slices/images/run.js +221 -0
- package/dist/slices/library/lint.js +53 -0
- package/dist/slices/library/model.js +13 -0
- package/dist/slices/library/repo.js +111 -0
- package/dist/slices/library/save.js +87 -0
- package/dist/slices/library/slots.js +86 -0
- package/dist/slices/narration/chunk.js +71 -0
- package/dist/slices/narration/concat.js +79 -0
- package/dist/slices/narration/run.js +280 -0
- package/dist/slices/reruns/cascade.js +66 -0
- package/dist/slices/reruns/index.js +269 -0
- package/dist/slices/research/planner.js +81 -0
- package/dist/slices/research/run.js +192 -0
- package/dist/slices/research/synthesis.js +48 -0
- package/dist/slices/settings/cli-status.js +37 -0
- package/dist/slices/settings/keys.js +55 -0
- package/dist/slices/settings/model.js +54 -0
- package/dist/slices/settings/playback.js +60 -0
- package/dist/slices/settings/readiness.js +21 -0
- package/dist/slices/settings/repo.js +59 -0
- package/dist/slices/settings/voices.js +48 -0
- package/dist/slices/storage/asset-name.js +16 -0
- package/dist/slices/storage/delete-project.js +35 -0
- package/dist/slices/storage/downloads.js +100 -0
- package/dist/slices/storage/layout.js +59 -0
- package/dist/slices/storage/model.js +20 -0
- package/dist/slices/storage/reconcile.js +102 -0
- package/dist/slices/storage/repo.js +104 -0
- package/dist/slices/storage/staging.js +224 -0
- package/dist/slices/telemetry/collector-client.js +51 -0
- package/dist/slices/telemetry/flush.js +111 -0
- package/dist/slices/telemetry/machine.js +33 -0
- package/dist/slices/telemetry/model.js +39 -0
- package/dist/slices/telemetry/record.js +41 -0
- package/dist/slices/telemetry/repo.js +73 -0
- package/dist/slices/telemetry/usage.js +82 -0
- package/dist/slices/thumbnail/by-llm.js +30 -0
- package/dist/slices/thumbnail/run.js +200 -0
- package/dist/slices/video/ffmpeg.js +230 -0
- package/dist/slices/video/plan.js +71 -0
- package/dist/slices/video/run.js +179 -0
- package/dist/web/app-icon.svg +1 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-Dmwat-ge.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-v1xN8_Wq.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-BIHFfxf0.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-CwuXbfVR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DYeBwlKR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DhIzd8Tb.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-fsAxiSwU.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-C1h8hMer.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-CNwfPWQD.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-__SGTsZ1.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-BdVooDN4.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-s1sAMnoV.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-CvBsJvxq.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-CJPcKP2Q.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-DMVRjfRT.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-CctuGmmz.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-BiucknKG.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-D7I3yvUw.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-BFeobeCK.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-Dpl4UHAZ.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-GNfB7rCE.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-zTViEIzf.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-CA_GiK2e.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-DcjprdFV.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-4Jt4k04K.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-D6euyNzi.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-Cl1Mc_Dv.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-D0VWpbij.woff +0 -0
- package/dist/web/assets/index-BPqQnrpy.css +1 -0
- package/dist/web/assets/index-D_sWbKQi.js +81 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +15 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gent Bajko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Slopify
|
|
2
|
+
|
|
3
|
+
A prompt and a few keywords in. A narrated slideshow video out. Your keys, your machine, free.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @gentbajko/slopify@latest
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
That opens a browser at `http://127.0.0.1:4242`. Nothing to configure first; add
|
|
10
|
+
provider keys on the Settings screen when you want a stage to generate rather than
|
|
11
|
+
take what you paste in.
|
|
12
|
+
|
|
13
|
+
Six stages run as a graph: research, article, narration, images, thumbnail, video.
|
|
14
|
+
Generate any of them, or provide the output yourself and that stage is skipped. The
|
|
15
|
+
video is a slideshow with alternating zoom over the narration, rendered with ffmpeg.
|
|
16
|
+
|
|
17
|
+
## Options
|
|
18
|
+
|
|
19
|
+
| Flag | Environment variable | Default |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `--port` | `SLOPIFY_PORT` | `4242` |
|
|
22
|
+
| `--host` | `SLOPIFY_HOST` | `127.0.0.1` |
|
|
23
|
+
| `--data-dir` | `SLOPIFY_DATA_DIR` | `~/.slopify` |
|
|
24
|
+
| `--no-open` | `SLOPIFY_NO_OPEN` | the browser opens |
|
|
25
|
+
| — | `SLOPIFY_FFMPEG` | the bundled binary |
|
|
26
|
+
|
|
27
|
+
There is no login. Binding to anything but `127.0.0.1` hands the app and every key in
|
|
28
|
+
it to whoever reaches the port, and the CLI says so on startup.
|
|
29
|
+
|
|
30
|
+
Everything lives in one SQLite file and one directory tree under the data directory:
|
|
31
|
+
`slopify.db`, `projects/`, `staging/`, `logs/`. Delete it and nothing of yours remains.
|
|
32
|
+
|
|
33
|
+
## ffmpeg and the GPL
|
|
34
|
+
|
|
35
|
+
Slopify renders video with ffmpeg. The `ffmpeg-static` dependency downloads a
|
|
36
|
+
prebuilt binary to `node_modules/ffmpeg-static/` at install time.
|
|
37
|
+
|
|
38
|
+
That binary is a separate program, run as a child process with an argument array. It
|
|
39
|
+
is licensed under the **GPL-3.0-or-later**. Slopify does not link against it, does not
|
|
40
|
+
embed it, and does not distribute it inside this package; its licence text and the
|
|
41
|
+
location of its corresponding source ship beside it in `ffmpeg-static`. Slopify's own
|
|
42
|
+
code is MIT and stays MIT. Anyone redistributing the downloaded binary takes on the
|
|
43
|
+
GPL's obligations for it, including offering that corresponding source.
|
|
44
|
+
|
|
45
|
+
Point `SLOPIFY_FFMPEG` at your own build to use that instead.
|
|
46
|
+
|
|
47
|
+
## Telemetry
|
|
48
|
+
|
|
49
|
+
Slopify sends anonymous counters to a collector: installs, projects created, stages
|
|
50
|
+
completed, images and videos made, audio seconds, provider and model names, token
|
|
51
|
+
counts, and the time each of those happened. Every event carries a random id of its
|
|
52
|
+
own and this machine's random id, and nothing else.
|
|
53
|
+
|
|
54
|
+
Never your keys, prompts, keywords, titles, article text, filenames, or anything about
|
|
55
|
+
your machine. A notice says all of this the first time you run it, before the machine
|
|
56
|
+
id exists, and the Usage screen shows you your own numbers at any time.
|
|
57
|
+
|
|
58
|
+
## Licence
|
|
59
|
+
|
|
60
|
+
MIT. The ffmpeg binary fetched at install time is a separate GPL-3.0-or-later program,
|
|
61
|
+
as described above.
|
|
62
|
+
|
|
63
|
+
Source, issues and the full guide: <https://github.com/GentBajko/slopify>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { falImage } from "./adapters/image/fal.js";
|
|
2
|
+
import { openAiImage } from "./adapters/image/openai.js";
|
|
3
|
+
import { replicateImage } from "./adapters/image/replicate.js";
|
|
4
|
+
import { claudeCodeLlm } from "./adapters/llm/claude-code.js";
|
|
5
|
+
import { codexLlm } from "./adapters/llm/codex.js";
|
|
6
|
+
import { openRouterLlm } from "./adapters/llm/openrouter.js";
|
|
7
|
+
import { cartesiaTts } from "./adapters/tts/cartesia.js";
|
|
8
|
+
import { elevenLabsTts } from "./adapters/tts/elevenlabs.js";
|
|
9
|
+
import { openAiTts } from "./adapters/tts/openai.js";
|
|
10
|
+
import { keyForAttempt } from "./slices/settings/keys.js";
|
|
11
|
+
import { providerStatuses } from "./slices/settings/readiness.js";
|
|
12
|
+
export function buildRegistry(deps) {
|
|
13
|
+
// `logic/02` §Q16: read per request, never held, so an attempt in flight finishes on
|
|
14
|
+
// the key it started with and the next one picks up a key saved since. The closure is
|
|
15
|
+
// handed to the one adapter that provider belongs to and to nothing else (§Q18).
|
|
16
|
+
const keyOf = (provider) => () => {
|
|
17
|
+
const found = keyForAttempt({ db: deps.db, clock: deps.clock }, provider);
|
|
18
|
+
return found.ok ? found.key : undefined;
|
|
19
|
+
};
|
|
20
|
+
const llms = new Map([
|
|
21
|
+
["openrouter", openRouterLlm({ fetch: deps.fetch, key: keyOf("openrouter") })],
|
|
22
|
+
// No key: both CLIs authenticate with their own login (`logic/02` §Q135).
|
|
23
|
+
["claude-code", claudeCodeLlm({ run: deps.spawn })],
|
|
24
|
+
["codex", codexLlm({ run: deps.spawn })],
|
|
25
|
+
]);
|
|
26
|
+
// One key per provider (`logic/02` invariant), so each adapter is handed the reader for
|
|
27
|
+
// its own row and no other. OpenAI keeps two rows because it ships an adapter in two
|
|
28
|
+
// families and a user may key one without the other (`slices/settings/model.ts`).
|
|
29
|
+
const ttses = new Map([
|
|
30
|
+
["elevenlabs", elevenLabsTts({ fetch: deps.fetch, key: keyOf("elevenlabs") })],
|
|
31
|
+
["openai-tts", openAiTts({ fetch: deps.fetch, key: keyOf("openai-tts") })],
|
|
32
|
+
["cartesia", cartesiaTts({ fetch: deps.fetch, key: keyOf("cartesia") })],
|
|
33
|
+
]);
|
|
34
|
+
// `logic/09` and `logic/02` §Q15: three image providers behind one port, each handed
|
|
35
|
+
// the reader for its own key row. Replicate also takes the clock: `Prefer: wait` gives
|
|
36
|
+
// up after 60 s and the prediction has to be polled, and the wait is spent on the
|
|
37
|
+
// app's clock so a test never sits through one.
|
|
38
|
+
const images = new Map([
|
|
39
|
+
["fal", falImage({ fetch: deps.fetch, key: keyOf("fal") })],
|
|
40
|
+
[
|
|
41
|
+
"replicate",
|
|
42
|
+
replicateImage({ fetch: deps.fetch, key: keyOf("replicate"), clock: deps.clock }),
|
|
43
|
+
],
|
|
44
|
+
["openai-image", openAiImage({ fetch: deps.fetch, key: keyOf("openai-image") })],
|
|
45
|
+
]);
|
|
46
|
+
return {
|
|
47
|
+
llm: (id) => resolve(llms, "llm", id),
|
|
48
|
+
tts: (id) => resolve(ttses, "tts", id),
|
|
49
|
+
image: (id) => resolve(images, "image", id),
|
|
50
|
+
// `logic/02` step 5: every supported provider, keyed or not, found or not, so Play
|
|
51
|
+
// can grey one out with a reason instead of hiding it.
|
|
52
|
+
list: async () => (await providerStatuses({ db: deps.db, probe: deps.probe })).map((status) => ({
|
|
53
|
+
family: status.family,
|
|
54
|
+
id: status.id,
|
|
55
|
+
name: status.displayName,
|
|
56
|
+
readiness: status.readiness,
|
|
57
|
+
})),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// The Registry contract: an id the catalogue does not carry is a bug in admission, not a
|
|
61
|
+
// user error, so it throws rather than answering undefined.
|
|
62
|
+
function resolve(ports, family, id) {
|
|
63
|
+
const port = ports.get(id);
|
|
64
|
+
if (port === undefined) {
|
|
65
|
+
throw new Error(`no ${family} adapter is registered for ${id}`);
|
|
66
|
+
}
|
|
67
|
+
return port;
|
|
68
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
2
|
+
// Two of the three image providers answer with a link rather than with bytes, so fetching
|
|
3
|
+
// that link is part of the provider call and belongs inside the attempt with it: a URL
|
|
4
|
+
// that 404s or hands back an HTML error page is a failed attempt, not a corrupt file on
|
|
5
|
+
// disk. It sits beside the adapters, as `retry-after.ts` does, because the rule is the
|
|
6
|
+
// port's - `GeneratedImage` is a PNG or a JPEG and nothing else - rather than any one
|
|
7
|
+
// vendor's. Nothing else is shared between the three adapters.
|
|
8
|
+
const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
9
|
+
const jpeg = [0xff, 0xd8, 0xff];
|
|
10
|
+
// Not a format the port carries, but the likeliest wrong answer: it is what most
|
|
11
|
+
// diffusion models return by default, so naming it turns a puzzling failure into one
|
|
12
|
+
// sentence the user can act on.
|
|
13
|
+
const riff = [0x52, 0x49, 0x46, 0x46];
|
|
14
|
+
const webp = [0x57, 0x45, 0x42, 0x50];
|
|
15
|
+
// The bytes decide, never the Content-Type header. A CDN in front of an expired link
|
|
16
|
+
// serves `image/png` over an HTML error page often enough to be worth not trusting, and
|
|
17
|
+
// the mime is stored with the file and becomes its extension (`logic/09` step 3).
|
|
18
|
+
export function sniffImage(bytes) {
|
|
19
|
+
if (startsWith(bytes, png, 0)) {
|
|
20
|
+
return "image/png";
|
|
21
|
+
}
|
|
22
|
+
return startsWith(bytes, jpeg, 0) ? "image/jpeg" : undefined;
|
|
23
|
+
}
|
|
24
|
+
// What to call what arrived when it is not an image, without echoing bytes that may be a
|
|
25
|
+
// signed URL, a key quoted back, or a megabyte of HTML.
|
|
26
|
+
export function describeBytes(bytes) {
|
|
27
|
+
if (bytes.length === 0) {
|
|
28
|
+
return "an empty body";
|
|
29
|
+
}
|
|
30
|
+
if (startsWith(bytes, riff, 0) && startsWith(bytes, webp, 8)) {
|
|
31
|
+
return "a WebP image";
|
|
32
|
+
}
|
|
33
|
+
return `${String(bytes.length)} bytes beginning ${hex(bytes)}`;
|
|
34
|
+
}
|
|
35
|
+
export async function downloadImage(download) {
|
|
36
|
+
const response = await download.fetch(download.url, { signal: download.signal });
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
// `other`, so the attempt wrapper retries it: a link that is not ready yet or a CDN
|
|
39
|
+
// hiccup is exactly the transient failure the retry policy exists for. The URL is not
|
|
40
|
+
// quoted back - a fal or Replicate delivery link carries its own signature.
|
|
41
|
+
throw providerError({
|
|
42
|
+
kind: "other",
|
|
43
|
+
message: `${download.provider} answered ${String(response.status)} for the image it said it had made`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
47
|
+
const mime = sniffImage(bytes);
|
|
48
|
+
if (mime === undefined) {
|
|
49
|
+
throw providerError({
|
|
50
|
+
kind: "other",
|
|
51
|
+
message: `${download.provider}'s image link answered with ${describeBytes(bytes)} rather than a PNG or a JPEG`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return { bytes, mime };
|
|
55
|
+
}
|
|
56
|
+
function startsWith(bytes, magic, at) {
|
|
57
|
+
if (bytes.length < at + magic.length) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return magic.every((byte, index) => bytes[at + index] === byte);
|
|
61
|
+
}
|
|
62
|
+
function hex(bytes) {
|
|
63
|
+
return [...bytes.slice(0, 4)].map((byte) => byte.toString(16).padStart(2, "0")).join(" ");
|
|
64
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { redact } from "../../kernel/log.js";
|
|
3
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { retryAfter } from "../retry-after.js";
|
|
5
|
+
import { downloadImage } from "./bytes.js";
|
|
6
|
+
// The HTTP gateway adapter for fal.ai (01-architecture Module boundaries). `fetch` and
|
|
7
|
+
// the downloader beside this file, no SDK: `@fal-ai/client` exists and would buy the
|
|
8
|
+
// queue polling this endpoint does not need, so 05-dependencies' rung 3 - the platform's
|
|
9
|
+
// own `fetch` - already covers the whole call. The synchronous host runs the model on the
|
|
10
|
+
// open connection, which is what the attempt wrapper's 300 s is measuring.
|
|
11
|
+
export const falBase = "https://fal.run";
|
|
12
|
+
// `logic/02` §Q15: the model dropdown is filled from what the provider offers, and fal has
|
|
13
|
+
// no endpoint that lists only its text-to-image models, so the list is this adapter's own
|
|
14
|
+
// data. Adding next month's model is a line here and no code change anywhere else.
|
|
15
|
+
// Every entry takes the same input shape: `image_size` as an enum, `num_images`, and
|
|
16
|
+
// `output_format`. ceiling: a fal model that spells its aspect `aspect_ratio` instead -
|
|
17
|
+
// `xai/grok-imagine-image` does - cannot be added to this list without a per-model input
|
|
18
|
+
// map, which is the upgrade when one is wanted.
|
|
19
|
+
export const falModels = [
|
|
20
|
+
{ id: "fal-ai/flux-2", name: "FLUX.2" },
|
|
21
|
+
{ id: "fal-ai/flux/dev", name: "FLUX.1 [dev]" },
|
|
22
|
+
{ id: "fal-ai/flux/schnell", name: "FLUX.1 [schnell]" },
|
|
23
|
+
];
|
|
24
|
+
// `logic/09` step 1: the closest supported size to the run's aspect. fal names the two
|
|
25
|
+
// 16:9 frames from the orientation, so the portrait one is "portrait_16_9" and is 9:16.
|
|
26
|
+
const sizes = {
|
|
27
|
+
"16:9": "landscape_16_9",
|
|
28
|
+
"9:16": "portrait_16_9",
|
|
29
|
+
};
|
|
30
|
+
// A wire payload is narrowed, never cast: everything unlisted is dropped at the seam so
|
|
31
|
+
// no vendor shape can leak past this file (01-architecture §Q10, §Q33).
|
|
32
|
+
const generated = z.object({
|
|
33
|
+
images: z.array(z.object({ url: z.string() })),
|
|
34
|
+
// The safety checker's verdict, one flag per image. It is how fal says it declined:
|
|
35
|
+
// the request is a 200 and the image that comes back is blank.
|
|
36
|
+
has_nsfw_concepts: z.array(z.boolean()).nullish(),
|
|
37
|
+
});
|
|
38
|
+
// fal answers a rejected request with FastAPI's own envelope: an object per failed field
|
|
39
|
+
// for a validation error, a bare string for everything else.
|
|
40
|
+
const errorBody = z.object({
|
|
41
|
+
detail: z.union([
|
|
42
|
+
z.string(),
|
|
43
|
+
z.array(z.object({ msg: z.string(), type: z.string().optional() })),
|
|
44
|
+
]),
|
|
45
|
+
});
|
|
46
|
+
export function falImage(deps) {
|
|
47
|
+
return {
|
|
48
|
+
id: "fal",
|
|
49
|
+
models: () => Promise.resolve(falModels),
|
|
50
|
+
generate: async (req) => {
|
|
51
|
+
const response = await deps.fetch(`${falBase}/${req.model}`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
signal: req.signal,
|
|
54
|
+
headers: { Authorization: `Key ${keyOf(deps)}`, "Content-Type": "application/json" },
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
prompt: req.prompt,
|
|
57
|
+
image_size: sizes[req.aspect],
|
|
58
|
+
// `logic/09` step 2 sends Number as that many independent calls, each its own
|
|
59
|
+
// resumable piece, so one image per request is what the stage asks for.
|
|
60
|
+
num_images: 1,
|
|
61
|
+
// The port stores a PNG or a JPEG; fal's own default is WebP on some models and
|
|
62
|
+
// the download would refuse it. Nothing else about quality or style is set:
|
|
63
|
+
// step 2 asks for the provider's defaults.
|
|
64
|
+
output_format: "png",
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw await failure(response);
|
|
69
|
+
}
|
|
70
|
+
const answer = parse(await response.text());
|
|
71
|
+
refused(answer, req.prompt);
|
|
72
|
+
const first = answer.images[0];
|
|
73
|
+
if (first === undefined) {
|
|
74
|
+
throw providerError({ kind: "other", message: "fal answered with no image" });
|
|
75
|
+
}
|
|
76
|
+
// The download is inside the attempt with the call that produced the link, so a
|
|
77
|
+
// link that 404s is a failed attempt rather than a broken file on the project.
|
|
78
|
+
return await downloadImage({
|
|
79
|
+
fetch: deps.fetch,
|
|
80
|
+
provider: "fal",
|
|
81
|
+
url: first.url,
|
|
82
|
+
signal: req.signal,
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function keyOf(deps) {
|
|
88
|
+
// `logic/02` §Q13: an attempt that finds no key fails rather than calling anonymously.
|
|
89
|
+
// `missing_key` rather than `auth` because the same rule makes it terminal.
|
|
90
|
+
const key = deps.key();
|
|
91
|
+
if (key === undefined || key === "") {
|
|
92
|
+
throw providerError({ kind: "missing_key", message: "no fal key is stored" });
|
|
93
|
+
}
|
|
94
|
+
return key;
|
|
95
|
+
}
|
|
96
|
+
// `logic/09` §Q74: a content-policy refusal is the provider's final answer, so it is named
|
|
97
|
+
// here and the wrapper makes it terminal. fal declines inside a 200: the safety checker
|
|
98
|
+
// flags the image and hands back a blank one. There is no sentence to quote, so this is
|
|
99
|
+
// the one refusal in the app whose words are the app's; fal's own verdict is the flag.
|
|
100
|
+
function refused(answer, prompt) {
|
|
101
|
+
const flags = answer.has_nsfw_concepts ?? [];
|
|
102
|
+
if (flags.length > 0 && flags.every((flagged) => flagged)) {
|
|
103
|
+
throw providerError({
|
|
104
|
+
kind: "refusal",
|
|
105
|
+
message: `fal's safety checker rejected every image for this prompt: ${redact(prompt)}`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Only the adapter can read a vendor's status code, so only the adapter names the kind;
|
|
110
|
+
// the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
|
|
111
|
+
function kindOf(status) {
|
|
112
|
+
if (status === 401 || status === 403) {
|
|
113
|
+
return "auth";
|
|
114
|
+
}
|
|
115
|
+
if (status === 429) {
|
|
116
|
+
return "rate_limit";
|
|
117
|
+
}
|
|
118
|
+
// ceiling: everything else is `other` and is retried, so a 422 naming an input this
|
|
119
|
+
// model does not take fails the same way four times over. A terminal kind for "this
|
|
120
|
+
// request will never work" would have to be added to the port's error contract first,
|
|
121
|
+
// which is not this adapter's to widen.
|
|
122
|
+
return "other";
|
|
123
|
+
}
|
|
124
|
+
async function failure(response) {
|
|
125
|
+
const text = await response.text().catch(() => "");
|
|
126
|
+
// The provider's own words, verbatim, through the same redactor the wrapper uses: an
|
|
127
|
+
// error body is free to quote the key back and this is the first place it is held.
|
|
128
|
+
const message = redact(detailOf(text) || response.statusText);
|
|
129
|
+
const retryAfterMs = retryAfter(response.headers.get("retry-after"));
|
|
130
|
+
return providerError({
|
|
131
|
+
kind: kindOf(response.status),
|
|
132
|
+
message: `fal answered ${String(response.status)}: ${message}`,
|
|
133
|
+
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function detailOf(text) {
|
|
137
|
+
const parsed = errorBody.safeParse(safeJson(text));
|
|
138
|
+
if (!parsed.success) {
|
|
139
|
+
return text.trim();
|
|
140
|
+
}
|
|
141
|
+
const { detail } = parsed.data;
|
|
142
|
+
if (typeof detail === "string") {
|
|
143
|
+
return detail;
|
|
144
|
+
}
|
|
145
|
+
return detail.map((one) => one.msg).join("; ");
|
|
146
|
+
}
|
|
147
|
+
function parse(text) {
|
|
148
|
+
const parsed = generated.safeParse(safeJson(text));
|
|
149
|
+
if (!parsed.success) {
|
|
150
|
+
throw providerError({
|
|
151
|
+
kind: "other",
|
|
152
|
+
message: "fal's answer was not in the shape this app can read",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return parsed.data;
|
|
156
|
+
}
|
|
157
|
+
function safeJson(text) {
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(text);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { redact } from "../../kernel/log.js";
|
|
3
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { retryAfter } from "../retry-after.js";
|
|
5
|
+
import { describeBytes, sniffImage } from "./bytes.js";
|
|
6
|
+
// The HTTP gateway adapter for OpenAI's images endpoint (01-architecture Module
|
|
7
|
+
// boundaries). `fetch` and nothing else: 05-dependencies records global `fetch` at rung 3
|
|
8
|
+
// and the whole call is one request. Unlike fal and Replicate this one hands back the
|
|
9
|
+
// image itself - a GPT image model always answers with base64 and never with a URL - so
|
|
10
|
+
// there is no link to follow.
|
|
11
|
+
export const openAiImagesBase = "https://api.openai.com/v1";
|
|
12
|
+
// `logic/02` §Q15: the dropdown is filled from what the provider offers, and `/v1/models`
|
|
13
|
+
// lists every model on the account, chat and embeddings among them, so the image
|
|
14
|
+
// shortlist is this adapter's own data. These are the four GPT image models OpenAI
|
|
15
|
+
// documents; adding the next one is a line here and no code change anywhere else.
|
|
16
|
+
export const openAiImageModels = [
|
|
17
|
+
{ id: "gpt-image-2", name: "GPT Image 2" },
|
|
18
|
+
{ id: "gpt-image-1.5", name: "GPT Image 1.5" },
|
|
19
|
+
{ id: "gpt-image-1", name: "GPT Image 1" },
|
|
20
|
+
{ id: "gpt-image-1-mini", name: "GPT Image 1 mini" },
|
|
21
|
+
];
|
|
22
|
+
// `logic/09` step 1: "the provider's closest supported size to the run's aspect". The
|
|
23
|
+
// standard GPT image sizes are 3:2 and 2:3, so 16:9 is asked for as 1536×1024 and the
|
|
24
|
+
// remaining sliver is cropped by the render (`logic/11` step 4).
|
|
25
|
+
const standard = {
|
|
26
|
+
"16:9": "1536x1024",
|
|
27
|
+
"9:16": "1024x1536",
|
|
28
|
+
};
|
|
29
|
+
// gpt-image-2 takes an arbitrary WIDTH×HEIGHT whose sides divide by 16, between 1:3 and
|
|
30
|
+
// 3:1, so on that model the closest supported size is the aspect exactly and the render
|
|
31
|
+
// crops nothing.
|
|
32
|
+
const exact = {
|
|
33
|
+
"16:9": "1536x864",
|
|
34
|
+
"9:16": "864x1536",
|
|
35
|
+
};
|
|
36
|
+
const arbitrarySizes = /^gpt-image-2/;
|
|
37
|
+
// A wire payload is narrowed, never cast (01-architecture §Q10, §Q33).
|
|
38
|
+
const generated = z.object({ data: z.array(z.object({ b64_json: z.string().nullish() })) });
|
|
39
|
+
const errorBody = z.object({
|
|
40
|
+
error: z.object({
|
|
41
|
+
message: z.string(),
|
|
42
|
+
type: z.string().nullish(),
|
|
43
|
+
code: z.string().nullish(),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
// `logic/09` §Q74: a content-policy refusal is the provider's final answer and is never
|
|
47
|
+
// retried. OpenAI names it in the body rather than in the status, so the code is what
|
|
48
|
+
// tells a declined prompt from a malformed request that shares its 400.
|
|
49
|
+
const refusalCodes = ["moderation_blocked", "content_policy_violation"];
|
|
50
|
+
export function sizeFor(model, aspect) {
|
|
51
|
+
return arbitrarySizes.test(model) ? exact[aspect] : standard[aspect];
|
|
52
|
+
}
|
|
53
|
+
export function openAiImage(deps) {
|
|
54
|
+
return {
|
|
55
|
+
id: "openai-image",
|
|
56
|
+
models: () => Promise.resolve(openAiImageModels),
|
|
57
|
+
generate: async (req) => {
|
|
58
|
+
const response = await deps.fetch(`${openAiImagesBase}/images/generations`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
signal: req.signal,
|
|
61
|
+
headers: { Authorization: `Bearer ${keyOf(deps)}`, "Content-Type": "application/json" },
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
model: req.model,
|
|
64
|
+
prompt: req.prompt,
|
|
65
|
+
// `logic/09` step 2 sends Number as that many independent calls, each its own
|
|
66
|
+
// resumable piece, so one image per request is what the stage asks for.
|
|
67
|
+
n: 1,
|
|
68
|
+
size: sizeFor(req.model, req.aspect),
|
|
69
|
+
// No `quality` and no `background`: step 2 asks for the provider's default
|
|
70
|
+
// quality and style, which is what leaving them off means.
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw await failure(response);
|
|
75
|
+
}
|
|
76
|
+
const first = parse(await response.text()).data[0]?.b64_json;
|
|
77
|
+
if (first === undefined || first === null || first === "") {
|
|
78
|
+
throw providerError({ kind: "other", message: "OpenAI answered with no image" });
|
|
79
|
+
}
|
|
80
|
+
return decode(first);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// A GPT image model always answers with base64, so the bytes arrive with the call and
|
|
85
|
+
// there is no link to follow. They are still sniffed: what the port stores is a PNG or a
|
|
86
|
+
// JPEG, and a truncated payload that decodes to something else must not reach the disk.
|
|
87
|
+
function decode(b64) {
|
|
88
|
+
const bytes = new Uint8Array(Buffer.from(b64, "base64"));
|
|
89
|
+
const mime = sniffImage(bytes);
|
|
90
|
+
if (mime === undefined) {
|
|
91
|
+
throw providerError({
|
|
92
|
+
kind: "other",
|
|
93
|
+
message: `OpenAI's image decoded to ${describeBytes(bytes)} rather than a PNG or a JPEG`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return { bytes, mime };
|
|
97
|
+
}
|
|
98
|
+
function keyOf(deps) {
|
|
99
|
+
// `logic/02` §Q13: an attempt that finds no key fails rather than calling anonymously.
|
|
100
|
+
// `missing_key` rather than `auth` because the same rule makes it terminal.
|
|
101
|
+
const key = deps.key();
|
|
102
|
+
if (key === undefined || key === "") {
|
|
103
|
+
throw providerError({ kind: "missing_key", message: "no OpenAI image key is stored" });
|
|
104
|
+
}
|
|
105
|
+
return key;
|
|
106
|
+
}
|
|
107
|
+
// Only the adapter can read a vendor's status code, so only the adapter names the kind;
|
|
108
|
+
// the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
|
|
109
|
+
function kindOf(status, code) {
|
|
110
|
+
if (code !== undefined && code !== null && refusalCodes.includes(code)) {
|
|
111
|
+
return "refusal";
|
|
112
|
+
}
|
|
113
|
+
if (status === 401 || status === 403) {
|
|
114
|
+
return "auth";
|
|
115
|
+
}
|
|
116
|
+
if (status === 429) {
|
|
117
|
+
return "rate_limit";
|
|
118
|
+
}
|
|
119
|
+
// ceiling: everything else is `other` and is retried, so a prompt past the model's
|
|
120
|
+
// length limit fails the same way four times over. A terminal kind for "this request
|
|
121
|
+
// will never work" would have to be added to the port's error contract first, which is
|
|
122
|
+
// not this adapter's to widen.
|
|
123
|
+
return "other";
|
|
124
|
+
}
|
|
125
|
+
async function failure(response) {
|
|
126
|
+
const text = await response.text().catch(() => "");
|
|
127
|
+
const parsed = errorBody.safeParse(safeJson(text));
|
|
128
|
+
// The provider's own words, verbatim, through the same redactor the wrapper uses: an
|
|
129
|
+
// error body is free to quote the key back and this is the first place it is held.
|
|
130
|
+
const message = redact(parsed.success ? parsed.data.error.message : text.trim() || response.statusText);
|
|
131
|
+
const retryAfterMs = retryAfter(response.headers.get("retry-after"));
|
|
132
|
+
return providerError({
|
|
133
|
+
kind: kindOf(response.status, parsed.success ? parsed.data.error.code : undefined),
|
|
134
|
+
message: `OpenAI answered ${String(response.status)}: ${message}`,
|
|
135
|
+
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function parse(text) {
|
|
139
|
+
const parsed = generated.safeParse(safeJson(text));
|
|
140
|
+
if (!parsed.success) {
|
|
141
|
+
throw providerError({
|
|
142
|
+
kind: "other",
|
|
143
|
+
message: "OpenAI's answer was not in the shape this app can read",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return parsed.data;
|
|
147
|
+
}
|
|
148
|
+
function safeJson(text) {
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(text);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|