runline 0.31.0 → 0.33.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/plugin/schema.d.ts +2 -4
- package/dist/plugin/schema.js +47 -12
- package/dist/plugins/_shared/{imageFile.js → mediaFile.js} +33 -12
- package/dist/plugins/_shared/provider.js +22 -16
- package/dist/plugins/_shared/shiftCloud.js +34 -10
- package/dist/plugins/_shared/shiftUpload.js +1 -1
- package/dist/plugins/elevenlabs/src/index.js +277 -0
- package/dist/plugins/elevenlabs/src/schemas.js +115 -0
- package/dist/plugins/elevenlabs/src/shared.js +272 -0
- package/dist/plugins/elevenlabs/src/workflows.js +191 -0
- package/dist/plugins/fal/src/index.js +181 -0
- package/dist/plugins/fal/src/shared.js +303 -0
- package/dist/plugins/googleImage/src/index.js +1 -1
- package/dist/plugins/openai/src/index.js +1 -1
- package/dist/plugins/recraft/src/index.js +1 -1
- package/dist/plugins/replicate/src/index.js +5 -6
- package/dist/plugins/shiftBwm/src/collections.js +201 -0
- package/dist/plugins/shiftBwm/src/index.js +33 -0
- package/dist/plugins/shiftBwm/src/records.js +180 -0
- package/dist/plugins/shiftBwm/src/shared.js +143 -0
- package/dist/plugins/shiftBwm/src/vendor/client.js +227 -0
- package/dist/plugins/shiftBwm/src/vendor/contracts.js +59 -0
- package/dist/plugins/shiftBwm/src/vendor/models.js +525 -0
- package/dist/plugins/shiftObjects/src/objects.js +1 -2
- package/dist/plugins/shiftOcr/src/index.js +3 -1
- package/dist/plugins/shiftTranscription/src/shared.js +1 -1
- package/dist/plugins/shiftTranscription/src/transcription.js +4 -3
- package/dist/plugins/together/src/index.js +1 -1
- package/dist/plugins/xai/src/index.js +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import * as t from "typebox";
|
|
2
|
+
import { readImageInput } from "../../_shared/mediaFile.js";
|
|
3
|
+
import { parseSize } from "../../_shared/parseSize.js";
|
|
4
|
+
import { assertModelId, cancel, DEFAULT_TIMEOUT_MS, extraInputSchema, result, runModel, STRICT, saveDirSchema, savedResult, status, submit, timeoutSchema, } from "./shared.js";
|
|
5
|
+
const IMAGE_MODEL = "fal-ai/flux/schnell";
|
|
6
|
+
const EDIT_MODEL = "fal-ai/flux-pro/kontext";
|
|
7
|
+
const VIDEO_MODEL = "fal-ai/kling-video/v1/standard/text-to-video";
|
|
8
|
+
const model = t.String({
|
|
9
|
+
minLength: 1,
|
|
10
|
+
description: "fal endpoint id: owner/model[/variant], workflows/owner/model, or comfy/owner/model.",
|
|
11
|
+
});
|
|
12
|
+
const prompt = t.String({
|
|
13
|
+
minLength: 1,
|
|
14
|
+
pattern: "\\S",
|
|
15
|
+
description: "Describe the image, edit, or video.",
|
|
16
|
+
});
|
|
17
|
+
const fileOptions = { saveDir: t.Optional(saveDirSchema) };
|
|
18
|
+
const generationOptions = {
|
|
19
|
+
...fileOptions,
|
|
20
|
+
timeoutMs: t.Optional(timeoutSchema),
|
|
21
|
+
extraInput: t.Optional(extraInputSchema),
|
|
22
|
+
};
|
|
23
|
+
const createSchema = t.Object({
|
|
24
|
+
...generationOptions,
|
|
25
|
+
prompt,
|
|
26
|
+
model: t.Optional(t.String({ ...model, default: IMAGE_MODEL })),
|
|
27
|
+
size: t.Optional(t.String({
|
|
28
|
+
pattern: "^[1-9][0-9]*x[1-9][0-9]*$",
|
|
29
|
+
description: "WxH, sent as image_size. Omit for the model's default. For other size fields use extraInput.",
|
|
30
|
+
})),
|
|
31
|
+
n: t.Optional(t.Integer({
|
|
32
|
+
minimum: 1,
|
|
33
|
+
maximum: 4,
|
|
34
|
+
default: 1,
|
|
35
|
+
description: "num_images; supported counts depend on the model.",
|
|
36
|
+
})),
|
|
37
|
+
}, STRICT);
|
|
38
|
+
const editSchema = t.Object({
|
|
39
|
+
...generationOptions,
|
|
40
|
+
prompt,
|
|
41
|
+
model: t.Optional(t.String({ ...model, default: EDIT_MODEL })),
|
|
42
|
+
imagePath: t.String({
|
|
43
|
+
minLength: 1,
|
|
44
|
+
description: "Local source image, sent as a data URI. For hosted input URLs use fal.run instead.",
|
|
45
|
+
}),
|
|
46
|
+
imageInputKey: t.Optional(t.String({
|
|
47
|
+
minLength: 1,
|
|
48
|
+
default: "image_url",
|
|
49
|
+
description: "Source field; keys ending in _urls receive an array. Use image_urls for fal-ai/nano-banana/edit.",
|
|
50
|
+
})),
|
|
51
|
+
}, STRICT);
|
|
52
|
+
const videoSchema = t.Object({
|
|
53
|
+
...generationOptions,
|
|
54
|
+
prompt,
|
|
55
|
+
model: t.Optional(t.String({ ...model, default: VIDEO_MODEL })),
|
|
56
|
+
}, STRICT);
|
|
57
|
+
const modelInput = {
|
|
58
|
+
model,
|
|
59
|
+
input: t.Record(t.String(), t.Unknown(), {
|
|
60
|
+
description: "Model input body, verbatim. See https://fal.ai/models/<model>/api.",
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
const runSchema = t.Object({ ...modelInput, ...fileOptions, timeoutMs: t.Optional(timeoutSchema) }, STRICT);
|
|
64
|
+
const submitSchema = t.Object(modelInput, STRICT);
|
|
65
|
+
const requestInput = {
|
|
66
|
+
model,
|
|
67
|
+
requestId: t.String({
|
|
68
|
+
minLength: 1,
|
|
69
|
+
description: "Request id returned by queue.submit or a timeout error.",
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
const statusInput = t.Object({ ...requestInput, logs: t.Optional(t.Boolean({ default: false })) }, STRICT);
|
|
73
|
+
const resultInput = t.Object({ ...requestInput, ...fileOptions }, STRICT);
|
|
74
|
+
const cancelInput = t.Object(requestInput, STRICT);
|
|
75
|
+
async function generate(ctx, p, model, input, kind) {
|
|
76
|
+
const id = assertModelId(model);
|
|
77
|
+
const { output, requestId } = await runModel(ctx, id, { ...input, ...p.extraInput }, p.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
78
|
+
return savedResult(output, id, requestId, p.saveDir, kind);
|
|
79
|
+
}
|
|
80
|
+
/** fal model inference via its queue API. Generation is billed; downloads return local paths. */
|
|
81
|
+
export default function fal(rl) {
|
|
82
|
+
rl.setName("fal");
|
|
83
|
+
rl.setVersion("0.1.0");
|
|
84
|
+
rl.setConnectionSchema(t.Object({
|
|
85
|
+
apiKey: t.String({
|
|
86
|
+
minLength: 1,
|
|
87
|
+
env: "FAL_KEY",
|
|
88
|
+
description: "API key from https://fal.ai/dashboard/keys, sent as Authorization: Key <key>.",
|
|
89
|
+
}),
|
|
90
|
+
}));
|
|
91
|
+
rl.registerAction("image.create", {
|
|
92
|
+
access: "write",
|
|
93
|
+
description: `Generate images through fal (billed). Defaults to ${IMAGE_MODEL}. Saves files and returns images[].path for send_file. Additional model parameters go in extraInput.`,
|
|
94
|
+
inputSchema: createSchema,
|
|
95
|
+
async execute(input, ctx) {
|
|
96
|
+
const p = input;
|
|
97
|
+
return generate(ctx, p, p.model ?? IMAGE_MODEL, {
|
|
98
|
+
prompt: p.prompt,
|
|
99
|
+
num_images: p.n ?? 1,
|
|
100
|
+
...(p.size ? { image_size: parseSize(p.size, "fal") } : {}),
|
|
101
|
+
}, "images");
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
rl.registerAction("image.edit", {
|
|
105
|
+
access: "write",
|
|
106
|
+
description: `Edit a local image through fal (billed). Defaults to ${EDIT_MODEL}, using image_url. For fal-ai/nano-banana/edit set imageInputKey to image_urls. Returns images[].path.`,
|
|
107
|
+
inputSchema: editSchema,
|
|
108
|
+
async execute(input, ctx) {
|
|
109
|
+
const p = input;
|
|
110
|
+
const image = readImageInput(p.imagePath, "fal");
|
|
111
|
+
const key = p.imageInputKey ?? "image_url";
|
|
112
|
+
return generate(ctx, p, p.model ?? EDIT_MODEL, {
|
|
113
|
+
prompt: p.prompt,
|
|
114
|
+
[key]: key.endsWith("_urls") ? [image.dataUri] : image.dataUri,
|
|
115
|
+
}, "images");
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
rl.registerAction("video.create", {
|
|
119
|
+
access: "write",
|
|
120
|
+
description: `Generate a video through fal (billed). Defaults to ${VIDEO_MODEL}; override model and supply its parameters in extraInput. Returns videos[].path. For long jobs prefer queue.submit to avoid host execution deadlines.`,
|
|
121
|
+
inputSchema: videoSchema,
|
|
122
|
+
async execute(input, ctx) {
|
|
123
|
+
const p = input;
|
|
124
|
+
return generate(ctx, p, p.model ?? VIDEO_MODEL, { prompt: p.prompt }, "videos");
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
rl.registerAction("run", {
|
|
128
|
+
access: "write",
|
|
129
|
+
description: "Run a fal endpoint with its model-specific input (billed), and wait. Returns raw output plus downloaded files from common top-level image/video/audio/file fields. Automatic downloads accept HTTPS fal.media URLs or data URIs only, up to 512 MiB per file; JSON responses are limited to 32 MiB.",
|
|
130
|
+
inputSchema: runSchema,
|
|
131
|
+
async execute(input, ctx) {
|
|
132
|
+
const p = input;
|
|
133
|
+
const id = assertModelId(p.model);
|
|
134
|
+
const { output, requestId } = await runModel(ctx, id, p.input, p.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
135
|
+
return savedResult(output, id, requestId, p.saveDir);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
rl.registerAction("queue.submit", {
|
|
139
|
+
access: "write",
|
|
140
|
+
description: "Submit billed model work and return immediately. Save model and requestId, poll queue.status, then collect with queue.result. No client-side retries: repeating a submission can bill twice.",
|
|
141
|
+
inputSchema: submitSchema,
|
|
142
|
+
async execute(input, ctx) {
|
|
143
|
+
const p = input;
|
|
144
|
+
const id = assertModelId(p.model);
|
|
145
|
+
const receipt = await submit(ctx, id, p.input);
|
|
146
|
+
return {
|
|
147
|
+
model: id,
|
|
148
|
+
requestId: receipt.request_id,
|
|
149
|
+
queuePosition: receipt.queue_position,
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
rl.registerAction("queue.status", {
|
|
154
|
+
access: "read",
|
|
155
|
+
description: "Read queue state (IN_QUEUE, IN_PROGRESS, COMPLETED), optionally including logs. COMPLETED can indicate failure; inspect error/error_type or retrieve the result.",
|
|
156
|
+
inputSchema: statusInput,
|
|
157
|
+
async execute(input, ctx) {
|
|
158
|
+
const p = input;
|
|
159
|
+
return status(ctx, p.model, p.requestId, p.logs);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
rl.registerAction("queue.result", {
|
|
163
|
+
access: "write",
|
|
164
|
+
description: "Retrieve completed output without re-running or re-billing. Writes common image/video/audio/file outputs to disk and returns files[].path. Download limits: HTTPS fal.media URLs or data URIs, 512 MiB per file, 60 seconds per file; JSON responses up to 32 MiB.",
|
|
165
|
+
inputSchema: resultInput,
|
|
166
|
+
async execute(input, ctx) {
|
|
167
|
+
const p = input;
|
|
168
|
+
const id = assertModelId(p.model);
|
|
169
|
+
return savedResult(await result(ctx, id, p.requestId), id, p.requestId, p.saveDir);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
rl.registerAction("queue.cancel", {
|
|
173
|
+
access: "write",
|
|
174
|
+
description: "Request cancellation. Queued work is removed; in-progress work may still finish and incur charges. Fails if already completed or missing.",
|
|
175
|
+
inputSchema: cancelInput,
|
|
176
|
+
async execute(input, ctx) {
|
|
177
|
+
const p = input;
|
|
178
|
+
return cancel(ctx, p.model, p.requestId);
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import * as t from "typebox";
|
|
3
|
+
import { Check } from "typebox/value";
|
|
4
|
+
import { authedFetch } from "../../_shared/authedFetch.js";
|
|
5
|
+
import { SEND_FILE_NOTE, writeMediaFile, } from "../../_shared/mediaFile.js";
|
|
6
|
+
import { readBounded, readBoundedBytes, seg } from "../../_shared/provider.js";
|
|
7
|
+
export const STRICT = { additionalProperties: false };
|
|
8
|
+
export const DEFAULT_TIMEOUT_MS = 300_000;
|
|
9
|
+
const QUEUE_BASE = "https://queue.fal.run";
|
|
10
|
+
const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
|
|
11
|
+
const MAX_JSON_BYTES = 32 * 1024 * 1024;
|
|
12
|
+
const receiptSchema = t.Object({
|
|
13
|
+
request_id: t.String({ minLength: 1 }),
|
|
14
|
+
status_url: t.Optional(t.String()),
|
|
15
|
+
response_url: t.Optional(t.String()),
|
|
16
|
+
cancel_url: t.Optional(t.String()),
|
|
17
|
+
queue_position: t.Optional(t.Integer()),
|
|
18
|
+
});
|
|
19
|
+
const statusSchema = t.Object({
|
|
20
|
+
status: t.Union([
|
|
21
|
+
t.Literal("IN_QUEUE"),
|
|
22
|
+
t.Literal("IN_PROGRESS"),
|
|
23
|
+
t.Literal("COMPLETED"),
|
|
24
|
+
]),
|
|
25
|
+
error: t.Optional(t.Union([t.String(), t.Null()])),
|
|
26
|
+
error_type: t.Optional(t.Union([t.String(), t.Null()])),
|
|
27
|
+
});
|
|
28
|
+
function object(value) {
|
|
29
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
export function assertModelId(model) {
|
|
32
|
+
const id = model.trim();
|
|
33
|
+
const parts = id.split("/");
|
|
34
|
+
const namespaced = parts[0] === "workflows" || parts[0] === "comfy";
|
|
35
|
+
if (!/^[\w-]+(\/[\w.-]+)+$/.test(id) ||
|
|
36
|
+
parts.some((part) => part === "." || part === "..") ||
|
|
37
|
+
(namespaced && parts.length < 3)) {
|
|
38
|
+
throw new Error("fal: invalid model id; expected owner/model[/variant] or workflows/owner/model[/variant]");
|
|
39
|
+
}
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
/** Queue operations address the application, excluding its inference sub-path. */
|
|
43
|
+
function queueUrl(model, suffix = "") {
|
|
44
|
+
const id = assertModelId(model);
|
|
45
|
+
const parts = id.split("/");
|
|
46
|
+
const count = parts[0] === "workflows" || parts[0] === "comfy" ? 3 : 2;
|
|
47
|
+
return `${QUEUE_BASE}/${suffix ? parts.slice(0, count).join("/") : id}${suffix}`;
|
|
48
|
+
}
|
|
49
|
+
/** Keep validation field names and error types, without echoing model inputs. */
|
|
50
|
+
export function explainFalError(status, body) {
|
|
51
|
+
const data = object(body) ? body : {};
|
|
52
|
+
const detail = data.detail;
|
|
53
|
+
const message = Array.isArray(detail)
|
|
54
|
+
? detail
|
|
55
|
+
.filter(object)
|
|
56
|
+
.map((entry) => {
|
|
57
|
+
const field = Array.isArray(entry.loc) ? entry.loc.join(".") : "body";
|
|
58
|
+
return `${field}: ${String(entry.msg ?? "invalid")} [${String(entry.type ?? "error")}]`;
|
|
59
|
+
})
|
|
60
|
+
.join("; ")
|
|
61
|
+
: typeof detail === "string"
|
|
62
|
+
? detail
|
|
63
|
+
: typeof data.status === "string"
|
|
64
|
+
? data.status
|
|
65
|
+
: "request failed";
|
|
66
|
+
const type = typeof data.error_type === "string" ? ` [${data.error_type}]` : "";
|
|
67
|
+
return `fal ${status}: ${message.slice(0, 1000)}${type}`;
|
|
68
|
+
}
|
|
69
|
+
/** All credential-bearing calls use a fixed origin and refuse redirects. */
|
|
70
|
+
async function request(ctx, url, init = {}) {
|
|
71
|
+
const key = ctx.connection.config.apiKey;
|
|
72
|
+
if (typeof key !== "string" || !key.trim())
|
|
73
|
+
throw new Error("Missing FAL_KEY. Create a key at https://fal.ai/dashboard/keys.");
|
|
74
|
+
const response = await authedFetch(url, {
|
|
75
|
+
...init,
|
|
76
|
+
headers: {
|
|
77
|
+
authorization: `Key ${key.trim()}`,
|
|
78
|
+
accept: "application/json",
|
|
79
|
+
"content-type": "application/json",
|
|
80
|
+
},
|
|
81
|
+
signal: init.signal ?? AbortSignal.timeout(60_000),
|
|
82
|
+
});
|
|
83
|
+
const text = await readBounded(response, MAX_JSON_BYTES, "fal: response exceeds 32 MiB");
|
|
84
|
+
let body;
|
|
85
|
+
try {
|
|
86
|
+
body = JSON.parse(text);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw new Error(`fal HTTP ${response.status}: non-JSON response`);
|
|
90
|
+
}
|
|
91
|
+
if (!response.ok)
|
|
92
|
+
throw new Error(explainFalError(response.status, body));
|
|
93
|
+
return body;
|
|
94
|
+
}
|
|
95
|
+
export async function submit(ctx, model, input, signal) {
|
|
96
|
+
// A failed POST can have been accepted, so submissions are never retried here.
|
|
97
|
+
const receipt = await request(ctx, queueUrl(model), {
|
|
98
|
+
method: "POST",
|
|
99
|
+
body: JSON.stringify(input),
|
|
100
|
+
signal,
|
|
101
|
+
});
|
|
102
|
+
if (!Check(receiptSchema, receipt))
|
|
103
|
+
throw new Error("fal: invalid submission receipt or no request id; do not resubmit automatically");
|
|
104
|
+
seg(receipt.request_id, "request id", "fal");
|
|
105
|
+
return receipt;
|
|
106
|
+
}
|
|
107
|
+
export async function status(ctx, model, requestId, logs = false, signal) {
|
|
108
|
+
const body = await request(ctx, queueUrl(model, `/requests/${seg(requestId, "request id", "fal")}/status${logs ? "?logs=1" : ""}`), { signal });
|
|
109
|
+
if (!Check(statusSchema, body))
|
|
110
|
+
throw new Error("fal: invalid queue status response");
|
|
111
|
+
return body;
|
|
112
|
+
}
|
|
113
|
+
export async function result(ctx, model, requestId, signal) {
|
|
114
|
+
const body = await request(ctx, queueUrl(model, `/requests/${seg(requestId, "request id", "fal")}`), { signal });
|
|
115
|
+
if (!object(body))
|
|
116
|
+
throw new Error("fal: expected an object result");
|
|
117
|
+
return body;
|
|
118
|
+
}
|
|
119
|
+
export async function cancel(ctx, model, requestId) {
|
|
120
|
+
const body = await request(ctx, queueUrl(model, `/requests/${seg(requestId, "request id", "fal")}/cancel`), { method: "PUT" });
|
|
121
|
+
if (!object(body) || body.status !== "CANCELLATION_REQUESTED")
|
|
122
|
+
throw new Error("fal: cancellation was not confirmed");
|
|
123
|
+
return body;
|
|
124
|
+
}
|
|
125
|
+
/** One deadline covers submission, polling, and result retrieval, including response bodies. */
|
|
126
|
+
export async function runModel(ctx, model, input, timeoutMs) {
|
|
127
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
128
|
+
let requestId;
|
|
129
|
+
try {
|
|
130
|
+
requestId = (await submit(ctx, model, input, signal)).request_id;
|
|
131
|
+
for (;;) {
|
|
132
|
+
signal.throwIfAborted();
|
|
133
|
+
const current = await status(ctx, model, requestId, false, signal);
|
|
134
|
+
if (current.status === "COMPLETED") {
|
|
135
|
+
if (current.error || current.error_type)
|
|
136
|
+
throw new Error(`fal: ${current.error ?? "generation failed"}${current.error_type ? ` [${current.error_type}]` : ""}`);
|
|
137
|
+
return {
|
|
138
|
+
output: await result(ctx, model, requestId, signal),
|
|
139
|
+
requestId,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
await sleep(1000, undefined, { signal });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const message = signal.aborted
|
|
147
|
+
? `fal: timed out after ${timeoutMs}ms`
|
|
148
|
+
: error instanceof Error
|
|
149
|
+
? error.message
|
|
150
|
+
: String(error);
|
|
151
|
+
const recovery = requestId
|
|
152
|
+
? `Collect with fal.queue.result using model ${model} and requestId ${requestId}; do not submit a replacement automatically.`
|
|
153
|
+
: "Submission may have been accepted; do not resubmit automatically.";
|
|
154
|
+
throw new Error(`${message}. ${recovery}`, { cause: error });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const MEDIA_FIELDS = {
|
|
158
|
+
images: ["images", "image"],
|
|
159
|
+
videos: ["video", "videos"],
|
|
160
|
+
other: ["audio", "audio_url", "file", "files"],
|
|
161
|
+
};
|
|
162
|
+
/** Only documented top-level media fields are downloaded; other output remains untouched. */
|
|
163
|
+
export function collectMedia(output, kind) {
|
|
164
|
+
const found = [];
|
|
165
|
+
const seen = new Set();
|
|
166
|
+
const push = (value) => {
|
|
167
|
+
if (value === undefined || value === null)
|
|
168
|
+
return;
|
|
169
|
+
if (Array.isArray(value)) {
|
|
170
|
+
for (const entry of value)
|
|
171
|
+
push(entry);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const url = typeof value === "string" ? value : object(value) ? value.url : undefined;
|
|
175
|
+
if (typeof url !== "string" || !url)
|
|
176
|
+
throw new Error("fal: malformed media reference");
|
|
177
|
+
if (seen.has(url))
|
|
178
|
+
return;
|
|
179
|
+
seen.add(url);
|
|
180
|
+
found.push({
|
|
181
|
+
url,
|
|
182
|
+
contentType: object(value) && typeof value.content_type === "string"
|
|
183
|
+
? value.content_type
|
|
184
|
+
: undefined,
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
for (const field of kind
|
|
188
|
+
? MEDIA_FIELDS[kind]
|
|
189
|
+
: Object.values(MEDIA_FIELDS).flat())
|
|
190
|
+
push(output[field]);
|
|
191
|
+
return found;
|
|
192
|
+
}
|
|
193
|
+
/** Restrict automatic downloads to fal's CDN, including every redirect target. */
|
|
194
|
+
function mediaUrl(value) {
|
|
195
|
+
const url = new URL(value);
|
|
196
|
+
if (url.protocol !== "https:" ||
|
|
197
|
+
url.port ||
|
|
198
|
+
url.username ||
|
|
199
|
+
url.password ||
|
|
200
|
+
!(url.hostname === "fal.media" || url.hostname.endsWith(".fal.media"))) {
|
|
201
|
+
throw new Error("fal: automatic downloads require an HTTPS fal.media URL");
|
|
202
|
+
}
|
|
203
|
+
return url;
|
|
204
|
+
}
|
|
205
|
+
async function download(value) {
|
|
206
|
+
const signal = AbortSignal.timeout(60_000);
|
|
207
|
+
let url = value;
|
|
208
|
+
let response;
|
|
209
|
+
for (let hops = 0; hops <= 5; hops++) {
|
|
210
|
+
const inline = url.startsWith("data:");
|
|
211
|
+
if (!inline)
|
|
212
|
+
url = mediaUrl(url).href;
|
|
213
|
+
// No API credential is attached to either CDN or inline media reads.
|
|
214
|
+
response = await fetch(url, {
|
|
215
|
+
signal,
|
|
216
|
+
redirect: "manual",
|
|
217
|
+
credentials: "omit",
|
|
218
|
+
});
|
|
219
|
+
if (response.redirected) {
|
|
220
|
+
await response.body?.cancel();
|
|
221
|
+
throw new Error("fal: media fetch followed an unchecked redirect");
|
|
222
|
+
}
|
|
223
|
+
if (response.status < 300 || response.status >= 400)
|
|
224
|
+
break;
|
|
225
|
+
const location = response.headers.get("location");
|
|
226
|
+
await response.body?.cancel();
|
|
227
|
+
if (!location || hops === 5)
|
|
228
|
+
throw new Error("fal: invalid or excessive media redirects");
|
|
229
|
+
url = mediaUrl(new URL(location, url).href).href;
|
|
230
|
+
}
|
|
231
|
+
if (!response?.ok) {
|
|
232
|
+
await response?.body?.cancel();
|
|
233
|
+
throw new Error(`download failed (${response?.status})`);
|
|
234
|
+
}
|
|
235
|
+
if (Number(response.headers.get("content-length")) > MAX_DOWNLOAD_BYTES) {
|
|
236
|
+
await response.body?.cancel();
|
|
237
|
+
throw new Error("fal: artifact exceeds 512 MiB");
|
|
238
|
+
}
|
|
239
|
+
const bytes = await readBoundedBytes(response, MAX_DOWNLOAD_BYTES, "fal: artifact exceeds 512 MiB");
|
|
240
|
+
if (!bytes.byteLength)
|
|
241
|
+
throw new Error("fal: empty artifact body");
|
|
242
|
+
return {
|
|
243
|
+
bytes,
|
|
244
|
+
mime: (response.headers.get("content-type") ?? "application/octet-stream")
|
|
245
|
+
.split(";")[0]
|
|
246
|
+
.trim(),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** Shared by generation and queue.result so file handling and partial-failure reporting agree. */
|
|
250
|
+
export async function savedResult(output, model, requestId, saveDir, kind) {
|
|
251
|
+
const files = [];
|
|
252
|
+
const failures = [];
|
|
253
|
+
try {
|
|
254
|
+
const media = collectMedia(output, kind);
|
|
255
|
+
if (kind && !media.length)
|
|
256
|
+
throw new Error("fal: model returned no media");
|
|
257
|
+
for (const ref of media) {
|
|
258
|
+
try {
|
|
259
|
+
const { bytes, mime } = await download(ref.url);
|
|
260
|
+
files.push(writeMediaFile({
|
|
261
|
+
bytes,
|
|
262
|
+
mimeType: ref.contentType ?? mime,
|
|
263
|
+
provider: "fal",
|
|
264
|
+
index: files.length,
|
|
265
|
+
saveDir,
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
failures.push({
|
|
270
|
+
url: ref.url.startsWith("data:") ? "inline media" : ref.url,
|
|
271
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (media.length && !files.length)
|
|
276
|
+
throw new Error(`fal: none could be downloaded: ${failures.map((f) => f.reason).join("; ")}`);
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}. Retrieve the existing result with model ${model} and requestId ${requestId}; do not regenerate.`, { cause: error });
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
provider: "fal",
|
|
283
|
+
model,
|
|
284
|
+
requestId,
|
|
285
|
+
...(kind
|
|
286
|
+
? { [kind]: files }
|
|
287
|
+
: { output, ...(files.length ? { files } : {}) }),
|
|
288
|
+
...(files.length ? { note: SEND_FILE_NOTE } : {}),
|
|
289
|
+
...(failures.length ? { failures } : {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
export const timeoutSchema = t.Integer({
|
|
293
|
+
minimum: 1000,
|
|
294
|
+
maximum: 3_600_000,
|
|
295
|
+
default: DEFAULT_TIMEOUT_MS,
|
|
296
|
+
description: "Queue deadline in milliseconds (default 300000). Includes submission and result retrieval, not downloads (60 seconds per file). Does not cancel server work; use the requestId in the error to collect it.",
|
|
297
|
+
});
|
|
298
|
+
export const saveDirSchema = t.String({
|
|
299
|
+
description: "Existing directory for generated files; defaults to the OS temp directory.",
|
|
300
|
+
});
|
|
301
|
+
export const extraInputSchema = t.Record(t.String(), t.Unknown(), {
|
|
302
|
+
description: "Model-specific fields, overriding action defaults. See https://fal.ai/models/<model>/api. Media downloads support HTTPS fal.media URLs and data URIs, up to 512 MiB each; JSON responses are limited to 32 MiB.",
|
|
303
|
+
});
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* inline next to the instruction and writes the edited result to disk.
|
|
19
19
|
*/
|
|
20
20
|
import * as t from "typebox";
|
|
21
|
-
import { readImageInput, SEND_FILE_NOTE, writeImageFile, } from "../../_shared/
|
|
21
|
+
import { readImageInput, SEND_FILE_NOTE, writeImageFile, } from "../../_shared/mediaFile.js";
|
|
22
22
|
const BASE = "https://generativelanguage.googleapis.com/v1beta/models";
|
|
23
23
|
/** Write every inline image part of a Gemini response to disk. */
|
|
24
24
|
function saveInlineImages(data, saveDir) {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* const { images } = await openai.image.create({ prompt: "a red bicycle on snow" })
|
|
16
16
|
* // images[0].path -> "/tmp/openai-image-….png"
|
|
17
17
|
*/
|
|
18
|
-
import { readImageInput, SEND_FILE_NOTE, writeImageFile, } from "../../_shared/
|
|
18
|
+
import { readImageInput, SEND_FILE_NOTE, writeImageFile, } from "../../_shared/mediaFile.js";
|
|
19
19
|
const ENDPOINT = "https://api.openai.com/v1/images/generations";
|
|
20
20
|
const EDIT_ENDPOINT = "https://api.openai.com/v1/images/edits";
|
|
21
21
|
/**
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* legacy `style` knob — pass `styleId` against your own custom
|
|
14
14
|
* style if you've set one up.
|
|
15
15
|
*/
|
|
16
|
-
import { readImageInput, SEND_FILE_NOTE, writeImageFile } from "../../_shared/
|
|
16
|
+
import { readImageInput, SEND_FILE_NOTE, writeImageFile } from "../../_shared/mediaFile.js";
|
|
17
17
|
const ENDPOINT = "https://external.api.recraft.ai/v1/images/generations";
|
|
18
18
|
const EDIT_ENDPOINT = "https://external.api.recraft.ai/v1/images/imageToImage";
|
|
19
19
|
export default function recraft(rl) {
|
|
@@ -15,11 +15,10 @@
|
|
|
15
15
|
* Predictions are created with `Prefer: wait` so simple jobs return
|
|
16
16
|
* synchronously; anything still processing is polled until terminal
|
|
17
17
|
* or until `timeoutMs` elapses (default: 5 minutes). Output URLs are
|
|
18
|
-
* downloaded
|
|
19
|
-
* separately.
|
|
18
|
+
* downloaded to local files so callers can deliver them directly.
|
|
20
19
|
*/
|
|
21
20
|
import { Buffer } from "node:buffer";
|
|
22
|
-
import { readImageInput, SEND_FILE_NOTE,
|
|
21
|
+
import { readImageInput, SEND_FILE_NOTE, writeMediaFile } from "../../_shared/mediaFile.js";
|
|
23
22
|
import { parseSize } from "../../_shared/parseSize.js";
|
|
24
23
|
const POLL_INTERVAL_MS = 2_000;
|
|
25
24
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -78,7 +77,7 @@ async function runPrediction(opts) {
|
|
|
78
77
|
throw new Error(`Replicate generation ${prediction.status}: ${stringifyError(prediction.error)}`);
|
|
79
78
|
}
|
|
80
79
|
// Output is either a single URL or an array of them. Download
|
|
81
|
-
// each
|
|
80
|
+
// each to disk so the caller gets file paths, not
|
|
82
81
|
// pre-signed URLs that expire. Track per-URL failures and
|
|
83
82
|
// surface them: silent partial success would let an agent
|
|
84
83
|
// think it got 3 images when one 404'd.
|
|
@@ -107,8 +106,8 @@ async function runPrediction(opts) {
|
|
|
107
106
|
const contentType = (imgRes.headers.get("content-type") ?? "image/webp")
|
|
108
107
|
.split(";")[0]
|
|
109
108
|
.trim();
|
|
110
|
-
images.push(
|
|
111
|
-
|
|
109
|
+
images.push(writeMediaFile({
|
|
110
|
+
bytes: buf,
|
|
112
111
|
mimeType: contentType,
|
|
113
112
|
provider: "replicate",
|
|
114
113
|
index: images.length,
|