@ohmygame/fal 0.1.0 → 0.2.1
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/.ohmygame-plugin/plugin.json +8 -1
- package/README.md +11 -4
- package/locales/en.json +1 -1
- package/locales/zh-CN.json +1 -1
- package/media/adapter.js +435 -5
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://www.ohmygame.ai/schema/plugin-2.json",
|
|
3
3
|
"id": "ohmygame/fal",
|
|
4
|
-
"
|
|
4
|
+
"beta": true,
|
|
5
|
+
"version": "0.2.1",
|
|
5
6
|
"engines": {
|
|
6
7
|
"ohmygame-api": "^2.5.0"
|
|
7
8
|
},
|
|
@@ -15,7 +16,9 @@
|
|
|
15
16
|
"capabilities": {
|
|
16
17
|
"network": [
|
|
17
18
|
"queue.fal.run",
|
|
19
|
+
"api.fal.ai",
|
|
18
20
|
"fal.media",
|
|
21
|
+
"v2.fal.media",
|
|
19
22
|
"v3.fal.media",
|
|
20
23
|
"v3b.fal.media"
|
|
21
24
|
]
|
|
@@ -41,6 +44,7 @@
|
|
|
41
44
|
"scheme": "Key"
|
|
42
45
|
},
|
|
43
46
|
"image": {
|
|
47
|
+
"discoverModels": true,
|
|
44
48
|
"models": [
|
|
45
49
|
{
|
|
46
50
|
"id": "fal-ai/flux/schnell",
|
|
@@ -60,6 +64,7 @@
|
|
|
60
64
|
]
|
|
61
65
|
},
|
|
62
66
|
"video": {
|
|
67
|
+
"discoverModels": true,
|
|
63
68
|
"maxMinutes": 30,
|
|
64
69
|
"models": [
|
|
65
70
|
{
|
|
@@ -85,6 +90,7 @@
|
|
|
85
90
|
]
|
|
86
91
|
},
|
|
87
92
|
"audio": {
|
|
93
|
+
"discoverModels": true,
|
|
88
94
|
"models": [
|
|
89
95
|
{
|
|
90
96
|
"id": "fal-ai/minimax/speech-02-hd",
|
|
@@ -184,6 +190,7 @@
|
|
|
184
190
|
]
|
|
185
191
|
},
|
|
186
192
|
"3d": {
|
|
193
|
+
"discoverModels": true,
|
|
187
194
|
"models": [
|
|
188
195
|
{
|
|
189
196
|
"id": "fal-ai/trellis",
|
package/README.md
CHANGED
|
@@ -13,12 +13,19 @@ pictures, videos, audio and 3D models. It has no chat models.
|
|
|
13
13
|
| Sound effects, music | Stable Audio (`fal-ai/stable-audio`) | A description and up to 47 seconds, as WAV |
|
|
14
14
|
| 3D | TRELLIS (`fal-ai/trellis`) | One picture of the object, as GLB |
|
|
15
15
|
|
|
16
|
+
These are on from the start. To use more of fal.ai's models, open the fal.ai
|
|
17
|
+
provider in Settings → Providers and choose **Load models** under a kind: the
|
|
18
|
+
plugin reads fal.ai's most used models of that kind and what each one takes
|
|
19
|
+
(aspect ratios, lengths, voices, how many pictures), and you turn on the ones
|
|
20
|
+
you want. A model that asks for something OhMyGame doesn't send, such as a
|
|
21
|
+
mask or a sound clip, isn't listed.
|
|
22
|
+
|
|
16
23
|
Every request goes through fal.ai's queue, in
|
|
17
24
|
[`media/adapter.js`](media/adapter.js). OhMyGame runs that script in a sandbox:
|
|
18
|
-
it reaches only `queue.fal.run`
|
|
19
|
-
OhMyGame adds the key to its requests. A video,
|
|
20
|
-
restart cut off is checked again on **Retry**, not
|
|
21
|
-
each request to your account.
|
|
25
|
+
it reaches only `queue.fal.run`, `api.fal.ai` (to list models) and the hosts
|
|
26
|
+
fal.ai's files are on, and OhMyGame adds the key to its requests. A video,
|
|
27
|
+
audio file or 3D model a restart cut off is checked again on **Retry**, not
|
|
28
|
+
paid for twice. fal.ai bills each request to your account.
|
|
22
29
|
|
|
23
30
|
Needs OhMyGame with plugin API 2.5.0 or later.
|
|
24
31
|
|
package/locales/en.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"plugin.name": "fal.ai",
|
|
3
3
|
"plugin.description": "fal.ai's image, video, audio and 3D models, with your own API key.",
|
|
4
|
-
"plugin.longDescription": "Adds fal.ai to Settings → Providers. Enter your API key here or in Settings: FLUX.1 [schnell] makes pictures, Kling O3 makes videos of 3 to 15 seconds from a first frame (and a last one if you give it), MiniMax Speech-02 HD reads text aloud, Stable Audio makes sound effects and music, and TRELLIS makes a 3D model from a picture. fal.ai bills each request to your account.",
|
|
4
|
+
"plugin.longDescription": "Adds fal.ai to Settings → Providers. Enter your API key here or in Settings: FLUX.1 [schnell] makes pictures, Kling O3 makes videos of 3 to 15 seconds from a first frame (and a last one if you give it), MiniMax Speech-02 HD reads text aloud, Stable Audio makes sound effects and music, and TRELLIS makes a 3D model from a picture. fal.ai bills each request to your account. To use more of fal.ai's models, choose Load models on its page in Settings and turn on the ones you want.",
|
|
5
5
|
"config.apiKey.title": "fal.ai API key",
|
|
6
6
|
"config.apiKey.description": "From fal.ai → Dashboard → Keys."
|
|
7
7
|
}
|
package/locales/zh-CN.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"plugin.name": "fal.ai",
|
|
3
3
|
"plugin.description": "用你自己的 API key,使用 fal.ai 的图片、视频、音频和 3D 模型。",
|
|
4
|
-
"plugin.longDescription": "把 fal.ai 加入 设置 → 服务商。在这里或设置里填好 API key:FLUX.1 [schnell] 生成图片,Kling O3 根据首帧(可加尾帧)生成 3 到 15 秒的视频,MiniMax Speech-02 HD 朗读文本,Stable Audio 生成音效和音乐,TRELLIS 根据图片生成 3D 模型。每次请求由 fal.ai
|
|
4
|
+
"plugin.longDescription": "把 fal.ai 加入 设置 → 服务商。在这里或设置里填好 API key:FLUX.1 [schnell] 生成图片,Kling O3 根据首帧(可加尾帧)生成 3 到 15 秒的视频,MiniMax Speech-02 HD 朗读文本,Stable Audio 生成音效和音乐,TRELLIS 根据图片生成 3D 模型。每次请求由 fal.ai 向你的账户计费。想用 fal.ai 的更多模型,在设置里它的页面点「读取模型」,再勾选要用的。",
|
|
5
5
|
"config.apiKey.title": "fal.ai API key",
|
|
6
6
|
"config.apiKey.description": "在 fal.ai → Dashboard → Keys 创建。"
|
|
7
7
|
}
|
package/media/adapter.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// fal.ai's queue API for OhMyGame (docs.fal.ai: model endpoints, queue
|
|
1
|
+
// fal.ai's queue API for OhMyGame (docs.fal.ai: model endpoints, queue, and
|
|
2
|
+
// the platform API's model search).
|
|
2
3
|
// Runs in OhMyGame's sandbox: host.fetch reaches only the hosts in
|
|
3
4
|
// capabilities.network, and `auth: true` adds the key ("Key <key>"), which
|
|
4
5
|
// this script never sees.
|
|
@@ -6,20 +7,44 @@
|
|
|
6
7
|
// Every model goes through the same queue: `start` submits the request, `check`
|
|
7
8
|
// reads its status and, once it's done, its result, and `cancel` withdraws it.
|
|
8
9
|
// The queue answers with the addresses to use, so they're kept as the task.
|
|
10
|
+
//
|
|
11
|
+
// `models` lists fal.ai's models of a kind with their input schemas and turns
|
|
12
|
+
// each into what OhMyGame knows (aspect ratios, durations, voices and so on).
|
|
13
|
+
// How to build a request for it goes in the model's `data`, which OhMyGame
|
|
14
|
+
// gives back as `input.modelData`. A model whose schema asks for something
|
|
15
|
+
// this script can't fill is left out rather than listed wrong.
|
|
9
16
|
|
|
10
17
|
const QUEUE = "https://queue.fal.run/";
|
|
18
|
+
const MODELS = "https://api.fal.ai/v1/models";
|
|
11
19
|
|
|
12
20
|
// FLUX's named sizes for the aspect ratios it's offered with.
|
|
13
21
|
const IMAGE_SIZES = {
|
|
14
22
|
"1:1": "square_hd", "4:3": "landscape_4_3", "3:4": "portrait_4_3", "16:9": "landscape_16_9", "9:16": "portrait_16_9",
|
|
15
23
|
};
|
|
16
24
|
|
|
25
|
+
// fal.ai's categories for each kind OhMyGame asks for.
|
|
26
|
+
const CATEGORIES = {
|
|
27
|
+
image: ["text-to-image", "image-to-image"],
|
|
28
|
+
video: ["text-to-video", "image-to-video"],
|
|
29
|
+
audio: ["text-to-speech", "text-to-audio"],
|
|
30
|
+
"3d": ["image-to-3d", "text-to-3d"],
|
|
31
|
+
};
|
|
32
|
+
// Pages read per category, 10 models each with their schemas, most used first.
|
|
33
|
+
const PAGES = 6;
|
|
34
|
+
|
|
35
|
+
// What OhMyGame takes, as the plugin API lists it.
|
|
36
|
+
const IMAGE_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
|
|
37
|
+
const VIDEO_RATIOS = ["adaptive", "21:9", "16:9", "4:3", "3:2", "1:1", "2:3", "3:4", "9:16", "9:21"];
|
|
38
|
+
const VIDEO_RESOLUTIONS = ["480p", "720p", "768p", "1080p", "1K", "2K", "4K"];
|
|
39
|
+
// MiniMax's system voices, which its speech models take as voice_setting.voice_id.
|
|
40
|
+
const MINIMAX_VOICES = ["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman", "Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man", "Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess", "Sweet_Girl_2", "Exuberant_Girl"];
|
|
41
|
+
|
|
17
42
|
/** The answer's JSON, and its message when fal.ai refused the request. */
|
|
18
43
|
async function read(response) {
|
|
19
44
|
const text = await response.text();
|
|
20
45
|
let answer;
|
|
21
46
|
try { answer = JSON.parse(text); } catch { answer = undefined; }
|
|
22
|
-
const detail = answer && answer.detail;
|
|
47
|
+
const detail = answer && (answer.detail || (answer.error && answer.error.message));
|
|
23
48
|
const message = (typeof detail === "string" && detail)
|
|
24
49
|
// A validation error lists what was wrong with each field.
|
|
25
50
|
|| (Array.isArray(detail) && detail.map((item) => item && item.msg).filter(Boolean).join("; "))
|
|
@@ -36,8 +61,8 @@ function onQueue(url) {
|
|
|
36
61
|
return typeof url === "string" && url.startsWith(QUEUE);
|
|
37
62
|
}
|
|
38
63
|
|
|
39
|
-
/** Submits a request to a model's queue. */
|
|
40
|
-
async function submit(model, body, host) {
|
|
64
|
+
/** Submits a request to a model's queue; `out` says where its file is in the result. */
|
|
65
|
+
async function submit(model, body, host, out) {
|
|
41
66
|
const response = await host.fetch(QUEUE + model, { method: "POST", auth: true, json: body });
|
|
42
67
|
const { answer, message } = await read(response);
|
|
43
68
|
// A server error may come after fal.ai took the request: throwing has OhMyGame
|
|
@@ -47,6 +72,7 @@ async function submit(model, body, host) {
|
|
|
47
72
|
const task = answer && { status: answer.status_url, result: answer.response_url, cancel: answer.cancel_url };
|
|
48
73
|
// Taken but unreadable: thrown for the same reason as a server error.
|
|
49
74
|
if (!task || !onQueue(task.status) || !onQueue(task.result)) throw new Error("fal.ai returned no request to follow");
|
|
75
|
+
if (out) task.out = out;
|
|
50
76
|
return { task };
|
|
51
77
|
}
|
|
52
78
|
|
|
@@ -66,7 +92,7 @@ async function follow(task, host, retryAfter, pick) {
|
|
|
66
92
|
if (result.status === 429 || result.status >= 500) throw new Error(output.message);
|
|
67
93
|
if (!result.ok) return { error: output.message };
|
|
68
94
|
// fal.ai keeps the file for a while; OhMyGame downloads it at once.
|
|
69
|
-
const url = output.answer && pick(output.answer);
|
|
95
|
+
const url = output.answer && (Array.isArray(task.out) ? fileAt(output.answer, task.out) : pick(output.answer));
|
|
70
96
|
return typeof url === "string" && url ? { outputs: [{ url }] } : { error: "fal.ai returned no file" };
|
|
71
97
|
}
|
|
72
98
|
|
|
@@ -74,9 +100,407 @@ async function cancel(task, host) {
|
|
|
74
100
|
if (onQueue(task.cancel)) await host.fetch(task.cancel, { method: "PUT", auth: true });
|
|
75
101
|
}
|
|
76
102
|
|
|
103
|
+
/** The first of `paths` that leads to a string in the result. */
|
|
104
|
+
function fileAt(result, paths) {
|
|
105
|
+
for (const path of paths) {
|
|
106
|
+
let value = result;
|
|
107
|
+
for (const step of path) value = value == null ? undefined : value[step];
|
|
108
|
+
if (typeof value === "string" && value) return value;
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Sets a field, which may sit inside an object (`voice_setting.voice_id`). */
|
|
114
|
+
function put(body, field, value) {
|
|
115
|
+
const steps = field.split(".");
|
|
116
|
+
let target = body;
|
|
117
|
+
for (const step of steps.slice(0, -1)) {
|
|
118
|
+
if (!target[step] || typeof target[step] !== "object") target[step] = {};
|
|
119
|
+
target = target[step];
|
|
120
|
+
}
|
|
121
|
+
target[steps[steps.length - 1]] = value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- Requests for a model listed from fal.ai, built from its `data` ----
|
|
125
|
+
|
|
126
|
+
function build(kind, input, d) {
|
|
127
|
+
const body = JSON.parse(JSON.stringify(d.fixed || {}));
|
|
128
|
+
const refs = input.references || [];
|
|
129
|
+
if (d.prompt && input.prompt) put(body, d.prompt, input.prompt);
|
|
130
|
+
if (d.ar && d.ar.m[input.aspectRatio]) put(body, d.ar.f, d.ar.m[input.aspectRatio]);
|
|
131
|
+
if (d.res && d.res.m[input.resolution]) put(body, d.res.f, d.res.m[input.resolution]);
|
|
132
|
+
if (kind === "image" && d.bg && input.transparentBackground) put(body, d.bg, "transparent");
|
|
133
|
+
if (kind === "video") {
|
|
134
|
+
if (d.dur) put(body, d.dur.f, d.dur.m && d.dur.m[input.duration] !== undefined ? d.dur.m[input.duration] : d.dur.t === "string" ? String(input.duration) : input.duration);
|
|
135
|
+
if (input.referenceMode === "reference" && d.refs) {
|
|
136
|
+
put(body, d.refs, refs.map(dataUri));
|
|
137
|
+
return body;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (kind === "audio") {
|
|
141
|
+
if (d.voice && input.voiceId) put(body, d.voice, input.voiceId);
|
|
142
|
+
// OhMyGame's speed runs from -50 (half as fast) to 100 (twice as fast).
|
|
143
|
+
if (d.speed && input.speed) put(body, d.speed.f, Math.min(d.speed.max, Math.max(d.speed.min, 1 + input.speed / 100)));
|
|
144
|
+
if (d.fmt && d.fmt.m[input.format]) put(body, d.fmt.f, d.fmt.m[input.format]);
|
|
145
|
+
if (d.dur && input.durationSeconds) put(body, d.dur.f, d.dur.ms ? input.durationSeconds * 1000 : input.durationSeconds);
|
|
146
|
+
if (d.inst && input.instrumental) put(body, d.inst, true);
|
|
147
|
+
}
|
|
148
|
+
if (kind === "3d") {
|
|
149
|
+
if (d.poly && input.targetPolycount) put(body, d.poly, input.targetPolycount);
|
|
150
|
+
if (d.tex) put(body, d.tex.f, d.tex.m ? d.tex.m[String(Boolean(input.texture))] : Boolean(input.texture));
|
|
151
|
+
if (d.pbr) put(body, d.pbr, Boolean(input.pbr));
|
|
152
|
+
}
|
|
153
|
+
if (d.ref && refs.length) {
|
|
154
|
+
// Pictures as the model takes them: several, or one (a first frame, then perhaps a last).
|
|
155
|
+
if (d.ref.many) put(body, d.ref.f, refs.map(dataUri));
|
|
156
|
+
else put(body, d.ref.f, dataUri(refs[0]));
|
|
157
|
+
if (d.end && refs[1]) put(body, d.end, dataUri(refs[1]));
|
|
158
|
+
}
|
|
159
|
+
return body;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Starts a listed model, or says what it needs that wasn't given. */
|
|
163
|
+
function startListed(kind, input, host) {
|
|
164
|
+
const d = input.modelData;
|
|
165
|
+
if (d.ref && d.ref.need && !(input.references || []).length) {
|
|
166
|
+
return { error: kind === "video" ? "This model makes a video from a first frame: add a picture." : "This model needs a picture: add one." };
|
|
167
|
+
}
|
|
168
|
+
return submit(input.model, build(kind, input, d), host, d.out);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- Turning fal.ai's schemas into OhMyGame's models ----
|
|
172
|
+
|
|
173
|
+
/** Up to PAGES pages of a category's models, with their schemas. */
|
|
174
|
+
async function listCategory(category, host) {
|
|
175
|
+
const found = [];
|
|
176
|
+
let cursor;
|
|
177
|
+
for (let page = 0; page < PAGES; page++) {
|
|
178
|
+
const url = `${MODELS}?category=${category}&status=active&limit=10&expand=openapi-3.0${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
|
|
179
|
+
const response = await host.fetch(url, { method: "GET", auth: true });
|
|
180
|
+
const { answer, message } = await read(response);
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
// Turned away partway, as when rate limited: what was read is still worth listing.
|
|
183
|
+
if (found.length) break;
|
|
184
|
+
throw new Error(message);
|
|
185
|
+
}
|
|
186
|
+
found.push(...((answer && Array.isArray(answer.models) && answer.models) || []));
|
|
187
|
+
if (!answer || !answer.has_more || !answer.next_cursor) break;
|
|
188
|
+
cursor = answer.next_cursor;
|
|
189
|
+
}
|
|
190
|
+
return found;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A model's input fields and output, with schema references resolved. */
|
|
194
|
+
function schemasOf(model) {
|
|
195
|
+
const doc = model.openapi;
|
|
196
|
+
if (!doc || !doc.paths) return undefined;
|
|
197
|
+
const named = (doc.components && doc.components.schemas) || {};
|
|
198
|
+
const deref = (schema, depth = 0) => {
|
|
199
|
+
let value = schema;
|
|
200
|
+
for (let i = 0; i < 10 && value && typeof value === "object"; i++) {
|
|
201
|
+
if (typeof value.$ref === "string") value = named[value.$ref.split("/").pop()];
|
|
202
|
+
else if (Array.isArray(value.allOf) && value.allOf.length === 1) value = value.allOf[0];
|
|
203
|
+
else if (Array.isArray(value.anyOf) && depth < 3) {
|
|
204
|
+
// `X | null` reads as X; of several, the one with choices, then a string.
|
|
205
|
+
const options = value.anyOf.map((option) => deref(option, depth + 1)).filter((option) => option && option.type !== "null");
|
|
206
|
+
value = options.find((option) => Array.isArray(option.enum)) || options.find((option) => option.type === "string") || options[0];
|
|
207
|
+
} else break;
|
|
208
|
+
}
|
|
209
|
+
return value && typeof value === "object" ? value : {};
|
|
210
|
+
};
|
|
211
|
+
const json = (holder) => holder && holder.content && holder.content["application/json"] && holder.content["application/json"].schema;
|
|
212
|
+
let input, output;
|
|
213
|
+
for (const [path, item] of Object.entries(doc.paths)) {
|
|
214
|
+
if (item && item.post && item.post.requestBody) input = deref(json(item.post.requestBody));
|
|
215
|
+
if (item && item.get && /\/requests\/\{request_id\}$/.test(path)) output = deref(json(item.get.responses && item.get.responses["200"]));
|
|
216
|
+
}
|
|
217
|
+
if (!input || !input.properties || !output || !output.properties) return undefined;
|
|
218
|
+
const fields = {};
|
|
219
|
+
for (const [name, schema] of Object.entries(input.properties)) fields[name] = deref(schema);
|
|
220
|
+
const out = {};
|
|
221
|
+
for (const [name, schema] of Object.entries(output.properties)) out[name] = deref(schema);
|
|
222
|
+
return { fields, required: Array.isArray(input.required) ? input.required : [], out, deref };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const firstOf = (fields, names) => names.find((name) => fields[name]);
|
|
226
|
+
const choices = (schema) => Array.isArray(schema && schema.enum) ? schema.enum : [];
|
|
227
|
+
const range = (schema) => schema && typeof schema.minimum === "number" && typeof schema.maximum === "number";
|
|
228
|
+
function text(value, max) {
|
|
229
|
+
const clean = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
230
|
+
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A choice of length in seconds, which fal.ai writes as 5, "5" or "5s". */
|
|
234
|
+
const secondsIn = (value) => typeof value === "string" ? Number(value.replace(/s$/, "")) : Number(value);
|
|
235
|
+
|
|
236
|
+
/** Whole seconds from 1 to `cap` a duration field takes, from its choices or its range. */
|
|
237
|
+
function secondsOf(schema, cap, ms) {
|
|
238
|
+
const scale = ms ? 1000 : 1;
|
|
239
|
+
const listed = choices(schema).map((value) => secondsIn(value) / scale).filter((value) => Number.isInteger(value) && value >= 1 && value <= cap);
|
|
240
|
+
if (listed.length) return [...new Set(listed)].sort((a, b) => a - b);
|
|
241
|
+
if (!range(schema)) return [];
|
|
242
|
+
const low = Math.max(1, Math.ceil(schema.minimum / scale));
|
|
243
|
+
const high = Math.min(cap, Math.floor(schema.maximum / scale));
|
|
244
|
+
return low <= high ? Array.from({ length: high - low + 1 }, (_, index) => low + index) : [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Whether every field the model requires is one this script fills. */
|
|
248
|
+
const fills = (s, known) => s.required.every((name) => known.includes(name));
|
|
249
|
+
|
|
250
|
+
/** Where the file is in the result: the paths whose first field the output has. */
|
|
251
|
+
function outOf(s, paths) {
|
|
252
|
+
const found = paths.filter((path) => s.out[path[0]]);
|
|
253
|
+
return found.length ? found : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function describeImage(model, s) {
|
|
257
|
+
const f = s.fields;
|
|
258
|
+
if (!f.prompt) return undefined;
|
|
259
|
+
const d = { prompt: "prompt", fixed: {} };
|
|
260
|
+
const many = firstOf(f, ["image_urls"]);
|
|
261
|
+
const one = firstOf(f, ["image_url"]);
|
|
262
|
+
let maxReferenceImages = 0;
|
|
263
|
+
if (many) {
|
|
264
|
+
d.ref = { f: many, many: true, need: s.required.includes(many) };
|
|
265
|
+
maxReferenceImages = Math.min(16, Math.max(1, f[many].maxItems || 4));
|
|
266
|
+
} else if (one) {
|
|
267
|
+
d.ref = { f: one, many: false, need: s.required.includes(one) };
|
|
268
|
+
maxReferenceImages = 1;
|
|
269
|
+
}
|
|
270
|
+
if (!fills(s, ["prompt", many, one].filter(Boolean))) return undefined;
|
|
271
|
+
let aspectRatios = ["1:1"];
|
|
272
|
+
const ratios = choices(f.aspect_ratio).filter((value) => IMAGE_RATIOS.includes(value));
|
|
273
|
+
const sizes = choices(f.image_size);
|
|
274
|
+
const named = Object.entries(IMAGE_SIZES).filter(([, name]) => sizes.includes(name));
|
|
275
|
+
if (ratios.length) {
|
|
276
|
+
aspectRatios = ratios;
|
|
277
|
+
d.ar = { f: "aspect_ratio", m: Object.fromEntries(ratios.map((ratio) => [ratio, ratio])) };
|
|
278
|
+
} else if (named.length) {
|
|
279
|
+
aspectRatios = named.map(([ratio]) => ratio);
|
|
280
|
+
d.ar = { f: "image_size", m: Object.fromEntries(named) };
|
|
281
|
+
}
|
|
282
|
+
let resolutions = ["1K"];
|
|
283
|
+
const levels = { "0.5K": "512", "1K": "1K", "2K": "2K", "4K": "4K" };
|
|
284
|
+
const offered = choices(f.resolution).filter((value) => levels[value]);
|
|
285
|
+
if (offered.length) {
|
|
286
|
+
resolutions = offered.map((value) => levels[value]);
|
|
287
|
+
d.res = { f: "resolution", m: Object.fromEntries(offered.map((value) => [levels[value], value])) };
|
|
288
|
+
}
|
|
289
|
+
if (f.num_images) d.fixed.num_images = 1;
|
|
290
|
+
if (choices(f.output_format).includes("png")) d.fixed.output_format = "png";
|
|
291
|
+
if (choices(f.background).includes("transparent")) d.bg = "background";
|
|
292
|
+
d.out = outOf(s, [["images", 0, "url"], ["image", "url"]]);
|
|
293
|
+
if (!d.out) return undefined;
|
|
294
|
+
return { resolutions, aspectRatios, maxReferenceImages, data: d };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function describeVideo(model, s, category) {
|
|
298
|
+
const f = s.fields;
|
|
299
|
+
if (!f.prompt) return undefined;
|
|
300
|
+
const d = { prompt: "prompt", fixed: {} };
|
|
301
|
+
const start = firstOf(f, ["image_url", "start_image_url", "first_frame_url", "first_image_url"]);
|
|
302
|
+
const end = firstOf(f, ["end_image_url", "tail_image_url", "last_frame_url", "last_image_url"]);
|
|
303
|
+
const looks = firstOf(f, ["reference_image_urls", "image_urls"]);
|
|
304
|
+
let maxReferenceImages = 0;
|
|
305
|
+
const referenceModes = [];
|
|
306
|
+
if (start) {
|
|
307
|
+
d.ref = { f: start, many: false, need: s.required.includes(start) };
|
|
308
|
+
if (end) d.end = end;
|
|
309
|
+
maxReferenceImages = end ? 2 : 1;
|
|
310
|
+
referenceModes.push("frame");
|
|
311
|
+
}
|
|
312
|
+
// A model that must have a first frame can't take a look to follow instead.
|
|
313
|
+
if (looks && !(d.ref && d.ref.need)) {
|
|
314
|
+
d.refs = looks;
|
|
315
|
+
maxReferenceImages = Math.max(maxReferenceImages, Math.min(16, Math.max(1, f[looks].maxItems || 4)));
|
|
316
|
+
referenceModes.push("reference");
|
|
317
|
+
}
|
|
318
|
+
if (category === "image-to-video" && !referenceModes.length) return undefined;
|
|
319
|
+
if (!fills(s, ["prompt", start, end, looks].filter(Boolean))) return undefined;
|
|
320
|
+
let durations = [5];
|
|
321
|
+
if (f.duration) {
|
|
322
|
+
const seconds = secondsOf(f.duration, 30, false);
|
|
323
|
+
if (seconds.length) {
|
|
324
|
+
durations = seconds;
|
|
325
|
+
const asText = choices(f.duration).some((value) => typeof value === "string") || f.duration.type === "string";
|
|
326
|
+
d.dur = { f: "duration", t: asText ? "string" : "number" };
|
|
327
|
+
// Choices are sent as fal.ai writes them ("8s").
|
|
328
|
+
const written = Object.fromEntries(choices(f.duration).filter((value) => seconds.includes(secondsIn(value))).map((value) => [secondsIn(value), value]));
|
|
329
|
+
if (Object.keys(written).length) d.dur.m = written;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
let resolutions = ["720p"];
|
|
333
|
+
const resolution = {};
|
|
334
|
+
for (const value of choices(f.resolution)) {
|
|
335
|
+
const ours = typeof value !== "string" ? undefined : /^\d+p$/i.test(value) ? value.toLowerCase() : /^\dk$/i.test(value) ? value.toUpperCase() : undefined;
|
|
336
|
+
if (ours && VIDEO_RESOLUTIONS.includes(ours) && !resolution[ours]) resolution[ours] = value;
|
|
337
|
+
}
|
|
338
|
+
if (Object.keys(resolution).length) {
|
|
339
|
+
resolutions = Object.keys(resolution);
|
|
340
|
+
d.res = { f: "resolution", m: resolution };
|
|
341
|
+
}
|
|
342
|
+
let aspectRatios = ["adaptive"];
|
|
343
|
+
const ratio = {};
|
|
344
|
+
for (const value of choices(f.aspect_ratio)) {
|
|
345
|
+
const ours = value === "auto" ? "adaptive" : value;
|
|
346
|
+
if (VIDEO_RATIOS.includes(ours) && !ratio[ours]) ratio[ours] = value;
|
|
347
|
+
}
|
|
348
|
+
if (Object.keys(ratio).length) {
|
|
349
|
+
aspectRatios = Object.keys(ratio);
|
|
350
|
+
d.ar = { f: "aspect_ratio", m: ratio };
|
|
351
|
+
}
|
|
352
|
+
d.out = outOf(s, [["video", "url"], ["video"]]);
|
|
353
|
+
if (!d.out) return undefined;
|
|
354
|
+
return { durations, resolutions, aspectRatios, maxReferenceImages, ...(referenceModes.length ? { referenceModes } : {}), data: d };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function describeAudio(model, s, category) {
|
|
358
|
+
const f = s.fields;
|
|
359
|
+
const name = `${model.endpoint_id} ${(model.metadata && model.metadata.display_name) || ""}`.toLowerCase();
|
|
360
|
+
const voiceSetting = f.voice_setting && f.voice_setting.properties ? f.voice_setting : undefined;
|
|
361
|
+
const speech = category === "text-to-speech" || Boolean(f.voice || voiceSetting);
|
|
362
|
+
const said = speech ? firstOf(f, ["text", "prompt"]) : firstOf(f, ["prompt", "text"]);
|
|
363
|
+
if (!said) return undefined;
|
|
364
|
+
const d = { prompt: said, fixed: {} };
|
|
365
|
+
const result = { kinds: [], formats: ["mp3"] };
|
|
366
|
+
if (speech) {
|
|
367
|
+
result.kinds = ["speech"];
|
|
368
|
+
result.readsText = true;
|
|
369
|
+
const limit = f[said].maxLength;
|
|
370
|
+
if (Number.isInteger(limit) && limit >= 1) result.maxTextCharacters = Math.min(32000, limit);
|
|
371
|
+
if (f.voice) {
|
|
372
|
+
d.voice = "voice";
|
|
373
|
+
const voices = choices(f.voice).filter((value) => typeof value === "string" && value && value.length <= 200).slice(0, 200);
|
|
374
|
+
const fallback = typeof f.voice.default === "string" && f.voice.default ? f.voice.default : undefined;
|
|
375
|
+
// The default first, as OhMyGame picks the first.
|
|
376
|
+
const listed = fallback && voices.includes(fallback) ? [fallback, ...voices.filter((value) => value !== fallback)] : voices.length ? voices : fallback ? [fallback] : [];
|
|
377
|
+
if (listed.length) result.voices = listed.map((id) => ({ id, name: id }));
|
|
378
|
+
} else if (voiceSetting && voiceSetting.properties.voice_id) {
|
|
379
|
+
d.voice = "voice_setting.voice_id";
|
|
380
|
+
result.voices = MINIMAX_VOICES.map((id) => ({ id, name: id.replace(/_/g, " ") }));
|
|
381
|
+
}
|
|
382
|
+
const speed = voiceSetting && voiceSetting.properties.speed ? ["voice_setting.speed", s.deref(voiceSetting.properties.speed)] : f.speed ? ["speed", f.speed] : undefined;
|
|
383
|
+
if (speed && range(speed[1])) d.speed = { f: speed[0], min: speed[1].minimum, max: speed[1].maximum };
|
|
384
|
+
} else {
|
|
385
|
+
const music = /music|lyria|song|ace-step/.test(name) || Boolean(f.lyrics || f.force_instrumental || f.music_length_ms);
|
|
386
|
+
const effects = /sound|sfx|effect|foley/.test(name);
|
|
387
|
+
result.kinds = music && !effects ? ["music"] : effects && !music ? ["sound-effect"] : ["sound-effect", "music"];
|
|
388
|
+
const length = firstOf(f, ["duration", "seconds_total", "music_length_ms"]);
|
|
389
|
+
const seconds = length ? secondsOf(f[length], 600, length === "music_length_ms") : [];
|
|
390
|
+
if (seconds.length) {
|
|
391
|
+
result.minDurationSeconds = seconds[0];
|
|
392
|
+
result.maxDurationSeconds = seconds[seconds.length - 1];
|
|
393
|
+
d.dur = { f: length, ms: length === "music_length_ms" };
|
|
394
|
+
}
|
|
395
|
+
if (f.force_instrumental) {
|
|
396
|
+
result.instrumental = true;
|
|
397
|
+
d.inst = "force_instrumental";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// The file it makes, where the schema lets it be asked for; otherwise any that comes back is kept.
|
|
401
|
+
const formats = choices(f.output_format);
|
|
402
|
+
const setting = f.audio_setting && f.audio_setting.properties && f.audio_setting.properties.format ? s.deref(f.audio_setting.properties.format) : undefined;
|
|
403
|
+
if (formats.includes("url")) d.fixed.output_format = "url";
|
|
404
|
+
if (setting && choices(setting).includes("mp3")) {
|
|
405
|
+
d.fixed.audio_setting = { format: "mp3" };
|
|
406
|
+
} else if (formats.length) {
|
|
407
|
+
const map = {};
|
|
408
|
+
const mp3 = formats.includes("mp3_44100_128") ? "mp3_44100_128" : formats.find((value) => value === "mp3" || /^mp3_/.test(value));
|
|
409
|
+
if (mp3) map.mp3 = mp3;
|
|
410
|
+
if (formats.includes("wav")) map.wav = "wav";
|
|
411
|
+
if (formats.includes("ogg_opus")) map.ogg_opus = "ogg_opus";
|
|
412
|
+
if (Object.keys(map).length) {
|
|
413
|
+
result.formats = Object.keys(map);
|
|
414
|
+
d.fmt = { f: "output_format", m: map };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!fills(s, [said])) return undefined;
|
|
418
|
+
// Some models give the file as an object with a url, others as the url alone.
|
|
419
|
+
d.out = outOf(s, [["audio", "url"], ["audio"], ["audio_file", "url"], ["audios", 0, "url"]]);
|
|
420
|
+
if (!d.out) return undefined;
|
|
421
|
+
return { ...result, data: d };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function describe3D(model, s, category) {
|
|
425
|
+
const f = s.fields;
|
|
426
|
+
const d = { fixed: {} };
|
|
427
|
+
const result = {};
|
|
428
|
+
if (category === "text-to-3d") {
|
|
429
|
+
if (!f.prompt || !fills(s, ["prompt"])) return undefined;
|
|
430
|
+
d.prompt = "prompt";
|
|
431
|
+
result.operations = ["text"];
|
|
432
|
+
} else {
|
|
433
|
+
const many = firstOf(f, ["image_urls"]);
|
|
434
|
+
const one = firstOf(f, ["image_url", "input_image_url"]);
|
|
435
|
+
if (!many && !one) return undefined;
|
|
436
|
+
d.ref = one && !(many && s.required.includes(many)) ? { f: one, many: false, need: true } : { f: many, many: true, need: true };
|
|
437
|
+
if (!fills(s, [d.ref.f])) return undefined;
|
|
438
|
+
result.operations = ["image"];
|
|
439
|
+
result.maxReferenceImages = d.ref.many ? Math.min(8, Math.max(1, f[many].maxItems || 4)) : 1;
|
|
440
|
+
}
|
|
441
|
+
const poly = firstOf(f, ["target_polycount", "face_count", "face_limit"]);
|
|
442
|
+
if (poly && range(f[poly])) {
|
|
443
|
+
const min = Math.max(100, Math.ceil(f[poly].minimum));
|
|
444
|
+
const max = Math.min(2000000, Math.floor(f[poly].maximum));
|
|
445
|
+
if (min <= max) {
|
|
446
|
+
const wanted = typeof f[poly].default === "number" ? f[poly].default : 30000;
|
|
447
|
+
const preset = Math.min(max, Math.max(min, Math.round(wanted)));
|
|
448
|
+
result.polycount = { min, max, default: preset, presets: [...new Set([min, preset, max])].sort((a, b) => a - b) };
|
|
449
|
+
d.poly = poly;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// Without a count it takes, the canvas shows a nominal one, which isn't sent.
|
|
453
|
+
if (!result.polycount) result.polycount = { min: 20000, max: 20000, default: 20000 };
|
|
454
|
+
const texture = firstOf(f, ["should_texture", "texture"]);
|
|
455
|
+
if (texture && f[texture].type === "boolean") {
|
|
456
|
+
result.texture = true;
|
|
457
|
+
d.tex = { f: texture };
|
|
458
|
+
} else if (texture && choices(f[texture]).includes("no") && choices(f[texture]).includes("standard")) {
|
|
459
|
+
result.texture = true;
|
|
460
|
+
d.tex = { f: texture, m: { true: "standard", false: "no" } };
|
|
461
|
+
}
|
|
462
|
+
const pbr = firstOf(f, ["enable_pbr", "pbr"]);
|
|
463
|
+
if (pbr && f[pbr].type === "boolean") {
|
|
464
|
+
result.pbr = true;
|
|
465
|
+
d.pbr = pbr;
|
|
466
|
+
}
|
|
467
|
+
// model_urls.glb first: some models put the format asked for (OBJ, say) in model_glb.
|
|
468
|
+
d.out = outOf(s, [["model_urls", "glb", "url"], ["model_glb", "url"], ["model_mesh", "url"]]);
|
|
469
|
+
if (!d.out) return undefined;
|
|
470
|
+
return { ...result, data: d };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const DESCRIBE = { image: describeImage, video: describeVideo, audio: describeAudio, "3d": describe3D };
|
|
474
|
+
|
|
475
|
+
/** fal.ai's models of a kind, as OhMyGame lists them. */
|
|
476
|
+
async function listModels(kind, host) {
|
|
477
|
+
const lists = await Promise.all(CATEGORIES[kind].map(async (category) => (await listCategory(category, host)).map((model) => [category, model])));
|
|
478
|
+
const models = [];
|
|
479
|
+
const seen = new Set();
|
|
480
|
+
for (const [category, model] of lists.flat()) {
|
|
481
|
+
const id = model && model.endpoint_id;
|
|
482
|
+
if (typeof id !== "string" || !id || id.length > 200 || seen.has(id)) continue;
|
|
483
|
+
let described;
|
|
484
|
+
try {
|
|
485
|
+
const schemas = schemasOf(model);
|
|
486
|
+
described = schemas && DESCRIBE[kind](model, schemas, category);
|
|
487
|
+
} catch {
|
|
488
|
+
described = undefined;
|
|
489
|
+
}
|
|
490
|
+
if (!described) continue;
|
|
491
|
+
seen.add(id);
|
|
492
|
+
const meta = model.metadata || {};
|
|
493
|
+
const description = text(meta.description, 500);
|
|
494
|
+
models.push({ id, title: text(meta.display_name, 200) || id, ...(description ? { description } : {}), ...described });
|
|
495
|
+
}
|
|
496
|
+
return { models };
|
|
497
|
+
}
|
|
498
|
+
|
|
77
499
|
export default {
|
|
78
500
|
image: {
|
|
501
|
+
models: (host) => listModels("image", host),
|
|
79
502
|
start(input, host) {
|
|
503
|
+
if (input.modelData) return startListed("image", input, host);
|
|
80
504
|
return submit(input.model, {
|
|
81
505
|
prompt: input.prompt,
|
|
82
506
|
image_size: IMAGE_SIZES[input.aspectRatio] || "square_hd",
|
|
@@ -89,7 +513,9 @@ export default {
|
|
|
89
513
|
},
|
|
90
514
|
|
|
91
515
|
video: {
|
|
516
|
+
models: (host) => listModels("video", host),
|
|
92
517
|
start(input, host) {
|
|
518
|
+
if (input.modelData) return startListed("video", input, host);
|
|
93
519
|
const [first, last] = input.references;
|
|
94
520
|
if (!first) return { error: "Kling O3 on fal.ai makes a video from a first frame: add a picture." };
|
|
95
521
|
const body = { image_url: dataUri(first), duration: String(input.duration) };
|
|
@@ -102,7 +528,9 @@ export default {
|
|
|
102
528
|
},
|
|
103
529
|
|
|
104
530
|
audio: {
|
|
531
|
+
models: (host) => listModels("audio", host),
|
|
105
532
|
start(input, host) {
|
|
533
|
+
if (input.modelData) return startListed("audio", input, host);
|
|
106
534
|
if (input.model === "fal-ai/minimax/speech-02-hd") {
|
|
107
535
|
// OhMyGame's speed runs from -50 (half as fast) to 100 (twice as fast); MiniMax's from 0.5 to 2.
|
|
108
536
|
const speed = Math.min(2, Math.max(0.5, 1 + (input.speed || 0) / 100));
|
|
@@ -125,7 +553,9 @@ export default {
|
|
|
125
553
|
},
|
|
126
554
|
|
|
127
555
|
"3d": {
|
|
556
|
+
models: (host) => listModels("3d", host),
|
|
128
557
|
start(input, host) {
|
|
558
|
+
if (input.modelData) return startListed("3d", input, host);
|
|
129
559
|
const front = input.references && input.references[0];
|
|
130
560
|
if (input.operation !== "image" || !front) return { error: "TRELLIS on fal.ai makes a 3D model from a picture: add one." };
|
|
131
561
|
return submit(input.model, { image_url: dataUri(front) }, host);
|