@arthovlabs/liber 0.6.0 → 0.7.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 +28 -0
- package/package.json +1 -1
- package/src/daemon/core/budget.js +17 -1
- package/src/daemon/core/engine.js +4 -2
- package/src/daemon/core/providers.js +2 -0
- package/src/daemon/core/registry.js +41 -0
- package/src/daemon/core/tools/media-tool.js +247 -0
- package/src/daemon/liberd.js +7 -0
package/README.md
CHANGED
|
@@ -419,6 +419,34 @@ The only thing it unlocks is `GET /shared/<id>`, once per request, while
|
|
|
419
419
|
`expires_at` is in the future and `revoked` is false.
|
|
420
420
|
|
|
421
421
|
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
## 🖼️ Images and video
|
|
425
|
+
|
|
426
|
+
Two tools turn text into media. Neither model runs on your machine: the call goes
|
|
427
|
+
to the provider whose key you configured, and LiBER keeps the part that matters —
|
|
428
|
+
the file is written into the workspace, its path and size come back as a receipt, so
|
|
429
|
+
a turn cannot claim "the image is ready" without a file behind it.
|
|
430
|
+
|
|
431
|
+
| tool | provider | key |
|
|
432
|
+
|---|---|---|
|
|
433
|
+
| `image_generate` | OpenAI Images (or any endpoint that speaks `POST {base}/images/generations`) | `OPENAI_API_KEY` |
|
|
434
|
+
| `video_generate` | Replicate (`/v1/predictions`, polled to completion) | `REPLICATE_API_TOKEN` |
|
|
435
|
+
|
|
436
|
+
Results land in `artifacts/media/<timestamp>-<slug>.png|mp4`. Video asks for a model
|
|
437
|
+
version (`LIBER_VIDEO_MODEL=owner/model:version`, or the `model` argument), and when
|
|
438
|
+
`ffmpeg` is available it also extracts a thumbnail; when it is not, the reply says so
|
|
439
|
+
instead of pretending.
|
|
440
|
+
|
|
441
|
+
**Cost is capped before the call, not after.** `LIBER_MEDIA_MAX_USD` (default `$2`)
|
|
442
|
+
refuses a single call above the ceiling, `LIBER_MEDIA_DAILY_USD` adds a daily media
|
|
443
|
+
budget, and every successful call is recorded in `.liber/budget.json` so
|
|
444
|
+
`limits.max_cost_per_day` covers media alongside tokens.
|
|
445
|
+
|
|
446
|
+
`GET /media` reports what this machine can actually do right now — which provider is
|
|
447
|
+
configured, whether ffmpeg was found — and **Settings → Images & Video** shows the
|
|
448
|
+
same, plus a prompt box to try it.
|
|
449
|
+
|
|
422
450
|
---
|
|
423
451
|
|
|
424
452
|
## 🔑 Clients and tokens
|
package/package.json
CHANGED
|
@@ -138,8 +138,24 @@ function record(usage, engineConfig = {}) {
|
|
|
138
138
|
return s;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Media calls are priced per call, not per token. They land in the same daily cost
|
|
143
|
+
* counter so limits.max_cost_per_day covers them too.
|
|
144
|
+
*/
|
|
145
|
+
function recordSpend(costUsd, label = 'media') {
|
|
146
|
+
const amount = Number(costUsd);
|
|
147
|
+
if (!Number.isFinite(amount) || amount <= 0) return load();
|
|
148
|
+
const s = load();
|
|
149
|
+
s.cost_usd += amount;
|
|
150
|
+
s.calls += 1;
|
|
151
|
+
s.by_kind = { ...(s.by_kind || {}) };
|
|
152
|
+
s.by_kind[label] = Number(s.by_kind[label] || 0) + amount;
|
|
153
|
+
save();
|
|
154
|
+
return s;
|
|
155
|
+
}
|
|
156
|
+
|
|
141
157
|
function snapshot() {
|
|
142
158
|
return { ...load() };
|
|
143
159
|
}
|
|
144
160
|
|
|
145
|
-
module.exports = { check, summary, record, reset, reload, snapshot, costOf, priceOf, today };
|
|
161
|
+
module.exports = { check, summary, record, recordSpend, reset, reload, snapshot, costOf, priceOf, today };
|
|
@@ -418,6 +418,8 @@ class LiberEngine {
|
|
|
418
418
|
const memKey = memoryKey || agentId;
|
|
419
419
|
const cfg = { ...DEFAULT_GROUNDING, ...(agentConfig.grounding || {}) };
|
|
420
420
|
const ledger = resumed ? EvidenceLedger.fromJSON(resumeState.ledger) : new EvidenceLedger(agentId);
|
|
421
|
+
// One id per turn, so a terminal line can be tied to the turn that caused it.
|
|
422
|
+
const turnId = `${agentId}:${Date.now().toString(36)}`;
|
|
421
423
|
const skillCtx = skills.buildContext(String(userMessage || ''), cfg.doctrine_budget_chars);
|
|
422
424
|
// Standing facts are injected every turn, not fetched on demand: a new
|
|
423
425
|
// session should open already knowing the user and this workspace.
|
|
@@ -867,7 +869,7 @@ class LiberEngine {
|
|
|
867
869
|
seq: ++subSeq
|
|
868
870
|
});
|
|
869
871
|
} else {
|
|
870
|
-
result = await registry.execute(call.name, call.args, { agentId, client: client || null, turnId
|
|
872
|
+
result = await registry.execute(call.name, call.args, { agentId, client: client || null, turnId });
|
|
871
873
|
}
|
|
872
874
|
|
|
873
875
|
// A tool can refuse for policy reasons and report that a human may
|
|
@@ -881,7 +883,7 @@ class LiberEngine {
|
|
|
881
883
|
const approved = await waitForHitl(agentId, call.name, call.args);
|
|
882
884
|
if (approved) {
|
|
883
885
|
onEvent('hitl_approved', { tool: call.name, retried: true });
|
|
884
|
-
result = await registry.execute(call.name, call.args, { approved: true, agentId, client: client || null, turnId
|
|
886
|
+
result = await registry.execute(call.name, call.args, { approved: true, agentId, client: client || null, turnId });
|
|
885
887
|
} else {
|
|
886
888
|
result = { error: `DENIED: approval was not granted. Tool '${call.name}' did not execute.`, policy_denied: true };
|
|
887
889
|
}
|
|
@@ -51,6 +51,8 @@ function envValue(name) {
|
|
|
51
51
|
const CATALOG = [
|
|
52
52
|
// — OpenAI-compatible cloud APIs
|
|
53
53
|
{ id: 'openai', label: 'OpenAI', adapter: 'openai', base: 'https://api.openai.com/v1', env: 'OPENAI_API_KEY', docs: 'platform.openai.com/api-keys', kind: 'api' },
|
|
54
|
+
{ id: 'replicate', label: 'Replicate', adapter: 'openai', base: 'https://api.replicate.com/v1', env: 'REPLICATE_API_TOKEN', docs: 'replicate.com/account/api-tokens', kind: 'api' },
|
|
55
|
+
{ id: 'stability', label: 'Stability AI', adapter: 'openai', base: 'https://api.stability.ai', env: 'STABILITY_API_KEY', docs: 'platform.stability.ai', kind: 'api' },
|
|
54
56
|
{ id: 'anthropic', label: 'Anthropic', adapter: 'anthropic', base: 'https://api.anthropic.com', env: 'ANTHROPIC_API_KEY', docs: 'console.anthropic.com', kind: 'api' },
|
|
55
57
|
{ id: 'google', label: 'Google Gemini', adapter: 'gemini', base: 'https://generativelanguage.googleapis.com', env: 'GEMINI_API_KEY', docs: 'aistudio.google.com/apikey', kind: 'api' },
|
|
56
58
|
{ id: 'xai', label: 'xAI Grok', adapter: 'openai', base: 'https://api.x.ai/v1', env: 'XAI_API_KEY', docs: 'console.x.ai', kind: 'api' },
|
|
@@ -263,12 +263,53 @@ class ToolRegistry {
|
|
|
263
263
|
},
|
|
264
264
|
handler: searchWeb
|
|
265
265
|
});
|
|
266
|
+
|
|
267
|
+
// images and video are opt-in by key: the tool explains itself when missing
|
|
268
|
+
this.registerMedia();
|
|
266
269
|
}
|
|
267
270
|
|
|
268
271
|
register(def) {
|
|
269
272
|
this.tools.set(def.name, def);
|
|
270
273
|
}
|
|
271
274
|
|
|
275
|
+
/** Text to image/video: provider-backed, written into the workspace, spent from
|
|
276
|
+
* the same daily budget. Registered apart because it is opt-in by key. */
|
|
277
|
+
registerMedia() {
|
|
278
|
+
const media = require('./tools/media-tool');
|
|
279
|
+
|
|
280
|
+
this.register({
|
|
281
|
+
name: 'image_generate',
|
|
282
|
+
description: 'Generate an image from a text prompt and save it into the workspace under artifacts/media/. Requires an image provider key (OpenAI by default, or any endpoint that speaks POST /images/generations). Returns the file path and size so the result can be cited.',
|
|
283
|
+
parameters: {
|
|
284
|
+
type: 'object',
|
|
285
|
+
properties: {
|
|
286
|
+
prompt: { type: 'string', description: 'What the image should show' },
|
|
287
|
+
size: { type: 'string', description: 'e.g. 1024x1024, 1536x1024, 1024x1536' },
|
|
288
|
+
model: { type: 'string', description: 'Provider model id (default gpt-image-1)' },
|
|
289
|
+
endpoint: { type: 'string', description: 'Override the provider base URL' },
|
|
290
|
+
n: { type: 'integer', description: 'How many images (1-4)' }
|
|
291
|
+
},
|
|
292
|
+
required: ['prompt']
|
|
293
|
+
},
|
|
294
|
+
handler: media.imageGenerate
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
this.register({
|
|
298
|
+
name: 'video_generate',
|
|
299
|
+
description: 'Generate a short video from a text prompt through Replicate, save the mp4 under artifacts/media/, and extract a thumbnail when ffmpeg is available. Requires REPLICATE_API_TOKEN and a model version (LIBER_VIDEO_MODEL or the model argument).',
|
|
300
|
+
parameters: {
|
|
301
|
+
type: 'object',
|
|
302
|
+
properties: {
|
|
303
|
+
prompt: { type: 'string', description: 'What the video should show' },
|
|
304
|
+
model: { type: 'string', description: 'owner/model:version (falls back to LIBER_VIDEO_MODEL)' },
|
|
305
|
+
seconds: { type: 'integer', description: 'Clip length in seconds if the model supports it' }
|
|
306
|
+
},
|
|
307
|
+
required: ['prompt']
|
|
308
|
+
},
|
|
309
|
+
handler: media.videoGenerate
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
272
313
|
get(name) {
|
|
273
314
|
if (name === 'shell_execute' || name === 'exec') return this.tools.get('terminal');
|
|
274
315
|
return this.tools.get(name);
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// src/daemon/core/tools/media-tool.js — images and video from text, with receipts.
|
|
2
|
+
//
|
|
3
|
+
// Nothing is generated locally: there is no model on this machine, so every call
|
|
4
|
+
// goes to a provider the operator configured. What LiBER adds is the part that
|
|
5
|
+
// makes the result trustworthy: the file is written into the workspace, its path
|
|
6
|
+
// and size come back as a tool result (so the grounding gate can cite it), the
|
|
7
|
+
// spend is recorded, and a missing capability is reported instead of pretended.
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const providers = require('../providers');
|
|
11
|
+
const budget = require('../budget');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_IMAGE_MODEL = 'gpt-image-1';
|
|
14
|
+
|
|
15
|
+
function artifactsDir() {
|
|
16
|
+
const dir = path.resolve(process.cwd(), 'artifacts', 'media');
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function slug(text, max = 40) {
|
|
22
|
+
return String(text || 'media')
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
25
|
+
.replace(/^-+|-+$/g, '')
|
|
26
|
+
.slice(0, max) || 'media';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stamp() {
|
|
30
|
+
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Which key and base URL an image/video call would use right now. */
|
|
34
|
+
function policy() {
|
|
35
|
+
const key = providers.resolve('openai', 'https://api.openai.com/v1');
|
|
36
|
+
const replicate = providers.resolve('replicate', 'https://api.replicate.com/v1');
|
|
37
|
+
let ffmpeg = null;
|
|
38
|
+
try {
|
|
39
|
+
const { sandboxEnv } = require('./terminal-tool');
|
|
40
|
+
const candidates = [
|
|
41
|
+
process.env.LIBER_FFMPEG,
|
|
42
|
+
path.resolve(process.cwd(), 'runtime', 'ffmpeg.exe'),
|
|
43
|
+
path.resolve(process.cwd(), 'runtime', 'ffmpeg')
|
|
44
|
+
].filter(Boolean);
|
|
45
|
+
for (const c of candidates) {
|
|
46
|
+
if (fs.existsSync(c)) { ffmpeg = c; break; }
|
|
47
|
+
}
|
|
48
|
+
if (!ffmpeg) {
|
|
49
|
+
const which = require('child_process').execFileSync(process.platform === 'win32' ? 'where' : 'which',
|
|
50
|
+
['ffmpeg'], { stdio: ['ignore', 'pipe', 'ignore'], env: sandboxEnv() }).toString().trim().split(/\r?\n/)[0];
|
|
51
|
+
if (which) ffmpeg = which;
|
|
52
|
+
}
|
|
53
|
+
} catch { /* not installed: reported honestly below */ }
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
images: { configured: !!key, provider: key ? 'openai' : null, model: DEFAULT_IMAGE_MODEL },
|
|
57
|
+
video: { configured: !!replicate, provider: replicate ? 'replicate' : null },
|
|
58
|
+
ffmpeg,
|
|
59
|
+
max_usd_per_call: Number(process.env.LIBER_MEDIA_MAX_USD) || 2
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ensureSpend(cost) {
|
|
64
|
+
const snap = budget.snapshot();
|
|
65
|
+
const cap = Number(process.env.LIBER_MEDIA_MAX_USD) || 2;
|
|
66
|
+
const perCall = Number(cost) || 0;
|
|
67
|
+
if (perCall > cap) {
|
|
68
|
+
return { ok: false, error: `This call is estimated at $${perCall.toFixed(2)}, above the $${cap.toFixed(2)} per-call ceiling (LIBER_MEDIA_MAX_USD).` };
|
|
69
|
+
}
|
|
70
|
+
const daily = Number(process.env.LIBER_MEDIA_DAILY_USD) || 0;
|
|
71
|
+
if (daily > 0 && snap.cost_usd + perCall > daily) {
|
|
72
|
+
return { ok: false, error: `Daily media ceiling reached ($${snap.cost_usd.toFixed(2)} spent, $${daily.toFixed(2)} allowed).` };
|
|
73
|
+
}
|
|
74
|
+
return { ok: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function postJson(url, body, headers, timeout = 120000) {
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(url, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
signal: controller.signal
|
|
86
|
+
});
|
|
87
|
+
const text = await res.text();
|
|
88
|
+
let data = null;
|
|
89
|
+
try { data = JSON.parse(text); } catch { data = { raw: text.slice(0, 400) }; }
|
|
90
|
+
return { ok: res.ok, status: res.status, data };
|
|
91
|
+
} finally {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Text to image. OpenAI-compatible by default; any endpoint that speaks
|
|
98
|
+
* POST {base}/images/generations works (including a self-hosted gateway).
|
|
99
|
+
*/
|
|
100
|
+
async function imageGenerate({ prompt, size = '1024x1024', model = DEFAULT_IMAGE_MODEL, endpoint = null, n = 1 } = {}, meta = {}) {
|
|
101
|
+
const text = String(prompt || '').trim();
|
|
102
|
+
if (!text) return { error: 'A prompt is required.' };
|
|
103
|
+
|
|
104
|
+
const key = providers.resolve('openai', endpoint || 'https://api.openai.com/v1');
|
|
105
|
+
if (!key) {
|
|
106
|
+
return {
|
|
107
|
+
error: 'No image provider is configured. Add an OpenAI key in Settings → Providers (or a custom endpoint that speaks /images/generations).',
|
|
108
|
+
needs_provider: 'openai'
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const base = String(endpoint || 'https://api.openai.com/v1').replace(/\/+$/, '');
|
|
112
|
+
|
|
113
|
+
const spend = ensureSpend(0.08);
|
|
114
|
+
if (!spend.ok) return { error: spend.error, budget_blocked: true };
|
|
115
|
+
|
|
116
|
+
const res = await postJson(`${base}/images/generations`,
|
|
117
|
+
{ model, prompt: text, size, n: Math.max(1, Math.min(Number(n) || 1, 4)) },
|
|
118
|
+
{ Authorization: `Bearer ${key}` });
|
|
119
|
+
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
const detail = res.data?.error?.message || res.data?.error || res.data?.raw || `HTTP ${res.status}`;
|
|
122
|
+
return { error: `Image provider refused the request: ${String(detail).slice(0, 300)}` };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const items = Array.isArray(res.data?.data) ? res.data.data : [];
|
|
126
|
+
if (!items.length) return { error: 'The provider answered without an image.' };
|
|
127
|
+
|
|
128
|
+
const dir = artifactsDir();
|
|
129
|
+
const files = [];
|
|
130
|
+
items.forEach((item, i) => {
|
|
131
|
+
if (!item?.b64_json) return;
|
|
132
|
+
const bytes = Buffer.from(item.b64_json, 'base64');
|
|
133
|
+
const file = path.join(dir, `${stamp()}-${slug(text)}${items.length > 1 ? '-' + (i + 1) : ''}.png`);
|
|
134
|
+
fs.writeFileSync(file, bytes);
|
|
135
|
+
files.push({ path: file, bytes: bytes.length });
|
|
136
|
+
});
|
|
137
|
+
if (!files.length) return { error: 'The provider returned no base64 image data (response_format may be unsupported).' };
|
|
138
|
+
|
|
139
|
+
budget.recordSpend(0.08, 'media');
|
|
140
|
+
return {
|
|
141
|
+
kind: 'image',
|
|
142
|
+
provider: 'openai',
|
|
143
|
+
model,
|
|
144
|
+
prompt: text,
|
|
145
|
+
files,
|
|
146
|
+
cost_usd: 0.08,
|
|
147
|
+
revised_prompt: items[0]?.revised_prompt || null,
|
|
148
|
+
note: files.length > 1 ? `${files.length} images written.` : 'Image written to the workspace.'
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function pollPrediction(url, key, timeoutMs = 600000) {
|
|
153
|
+
const started = Date.now();
|
|
154
|
+
for (;;) {
|
|
155
|
+
if (Date.now() - started > timeoutMs) return { ok: false, error: 'The provider did not finish in 10 minutes.' };
|
|
156
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
|
|
157
|
+
const data = await res.json().catch(() => ({}));
|
|
158
|
+
const status = String(data.status || '');
|
|
159
|
+
if (status === 'succeeded') return { ok: true, data };
|
|
160
|
+
if (status === 'failed' || status === 'canceled') {
|
|
161
|
+
return { ok: false, error: `Generation ${status}: ${data.error || 'no detail'}` };
|
|
162
|
+
}
|
|
163
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Text to video through Replicate. Model versions change, so the caller passes one
|
|
169
|
+
* (owner/model:version) or the operator sets LIBER_VIDEO_MODEL.
|
|
170
|
+
*/
|
|
171
|
+
async function videoGenerate({ prompt, model = null, seconds = 4 } = {}, meta = {}) {
|
|
172
|
+
const text = String(prompt || '').trim();
|
|
173
|
+
if (!text) return { error: 'A prompt is required.' };
|
|
174
|
+
|
|
175
|
+
const key = providers.resolve('replicate', 'https://api.replicate.com/v1');
|
|
176
|
+
if (!key) {
|
|
177
|
+
return {
|
|
178
|
+
error: 'No video provider is configured. Add a Replicate token in Settings → Providers.',
|
|
179
|
+
needs_provider: 'replicate'
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const version = String(model || process.env.LIBER_VIDEO_MODEL || '').trim();
|
|
183
|
+
if (!version) {
|
|
184
|
+
return {
|
|
185
|
+
error: 'A model version is required for video. Set LIBER_VIDEO_MODEL (owner/model:version) or pass model.',
|
|
186
|
+
needs_model: true
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const spend = ensureSpend(Number(process.env.LIBER_VIDEO_COST_USD) || 0.5);
|
|
191
|
+
if (!spend.ok) return { error: spend.error, budget_blocked: true };
|
|
192
|
+
|
|
193
|
+
const create = await postJson('https://api.replicate.com/v1/predictions',
|
|
194
|
+
{ version, input: { prompt: text, seconds: Number(seconds) || 4 } },
|
|
195
|
+
{ Authorization: `Bearer ${key}` });
|
|
196
|
+
if (!create.ok) {
|
|
197
|
+
return { error: `Video provider refused the request: ${JSON.stringify(create.data).slice(0, 300)}` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const pollUrl = create.data?.urls?.get;
|
|
201
|
+
if (!pollUrl) return { error: 'The provider did not return a polling URL.' };
|
|
202
|
+
|
|
203
|
+
const done = await pollPrediction(pollUrl, key);
|
|
204
|
+
if (!done.ok) return { error: done.error };
|
|
205
|
+
|
|
206
|
+
const output = done.data.output;
|
|
207
|
+
const url = Array.isArray(output) ? output[output.length - 1] : (typeof output === 'string' ? output : null);
|
|
208
|
+
if (!url) return { error: 'The provider finished without a video URL.' };
|
|
209
|
+
|
|
210
|
+
const bin = await fetch(url);
|
|
211
|
+
if (!bin.ok) return { error: `Could not download the result (HTTP ${bin.status}).` };
|
|
212
|
+
const bytes = Buffer.from(await bin.arrayBuffer());
|
|
213
|
+
|
|
214
|
+
const dir = artifactsDir();
|
|
215
|
+
const file = path.join(dir, `${stamp()}-${slug(text)}.mp4`);
|
|
216
|
+
fs.writeFileSync(file, bytes);
|
|
217
|
+
|
|
218
|
+
const cost = Number(process.env.LIBER_VIDEO_COST_USD) || 0.5;
|
|
219
|
+
budget.recordSpend(cost, 'media');
|
|
220
|
+
|
|
221
|
+
const out = {
|
|
222
|
+
kind: 'video',
|
|
223
|
+
provider: 'replicate',
|
|
224
|
+
model: version,
|
|
225
|
+
prompt: text,
|
|
226
|
+
files: [{ path: file, bytes: bytes.length }],
|
|
227
|
+
cost_usd: cost
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// A thumbnail is the cheapest way to prove the file is a real video, and it is
|
|
231
|
+
// what a chat surface needs. Only claimed when ffmpeg is actually present.
|
|
232
|
+
const p = policy();
|
|
233
|
+
if (p.ffmpeg) {
|
|
234
|
+
try {
|
|
235
|
+
const thumb = file.replace(/\.mp4$/, '-thumb.png');
|
|
236
|
+
require('child_process').execFileSync(p.ffmpeg, ['-y', '-i', file, '-frames:v', '1', thumb],
|
|
237
|
+
{ stdio: 'ignore', timeout: 60000 });
|
|
238
|
+
if (fs.existsSync(thumb)) out.files.push({ path: thumb, bytes: fs.statSync(thumb).size, kind: 'thumbnail' });
|
|
239
|
+
} catch { out.thumbnail_error = 'ffmpeg could not extract a frame.'; }
|
|
240
|
+
} else {
|
|
241
|
+
out.note = 'No ffmpeg on this machine: no thumbnail was produced.';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = { imageGenerate, videoGenerate, policy, artifactsDir };
|
package/src/daemon/liberd.js
CHANGED
|
@@ -979,6 +979,13 @@ class LiberDaemon {
|
|
|
979
979
|
}));
|
|
980
980
|
return;
|
|
981
981
|
}
|
|
982
|
+
else if (endpoint === '/media') {
|
|
983
|
+
// What this machine can actually do today: which provider answered, and
|
|
984
|
+
// whether ffmpeg exists for video post-processing. No capability is claimed
|
|
985
|
+
// without a key behind it.
|
|
986
|
+
const media = require('./core/tools/media-tool');
|
|
987
|
+
res.end(JSON.stringify({ success: true, ...media.policy() }));
|
|
988
|
+
}
|
|
982
989
|
else if (endpoint === '/voice' || endpoint === '/voice/speak') {
|
|
983
990
|
if (endpoint === '/voice' && req.method === 'GET') {
|
|
984
991
|
const voices = await voice.voices();
|