@empir3/empir3-bridge 0.3.21
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/CHANGELOG.md +1531 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +75 -0
- package/LICENSE +21 -0
- package/README.md +464 -0
- package/SECURITY.md +130 -0
- package/assets/accuracy-lab.html +2639 -0
- package/assets/api-clis-real.jpg +0 -0
- package/assets/bridge-console-hero.jpg +0 -0
- package/assets/browser-privacy.svg +151 -0
- package/assets/demo-orchestration.svg +74 -0
- package/assets/desktop-select-region.jpg +0 -0
- package/assets/in-page-chat.gif +0 -0
- package/assets/orchestration-hero.svg +126 -0
- package/assets/social-preview.png +0 -0
- package/assets/zara-accent.png +0 -0
- package/build/bootstrap.js +548 -0
- package/build/build.js +680 -0
- package/build/payload-entry.js +649 -0
- package/build/payload-signing-pub.json +7 -0
- package/docs/AGENT_GUIDE.md +259 -0
- package/docs/RELEASE.md +106 -0
- package/docs/SAFETY.md +112 -0
- package/docs/TESTING.md +181 -0
- package/installer/server.js +231 -0
- package/installer/ui/app.js +278 -0
- package/installer/ui/index.html +24 -0
- package/installer/ui/styles.css +146 -0
- package/package.json +95 -0
- package/scripts/bootstrap-e2e.mjs +650 -0
- package/scripts/certify-bridge.mjs +636 -0
- package/scripts/check-companion-surface.mjs +118 -0
- package/scripts/extract-welcome.mjs +64 -0
- package/scripts/gh-route-handler-check.mjs +57 -0
- package/scripts/gh-wire-test.mjs +107 -0
- package/scripts/publish-downloads.mjs +180 -0
- package/scripts/smoke-all-tools.mjs +509 -0
- package/scripts/smoke-live-bridge.mjs +696 -0
- package/scripts/splice-welcome.mjs +63 -0
- package/scripts/welcome-body.txt +2733 -0
- package/src/anthropic-client.ts +192 -0
- package/src/bootstrap-exe.ts +69 -0
- package/src/bridge.ts +2444 -0
- package/src/chat.ts +345 -0
- package/src/cli-runner.ts +239 -0
- package/src/cli.ts +649 -0
- package/src/config.ts +199 -0
- package/src/desktop-overlay.ps1 +121 -0
- package/src/executable-resolver.ts +330 -0
- package/src/handlers/agy-imagegen.ts +179 -0
- package/src/handlers/github-cli.ts +399 -0
- package/src/handlers/higgsfield-cli.ts +783 -0
- package/src/launch.js +337 -0
- package/src/mcp-server.ts +1265 -0
- package/src/pair-claim.ts +218 -0
- package/src/payload-daemon.ts +168 -0
- package/src/server.ts +21036 -0
- package/src/tool-defaults.ts +230 -0
- package/src/update-check.js +136 -0
- package/tray/build.py +76 -0
- package/tray/requirements.txt +2 -0
- package/tray/tray.py +1843 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Higgsfield CLI handler.
|
|
3
|
+
*
|
|
4
|
+
* Same mental model as cli-runner.ts: the user already pays / authenticates
|
|
5
|
+
* with Higgsfield; we shell out to their local `higgsfield` binary instead
|
|
6
|
+
* of being a Higgsfield API client. The bridge is a permission gate + tool
|
|
7
|
+
* surface, not an upstream integration.
|
|
8
|
+
*
|
|
9
|
+
* Spec lives at docs/handoff_bridge_higgsfield_cli.md. Verified facts encoded
|
|
10
|
+
* inline so a future maintainer doesn't re-probe the CLI.
|
|
11
|
+
*
|
|
12
|
+
* Pre-decided defaults (from the handoff doc — see header comment per item):
|
|
13
|
+
* #1 Result URL extraction — generic path-priority parser, no per-model
|
|
14
|
+
* upfront probing. Logs the full parsed JSON every call so shape
|
|
15
|
+
* surprises become a polish item.
|
|
16
|
+
* #2 Per-model param naming — handler is pass-through via `extra: {...}`.
|
|
17
|
+
* MCP-calling agent owns CLI flag naming, no translation here.
|
|
18
|
+
* #3 Concurrency — single-job FIFO queue at the handler level. Avoids
|
|
19
|
+
* CLI credential lock contention without per-model awareness.
|
|
20
|
+
* #4 Cost cap — none for v1. Per-tool permission (defaults OFF for
|
|
21
|
+
* _generate) + global execute gate carry the safety load.
|
|
22
|
+
* #5 Token re-auth — regex-scan stderr for known auth-error patterns,
|
|
23
|
+
* surface as { stage: 'auth_expired', recoverable: true } so agents
|
|
24
|
+
* can prompt the user. No probe-by-invalidation.
|
|
25
|
+
* #6 Tray menu wording — see tray.py change; matches existing voice.
|
|
26
|
+
* #7 bridge-settings.json schema — generic `handlers: { [name]: { enabled } }`
|
|
27
|
+
* so future handlers (Replicate, Runway, Suno) drop in without
|
|
28
|
+
* migration. Gate logic mirrored in server.ts dispatcher.
|
|
29
|
+
* #8 Output contract — return BOTH parsed result and fetched bytes.
|
|
30
|
+
* Handler does spawn -> parse JSON -> extract URL -> HTTP GET ->
|
|
31
|
+
* save to ~/.empir3-bridge/artifacts/higgsfield/<stamp>-<uuid>.<ext>.
|
|
32
|
+
* #9 _list subcommand shape — probed once via `higgsfield generate --help`
|
|
33
|
+
* on first run; result cached in-process.
|
|
34
|
+
* #10 waitTimeoutMs ceiling — hard cap 20 min. Bridge ceiling, not CLI.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { spawn } from 'child_process';
|
|
38
|
+
import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync } from 'fs';
|
|
39
|
+
import { join, extname } from 'path';
|
|
40
|
+
import { homedir } from 'os';
|
|
41
|
+
import { randomUUID } from 'crypto';
|
|
42
|
+
|
|
43
|
+
const STATUS_TIMEOUT_MS = 10 * 1000;
|
|
44
|
+
const LIST_TIMEOUT_MS = 30 * 1000;
|
|
45
|
+
const HELP_TIMEOUT_MS = 5 * 1000;
|
|
46
|
+
const GENERATE_TIMEOUT_MS = 25 * 60 * 1000; // matches --wait-timeout 20m + grace
|
|
47
|
+
const SIGTERM_GRACE_MS = 5000;
|
|
48
|
+
const MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
49
|
+
const ARTIFACT_FETCH_TIMEOUT_MS = 60 * 1000;
|
|
50
|
+
const MAX_PROMPT_LOG_CHARS = 80;
|
|
51
|
+
|
|
52
|
+
const ARTIFACT_DIR = join(homedir(), '.empir3-bridge', 'artifacts', 'higgsfield');
|
|
53
|
+
|
|
54
|
+
// Patterns scanned in stderr to translate raw CLI errors into structured
|
|
55
|
+
// recoverable stages. See default #5.
|
|
56
|
+
const AUTH_ERROR_PATTERNS: RegExp[] = [
|
|
57
|
+
/not authenticated/i,
|
|
58
|
+
/auth(?:enticate)?.{0,30}expired/i,
|
|
59
|
+
/token.{0,20}expired/i,
|
|
60
|
+
/token.{0,20}invalid/i,
|
|
61
|
+
/please run.*auth.*(?:login|signin|sign in)/i,
|
|
62
|
+
/unauthorized/i,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const RATE_LIMIT_PATTERNS: RegExp[] = [
|
|
66
|
+
/rate.?limit/i,
|
|
67
|
+
/too many requests/i,
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const QUOTA_PATTERNS: RegExp[] = [
|
|
71
|
+
/quota/i,
|
|
72
|
+
/insufficient (?:credits|balance|funds)/i,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// ── Binary discovery ────────────────────────────────────────────
|
|
76
|
+
//
|
|
77
|
+
// Verified install paths: `higgsfield` on PATH (POSIX + Windows when
|
|
78
|
+
// %APPDATA%\npm is on PATH), or %APPDATA%\npm\higgsfield.cmd (the npm-global
|
|
79
|
+
// shim) on Windows. Don't hunt anywhere else — if neither resolves, return
|
|
80
|
+
// null and let _status report installed:false cleanly.
|
|
81
|
+
function findHiggsfieldBinary(): string | null {
|
|
82
|
+
const onPath = whichSync('higgsfield');
|
|
83
|
+
if (onPath) return onPath;
|
|
84
|
+
if (process.platform === 'win32') {
|
|
85
|
+
const appdata = process.env.APPDATA || '';
|
|
86
|
+
if (appdata) {
|
|
87
|
+
const cmdShim = join(appdata, 'npm', 'higgsfield.cmd');
|
|
88
|
+
if (existsSync(cmdShim)) return cmdShim;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function whichSync(name: string): string | null {
|
|
95
|
+
try {
|
|
96
|
+
const cmd = process.platform === 'win32' ? `where ${name}` : `which ${name}`;
|
|
97
|
+
const { execSync } = require('child_process') as typeof import('child_process');
|
|
98
|
+
const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
|
|
99
|
+
const lines = out.split(/\r?\n/).map((l: string) => l.trim()).filter(Boolean);
|
|
100
|
+
if (!lines.length) return null;
|
|
101
|
+
if (process.platform === 'win32') {
|
|
102
|
+
const cmdShim = lines.find((l: string) => l.toLowerCase().endsWith('.cmd'));
|
|
103
|
+
if (cmdShim) return cmdShim;
|
|
104
|
+
}
|
|
105
|
+
return lines[0];
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Spawn helper ────────────────────────────────────────────────
|
|
112
|
+
//
|
|
113
|
+
// argv-only (no shell). Mirrors cli-runner.ts: SIGTERM, then SIGKILL after
|
|
114
|
+
// SIGTERM_GRACE_MS. Captures stdout/stderr as utf-8 strings with a hard
|
|
115
|
+
// MAX_BUFFER_BYTES ceiling so a runaway CLI doesn't OOM the bridge.
|
|
116
|
+
interface SpawnResult {
|
|
117
|
+
exitCode: number;
|
|
118
|
+
stdout: string;
|
|
119
|
+
stderr: string;
|
|
120
|
+
elapsedMs: number;
|
|
121
|
+
timedOut: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function spawnCapture(bin: string, argv: string[], timeoutMs: number): Promise<SpawnResult> {
|
|
125
|
+
return new Promise(resolve => {
|
|
126
|
+
const start = Date.now();
|
|
127
|
+
let child;
|
|
128
|
+
try {
|
|
129
|
+
const isWinShim = process.platform === 'win32' && /\.(cmd|bat)$/i.test(bin);
|
|
130
|
+
if (isWinShim) {
|
|
131
|
+
// Node 18.20+/20.12+ refuse to spawn .cmd directly on Windows
|
|
132
|
+
// (CVE-2024-27980). Spawn cmd.exe with the shim as an arg instead.
|
|
133
|
+
child = spawn('cmd.exe', ['/d', '/s', '/c', bin, ...argv], { windowsHide: true });
|
|
134
|
+
} else {
|
|
135
|
+
child = spawn(bin, argv, { windowsHide: true });
|
|
136
|
+
}
|
|
137
|
+
} catch (e: any) {
|
|
138
|
+
resolve({ exitCode: -1, stdout: '', stderr: `spawn failed: ${e?.message || String(e)}`, elapsedMs: Date.now() - start, timedOut: false });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let stdout = '';
|
|
143
|
+
let stderr = '';
|
|
144
|
+
let timedOut = false;
|
|
145
|
+
let killed = false;
|
|
146
|
+
let stdoutBytes = 0;
|
|
147
|
+
let stderrBytes = 0;
|
|
148
|
+
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
timedOut = true;
|
|
151
|
+
killed = true;
|
|
152
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
153
|
+
setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, SIGTERM_GRACE_MS);
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
|
|
156
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
157
|
+
stdoutBytes += chunk.length;
|
|
158
|
+
if (stdoutBytes <= MAX_BUFFER_BYTES) stdout += chunk.toString('utf-8');
|
|
159
|
+
});
|
|
160
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
161
|
+
stderrBytes += chunk.length;
|
|
162
|
+
if (stderrBytes <= MAX_BUFFER_BYTES) stderr += chunk.toString('utf-8');
|
|
163
|
+
});
|
|
164
|
+
child.on('error', (err: any) => {
|
|
165
|
+
stderr += `\n[spawn error] ${err?.message || String(err)}`;
|
|
166
|
+
});
|
|
167
|
+
child.on('close', (code: number | null) => {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
resolve({
|
|
170
|
+
exitCode: killed ? -1 : (code ?? -1),
|
|
171
|
+
stdout,
|
|
172
|
+
stderr,
|
|
173
|
+
elapsedMs: Date.now() - start,
|
|
174
|
+
timedOut,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Error classification ────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
function parseHiggsfieldError(r: SpawnResult): { error: string; stage: string; recoverable: boolean } {
|
|
183
|
+
const text = `${r.stderr}\n${r.stdout}`;
|
|
184
|
+
if (r.timedOut) return { error: `higgsfield timed out after ${r.elapsedMs}ms`, stage: 'timeout', recoverable: true };
|
|
185
|
+
for (const p of AUTH_ERROR_PATTERNS) {
|
|
186
|
+
if (p.test(text)) return { error: 'higgsfield CLI is not authenticated — run `higgsfield auth login`', stage: 'auth_expired', recoverable: true };
|
|
187
|
+
}
|
|
188
|
+
for (const p of RATE_LIMIT_PATTERNS) {
|
|
189
|
+
if (p.test(text)) return { error: 'higgsfield CLI rate-limited', stage: 'rate_limit', recoverable: true };
|
|
190
|
+
}
|
|
191
|
+
for (const p of QUOTA_PATTERNS) {
|
|
192
|
+
if (p.test(text)) return { error: 'higgsfield CLI quota exhausted', stage: 'quota', recoverable: false };
|
|
193
|
+
}
|
|
194
|
+
const trimmed = (r.stderr.trim() || r.stdout.trim() || `higgsfield exited ${r.exitCode}`).slice(0, 2000);
|
|
195
|
+
return { error: trimmed, stage: 'cli_error', recoverable: false };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function lastNonEmptyLine(s: string): string {
|
|
199
|
+
const lines = s.split(/\r?\n/).filter(l => l.trim().length > 0);
|
|
200
|
+
return lines.length ? lines[lines.length - 1] : '';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function safeJsonParse(s: string): any {
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(s);
|
|
206
|
+
} catch {
|
|
207
|
+
// CLIs sometimes emit a progress line before the final JSON; fall back
|
|
208
|
+
// to parsing only the last non-empty line.
|
|
209
|
+
try { return JSON.parse(lastNonEmptyLine(s)); } catch { return null; }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Default #1 — generic URL extractor, returns first matching path.
|
|
214
|
+
// The Higgsfield CLI as of v0.1.40 actually returns an array of job
|
|
215
|
+
// objects with a top-level `result_url` field (not the nested shapes
|
|
216
|
+
// the original priority list assumed). Try the real-world shapes
|
|
217
|
+
// first, keep the speculative ones as fallbacks.
|
|
218
|
+
function extractArtifactUrl(parsed: any): string | null {
|
|
219
|
+
if (!parsed) return null;
|
|
220
|
+
// Array-of-jobs shape (verified against `higgsfield generate create
|
|
221
|
+
// z_image --json` on 2026-05-28).
|
|
222
|
+
const firstJob = Array.isArray(parsed) ? parsed[0] : null;
|
|
223
|
+
const candidates: any[] = [
|
|
224
|
+
firstJob?.result_url,
|
|
225
|
+
firstJob?.results?.[0]?.url,
|
|
226
|
+
firstJob?.results?.raw?.url,
|
|
227
|
+
parsed?.result_url,
|
|
228
|
+
parsed?.result?.url,
|
|
229
|
+
parsed?.result?.video?.url,
|
|
230
|
+
parsed?.result?.image?.url,
|
|
231
|
+
parsed?.results?.[0]?.url,
|
|
232
|
+
parsed?.results?.[0]?.result_url,
|
|
233
|
+
parsed?.jobs?.[0]?.result_url,
|
|
234
|
+
parsed?.jobs?.[0]?.results?.raw?.url,
|
|
235
|
+
parsed?.url,
|
|
236
|
+
parsed?.video?.url,
|
|
237
|
+
parsed?.image?.url,
|
|
238
|
+
];
|
|
239
|
+
for (const c of candidates) {
|
|
240
|
+
if (typeof c === 'string' && /^https?:\/\//i.test(c)) return c;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function extFromUrl(url: string, contentType: string | null): string {
|
|
246
|
+
if (contentType) {
|
|
247
|
+
const ct = contentType.split(';')[0].trim().toLowerCase();
|
|
248
|
+
if (ct === 'video/mp4') return '.mp4';
|
|
249
|
+
if (ct === 'video/webm') return '.webm';
|
|
250
|
+
if (ct === 'image/jpeg') return '.jpg';
|
|
251
|
+
if (ct === 'image/png') return '.png';
|
|
252
|
+
if (ct === 'image/webp') return '.webp';
|
|
253
|
+
if (ct === 'image/gif') return '.gif';
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const u = new URL(url);
|
|
257
|
+
const ext = extname(u.pathname);
|
|
258
|
+
if (ext && ext.length <= 6) return ext;
|
|
259
|
+
} catch {}
|
|
260
|
+
return '.bin';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function fetchToArtifact(url: string): Promise<{ path: string; bytes: number } | null> {
|
|
264
|
+
try {
|
|
265
|
+
const controller = new AbortController();
|
|
266
|
+
const timer = setTimeout(() => controller.abort(), ARTIFACT_FETCH_TIMEOUT_MS);
|
|
267
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
if (!res.ok) return null;
|
|
270
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
271
|
+
const ext = extFromUrl(url, res.headers.get('content-type'));
|
|
272
|
+
mkdirSync(ARTIFACT_DIR, { recursive: true });
|
|
273
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
274
|
+
const path = join(ARTIFACT_DIR, `${stamp}-${randomUUID()}${ext}`);
|
|
275
|
+
writeFileSync(path, buf);
|
|
276
|
+
return { path, bytes: buf.length };
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Default #3 — single-job FIFO queue.
|
|
283
|
+
let generateQueue: Promise<any> = Promise.resolve();
|
|
284
|
+
function enqueueGenerate<T>(fn: () => Promise<T>): Promise<T> {
|
|
285
|
+
const next = generateQueue.then(fn, fn);
|
|
286
|
+
generateQueue = next.then(() => undefined, () => undefined);
|
|
287
|
+
return next;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Default #9 — probe `generate --help` once, cache for the process lifetime.
|
|
291
|
+
let listProbeCache: { subcommand: string[] | null; probedAt: number } | null = null;
|
|
292
|
+
|
|
293
|
+
async function resolveListInvocation(bin: string): Promise<string[] | null> {
|
|
294
|
+
if (listProbeCache) return listProbeCache.subcommand;
|
|
295
|
+
const help = await spawnCapture(bin, ['generate', '--help'], HELP_TIMEOUT_MS);
|
|
296
|
+
let subcommand: string[] | null = null;
|
|
297
|
+
if (help.exitCode === 0) {
|
|
298
|
+
const text = `${help.stdout}\n${help.stderr}`.toLowerCase();
|
|
299
|
+
if (/\blist\b/.test(text)) subcommand = ['generate', 'list'];
|
|
300
|
+
}
|
|
301
|
+
listProbeCache = { subcommand, probedAt: Date.now() };
|
|
302
|
+
return subcommand;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Public handlers ─────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
export interface HiggsfieldStatusResult {
|
|
308
|
+
installed: boolean;
|
|
309
|
+
version?: string | null;
|
|
310
|
+
authenticated?: boolean;
|
|
311
|
+
credentialsPath?: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function higgsfieldStatus(_params: Record<string, unknown> = {}): Promise<{ success: true; result: HiggsfieldStatusResult }> {
|
|
315
|
+
const bin = findHiggsfieldBinary();
|
|
316
|
+
if (!bin) {
|
|
317
|
+
return { success: true, result: { installed: false, authenticated: false } };
|
|
318
|
+
}
|
|
319
|
+
const version = await spawnCapture(bin, ['--version'], STATUS_TIMEOUT_MS);
|
|
320
|
+
const m = /^higgsfield\s+(\S+)/i.exec((version.stdout || '').trim());
|
|
321
|
+
const tok = await spawnCapture(bin, ['auth', 'token'], STATUS_TIMEOUT_MS);
|
|
322
|
+
// Auth iff exit 0 AND stdout matches /^hf_\S+\s*$/. Never log or surface
|
|
323
|
+
// the token value itself.
|
|
324
|
+
const authed = tok.exitCode === 0 && /^hf_\S+\s*$/.test(tok.stdout.trim());
|
|
325
|
+
return {
|
|
326
|
+
success: true,
|
|
327
|
+
result: {
|
|
328
|
+
installed: true,
|
|
329
|
+
version: m?.[1] || null,
|
|
330
|
+
authenticated: authed,
|
|
331
|
+
// Surfaced for user reference only — handler never opens it.
|
|
332
|
+
credentialsPath: join(homedir(), '.config', 'higgsfield', 'credentials.json'),
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface HiggsfieldGenerateParams {
|
|
338
|
+
model: string;
|
|
339
|
+
prompt: string;
|
|
340
|
+
image?: string | Buffer | { data?: string; path?: string };
|
|
341
|
+
extra?: Record<string, string | number | boolean>;
|
|
342
|
+
waitTimeoutMs?: number;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function higgsfieldGenerate(params: HiggsfieldGenerateParams): Promise<any> {
|
|
346
|
+
if (!params || typeof params.model !== 'string' || !params.model.trim()) {
|
|
347
|
+
return { success: false, error: 'higgsfield_generate: `model` is required' };
|
|
348
|
+
}
|
|
349
|
+
if (typeof params.prompt !== 'string' || !params.prompt.trim()) {
|
|
350
|
+
return { success: false, error: 'higgsfield_generate: `prompt` is required' };
|
|
351
|
+
}
|
|
352
|
+
const bin = findHiggsfieldBinary();
|
|
353
|
+
if (!bin) return { success: false, error: 'higgsfield CLI not installed' };
|
|
354
|
+
|
|
355
|
+
// Default #10 — hard-cap 20 min on the wait window.
|
|
356
|
+
const timeoutMin = Math.min(
|
|
357
|
+
Math.max(Math.floor((params.waitTimeoutMs || 20 * 60_000) / 60_000), 1),
|
|
358
|
+
20,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
return enqueueGenerate(async () => {
|
|
362
|
+
mkdirSync(ARTIFACT_DIR, { recursive: true });
|
|
363
|
+
|
|
364
|
+
let imagePath: string | null = null;
|
|
365
|
+
let tempImageToCleanup: string | null = null;
|
|
366
|
+
try {
|
|
367
|
+
if (params.image !== undefined && params.image !== null) {
|
|
368
|
+
const resolved = resolveImageInput(params.image);
|
|
369
|
+
if (resolved.tempPath) tempImageToCleanup = resolved.tempPath;
|
|
370
|
+
imagePath = resolved.path;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const extraArgs: string[] = [];
|
|
374
|
+
if (params.extra && typeof params.extra === 'object') {
|
|
375
|
+
for (const [k, v] of Object.entries(params.extra)) {
|
|
376
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(k)) continue; // ignore obviously-bogus keys
|
|
377
|
+
extraArgs.push(`--${k}`, String(v));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const argv = [
|
|
382
|
+
'generate', 'create', params.model,
|
|
383
|
+
'--prompt', params.prompt,
|
|
384
|
+
...(imagePath ? ['--image', imagePath] : []),
|
|
385
|
+
'--wait',
|
|
386
|
+
'--wait-timeout', `${timeoutMin}m`,
|
|
387
|
+
'--wait-interval', '5s',
|
|
388
|
+
'--json',
|
|
389
|
+
...extraArgs,
|
|
390
|
+
];
|
|
391
|
+
|
|
392
|
+
const r = await spawnCapture(bin, argv, GENERATE_TIMEOUT_MS);
|
|
393
|
+
if (r.exitCode !== 0) {
|
|
394
|
+
const parsed = parseHiggsfieldError(r);
|
|
395
|
+
return {
|
|
396
|
+
success: false,
|
|
397
|
+
error: parsed.error,
|
|
398
|
+
stage: parsed.stage,
|
|
399
|
+
recoverable: parsed.recoverable,
|
|
400
|
+
durationMs: r.elapsedMs,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const parsed = safeJsonParse(r.stdout);
|
|
405
|
+
const url = extractArtifactUrl(parsed);
|
|
406
|
+
let artifactPath: string | null = null;
|
|
407
|
+
let artifactBytes: number | null = null;
|
|
408
|
+
if (url) {
|
|
409
|
+
const saved = await fetchToArtifact(url);
|
|
410
|
+
if (saved) {
|
|
411
|
+
artifactPath = saved.path;
|
|
412
|
+
artifactBytes = saved.bytes;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Default #1 — log full parsed JSON shape so the user can see new
|
|
417
|
+
// result shapes and ask for a priority bump later. Truncate the
|
|
418
|
+
// prompt in input echoes to MAX_PROMPT_LOG_CHARS (acceptance #5).
|
|
419
|
+
const promptEcho = params.prompt.length > MAX_PROMPT_LOG_CHARS
|
|
420
|
+
? params.prompt.slice(0, MAX_PROMPT_LOG_CHARS) + '…'
|
|
421
|
+
: params.prompt;
|
|
422
|
+
try {
|
|
423
|
+
console.error('[higgsfield] generate ok', JSON.stringify({
|
|
424
|
+
model: params.model,
|
|
425
|
+
promptPreview: promptEcho,
|
|
426
|
+
durationMs: r.elapsedMs,
|
|
427
|
+
urlFound: !!url,
|
|
428
|
+
resultKeys: parsed && typeof parsed === 'object' ? Object.keys(parsed) : null,
|
|
429
|
+
}));
|
|
430
|
+
} catch {}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
success: true,
|
|
434
|
+
result: {
|
|
435
|
+
raw: parsed,
|
|
436
|
+
url,
|
|
437
|
+
artifactPath,
|
|
438
|
+
artifactBytes,
|
|
439
|
+
durationMs: r.elapsedMs,
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
} finally {
|
|
443
|
+
if (tempImageToCleanup) {
|
|
444
|
+
try { unlinkSync(tempImageToCleanup); } catch {}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── empir3-channel lending bridge ───────────────────────────────
|
|
451
|
+
//
|
|
452
|
+
// The Empir3 server lends the user's local higgsfield CLI for image/video
|
|
453
|
+
// gen over the empir3 WebSocket (HiggsfieldClient.runCli on the server side).
|
|
454
|
+
// Wire contract (server ⇄ bridge), mirrors claude:cli / codex:cli:
|
|
455
|
+
//
|
|
456
|
+
// server → bridge: higgsfield:cli:gen {id, kind, prompt, model, params, input_image?, timeout_sec}
|
|
457
|
+
// bridge → server: higgsfield:cli:progress {id, status} (optional)
|
|
458
|
+
// higgsfield:cli:done {id, exit_code, mime_type, bytes_base64, duration_sec}
|
|
459
|
+
// higgsfield:cli:error {id, stage, error}
|
|
460
|
+
// server → bridge: higgsfield:cli:abort {id} (cancel — no mid-flight hook, acked)
|
|
461
|
+
//
|
|
462
|
+
// This is the ONLY thing that was missing for CLI-mode videogen: the local
|
|
463
|
+
// executeCommand path (higgsfield_generate) was wired, but the empir3 channel
|
|
464
|
+
// never routed higgsfield:cli:* — so server requests dead-ended and timed out
|
|
465
|
+
// as "upstream returned no video". generate_image worked only because its
|
|
466
|
+
// route resolves to a direct API provider (Imagen), never touching the bridge.
|
|
467
|
+
//
|
|
468
|
+
// Param fidelity: server sends model-native snake_case params (video:
|
|
469
|
+
// { aspect_ratio, duration }). We forward them verbatim as --flag value (no
|
|
470
|
+
// translation — per default #2 the caller owns flag naming). Note veo3_1
|
|
471
|
+
// constrains duration to 4/6/8; the caller must send a valid value.
|
|
472
|
+
function mimeForArtifact(path: string, kind?: string): string {
|
|
473
|
+
switch (extname(path).toLowerCase()) {
|
|
474
|
+
case '.mp4': return 'video/mp4';
|
|
475
|
+
case '.webm': return 'video/webm';
|
|
476
|
+
case '.mov': return 'video/quicktime';
|
|
477
|
+
case '.png': return 'image/png';
|
|
478
|
+
case '.jpg':
|
|
479
|
+
case '.jpeg': return 'image/jpeg';
|
|
480
|
+
case '.webp': return 'image/webp';
|
|
481
|
+
case '.gif': return 'image/gif';
|
|
482
|
+
default: return kind === 'video' ? 'video/mp4' : 'image/png';
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Per-model param schema cache (`higgsfield model get <model> --json` →
|
|
487
|
+
// params[]). Separate from modelsCache (that one is `model list`, no params).
|
|
488
|
+
const MODEL_PARAMS_TTL_MS = 5 * 60 * 1000;
|
|
489
|
+
const modelParamsCache = new Map<string, { at: number; params: any[] | null }>();
|
|
490
|
+
|
|
491
|
+
async function getModelParams(model: string): Promise<any[] | null> {
|
|
492
|
+
const cached = modelParamsCache.get(model);
|
|
493
|
+
if (cached && (Date.now() - cached.at) < MODEL_PARAMS_TTL_MS) return cached.params;
|
|
494
|
+
const bin = findHiggsfieldBinary();
|
|
495
|
+
if (!bin) return null;
|
|
496
|
+
try {
|
|
497
|
+
const r = await spawnCapture(bin, ['model', 'get', model, '--json'], LIST_TIMEOUT_MS);
|
|
498
|
+
const parsed = r.exitCode === 0 ? safeJsonParse(r.stdout) : null;
|
|
499
|
+
const params = parsed && Array.isArray(parsed.params) ? parsed.params : null;
|
|
500
|
+
modelParamsCache.set(model, { at: Date.now(), params });
|
|
501
|
+
return params;
|
|
502
|
+
} catch {
|
|
503
|
+
modelParamsCache.set(model, { at: Date.now(), params: null });
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Snap caller params to what the model actually accepts, using the live schema.
|
|
509
|
+
// The server clamps videogen duration to 1-15 and sends it raw, but each model
|
|
510
|
+
// constrains it differently (veo3_1 duration ∈ {4,6,8}, aspect_ratio ∈
|
|
511
|
+
// {16:9,9:16}; kling3_0 duration is a free integer). Without this, a 5s veo3_1
|
|
512
|
+
// request — or a 1:1 aspect — CLI-errors. Rules: drop params the model doesn't
|
|
513
|
+
// list (unknown --flags error the CLI); snap enum values (nearest for numeric
|
|
514
|
+
// enums, model default otherwise); round integer params. Best-effort — if the
|
|
515
|
+
// schema can't be read we pass params through so generation is never blocked.
|
|
516
|
+
async function normalizeParamsForModel(
|
|
517
|
+
model: string,
|
|
518
|
+
params: Record<string, unknown>,
|
|
519
|
+
): Promise<Record<string, unknown>> {
|
|
520
|
+
const specs = await getModelParams(model);
|
|
521
|
+
if (!specs) return { ...params };
|
|
522
|
+
const byName = new Map<string, any>();
|
|
523
|
+
for (const s of specs) if (s && typeof s.name === 'string') byName.set(s.name, s);
|
|
524
|
+
|
|
525
|
+
const out: Record<string, unknown> = {};
|
|
526
|
+
for (const [key, value] of Object.entries(params)) {
|
|
527
|
+
if (value == null) continue;
|
|
528
|
+
const spec = byName.get(key);
|
|
529
|
+
if (!spec) {
|
|
530
|
+
console.error(`[higgsfield] drop unsupported param --${key} for ${model}`);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
let v: unknown = value;
|
|
534
|
+
const enumVals: any[] | null = Array.isArray(spec.enum) && spec.enum.length ? spec.enum : null;
|
|
535
|
+
if (enumVals && !enumVals.map(String).includes(String(v))) {
|
|
536
|
+
const numericEnum = enumVals.every((e) => Number.isFinite(Number(e)));
|
|
537
|
+
const want = Number(v);
|
|
538
|
+
if (numericEnum && Number.isFinite(want)) {
|
|
539
|
+
// Snap to nearest; on a tie prefer the larger value (e.g. 5s → 6s).
|
|
540
|
+
let best = enumVals[0];
|
|
541
|
+
let bestD = Infinity;
|
|
542
|
+
for (const e of enumVals) {
|
|
543
|
+
const d = Math.abs(Number(e) - want);
|
|
544
|
+
if (d < bestD || (d === bestD && Number(e) > Number(best))) { best = e; bestD = d; }
|
|
545
|
+
}
|
|
546
|
+
v = best;
|
|
547
|
+
} else {
|
|
548
|
+
v = spec.default != null ? spec.default : enumVals[0];
|
|
549
|
+
}
|
|
550
|
+
console.error(`[higgsfield] snap --${key} ${String(value)} → ${String(v)} for ${model}`);
|
|
551
|
+
} else if (!enumVals && spec.type === 'integer') {
|
|
552
|
+
const n = Number(v);
|
|
553
|
+
if (Number.isFinite(n) && !Number.isInteger(n)) v = Math.round(n);
|
|
554
|
+
}
|
|
555
|
+
out[key] = v;
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export async function handleHiggsfieldCliCommand(
|
|
561
|
+
action: string,
|
|
562
|
+
payload: any,
|
|
563
|
+
send: (eventType: string, eventPayload: any) => void,
|
|
564
|
+
): Promise<void> {
|
|
565
|
+
const id = payload?.id || '';
|
|
566
|
+
|
|
567
|
+
// No mid-flight cancel hook (single-shot CLI --wait). Ack silently so the
|
|
568
|
+
// server's abort path is satisfied; the watchdog will reconcile.
|
|
569
|
+
if (action === 'abort') return;
|
|
570
|
+
|
|
571
|
+
if (action !== 'gen') {
|
|
572
|
+
send('higgsfield:cli:error', { id, stage: 'unknown_action', error: `higgsfield:cli:${action} is not supported` });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const kind = typeof payload?.kind === 'string' ? payload.kind : undefined;
|
|
577
|
+
const model = typeof payload?.model === 'string' ? payload.model.trim() : '';
|
|
578
|
+
const prompt = typeof payload?.prompt === 'string' ? payload.prompt : '';
|
|
579
|
+
if (!model) { send('higgsfield:cli:error', { id, stage: 'bad_request', error: 'higgsfield:cli:gen missing model' }); return; }
|
|
580
|
+
if (!prompt) { send('higgsfield:cli:error', { id, stage: 'bad_request', error: 'higgsfield:cli:gen missing prompt' }); return; }
|
|
581
|
+
|
|
582
|
+
// Server params (model-native names) → CLI --flag value. Snap to the model's
|
|
583
|
+
// live schema first (duration enum, aspect_ratio enum, integer rounding,
|
|
584
|
+
// drop-unknowns), then keep only scalars.
|
|
585
|
+
const rawParams = payload?.params && typeof payload.params === 'object' ? payload.params : {};
|
|
586
|
+
const params = await normalizeParamsForModel(model, rawParams);
|
|
587
|
+
const extra: Record<string, string | number | boolean> = {};
|
|
588
|
+
for (const [k, v] of Object.entries(params)) {
|
|
589
|
+
if (v == null) continue;
|
|
590
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') extra[k] = v;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Optional input image for image-to-video / img2img — base64 { mime_type, data }.
|
|
594
|
+
let image: HiggsfieldGenerateParams['image'] | undefined;
|
|
595
|
+
if (payload?.input_image && typeof payload.input_image === 'object' && typeof payload.input_image.data === 'string') {
|
|
596
|
+
image = { data: payload.input_image.data };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const timeoutSec = Number(payload?.timeout_sec);
|
|
600
|
+
const waitTimeoutMs = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec * 1000 : undefined;
|
|
601
|
+
|
|
602
|
+
send('higgsfield:cli:progress', { id, status: 'spawning' });
|
|
603
|
+
|
|
604
|
+
let out: any;
|
|
605
|
+
try {
|
|
606
|
+
out = await higgsfieldGenerate({ model, prompt, image, extra, waitTimeoutMs });
|
|
607
|
+
} catch (e: any) {
|
|
608
|
+
send('higgsfield:cli:error', { id, stage: 'spawn', error: e?.message || String(e) });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (!out || out.success !== true) {
|
|
613
|
+
send('higgsfield:cli:error', {
|
|
614
|
+
id,
|
|
615
|
+
stage: out?.stage || 'generate_failed',
|
|
616
|
+
error: out?.error || 'higgsfield generate failed',
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const artifactPath: string | undefined = out.result?.artifactPath;
|
|
622
|
+
if (!artifactPath || !existsSync(artifactPath)) {
|
|
623
|
+
// CLI reported success but bytes weren't fetched — most often a result-URL
|
|
624
|
+
// shape we didn't recognize. Surface the URL so the server can fall back.
|
|
625
|
+
send('higgsfield:cli:error', {
|
|
626
|
+
id,
|
|
627
|
+
stage: 'no_artifact',
|
|
628
|
+
error: out.result?.url
|
|
629
|
+
? `higgsfield generated but artifact fetch failed (url=${out.result.url})`
|
|
630
|
+
: 'higgsfield produced no downloadable artifact',
|
|
631
|
+
});
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let bytesBase64: string;
|
|
636
|
+
try {
|
|
637
|
+
bytesBase64 = readFileSync(artifactPath).toString('base64');
|
|
638
|
+
} catch (e: any) {
|
|
639
|
+
send('higgsfield:cli:error', { id, stage: 'read_artifact', error: e?.message || 'failed to read artifact' });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
send('higgsfield:cli:done', {
|
|
644
|
+
id,
|
|
645
|
+
exit_code: 0,
|
|
646
|
+
mime_type: mimeForArtifact(artifactPath, kind),
|
|
647
|
+
bytes_base64: bytesBase64,
|
|
648
|
+
duration_sec: Number(extra.duration) || undefined,
|
|
649
|
+
duration_ms: out.result?.durationMs,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export interface HiggsfieldListParams {
|
|
654
|
+
limit?: number;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export async function higgsfieldList(params: HiggsfieldListParams = {}): Promise<any> {
|
|
658
|
+
const bin = findHiggsfieldBinary();
|
|
659
|
+
if (!bin) return { success: false, error: 'higgsfield CLI not installed' };
|
|
660
|
+
|
|
661
|
+
const subcommand = await resolveListInvocation(bin);
|
|
662
|
+
if (!subcommand) {
|
|
663
|
+
return {
|
|
664
|
+
success: false,
|
|
665
|
+
error: 'higgsfield generate --help did not advertise a `list` subcommand on this CLI version',
|
|
666
|
+
stage: 'unsupported',
|
|
667
|
+
recoverable: false,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// The higgsfield CLI (v0.1.40) `generate list` does NOT accept a `--limit`
|
|
672
|
+
// flag — passing it errors with `unknown flag: --limit`. So we never forward
|
|
673
|
+
// it; instead we fetch the full list and trim client-side after parsing.
|
|
674
|
+
const argv = [...subcommand, '--json'];
|
|
675
|
+
|
|
676
|
+
const r = await spawnCapture(bin, argv, LIST_TIMEOUT_MS);
|
|
677
|
+
if (r.exitCode !== 0) {
|
|
678
|
+
const parsed = parseHiggsfieldError(r);
|
|
679
|
+
return { success: false, error: parsed.error, stage: parsed.stage, recoverable: parsed.recoverable };
|
|
680
|
+
}
|
|
681
|
+
let parsed = safeJsonParse(r.stdout);
|
|
682
|
+
const limit = (typeof params?.limit === 'number' && Number.isFinite(params.limit) && params.limit > 0)
|
|
683
|
+
? Math.floor(params.limit)
|
|
684
|
+
: null;
|
|
685
|
+
if (limit !== null && Array.isArray(parsed)) {
|
|
686
|
+
parsed = parsed.slice(0, limit);
|
|
687
|
+
}
|
|
688
|
+
return { success: true, result: { raw: parsed ?? r.stdout.trim(), durationMs: r.elapsedMs } };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export interface HiggsfieldModelsParams {
|
|
692
|
+
type?: string; // 'image' | 'video' | 'text' (optional filter)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Live model discovery so a controlling LLM never has to guess a job_set_type.
|
|
696
|
+
// `higgsfield model list --json` is the source of truth (the catalog changes as
|
|
697
|
+
// Higgsfield adds models), filtered optionally by media type. Cached per-process
|
|
698
|
+
// (~5 min) — the list is stable within a session and the CLI call is ~1s.
|
|
699
|
+
const modelsCache = new Map<string, { at: number; data: any[] }>();
|
|
700
|
+
const MODELS_TTL_MS = 5 * 60 * 1000;
|
|
701
|
+
|
|
702
|
+
export async function higgsfieldModels(params: HiggsfieldModelsParams = {}): Promise<any> {
|
|
703
|
+
const bin = findHiggsfieldBinary();
|
|
704
|
+
if (!bin) return { success: false, error: 'higgsfield CLI not installed' };
|
|
705
|
+
const typeRaw = typeof params?.type === 'string' ? params.type.toLowerCase().trim() : '';
|
|
706
|
+
const type = ['image', 'video', 'text'].includes(typeRaw) ? typeRaw : '';
|
|
707
|
+
const cacheKey = type || 'all';
|
|
708
|
+
const cached = modelsCache.get(cacheKey);
|
|
709
|
+
if (cached && (Date.now() - cached.at) < MODELS_TTL_MS) {
|
|
710
|
+
return { success: true, result: { models: cached.data, type: type || 'all', count: cached.data.length, cached: true } };
|
|
711
|
+
}
|
|
712
|
+
const argv = ['model', 'list', '--json'];
|
|
713
|
+
if (type) argv.splice(2, 0, `--${type}`); // -> model list --image --json
|
|
714
|
+
const r = await spawnCapture(bin, argv, LIST_TIMEOUT_MS);
|
|
715
|
+
if (r.exitCode !== 0) {
|
|
716
|
+
const parsed = parseHiggsfieldError(r);
|
|
717
|
+
return { success: false, error: parsed.error, stage: parsed.stage, recoverable: parsed.recoverable };
|
|
718
|
+
}
|
|
719
|
+
const parsed = safeJsonParse(r.stdout);
|
|
720
|
+
const models = Array.isArray(parsed)
|
|
721
|
+
? parsed
|
|
722
|
+
.map((m: any) => ({ job_set_type: m.job_set_type, name: m.display_name, type: m.type }))
|
|
723
|
+
.filter((m: any) => m.job_set_type)
|
|
724
|
+
: [];
|
|
725
|
+
modelsCache.set(cacheKey, { at: Date.now(), data: models });
|
|
726
|
+
return { success: true, result: { models, type: type || 'all', count: models.length } };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
730
|
+
|
|
731
|
+
function resolveImageInput(input: HiggsfieldGenerateParams['image']): { path: string | null; tempPath: string | null } {
|
|
732
|
+
if (input == null) return { path: null, tempPath: null };
|
|
733
|
+
if (Buffer.isBuffer(input)) return writeTempImage(input);
|
|
734
|
+
|
|
735
|
+
if (typeof input === 'string') {
|
|
736
|
+
// Heuristic: if it's a real path on disk, use it directly. Otherwise treat
|
|
737
|
+
// it as base64 image bytes (common JSON-transport encoding from agents).
|
|
738
|
+
if (existsSync(input)) return { path: input, tempPath: null };
|
|
739
|
+
try {
|
|
740
|
+
const buf = Buffer.from(stripDataUri(input), 'base64');
|
|
741
|
+
if (buf.length > 0) return writeTempImage(buf);
|
|
742
|
+
} catch {}
|
|
743
|
+
return { path: null, tempPath: null };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (typeof input === 'object') {
|
|
747
|
+
if (typeof input.path === 'string' && input.path && existsSync(input.path)) {
|
|
748
|
+
return { path: input.path, tempPath: null };
|
|
749
|
+
}
|
|
750
|
+
if (typeof input.data === 'string' && input.data) {
|
|
751
|
+
try {
|
|
752
|
+
const buf = Buffer.from(stripDataUri(input.data), 'base64');
|
|
753
|
+
if (buf.length > 0) return writeTempImage(buf);
|
|
754
|
+
} catch {}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return { path: null, tempPath: null };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function stripDataUri(s: string): string {
|
|
761
|
+
const m = /^data:[^;]+;base64,(.*)$/i.exec(s.trim());
|
|
762
|
+
return m ? m[1] : s.trim();
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function writeTempImage(buf: Buffer): { path: string; tempPath: string } {
|
|
766
|
+
mkdirSync(ARTIFACT_DIR, { recursive: true });
|
|
767
|
+
// Default extension is .bin; auto-upload doesn't care. Pick by magic for
|
|
768
|
+
// common formats so the CLI's content-type inference (if any) is happy.
|
|
769
|
+
const ext = sniffExt(buf);
|
|
770
|
+
const path = join(ARTIFACT_DIR, `upload-${randomUUID()}${ext}`);
|
|
771
|
+
writeFileSync(path, buf);
|
|
772
|
+
return { path, tempPath: path };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function sniffExt(buf: Buffer): string {
|
|
776
|
+
if (buf.length >= 4) {
|
|
777
|
+
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return '.jpg';
|
|
778
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return '.png';
|
|
779
|
+
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return '.gif';
|
|
780
|
+
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46) return '.webp';
|
|
781
|
+
}
|
|
782
|
+
return '.bin';
|
|
783
|
+
}
|