@hubfluencer/mcp 0.4.0 → 0.6.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/README.md +188 -31
- package/dist/index.js +2757 -177
- package/package.json +9 -1
- package/src/campaign.ts +797 -0
- package/src/client.ts +195 -36
- package/src/core.ts +633 -24
- package/src/index.ts +3122 -144
- package/src/output-schemas.ts +360 -0
- package/src/perf-review-assets.ts +470 -0
- package/src/series-calendar.ts +351 -0
- package/src/uploads.ts +335 -42
- package/src/version.ts +18 -0
package/src/core.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { isAbsolute, resolve, sep } from "node:path";
|
|
12
12
|
|
|
13
|
-
export type Kind = "short" | "editor";
|
|
13
|
+
export type Kind = "short" | "editor" | "tracking" | "slider";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* True if `hostname` is an IP literal in a private, loopback, link-local, or
|
|
@@ -29,32 +29,92 @@ export function isPrivateOrMetadataHost(hostname: string): boolean {
|
|
|
29
29
|
if (zone !== -1) host = host.slice(0, zone);
|
|
30
30
|
|
|
31
31
|
// IPv4 dotted-quad.
|
|
32
|
+
const v4 = parseV4Octets(host);
|
|
33
|
+
if (v4) return isPrivateV4(v4[0], v4[1]);
|
|
34
|
+
|
|
35
|
+
if (!host.includes(":")) return false;
|
|
36
|
+
|
|
37
|
+
const v6 = parseIPv6Words(host);
|
|
38
|
+
if (!v6) return false;
|
|
39
|
+
|
|
40
|
+
// IPv4 can be smuggled through several IPv6 literal forms:
|
|
41
|
+
// ::ffff:7f00:1 IPv4-mapped (hex words)
|
|
42
|
+
// ::7f00:1 deprecated IPv4-compatible
|
|
43
|
+
// 64:ff9b::a9fe:a9fe NAT64 well-known prefix
|
|
44
|
+
// Treat those by the embedded IPv4 range so private/metadata literals fail
|
|
45
|
+
// closed while public mapped addresses (e.g. ::ffff:8.8.8.8) still pass.
|
|
46
|
+
const embeddedV4 = embeddedIPv4Octets(v6);
|
|
47
|
+
if (embeddedV4 && isPrivateV4(embeddedV4[0], embeddedV4[1])) return true;
|
|
48
|
+
|
|
49
|
+
if (v6.every((word) => word === 0)) return true; // unspecified
|
|
50
|
+
if (v6.slice(0, 7).every((word) => word === 0) && v6[7] === 1) {
|
|
51
|
+
return true; // loopback
|
|
52
|
+
}
|
|
53
|
+
// fc00::/7 (unique-local) and fe80::/10 (link-local).
|
|
54
|
+
if ((v6[0] & 0xfe00) === 0xfc00) return true;
|
|
55
|
+
if ((v6[0] & 0xffc0) === 0xfe80) return true;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseV4Octets(host: string): number[] | undefined {
|
|
32
60
|
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
33
|
-
if (v4)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
if (!o.some((n) => n > 255) && isPrivateV4(o[0], o[1])) {
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
if (host === "::1" || host === "::") return true; // loopback / unspecified
|
|
50
|
-
if (host === "::ffff:0:0") return true;
|
|
51
|
-
// fc00::/7 (unique-local) and fe80::/10 (link-local).
|
|
52
|
-
if (/^f[cd]/.test(host)) return true;
|
|
53
|
-
if (/^fe[89ab]/.test(host)) return true;
|
|
54
|
-
return false;
|
|
61
|
+
if (!v4) return undefined;
|
|
62
|
+
const octets = v4.slice(1).map(Number);
|
|
63
|
+
return octets.some((n) => n > 255) ? undefined : octets;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseIPv6Words(host: string): number[] | undefined {
|
|
67
|
+
if (host.includes(".")) {
|
|
68
|
+
const lastColon = host.lastIndexOf(":");
|
|
69
|
+
if (lastColon === -1) return undefined;
|
|
70
|
+
const v4 = parseV4Octets(host.slice(lastColon + 1));
|
|
71
|
+
if (!v4) return undefined;
|
|
72
|
+
host = `${host.slice(0, lastColon)}:${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`;
|
|
55
73
|
}
|
|
56
74
|
|
|
57
|
-
|
|
75
|
+
const halves = host.split("::");
|
|
76
|
+
if (halves.length > 2) return undefined;
|
|
77
|
+
|
|
78
|
+
const head = splitIPv6Half(halves[0]);
|
|
79
|
+
const tail = halves.length === 2 ? splitIPv6Half(halves[1]) : [];
|
|
80
|
+
if (!head || !tail) return undefined;
|
|
81
|
+
|
|
82
|
+
if (halves.length === 1) return head.length === 8 ? head : undefined;
|
|
83
|
+
|
|
84
|
+
const missing = 8 - head.length - tail.length;
|
|
85
|
+
if (missing < 1) return undefined;
|
|
86
|
+
return [...head, ...Array(missing).fill(0), ...tail];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function splitIPv6Half(half: string): number[] | undefined {
|
|
90
|
+
if (half === "") return [];
|
|
91
|
+
const words = half.split(":");
|
|
92
|
+
const parsed: number[] = [];
|
|
93
|
+
for (const word of words) {
|
|
94
|
+
if (!/^[0-9a-f]{1,4}$/.test(word)) return undefined;
|
|
95
|
+
parsed.push(Number.parseInt(word, 16));
|
|
96
|
+
}
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function embeddedIPv4Octets(v6: number[]): number[] | undefined {
|
|
101
|
+
const firstFiveZero = v6.slice(0, 5).every((word) => word === 0);
|
|
102
|
+
if (firstFiveZero && v6[5] === 0xffff) return wordsToV4(v6[6], v6[7]);
|
|
103
|
+
|
|
104
|
+
const firstSixZero = v6.slice(0, 6).every((word) => word === 0);
|
|
105
|
+
if (firstSixZero && (v6[6] !== 0 || v6[7] !== 0)) {
|
|
106
|
+
return wordsToV4(v6[6], v6[7]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const isNat64WellKnown =
|
|
110
|
+
v6[0] === 0x64 &&
|
|
111
|
+
v6[1] === 0xff9b &&
|
|
112
|
+
v6.slice(2, 6).every((word) => word === 0);
|
|
113
|
+
return isNat64WellKnown ? wordsToV4(v6[6], v6[7]) : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function wordsToV4(hi: number, lo: number): number[] {
|
|
117
|
+
return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff];
|
|
58
118
|
}
|
|
59
119
|
|
|
60
120
|
// Classifies an IPv4 address by its first two octets — all the blocked ranges
|
|
@@ -119,6 +179,84 @@ export function assertSafeFetchUrl(
|
|
|
119
179
|
);
|
|
120
180
|
}
|
|
121
181
|
|
|
182
|
+
// ── Retry / bounded backoff (idempotent GETs only) ───────────────────────────
|
|
183
|
+
//
|
|
184
|
+
// One transient blip — a dropped connection, a 502 from a load balancer, a brief
|
|
185
|
+
// 429 — must not fail a whole tool call or, worse, an entire multi-minute poll
|
|
186
|
+
// loop (every poll iteration is a fresh GET). These pure helpers decide *whether*
|
|
187
|
+
// a failure is worth retrying and *how long* to wait; the actual loop lives in
|
|
188
|
+
// client.ts (request) and uploads.ts (multipart parts). They are the single
|
|
189
|
+
// source of truth for the policy, so it's unit-testable without the network.
|
|
190
|
+
//
|
|
191
|
+
// CRITICAL: retry is only ever applied to idempotent GETs (and the re-signed
|
|
192
|
+
// multipart PUT of a single part, whose semantics are also idempotent). A
|
|
193
|
+
// non-idempotent POST/PATCH/DELETE is NEVER auto-retried — a blind retry could
|
|
194
|
+
// double-charge credits or fork a duplicate resource. That gate lives at the
|
|
195
|
+
// call sites; these helpers only classify.
|
|
196
|
+
|
|
197
|
+
export interface RetryOptions {
|
|
198
|
+
/** Total attempts including the first (so 3 = 1 try + 2 retries). */
|
|
199
|
+
attempts: number;
|
|
200
|
+
/** Base backoff in ms; attempt n waits ~baseMs * 2^(n-1) before retrying. */
|
|
201
|
+
baseMs: number;
|
|
202
|
+
/** Upper bound on any single backoff wait (before jitter). */
|
|
203
|
+
maxMs: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const DEFAULT_RETRY: RetryOptions = {
|
|
207
|
+
attempts: 3,
|
|
208
|
+
baseMs: 300,
|
|
209
|
+
maxMs: 2000,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* True for an HTTP status worth retrying on an idempotent GET: any 5xx (a
|
|
214
|
+
* transient server/proxy fault) or 429 (rate limited — back off and retry).
|
|
215
|
+
* 4xx other than 429 are the caller's problem (auth, not-found, validation) and
|
|
216
|
+
* must NOT be retried. NB: the AI-assist-quota 429 is a POST-only path handled by
|
|
217
|
+
* withAssist, so retrying a 429 here (GET-only) never collides with it.
|
|
218
|
+
*/
|
|
219
|
+
export function isRetryableStatus(status: number): boolean {
|
|
220
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* True for a thrown fetch error worth retrying: a network-layer failure
|
|
225
|
+
* (connection reset/refused, DNS blip — surfaced as a TypeError) or a request
|
|
226
|
+
* timeout (AbortSignal.timeout → TimeoutError; a plain AbortError is treated the
|
|
227
|
+
* same). Also honours an explicit `retryable:true` marker, which uploads.ts's
|
|
228
|
+
* putToPresignedUrl sets when it re-wraps a transient PUT failure into a friendly
|
|
229
|
+
* message (the original name would otherwise be lost). Our own pre-flight guards
|
|
230
|
+
* (assertSafeFetchUrl throws a plain Error with no name/marker) are deliberately
|
|
231
|
+
* NOT matched, so a refused SSRF/insecure URL fails immediately.
|
|
232
|
+
*/
|
|
233
|
+
export function isRetryableNetworkError(err: unknown): boolean {
|
|
234
|
+
if (!(err instanceof Error)) return false;
|
|
235
|
+
if ((err as { retryable?: boolean }).retryable === true) return true;
|
|
236
|
+
return (
|
|
237
|
+
err.name === "TypeError" ||
|
|
238
|
+
err.name === "TimeoutError" ||
|
|
239
|
+
err.name === "AbortError"
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Backoff wait before the next attempt. Exponential (baseMs·2^(attempt-1)) capped
|
|
245
|
+
* at maxMs, then full-jittered into [half, full] so concurrent clients don't
|
|
246
|
+
* synchronize their retries. `attempt` is 1-based (the wait BEFORE retry #attempt,
|
|
247
|
+
* i.e. after the attempt-th failure). `rand` is injectable (defaults to
|
|
248
|
+
* Math.random) purely so the jitter is deterministic in tests.
|
|
249
|
+
*/
|
|
250
|
+
export function backoffDelayMs(
|
|
251
|
+
attempt: number,
|
|
252
|
+
opts: RetryOptions,
|
|
253
|
+
rand: () => number = Math.random,
|
|
254
|
+
): number {
|
|
255
|
+
const capped = Math.min(opts.maxMs, opts.baseMs * 2 ** (attempt - 1));
|
|
256
|
+
// Full jitter: sleep a random duration in [capped/2, capped].
|
|
257
|
+
return Math.round(capped / 2 + rand() * (capped / 2));
|
|
258
|
+
}
|
|
259
|
+
|
|
122
260
|
export interface NormalizedStatus {
|
|
123
261
|
kind: Kind;
|
|
124
262
|
slug: string;
|
|
@@ -129,6 +267,41 @@ export interface NormalizedStatus {
|
|
|
129
267
|
error: string | null;
|
|
130
268
|
}
|
|
131
269
|
|
|
270
|
+
/** The structured fields of an API error envelope errMessage formats from. */
|
|
271
|
+
export interface ApiErrorFields {
|
|
272
|
+
status: number;
|
|
273
|
+
code?: string;
|
|
274
|
+
message: string;
|
|
275
|
+
body?: unknown;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Formats a structured API error into a one-line agent-facing message.
|
|
280
|
+
*
|
|
281
|
+
* Lives here (not in index.ts) so the credit-field parsing — exactly the kind
|
|
282
|
+
* of string-shaped logic that silently regresses on a key rename — is unit
|
|
283
|
+
* testable: index.ts boots a live stdio MCP server at module load, so it can't
|
|
284
|
+
* be imported from a test (see this file's header). index.ts's errMessage is a
|
|
285
|
+
* thin wrapper that pulls these fields off a HubfluencerError and delegates here.
|
|
286
|
+
*
|
|
287
|
+
* For a 402 it surfaces the structured credit shortfall the server attaches so
|
|
288
|
+
* the agent can report exactly how short it is instead of a bare sentence. The
|
|
289
|
+
* canonical shape is required_credits/available_credits (tracking render +
|
|
290
|
+
* editor render); it falls back to the legacy required/available keys (older
|
|
291
|
+
* faceless 402s) so the counts always surface regardless of which the route
|
|
292
|
+
* returns.
|
|
293
|
+
*/
|
|
294
|
+
export function formatApiErrorMessage(f: ApiErrorFields): string {
|
|
295
|
+
const base = `API error (HTTP ${f.status}${f.code ? `, ${f.code}` : ""}): ${f.message}`;
|
|
296
|
+
const body = asRecord(f.body);
|
|
297
|
+
const req = body.required_credits ?? body.required;
|
|
298
|
+
const have = body.available_credits ?? body.available;
|
|
299
|
+
if (req != null || have != null) {
|
|
300
|
+
return `${base} (needs ${req ?? "?"} credits, have ${have ?? "?"}).`;
|
|
301
|
+
}
|
|
302
|
+
return base;
|
|
303
|
+
}
|
|
304
|
+
|
|
132
305
|
export function asRecord(v: unknown): Record<string, unknown> {
|
|
133
306
|
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
|
|
134
307
|
}
|
|
@@ -156,6 +329,51 @@ export function normalizeStatus(
|
|
|
156
329
|
};
|
|
157
330
|
}
|
|
158
331
|
|
|
332
|
+
if (kind === "tracking") {
|
|
333
|
+
// The /tracking payload nests the render under `result` (status flows
|
|
334
|
+
// pending → processing → completed | failed); `source` is the upload. The
|
|
335
|
+
// burned-in MP4 URL lives at result.video_url. Before any render the result
|
|
336
|
+
// is null, so the project is non-terminal (the factory status carries the
|
|
337
|
+
// draft/processing/ready stage).
|
|
338
|
+
const result = asRecord(d.result);
|
|
339
|
+
const renderStatus = (result.status as string) ?? null;
|
|
340
|
+
const trackingUrl = (result.video_url as string) ?? null;
|
|
341
|
+
const ready = renderStatus === "completed" && Boolean(trackingUrl);
|
|
342
|
+
return {
|
|
343
|
+
kind,
|
|
344
|
+
slug,
|
|
345
|
+
stage: renderStatus ?? (d.status as string) ?? "unknown",
|
|
346
|
+
terminal: ready || renderStatus === "failed",
|
|
347
|
+
ready,
|
|
348
|
+
video_url: trackingUrl,
|
|
349
|
+
// The /tracking serializer (result_payload/1) emits error_message on the
|
|
350
|
+
// result — null until a render fails, the failure reason once it does —
|
|
351
|
+
// so a failed render surfaces its diagnostic here.
|
|
352
|
+
error: (result.error_message as string) ?? null,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (kind === "slider") {
|
|
357
|
+
// The /sliders payload carries a top-level status (draft → processing →
|
|
358
|
+
// completed | failed) and per-slide image_urls (no single MP4). "completed"
|
|
359
|
+
// is written in one transaction once every slide is composited, so it always
|
|
360
|
+
// implies the slides are ready — mirroring the get_slider tool's contract.
|
|
361
|
+
// video_url stays null (a carousel is stills, not a video): wait_for_completion
|
|
362
|
+
// therefore never tries to download an MP4 for it, and callers read the slide
|
|
363
|
+
// URLs via get_slider. Kept consistent with the short/tracking shape so an
|
|
364
|
+
// agent can poll any kind through the same {stage,terminal,ready} fields.
|
|
365
|
+
const stage = (d.status as string) ?? "unknown";
|
|
366
|
+
return {
|
|
367
|
+
kind,
|
|
368
|
+
slug,
|
|
369
|
+
stage,
|
|
370
|
+
terminal: stage === "completed" || stage === "failed",
|
|
371
|
+
ready: stage === "completed",
|
|
372
|
+
video_url: null,
|
|
373
|
+
error: (d.error_message as string) ?? null,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
159
377
|
// editor
|
|
160
378
|
const autopilot = (d.autopilot_status as string) ?? "unknown";
|
|
161
379
|
const renderStatus = (latest.status as string) ?? null;
|
|
@@ -176,6 +394,46 @@ export function normalizeStatus(
|
|
|
176
394
|
};
|
|
177
395
|
}
|
|
178
396
|
|
|
397
|
+
/**
|
|
398
|
+
* A discriminator folded into make_video's start idempotency key so a re-run
|
|
399
|
+
* AFTER a terminal failure genuinely restarts instead of replaying the failed
|
|
400
|
+
* run's cached 202 for 24h (the server caches idempotent POSTs). Mirrors the
|
|
401
|
+
* tracking render key's anti-replay design (which folds the failed result id):
|
|
402
|
+
*
|
|
403
|
+
* - Not terminally failed (fresh draft, in flight, or completed) → "start", a
|
|
404
|
+
* STABLE token. A transport retry of the same start therefore reuses the key
|
|
405
|
+
* and dedupes (a double-start is also independently blocked server-side by
|
|
406
|
+
* the already-running / 409 gate), so no double charge.
|
|
407
|
+
* - Terminally failed → a signature of the per-attempt-varying render
|
|
408
|
+
* id/version (plus the failure step/stage), so a re-run reads the failed
|
|
409
|
+
* state, mints a DIFFERENT key, and re-enqueues the pipeline. Each subsequent
|
|
410
|
+
* rendered failure changes latest_render.id, so successive retries keep
|
|
411
|
+
* minting fresh keys rather than replaying a dead 202.
|
|
412
|
+
*
|
|
413
|
+
* Pure: derived solely from the status payload the caller already fetched.
|
|
414
|
+
*/
|
|
415
|
+
export function startStateDiscriminator(kind: Kind, data: unknown): string {
|
|
416
|
+
const d = asRecord(data);
|
|
417
|
+
const latest = asRecord(d.latest_render);
|
|
418
|
+
const renderSig = `${latest.id ?? ""}:${latest.version ?? ""}`;
|
|
419
|
+
|
|
420
|
+
if (kind === "short") {
|
|
421
|
+
const stage = (d.stage as string) ?? "";
|
|
422
|
+
if (stage !== "failed") return "start";
|
|
423
|
+
return `failed:${d.failed_stage ?? ""}:${renderSig}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// editor
|
|
427
|
+
const autopilot = (d.autopilot_status as string) ?? "";
|
|
428
|
+
const renderStatus = (latest.status as string) ?? "";
|
|
429
|
+
const failed =
|
|
430
|
+
autopilot === "failed" ||
|
|
431
|
+
autopilot === "cancelled" ||
|
|
432
|
+
renderStatus === "failed";
|
|
433
|
+
if (!failed) return "start";
|
|
434
|
+
return `failed:${autopilot}:${d.autopilot_error_step ?? ""}:${renderSig}`;
|
|
435
|
+
}
|
|
436
|
+
|
|
179
437
|
/** Stable idempotency key for a logical operation, so a retried call is safe. */
|
|
180
438
|
export function idemKey(...parts: string[]): string {
|
|
181
439
|
return createHash("sha256")
|
|
@@ -184,6 +442,172 @@ export function idemKey(...parts: string[]): string {
|
|
|
184
442
|
.slice(0, 32);
|
|
185
443
|
}
|
|
186
444
|
|
|
445
|
+
// ── Editor idempotency-key compositions ───────────────────────────────────────
|
|
446
|
+
//
|
|
447
|
+
// These are the exact `idemKey()` argument lists the editor create/start handlers
|
|
448
|
+
// send, extracted so the handler AND the golden-pin test share ONE definition.
|
|
449
|
+
// Previously each was hand-copied into test/handlers.test.ts, so a handler-side
|
|
450
|
+
// arg reorder/add/drop silently changed the real key while the test's private
|
|
451
|
+
// copy did not move — the tripwire could not fire. Keep these pure (no I/O) and
|
|
452
|
+
// keep the argument ORDER/SET/prefix stable: any change here shifts the hash and
|
|
453
|
+
// must be reflected in the pinned golden. The two tools deliberately use different
|
|
454
|
+
// field names (make_video: prompt/aspect · create_editor_ad: product_prompt/
|
|
455
|
+
// export_aspect_ratio) and orders, so they are separate functions on purpose.
|
|
456
|
+
|
|
457
|
+
/** make_video's editor draft-create key (`POST /editor`). */
|
|
458
|
+
export interface MakeVideoEditorCreateArgs {
|
|
459
|
+
prompt: string;
|
|
460
|
+
product_subject?: string;
|
|
461
|
+
project_intent?: string;
|
|
462
|
+
language?: string;
|
|
463
|
+
aspect?: string;
|
|
464
|
+
voice_id?: string;
|
|
465
|
+
creative_format?: string;
|
|
466
|
+
visual_language?: string;
|
|
467
|
+
theme?: string;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function makeVideoEditorCreateKey(a: MakeVideoEditorCreateArgs): string {
|
|
471
|
+
return idemKey(
|
|
472
|
+
"make-editor",
|
|
473
|
+
a.prompt,
|
|
474
|
+
a.product_subject ?? "",
|
|
475
|
+
a.project_intent ?? "",
|
|
476
|
+
a.language ?? "en",
|
|
477
|
+
a.aspect ?? "",
|
|
478
|
+
a.voice_id ?? "",
|
|
479
|
+
a.creative_format ?? "",
|
|
480
|
+
a.visual_language ?? "",
|
|
481
|
+
a.theme ?? "",
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* make_video's autopilot-start key (`POST /editor/:slug/autopilot`). Folds the
|
|
487
|
+
* create-affecting params AND an anti-replay `startState` discriminator so a
|
|
488
|
+
* re-run after a terminal failure mints a fresh key and re-enqueues instead of
|
|
489
|
+
* replaying the server's cached 202.
|
|
490
|
+
*/
|
|
491
|
+
export function makeVideoAutopilotKey(
|
|
492
|
+
slug: string,
|
|
493
|
+
a: MakeVideoEditorCreateArgs,
|
|
494
|
+
startState: string,
|
|
495
|
+
): string {
|
|
496
|
+
return idemKey(
|
|
497
|
+
"autopilot",
|
|
498
|
+
slug,
|
|
499
|
+
a.prompt,
|
|
500
|
+
a.product_subject ?? "",
|
|
501
|
+
a.project_intent ?? "",
|
|
502
|
+
a.language ?? "en",
|
|
503
|
+
a.aspect ?? "",
|
|
504
|
+
a.voice_id ?? "",
|
|
505
|
+
a.creative_format ?? "",
|
|
506
|
+
a.visual_language ?? "",
|
|
507
|
+
a.theme ?? "",
|
|
508
|
+
startState,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** create_editor_ad's draft-create and autopilot-start key argument set. */
|
|
513
|
+
export interface EditorAdKeyArgs {
|
|
514
|
+
product_prompt?: string;
|
|
515
|
+
product_subject?: string;
|
|
516
|
+
project_intent?: string;
|
|
517
|
+
language?: string;
|
|
518
|
+
creative_format?: string;
|
|
519
|
+
visual_language?: string;
|
|
520
|
+
theme?: string;
|
|
521
|
+
voice_id?: string;
|
|
522
|
+
export_aspect_ratio?: string;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** create_editor_ad's editor draft-create key (`POST /editor`). */
|
|
526
|
+
export function editorAdCreateKey(a: EditorAdKeyArgs): string {
|
|
527
|
+
return idemKey(
|
|
528
|
+
"create-editor",
|
|
529
|
+
a.product_prompt ?? "",
|
|
530
|
+
a.product_subject ?? "",
|
|
531
|
+
a.project_intent ?? "",
|
|
532
|
+
a.language ?? "en",
|
|
533
|
+
a.creative_format ?? "",
|
|
534
|
+
a.visual_language ?? "",
|
|
535
|
+
a.theme ?? "",
|
|
536
|
+
a.voice_id ?? "",
|
|
537
|
+
a.export_aspect_ratio ?? "",
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* create_editor_ad's autopilot-start key (`POST /editor/:slug/autopilot`). Folds
|
|
543
|
+
* the create params (not a startState — this tool starts right after create, so
|
|
544
|
+
* there is no prior terminal state to guard against) so it tracks the inputs, not
|
|
545
|
+
* just the slug. Self-consistent per-tool only.
|
|
546
|
+
*/
|
|
547
|
+
export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
|
|
548
|
+
return idemKey(
|
|
549
|
+
"autopilot",
|
|
550
|
+
slug,
|
|
551
|
+
a.product_prompt ?? "",
|
|
552
|
+
a.product_subject ?? "",
|
|
553
|
+
a.project_intent ?? "",
|
|
554
|
+
a.language ?? "en",
|
|
555
|
+
a.creative_format ?? "",
|
|
556
|
+
a.visual_language ?? "",
|
|
557
|
+
a.theme ?? "",
|
|
558
|
+
a.voice_id ?? "",
|
|
559
|
+
a.export_aspect_ratio ?? "",
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** create_slider's draft-create key (`POST /sliders`). */
|
|
564
|
+
export interface SliderCreateKeyArgs {
|
|
565
|
+
prompt: string;
|
|
566
|
+
mode?: string;
|
|
567
|
+
template?: string;
|
|
568
|
+
slide_count?: number;
|
|
569
|
+
aspect_ratio?: string;
|
|
570
|
+
accent_color?: string;
|
|
571
|
+
text_position?: string;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function sliderCreateKey(a: SliderCreateKeyArgs): string {
|
|
575
|
+
return idemKey(
|
|
576
|
+
"create-slider",
|
|
577
|
+
a.prompt,
|
|
578
|
+
a.mode ?? "",
|
|
579
|
+
a.template ?? "",
|
|
580
|
+
a.slide_count === undefined ? "" : String(a.slide_count),
|
|
581
|
+
a.aspect_ratio ?? "",
|
|
582
|
+
a.accent_color ?? "",
|
|
583
|
+
a.text_position ?? "",
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* set_scene_count's per-add idempotency key for `POST /editor/:slug/segments`.
|
|
589
|
+
* The server dedupes add-segment by Idempotency-Key for 24h (a replay returns the
|
|
590
|
+
* cached 201 and inserts NOTHING). Keying only on (slug, index) meant a
|
|
591
|
+
* grow→shrink→grow on the same draft inside that window reused the earlier round's
|
|
592
|
+
* keys — the second grow replayed the cached 201s and silently added no segments.
|
|
593
|
+
*
|
|
594
|
+
* A `nonce` freshly generated ONCE per set_scene_count call salts the key so every
|
|
595
|
+
* grow round mints keys the server has never seen, so each round actually adds its
|
|
596
|
+
* segments. This does NOT weaken safety: set_scene_count re-reads the live segment
|
|
597
|
+
* count each call and only issues adds for the shortfall, so a whole-tool retry is
|
|
598
|
+
* already convergent (a repeat with the same target adds nothing because
|
|
599
|
+
* target ≤ current); the per-add key only guards a single call's individual POSTs,
|
|
600
|
+
* which the client never auto-retries anyway. `index` keeps the keys within one
|
|
601
|
+
* call distinct.
|
|
602
|
+
*/
|
|
603
|
+
export function addSegmentKey(
|
|
604
|
+
slug: string,
|
|
605
|
+
index: number,
|
|
606
|
+
nonce: string,
|
|
607
|
+
): string {
|
|
608
|
+
return idemKey("add-segment", slug, String(index), nonce);
|
|
609
|
+
}
|
|
610
|
+
|
|
187
611
|
/**
|
|
188
612
|
* Confines a model-supplied save_path to an allowlisted output base so a
|
|
189
613
|
* prompt-injected path can't write outside it (e.g. ~/.ssh/authorized_keys).
|
|
@@ -242,3 +666,188 @@ export function inferKind(prompt: string): Kind {
|
|
|
242
666
|
);
|
|
243
667
|
return wantsEditor ? "editor" : "short";
|
|
244
668
|
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Client-side guards for the autopilot pre-launch inputs (language, brief).
|
|
672
|
+
* The flat-schema tools (e.g. make_video) can't carry .min/.max chains without
|
|
673
|
+
* tripping TS2589, so these run in-code and return a clean message (or null when
|
|
674
|
+
* valid) instead of relying on a raw Zod parse error. Kept in sync with the
|
|
675
|
+
* ranges the API enforces and the create_editor_draft schema (lang 2–10,
|
|
676
|
+
* product_prompt 10–5000).
|
|
677
|
+
*/
|
|
678
|
+
export function validateLanguage(language: string | undefined): string | null {
|
|
679
|
+
if (language === undefined) return null;
|
|
680
|
+
const len = language.trim().length;
|
|
681
|
+
if (len < 2 || len > 10) {
|
|
682
|
+
return 'language must be a 2–10 character code (e.g. "en", "fr").';
|
|
683
|
+
}
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export function validateProductPrompt(
|
|
688
|
+
prompt: string | undefined,
|
|
689
|
+
): string | null {
|
|
690
|
+
// A trimmed-empty string is a no-op on every path the server exposes:
|
|
691
|
+
// pre-launch override handling drops blank values (leave the stored value
|
|
692
|
+
// untouched) and the editor prompt is valid when blank, so treat "" the same
|
|
693
|
+
// as undefined. Still reject 1–9 non-blank chars (too short to be a brief).
|
|
694
|
+
if (prompt === undefined) return null;
|
|
695
|
+
const len = prompt.trim().length;
|
|
696
|
+
if (len === 0) return null;
|
|
697
|
+
if (len < 10 || len > 5000) {
|
|
698
|
+
return "product_prompt, when provided, must be 10–5000 characters.";
|
|
699
|
+
}
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Guards make_video's `kind`. It is a flat z.string() (the flat-schema TS2589
|
|
705
|
+
* constraint again), so validate it in code instead of silently inferring: a
|
|
706
|
+
* typo or an unsupported kind ("slider"/"tracking") would otherwise fall through
|
|
707
|
+
* to inferKind and spend on the wrong product. Accepts the documented values
|
|
708
|
+
* ("short", "editor", "auto") and undefined (→ infer); rejects everything else
|
|
709
|
+
* with a message that names the allowed values and the right tool to use.
|
|
710
|
+
*/
|
|
711
|
+
export function validateKind(kind: string | undefined): string | null {
|
|
712
|
+
if (
|
|
713
|
+
kind === undefined ||
|
|
714
|
+
kind === "short" ||
|
|
715
|
+
kind === "editor" ||
|
|
716
|
+
kind === "auto"
|
|
717
|
+
) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
return (
|
|
721
|
+
`kind must be one of: short, editor, auto (got "${kind}"). ` +
|
|
722
|
+
"Use create_slider for carousels or create_tracking_video for tracking overlays."
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ── create_tracking_video orchestration (pure decision logic) ────────────────
|
|
727
|
+
//
|
|
728
|
+
// The create_tracking_video handler in index.ts is one long async body that
|
|
729
|
+
// interleaves I/O (create, upload, poll, render) with non-trivial branching:
|
|
730
|
+
// resume-vs-fresh, whether the existing source is still live, whether to submit
|
|
731
|
+
// a render, and which idempotency keys to mint. importing index.ts to unit-test
|
|
732
|
+
// that branching is a non-starter (it boots a live stdio MCP server at module
|
|
733
|
+
// load — see this file's header), so the branching is extracted here as pure
|
|
734
|
+
// functions and the handler just calls them. Keeping these side-effect-free also
|
|
735
|
+
// makes the safety-critical render-key derivation (anti-replay-of-a-dead-202)
|
|
736
|
+
// directly testable.
|
|
737
|
+
|
|
738
|
+
/** The shape of GET /tracking/:slug the orchestration needs to reason about. */
|
|
739
|
+
export interface TrackingSnapshot {
|
|
740
|
+
source: { status: string } | null;
|
|
741
|
+
result: { id?: number; status: string; video_url?: string | null } | null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** The create_tracking_video inputs that steer the orchestration. */
|
|
745
|
+
export interface TrackingArgs {
|
|
746
|
+
video_path?: string;
|
|
747
|
+
slug?: string;
|
|
748
|
+
classes?: number[];
|
|
749
|
+
color?: number[];
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
export interface TrackingPlan {
|
|
753
|
+
/**
|
|
754
|
+
* Stable idempotency anchor for the create POST and the base from which the
|
|
755
|
+
* render key is derived. On a resume it's keyed on the slug; on a fresh call
|
|
756
|
+
* it folds in a per-invocation nonce so two runs over the SAME local clip path
|
|
757
|
+
* within the 24h idempotency window fork distinct projects instead of the
|
|
758
|
+
* second replaying the first's cached 201 (and appending an upload+render to
|
|
759
|
+
* the stale project). See index.ts for the full rationale.
|
|
760
|
+
*/
|
|
761
|
+
idemBase: string;
|
|
762
|
+
/**
|
|
763
|
+
* True when the project already has a source row that is NOT `failed`.
|
|
764
|
+
* EditorUpload statuses are pending|processing|ready|failed; only `failed` is
|
|
765
|
+
* unusable. pending/processing are progressing toward ready (the API now
|
|
766
|
+
* surfaces an in-flight source instead of hiding it), so a live source means
|
|
767
|
+
* "do NOT re-upload — just wait for it", never "re-upload".
|
|
768
|
+
*/
|
|
769
|
+
hasLiveSource: boolean;
|
|
770
|
+
/** True when the caller supplied classes or color (a specific overlay config). */
|
|
771
|
+
configSupplied: boolean;
|
|
772
|
+
/**
|
|
773
|
+
* True only when resuming a COMPLETED render AND new classes/color were given
|
|
774
|
+
* — the caller wants a different overlay than the finished video, so we hand
|
|
775
|
+
* the dedup decision to the backend (its combination_hash replays an identical
|
|
776
|
+
* config free or charges 1 credit for a changed one).
|
|
777
|
+
*/
|
|
778
|
+
reRenderCompletedWithNewConfig: boolean;
|
|
779
|
+
/**
|
|
780
|
+
* Whether to (re)submit POST /tracking/:slug/render: no result yet, the prior
|
|
781
|
+
* one failed (and was refunded), or a completed render is being re-run with a
|
|
782
|
+
* new config. A render already in flight (pending/processing) is left alone.
|
|
783
|
+
*/
|
|
784
|
+
shouldRender: boolean;
|
|
785
|
+
/**
|
|
786
|
+
* Per-attempt discriminator folded into the render key so it diverges exactly
|
|
787
|
+
* when a fresh enqueue is required: "retry:<id>" after a fail+refund (a NEW key
|
|
788
|
+
* so the worker re-enqueues instead of replaying the dead 202), "rerender:<id>"
|
|
789
|
+
* when re-rendering a completed result with new config, "new" otherwise.
|
|
790
|
+
*/
|
|
791
|
+
renderAttempt: string;
|
|
792
|
+
/** The idempotency key for the render POST (only meaningful when shouldRender). */
|
|
793
|
+
renderKey: string;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Computes the create_tracking_video orchestration plan from the (possibly null)
|
|
798
|
+
* current project snapshot and the call args. Pure: no I/O, no clock, no env.
|
|
799
|
+
*
|
|
800
|
+
* `freshNonce` is threaded in rather than generated here so the runtime can pass
|
|
801
|
+
* randomUUID() (each fresh call forks a new project) while tests pass a fixed
|
|
802
|
+
* sentinel (so the derived goldens are reproducible). It is ignored on a resume
|
|
803
|
+
* (the slug is the stable anchor across resumes).
|
|
804
|
+
*/
|
|
805
|
+
export function decideTrackingPlan(
|
|
806
|
+
existing: TrackingSnapshot | null,
|
|
807
|
+
args: TrackingArgs,
|
|
808
|
+
freshNonce: string,
|
|
809
|
+
): TrackingPlan {
|
|
810
|
+
const idemBase = args.slug
|
|
811
|
+
? idemKey("tracking", args.slug)
|
|
812
|
+
: idemKey("tracking", args.video_path ?? "", freshNonce);
|
|
813
|
+
|
|
814
|
+
// A source is "live" (do not re-upload) when it exists and isn't failed. The
|
|
815
|
+
// API now surfaces pending/processing sources, so an in-flight upload is
|
|
816
|
+
// observed here and routed to wait-not-reupload rather than forking a
|
|
817
|
+
// duplicate EditorUpload.
|
|
818
|
+
const hasLiveSource =
|
|
819
|
+
existing?.source != null && existing.source.status !== "failed";
|
|
820
|
+
|
|
821
|
+
const resultStatus = existing?.result?.status;
|
|
822
|
+
const configSupplied = args.classes !== undefined || args.color !== undefined;
|
|
823
|
+
const reRenderCompletedWithNewConfig =
|
|
824
|
+
resultStatus === "completed" && configSupplied;
|
|
825
|
+
const shouldRender =
|
|
826
|
+
!existing?.result ||
|
|
827
|
+
resultStatus === "failed" ||
|
|
828
|
+
reRenderCompletedWithNewConfig;
|
|
829
|
+
|
|
830
|
+
const renderAttempt =
|
|
831
|
+
resultStatus === "failed"
|
|
832
|
+
? `retry:${existing?.result?.id ?? "unknown"}`
|
|
833
|
+
: reRenderCompletedWithNewConfig
|
|
834
|
+
? `rerender:${existing?.result?.id ?? "unknown"}`
|
|
835
|
+
: "new";
|
|
836
|
+
const renderKey = idemKey(
|
|
837
|
+
idemBase,
|
|
838
|
+
"render",
|
|
839
|
+
renderAttempt,
|
|
840
|
+
JSON.stringify(args.classes ?? null),
|
|
841
|
+
JSON.stringify(args.color ?? null),
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
return {
|
|
845
|
+
idemBase,
|
|
846
|
+
hasLiveSource,
|
|
847
|
+
configSupplied,
|
|
848
|
+
reRenderCompletedWithNewConfig,
|
|
849
|
+
shouldRender,
|
|
850
|
+
renderAttempt,
|
|
851
|
+
renderKey,
|
|
852
|
+
};
|
|
853
|
+
}
|