@gentbajko/slopify 2.3.1 → 2.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/dist/adapter-registry.js +9 -2
- package/dist/adapters/host-cli/index.js +11 -0
- package/dist/adapters/host-cli/transport.js +6 -1
- package/dist/adapters/image/codex-output.js +59 -17
- package/dist/adapters/image/codex.js +92 -16
- package/dist/adapters/image/fal.js +99 -8
- package/dist/adapters/image/google.js +13 -1
- package/dist/adapters/image/openai.js +37 -15
- package/dist/adapters/image/reference.js +7 -0
- package/dist/adapters/image/replicate.js +48 -2
- package/dist/adapters/image/video.js +28 -0
- package/dist/adapters/llm/codex-models.js +2 -1
- package/dist/adapters/llm/codex-server-models.js +2 -1
- package/dist/adapters/llm/run-cli.js +14 -2
- package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
- package/dist/assets/fonts/SOURCE.txt +6 -0
- package/dist/assets/models.yaml +89 -1
- package/dist/catalog/registry.js +24 -3
- package/dist/catalog/runtime-models.js +1 -1
- package/dist/catalog/schema.js +20 -1
- package/dist/catalog/store.js +2 -2
- package/dist/catalog/validate.js +47 -4
- package/dist/edge/docker-launch.js +4 -0
- package/dist/edge/docker-projects/engine.js +57 -1
- package/dist/edge/docker-projects/install.js +10 -2
- package/dist/edge/docker-projects/prune.js +66 -0
- package/dist/edge/events/hub.js +11 -0
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/backups.js +40 -0
- package/dist/edge/http/host-cli.js +17 -2
- package/dist/edge/http/problem.js +1 -0
- package/dist/edge/http/providers.js +8 -0
- package/dist/edge/http/schedules.js +1 -1
- package/dist/edge/http/settings.js +44 -0
- package/dist/edge/http/timeouts.js +38 -0
- package/dist/host-cli/runtime.js +3 -2
- package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
- package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
- package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
- package/dist/kernel/ports/host-cli.js +17 -2
- package/dist/kernel/ports/image.js +3 -1
- package/dist/kernel/ports/llm.js +3 -1
- package/dist/kernel/runner/attempt.js +5 -2
- package/dist/kernel/runner/providers.js +42 -2
- package/dist/main.js +55 -7
- package/dist/slices/admission/model.js +8 -0
- package/dist/slices/admission/rules.js +91 -2
- package/dist/slices/admission/schema.js +42 -1
- package/dist/slices/admission/start.js +53 -15
- package/dist/slices/backups/files.js +135 -0
- package/dist/slices/backups/folder.js +35 -0
- package/dist/slices/backups/model.js +65 -0
- package/dist/slices/backups/repo.js +28 -0
- package/dist/slices/backups/schedule.js +60 -0
- package/dist/slices/backups/service.js +311 -0
- package/dist/slices/batch/index.js +2 -0
- package/dist/slices/estimate/index.js +65 -2
- package/dist/slices/fonts/catalog.js +23 -0
- package/dist/slices/fonts/index.js +1 -1
- package/dist/slices/library/model.js +8 -1
- package/dist/slices/library/slots.js +13 -2
- package/dist/slices/library/snapshot.js +8 -2
- package/dist/slices/notifications/notifier.js +76 -0
- package/dist/slices/notifications/rules.js +81 -0
- package/dist/slices/notifications/send.js +40 -0
- package/dist/slices/notifications/settings.js +34 -0
- package/dist/slices/play-drafts/convert.js +72 -4
- package/dist/slices/play-drafts/readiness.js +7 -3
- package/dist/slices/play-drafts/repo.js +7 -1
- package/dist/slices/play-drafts/review-inputs.js +2 -0
- package/dist/slices/play-drafts/schema.js +44 -3
- package/dist/slices/play-drafts/service.js +6 -0
- package/dist/slices/play-drafts/uploads.js +2 -1
- package/dist/slices/project-templates/from-project.js +42 -0
- package/dist/slices/project-templates/setup.js +13 -0
- package/dist/slices/rebuild/preview-details.js +53 -3
- package/dist/slices/rebuild/preview-plan.js +2 -2
- package/dist/slices/rebuild/recipe-build.js +13 -3
- package/dist/slices/rebuild/recipe-edit.js +178 -0
- package/dist/slices/rebuild/recipe-exports.js +9 -4
- package/dist/slices/rebuild/recipe-input-schema.js +9 -1
- package/dist/slices/rebuild/recipe-legacy.js +12 -0
- package/dist/slices/rebuild/recipe-model.js +7 -1
- package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
- package/dist/slices/rebuild/recipe-reference.js +44 -0
- package/dist/slices/rebuild/recipe-save.js +25 -1
- package/dist/slices/rebuild/recipe-shorts.js +186 -0
- package/dist/slices/rebuild/recipe-validation.js +33 -2
- package/dist/slices/rebuild/recipe-visual.js +29 -8
- package/dist/slices/rebuild/recipe-work.js +83 -1
- package/dist/slices/rebuild/runtime-actions.js +9 -2
- package/dist/slices/rebuild/runtime-animate.js +110 -0
- package/dist/slices/rebuild/runtime-export-edit.js +119 -0
- package/dist/slices/rebuild/runtime-export.js +13 -1
- package/dist/slices/rebuild/runtime-image.js +50 -0
- package/dist/slices/rebuild/runtime-local.js +6 -3
- package/dist/slices/rebuild/runtime-materialize.js +48 -0
- package/dist/slices/rebuild/runtime-piece-label.js +17 -0
- package/dist/slices/rebuild/runtime-provider.js +17 -10
- package/dist/slices/rebuild/runtime-publication.js +25 -4
- package/dist/slices/rebuild/runtime-run.js +11 -5
- package/dist/slices/rebuild/runtime-shorts.js +328 -0
- package/dist/slices/rebuild/runtime-store.js +7 -3
- package/dist/slices/rebuild/service-readiness.js +15 -1
- package/dist/slices/rebuild/transition-repo.js +40 -1
- package/dist/slices/revisions/adopt-content.js +7 -0
- package/dist/slices/revisions/adopt.js +7 -2
- package/dist/slices/revisions/mutation-assets.js +51 -9
- package/dist/slices/revisions/mutation-prepare.js +14 -14
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/publish.js +20 -2
- package/dist/slices/revisions/schema.js +16 -1
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/service.js +6 -1
- package/dist/slices/settings/model.js +5 -0
- package/dist/slices/shorts/captions.js +272 -0
- package/dist/slices/shorts/clips.js +89 -0
- package/dist/slices/shorts/model.js +186 -0
- package/dist/slices/shorts/pick.js +232 -0
- package/dist/slices/shorts/prompts.js +68 -0
- package/dist/slices/shorts/render.js +163 -0
- package/dist/slices/storage/asset-name.js +9 -2
- package/dist/slices/storage/layout.js +18 -0
- package/dist/slices/storage/model.js +9 -0
- package/dist/slices/storage/portable.js +6 -3
- package/dist/slices/storage/reconcile.js +5 -1
- package/dist/slices/storage/schema.js +3 -0
- package/dist/slices/telemetry/model.js +3 -0
- package/dist/slices/video/cards.js +72 -0
- package/dist/slices/video/chapters.js +89 -0
- package/dist/slices/video/cuts.js +141 -0
- package/dist/slices/video/edit-list.js +36 -6
- package/dist/slices/video/edit-settings.js +191 -0
- package/dist/slices/video/ffmpeg.js +155 -29
- package/dist/slices/video/look.js +164 -0
- package/dist/slices/video/plan.js +68 -11
- package/dist/slices/video/slideshow.js +22 -6
- package/dist/slices/video/transitions.js +51 -0
- package/dist/slices/youtube/transcript.js +54 -0
- package/dist/web/assets/index-CktpxlJd.js +171 -0
- package/dist/web/assets/index-DB-vOnGU.css +1 -0
- package/dist/web/assets/{pdf-DR97Byps.js → pdf-BLF2i8c2.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DfXmBzCk.js +0 -168
- package/dist/web/assets/index-Y3RhfTEh.css +0 -1
package/dist/adapter-registry.js
CHANGED
|
@@ -66,14 +66,21 @@ export function buildRegistry(deps) {
|
|
|
66
66
|
// Replicate also takes the clock: `Prefer: wait` gives up after 60 s and the prediction has
|
|
67
67
|
// to be polled, and the wait is spent on the app's clock so a test never sits through one.
|
|
68
68
|
const images = new Map([
|
|
69
|
-
["fal", falImage({ fetch: deps.fetch, key: keyOf("fal") })],
|
|
69
|
+
["fal", falImage({ fetch: deps.fetch, key: keyOf("fal"), clock: deps.clock })],
|
|
70
70
|
[
|
|
71
71
|
"replicate",
|
|
72
72
|
replicateImage({ fetch: deps.fetch, key: keyOf("replicate"), clock: deps.clock }),
|
|
73
73
|
],
|
|
74
74
|
["openai-image", openAiImage({ fetch: deps.fetch, key: keyOf("openai-image") })],
|
|
75
75
|
["google-image", googleImage({ fetch: deps.fetch, key: keyOf("google-image") })],
|
|
76
|
-
[
|
|
76
|
+
[
|
|
77
|
+
"codex-image",
|
|
78
|
+
codexImage({
|
|
79
|
+
run: cliFor("codex"),
|
|
80
|
+
// The same list, and so the same models and efforts, as the Codex text provider.
|
|
81
|
+
readModels: () => nodeCodexModels(process.env, cliBinary(deps.db, "codex")),
|
|
82
|
+
}),
|
|
83
|
+
],
|
|
77
84
|
]);
|
|
78
85
|
if (deps.hostCli) {
|
|
79
86
|
for (const id of hostLlmIds)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { redact } from "../../kernel/log.js";
|
|
2
2
|
import { bridgeLimits, hostFaultSchema, hostFrameSchema, hostHealthSchema, hostImageSchema, hostLlmSchema, hostModelsSchema, hostOpenedSchema, hostOpenFolderSchema, hostStatusSchema, } from "../../kernel/ports/host-cli.js";
|
|
3
|
+
import { agentImageTimeoutMs } from "../../kernel/ports/image.js";
|
|
3
4
|
import { isProviderError, providerError } from "../../kernel/ports/model.js";
|
|
4
5
|
import { sniffImage } from "../image/bytes.js";
|
|
5
6
|
import { hostRequest, hostUnavailable, readHostBytes, } from "./transport.js";
|
|
@@ -142,12 +143,22 @@ export function createHostCliClient(options) {
|
|
|
142
143
|
}),
|
|
143
144
|
image: {
|
|
144
145
|
id: "codex-image",
|
|
146
|
+
timeoutMs: agentImageTimeoutMs,
|
|
145
147
|
models: () => models("codex-image"),
|
|
146
148
|
generate: async (req) => {
|
|
147
149
|
const parsed = hostImageSchema.safeParse({
|
|
148
150
|
model: req.model,
|
|
149
151
|
prompt: req.prompt,
|
|
150
152
|
aspect: req.aspect,
|
|
153
|
+
...(req.thinking === undefined ? {} : { thinking: req.thinking }),
|
|
154
|
+
...(req.reference === undefined
|
|
155
|
+
? {}
|
|
156
|
+
: {
|
|
157
|
+
reference: {
|
|
158
|
+
mime: req.reference.mime,
|
|
159
|
+
base64: Buffer.from(req.reference.bytes).toString("base64"),
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
151
162
|
});
|
|
152
163
|
if (!parsed.success)
|
|
153
164
|
throw providerError({
|
|
@@ -3,6 +3,7 @@ import { open } from "node:fs/promises";
|
|
|
3
3
|
import { request } from "node:http";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { bridgeLimits } from "../../kernel/ports/host-cli.js";
|
|
6
|
+
import { agentImageTimeoutMs } from "../../kernel/ports/image.js";
|
|
6
7
|
import { providerError } from "../../kernel/ports/model.js";
|
|
7
8
|
export function hostUnavailable(submitted = false) {
|
|
8
9
|
return providerError({
|
|
@@ -81,7 +82,11 @@ export async function hostRequest(options) {
|
|
|
81
82
|
reject(error);
|
|
82
83
|
};
|
|
83
84
|
const connectTimer = setTimeout(fail, 5000);
|
|
84
|
-
const deadline = setTimeout(fail, options.kind === "image"
|
|
85
|
+
const deadline = setTimeout(fail, options.kind === "image"
|
|
86
|
+
? agentImageTimeoutMs
|
|
87
|
+
: options.kind === "metadata"
|
|
88
|
+
? 35_000
|
|
89
|
+
: 120_000);
|
|
85
90
|
const cleanup = () => {
|
|
86
91
|
clearTimeout(connectTimer);
|
|
87
92
|
clearTimeout(deadline);
|
|
@@ -11,10 +11,64 @@ function unavailable(detail) {
|
|
|
11
11
|
message: `The Codex CLI finished, but Slopify could not collect the image: ${detail}. Use Retry stage to make it again (this uses your Codex quota again).`,
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
|
+
// A thread that drew more than this is not an image job any more.
|
|
15
|
+
const maxImagesPerThread = 50;
|
|
16
|
+
const imageName = /^[a-zA-Z0-9_-]+\.png$/;
|
|
17
|
+
function imagesRoot(env) {
|
|
18
|
+
return join(resolve(env.CODEX_HOME || join(env.HOME || homedir(), ".codex")), "generated_images");
|
|
19
|
+
}
|
|
20
|
+
function latestImage(directory) {
|
|
21
|
+
const dir = opendirSync(directory);
|
|
22
|
+
let latest;
|
|
23
|
+
let count = 0;
|
|
24
|
+
try {
|
|
25
|
+
for (let entry = dir.readSync(); entry !== null; entry = dir.readSync()) {
|
|
26
|
+
if (++count > maxImagesPerThread)
|
|
27
|
+
throw unavailable("far too many files were saved");
|
|
28
|
+
if (!entry.isFile() || !imageName.test(entry.name))
|
|
29
|
+
throw unavailable("the saved file is not a plain image file");
|
|
30
|
+
const at = lstatSync(join(directory, entry.name)).mtimeMs;
|
|
31
|
+
if (latest === undefined || at > latest.at || (at === latest.at && entry.name > latest.name))
|
|
32
|
+
latest = { name: entry.name, at };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
dir.closeSync();
|
|
37
|
+
}
|
|
38
|
+
if (latest === undefined)
|
|
39
|
+
throw unavailable("no image was saved");
|
|
40
|
+
return latest.name;
|
|
41
|
+
}
|
|
42
|
+
// How many images this job's thread has drawn so far, for its progress line. Never throws:
|
|
43
|
+
// a folder not made yet, or one being written, counts what it can.
|
|
44
|
+
export function codexImageCount(env, threadId) {
|
|
45
|
+
if (!z.uuid().safeParse(threadId).success)
|
|
46
|
+
return 0;
|
|
47
|
+
try {
|
|
48
|
+
const directory = join(imagesRoot(env), threadId);
|
|
49
|
+
const entry = lstatSync(directory);
|
|
50
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
51
|
+
return 0;
|
|
52
|
+
const dir = opendirSync(directory);
|
|
53
|
+
let count = 0;
|
|
54
|
+
try {
|
|
55
|
+
for (let one = dir.readSync(); one !== null; one = dir.readSync())
|
|
56
|
+
if (imageName.test(one.name))
|
|
57
|
+
count++;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
dir.closeSync();
|
|
61
|
+
}
|
|
62
|
+
return count;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
14
68
|
export function codexGeneratedImage(env, threadId, startedAt) {
|
|
15
69
|
if (!z.uuid().safeParse(threadId).success || threadId === undefined)
|
|
16
70
|
throw unavailable("the image job could not be identified");
|
|
17
|
-
const root =
|
|
71
|
+
const root = imagesRoot(env);
|
|
18
72
|
const directory = join(root, threadId);
|
|
19
73
|
try {
|
|
20
74
|
for (const path of [root, directory]) {
|
|
@@ -26,22 +80,10 @@ export function codexGeneratedImage(env, threadId, startedAt) {
|
|
|
26
80
|
if (canonical !== join(realpathSync(root), threadId))
|
|
27
81
|
throw unavailable("Codex's image folder moved while it was being read");
|
|
28
82
|
// Codex 0.155.1 omits image items from exec JSONL. Its artifact contract is
|
|
29
|
-
// generated_images/<thread.started ID>/<sanitized tool call ID>.png.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const entry = dir.readSync();
|
|
34
|
-
if (entry === null)
|
|
35
|
-
throw unavailable("no image was saved");
|
|
36
|
-
if (dir.readSync() !== null)
|
|
37
|
-
throw unavailable("more than one file was saved");
|
|
38
|
-
if (!entry.isFile() || !/^[a-zA-Z0-9_-]+\.png$/.test(entry.name))
|
|
39
|
-
throw unavailable("the saved file is not a plain image file");
|
|
40
|
-
name = entry.name;
|
|
41
|
-
}
|
|
42
|
-
finally {
|
|
43
|
-
dir.closeSync();
|
|
44
|
-
}
|
|
83
|
+
// generated_images/<thread.started ID>/<sanitized tool call ID>.png. An agent that reviews
|
|
84
|
+
// its work may draw several in its one thread; the last one it drew is its answer, so the
|
|
85
|
+
// newest file wins (the name breaks a tie, the call IDs being in no useful order).
|
|
86
|
+
const name = latestImage(directory);
|
|
45
87
|
const path = join(directory, name);
|
|
46
88
|
const before = lstatSync(path);
|
|
47
89
|
if (!before.isFile() || before.isSymbolicLink() || realpathSync(path) !== join(canonical, name))
|
|
@@ -1,20 +1,33 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { redact } from "../../kernel/log.js";
|
|
6
|
+
import { agentImageTimeoutMs, } from "../../kernel/ports/image.js";
|
|
6
7
|
import { providerError } from "../../kernel/ports/model.js";
|
|
7
8
|
import { cliReported, quoted, refusedImage } from "../explain.js";
|
|
8
9
|
import { cliLoginError } from "../llm/cli-login-error.js";
|
|
9
10
|
import { cliEvent, cliShaped, endedWithout, stopCliRun } from "../llm/run-cli.js";
|
|
10
11
|
import { lines } from "../llm/sse-lines.js";
|
|
11
|
-
import { codexGeneratedImage } from "./codex-output.js";
|
|
12
|
+
import { codexGeneratedImage, codexImageCount } from "./codex-output.js";
|
|
13
|
+
// "Codex default" puts no model and no effort on the command line, so the CLI's own defaults
|
|
14
|
+
// draw the image; it is what every project saved before the choice existed runs. Every other
|
|
15
|
+
// model id is one of the Codex CLI's own models - the list the Codex text provider shows -
|
|
16
|
+
// run with the chosen reasoning effort.
|
|
12
17
|
export const codexImageModel = {
|
|
13
18
|
id: "codex-imagegen",
|
|
14
|
-
name: "Codex
|
|
19
|
+
name: "Codex default",
|
|
15
20
|
};
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
// An agent that reviews and redraws its image works for several minutes at a high effort, so
|
|
22
|
+
// the usual 300 s image limit would cut the best runs off.
|
|
23
|
+
export const codexImageTimeoutMs = agentImageTimeoutMs;
|
|
24
|
+
const maxEventBytes = 4 * 1024 * 1024;
|
|
25
|
+
// Every tool beyond the image tool stays off. `view_image`, which only reads an image file
|
|
26
|
+
// into the conversation, is let back in when the agent is asked to review its work (a chosen
|
|
27
|
+
// model or effort) or has a reference to look at. The bundled imagegen skill stays off with
|
|
28
|
+
// the shell: its workflow runs Python scripts against the Images API with an API key, which
|
|
29
|
+
// this job neither needs nor has, and the built-in tool it wraps is already here.
|
|
30
|
+
const alwaysDisabled = [
|
|
18
31
|
"shell_tool",
|
|
19
32
|
"unified_exec",
|
|
20
33
|
"hooks",
|
|
@@ -25,18 +38,36 @@ const disabledFeatures = [
|
|
|
25
38
|
"multi_agent",
|
|
26
39
|
"computer_use",
|
|
27
40
|
"browser_use",
|
|
28
|
-
"view_image",
|
|
29
41
|
"workspace_dependencies",
|
|
30
42
|
];
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
function reviews(req) {
|
|
44
|
+
return (req.model !== codexImageModel.id || req.thinking !== undefined || req.reference !== undefined);
|
|
45
|
+
}
|
|
46
|
+
// The binary's image tool (`ImagegenArgs`: prompt, referenced_image_paths,
|
|
47
|
+
// num_last_images_to_include) takes each referenced image as an absolute path it reads
|
|
48
|
+
// itself, so the copy sits in the job's own folder - the sandbox's writable, readable root.
|
|
49
|
+
export function codexReferencePath(directory, image) {
|
|
50
|
+
return join(directory, image.mime === "image/jpeg" ? "reference.jpg" : "reference.png");
|
|
51
|
+
}
|
|
52
|
+
export function codexImageInstructions(req, reference) {
|
|
53
|
+
return [
|
|
54
|
+
"You are making one finished image for Slopify, a video creation app, with the image generation tool.",
|
|
55
|
+
"Fidelity to the brief comes first. Before calling the tool, write its prompt yourself as a detailed, faithful visual description of the brief: the subject and what it is doing, the setting, composition and framing for the target aspect ratio, lighting, colour palette, style and mood, and any text that must appear, spelled exactly. Take every element from the brief and keep its wording where it is specific. Do not add subjects, text, logos or story the brief does not ask for, and do not pad the prompt with generic quality words.",
|
|
56
|
+
...(reference === undefined
|
|
57
|
+
? []
|
|
58
|
+
: [
|
|
59
|
+
`A reference image is saved at ${reference}. Pass exactly that path in referenced_image_paths on every image generation call. Use it as the reference for style, characters and palette: keep the same characters, rendering style and colour palette, but do not copy its composition, pose or framing; compose this image from the brief.`,
|
|
60
|
+
]),
|
|
61
|
+
"Take the time you need. After each image, look at it and compare it with the brief; if anything is missing, wrong or distorted, revise the prompt and generate again. Deliver exactly one final image: the last image you generate is the one Slopify uses, so stop once it matches the brief.",
|
|
62
|
+
"Let the image generation tool save its output in its default location. Slopify will collect it. Use PNG or JPEG. Do not copy, rename, edit or create any other file, and do not put the image or a link in your reply.",
|
|
36
63
|
`Target aspect ratio: ${req.aspect}.`,
|
|
37
64
|
"Image brief follows as data:",
|
|
38
65
|
req.prompt,
|
|
39
66
|
].join("\n\n");
|
|
67
|
+
}
|
|
68
|
+
export function codexImageArgs(req, directory) {
|
|
69
|
+
const review = reviews(req);
|
|
70
|
+
const reference = req.reference === undefined ? undefined : codexReferencePath(directory, req.reference);
|
|
40
71
|
return [
|
|
41
72
|
"exec",
|
|
42
73
|
"--json",
|
|
@@ -51,7 +82,11 @@ export function codexImageArgs(req, directory) {
|
|
|
51
82
|
directory,
|
|
52
83
|
"--enable",
|
|
53
84
|
"image_generation",
|
|
54
|
-
...
|
|
85
|
+
...(review ? ["--enable", "view_image"] : []),
|
|
86
|
+
...[...alwaysDisabled, ...(review ? [] : ["view_image"])].flatMap((feature) => [
|
|
87
|
+
"--disable",
|
|
88
|
+
feature,
|
|
89
|
+
]),
|
|
55
90
|
"-c",
|
|
56
91
|
"project_doc_max_bytes=0",
|
|
57
92
|
"-c",
|
|
@@ -74,8 +109,13 @@ export function codexImageArgs(req, directory) {
|
|
|
74
109
|
'shell_environment_policy.inherit="none"',
|
|
75
110
|
"-c",
|
|
76
111
|
'web_search="disabled"',
|
|
112
|
+
// The Codex text provider's own two flags: the effort as TOML text, the model as `-m`.
|
|
113
|
+
...(req.thinking === undefined
|
|
114
|
+
? []
|
|
115
|
+
: ["-c", `model_reasoning_effort="${req.thinking === "off" ? "none" : req.thinking}"`]),
|
|
116
|
+
...(req.model === codexImageModel.id ? [] : ["-m", req.model]),
|
|
77
117
|
"--",
|
|
78
|
-
|
|
118
|
+
codexImageInstructions(req, reference),
|
|
79
119
|
];
|
|
80
120
|
}
|
|
81
121
|
const failure = z.object({ error: z.object({ message: z.string() }) });
|
|
@@ -85,12 +125,22 @@ export function codexImage(deps) {
|
|
|
85
125
|
const binary = deps.binary ?? "codex";
|
|
86
126
|
return {
|
|
87
127
|
id: "codex-image",
|
|
88
|
-
|
|
128
|
+
timeoutMs: codexImageTimeoutMs,
|
|
129
|
+
models: async () => {
|
|
130
|
+
let listed = [];
|
|
131
|
+
try {
|
|
132
|
+
listed = (await deps.readModels?.()) ?? [];
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// The default still works without the list.
|
|
136
|
+
}
|
|
137
|
+
return [codexImageModel, ...listed.filter((model) => model.id !== codexImageModel.id)];
|
|
138
|
+
},
|
|
89
139
|
generate: async (req) => {
|
|
90
|
-
if (req.model
|
|
140
|
+
if (req.model.trim() === "" || req.model.startsWith("-"))
|
|
91
141
|
throw providerError({
|
|
92
142
|
kind: "unsupported",
|
|
93
|
-
message: "
|
|
143
|
+
message: "No Codex model is chosen for images. Choose one (or Codex default) for images in the Providers section of Edit project, then use Retry stage.",
|
|
94
144
|
});
|
|
95
145
|
req.signal.throwIfAborted();
|
|
96
146
|
const directory = mkdtempSync(join(tmpdir(), "slopify-codex-image-"));
|
|
@@ -98,7 +148,30 @@ export function codexImage(deps) {
|
|
|
98
148
|
const startedAt = Date.now();
|
|
99
149
|
let threadId;
|
|
100
150
|
let run;
|
|
151
|
+
let reported = -1;
|
|
152
|
+
// A line for the stage's live panel each time this thread's image count moves.
|
|
153
|
+
const report = () => {
|
|
154
|
+
if (req.onProgress === undefined)
|
|
155
|
+
return;
|
|
156
|
+
const count = threadId === undefined ? 0 : codexImageCount(env, threadId);
|
|
157
|
+
if (count === reported)
|
|
158
|
+
return;
|
|
159
|
+
reported = count;
|
|
160
|
+
try {
|
|
161
|
+
req.onProgress(count === 0
|
|
162
|
+
? "Codex is working on the image…"
|
|
163
|
+
: `Codex is refining the image… ${String(count)} ${count === 1 ? "image" : "images"} so far`);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// A progress line is optional; it never fails the image.
|
|
167
|
+
}
|
|
168
|
+
};
|
|
101
169
|
try {
|
|
170
|
+
if (req.reference !== undefined)
|
|
171
|
+
writeFileSync(codexReferencePath(directory, req.reference), req.reference.bytes, {
|
|
172
|
+
mode: 0o600,
|
|
173
|
+
});
|
|
174
|
+
report();
|
|
102
175
|
try {
|
|
103
176
|
run = deps.run(binary, codexImageArgs(req, directory), req.signal, {
|
|
104
177
|
cwd: directory,
|
|
@@ -118,6 +191,9 @@ export function codexImage(deps) {
|
|
|
118
191
|
continue;
|
|
119
192
|
const event = cliEvent(binary, line);
|
|
120
193
|
req.signal.throwIfAborted();
|
|
194
|
+
// Only the whole turn's end counts: an agent that reviews its work draws, looks and
|
|
195
|
+
// draws again, so the first image is rarely its answer.
|
|
196
|
+
report();
|
|
121
197
|
if (event.type === "thread.started") {
|
|
122
198
|
if (threadId !== undefined)
|
|
123
199
|
throw providerError({
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { redact } from "../../kernel/log.js";
|
|
3
3
|
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
-
import { httpFailure, missingKey, noImage, unreadable } from "../explain.js";
|
|
4
|
+
import { httpFailure, internalError, missingKey, noImage, providerSaid, unreadable, } from "../explain.js";
|
|
5
5
|
import { retryAfter } from "../retry-after.js";
|
|
6
6
|
import { downloadImage } from "./bytes.js";
|
|
7
|
+
import { withReferenceNote } from "./reference.js";
|
|
8
|
+
import { dataUri, downloadVideo } from "./video.js";
|
|
7
9
|
// The HTTP gateway adapter for fal.ai: `fetch` and the downloader beside this file, no SDK.
|
|
8
|
-
// `@fal-ai/client` would buy queue polling
|
|
9
|
-
// own `fetch` covers
|
|
10
|
-
//
|
|
10
|
+
// `@fal-ai/client` would buy queue polling, which only the video clips need and which is three
|
|
11
|
+
// plain requests, so the platform's own `fetch` covers every call. Images use the synchronous
|
|
12
|
+
// host, which runs the model on the open connection the wrapper's 300 s measures.
|
|
11
13
|
export const falBase = "https://fal.run";
|
|
12
14
|
// fal has no endpoint listing only its text-to-image models, so the list is this adapter's own
|
|
13
15
|
// data and adding one is a line here plus its aspect shape below.
|
|
@@ -48,6 +50,36 @@ function aspectOf(model, aspect) {
|
|
|
48
50
|
const shape = field ?? "image_size";
|
|
49
51
|
return { [shape]: sizes[shape][aspect] };
|
|
50
52
|
}
|
|
53
|
+
// The text-to-image models whose fal endpoint has an `/edit` twin taking the same fields plus
|
|
54
|
+
// `image_urls`, which is how an establishing image is sent.
|
|
55
|
+
const editModels = [
|
|
56
|
+
"fal-ai/flux-2",
|
|
57
|
+
"fal-ai/nano-banana",
|
|
58
|
+
"fal-ai/nano-banana-2",
|
|
59
|
+
];
|
|
60
|
+
function editEndpoint(model) {
|
|
61
|
+
return editModels.includes(model) ? `${model}/edit` : undefined;
|
|
62
|
+
}
|
|
63
|
+
// Image-to-video runs on fal's queue, not the synchronous host: a clip takes one to five
|
|
64
|
+
// minutes, and fal documents the queue as the way to call anything that slow. A request is
|
|
65
|
+
// submitted, its status polled until it completes, and its answer fetched from the links the
|
|
66
|
+
// submission gave.
|
|
67
|
+
export const falQueueBase = "https://queue.fal.run";
|
|
68
|
+
export const falPollMs = 5000;
|
|
69
|
+
// The image-to-video models this adapter knows the inputs of. Each takes `prompt`,
|
|
70
|
+
// `image_url` and a `duration` string; the rest is per model. A model not listed gets only
|
|
71
|
+
// those three.
|
|
72
|
+
const videoInputs = {
|
|
73
|
+
"fal-ai/kling-video/v2.5-turbo/pro/image-to-video": () => ({}),
|
|
74
|
+
"fal-ai/wan-25-preview/image-to-video": () => ({ resolution: "720p" }),
|
|
75
|
+
"fal-ai/bytedance/seedance/v1/pro/fast/image-to-video": (aspect) => ({
|
|
76
|
+
resolution: "720p",
|
|
77
|
+
aspect_ratio: aspect,
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
const queued = z.object({ status_url: z.string(), response_url: z.string() });
|
|
81
|
+
const queueStatus = z.object({ status: z.string(), error: z.string().nullish() });
|
|
82
|
+
const video = z.object({ video: z.object({ url: z.string() }) });
|
|
51
83
|
// A wire payload is narrowed, never cast: everything unlisted is dropped at the seam.
|
|
52
84
|
const generated = z.object({
|
|
53
85
|
images: z.array(z.object({ url: z.string() })),
|
|
@@ -68,12 +100,21 @@ export function falImage(deps) {
|
|
|
68
100
|
id: "fal",
|
|
69
101
|
models: () => Promise.resolve(falModels),
|
|
70
102
|
generate: async (req) => {
|
|
71
|
-
|
|
103
|
+
// An establishing image goes to the model's edit endpoint, which takes input images as
|
|
104
|
+
// `image_urls`; only the models that have one are marked as taking a reference.
|
|
105
|
+
const edit = req.reference === undefined ? undefined : editEndpoint(req.model);
|
|
106
|
+
if (req.reference !== undefined && edit === undefined)
|
|
107
|
+
throw providerError({
|
|
108
|
+
kind: "unsupported",
|
|
109
|
+
message: "This fal.ai model can't use an establishing image as a reference. Choose FLUX.2 or Nano Banana 2 under Images → Model on Play or in Edit project → Providers, or set Establishing image to Off in the Images section.",
|
|
110
|
+
});
|
|
111
|
+
const response = await deps.fetch(`${falBase}/${edit ?? req.model}`, {
|
|
72
112
|
method: "POST",
|
|
73
113
|
signal: req.signal,
|
|
74
114
|
headers: { Authorization: `Key ${keyOf(deps)}`, "Content-Type": "application/json" },
|
|
75
115
|
body: JSON.stringify({
|
|
76
|
-
prompt: req.prompt,
|
|
116
|
+
prompt: withReferenceNote(req.prompt, req.reference),
|
|
117
|
+
...(req.reference === undefined ? {} : { image_urls: [dataUri(req.reference)] }),
|
|
77
118
|
...aspectOf(req.model, req.aspect),
|
|
78
119
|
// The stage sends Number as that many independent calls, one piece each.
|
|
79
120
|
num_images: 1,
|
|
@@ -99,8 +140,58 @@ export function falImage(deps) {
|
|
|
99
140
|
signal: req.signal,
|
|
100
141
|
});
|
|
101
142
|
},
|
|
143
|
+
animate: async (req) => {
|
|
144
|
+
const clock = deps.clock;
|
|
145
|
+
if (clock === undefined)
|
|
146
|
+
throw providerError({ kind: "other", message: internalError("fal.ai has no clock") });
|
|
147
|
+
const headers = { Authorization: `Key ${keyOf(deps)}`, "Content-Type": "application/json" };
|
|
148
|
+
const submitted = await deps.fetch(`${falQueueBase}/${req.model}`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
signal: req.signal,
|
|
151
|
+
headers,
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
prompt: req.prompt,
|
|
154
|
+
image_url: dataUri(req.image),
|
|
155
|
+
duration: String(Math.round(req.seconds)),
|
|
156
|
+
...(Object.hasOwn(videoInputs, req.model) ? videoInputs[req.model]?.(req.aspect) : {}),
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
if (!submitted.ok)
|
|
160
|
+
throw await failure(submitted, "video clip request");
|
|
161
|
+
const links = read(queued, await submitted.text());
|
|
162
|
+
for (;;) {
|
|
163
|
+
const status = await deps.fetch(links.status_url, { signal: req.signal, headers });
|
|
164
|
+
if (!status.ok)
|
|
165
|
+
throw await failure(status, "video clip request");
|
|
166
|
+
const current = read(queueStatus, await status.text());
|
|
167
|
+
if (current.error)
|
|
168
|
+
throw providerError({
|
|
169
|
+
kind: "other",
|
|
170
|
+
message: providerSaid("fal.ai", "could not make the video clip", redact(current.error), "Use Retry stage; if it keeps happening, choose another image-to-video model under Animate images in Edit project → Inputs → Look."),
|
|
171
|
+
});
|
|
172
|
+
if (current.status === "COMPLETED")
|
|
173
|
+
break;
|
|
174
|
+
await clock.sleep(falPollMs, req.signal);
|
|
175
|
+
}
|
|
176
|
+
const answer = await deps.fetch(links.response_url, { signal: req.signal, headers });
|
|
177
|
+
if (!answer.ok)
|
|
178
|
+
throw await failure(answer, "video clip request");
|
|
179
|
+
const made = read(video, await answer.text());
|
|
180
|
+
return await downloadVideo({
|
|
181
|
+
fetch: deps.fetch,
|
|
182
|
+
provider: "fal.ai",
|
|
183
|
+
url: made.video.url,
|
|
184
|
+
signal: req.signal,
|
|
185
|
+
});
|
|
186
|
+
},
|
|
102
187
|
};
|
|
103
188
|
}
|
|
189
|
+
function read(schema, text) {
|
|
190
|
+
const parsed = schema.safeParse(safeJson(text));
|
|
191
|
+
if (!parsed.success)
|
|
192
|
+
throw providerError({ kind: "other", message: unreadable("fal.ai") });
|
|
193
|
+
return parsed.data;
|
|
194
|
+
}
|
|
104
195
|
function keyOf(deps) {
|
|
105
196
|
// `missing_key`, not `auth`, because that rule makes it terminal.
|
|
106
197
|
const key = deps.key();
|
|
@@ -134,7 +225,7 @@ function kindOf(status) {
|
|
|
134
225
|
// has to reach the port's error contract first, which is not this adapter's to widen.
|
|
135
226
|
return "other";
|
|
136
227
|
}
|
|
137
|
-
async function failure(response) {
|
|
228
|
+
async function failure(response, subject = "image request") {
|
|
138
229
|
const text = await response.text().catch(() => "");
|
|
139
230
|
// The provider's own words, through the redactor - an error body may quote the key back.
|
|
140
231
|
const message = redact(detailOf(text) || response.statusText);
|
|
@@ -145,7 +236,7 @@ async function failure(response) {
|
|
|
145
236
|
provider: "fal.ai",
|
|
146
237
|
status: response.status,
|
|
147
238
|
detail: message,
|
|
148
|
-
subject
|
|
239
|
+
subject,
|
|
149
240
|
}),
|
|
150
241
|
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
151
242
|
});
|
|
@@ -5,6 +5,7 @@ import { httpFailure, missingKey, noImage, quoted, refusedImage, unreadable } fr
|
|
|
5
5
|
import { retryAfter } from "../retry-after.js";
|
|
6
6
|
import { describeBytes, sniffImage } from "./bytes.js";
|
|
7
7
|
import { discoverGoogleImages } from "./models.js";
|
|
8
|
+
import { withReferenceNote } from "./reference.js";
|
|
8
9
|
// The HTTP gateway adapter for Google's own image generation, billed to a Gemini API key
|
|
9
10
|
// rather than to a host reselling the same models. Like the OpenAI one it hands back the
|
|
10
11
|
// bytes with the call, so there is no link to follow.
|
|
@@ -62,7 +63,18 @@ export function googleImage(deps) {
|
|
|
62
63
|
headers: { "x-goog-api-key": keyOf(deps), "Content-Type": "application/json" },
|
|
63
64
|
body: JSON.stringify({
|
|
64
65
|
model: req.model,
|
|
65
|
-
input
|
|
66
|
+
// With an establishing image the input is two parts, the image first; the Gemini
|
|
67
|
+
// image models draw with an input image as their reference.
|
|
68
|
+
input: req.reference === undefined
|
|
69
|
+
? req.prompt
|
|
70
|
+
: [
|
|
71
|
+
{
|
|
72
|
+
type: "image",
|
|
73
|
+
mime_type: req.reference.mime,
|
|
74
|
+
data: Buffer.from(req.reference.bytes).toString("base64"),
|
|
75
|
+
},
|
|
76
|
+
{ type: "text", text: withReferenceNote(req.prompt, req.reference) },
|
|
77
|
+
],
|
|
66
78
|
// The stage sends Number as that many independent calls, one piece each, so one
|
|
67
79
|
// image per request is what it asks for. Nothing about style is set: the stage
|
|
68
80
|
// asks for the provider's own.
|
|
@@ -5,6 +5,7 @@ import { httpFailure, missingKey, noImage, refusedImage, unreadable } from "../e
|
|
|
5
5
|
import { retryAfter } from "../retry-after.js";
|
|
6
6
|
import { describeBytes, sniffImage } from "./bytes.js";
|
|
7
7
|
import { discoverOpenAiImages } from "./models.js";
|
|
8
|
+
import { withReferenceNote } from "./reference.js";
|
|
8
9
|
// The HTTP gateway adapter for OpenAI's images endpoint: the platform's own `fetch` and
|
|
9
10
|
// nothing else, because the whole call is one request. Unlike fal and Replicate this one
|
|
10
11
|
// hands back the image itself - a GPT image model always answers with base64, never a URL -
|
|
@@ -53,21 +54,33 @@ export function openAiImage(deps) {
|
|
|
53
54
|
id: "openai-image",
|
|
54
55
|
models: () => discoverOpenAiImages(deps),
|
|
55
56
|
generate: async (req) => {
|
|
56
|
-
const response =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
57
|
+
const response = req.reference === undefined
|
|
58
|
+
? await deps.fetch(`${openAiImagesBase}/images/generations`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
signal: req.signal,
|
|
61
|
+
headers: {
|
|
62
|
+
Authorization: `Bearer ${keyOf(deps)}`,
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify({
|
|
66
|
+
model: req.model,
|
|
67
|
+
prompt: req.prompt,
|
|
68
|
+
// The stage sends Number as that many independent calls, one piece each, so
|
|
69
|
+
// one image per request is what it asks for.
|
|
70
|
+
n: 1,
|
|
71
|
+
size: sizeFor(req.model, req.aspect),
|
|
72
|
+
// No `quality` and no `background`: the stage asks for the provider's default
|
|
73
|
+
// quality and style, which is what leaving them off means.
|
|
74
|
+
}),
|
|
75
|
+
})
|
|
76
|
+
: // With an establishing image the GPT image models take it as an input image on the
|
|
77
|
+
// edits endpoint, a multipart form; the note says it is a reference, not the canvas.
|
|
78
|
+
await deps.fetch(`${openAiImagesBase}/images/edits`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
signal: req.signal,
|
|
81
|
+
headers: { Authorization: `Bearer ${keyOf(deps)}` },
|
|
82
|
+
body: editForm(req, req.reference),
|
|
83
|
+
});
|
|
71
84
|
if (!response.ok) {
|
|
72
85
|
throw await failure(response);
|
|
73
86
|
}
|
|
@@ -79,6 +92,15 @@ export function openAiImage(deps) {
|
|
|
79
92
|
},
|
|
80
93
|
};
|
|
81
94
|
}
|
|
95
|
+
function editForm(req, reference) {
|
|
96
|
+
const form = new FormData();
|
|
97
|
+
form.set("model", req.model);
|
|
98
|
+
form.set("prompt", withReferenceNote(req.prompt, reference));
|
|
99
|
+
form.set("n", "1");
|
|
100
|
+
form.set("size", sizeFor(req.model, req.aspect));
|
|
101
|
+
form.append("image[]", new Blob([Uint8Array.from(reference.bytes)], { type: reference.mime }), reference.mime === "image/jpeg" ? "reference.jpg" : "reference.png");
|
|
102
|
+
return form;
|
|
103
|
+
}
|
|
82
104
|
// A GPT image model always answers with base64, so the bytes arrive with the call and
|
|
83
105
|
// there is no link to follow. They are still sniffed: what the port stores is a PNG or a
|
|
84
106
|
// JPEG, and a truncated payload that decodes to something else must not reach the disk.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// What an image API is told about the establishing image sent with a request, ahead of the
|
|
2
|
+
// brief. An edit endpoint otherwise treats its input as the picture to change, so it says the
|
|
3
|
+
// input is a reference for the look and the cast, not the composition to keep.
|
|
4
|
+
export const referenceNote = "The attached image is a visual reference only: keep its characters, rendering style and colour palette, but do not copy its composition, pose or framing. Compose a new image from this brief:";
|
|
5
|
+
export function withReferenceNote(prompt, reference) {
|
|
6
|
+
return reference === undefined ? prompt : `${referenceNote}\n\n${prompt}`;
|
|
7
|
+
}
|