@blockrun/franklin 3.8.35 → 3.8.36

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.
@@ -9,13 +9,89 @@ import { loadChain, API_URLS, VERSION } from '../config.js';
9
9
  import { checkImageBudget, recordImageAsset } from '../content/record-image.js';
10
10
  import { ModelClient } from '../agent/llm.js';
11
11
  import { analyzeMediaRequest, renderProposalForAskUser } from '../agent/media-router.js';
12
+ import { recordUsage } from '../stats/tracker.js';
13
+ import { findModel, estimateCostUsd } from '../gateway-models.js';
14
+ /**
15
+ * Models that accept a reference image via /v1/images/image2image. Currently
16
+ * limited to OpenAI's edit endpoint — Gemini Nano Banana Pro and Grok Imagine
17
+ * Image Pro need gateway-side support before they can be wired in here.
18
+ */
19
+ export const EDIT_SUPPORTED_MODELS = new Set([
20
+ 'openai/gpt-image-1',
21
+ 'openai/gpt-image-2',
22
+ ]);
23
+ export const REFERENCE_IMAGE_MAX_BYTES = 4_000_000;
24
+ /**
25
+ * Normalize a reference image into a base64 data URI for the gateway. The
26
+ * /v1/images/image2image endpoint validates `image` against /^data:image\//,
27
+ * so http(s) URLs and local paths both have to be inlined client-side before
28
+ * posting. Already-formed data URIs pass through.
29
+ */
30
+ export async function resolveReferenceImage(input, workingDir) {
31
+ if (input.startsWith('data:image/'))
32
+ return input;
33
+ if (/^https?:\/\//i.test(input)) {
34
+ const ctrl = new AbortController();
35
+ const timeout = setTimeout(() => ctrl.abort(), 30_000);
36
+ try {
37
+ const resp = await fetch(input, { signal: ctrl.signal });
38
+ if (!resp.ok) {
39
+ throw new Error(`Reference image fetch failed: ${resp.status} ${resp.statusText}`);
40
+ }
41
+ const contentType = (resp.headers.get('content-type') || '').toLowerCase().split(';')[0].trim();
42
+ if (!contentType.startsWith('image/')) {
43
+ throw new Error(`Reference image URL returned non-image content-type: ${contentType || '(none)'}`);
44
+ }
45
+ const buf = Buffer.from(await resp.arrayBuffer());
46
+ if (buf.byteLength > REFERENCE_IMAGE_MAX_BYTES) {
47
+ throw new Error(`Reference image too large: ${(buf.byteLength / 1_000_000).toFixed(1)}MB > ${(REFERENCE_IMAGE_MAX_BYTES / 1_000_000).toFixed(1)}MB cap.`);
48
+ }
49
+ return `data:${contentType};base64,${buf.toString('base64')}`;
50
+ }
51
+ finally {
52
+ clearTimeout(timeout);
53
+ }
54
+ }
55
+ // Treat as local file path.
56
+ const resolved = path.isAbsolute(input) ? input : path.resolve(workingDir, input);
57
+ const stat = fs.statSync(resolved);
58
+ if (stat.size > REFERENCE_IMAGE_MAX_BYTES) {
59
+ throw new Error(`Reference image too large: ${(stat.size / 1_000_000).toFixed(1)}MB > ${(REFERENCE_IMAGE_MAX_BYTES / 1_000_000).toFixed(1)}MB cap. Resize or crop first.`);
60
+ }
61
+ const ext = path.extname(resolved).toLowerCase();
62
+ const mimeMap = {
63
+ '.png': 'image/png',
64
+ '.jpg': 'image/jpeg',
65
+ '.jpeg': 'image/jpeg',
66
+ '.gif': 'image/gif',
67
+ '.webp': 'image/webp',
68
+ };
69
+ const mime = mimeMap[ext];
70
+ if (!mime) {
71
+ throw new Error(`Unsupported reference image extension ${ext || '(none)'}. Use .png/.jpg/.jpeg/.gif/.webp.`);
72
+ }
73
+ const bytes = fs.readFileSync(resolved);
74
+ return `data:${mime};base64,${bytes.toString('base64')}`;
75
+ }
12
76
  function buildExecute(deps) {
13
77
  return async function execute(input, ctx) {
14
78
  const rawInput = input;
15
- const { output_path, size, model, contentId } = rawInput;
79
+ const { output_path, size, model, contentId, image_url } = rawInput;
16
80
  if (!rawInput.prompt) {
17
81
  return { output: 'Error: prompt is required', isError: true };
18
82
  }
83
+ // Resolve the reference image (if any) before any paid call so we fail
84
+ // cheaply on bad paths / oversize attachments. Holds the resolved data URI
85
+ // / http URL that gets posted to /v1/images/image2image.
86
+ let referenceImage;
87
+ if (image_url) {
88
+ try {
89
+ referenceImage = await resolveReferenceImage(image_url, ctx.workingDir);
90
+ }
91
+ catch (err) {
92
+ return { output: `Error: ${err.message}`, isError: true };
93
+ }
94
+ }
19
95
  // One-shot refinement opt-out: leading `///` tells Franklin "don't
20
96
  // refine this prompt, I wrote it the way I want it." Strip the prefix
21
97
  // and pass skipRefine through to the router.
@@ -31,11 +107,26 @@ function buildExecute(deps) {
31
107
  // step and use the old default. Otherwise: classifier picks a fitting
32
108
  // model + rewrites the prompt, the preview goes to AskUser, user
33
109
  // chooses or cancels.
34
- let imageModel = model || 'openai/gpt-image-1';
110
+ // Reference-image mode forces an edit-capable model. If the caller named
111
+ // an unsupported one, fail loudly so we don't silently downgrade their
112
+ // request to text-only generation.
113
+ if (referenceImage && model && !EDIT_SUPPORTED_MODELS.has(model)) {
114
+ return {
115
+ output: `Error: model ${model} does not support reference images. ` +
116
+ `Use one of: ${[...EDIT_SUPPORTED_MODELS].join(', ')}.`,
117
+ isError: true,
118
+ };
119
+ }
120
+ let imageModel = model || (referenceImage ? 'openai/gpt-image-2' : 'openai/gpt-image-1');
35
121
  const imageSize = size || '1024x1024';
36
122
  let chosenPrompt = prompt;
123
+ // Skip the proposal flow when a reference image is set: the media router
124
+ // doesn't know which models support image-to-image, so its suggestions
125
+ // would frequently be unusable (text-only models). Default to gpt-image-1
126
+ // for now; a future router upgrade can pick between the four edit-capable
127
+ // models based on the prompt.
37
128
  const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1';
38
- if (!model && !autoApprove && ctx.onAskUser) {
129
+ if (!model && !autoApprove && ctx.onAskUser && !referenceImage) {
39
130
  try {
40
131
  const chain = loadChain();
41
132
  const client = new ModelClient({ apiUrl: API_URLS[chain], chain });
@@ -95,18 +186,30 @@ function buildExecute(deps) {
95
186
  }
96
187
  const chain = loadChain();
97
188
  const apiUrl = API_URLS[chain];
98
- const endpoint = `${apiUrl}/v1/images/generations`;
189
+ // Reference-image mode hits the dedicated /v1/images/image2image endpoint;
190
+ // otherwise stay on text-to-image generations.
191
+ const endpoint = referenceImage
192
+ ? `${apiUrl}/v1/images/image2image`
193
+ : `${apiUrl}/v1/images/generations`;
99
194
  // Default output path
100
195
  const outPath = output_path
101
196
  ? (path.isAbsolute(output_path) ? output_path : path.resolve(ctx.workingDir, output_path))
102
197
  : path.resolve(ctx.workingDir, `generated-${Date.now()}.png`);
103
- const body = JSON.stringify({
104
- model: imageModel,
105
- prompt: chosenPrompt,
106
- n: 1,
107
- size: imageSize,
108
- response_format: 'b64_json',
109
- });
198
+ const body = JSON.stringify(referenceImage
199
+ ? {
200
+ model: imageModel,
201
+ prompt: chosenPrompt,
202
+ image: referenceImage,
203
+ size: imageSize,
204
+ n: 1,
205
+ }
206
+ : {
207
+ model: imageModel,
208
+ prompt: chosenPrompt,
209
+ n: 1,
210
+ size: imageSize,
211
+ response_format: 'b64_json',
212
+ });
110
213
  const headers = {
111
214
  'Content-Type': 'application/json',
112
215
  'User-Agent': `franklin/${VERSION}`,
@@ -125,7 +228,7 @@ function buildExecute(deps) {
125
228
  if (response.status === 402) {
126
229
  const paymentHeaders = await signPayment(response, chain, endpoint);
127
230
  if (!paymentHeaders) {
128
- return { output: 'Payment failed. Check wallet balance with: runcode balance', isError: true };
231
+ return { output: 'Payment failed. Check wallet balance with: franklin balance', isError: true };
129
232
  }
130
233
  response = await fetch(endpoint, {
131
234
  method: 'POST',
@@ -143,12 +246,23 @@ function buildExecute(deps) {
143
246
  if (!imageData) {
144
247
  return { output: 'No image data returned from API', isError: true };
145
248
  }
146
- // Save image
249
+ // Save image. The /v1/images/image2image endpoint returns Gemini results
250
+ // as a data URI in `url`, so decode those locally instead of going through
251
+ // fetch — saves a network round-trip and avoids data:-URI fetch quirks.
147
252
  if (imageData.b64_json) {
148
253
  const buffer = Buffer.from(imageData.b64_json, 'base64');
149
254
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
150
255
  fs.writeFileSync(outPath, buffer);
151
256
  }
257
+ else if (imageData.url && imageData.url.startsWith('data:')) {
258
+ const match = imageData.url.match(/^data:[^;]+;base64,(.+)$/);
259
+ if (!match) {
260
+ return { output: 'Malformed data URI in response', isError: true };
261
+ }
262
+ const buffer = Buffer.from(match[1], 'base64');
263
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
264
+ fs.writeFileSync(outPath, buffer);
265
+ }
152
266
  else if (imageData.url) {
153
267
  // Download from URL (with 30s timeout)
154
268
  const dlCtrl = new AbortController();
@@ -165,6 +279,20 @@ function buildExecute(deps) {
165
279
  const fileSize = fs.statSync(outPath).size;
166
280
  const sizeKB = (fileSize / 1024).toFixed(1);
167
281
  const revisedPrompt = imageData.revised_prompt ? `\nRevised prompt: ${imageData.revised_prompt}` : '';
282
+ // Stats: record this generation so it shows up in `franklin insights`
283
+ // alongside chat spend. Before this, media generations bypassed
284
+ // recordUsage entirely (only LLM chat calls were tracked), so the
285
+ // insights panel under-reported total spend and never surfaced
286
+ // image-generation models in its "top models" list. Fire-and-forget —
287
+ // stats write must not fail a user-visible generation.
288
+ void (async () => {
289
+ try {
290
+ const m = await findModel(imageModel);
291
+ const estCost = m ? estimateCostUsd(m, { quantity: 1 }) : 0;
292
+ recordUsage(imageModel, 0, 0, estCost, 0);
293
+ }
294
+ catch { /* ignore stats errors */ }
295
+ })();
168
296
  let contentSummary = '';
169
297
  if (contentId && deps.library) {
170
298
  const rec = recordImageAsset(deps.library, {
@@ -225,7 +353,7 @@ async function signPayment(response, chain, endpoint) {
225
353
  const feePayer = details.extra?.feePayer || details.recipient;
226
354
  const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
227
355
  resourceUrl: details.resource?.url || endpoint,
228
- resourceDescription: details.resource?.description || 'RunCode image generation',
356
+ resourceDescription: details.resource?.description || 'Franklin image generation',
229
357
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
230
358
  extra: details.extra,
231
359
  });
@@ -237,7 +365,7 @@ async function signPayment(response, chain, endpoint) {
237
365
  const details = extractPaymentDetails(paymentRequired);
238
366
  const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
239
367
  resourceUrl: details.resource?.url || endpoint,
240
- resourceDescription: details.resource?.description || 'RunCode image generation',
368
+ resourceDescription: details.resource?.description || 'Franklin image generation',
241
369
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
242
370
  extra: details.extra,
243
371
  });
@@ -272,13 +400,16 @@ export function createImageGenCapability(deps = {}) {
272
400
  return {
273
401
  spec: {
274
402
  name: 'ImageGen',
275
- description: "Generate an image from a text prompt. Costs USDC from the user's wallet " +
276
- " confirm before generating. Saves to a local file. Default size: " +
277
- "1024x1024. Do NOT call repeatedly to iterate on style ask the user " +
278
- "first. Pass contentId to attach the result to an existing Content " +
279
- "piece: the content's budget is checked BEFORE paying, and on success " +
280
- "the image is recorded as an asset with its estimated cost. Skipping " +
281
- "contentId generates a one-off image with no budget tracking.",
403
+ description: "Generate an image from a text prompt optionally with a reference " +
404
+ "image for style transfer / character consistency / edits. Costs USDC " +
405
+ "from the user's wallet confirm before generating. Saves to a local " +
406
+ "file. Default size: 1024x1024. Do NOT call repeatedly to iterate on " +
407
+ "style — ask the user first. Pass contentId to attach the result to " +
408
+ "an existing Content piece: the content's budget is checked BEFORE " +
409
+ "paying, and on success the image is recorded as an asset with its " +
410
+ "estimated cost. Skipping contentId generates a one-off image with no " +
411
+ "budget tracking. When image_url is set, only edit-capable models " +
412
+ "(openai/gpt-image-1, openai/gpt-image-2) are accepted.",
282
413
  input_schema: {
283
414
  type: 'object',
284
415
  properties: {
@@ -286,6 +417,7 @@ export function createImageGenCapability(deps = {}) {
286
417
  output_path: { type: 'string', description: 'Where to save the image. Default: generated-<timestamp>.png in working directory' },
287
418
  size: { type: 'string', description: 'Image size: 1024x1024, 1792x1024, or 1024x1792. Default: 1024x1024' },
288
419
  model: { type: 'string', description: 'Image model to use. Default: openai/gpt-image-1' },
420
+ image_url: { type: 'string', description: 'Optional reference image (image-to-image / style transfer). Accepts an http(s) URL, a data URI, or a local file path. Only works with edit-capable models.' },
289
421
  contentId: { type: 'string', description: 'Optional Content id to attach this generation to. Pre-flight budget check + auto-record on success.' },
290
422
  },
291
423
  required: ['prompt'],
@@ -84,7 +84,34 @@ async function execute(input, ctx) {
84
84
  // (some binaries have no extension: `.env.enc`, `.data`, compiled tools
85
85
  // without suffixes, etc. Content sniff catches those.)
86
86
  const ext = path.extname(resolved).toLowerCase();
87
- const binaryExts = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.pdf', '.zip', '.tar', '.gz', '.woff', '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.wav', '.avi', '.mov', '.exe', '.dll', '.so', '.dylib']);
87
+ // Image extensions load as vision content so models with vision (Sonnet,
88
+ // GPT-4o, Gemini) actually see the bytes instead of a "Binary file" stub.
89
+ // The agent loop wraps `images` into tool_result.content for provider APIs.
90
+ const IMAGE_MEDIA_TYPES = {
91
+ '.png': 'image/png',
92
+ '.jpg': 'image/jpeg',
93
+ '.jpeg': 'image/jpeg',
94
+ '.gif': 'image/gif',
95
+ '.webp': 'image/webp',
96
+ };
97
+ if (IMAGE_MEDIA_TYPES[ext]) {
98
+ const sizeStr = stat.size >= 1024 ? `${(stat.size / 1024).toFixed(1)}KB` : `${stat.size}B`;
99
+ // Anthropic accepts up to 5MB base64; cap raw bytes at ~3.75MB to be safe.
100
+ const IMAGE_MAX_BYTES = 3_750_000;
101
+ if (stat.size > IMAGE_MAX_BYTES) {
102
+ return {
103
+ output: `Image file: ${resolved} (${ext}, ${sizeStr}). Too large to inline for vision (>${Math.round(IMAGE_MAX_BYTES / 1_000_000)}MB). Resize or crop first.`,
104
+ };
105
+ }
106
+ const bytes = fs.readFileSync(resolved);
107
+ const base64 = bytes.toString('base64');
108
+ fileReadTracker.set(resolved, { mtimeMs: stat.mtimeMs, readAt: Date.now() });
109
+ return {
110
+ output: `Image file: ${resolved} (${ext}, ${sizeStr}). Rendered below for vision-capable models.`,
111
+ images: [{ mediaType: IMAGE_MEDIA_TYPES[ext], base64 }],
112
+ };
113
+ }
114
+ const binaryExts = new Set(['.ico', '.bmp', '.pdf', '.zip', '.tar', '.gz', '.woff', '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.wav', '.avi', '.mov', '.exe', '.dll', '.so', '.dylib']);
88
115
  if (binaryExts.has(ext)) {
89
116
  const sizeStr = stat.size >= 1024 ? `${(stat.size / 1024).toFixed(1)}KB` : `${stat.size}B`;
90
117
  return { output: `Binary file: ${resolved} (${ext}, ${sizeStr}). Cannot display contents.` };
@@ -163,7 +190,7 @@ Usage:
163
190
  - This tool can only read files, not directories. To list a directory, use Glob or ls via Bash.
164
191
  - If you read a file that exists but has empty contents you will receive a warning.
165
192
  - Reads over 2MB are rejected — use offset/limit to read portions.
166
- - Cannot read binary files (images, PDFs, archives).
193
+ - Image files (.png, .jpg, .jpeg, .gif, .webp) are loaded as vision content — vision-capable models see the actual image. Other binary files (PDFs, archives, fonts) cannot be displayed.
167
194
  - You will regularly be asked to read screenshots or images. If the user provides a path, ALWAYS use this tool to view it.
168
195
 
169
196
  IMPORTANT: Always use Read instead of cat, head, or tail via Bash. This tool provides line numbers and integrates with Edit's read-before-edit enforcement.`,
@@ -3,9 +3,20 @@
3
3
  * /v1/videos/generations endpoint. Uses x402 payment (Base or Solana).
4
4
  *
5
5
  * Default model `xai/grok-imagine-video` returns an 8-second clip for ~$0.42.
6
- * The endpoint is synchronous-over-polling: the HTTP connection stays open
7
- * until the upstream xAI job finishes (typically 20–60s, timeout 180s), so
8
- * the caller only needs to issue a single POST.
6
+ * Seedance 2.0 (bytedance/seedance-2.0 and -fast) runs longer up to a few
7
+ * minutes for a 10s clip.
8
+ *
9
+ * Flow (async since blockrun@654cd35):
10
+ * 1. POST /v1/videos/generations with signed x-payment header. The server
11
+ * verifies payment (does NOT settle), submits the upstream job, and
12
+ * returns 202 { id, poll_url, status: "queued" }.
13
+ * 2. GET the poll_url with the SAME x-payment header every ~5s until
14
+ * status=completed. On the first completed poll the server backs up
15
+ * the MP4 to GCS, settles payment, and returns the video URL.
16
+ * 3. Download the MP4 and write it locally.
17
+ *
18
+ * If the upstream job fails, the server returns status=failed and no USDC
19
+ * is ever transferred. If the client never polls, no charge either.
9
20
  */
10
21
  import type { CapabilityHandler } from '../agent/types.js';
11
22
  import type { ContentLibrary } from '../content/library.js';
@@ -3,9 +3,20 @@
3
3
  * /v1/videos/generations endpoint. Uses x402 payment (Base or Solana).
4
4
  *
5
5
  * Default model `xai/grok-imagine-video` returns an 8-second clip for ~$0.42.
6
- * The endpoint is synchronous-over-polling: the HTTP connection stays open
7
- * until the upstream xAI job finishes (typically 20–60s, timeout 180s), so
8
- * the caller only needs to issue a single POST.
6
+ * Seedance 2.0 (bytedance/seedance-2.0 and -fast) runs longer up to a few
7
+ * minutes for a 10s clip.
8
+ *
9
+ * Flow (async since blockrun@654cd35):
10
+ * 1. POST /v1/videos/generations with signed x-payment header. The server
11
+ * verifies payment (does NOT settle), submits the upstream job, and
12
+ * returns 202 { id, poll_url, status: "queued" }.
13
+ * 2. GET the poll_url with the SAME x-payment header every ~5s until
14
+ * status=completed. On the first completed poll the server backs up
15
+ * the MP4 to GCS, settles payment, and returns the video URL.
16
+ * 3. Download the MP4 and write it locally.
17
+ *
18
+ * If the upstream job fails, the server returns status=failed and no USDC
19
+ * is ever transferred. If the client never polls, no charge either.
9
20
  */
10
21
  import fs from 'node:fs';
11
22
  import path from 'node:path';
@@ -13,12 +24,17 @@ import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, creat
13
24
  import { loadChain, API_URLS, VERSION } from '../config.js';
14
25
  import { ModelClient } from '../agent/llm.js';
15
26
  import { analyzeMediaRequest, renderProposalForAskUser } from '../agent/media-router.js';
27
+ import { recordUsage } from '../stats/tracker.js';
28
+ import { findModel, estimateCostUsd } from '../gateway-models.js';
16
29
  const DEFAULT_MODEL = 'xai/grok-imagine-video';
17
30
  const DEFAULT_DURATION = 8;
18
31
  const PRICE_PER_SECOND_USD = 0.05;
19
- // Long ceiling the endpoint synchronously waits for xAI's async job (up to
20
- // ~180s). Give ourselves a bit of headroom for the GCS backup + settle step.
21
- const GEN_TIMEOUT_MS = 210_000;
32
+ // POST submit is fast (~3-20s). Generation is async upstream (60-300s for
33
+ // Seedance, 20-90s for Grok). We poll until completed, then download. The
34
+ // server signs authorizations for 600s — keep the overall budget below that.
35
+ const SUBMIT_TIMEOUT_MS = 30_000;
36
+ const POLL_INTERVAL_MS = 5_000;
37
+ const POLL_MAX_WAIT_MS = 480_000; // 8 min — covers Seedance worst case
22
38
  const DOWNLOAD_TIMEOUT_MS = 60_000;
23
39
  function estimateVideoCostUsd(durationSeconds = DEFAULT_DURATION) {
24
40
  return Math.max(1, durationSeconds) * PRICE_PER_SECOND_USD;
@@ -121,26 +137,32 @@ function buildExecute(deps) {
121
137
  'Content-Type': 'application/json',
122
138
  'User-Agent': `franklin/${VERSION}`,
123
139
  };
124
- const controller = new AbortController();
125
- const timeout = setTimeout(() => controller.abort(), GEN_TIMEOUT_MS);
126
- // Abort on user cancel too
127
- const onAbort = () => controller.abort();
128
- ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
140
+ const onAbort = (ctrl) => () => ctrl.abort();
141
+ // Phase 1: submit the job. First POST triggers a 402; we sign and retry.
142
+ // The signed paymentHeaders must be reused on every GET poll — the server
143
+ // uses the authorization to verify identity on each poll and settles on
144
+ // the first completed response.
145
+ const submitCtrl = new AbortController();
146
+ const submitTimeout = setTimeout(() => submitCtrl.abort(), SUBMIT_TIMEOUT_MS);
147
+ const submitAbort = onAbort(submitCtrl);
148
+ ctx.abortSignal.addEventListener('abort', submitAbort, { once: true });
149
+ let paymentHeaders = null;
150
+ let submitResult;
129
151
  try {
130
152
  let response = await fetch(endpoint, {
131
153
  method: 'POST',
132
- signal: controller.signal,
154
+ signal: submitCtrl.signal,
133
155
  headers,
134
156
  body,
135
157
  });
136
158
  if (response.status === 402) {
137
- const paymentHeaders = await signPayment(response, chain, endpoint);
159
+ paymentHeaders = await signPayment(response, chain, endpoint);
138
160
  if (!paymentHeaders) {
139
161
  return { output: 'Payment failed. Check wallet balance with: franklin balance', isError: true };
140
162
  }
141
163
  response = await fetch(endpoint, {
142
164
  method: 'POST',
143
- signal: controller.signal,
165
+ signal: submitCtrl.signal,
144
166
  headers: { ...headers, ...paymentHeaders },
145
167
  body,
146
168
  });
@@ -148,22 +170,71 @@ function buildExecute(deps) {
148
170
  if (!response.ok) {
149
171
  const errText = await response.text().catch(() => '');
150
172
  return {
151
- output: `Video generation failed (${response.status}): ${errText.slice(0, 300)}`,
173
+ output: `Video submit failed (${response.status}): ${errText.slice(0, 300)}`,
152
174
  isError: true,
153
175
  };
154
176
  }
155
- const result = (await response.json());
156
- const videoData = result.data?.[0];
157
- if (!videoData?.url) {
158
- return { output: 'No video URL returned from API', isError: true };
177
+ submitResult = await response.json();
178
+ }
179
+ catch (err) {
180
+ const msg = err.message || '';
181
+ if (msg.includes('abort')) {
182
+ return {
183
+ output: `Video submit timed out or was aborted after ${Math.round(SUBMIT_TIMEOUT_MS / 1000)}s.`,
184
+ isError: true,
185
+ };
159
186
  }
187
+ return { output: `Error submitting video job: ${msg}`, isError: true };
188
+ }
189
+ finally {
190
+ clearTimeout(submitTimeout);
191
+ ctx.abortSignal.removeEventListener('abort', submitAbort);
192
+ }
193
+ if (!submitResult.poll_url || !paymentHeaders) {
194
+ return { output: 'API did not return a poll_url for the video job', isError: true };
195
+ }
196
+ // Phase 2: poll GET /v1/videos/generations/{id} with the SAME signed
197
+ // x-payment header until the job completes. Server settles on the first
198
+ // completed poll and returns the backed-up video URL.
199
+ const origin = new URL(apiUrl).origin;
200
+ const pollEndpoint = submitResult.poll_url.startsWith('http')
201
+ ? submitResult.poll_url
202
+ : `${origin}${submitResult.poll_url}`;
203
+ const outcome = await pollUntilReady(pollEndpoint, { ...headers, ...paymentHeaders }, ctx.abortSignal);
204
+ if (outcome.kind === 'timed_out') {
205
+ return {
206
+ output: `Video generation did not complete within ${Math.round(POLL_MAX_WAIT_MS / 1000)}s. ` +
207
+ `No USDC was charged (settlement only fires on completion).`,
208
+ isError: true,
209
+ };
210
+ }
211
+ if (outcome.kind === 'failed') {
212
+ return {
213
+ output: `Video generation failed upstream: ${outcome.error ?? 'unknown error'}. No USDC was charged.`,
214
+ isError: true,
215
+ };
216
+ }
217
+ const videoData = outcome.data;
218
+ const videoUrl = videoData.url;
219
+ if (!videoUrl) {
220
+ return { output: 'No video URL returned from API', isError: true };
221
+ }
222
+ try {
160
223
  // Download the MP4
161
224
  const dlCtrl = new AbortController();
162
225
  const dlTimeout = setTimeout(() => dlCtrl.abort(), DOWNLOAD_TIMEOUT_MS);
163
- const vidResp = await fetch(videoData.url, { signal: dlCtrl.signal });
164
- clearTimeout(dlTimeout);
226
+ const dlAbort = onAbort(dlCtrl);
227
+ ctx.abortSignal.addEventListener('abort', dlAbort, { once: true });
228
+ let vidResp;
229
+ try {
230
+ vidResp = await fetch(videoUrl, { signal: dlCtrl.signal });
231
+ }
232
+ finally {
233
+ clearTimeout(dlTimeout);
234
+ ctx.abortSignal.removeEventListener('abort', dlAbort);
235
+ }
165
236
  if (!vidResp.ok) {
166
- return { output: `Video fetched URL but download failed (${vidResp.status}): ${videoData.url}`, isError: true };
237
+ return { output: `Video fetched URL but download failed (${vidResp.status}): ${videoUrl}`, isError: true };
167
238
  }
168
239
  const buffer = Buffer.from(await vidResp.arrayBuffer());
169
240
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
@@ -171,6 +242,21 @@ function buildExecute(deps) {
171
242
  const fileSize = fs.statSync(outPath).size;
172
243
  const sizeMB = (fileSize / 1_048_576).toFixed(1);
173
244
  const dur = videoData.duration_seconds ?? duration;
245
+ // Stats: record this generation so it shows up in `franklin insights`
246
+ // alongside chat spend. Before this, media generations bypassed
247
+ // recordUsage entirely, so the insights panel under-reported total
248
+ // spend and never surfaced video models in its "top models" list.
249
+ // Prefer the live gateway price when the model is in the catalog;
250
+ // fall back to the legacy $0.05/s estimate otherwise. Fire-and-
251
+ // forget — stats write must not fail a user-visible generation.
252
+ void (async () => {
253
+ try {
254
+ const m = await findModel(videoModel);
255
+ const estCost = m ? estimateCostUsd(m, { duration_seconds: dur }) : estimateVideoCostUsd(dur);
256
+ recordUsage(videoModel, 0, 0, estCost, 0);
257
+ }
258
+ catch { /* ignore stats errors */ }
259
+ })();
174
260
  let contentSummary = '';
175
261
  if (contentId && deps.library) {
176
262
  const rec = deps.library.addAsset(contentId, {
@@ -208,18 +294,63 @@ function buildExecute(deps) {
208
294
  const msg = err.message || '';
209
295
  if (msg.includes('abort')) {
210
296
  return {
211
- output: `Video generation timed out or was aborted (limit ${Math.round(GEN_TIMEOUT_MS / 1000)}s).`,
297
+ output: `Video download timed out or was aborted after ${Math.round(DOWNLOAD_TIMEOUT_MS / 1000)}s.`,
212
298
  isError: true,
213
299
  };
214
300
  }
215
301
  return { output: `Error: ${msg}`, isError: true };
216
302
  }
217
- finally {
218
- clearTimeout(timeout);
219
- ctx.abortSignal.removeEventListener('abort', onAbort);
220
- }
221
303
  };
222
304
  }
305
+ /**
306
+ * Poll the GET /v1/videos/generations/{id} endpoint until the job reaches a
307
+ * terminal state. Reuses the caller's signed x-payment header verbatim on
308
+ * every request — the server verifies the same authorization each poll and
309
+ * settles on the first completed response.
310
+ */
311
+ async function pollUntilReady(pollEndpoint, headers, userAbort) {
312
+ const deadline = Date.now() + POLL_MAX_WAIT_MS;
313
+ while (Date.now() < deadline) {
314
+ if (userAbort.aborted)
315
+ throw new Error('aborted');
316
+ const resp = await fetch(pollEndpoint, { method: 'GET', headers, signal: userAbort });
317
+ // 202 = still queued/in_progress; 200 = completed or failed.
318
+ if (resp.status === 202 || resp.status === 200) {
319
+ const body = (await resp.json().catch(() => ({})));
320
+ if (body.status === 'completed' && body.data?.[0]?.url) {
321
+ return { kind: 'completed', data: body.data[0] };
322
+ }
323
+ if (body.status === 'failed') {
324
+ return { kind: 'failed', error: body.error };
325
+ }
326
+ // queued / in_progress — sleep and try again.
327
+ }
328
+ else if (resp.status === 429 || resp.status >= 500) {
329
+ // Transient — back off briefly. Fall through to the sleep below.
330
+ }
331
+ else {
332
+ const text = await resp.text().catch(() => '');
333
+ throw new Error(`Poll failed (${resp.status}): ${text.slice(0, 300)}`);
334
+ }
335
+ await sleep(POLL_INTERVAL_MS, userAbort);
336
+ }
337
+ return { kind: 'timed_out' };
338
+ }
339
+ function sleep(ms, signal) {
340
+ return new Promise((resolve, reject) => {
341
+ if (signal.aborted)
342
+ return reject(new Error('aborted'));
343
+ const t = setTimeout(() => {
344
+ signal.removeEventListener('abort', onAbort);
345
+ resolve();
346
+ }, ms);
347
+ const onAbort = () => {
348
+ clearTimeout(t);
349
+ reject(new Error('aborted'));
350
+ };
351
+ signal.addEventListener('abort', onAbort, { once: true });
352
+ });
353
+ }
223
354
  // ─── Payment ───────────────────────────────────────────────────────────────
224
355
  async function signPayment(response, chain, endpoint) {
225
356
  try {
@@ -235,7 +366,9 @@ async function signPayment(response, chain, endpoint) {
235
366
  const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
236
367
  resourceUrl: details.resource?.url || endpoint,
237
368
  resourceDescription: details.resource?.description || 'Franklin video generation',
238
- maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
369
+ // Video poll can take up to 8 min; honor the server's advertised
370
+ // value (blockrun sends 600s) and fall back to 600 not 300.
371
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 600,
239
372
  extra: details.extra,
240
373
  });
241
374
  return { 'PAYMENT-SIGNATURE': payload };
@@ -15,6 +15,7 @@
15
15
  * session.
16
16
  */
17
17
  import { isIP } from 'node:net';
18
+ import { VERSION } from '../config.js';
18
19
  const DEFAULT_TIMEOUT_MS = 15_000;
19
20
  const MAX_BODY_BYTES = 512 * 1024; // 512 KB is generous for a chat push.
20
21
  function isPrivateHost(hostname) {
@@ -101,7 +102,7 @@ async function execute(input, ctx) {
101
102
  }
102
103
  const finalHeaders = {
103
104
  'Content-Type': contentType,
104
- 'User-Agent': 'franklin/3.8.9 (webhook)',
105
+ 'User-Agent': `franklin/${VERSION} (webhook)`,
105
106
  ...(headers ?? {}),
106
107
  };
107
108
  const ctrl = new AbortController();
@@ -6,8 +6,9 @@
6
6
  * cooldown, user-agent, timeout, and in-memory cache.
7
7
  */
8
8
  import { recordFetch } from '../telemetry.js';
9
+ import { VERSION } from '../../../config.js';
9
10
  const BASE = 'https://api.coingecko.com/api/v3';
10
- const UA = 'franklin/3.8.9 (trading)';
11
+ const UA = `franklin/${VERSION} (trading)`;
11
12
  const TIMEOUT_MS = 10_000;
12
13
  // Ticker → CoinGecko slug. Not exhaustive; unknown tickers fall through to
13
14
  // lowercase and let CoinGecko either accept the slug or 404.