@vidofy/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/backend.d.ts +133 -0
- package/dist/backend.js +455 -0
- package/dist/config.d.ts +85 -0
- package/dist/config.js +207 -0
- package/dist/heartbeat.d.ts +11 -0
- package/dist/heartbeat.js +70 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +885 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +661 -0
- package/dist/log.d.ts +19 -0
- package/dist/log.js +22 -0
- package/dist/map/b2c.d.ts +139 -0
- package/dist/map/b2c.js +236 -0
- package/dist/media-duration.d.ts +33 -0
- package/dist/media-duration.js +137 -0
- package/dist/oauth/authorize.d.ts +61 -0
- package/dist/oauth/authorize.js +275 -0
- package/dist/oauth/clients.d.ts +137 -0
- package/dist/oauth/clients.js +621 -0
- package/dist/oauth/ratelimit.d.ts +92 -0
- package/dist/oauth/ratelimit.js +116 -0
- package/dist/oauth/store.d.ts +135 -0
- package/dist/oauth/store.js +277 -0
- package/dist/oauth/token.d.ts +29 -0
- package/dist/oauth/token.js +165 -0
- package/dist/schema.d.ts +147 -0
- package/dist/schema.js +629 -0
- package/dist/tools/account.d.ts +28 -0
- package/dist/tools/account.js +130 -0
- package/dist/tools/generation.d.ts +102 -0
- package/dist/tools/generation.js +1194 -0
- package/dist/tools/info.d.ts +50 -0
- package/dist/tools/info.js +95 -0
- package/dist/ui/generation-card.html +869 -0
- package/package.json +79 -0
- package/server.json +36 -0
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a generation, price one first, and read one back.
|
|
3
|
+
*
|
|
4
|
+
* `generate` is the only tool in this package that spends anything. Everything
|
|
5
|
+
* around it is read-only.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { log } from '../log.js';
|
|
9
|
+
import { request, contentIdempotencyKey, sleep, VidofyError } from '../backend.js';
|
|
10
|
+
import { buildModelSchema } from '../schema.js';
|
|
11
|
+
import { mapStatus, mapResult, readCostCredits, stripProviderCost } from '../map/b2c.js';
|
|
12
|
+
/** coins on the account door, credits on the key door — the wallets differ. */
|
|
13
|
+
const unitFor = (cfg) => (cfg.mode === 'account' ? 'coins' : 'credits');
|
|
14
|
+
/**
|
|
15
|
+
* Turn the agent's clean-named input into what the wire wants.
|
|
16
|
+
*
|
|
17
|
+
* Shared by estimate_cost and generate deliberately: if the two mapped inputs
|
|
18
|
+
* differently, the quote the user approved would not be the job they paid for.
|
|
19
|
+
*
|
|
20
|
+
* An unrecognised key is an ERROR, not something to drop. Dropping it prices —
|
|
21
|
+
* and then generates — the model's DEFAULTS while the agent believes it asked
|
|
22
|
+
* for something else, which on a model whose price swings 20× with its settings
|
|
23
|
+
* is both the wrong output and the wrong bill, with nothing in either response
|
|
24
|
+
* to say so.
|
|
25
|
+
*/
|
|
26
|
+
function toWire(schema, input,
|
|
27
|
+
/* Slot indices already claimed by a reuse reference, keyed by the slot's
|
|
28
|
+
* clean name. A numbered multi-upload slot can hold a MIX — one file from
|
|
29
|
+
* disk and one chained from an earlier generation — and both halves are
|
|
30
|
+
* numbered into the same m_multi_file_<n> namespace. Without this the
|
|
31
|
+
* uploads restart at 0 and overwrite the reuse entries the server was told
|
|
32
|
+
* about, so the job silently runs on the wrong inputs. */
|
|
33
|
+
takenIndices = {}) {
|
|
34
|
+
const form = {
|
|
35
|
+
m_model_key: schema.model_key,
|
|
36
|
+
m_slug: schema.slug,
|
|
37
|
+
/* Sent for effect models, omitted for the rest.
|
|
38
|
+
*
|
|
39
|
+
* Without it the price handler falls back to a rate that does not match
|
|
40
|
+
* the effect, and the worker dispatches an empty scene, so the user is
|
|
41
|
+
* billed the wrong amount for the wrong output.
|
|
42
|
+
* It rides here rather than in `wire` because it is not a choice the
|
|
43
|
+
* agent makes: it belongs to the model it already picked. */
|
|
44
|
+
...(schema.effect_key !== '' ? { m_effect_key: schema.effect_key } : {}),
|
|
45
|
+
m_mode: schema.mode_wire,
|
|
46
|
+
};
|
|
47
|
+
const files = [];
|
|
48
|
+
const slots = new Map(schema.files.map((f) => [f.name, f]));
|
|
49
|
+
const unknown = [];
|
|
50
|
+
for (const [clean, value] of Object.entries(input ?? {})) {
|
|
51
|
+
if (value === undefined || value === null)
|
|
52
|
+
continue;
|
|
53
|
+
const slot = slots.get(clean);
|
|
54
|
+
if (slot) {
|
|
55
|
+
/* A reuse reference is resolved BEFORE toWire runs (generate does
|
|
56
|
+
it, because it needs a round trip), and estimate_cost now drops
|
|
57
|
+
every file slot before calling here, so no ordinary path reaches
|
|
58
|
+
this branch any more. It stays as a guard: a future caller that
|
|
59
|
+
forgets one of those two steps gets a sentence naming the input
|
|
60
|
+
instead of "[object Object]" arriving at the filesystem. */
|
|
61
|
+
if (isReuseRef(value)) {
|
|
62
|
+
throw new VidofyError('REUSE_NOT_SUPPORTED_HERE', `"${clean}" was given a from_generation reference, which only generate can resolve. ` +
|
|
63
|
+
'estimate_cost prices the settings, not the file — omit file inputs when pricing.');
|
|
64
|
+
}
|
|
65
|
+
/* A multi-upload slot's wire name ends in a LITERAL "_N", because
|
|
66
|
+
the server numbers the fields itself: it matches
|
|
67
|
+
^m_multi_file_\d+$ / ^m_multi_<type>_file_\d+$
|
|
68
|
+
as a pattern. Sent verbatim, the file
|
|
69
|
+
arrives under a field name nothing reads, the count comes to
|
|
70
|
+
zero, and the submit 422s below min_media — every one of the 56
|
|
71
|
+
models with a required multi slot was unusable until 2026-09-07.
|
|
72
|
+
These slots also take SEVERAL files, so the value may be a list. */
|
|
73
|
+
const isNumbered = slot.wire.endsWith('_N');
|
|
74
|
+
/* Check every ELEMENT, not just the value. `String()` on an object
|
|
75
|
+
* yields "[object Object]", which used to travel all the way to
|
|
76
|
+
* the filesystem and surface as
|
|
77
|
+
* FILE_UNREADABLE: ENOENT ... lstat '[object Object]'
|
|
78
|
+
* — an error that names nothing the caller wrote and leaks an
|
|
79
|
+
* internal call. A reuse reference inside a list is the case that
|
|
80
|
+
* hit it, and generate now lifts those out before this runs, so
|
|
81
|
+
* anything left here is genuinely not a path. */
|
|
82
|
+
const raw = Array.isArray(value) ? value : [value];
|
|
83
|
+
const paths = raw.map((el, i) => {
|
|
84
|
+
if (typeof el === 'string')
|
|
85
|
+
return el;
|
|
86
|
+
const where = Array.isArray(value) ? `${clean}[${i}]` : clean;
|
|
87
|
+
throw new VidofyError('INVALID_FILE_INPUT', `${where} must be a path to a file on this machine, or ` +
|
|
88
|
+
'{"from_generation": "<id>"} to reuse an earlier generation. ' +
|
|
89
|
+
`Received ${el === null ? 'null' : typeof el}.`);
|
|
90
|
+
});
|
|
91
|
+
if (!isNumbered && paths.length > 1) {
|
|
92
|
+
throw new VidofyError('TOO_MANY_FILES', `${clean} takes a single file, but ${paths.length} paths were given.`);
|
|
93
|
+
}
|
|
94
|
+
/* Number around whatever reuse already claimed, so a mixed list
|
|
95
|
+
* stays collision-free and keeps its order. */
|
|
96
|
+
const taken = takenIndices[clean] ?? new Set();
|
|
97
|
+
let next = 0;
|
|
98
|
+
paths.forEach((p) => {
|
|
99
|
+
while (taken.has(next))
|
|
100
|
+
next++;
|
|
101
|
+
const index = next++;
|
|
102
|
+
files.push({
|
|
103
|
+
field: isNumbered ? slot.wire.replace(/_N$/, `_${index}`) : slot.wire,
|
|
104
|
+
path: p,
|
|
105
|
+
// The limits travel with the file so the transport can
|
|
106
|
+
// refuse a wrong type or an oversized one before reading it.
|
|
107
|
+
accept: slot.accepts,
|
|
108
|
+
...(slot.maxSizeMb !== null ? { maxSizeMb: slot.maxSizeMb } : {}),
|
|
109
|
+
// The length cap travels with the file for the same reason
|
|
110
|
+
// the size and type do: so the transport can refuse before
|
|
111
|
+
// the upload rather than after the server's 422.
|
|
112
|
+
...(slot.maxDurationSec !== null ? { maxDurationSec: slot.maxDurationSec } : {}),
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const wire = schema.wire[clean];
|
|
118
|
+
if (wire === undefined) {
|
|
119
|
+
unknown.push(clean);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
/* A SETTING must be a single scalar, and the check has to be here.
|
|
123
|
+
*
|
|
124
|
+
* This line used to be `String(value)` with no test, and the input
|
|
125
|
+
* schema is `z.record(z.unknown())` — any shape parses — while the
|
|
126
|
+
* required-field check above asks only whether a key is PRESENT. So
|
|
127
|
+
* {"prompt": {"text": "a cat"}} passed every gate and reached the
|
|
128
|
+
* server as m_prompt=[object Object]. Coins are deducted when the
|
|
129
|
+
* submit is accepted, not when it succeeds, so the user paid for a
|
|
130
|
+
* generation on a literal that means nothing. A list is the same
|
|
131
|
+
* failure with a comma: String(["a","b"]) is "a,b".
|
|
132
|
+
*
|
|
133
|
+
* Refused rather than unwrapped. Guessing that {"text": …} meant its
|
|
134
|
+
* .text is how you charge someone for a generation on a value they
|
|
135
|
+
* never wrote — and the file slots above already refuse a non-string
|
|
136
|
+
* for exactly this reason, so this is the same rule applied to the
|
|
137
|
+
* other half of the input.
|
|
138
|
+
*
|
|
139
|
+
* Scalar is the whole legitimate set, measured across the live
|
|
140
|
+
* catalogue rather than assumed: every dynamic field in it is
|
|
141
|
+
* radio_group, select, slider or toggle, and none is multi-valued.
|
|
142
|
+
*
|
|
143
|
+
* Non-finite numbers go with them. NaN is typeof "number" and would
|
|
144
|
+
* slip through a plain typeof test as the string "NaN", which PHP
|
|
145
|
+
* casts to 0 — a slider silently priced and generated at zero is the
|
|
146
|
+
* same defect wearing a different type. */
|
|
147
|
+
const scalar = typeof value === 'string' ||
|
|
148
|
+
typeof value === 'boolean' ||
|
|
149
|
+
(typeof value === 'number' && Number.isFinite(value));
|
|
150
|
+
if (!scalar) {
|
|
151
|
+
const got = Array.isArray(value)
|
|
152
|
+
? 'a list'
|
|
153
|
+
: typeof value === 'object'
|
|
154
|
+
? 'an object'
|
|
155
|
+
: typeof value === 'number'
|
|
156
|
+
? `${value}`
|
|
157
|
+
: typeof value;
|
|
158
|
+
throw new VidofyError('INVALID_INPUT_VALUE', `"${clean}" takes one text, number or true/false value — received ${got}. ` +
|
|
159
|
+
'Pass the value itself, not a wrapper around it: ' +
|
|
160
|
+
`{"${clean}": "…"}, not {"${clean}": {"…": "…"}}. ` +
|
|
161
|
+
'Call get_model for this input\'s type.');
|
|
162
|
+
}
|
|
163
|
+
form[wire] = typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value);
|
|
164
|
+
}
|
|
165
|
+
if (unknown.length) {
|
|
166
|
+
const known = [...Object.keys(schema.wire), ...slots.keys()].sort();
|
|
167
|
+
throw new VidofyError('UNKNOWN_INPUT_FIELD', `${schema.model_key} has no input called ${unknown.map((u) => `"${u}"`).join(', ')}. ` +
|
|
168
|
+
`Call get_model and use the names in its schema: ${known.join(', ')}.`);
|
|
169
|
+
}
|
|
170
|
+
return { form, files };
|
|
171
|
+
}
|
|
172
|
+
/* ── estimate_cost ───────────────────────────────────────────────────────── */
|
|
173
|
+
export const estimateCostInput = z.object({
|
|
174
|
+
model: z.string().min(1).describe('Model slug, as given to get_model.'),
|
|
175
|
+
input: z
|
|
176
|
+
.record(z.unknown())
|
|
177
|
+
.optional()
|
|
178
|
+
.describe('The same input object you would pass to generate. Price varies enormously with ' +
|
|
179
|
+
'it — on some models by 20× between the cheapest and dearest settings — so pass ' +
|
|
180
|
+
'the real values, not an empty object.'),
|
|
181
|
+
});
|
|
182
|
+
/**
|
|
183
|
+
* Ask the server what a generation would cost, before running it.
|
|
184
|
+
*
|
|
185
|
+
* The price is computed by the same calculator the submit path uses, so this
|
|
186
|
+
* is an answer rather than an estimate — provided the input matches what
|
|
187
|
+
* generate will be given.
|
|
188
|
+
*/
|
|
189
|
+
export async function estimateCost(cfg,
|
|
190
|
+
// `| undefined` spelled out because exactOptionalPropertyTypes distinguishes
|
|
191
|
+
// "absent" from "present and undefined", and zod's .optional() produces the
|
|
192
|
+
// latter.
|
|
193
|
+
args) {
|
|
194
|
+
// The wire names live in the model's schema; sending clean names would
|
|
195
|
+
// price the model's defaults instead of what the agent actually chose, and
|
|
196
|
+
// do it silently.
|
|
197
|
+
const info = await request(cfg, {
|
|
198
|
+
method: 'GET',
|
|
199
|
+
path: `info/model-info/${encodeURIComponent(args.model)}`,
|
|
200
|
+
});
|
|
201
|
+
const schema = buildModelSchema(info);
|
|
202
|
+
/* Same mapping generate will use — see toWire — but with the file slots
|
|
203
|
+
* stripped out first.
|
|
204
|
+
*
|
|
205
|
+
* Pricing is computed from the settings (duration, resolution, quality,
|
|
206
|
+
* count), never from the bytes; the endpoint receives no upload and would
|
|
207
|
+
* drop one anyway. Passing them through meant a caller who priced the exact
|
|
208
|
+
* input they were about to generate got REUSE_NOT_SUPPORTED_HERE and the
|
|
209
|
+
* whole call failed, which forced them to build two different input objects
|
|
210
|
+
* for one operation — an easy way to price A and then generate B.
|
|
211
|
+
*
|
|
212
|
+
* Dropped silently and by SLOT NAME, so a path is discarded on the same
|
|
213
|
+
* footing as a reuse reference and neither can reach toWire's file branch. */
|
|
214
|
+
const pricingInput = { ...(args.input ?? {}) };
|
|
215
|
+
for (const slot of schema.files)
|
|
216
|
+
delete pricingInput[slot.name];
|
|
217
|
+
const { form } = toWire(schema, pricingInput);
|
|
218
|
+
const payload = await request(cfg, { method: 'POST', path: 'info/model-credits', form });
|
|
219
|
+
const cost = readCostCredits(payload, unitFor(cfg));
|
|
220
|
+
/* On an upload-billed model this figure is a FLOOR, and saying so is the
|
|
221
|
+
* whole point of the field.
|
|
222
|
+
*
|
|
223
|
+
* The block above drops every file slot before pricing, and the pricing
|
|
224
|
+
* endpoint receives no upload and would discard one anyway — but a minority
|
|
225
|
+
* of models are charged by the DURATION of the media the
|
|
226
|
+
* user uploads (the submit path measures it with ffprobe). For those, a
|
|
227
|
+
* settings-only number returned under the name `cost` is a quote that is
|
|
228
|
+
* always too low, told to the user as a price and then contradicted by the
|
|
229
|
+
* charge. The number is still worth returning — it is the settings
|
|
230
|
+
* component, and the agent has nothing else — but not under a name that
|
|
231
|
+
* claims to be the total. */
|
|
232
|
+
const upload = schema.billedByUploadDuration;
|
|
233
|
+
return {
|
|
234
|
+
model: schema.model_key,
|
|
235
|
+
cost: cost,
|
|
236
|
+
unit: unitFor(cfg),
|
|
237
|
+
/* Present either way, and false rather than absent: an agent that has
|
|
238
|
+
* to infer "no news is good news" from a missing key will eventually
|
|
239
|
+
* infer it from a key that went missing for a different reason. */
|
|
240
|
+
cost_is_final: !upload,
|
|
241
|
+
...(upload ? { depends_on: 'the duration of the media you upload' } : {}),
|
|
242
|
+
note: (upload
|
|
243
|
+
? 'THIS IS A FLOOR, NOT THE PRICE. This model is billed by the duration of ' +
|
|
244
|
+
'the media you upload, which a quote never sends — the figure above covers ' +
|
|
245
|
+
'the settings only and the real charge will be higher, in proportion to how ' +
|
|
246
|
+
'long your file is. Tell the user that before generating. '
|
|
247
|
+
: '') +
|
|
248
|
+
`Charged when the generation is submitted, not when it finishes. ` +
|
|
249
|
+
`Re-check after changing any input — the price depends on all of them.`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const isReuseRef = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) &&
|
|
253
|
+
typeof v['from_generation'] === 'string';
|
|
254
|
+
/**
|
|
255
|
+
* Turn `{from_generation: id}` into the R2 object that generation produced.
|
|
256
|
+
*
|
|
257
|
+
* The raw key is fetched here and never shown to the agent: it asks with an id
|
|
258
|
+
* it already has from get_result, and the key stays inside this package.
|
|
259
|
+
*
|
|
260
|
+
* `bucket` matters. A private first-party output lives in the PRIVATE bucket
|
|
261
|
+
* and comes back as a presigned URL, which is why matching on the URL would
|
|
262
|
+
* fail — the resolver's CDN-prefix test only knows the public domain. The key
|
|
263
|
+
* plus `bucket:'private'` sidesteps that entirely, and the worker resolves it
|
|
264
|
+
* with its own private-URL builder. Nothing is downloaded.
|
|
265
|
+
*/
|
|
266
|
+
async function resolveReuse(cfg, ref, want, slotName, budgetMs) {
|
|
267
|
+
const payload = await request(cfg, {
|
|
268
|
+
method: 'GET',
|
|
269
|
+
path: `generate/result/${encodeURIComponent(ref.from_generation)}`,
|
|
270
|
+
allowEnvelopeError: true,
|
|
271
|
+
// Bounded by the caller's remaining budget — see the deadline in
|
|
272
|
+
// generate(). One attempt: a read that is retried three times inside a
|
|
273
|
+
// tool call the client has already abandoned helps nobody.
|
|
274
|
+
timeoutMs: budgetMs,
|
|
275
|
+
maxAttempts: 1,
|
|
276
|
+
});
|
|
277
|
+
const root = (payload ?? {});
|
|
278
|
+
const data = (root['data'] ?? {});
|
|
279
|
+
const result = (data['result'] ?? {});
|
|
280
|
+
const column = want === 'video' ? 'm_output_video' : want === 'audio' ? 'm_output_audio' : 'm_output_image';
|
|
281
|
+
const key = typeof result[column] === 'string' ? result[column].trim() : '';
|
|
282
|
+
if (key === '') {
|
|
283
|
+
const status = typeof data['m_status'] === 'string' ? data['m_status'] : 'unknown';
|
|
284
|
+
throw new VidofyError('REUSE_SOURCE_UNAVAILABLE', `Generation ${ref.from_generation} has no ${want} output to reuse for "${slotName}" ` +
|
|
285
|
+
`(its status is "${status}"). Only a finished generation of the right media type can be reused.`);
|
|
286
|
+
}
|
|
287
|
+
/* Always the PRIVATE bucket, and the reason is not the one this used to
|
|
288
|
+
give. It read `data['m_public']` and branched on it — but the result
|
|
289
|
+
endpoint emits m_public in NEITHER response shape, so that expression was always
|
|
290
|
+
'' and the branch always took this same side. A test that cannot fail is
|
|
291
|
+
worse than no test: it reads as a rule being applied.
|
|
292
|
+
|
|
293
|
+
Private is nonetheless the correct answer for everything an MCP caller
|
|
294
|
+
can reuse. The worker uploads every B2C output to the private bucket
|
|
295
|
+
FIRST and unconditionally, and only then COPIES a watermarked version to
|
|
296
|
+
the public one under the same key,
|
|
297
|
+
so the private object exists for every first-party row whatever m_public
|
|
298
|
+
says. Measured 2026-09-09 against live R2, ranged GET: private present
|
|
299
|
+
for 8/8 rows in each of image/video/audio x public/not-public.
|
|
300
|
+
|
|
301
|
+
The one shape with no private copy is a B2B row (m_origin='public_api'),
|
|
302
|
+
which is uploaded public-only. Reusing one would resolve to a key the
|
|
303
|
+
worker cannot fetch. It is reachable in principle — the result endpoint
|
|
304
|
+
scopes by user, not by origin, so an account that uses both the partner API and
|
|
305
|
+
this server could name such an id — and is left as a known gap rather
|
|
306
|
+
than guessed at, because closing it needs the origin in the payload. */
|
|
307
|
+
return {
|
|
308
|
+
key,
|
|
309
|
+
kind: want,
|
|
310
|
+
bucket: 'private',
|
|
311
|
+
file_name: key.split('/').pop() || key,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/* ── generate ────────────────────────────────────────────────────────────── */
|
|
315
|
+
export const generateInput = z.object({
|
|
316
|
+
model: z.string().min(1).describe('Model slug, as given to list_models / get_model.'),
|
|
317
|
+
input: z
|
|
318
|
+
.record(z.unknown())
|
|
319
|
+
.describe('The model\'s inputs, using the names from get_model. A file input takes EITHER ' +
|
|
320
|
+
'a path to a file on this machine (pass the user\'s own file, never a copy you ' +
|
|
321
|
+
'made), OR — to reuse something Vidofy already made — an object ' +
|
|
322
|
+
'{"from_generation": "<id of an earlier generation>"}. Reuse is the right way to ' +
|
|
323
|
+
'chain: it costs no upload and no download, because the file is already in ' +
|
|
324
|
+
'Vidofy\'s storage. Example: {"image": {"from_generation": "97803476830366548"}}.'),
|
|
325
|
+
/* The description says "on a subscriber account" because on a free one this
|
|
326
|
+
flag decides nothing: the submit handler forces m_public='on' below
|
|
327
|
+
u_pro 2 so that a watermarked copy exists, under the site's own B2C
|
|
328
|
+
watermark policy. Saying "the output stays private" to every caller
|
|
329
|
+
was a promise the server had stopped keeping. */
|
|
330
|
+
public: z
|
|
331
|
+
.boolean()
|
|
332
|
+
.optional()
|
|
333
|
+
.describe('Publish the result to the public Vidofy CDN, permanently. Default false. On a ' +
|
|
334
|
+
'subscriber account false keeps the output private, served through a link that ' +
|
|
335
|
+
'expires. On a free account the result is published and watermarked either way, ' +
|
|
336
|
+
'and this flag changes nothing. Only pass true if the user asked for a public, ' +
|
|
337
|
+
'permanent link.'),
|
|
338
|
+
});
|
|
339
|
+
/**
|
|
340
|
+
* Submit a generation. THIS SPENDS THE USER'S BALANCE.
|
|
341
|
+
*
|
|
342
|
+
* Charged at submit, not on success — so a job that
|
|
343
|
+
* fails later has still cost coins, refunded idempotently by the server's own
|
|
344
|
+
* failure path, not by anything here. Call estimate_cost first and show the
|
|
345
|
+
* user the number.
|
|
346
|
+
*
|
|
347
|
+
* Returns as soon as the job is queued, with an id to follow. It does not wait:
|
|
348
|
+
* a generation runs from ~30 seconds to several minutes, far past what a tool
|
|
349
|
+
* call should hold open, and the client would time out somewhere unpredictable.
|
|
350
|
+
* The agent polls get_status instead.
|
|
351
|
+
*/
|
|
352
|
+
/**
|
|
353
|
+
* Has the caller gone away?
|
|
354
|
+
*
|
|
355
|
+
* ⚠ THE ONE RULE ABOUT THIS SIGNAL: check it BEFORE the submit, never wire it
|
|
356
|
+
* INTO the submit.
|
|
357
|
+
*
|
|
358
|
+
* Charging happens server-side the instant PHP accepts the submit. So aborting a
|
|
359
|
+
* submit that is already in flight is the worst available outcome, strictly worse
|
|
360
|
+
* than doing nothing: the charge may already have committed, and by abandoning the
|
|
361
|
+
* response we throw away the media_id — the user pays and we cannot even tell them
|
|
362
|
+
* what was created. Passing `signal` to that request() call would look like the
|
|
363
|
+
* careful thing and would be the harmful thing.
|
|
364
|
+
*
|
|
365
|
+
* Before the submit, nothing has been spent, so stopping is free and saves the
|
|
366
|
+
* whole charge. After it, there is nothing to undo and the generation is not lost:
|
|
367
|
+
* it lands in the user's account and shows up in get_usage and in the studio. It
|
|
368
|
+
* simply does not appear in the chat they walked away from. Do not "fix" that with
|
|
369
|
+
* a refund — the work was done and delivered.
|
|
370
|
+
*/
|
|
371
|
+
function abandoned(ctx) {
|
|
372
|
+
return ctx?.signal?.aborted === true;
|
|
373
|
+
}
|
|
374
|
+
export async function generate(cfg, args, ctx) {
|
|
375
|
+
/* Checkpoint one, before any work at all.
|
|
376
|
+
*
|
|
377
|
+
* Cheap and rarely useful on its own — a client that has already gone by the
|
|
378
|
+
* time the handler starts is unusual. It is here because the expensive
|
|
379
|
+
* preparation begins on the next line, and the cost of checking is a property
|
|
380
|
+
* read. */
|
|
381
|
+
if (abandoned(ctx)) {
|
|
382
|
+
throw new VidofyError('CALL_ABANDONED', 'The caller disconnected before this generation started, so it was NOT submitted '
|
|
383
|
+
+ 'and nothing was charged.');
|
|
384
|
+
}
|
|
385
|
+
/* ONE budget for the whole tool call, because the client has one.
|
|
386
|
+
*
|
|
387
|
+
* The submit was already capped at 50s/1 attempt against the SDK's 60s
|
|
388
|
+
* DEFAULT_REQUEST_TIMEOUT_MSEC, but the reasoning stopped there and the
|
|
389
|
+
* preparation before it kept the transport defaults: 60s × 4 attempts,
|
|
390
|
+
* plus backoff. So a single slow model-info could run 264s — and with a
|
|
391
|
+
* reuse reference, another 264s on top — inside a call the client
|
|
392
|
+
* abandoned at 60s. Measured against the report of a generate that hung
|
|
393
|
+
* "more than 4 minutes" and ended with the server not responding: that is
|
|
394
|
+
* exactly 4 × 60s + backoff, and the arithmetic is the whole explanation.
|
|
395
|
+
*
|
|
396
|
+
* A deadline fixes it where per-request timeouts cannot: they compose by
|
|
397
|
+
* ADDING, so every new preflight step makes the ceiling worse, while a
|
|
398
|
+
* deadline is the same ceiling no matter how many steps run under it.
|
|
399
|
+
* Each step gets what is left, so the last one to start is the one that
|
|
400
|
+
* fails — and it fails before the client gives up, which is the point:
|
|
401
|
+
* a timeout the client sees is a timeout, and a timeout it does not see
|
|
402
|
+
* is a charge with no receipt. */
|
|
403
|
+
const deadline = Date.now() + 55_000;
|
|
404
|
+
const remainingMs = () => Math.max(2_000, deadline - Date.now());
|
|
405
|
+
const info = await request(cfg, {
|
|
406
|
+
method: 'GET',
|
|
407
|
+
path: `info/model-info/${encodeURIComponent(args.model)}`,
|
|
408
|
+
// Cached server-side for 7 days, so 10s is already generous; capped by
|
|
409
|
+
// the deadline as well, for the case where several reuse refs ran first.
|
|
410
|
+
timeoutMs: Math.min(10_000, remainingMs()),
|
|
411
|
+
maxAttempts: 1,
|
|
412
|
+
});
|
|
413
|
+
const schema = buildModelSchema(info);
|
|
414
|
+
/* Pull out the file slots the agent asked to REUSE rather than upload.
|
|
415
|
+
*
|
|
416
|
+
* The natural agent workflow is a chain — make an image, then animate it —
|
|
417
|
+
* and re-uploading is the wrong way to do it: the bytes are already in
|
|
418
|
+
* Vidofy's own R2. `regen_source_map` points the new job at the existing
|
|
419
|
+
* object, so nothing is downloaded, nothing is uploaded, and the server
|
|
420
|
+
* checks ownership before using it.
|
|
421
|
+
*
|
|
422
|
+
* Resolution happens here, not in toWire, because it needs a round trip
|
|
423
|
+
* per reference; what toWire receives afterwards is only local paths. */
|
|
424
|
+
const reuse = {};
|
|
425
|
+
const takenIndices = {};
|
|
426
|
+
const inputForWire = { ...(args.input ?? {}) };
|
|
427
|
+
for (const slot of schema.files) {
|
|
428
|
+
const v = inputForWire[slot.name];
|
|
429
|
+
// The slot decides which of the source's outputs to take: an image
|
|
430
|
+
// slot wants its image, a lipsync video slot wants its video.
|
|
431
|
+
const want = /video/i.test(slot.wire) || /video/i.test(slot.name) ? 'video'
|
|
432
|
+
: /audio/i.test(slot.wire) || /audio/i.test(slot.name) ? 'audio'
|
|
433
|
+
: 'image';
|
|
434
|
+
/* Refuse a chain the SERVER will throw away, before spending on it.
|
|
435
|
+
*
|
|
436
|
+
* supportsReuse was computed and reported by get_model and then never
|
|
437
|
+
* acted on here. The server-side filter that decides matches
|
|
438
|
+
* /^m_multi_file_\d+$/ and an exact allow-list, so
|
|
439
|
+
* a per-type slot — m_multi_image_file_0 and its kin — matches neither
|
|
440
|
+
* and is dropped with a bare `continue`. Nothing is said. The
|
|
441
|
+
* generation then runs without the input the caller asked for, or
|
|
442
|
+
* 422s below min_media after a wasted round trip resolving a reference
|
|
443
|
+
* that was never going to be used.
|
|
444
|
+
*
|
|
445
|
+
* Measured 2026-09-10 over the live catalogue: of the 46 active listed
|
|
446
|
+
* models with multi-upload, 39 use the legacy m_multi_file_N and chain
|
|
447
|
+
* fine; 7 use per-type slots and cannot. It is a server gap — the
|
|
448
|
+
* studio cannot chain those either — so this refuses rather than
|
|
449
|
+
* pretending, and says which slot and what to do instead. */
|
|
450
|
+
if ((isReuseRef(v) || (Array.isArray(v) && v.some(isReuseRef))) && !slot.supportsReuse) {
|
|
451
|
+
throw new VidofyError('REUSE_NOT_SUPPORTED_FOR_SLOT', `"${slot.name}" cannot reuse an earlier generation on ${schema.model_key}: the ` +
|
|
452
|
+
`server discards a chained input for this slot (${slot.wire}), so the job ` +
|
|
453
|
+
'would run without it. Download the file and pass its path instead, or pick ' +
|
|
454
|
+
'a model whose get_model shows supportsReuse on this slot.');
|
|
455
|
+
}
|
|
456
|
+
if (isReuseRef(v)) {
|
|
457
|
+
reuse[slot.wire] = await resolveReuse(cfg, v, want, slot.name, Math.min(10_000, remainingMs()));
|
|
458
|
+
delete inputForWire[slot.name];
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
/* A multi-upload slot takes a LIST, and a chain is the natural way to
|
|
462
|
+
* fill one — "edit the image you just made, plus this photo of mine".
|
|
463
|
+
* Only a bare object was recognised before, so a reference inside the
|
|
464
|
+
* list fell through to the upload path and died on
|
|
465
|
+
* lstat('[object Object]'). Each reference is numbered at the position
|
|
466
|
+
* the caller wrote it, and toWire numbers the remaining files around
|
|
467
|
+
* those, so order survives and nothing collides.
|
|
468
|
+
*
|
|
469
|
+
* Restricted to numbered slots because the wire name is what carries
|
|
470
|
+
* the index; a single-file slot given a list is still an error, and
|
|
471
|
+
* toWire raises it. */
|
|
472
|
+
if (Array.isArray(v) && slot.wire.endsWith('_N') && v.some(isReuseRef)) {
|
|
473
|
+
const taken = new Set();
|
|
474
|
+
const rest = [];
|
|
475
|
+
for (let i = 0; i < v.length; i++) {
|
|
476
|
+
const el = v[i];
|
|
477
|
+
if (isReuseRef(el)) {
|
|
478
|
+
reuse[slot.wire.replace(/_N$/, `_${i}`)] =
|
|
479
|
+
await resolveReuse(cfg, el, want, `${slot.name}[${i}]`, Math.min(10_000, remainingMs()));
|
|
480
|
+
taken.add(i);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
rest.push(el);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
takenIndices[slot.name] = taken;
|
|
487
|
+
if (rest.length)
|
|
488
|
+
inputForWire[slot.name] = rest;
|
|
489
|
+
else
|
|
490
|
+
delete inputForWire[slot.name];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const { form, files } = toWire(schema, inputForWire, takenIndices);
|
|
494
|
+
/* The map is keyed by the WIRE field name, which is exactly what the
|
|
495
|
+
server's own whitelist accepts — m_image / m_video / m_audio /
|
|
496
|
+
m_first_frame / m_last_frame / a dynamic field's own name / and
|
|
497
|
+
m_multi_file_N.
|
|
498
|
+
|
|
499
|
+
Known limit, and it is the SERVER's not ours: the per-type multi slots
|
|
500
|
+
(m_multi_<type>_file_N, 7 models) are absent from that whitelist, so
|
|
501
|
+
reuse is silently dropped for them — for the studio too, not just here. */
|
|
502
|
+
if (Object.keys(reuse).length) {
|
|
503
|
+
form['regen_source_map'] = JSON.stringify(reuse);
|
|
504
|
+
}
|
|
505
|
+
/* Refuse locally what the server would refuse anyway.
|
|
506
|
+
*
|
|
507
|
+
* Not a duplicate of the server's validator — it is the same check moved
|
|
508
|
+
* to where the error is readable. The 422 says INVALID_REQUEST with a
|
|
509
|
+
* field list; this says which named input is missing, in the vocabulary
|
|
510
|
+
* get_model just handed the agent, without a round trip. */
|
|
511
|
+
const missing = [
|
|
512
|
+
...schema.inputSchema.required.filter((r) => !(r in (args.input ?? {}))),
|
|
513
|
+
...schema.files.filter((f) => f.required && !(f.name in (args.input ?? {}))).map((f) => f.name),
|
|
514
|
+
];
|
|
515
|
+
if (missing.length) {
|
|
516
|
+
throw new VidofyError('MISSING_REQUIRED_INPUT', `${schema.model_key} needs ${missing.map((m) => `"${m}"`).join(', ')}. ` +
|
|
517
|
+
'Call get_model for the full input schema.');
|
|
518
|
+
}
|
|
519
|
+
/* Private by default. The server reads m_public as the literal 'on' — an
|
|
520
|
+
unchecked HTML checkbox sends nothing at all, which is exactly what
|
|
521
|
+
"private" means here, so the key is OMITTED rather than set to a falsy
|
|
522
|
+
value the handler would still see as present. */
|
|
523
|
+
if (args.public === true)
|
|
524
|
+
form['m_public'] = 'on';
|
|
525
|
+
/* Refuse to submit on a budget too small to hear the answer.
|
|
526
|
+
*
|
|
527
|
+
* Charging happens server-side the moment the submit is accepted, so a
|
|
528
|
+
* POST that starts with two seconds left is the worst possible outcome:
|
|
529
|
+
* the coins go, the answer arrives after the client has stopped listening,
|
|
530
|
+
* and the agent is told the call failed. Better to stop here, where
|
|
531
|
+
* nothing has been spent and the message can say so plainly. */
|
|
532
|
+
if (remainingMs() < 15_000) {
|
|
533
|
+
throw new VidofyError('PREFLIGHT_BUDGET_EXHAUSTED', 'Preparing this generation (reading the model, resolving the reused inputs) used the ' +
|
|
534
|
+
'whole time budget, so it was NOT submitted and nothing was charged. Retry, or ' +
|
|
535
|
+
'pass fewer from_generation references in one call.');
|
|
536
|
+
}
|
|
537
|
+
/* Checkpoint two — the one that matters, and it belongs exactly here.
|
|
538
|
+
*
|
|
539
|
+
* This is the last instant at which stopping is free: one line below, the POST
|
|
540
|
+
* goes out and the coins are gone. The guard above says the same thing about a
|
|
541
|
+
* spent time budget — "stop here, where nothing has been spent and the message
|
|
542
|
+
* can say so plainly" — and an abandoned call has the identical shape. The
|
|
543
|
+
* preparation that just ran (model-info, resolving every from_generation
|
|
544
|
+
* reference, hashing the payload) takes seconds and is where a user actually
|
|
545
|
+
* walks away, which is what makes this checkpoint the valuable one rather than
|
|
546
|
+
* the first.
|
|
547
|
+
*
|
|
548
|
+
* Measured 2026-09-13: a disconnect reaches this signal 5 ms after the socket
|
|
549
|
+
* dies, so by the time a multi-second preparation finishes, the flag is set. */
|
|
550
|
+
if (abandoned(ctx)) {
|
|
551
|
+
throw new VidofyError('CALL_ABANDONED', 'The caller disconnected while this generation was being prepared, so it was NOT '
|
|
552
|
+
+ 'submitted and nothing was charged.');
|
|
553
|
+
}
|
|
554
|
+
const payload = await request(cfg, {
|
|
555
|
+
method: 'POST',
|
|
556
|
+
path: 'generate/submit',
|
|
557
|
+
form,
|
|
558
|
+
files,
|
|
559
|
+
/* One key per logical generation — and "logical" has to mean the
|
|
560
|
+
* REQUEST, not the invocation.
|
|
561
|
+
*
|
|
562
|
+
* The server dedupes on it — it reads it, checks it, and keys the coin
|
|
563
|
+
* ledger with it — which makes
|
|
564
|
+
* the transport's retries safe. But this was newIdempotencyKey(), a
|
|
565
|
+
* randomUUID() minted fresh on every call, so the tuple the server
|
|
566
|
+
* dedupes on could never repeat and the dedupe could never fire: two
|
|
567
|
+
* identical generate calls were two generations and two charges. Both
|
|
568
|
+
* halves of the mechanism were built; the value joining them
|
|
569
|
+
* guaranteed they would miss.
|
|
570
|
+
*
|
|
571
|
+
* Derived from the model, the resolved inputs and the public flag,
|
|
572
|
+
* inside a 60-second window — see contentIdempotencyKey for why the
|
|
573
|
+
* window is part of the key rather than a nicety (the server's lookup
|
|
574
|
+
* has no time bound of its own).
|
|
575
|
+
*
|
|
576
|
+
* `form` is the right thing to hash rather than args.input: it is what
|
|
577
|
+
* actually goes on the wire, after toWire has resolved reuse
|
|
578
|
+
* references and normalised names, so two spellings of one request
|
|
579
|
+
* agree and two different requests cannot collide. The file PATHS ride
|
|
580
|
+
* separately because their bytes are not in `form`. */
|
|
581
|
+
idempotencyKey: contentIdempotencyKey(form, files.map((f) => f.path)),
|
|
582
|
+
/* Deliberately UNDER the client's own budget, not over it.
|
|
583
|
+
*
|
|
584
|
+
* The SDK gives a tool call 60s by default
|
|
585
|
+
* (shared/protocol.js DEFAULT_REQUEST_TIMEOUT_MSEC = 60000). This
|
|
586
|
+
* used to be 90s, and 300s with files, reasoning that an upload needs
|
|
587
|
+
* room — which had it exactly backwards: the client gives up at 60s
|
|
588
|
+
* while the POST keeps running, the server charges, and the agent is
|
|
589
|
+
* told the call failed and retries with a FRESH idempotency key. That
|
|
590
|
+
* is a double charge produced by the timeout meant to prevent
|
|
591
|
+
* trouble. Whatever budget we take has to end before the client's, so
|
|
592
|
+
* a timeout here is a real timeout and not a lost receipt. */
|
|
593
|
+
// Whatever the preparation left, never more than the original 50s.
|
|
594
|
+
// Guarded above to be at least 15s, so this is never a token amount.
|
|
595
|
+
timeoutMs: Math.min(50_000, remainingMs()),
|
|
596
|
+
// And ONE attempt, for the same reason: 4 × 50s would outlive the
|
|
597
|
+
// client's 60s budget several times over. The server-side dedupe makes
|
|
598
|
+
// a retry harmless; the client giving up mid-charge is what does not.
|
|
599
|
+
maxAttempts: 1,
|
|
600
|
+
/* ⚠ NO `signal` HERE, AND THAT IS THE POINT — see abandoned() above.
|
|
601
|
+
Aborting this request would not stop the charge (PHP commits the moment
|
|
602
|
+
it accepts) and would lose the media_id, so the user would pay for a
|
|
603
|
+
generation nobody can name. The signal is checked BEFORE this line and
|
|
604
|
+
never during it. */
|
|
605
|
+
});
|
|
606
|
+
/* Charged, and the caller is gone. Nothing to undo — the generation is real and
|
|
607
|
+
lands in their account — but it is worth a line in the log, because "a user
|
|
608
|
+
was charged for something they never saw" is the kind of thing that should be
|
|
609
|
+
countable rather than invisible if it turns out to be common. */
|
|
610
|
+
if (abandoned(ctx)) {
|
|
611
|
+
log('generate: the caller disconnected after the submit was accepted — '
|
|
612
|
+
+ 'the generation is charged and will complete; it appears in get_usage and the studio.');
|
|
613
|
+
}
|
|
614
|
+
const root = stripProviderCost(payload);
|
|
615
|
+
const data = (root['data'] ?? root);
|
|
616
|
+
const pick = (...keys) => {
|
|
617
|
+
for (const k of keys) {
|
|
618
|
+
if (data[k] !== undefined)
|
|
619
|
+
return data[k];
|
|
620
|
+
if (root[k] !== undefined)
|
|
621
|
+
return root[k];
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
};
|
|
625
|
+
const id = pick('media_id', 'id', 'm_id');
|
|
626
|
+
/* No id means we cannot tell the user what they just paid for.
|
|
627
|
+
*
|
|
628
|
+
* The submit answered 200 and the coins are gone, but the response carried
|
|
629
|
+
* no identifier — a shape change, a proxy, a truncated body. Returning
|
|
630
|
+
* `id: null` beside "call get_status with this id" (which is what this did
|
|
631
|
+
* until 2026-09-07) sends the agent to poll `null` and tells the user
|
|
632
|
+
* nothing went wrong. Fail loudly and point at the one place the job can
|
|
633
|
+
* still be found. */
|
|
634
|
+
if (id === null || id === undefined || String(id) === '') {
|
|
635
|
+
throw new VidofyError('SUBMIT_ID_MISSING', 'Vidofy accepted the generation but returned no id, so it cannot be tracked from here. ' +
|
|
636
|
+
'The balance may already have been charged — call get_usage to find the job before ' +
|
|
637
|
+
'trying again, or a retry will pay for a second one.');
|
|
638
|
+
}
|
|
639
|
+
/* `request_status` is the JOB. `status` is the ENVELOPE, and reading it
|
|
640
|
+
* here was a real bug: a submit answers `status: 'success'` meaning "the
|
|
641
|
+
* request was accepted" while the job it
|
|
642
|
+
* just queued is `request_status: 'processing'` and has not
|
|
643
|
+
* started. Neither shape carries `m_status` on submit — measured — so the
|
|
644
|
+
* old `pick('m_status', 'status', …)` fell through to the envelope every
|
|
645
|
+
* single time and told the agent its generation had SUCCEEDED, one field
|
|
646
|
+
* away from get_status saying 'processing'. An agent that believed it went
|
|
647
|
+
* straight to get_result on an unfinished job.
|
|
648
|
+
*
|
|
649
|
+
* Still read rather than asserted, because 'processing' is not the only
|
|
650
|
+
* honest answer: a duplicate Idempotency-Key replays the original row
|
|
651
|
+
* and reports that row's real state, which may have finished or
|
|
652
|
+
* failed long ago. Hardcoding 'queued' would send the agent to poll
|
|
653
|
+
* something that will never change again. */
|
|
654
|
+
const reported = pick('request_status');
|
|
655
|
+
const status = typeof reported === 'string' && reported !== '' ? reported : 'queued';
|
|
656
|
+
return {
|
|
657
|
+
id,
|
|
658
|
+
status,
|
|
659
|
+
spent: pick('coins_charged', 'credits_charged'),
|
|
660
|
+
unit: unitFor(cfg),
|
|
661
|
+
estimated_seconds: pick('m_sec', 'estimated_seconds'),
|
|
662
|
+
// Also read back, not echoed: the server is what decides, and for a
|
|
663
|
+
// replayed duplicate the answer belongs to the ORIGINAL submit.
|
|
664
|
+
visibility: String(pick('m_public') ?? '') === 'on' || args.public === true ? 'public' : 'private',
|
|
665
|
+
model: schema.model_key,
|
|
666
|
+
/* For the card, not for the agent — which is why they are separate
|
|
667
|
+
* fields rather than a nicer `model`.
|
|
668
|
+
*
|
|
669
|
+
* model_key is what chaining and every later call need, so it stays as
|
|
670
|
+
* `model` untouched. But "Flux_schnell_t2i" is a database key wearing a
|
|
671
|
+
* label's clothes, and the card showed it for the whole wait and then
|
|
672
|
+
* swapped to "Flux Schnell" on completion — the same generation
|
|
673
|
+
* appearing to change model. model_name is the name a person reads, and
|
|
674
|
+
* model_icon is the provider's mark, so the card can carry the maker's
|
|
675
|
+
* logo instead of a coloured square. Both come from the schema already
|
|
676
|
+
* in hand; neither costs a request. */
|
|
677
|
+
model_name: schema.name !== '' ? schema.name : schema.model_key,
|
|
678
|
+
model_icon: schema.icon,
|
|
679
|
+
/* 'processing' is what a fresh submit reports, and it has to be in this
|
|
680
|
+
* list: leaving it out sent every new generation down the "already
|
|
681
|
+
* finished" branch. 'queued'/'pending' stay for the fallback above and
|
|
682
|
+
* for any older shape. */
|
|
683
|
+
next: status === 'processing' || status === 'queued' || status === 'pending'
|
|
684
|
+
? 'Charged now, not on success. Call get_status with this id — it is not finished ' +
|
|
685
|
+
'yet. When done:true, call get_result.'
|
|
686
|
+
: `This id was already submitted before and Vidofy replayed that job, which is ` +
|
|
687
|
+
`"${status}". Nothing was charged twice. Call get_status to confirm, then get_result.`,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
/* ── get_status ──────────────────────────────────────────────────────────── */
|
|
691
|
+
export const generationIdInput = z.object({
|
|
692
|
+
id: z.string().min(1).describe('The id returned by generate.'),
|
|
693
|
+
});
|
|
694
|
+
/** get_result takes the same id, plus a way to decline the inline image. */
|
|
695
|
+
export const generationResultInput = generationIdInput.extend({
|
|
696
|
+
include_preview: z
|
|
697
|
+
.boolean()
|
|
698
|
+
.optional()
|
|
699
|
+
.describe('Return the media itself as an image, so it appears in the conversation and you can ' +
|
|
700
|
+
'see it. Default true. An image comes back as itself; a video comes back as its ' +
|
|
701
|
+
'poster frame, with the video at url. Pass false when you only need the link — it ' +
|
|
702
|
+
'saves roughly 1,400 tokens.'),
|
|
703
|
+
});
|
|
704
|
+
/**
|
|
705
|
+
* How long get_status may hold the call open before answering.
|
|
706
|
+
*
|
|
707
|
+
* A model has no way to wait. Told to "poll", with a tool it can call
|
|
708
|
+
* instantly and nothing saying how often, it calls as fast as the loop
|
|
709
|
+
* allows — measured on a real audio run, roughly once a second, each one a
|
|
710
|
+
* paid round trip. The pacing the card uses (poll_after_ms, 3s then 6s) lives
|
|
711
|
+
* on generation_card_state, which is visibility:['app'] and so invisible to
|
|
712
|
+
* the model by design.
|
|
713
|
+
*
|
|
714
|
+
* Holding the call is the half that does not depend on the model's goodwill:
|
|
715
|
+
* one call now covers ten seconds of real time whatever the model intends.
|
|
716
|
+
*
|
|
717
|
+
* Ten seconds, not more, because the host's own tool timeout is variable and
|
|
718
|
+
* outside our control (owner, 2026-09-11) — so this has to be short enough to
|
|
719
|
+
* be safe under the worst of it rather than tuned to any measured value.
|
|
720
|
+
*/
|
|
721
|
+
const STATUS_HOLD_MS = 10_000;
|
|
722
|
+
/** Gap between internal re-reads while holding. */
|
|
723
|
+
const STATUS_RECHECK_MS = 2_000;
|
|
724
|
+
/**
|
|
725
|
+
* How long the agent should wait before asking again — the advisory half.
|
|
726
|
+
*
|
|
727
|
+
* Built on ELAPSED, deliberately, never on estimated_seconds. The estimate is
|
|
728
|
+
* not reliable enough to schedule against: measured 2026-09-11 over all
|
|
729
|
+
* successful rows, real time runs several times the estimate for images and
|
|
730
|
+
* about double for video, while audio is close. It is not stale data either —
|
|
731
|
+
* the stored estimate is only refreshed for a model with enough recent
|
|
732
|
+
* successes, and almost none qualify. Advising from it would send the agent
|
|
733
|
+
* back early, every time, on exactly the media type that is slowest.
|
|
734
|
+
*
|
|
735
|
+
* The steps come from the real distribution: audio finishes soonest, images
|
|
736
|
+
* next, video last.
|
|
737
|
+
*/
|
|
738
|
+
function nextCheckIn(elapsedSeconds) {
|
|
739
|
+
const e = elapsedSeconds ?? 0;
|
|
740
|
+
if (e < 60)
|
|
741
|
+
return 5;
|
|
742
|
+
if (e < 180)
|
|
743
|
+
return 15;
|
|
744
|
+
return 30;
|
|
745
|
+
}
|
|
746
|
+
export async function getStatus(cfg, args) {
|
|
747
|
+
const read = async () => mapStatus(await request(cfg, {
|
|
748
|
+
method: 'GET',
|
|
749
|
+
path: `generate/status/${encodeURIComponent(args.id)}`,
|
|
750
|
+
// "Your generation failed" is an ANSWER, not a failed call — the server
|
|
751
|
+
// sends it as 200 + success:false. Let the mapper report it as a
|
|
752
|
+
// terminal status instead of throwing a transport-shaped error.
|
|
753
|
+
allowEnvelopeError: true,
|
|
754
|
+
}));
|
|
755
|
+
/* Hold until it finishes or the budget runs out, whichever comes first.
|
|
756
|
+
*
|
|
757
|
+
* Nothing here can finish sooner than this hold anyway: across the
|
|
758
|
+
* successful rows measured, the fastest generation ever recorded took about
|
|
759
|
+
* ten seconds (audio and image; video's floor is far higher), and with the NSFW
|
|
760
|
+
* pre-check enabled the request goes to a moderation model BEFORE the
|
|
761
|
+
* provider is called at all, which only pushes that floor later. So the
|
|
762
|
+
* first answer costs the agent nothing in responsiveness. */
|
|
763
|
+
const until = Date.now() + STATUS_HOLD_MS;
|
|
764
|
+
let status = await read();
|
|
765
|
+
while (!status.done && Date.now() + STATUS_RECHECK_MS <= until) {
|
|
766
|
+
await sleep(STATUS_RECHECK_MS);
|
|
767
|
+
status = await read();
|
|
768
|
+
}
|
|
769
|
+
if (status.done)
|
|
770
|
+
return status;
|
|
771
|
+
return { ...status, check_again_in_seconds: nextCheckIn(status.elapsed_seconds) };
|
|
772
|
+
}
|
|
773
|
+
/* ── get_result ──────────────────────────────────────────────────────────── */
|
|
774
|
+
/**
|
|
775
|
+
* The ceiling on a WHOLE tool result, text and image together.
|
|
776
|
+
*
|
|
777
|
+
* There are two different limits and picking the wrong one broke this: 5 MB is
|
|
778
|
+
* the cap on a single IMAGE at the API, but the client refuses a TOOL RESULT
|
|
779
|
+
* over 1 MB — "Tool result is too large. Maximum size is 1MB" — and that is the
|
|
780
|
+
* one that applies. It is enforced by the host, not by the SDK, so nothing in
|
|
781
|
+
* this package's types would have caught it.
|
|
782
|
+
*
|
|
783
|
+
* The failure is total, which is what makes it serious: the result is rejected
|
|
784
|
+
* whole, so the caller loses the URL, the id and the status as well as the
|
|
785
|
+
* preview. get_result stops being a way to read anything at all. Measured on
|
|
786
|
+
* two real generations: 553 KB of JPEG came back (721 KB encoded), 1,188 KB of
|
|
787
|
+
* PNG was refused (1,547 KB encoded).
|
|
788
|
+
*
|
|
789
|
+
* 1,000,000 rather than 1,048,576 — the message says "1MB" without saying
|
|
790
|
+
* which, and the few kilobytes are not worth being wrong about.
|
|
791
|
+
*/
|
|
792
|
+
const MAX_TOOL_RESULT_BYTES = 1_000_000;
|
|
793
|
+
/**
|
|
794
|
+
* The biggest SOURCE file worth fetching for an inline preview.
|
|
795
|
+
*
|
|
796
|
+
* base64 inflates by 4/3, so this is the ceiling above less the room the JSON
|
|
797
|
+
* text needs. Checked before the download so an oversized file costs nothing,
|
|
798
|
+
* and checked again exactly after it — an estimate here, arithmetic there.
|
|
799
|
+
*/
|
|
800
|
+
const MAX_INLINE_BYTES = Math.floor((MAX_TOOL_RESULT_BYTES - 20_000) * 3 / 4);
|
|
801
|
+
/**
|
|
802
|
+
* Fetch the bytes for an inline preview.
|
|
803
|
+
*
|
|
804
|
+
* Never throws. A preview is a nicety on top of a result the caller already
|
|
805
|
+
* has; failing the whole tool because a thumbnail would not download turns a
|
|
806
|
+
* successful, PAID generation into an error. Every failure path returns null
|
|
807
|
+
* and the caller reports the reason in words.
|
|
808
|
+
*/
|
|
809
|
+
async function fetchPreview(url, knownBytes) {
|
|
810
|
+
// Refuse before spending anything, when the row told us the size.
|
|
811
|
+
if (knownBytes !== null && knownBytes > MAX_INLINE_BYTES)
|
|
812
|
+
return null;
|
|
813
|
+
try {
|
|
814
|
+
const res = await fetch(url, {
|
|
815
|
+
// Short and single: the media is already reachable by URL, so a
|
|
816
|
+
// slow preview is worth abandoning, never retrying. Same budget
|
|
817
|
+
// discipline as generate() — see the deadline there.
|
|
818
|
+
signal: AbortSignal.timeout(15_000),
|
|
819
|
+
redirect: 'error',
|
|
820
|
+
});
|
|
821
|
+
if (!res.ok)
|
|
822
|
+
return null;
|
|
823
|
+
/* Second guard, on the header. The row's size can be 0 (never
|
|
824
|
+
* recorded) or stale, and a video POSTER has no stored size at all —
|
|
825
|
+
* measured 7-98 KB, but measured is not guaranteed. Content-Length
|
|
826
|
+
* arrives before the body, so this still costs no transfer. */
|
|
827
|
+
const declared = Number(res.headers.get('content-length') ?? '');
|
|
828
|
+
if (Number.isFinite(declared) && declared > MAX_INLINE_BYTES)
|
|
829
|
+
return null;
|
|
830
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
831
|
+
// Third guard, for a response that declared nothing.
|
|
832
|
+
if (buf.byteLength > MAX_INLINE_BYTES)
|
|
833
|
+
return null;
|
|
834
|
+
/* Normalise, do not pass through.
|
|
835
|
+
*
|
|
836
|
+
* R2 serves the posters as `image/jpg`, which is NOT a real media type
|
|
837
|
+
* — the registered one is `image/jpeg`, and `jpg` only ever existed as
|
|
838
|
+
* a filename extension. It comes from the upload path naming the type
|
|
839
|
+
* after the extension. Trusting the header verbatim put an invalid
|
|
840
|
+
* type on the wire, which a client is entitled to reject outright, and
|
|
841
|
+
* it is exactly the kind of thing that fails on someone else's client
|
|
842
|
+
* and not on the one you tested with.
|
|
843
|
+
*
|
|
844
|
+
* An allow-list rather than a rewrite of whatever arrives: only these
|
|
845
|
+
* four are safe to claim, so anything unrecognised falls back to the
|
|
846
|
+
* extension and then to JPEG. */
|
|
847
|
+
const raw = (res.headers.get('content-type')?.split(';')[0] ?? '').trim().toLowerCase();
|
|
848
|
+
const CANON = {
|
|
849
|
+
'image/jpeg': 'image/jpeg',
|
|
850
|
+
'image/jpg': 'image/jpeg',
|
|
851
|
+
'image/png': 'image/png',
|
|
852
|
+
'image/webp': 'image/webp',
|
|
853
|
+
'image/gif': 'image/gif',
|
|
854
|
+
};
|
|
855
|
+
const mimeType = CANON[raw] ??
|
|
856
|
+
(/\.png(\?|$)/i.test(url) ? 'image/png'
|
|
857
|
+
: /\.webp(\?|$)/i.test(url) ? 'image/webp'
|
|
858
|
+
: /\.gif(\?|$)/i.test(url) ? 'image/gif'
|
|
859
|
+
: 'image/jpeg');
|
|
860
|
+
return { data: buf.toString('base64'), mimeType };
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
export async function getResult(cfg, args) {
|
|
867
|
+
const payload = await request(cfg, {
|
|
868
|
+
method: 'GET',
|
|
869
|
+
path: `generate/result/${encodeURIComponent(args.id)}`,
|
|
870
|
+
// Same as get_status — the result endpoint reports a failed job the
|
|
871
|
+
// same way, as a 200 whose envelope says success:false.
|
|
872
|
+
allowEnvelopeError: true,
|
|
873
|
+
});
|
|
874
|
+
// No mode argument: whether the URL expires is a property of the row, and
|
|
875
|
+
// the mapper reads it off the URL itself. See the note in mapResult.
|
|
876
|
+
const mapped = mapResult(payload);
|
|
877
|
+
if (!mapped.done) {
|
|
878
|
+
return {
|
|
879
|
+
...mapped,
|
|
880
|
+
hint: 'Still running. Call get_status to check, and get_result once it reports done.',
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/* Put the media IN the conversation, not just a link to it.
|
|
884
|
+
*
|
|
885
|
+
* A URL is invisible to the model — it is a string. Without this the agent
|
|
886
|
+
* hands over an address it has never seen, cannot say whether the light is
|
|
887
|
+
* right or which of two takes is better, and reaches for some other tool to
|
|
888
|
+
* look. MCP's image content block is the mechanism, and its `data` field
|
|
889
|
+
* takes base64 bytes only; a URL placed there decodes to noise.
|
|
890
|
+
*
|
|
891
|
+
* The link is NOT replaced. The block is a view; the URL is the file the
|
|
892
|
+
* user keeps, the full resolution, the video itself, and the only thing
|
|
893
|
+
* left when a preview is skipped. */
|
|
894
|
+
if (args.include_preview === false)
|
|
895
|
+
return mapped;
|
|
896
|
+
const isImage = mapped.media_type === 'image';
|
|
897
|
+
const isVideo = mapped.media_type === 'video';
|
|
898
|
+
/* Video is a link, always: the protocol has no video block. Its POSTER is
|
|
899
|
+
* a separate JPEG of a few tens of KB, so the frame goes inline
|
|
900
|
+
* and the video stays a URL. Audio has neither a poster nor a size that
|
|
901
|
+
* would fit — several MB typical — so it is a link and says so. */
|
|
902
|
+
/* An image now has a poster of its own too (2026-09-11).
|
|
903
|
+
*
|
|
904
|
+
* Until then m_thumbnail WAS the output key for images — identical on every
|
|
905
|
+
* row — so sending "the thumbnail" sent the full file and
|
|
906
|
+
* the block was refused for exceeding the 1 MB tool-result limit on most
|
|
907
|
+
* images. The pipeline now writes a real 768px WebP beside the output;
|
|
908
|
+
* measured on the first real generation through it, 14 KB against 1,514 KB.
|
|
909
|
+
*
|
|
910
|
+
* Compared against the url rather than merely checked for existence,
|
|
911
|
+
* because on every row made BEFORE this the two are still the same string
|
|
912
|
+
* — no backfill was done (owner, 2026-09-11) — and those must keep the old
|
|
913
|
+
* behaviour exactly, size guard included. A row with a real thumbnail
|
|
914
|
+
* skips that guard: output_size describes the full file, which is not
|
|
915
|
+
* what we are fetching. */
|
|
916
|
+
const imageThumb = isImage && mapped.thumbnail_url !== null && mapped.thumbnail_url !== mapped.url
|
|
917
|
+
? mapped.thumbnail_url
|
|
918
|
+
: null;
|
|
919
|
+
const src = isImage ? (imageThumb ?? mapped.url) : isVideo ? mapped.thumbnail_url : null;
|
|
920
|
+
const known = isImage && imageThumb === null ? mapped.output_size : null;
|
|
921
|
+
if (src === null) {
|
|
922
|
+
return {
|
|
923
|
+
...mapped,
|
|
924
|
+
preview: `No inline preview for ${mapped.media_type ?? 'this media type'} — open the url.`,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const preview = await fetchPreview(src, known);
|
|
928
|
+
if (preview === null) {
|
|
929
|
+
const tooBig = known !== null && known > MAX_INLINE_BYTES;
|
|
930
|
+
return {
|
|
931
|
+
...mapped,
|
|
932
|
+
preview: tooBig
|
|
933
|
+
? `Not shown inline: the file is ${(known / 1_048_576).toFixed(1)} MB, and a tool ` +
|
|
934
|
+
`result may carry only 1 MB in total once the image is base64-encoded. ` +
|
|
935
|
+
`Open the url for it — everything else in this result is complete.`
|
|
936
|
+
: 'The inline preview could not be fetched. The result itself is fine — open the url.',
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
/* Last guard, and the only exact one: measure the finished result.
|
|
940
|
+
*
|
|
941
|
+
* Everything above is an estimate — a size the row reported, a header the
|
|
942
|
+
* origin declared, a byte count before encoding. This weighs what will
|
|
943
|
+
* actually be sent. It matters because being wrong here is not "no
|
|
944
|
+
* preview", it is the host rejecting the entire result, so the caller
|
|
945
|
+
* loses the URL and the status too and has no idea why. */
|
|
946
|
+
const textBytes = Buffer.byteLength(JSON.stringify(mapped, null, 2), 'utf8');
|
|
947
|
+
if (textBytes + preview.data.length > MAX_TOOL_RESULT_BYTES) {
|
|
948
|
+
return {
|
|
949
|
+
...mapped,
|
|
950
|
+
preview: `Not shown inline: encoded it comes to ${(preview.data.length / 1_048_576).toFixed(1)} MB, ` +
|
|
951
|
+
`over the 1 MB a tool result may carry. The file itself is fine — open the url.`,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
const out = {
|
|
955
|
+
...mapped,
|
|
956
|
+
preview: isVideo
|
|
957
|
+
? 'The image shown is the video POSTER. The video itself is at url.'
|
|
958
|
+
: 'Shown above. url is the same file at full resolution.',
|
|
959
|
+
};
|
|
960
|
+
/* Held beside the result, never on it — see setPreview. The dispatcher
|
|
961
|
+
* reads it back and emits a separate image block. */
|
|
962
|
+
setPreview(out, preview);
|
|
963
|
+
return out;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Media a tool wants shown, held BESIDE the result rather than on it.
|
|
967
|
+
*
|
|
968
|
+
* This was a Symbol key on the result object, chosen because JSON.stringify
|
|
969
|
+
* skips symbol keys — structural protection instead of a rule to remember. It
|
|
970
|
+
* was also, exactly, a bug: the SDK validates every tool result against
|
|
971
|
+
* CallToolResultSchema, structuredContent is a z.record(z.string(), …), and Zod
|
|
972
|
+
* walks own SYMBOL keys too. So the response was rejected before it left, as
|
|
973
|
+
* `-32602 Invalid tools/call result: expected string, received symbol`, and the
|
|
974
|
+
* host showed "Failed to call tool". Deterministic: every get_result carrying a
|
|
975
|
+
* preview failed and include_preview:false always worked, which is what made it
|
|
976
|
+
* look like a size limit when size had nothing to do with it.
|
|
977
|
+
*
|
|
978
|
+
* A WeakMap has no key on the object at all — nothing to serialise, nothing to
|
|
979
|
+
* validate, nothing to iterate over. It is the primitive this needed from the
|
|
980
|
+
* start. Entries die with the result they belong to.
|
|
981
|
+
*/
|
|
982
|
+
const previews = new WeakMap();
|
|
983
|
+
/** Attach media to a result. @see takePreview */
|
|
984
|
+
export function setPreview(result, preview) {
|
|
985
|
+
previews.set(result, preview);
|
|
986
|
+
}
|
|
987
|
+
/** The media attached to a result, if any. Read once by the dispatcher. */
|
|
988
|
+
export function takePreview(result) {
|
|
989
|
+
return result !== null && typeof result === 'object' ? previews.get(result) ?? null : null;
|
|
990
|
+
}
|
|
991
|
+
/* ── the generation card's state (MCP Apps) ──────────────────────────────── */
|
|
992
|
+
/**
|
|
993
|
+
* Turn the stored dimension into something a person reads.
|
|
994
|
+
*
|
|
995
|
+
* The column holds JSON — `{"width":1344,"height":768}` — and the card was
|
|
996
|
+
* printing it verbatim, so a finished image was labelled with a fragment of
|
|
997
|
+
* database. Anything unparseable is returned as-is rather than dropped: a
|
|
998
|
+
* value we did not expect is still better shown than hidden.
|
|
999
|
+
*/
|
|
1000
|
+
function readableDimensions(raw) {
|
|
1001
|
+
if (raw === null || raw === '')
|
|
1002
|
+
return null;
|
|
1003
|
+
try {
|
|
1004
|
+
const d = JSON.parse(raw);
|
|
1005
|
+
const w = Number(d.width);
|
|
1006
|
+
const h = Number(d.height);
|
|
1007
|
+
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
|
1008
|
+
// × (U+00D7), not the letter x — this is a dimension, not a variable.
|
|
1009
|
+
return `${Math.round(w)} × ${Math.round(h)}`;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
/* not JSON — some rows store "1920x1080" already */
|
|
1014
|
+
}
|
|
1015
|
+
return raw;
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Seconds as a person reads them: 47s, 3m 54s.
|
|
1019
|
+
*
|
|
1020
|
+
* One implementation, because the card shows a duration twice — counting up
|
|
1021
|
+
* while the job runs, and settled in the footer once it finishes — and two
|
|
1022
|
+
* spellings of the same quantity on one card ("190s elapsed" above, "234s"
|
|
1023
|
+
* below) look like two different kinds of number.
|
|
1024
|
+
*/
|
|
1025
|
+
function prettySeconds(secs) {
|
|
1026
|
+
if (secs === null || !Number.isFinite(secs))
|
|
1027
|
+
return null;
|
|
1028
|
+
const s = Math.max(0, Math.round(secs));
|
|
1029
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* One reading of a generation, shaped for the card view.
|
|
1033
|
+
*
|
|
1034
|
+
* Called by the view through the host, never by the model — the tool is
|
|
1035
|
+
* registered app-only. So it may be called every few seconds without costing
|
|
1036
|
+
* tokens or filling the transcript, and it must stay cheap: one request while
|
|
1037
|
+
* the job runs, two once it finishes.
|
|
1038
|
+
*
|
|
1039
|
+
* Status carries the progress; only a finished job needs the result, and only
|
|
1040
|
+
* then does the second call happen.
|
|
1041
|
+
*/
|
|
1042
|
+
export async function cardState(cfg, args) {
|
|
1043
|
+
const status = mapStatus(await request(cfg, {
|
|
1044
|
+
method: 'GET',
|
|
1045
|
+
path: `generate/status/${encodeURIComponent(args.id)}`,
|
|
1046
|
+
allowEnvelopeError: true,
|
|
1047
|
+
timeoutMs: 12_000,
|
|
1048
|
+
maxAttempts: 1,
|
|
1049
|
+
}));
|
|
1050
|
+
if (!status.done) {
|
|
1051
|
+
/* Only what is TRUE while a job runs: how long it has been going.
|
|
1052
|
+
*
|
|
1053
|
+
* The estimate (m_sec) is an average over past runs of this model, not
|
|
1054
|
+
* a forecast of this one, and it is wrong in both directions routinely.
|
|
1055
|
+
* Two things used to be built on it and both misled:
|
|
1056
|
+
*
|
|
1057
|
+
* "about 170s" — read as a promise. Measured 2026-09-10 on
|
|
1058
|
+
* veo-3-1-fast-i2v: estimate 170s, real 234s.
|
|
1059
|
+
* a percentage — elapsed/estimate, clamped to 95. Not "no percentage
|
|
1060
|
+
* invented" as the old comment claimed: it IS the
|
|
1061
|
+
* estimate wearing a different hat, so the same run
|
|
1062
|
+
* sat at 95% for the final minute. A bar that parks
|
|
1063
|
+
* just short of full is the classic lying bar.
|
|
1064
|
+
*
|
|
1065
|
+
* Removing the estimate and keeping the bar would have kept the lie and
|
|
1066
|
+
* dropped the disclaimer, so both go. The card shows a turning ring and
|
|
1067
|
+
* an indeterminate sweep instead — motion says "running" without
|
|
1068
|
+
* claiming to know when it ends.
|
|
1069
|
+
*
|
|
1070
|
+
* A "taking longer than usual" note was kept for a day as a THRESHOLD
|
|
1071
|
+
* use of the estimate — the one use that a passing second cannot
|
|
1072
|
+
* falsify — and then removed on measurement: it appeared on a large
|
|
1073
|
+
* fraction of perfectly successful runs of every media type.
|
|
1074
|
+
* Backfilling the estimate from real generations did not
|
|
1075
|
+
* rescue it — the rates barely moved — because the fault is
|
|
1076
|
+
* definitional, not data. m_sec is a MEAN, and roughly half of all
|
|
1077
|
+
* perfectly normal runs exceed their own mean; "unusual" would need a
|
|
1078
|
+
* high percentile, which this column is not and never was. A note
|
|
1079
|
+
* that fires on the majority of healthy runs is decoration.
|
|
1080
|
+
*
|
|
1081
|
+
* So the estimate now drives nothing on this card. Elapsed is a fact;
|
|
1082
|
+
* it is all the card claims. */
|
|
1083
|
+
const elapsed = status.elapsed_seconds;
|
|
1084
|
+
const secs = elapsed === null ? null : Math.max(0, Math.round(elapsed));
|
|
1085
|
+
const pretty = prettySeconds(secs);
|
|
1086
|
+
return {
|
|
1087
|
+
id: status.id,
|
|
1088
|
+
status: status.status,
|
|
1089
|
+
done: false,
|
|
1090
|
+
model: status.model,
|
|
1091
|
+
// Always null: see above. Kept in the payload because the card's
|
|
1092
|
+
// setWaiting still accepts a number, so a real per-stage progress
|
|
1093
|
+
// signal — if the pipeline ever reports one — needs no card change.
|
|
1094
|
+
progress: null,
|
|
1095
|
+
wait_label: pretty !== null ? `${pretty} elapsed` : 'working…',
|
|
1096
|
+
// Slower once a job is clearly long, so a four-minute video is not
|
|
1097
|
+
// eighty round trips.
|
|
1098
|
+
poll_after_ms: (elapsed ?? 0) > 60 ? 6_000 : 3_000,
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
if (status.status !== 'success') {
|
|
1102
|
+
return {
|
|
1103
|
+
id: status.id,
|
|
1104
|
+
status: status.status,
|
|
1105
|
+
done: true,
|
|
1106
|
+
model: status.model,
|
|
1107
|
+
error: status.error ?? 'The generation did not finish.',
|
|
1108
|
+
/* The server refunds a failed job on its own, idempotently; saying
|
|
1109
|
+
* so in the card is the difference between a user who is annoyed
|
|
1110
|
+
* and one who thinks they were charged for nothing.
|
|
1111
|
+
*
|
|
1112
|
+
* But NOT for deleted_media, which is not a failure: that
|
|
1113
|
+
* generation delivered, and an operator removed its files
|
|
1114
|
+
* afterwards. No coins come back, and telling someone
|
|
1115
|
+
* their money was returned when it was not is the one error here
|
|
1116
|
+
* that costs trust rather than time.
|
|
1117
|
+
*
|
|
1118
|
+
* Measured against the coin ledger on first-party rows: over a
|
|
1119
|
+
* recent window EVERY blocked, error and failed row carries a
|
|
1120
|
+
* refund — so `true` is right for those — while NO deleted_media
|
|
1121
|
+
* row ever has. (Older rows show far lower rates because refunds
|
|
1122
|
+
* were not ledger-recorded then. Those predate the ledger, not the
|
|
1123
|
+
* refund, and a card only ever shows a generation the user just
|
|
1124
|
+
* made.) */
|
|
1125
|
+
refunded: status.status !== 'deleted_media',
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
const result = mapResult(await request(cfg, {
|
|
1129
|
+
method: 'GET',
|
|
1130
|
+
path: `generate/result/${encodeURIComponent(args.id)}`,
|
|
1131
|
+
allowEnvelopeError: true,
|
|
1132
|
+
timeoutMs: 12_000,
|
|
1133
|
+
maxAttempts: 1,
|
|
1134
|
+
}));
|
|
1135
|
+
return {
|
|
1136
|
+
id: result.id,
|
|
1137
|
+
status: 'success',
|
|
1138
|
+
done: true,
|
|
1139
|
+
model: result.model,
|
|
1140
|
+
media_type: result.media_type,
|
|
1141
|
+
url: result.url,
|
|
1142
|
+
/* The generation's page on the site — where it can be renamed, shared,
|
|
1143
|
+
* published to the feed, or used as the start of another one.
|
|
1144
|
+
*
|
|
1145
|
+
* Built from the configured base rather than written down, so it points
|
|
1146
|
+
* at whichever deployment this server talks to. Reachable by the owner
|
|
1147
|
+
* even when the output is private: the view page admits a row that is
|
|
1148
|
+
* either public or the caller's own, so a signed-in owner sees their own
|
|
1149
|
+
* work and nobody else's. */
|
|
1150
|
+
vidofy_url: result.id !== null
|
|
1151
|
+
? `${new URL(cfg.baseUrl).origin}/en/view/${encodeURIComponent(result.id)}`
|
|
1152
|
+
: null,
|
|
1153
|
+
// For a video this is the poster; for an image the mapper reports the
|
|
1154
|
+
// same key as url, which the card simply does not use.
|
|
1155
|
+
poster_url: result.thumbnail_url,
|
|
1156
|
+
download_url: result.download_url,
|
|
1157
|
+
/* The watermark, and where to go to be rid of it.
|
|
1158
|
+
*
|
|
1159
|
+
* A free account's output is published and watermarked — the submit
|
|
1160
|
+
* handler forces m_public='on' below u_pro 2 precisely so a watermarked
|
|
1161
|
+
* copy exists, under the site's own B2C watermark policy. A
|
|
1162
|
+
* subscriber's stays private and clean.
|
|
1163
|
+
*
|
|
1164
|
+
* The card offers "Remove watermark" only when the file it is showing
|
|
1165
|
+
* actually carries one, so the button never appears over a clean image.
|
|
1166
|
+
* pricing_url is null in that case rather than always present: a button
|
|
1167
|
+
* cannot be rendered by mistake if there is nowhere for it to go. */
|
|
1168
|
+
watermarked: result.watermarked,
|
|
1169
|
+
pricing_url: result.watermarked
|
|
1170
|
+
? `${new URL(cfg.baseUrl).origin}/en/pricing`
|
|
1171
|
+
: null,
|
|
1172
|
+
credits: result.credits_charged,
|
|
1173
|
+
dimensions: readableDimensions(result.dimensions),
|
|
1174
|
+
/* How long it TOOK to make, not how long the file plays.
|
|
1175
|
+
*
|
|
1176
|
+
* It is result.elapsed_seconds — submit to final state. The footer
|
|
1177
|
+
* printed it bare, as "234s", immediately after "1920 × 1080": one
|
|
1178
|
+
* property of the file followed by a number that is not one. An
|
|
1179
|
+
* 8-second clip that took 234 seconds therefore advertised itself as
|
|
1180
|
+
* nearly four minutes long. Reformatting alone would have deepened
|
|
1181
|
+
* that — "3m 54s" reads even more like a running time — so the word
|
|
1182
|
+
* comes with it.
|
|
1183
|
+
*
|
|
1184
|
+
* Sent ready to print: the counter above it is worded on this side
|
|
1185
|
+
* too, and a second copy of the formatter in the card would be a
|
|
1186
|
+
* second place for the two to drift apart. */
|
|
1187
|
+
duration_label: result.elapsed_seconds === null
|
|
1188
|
+
? null
|
|
1189
|
+
: `took ${prettySeconds(result.elapsed_seconds)}`,
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
/** Exported for the gate, which asserts nothing costed ever escapes. */
|
|
1193
|
+
export { stripProviderCost };
|
|
1194
|
+
//# sourceMappingURL=generation.js.map
|