privateer-agent 0.11.0 → 0.12.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 +9 -0
- package/bin/privateer-launch.mjs +144 -68
- package/bin/privateer-subagent.mjs +35 -22
- package/extensions/privateer-brand.ts +14 -1
- package/extensions/privateer-gate.ts +20 -28
- package/extensions/privateer-media.ts +20 -0
- package/extensions/privateer-privacy.ts +3 -3
- package/package.json +16 -7
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +876 -5
- package/src/acp/run.ts +16 -19
- package/src/channels/run.ts +18 -27
- package/src/cli/chat.ts +61 -18
- package/src/config/hosted.ts +34 -0
- package/src/config/moat.ts +236 -0
- package/src/config/moatManifest.json +26 -0
- package/src/config/moatManifest.ts +71 -0
- package/src/harbor/index.ts +49 -113
- package/src/outbox/cloudOutbox.ts +95 -0
- package/src/permissions/classify.ts +113 -0
- package/src/providers/account.ts +88 -4
- package/src/providers/defaultModel.ts +66 -11
- package/src/remote/extensionsControl.ts +12 -24
- package/src/remote/liveTaskSession.ts +15 -24
- package/src/remote/relayClient.ts +39 -0
- package/src/remote/remoteBridge.ts +65 -0
- package/src/tools/media.ts +554 -0
- package/src/tools/relayFileTools.ts +10 -11
- package/src/tools/videoCompose.ts +579 -0
- package/src/tools/web.ts +12 -8
- package/src/config/harborDaemon.ts +0 -31
- package/src/config/inlineMoat.ts +0 -67
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
// `video_compose` — the local half of media work: stitching, trimming, muxing and
|
|
2
|
+
// sampling video and audio files on this machine with ffmpeg.
|
|
3
|
+
//
|
|
4
|
+
// WHY A TOOL AND NOT JUST BASH. The agent can already shell out, and on an interactive
|
|
5
|
+
// terminal it sometimes should. But the three surfaces that matter most for media
|
|
6
|
+
// workflows — the harbor's unattended runs, channels, and any remote-driven turn — run
|
|
7
|
+
// with a read-only builtin set where `bash` is deliberately absent, and a model writing
|
|
8
|
+
// its own filter_complex is exactly the kind of thing that fails silently at 3am with a
|
|
9
|
+
// half-written file and a burnt video budget. So the operations a media workflow
|
|
10
|
+
// actually needs are named, validated, and composed here: the model picks an operation,
|
|
11
|
+
// not an ffmpeg incantation.
|
|
12
|
+
//
|
|
13
|
+
// EVERYTHING IS LOCAL. No network, no account, no billing. The generated clips came
|
|
14
|
+
// from generate_video and the stills from generate_image; this is what turns them into
|
|
15
|
+
// something finished. ffmpeg is spawned WITHOUT a shell and every argument is built
|
|
16
|
+
// from validated parameters, so nothing a prompt injects can become a command.
|
|
17
|
+
//
|
|
18
|
+
// ffmpeg is not a dependency we ship. When it's missing every operation says so once,
|
|
19
|
+
// clearly, with the install line for the platform — rather than surfacing ENOENT.
|
|
20
|
+
|
|
21
|
+
import { Type } from "typebox";
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { existsSync, mkdirSync, statSync } from "node:fs";
|
|
24
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
25
|
+
|
|
26
|
+
/** Tool names this module registers, for allow-list construction. */
|
|
27
|
+
export const COMPOSE_TOOL_NAMES = ["video_compose"] as const;
|
|
28
|
+
|
|
29
|
+
// A long concat of 1080p clips is genuinely slow; a stuck ffmpeg is not. Bound it.
|
|
30
|
+
const FFMPEG_TIMEOUT_MS = Number(process.env.PRIVATEER_FFMPEG_TIMEOUT_MS) || 10 * 60_000;
|
|
31
|
+
// Only the tail of stderr is useful (ffmpeg's banner is noise) and the whole thing
|
|
32
|
+
// would eat the model's context.
|
|
33
|
+
const STDERR_TAIL_CHARS = 1200;
|
|
34
|
+
|
|
35
|
+
const OPERATIONS = ["probe", "concat", "slideshow", "mux_audio", "trim", "extract_frame", "gif"] as const;
|
|
36
|
+
type Operation = (typeof OPERATIONS)[number];
|
|
37
|
+
|
|
38
|
+
function text(t: string) {
|
|
39
|
+
return { content: [{ type: "text", text: t }], details: {} };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ffmpegBin(): string {
|
|
43
|
+
return process.env.PRIVATEER_FFMPEG || "ffmpeg";
|
|
44
|
+
}
|
|
45
|
+
function ffprobeBin(): string {
|
|
46
|
+
return process.env.PRIVATEER_FFPROBE || "ffprobe";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const INSTALL_HINT =
|
|
50
|
+
process.platform === "darwin"
|
|
51
|
+
? "install it with `brew install ffmpeg`"
|
|
52
|
+
: process.platform === "win32"
|
|
53
|
+
? "install it with `winget install Gyan.FFmpeg`"
|
|
54
|
+
: "install it with your package manager (e.g. `sudo apt install ffmpeg`)";
|
|
55
|
+
|
|
56
|
+
class ToolError extends Error {}
|
|
57
|
+
|
|
58
|
+
interface RunResult {
|
|
59
|
+
code: number | null;
|
|
60
|
+
stdout: string;
|
|
61
|
+
stderr: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Spawn a binary with an ARGUMENT ARRAY — never a shell string. Every caller below
|
|
65
|
+
// builds its args from validated numbers and resolved paths, so there is no point at
|
|
66
|
+
// which prompt-supplied text is parsed as a command.
|
|
67
|
+
function run(bin: string, args: string[], signal?: AbortSignal): Promise<RunResult> {
|
|
68
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
69
|
+
let child;
|
|
70
|
+
try {
|
|
71
|
+
child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return rejectPromise(e);
|
|
74
|
+
}
|
|
75
|
+
let stdout = "";
|
|
76
|
+
let stderr = "";
|
|
77
|
+
let settled = false;
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
if (!settled) {
|
|
80
|
+
child.kill("SIGKILL");
|
|
81
|
+
settled = true;
|
|
82
|
+
rejectPromise(new ToolError(`${bin} timed out after ${Math.round(FFMPEG_TIMEOUT_MS / 1000)}s`));
|
|
83
|
+
}
|
|
84
|
+
}, FFMPEG_TIMEOUT_MS);
|
|
85
|
+
const onAbort = () => {
|
|
86
|
+
child.kill("SIGKILL");
|
|
87
|
+
};
|
|
88
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
89
|
+
|
|
90
|
+
child.stdout?.on("data", (d) => { stdout += String(d); });
|
|
91
|
+
child.stderr?.on("data", (d) => {
|
|
92
|
+
stderr += String(d);
|
|
93
|
+
// Keep only the tail while running so a chatty encode can't grow unbounded.
|
|
94
|
+
if (stderr.length > STDERR_TAIL_CHARS * 4) stderr = stderr.slice(-STDERR_TAIL_CHARS * 2);
|
|
95
|
+
});
|
|
96
|
+
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
signal?.removeEventListener("abort", onAbort);
|
|
99
|
+
if (settled) return;
|
|
100
|
+
settled = true;
|
|
101
|
+
if (err.code === "ENOENT") {
|
|
102
|
+
rejectPromise(new ToolError(`ffmpeg is not installed (or not on PATH) — ${INSTALL_HINT}. Set PRIVATEER_FFMPEG to point at a specific binary.`));
|
|
103
|
+
} else {
|
|
104
|
+
rejectPromise(err);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
child.on("close", (code) => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
signal?.removeEventListener("abort", onAbort);
|
|
110
|
+
if (settled) return;
|
|
111
|
+
settled = true;
|
|
112
|
+
resolvePromise({ code, stdout, stderr });
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function ffmpeg(args: string[], signal?: AbortSignal): Promise<void> {
|
|
118
|
+
// `-nostdin` matters: without it a failing ffmpeg can block reading the terminal.
|
|
119
|
+
const res = await run(ffmpegBin(), ["-hide_banner", "-nostdin", "-y", ...args], signal);
|
|
120
|
+
if (res.code !== 0) {
|
|
121
|
+
throw new ToolError(`ffmpeg failed (exit ${res.code}):\n${res.stderr.slice(-STDERR_TAIL_CHARS).trim()}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface MediaInfo {
|
|
126
|
+
path: string;
|
|
127
|
+
durationSec: number;
|
|
128
|
+
width: number | null;
|
|
129
|
+
height: number | null;
|
|
130
|
+
hasVideo: boolean;
|
|
131
|
+
hasAudio: boolean;
|
|
132
|
+
fps: number | null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function probe(path: string, signal?: AbortSignal): Promise<MediaInfo> {
|
|
136
|
+
const res = await run(
|
|
137
|
+
ffprobeBin(),
|
|
138
|
+
["-v", "error", "-print_format", "json", "-show_format", "-show_streams", path],
|
|
139
|
+
signal,
|
|
140
|
+
);
|
|
141
|
+
if (res.code !== 0) {
|
|
142
|
+
throw new ToolError(`could not read ${path}: ${res.stderr.slice(-400).trim() || `ffprobe exit ${res.code}`}`);
|
|
143
|
+
}
|
|
144
|
+
let parsed: {
|
|
145
|
+
format?: { duration?: string };
|
|
146
|
+
streams?: { codec_type?: string; width?: number; height?: number; duration?: string; avg_frame_rate?: string }[];
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(res.stdout);
|
|
150
|
+
} catch {
|
|
151
|
+
throw new ToolError(`could not parse media info for ${path}`);
|
|
152
|
+
}
|
|
153
|
+
const streams = parsed.streams ?? [];
|
|
154
|
+
const video = streams.find((s) => s.codec_type === "video");
|
|
155
|
+
const audio = streams.find((s) => s.codec_type === "audio");
|
|
156
|
+
const duration = Number(parsed.format?.duration ?? video?.duration ?? 0);
|
|
157
|
+
// avg_frame_rate arrives as "30000/1001"; a still image reports "0/0".
|
|
158
|
+
let fps: number | null = null;
|
|
159
|
+
if (video?.avg_frame_rate && video.avg_frame_rate !== "0/0") {
|
|
160
|
+
const [num, den] = video.avg_frame_rate.split("/").map(Number);
|
|
161
|
+
if (num > 0 && den > 0) fps = num / den;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
path,
|
|
165
|
+
durationSec: Number.isFinite(duration) && duration > 0 ? duration : 0,
|
|
166
|
+
width: video?.width ?? null,
|
|
167
|
+
height: video?.height ?? null,
|
|
168
|
+
hasVideo: !!video,
|
|
169
|
+
hasAudio: !!audio,
|
|
170
|
+
fps,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function absPath(cwd: string, p: string): string {
|
|
175
|
+
return isAbsolute(p) ? p : resolve(cwd, p);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function requireExisting(cwd: string, p: string, label: string): string {
|
|
179
|
+
const target = absPath(cwd, p);
|
|
180
|
+
if (!existsSync(target)) throw new ToolError(`${label} not found: ${p}`);
|
|
181
|
+
if (statSync(target).isDirectory()) throw new ToolError(`${label} is a directory, not a file: ${p}`);
|
|
182
|
+
return target;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function prepareOutput(cwd: string, p: string | undefined, operation: string): string {
|
|
186
|
+
if (!p) throw new ToolError(`\`output\` is required for the ${operation} operation.`);
|
|
187
|
+
const target = absPath(cwd, p);
|
|
188
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
189
|
+
return target;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// H.264 + yuv420p + faststart: the combination that plays everywhere, including in
|
|
193
|
+
// the app's own preview and in a browser. Anything else is a support ticket.
|
|
194
|
+
const VIDEO_ENCODE = ["-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart"];
|
|
195
|
+
const AUDIO_ENCODE = ["-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2"];
|
|
196
|
+
|
|
197
|
+
// ffmpeg's H.264 encoder needs even dimensions; a 9:16 crop off an odd-sized still is
|
|
198
|
+
// the usual way to trip it.
|
|
199
|
+
function evenSize(w: number, h: number): [number, number] {
|
|
200
|
+
return [Math.max(2, w - (w % 2)), Math.max(2, h - (h % 2))];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseSize(size: string | undefined): [number, number] | null {
|
|
204
|
+
if (!size) return null;
|
|
205
|
+
const m = /^(\d{2,5})\s*[x×:]\s*(\d{2,5})$/i.exec(size.trim());
|
|
206
|
+
if (!m) throw new ToolError(`\`size\` must look like 1920x1080 (got "${size}")`);
|
|
207
|
+
return evenSize(Number(m[1]), Number(m[2]));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Normalize one input stream to the target grid: fit inside it, pad the rest, fix the
|
|
211
|
+
// sample aspect ratio and frame rate. Clips from different models really do come back
|
|
212
|
+
// at different sizes and frame rates, and concatenating those unnormalized produces
|
|
213
|
+
// either a hard failure or a video that plays at the wrong speed after the first cut.
|
|
214
|
+
function normalizeVideo(index: number, label: string, w: number, h: number, fps: number): string {
|
|
215
|
+
return (
|
|
216
|
+
`[${index}:v]scale=${w}:${h}:force_original_aspect_ratio=decrease,` +
|
|
217
|
+
`pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=${fps},format=yuv420p[${label}]`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function normalizeAudio(index: number, label: string): string {
|
|
222
|
+
return `[${index}:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,aresample=async=1[${label}]`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Operations ───────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
interface Params {
|
|
228
|
+
operation: Operation;
|
|
229
|
+
inputs?: string[];
|
|
230
|
+
input?: string;
|
|
231
|
+
audio?: string;
|
|
232
|
+
output?: string;
|
|
233
|
+
size?: string;
|
|
234
|
+
fps?: number;
|
|
235
|
+
secondsPerImage?: number;
|
|
236
|
+
crossfadeSeconds?: number;
|
|
237
|
+
start?: number;
|
|
238
|
+
duration?: number;
|
|
239
|
+
at?: number | string;
|
|
240
|
+
volume?: number;
|
|
241
|
+
loopAudio?: boolean;
|
|
242
|
+
keepOriginalAudio?: boolean;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function opProbe(cwd: string, p: Params, signal?: AbortSignal): Promise<string> {
|
|
246
|
+
const list = p.inputs?.length ? p.inputs : p.input ? [p.input] : [];
|
|
247
|
+
if (list.length === 0) throw new ToolError("`probe` needs `input` (or `inputs`).");
|
|
248
|
+
const infos = await Promise.all(list.map((f) => probe(requireExisting(cwd, f, "input"), signal)));
|
|
249
|
+
return infos
|
|
250
|
+
.map((i, n) => {
|
|
251
|
+
const dims = i.width && i.height ? `${i.width}x${i.height}` : "no video";
|
|
252
|
+
const fps = i.fps ? `, ${i.fps.toFixed(2)} fps` : "";
|
|
253
|
+
return `${list[n]}: ${i.durationSec.toFixed(2)}s, ${dims}${fps}, audio: ${i.hasAudio ? "yes" : "no"}`;
|
|
254
|
+
})
|
|
255
|
+
.join("\n");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Stitch clips end to end, optionally with a crossfade between them.
|
|
259
|
+
async function opConcat(cwd: string, p: Params, signal?: AbortSignal): Promise<string> {
|
|
260
|
+
const list = p.inputs ?? [];
|
|
261
|
+
if (list.length < 2) throw new ToolError("`concat` needs at least two paths in `inputs`.");
|
|
262
|
+
const paths = list.map((f) => requireExisting(cwd, f, "input"));
|
|
263
|
+
const output = prepareOutput(cwd, p.output, "concat");
|
|
264
|
+
const infos = await Promise.all(paths.map((f) => probe(f, signal)));
|
|
265
|
+
for (const [i, info] of infos.entries()) {
|
|
266
|
+
if (!info.hasVideo) throw new ToolError(`${list[i]} has no video stream — use mux_audio for audio.`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Target grid: the explicit size, else the first clip's (which is normally the
|
|
270
|
+
// aspect ratio the whole set was generated at).
|
|
271
|
+
const [w, h] = parseSize(p.size) ?? evenSize(infos[0].width ?? 1280, infos[0].height ?? 720);
|
|
272
|
+
const fps = p.fps && p.fps > 0 ? Math.round(p.fps) : Math.round(infos[0].fps ?? 24);
|
|
273
|
+
const xfade = p.crossfadeSeconds ?? 0;
|
|
274
|
+
if (xfade < 0) throw new ToolError("`crossfadeSeconds` cannot be negative.");
|
|
275
|
+
if (xfade > 0) {
|
|
276
|
+
for (const [i, info] of infos.entries()) {
|
|
277
|
+
if (info.durationSec <= xfade) {
|
|
278
|
+
throw new ToolError(`crossfade of ${xfade}s is longer than clip ${list[i]} (${info.durationSec.toFixed(2)}s).`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const args: string[] = [];
|
|
284
|
+
for (const f of paths) args.push("-i", f);
|
|
285
|
+
|
|
286
|
+
// A clip with no audio gets a silent track of its own length, so the concat has a
|
|
287
|
+
// uniform stream layout. Mixed audio/no-audio input is the common case the moment a
|
|
288
|
+
// silent image-to-video clip meets one Veo generated a soundtrack for.
|
|
289
|
+
const silenceInputIndex: (number | null)[] = infos.map(() => null);
|
|
290
|
+
let nextIndex = paths.length;
|
|
291
|
+
for (const [i, info] of infos.entries()) {
|
|
292
|
+
if (!info.hasAudio) {
|
|
293
|
+
args.push("-f", "lavfi", "-t", String(Math.max(0.1, info.durationSec)), "-i", "anullsrc=r=48000:cl=stereo");
|
|
294
|
+
silenceInputIndex[i] = nextIndex++;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const filters: string[] = [];
|
|
299
|
+
for (const [i, info] of infos.entries()) {
|
|
300
|
+
filters.push(normalizeVideo(i, `v${i}`, w, h, fps));
|
|
301
|
+
filters.push(normalizeAudio(info.hasAudio ? i : (silenceInputIndex[i] as number), `a${i}`));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let vOut: string;
|
|
305
|
+
let aOut: string;
|
|
306
|
+
if (xfade > 0) {
|
|
307
|
+
// xfade takes an absolute offset into the RUNNING timeline, which shortens by one
|
|
308
|
+
// crossfade at every join — hence the running subtraction rather than a plain sum.
|
|
309
|
+
let vPrev = "v0";
|
|
310
|
+
let aPrev = "a0";
|
|
311
|
+
let offset = infos[0].durationSec - xfade;
|
|
312
|
+
for (let i = 1; i < infos.length; i++) {
|
|
313
|
+
const v = `vx${i}`;
|
|
314
|
+
const a = `ax${i}`;
|
|
315
|
+
filters.push(`[${vPrev}][v${i}]xfade=transition=fade:duration=${xfade}:offset=${offset.toFixed(3)}[${v}]`);
|
|
316
|
+
filters.push(`[${aPrev}][a${i}]acrossfade=d=${xfade}[${a}]`);
|
|
317
|
+
vPrev = v;
|
|
318
|
+
aPrev = a;
|
|
319
|
+
offset += infos[i].durationSec - xfade;
|
|
320
|
+
}
|
|
321
|
+
vOut = vPrev;
|
|
322
|
+
aOut = aPrev;
|
|
323
|
+
} else {
|
|
324
|
+
const pairs = infos.map((_, i) => `[v${i}][a${i}]`).join("");
|
|
325
|
+
filters.push(`${pairs}concat=n=${infos.length}:v=1:a=1[vcat][acat]`);
|
|
326
|
+
vOut = "vcat";
|
|
327
|
+
aOut = "acat";
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
args.push("-filter_complex", filters.join(";"), "-map", `[${vOut}]`, "-map", `[${aOut}]`, ...VIDEO_ENCODE, ...AUDIO_ENCODE, output);
|
|
331
|
+
await ffmpeg(args, signal);
|
|
332
|
+
|
|
333
|
+
const result = await probe(output, signal);
|
|
334
|
+
return `Stitched ${paths.length} clips${xfade > 0 ? ` with ${xfade}s crossfades` : ""} → ${output} (${result.durationSec.toFixed(2)}s, ${w}x${h}, ${fps} fps)`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Still images → a video, each held for a fixed time, optionally cross-dissolving.
|
|
338
|
+
async function opSlideshow(cwd: string, p: Params, signal?: AbortSignal): Promise<string> {
|
|
339
|
+
const list = p.inputs ?? [];
|
|
340
|
+
if (list.length === 0) throw new ToolError("`slideshow` needs image paths in `inputs`.");
|
|
341
|
+
const paths = list.map((f) => requireExisting(cwd, f, "image"));
|
|
342
|
+
const output = prepareOutput(cwd, p.output, "slideshow");
|
|
343
|
+
const hold = p.secondsPerImage && p.secondsPerImage > 0 ? p.secondsPerImage : 3;
|
|
344
|
+
const fps = p.fps && p.fps > 0 ? Math.round(p.fps) : 30;
|
|
345
|
+
const xfade = p.crossfadeSeconds ?? 0;
|
|
346
|
+
if (xfade < 0) throw new ToolError("`crossfadeSeconds` cannot be negative.");
|
|
347
|
+
if (xfade >= hold) throw new ToolError(`\`crossfadeSeconds\` (${xfade}) must be shorter than \`secondsPerImage\` (${hold}).`);
|
|
348
|
+
|
|
349
|
+
const infos = await Promise.all(paths.map((f) => probe(f, signal)));
|
|
350
|
+
const [w, h] = parseSize(p.size) ?? evenSize(infos[0].width ?? 1280, infos[0].height ?? 720);
|
|
351
|
+
|
|
352
|
+
const args: string[] = [];
|
|
353
|
+
for (const f of paths) args.push("-loop", "1", "-t", String(hold), "-i", f);
|
|
354
|
+
|
|
355
|
+
const filters: string[] = [];
|
|
356
|
+
for (let i = 0; i < paths.length; i++) filters.push(normalizeVideo(i, `v${i}`, w, h, fps));
|
|
357
|
+
|
|
358
|
+
let vOut = "v0";
|
|
359
|
+
if (paths.length > 1) {
|
|
360
|
+
if (xfade > 0) {
|
|
361
|
+
let prev = "v0";
|
|
362
|
+
let offset = hold - xfade;
|
|
363
|
+
for (let i = 1; i < paths.length; i++) {
|
|
364
|
+
const v = `vx${i}`;
|
|
365
|
+
filters.push(`[${prev}][v${i}]xfade=transition=fade:duration=${xfade}:offset=${offset.toFixed(3)}[${v}]`);
|
|
366
|
+
prev = v;
|
|
367
|
+
offset += hold - xfade;
|
|
368
|
+
}
|
|
369
|
+
vOut = prev;
|
|
370
|
+
} else {
|
|
371
|
+
filters.push(`${paths.map((_, i) => `[v${i}]`).join("")}concat=n=${paths.length}:v=1:a=0[vcat]`);
|
|
372
|
+
vOut = "vcat";
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Silent by design — mux_audio adds narration or a score afterwards. A slideshow
|
|
377
|
+
// with a baked-in empty audio track is harder to score than one with none.
|
|
378
|
+
args.push("-filter_complex", filters.join(";"), "-map", `[${vOut}]`, ...VIDEO_ENCODE, output);
|
|
379
|
+
await ffmpeg(args, signal);
|
|
380
|
+
const result = await probe(output, signal);
|
|
381
|
+
return `Built a ${result.durationSec.toFixed(2)}s slideshow from ${paths.length} image(s) → ${output} (${w}x${h}, ${fps} fps, no audio — add some with mux_audio)`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Put narration or a score onto a video.
|
|
385
|
+
async function opMuxAudio(cwd: string, p: Params, signal?: AbortSignal): Promise<string> {
|
|
386
|
+
if (!p.input) throw new ToolError("`mux_audio` needs `input` (the video).");
|
|
387
|
+
if (!p.audio) throw new ToolError("`mux_audio` needs `audio` (the track to add).");
|
|
388
|
+
const video = requireExisting(cwd, p.input, "video");
|
|
389
|
+
const audio = requireExisting(cwd, p.audio, "audio");
|
|
390
|
+
const output = prepareOutput(cwd, p.output, "mux_audio");
|
|
391
|
+
const [vInfo, aInfo] = await Promise.all([probe(video, signal), probe(audio, signal)]);
|
|
392
|
+
if (!vInfo.hasVideo) throw new ToolError(`${p.input} has no video stream.`);
|
|
393
|
+
if (!aInfo.hasAudio) throw new ToolError(`${p.audio} has no audio stream.`);
|
|
394
|
+
|
|
395
|
+
const volume = p.volume != null ? Number(p.volume) : 1;
|
|
396
|
+
if (!Number.isFinite(volume) || volume < 0 || volume > 4) throw new ToolError("`volume` must be between 0 and 4.");
|
|
397
|
+
|
|
398
|
+
const args: string[] = ["-i", video];
|
|
399
|
+
// Looping a short score under a long video is the common case; without it the video
|
|
400
|
+
// simply falls silent partway through, which reads as a bug.
|
|
401
|
+
if (p.loopAudio) args.push("-stream_loop", "-1");
|
|
402
|
+
args.push("-i", audio);
|
|
403
|
+
|
|
404
|
+
const filters: string[] = [`[1:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${volume}[anew]`];
|
|
405
|
+
let aOut = "anew";
|
|
406
|
+
if (p.keepOriginalAudio) {
|
|
407
|
+
if (!vInfo.hasAudio) throw new ToolError(`\`keepOriginalAudio\` was set but ${p.input} has no audio to keep.`);
|
|
408
|
+
filters.push("[0:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[aorig]");
|
|
409
|
+
filters.push("[aorig][anew]amix=inputs=2:duration=first:dropout_transition=0,dynaudnorm[amix]");
|
|
410
|
+
aOut = "amix";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// `-shortest` bounds a looped track to the video. The video stream is copied, so
|
|
414
|
+
// this is fast and lossless whatever the source.
|
|
415
|
+
args.push(
|
|
416
|
+
"-filter_complex", filters.join(";"),
|
|
417
|
+
"-map", "0:v", "-map", `[${aOut}]`,
|
|
418
|
+
"-c:v", "copy", ...AUDIO_ENCODE, "-shortest", output,
|
|
419
|
+
);
|
|
420
|
+
await ffmpeg(args, signal);
|
|
421
|
+
const result = await probe(output, signal);
|
|
422
|
+
return `Muxed ${p.audio} onto ${p.input}${p.loopAudio ? " (looped)" : ""}${p.keepOriginalAudio ? " (mixed with the original audio)" : ""} → ${output} (${result.durationSec.toFixed(2)}s)`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function opTrim(cwd: string, p: Params, signal?: AbortSignal): Promise<string> {
|
|
426
|
+
if (!p.input) throw new ToolError("`trim` needs `input`.");
|
|
427
|
+
const input = requireExisting(cwd, p.input, "input");
|
|
428
|
+
const output = prepareOutput(cwd, p.output, "trim");
|
|
429
|
+
const start = p.start != null ? Number(p.start) : 0;
|
|
430
|
+
if (!Number.isFinite(start) || start < 0) throw new ToolError("`start` must be a number of seconds >= 0.");
|
|
431
|
+
const info = await probe(input, signal);
|
|
432
|
+
if (info.durationSec && start >= info.durationSec) {
|
|
433
|
+
throw new ToolError(`\`start\` (${start}s) is at or past the end of ${p.input} (${info.durationSec.toFixed(2)}s).`);
|
|
434
|
+
}
|
|
435
|
+
const args = ["-ss", String(start), "-i", input];
|
|
436
|
+
if (p.duration != null) {
|
|
437
|
+
const d = Number(p.duration);
|
|
438
|
+
if (!Number.isFinite(d) || d <= 0) throw new ToolError("`duration` must be a positive number of seconds.");
|
|
439
|
+
args.push("-t", String(d));
|
|
440
|
+
}
|
|
441
|
+
// Re-encoded rather than stream-copied: a copy can only cut on a keyframe, which
|
|
442
|
+
// silently moves the cut by up to a couple of seconds — the opposite of a trim.
|
|
443
|
+
args.push(...VIDEO_ENCODE, ...(info.hasAudio ? AUDIO_ENCODE : ["-an"]), output);
|
|
444
|
+
await ffmpeg(args, signal);
|
|
445
|
+
const result = await probe(output, signal);
|
|
446
|
+
return `Trimmed ${p.input} from ${start}s${p.duration != null ? ` for ${p.duration}s` : " to the end"} → ${output} (${result.durationSec.toFixed(2)}s)`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Pull a single still out of a video — the move that makes clips continuous: take the
|
|
450
|
+
// last frame of clip N and hand it to generate_video as clip N+1's first frame.
|
|
451
|
+
async function opExtractFrame(cwd: string, p: Params, signal?: AbortSignal): Promise<string> {
|
|
452
|
+
if (!p.input) throw new ToolError("`extract_frame` needs `input`.");
|
|
453
|
+
const input = requireExisting(cwd, p.input, "input");
|
|
454
|
+
const output = prepareOutput(cwd, p.output, "extract_frame");
|
|
455
|
+
const info = await probe(input, signal);
|
|
456
|
+
if (!info.hasVideo) throw new ToolError(`${p.input} has no video stream.`);
|
|
457
|
+
|
|
458
|
+
let at: number;
|
|
459
|
+
if (p.at === "last" || p.at === undefined) {
|
|
460
|
+
// A hair before the end: seeking exactly to the duration lands past the last
|
|
461
|
+
// frame and ffmpeg writes nothing at all.
|
|
462
|
+
at = p.at === undefined ? 0 : Math.max(0, info.durationSec - 0.05);
|
|
463
|
+
} else {
|
|
464
|
+
at = Number(p.at);
|
|
465
|
+
if (!Number.isFinite(at) || at < 0) throw new ToolError('`at` must be a number of seconds, or "last".');
|
|
466
|
+
if (info.durationSec && at > info.durationSec) {
|
|
467
|
+
throw new ToolError(`\`at\` (${at}s) is past the end of ${p.input} (${info.durationSec.toFixed(2)}s).`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
await ffmpeg(["-ss", String(at), "-i", input, "-frames:v", "1", "-q:v", "2", output], signal);
|
|
472
|
+
if (!existsSync(output)) throw new ToolError(`ffmpeg produced no frame at ${at}s — try a slightly earlier timestamp.`);
|
|
473
|
+
return `Extracted the frame at ${at.toFixed(2)}s of ${p.input} → ${output}`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function opGif(cwd: string, p: Params, signal?: AbortSignal): Promise<string> {
|
|
477
|
+
if (!p.input) throw new ToolError("`gif` needs `input`.");
|
|
478
|
+
const input = requireExisting(cwd, p.input, "input");
|
|
479
|
+
const output = prepareOutput(cwd, p.output, "gif");
|
|
480
|
+
const fps = p.fps && p.fps > 0 ? Math.round(p.fps) : 12;
|
|
481
|
+
const size = parseSize(p.size);
|
|
482
|
+
const width = size ? size[0] : 640;
|
|
483
|
+
const args = ["-i", input];
|
|
484
|
+
if (p.start != null) args.splice(0, 0, "-ss", String(Number(p.start)));
|
|
485
|
+
if (p.duration != null) args.push("-t", String(Number(p.duration)));
|
|
486
|
+
// Two-pass palette in one graph: a plain gif encode bands badly on generated video.
|
|
487
|
+
args.push(
|
|
488
|
+
"-filter_complex",
|
|
489
|
+
`fps=${fps},scale=${width}:-2:flags=lanczos,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3`,
|
|
490
|
+
"-loop", "0", output,
|
|
491
|
+
);
|
|
492
|
+
await ffmpeg(args, signal);
|
|
493
|
+
return `Made a ${fps} fps GIF from ${p.input} → ${output}`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── The tool ─────────────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
export const videoComposeToolDefinition = {
|
|
499
|
+
name: "video_compose",
|
|
500
|
+
label: "Compose Video",
|
|
501
|
+
description:
|
|
502
|
+
"Assemble and edit video and audio files on this machine with ffmpeg. Free, local, and instant — " +
|
|
503
|
+
"no account, no billing. This is the other half of a media workflow: generate_image and " +
|
|
504
|
+
"generate_video produce the raw material, and this turns it into a finished piece.\n" +
|
|
505
|
+
"Operations and the parameters each one uses:\n" +
|
|
506
|
+
" probe — input (or inputs): report duration, dimensions, fps and whether there is audio. " +
|
|
507
|
+
"Call this before planning any edit.\n" +
|
|
508
|
+
" concat — inputs (2+), output, optional crossfadeSeconds, size, fps: stitch clips end to " +
|
|
509
|
+
"end. Clips of different sizes/frame rates are normalized to the first one (or `size`), and clips " +
|
|
510
|
+
"with no audio get silence so the join doesn't fail.\n" +
|
|
511
|
+
" slideshow — inputs (images), output, optional secondsPerImage, crossfadeSeconds, size, fps: " +
|
|
512
|
+
"turn stills into a video. Output is silent; add sound with mux_audio.\n" +
|
|
513
|
+
" mux_audio — input (video), audio, output, optional loopAudio, volume, keepOriginalAudio: put " +
|
|
514
|
+
"narration or a score onto a video, trimmed to the video's length.\n" +
|
|
515
|
+
" trim — input, output, start, optional duration: cut a section out, frame-accurate.\n" +
|
|
516
|
+
" extract_frame — input, output, optional at (seconds, or \"last\"): pull one still. Extract the " +
|
|
517
|
+
"last frame of a clip and pass it to generate_video as `firstFrame` to keep consecutive clips " +
|
|
518
|
+
"visually continuous.\n" +
|
|
519
|
+
" gif — input, output, optional start, duration, fps, size: make an animated GIF.\n" +
|
|
520
|
+
"Needs ffmpeg installed; every operation says so plainly if it isn't.",
|
|
521
|
+
parameters: Type.Object({
|
|
522
|
+
operation: Type.String({
|
|
523
|
+
description: 'One of: "probe", "concat", "slideshow", "mux_audio", "trim", "extract_frame", "gif".',
|
|
524
|
+
}),
|
|
525
|
+
inputs: Type.Optional(Type.Array(Type.String(), { description: "Input paths, in order. Used by concat, slideshow, and probe." })),
|
|
526
|
+
input: Type.Optional(Type.String({ description: "A single input path. Used by probe, mux_audio (the video), trim, extract_frame, gif." })),
|
|
527
|
+
audio: Type.Optional(Type.String({ description: "Path to the audio track. mux_audio only." })),
|
|
528
|
+
output: Type.Optional(Type.String({ description: "Where to write the result. Required by everything except probe." })),
|
|
529
|
+
size: Type.Optional(Type.String({ description: 'Target frame size, e.g. "1920x1080". Defaults to the first input\'s size (gif defaults to 640 wide).' })),
|
|
530
|
+
fps: Type.Optional(Type.Number({ description: "Target frame rate. Defaults to the first input's (30 for slideshow, 12 for gif)." })),
|
|
531
|
+
secondsPerImage: Type.Optional(Type.Number({ description: "How long each still is held. slideshow only; defaults to 3." })),
|
|
532
|
+
crossfadeSeconds: Type.Optional(Type.Number({ description: "Cross-dissolve between clips/stills instead of a hard cut. concat and slideshow; defaults to 0 (hard cut)." })),
|
|
533
|
+
start: Type.Optional(Type.Number({ description: "Start offset in seconds. trim and gif." })),
|
|
534
|
+
duration: Type.Optional(Type.Number({ description: "Length in seconds from `start`. trim and gif; omit to run to the end." })),
|
|
535
|
+
at: Type.Optional(Type.Union([Type.Number(), Type.String()], { description: 'Timestamp for extract_frame: seconds, or "last" for the final frame. Defaults to 0.' })),
|
|
536
|
+
volume: Type.Optional(Type.Number({ description: "Volume multiplier for the added track, 0-4. mux_audio only; defaults to 1." })),
|
|
537
|
+
loopAudio: Type.Optional(Type.Boolean({ description: "Repeat a short audio track until the video ends. mux_audio only." })),
|
|
538
|
+
keepOriginalAudio: Type.Optional(Type.Boolean({ description: "Mix under the video's existing audio instead of replacing it. mux_audio only." })),
|
|
539
|
+
}),
|
|
540
|
+
async execute(
|
|
541
|
+
_toolCallId: string,
|
|
542
|
+
params: Params,
|
|
543
|
+
signal?: AbortSignal,
|
|
544
|
+
_onUpdate?: unknown,
|
|
545
|
+
ctx?: { cwd?: string },
|
|
546
|
+
) {
|
|
547
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
548
|
+
const operation = String(params.operation ?? "").trim().toLowerCase() as Operation;
|
|
549
|
+
if (!OPERATIONS.includes(operation)) {
|
|
550
|
+
return text(`Error: unknown operation "${params.operation}". Use one of: ${OPERATIONS.join(", ")}.`);
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
switch (operation) {
|
|
554
|
+
case "probe": return text(await opProbe(cwd, params, signal));
|
|
555
|
+
case "concat": return text(await opConcat(cwd, params, signal));
|
|
556
|
+
case "slideshow": return text(await opSlideshow(cwd, params, signal));
|
|
557
|
+
case "mux_audio": return text(await opMuxAudio(cwd, params, signal));
|
|
558
|
+
case "trim": return text(await opTrim(cwd, params, signal));
|
|
559
|
+
case "extract_frame": return text(await opExtractFrame(cwd, params, signal));
|
|
560
|
+
case "gif": return text(await opGif(cwd, params, signal));
|
|
561
|
+
}
|
|
562
|
+
} catch (e) {
|
|
563
|
+
// ToolError messages are written for the model to act on; anything else is a
|
|
564
|
+
// genuine surprise and is reported as-is rather than dressed up.
|
|
565
|
+
return text(`video_compose ${operation} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Extension factory registering the composition tool, for the surfaces that build a
|
|
572
|
+
* session from an explicit `extensionFactories` list. The TUI gets it through
|
|
573
|
+
* `extensions/privateer-media.ts`, passed to Pi as an `-e` argument by the launcher.
|
|
574
|
+
*/
|
|
575
|
+
export function makeComposeTools() {
|
|
576
|
+
return (pi: { registerTool?: (def: unknown) => void }): void => {
|
|
577
|
+
pi.registerTool?.(videoComposeToolDefinition);
|
|
578
|
+
};
|
|
579
|
+
}
|
package/src/tools/web.ts
CHANGED
|
@@ -20,11 +20,15 @@
|
|
|
20
20
|
//
|
|
21
21
|
// SCOPE. These are the UNATTENDED paths' web tools: the harbor (routines, tasks,
|
|
22
22
|
// workflow agent steps) and channels, both of which build a session from an explicit
|
|
23
|
-
// extensionFactories list. The interactive TUI is deliberately untouched —
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
23
|
+
// extensionFactories list. The interactive TUI is deliberately untouched — the launcher
|
|
24
|
+
// loads @juicesharp/rpiv-web-tools for it instead, which registers tools by these same
|
|
25
|
+
// two names against a provider key the user configures themselves. A person at a terminal
|
|
26
|
+
// choosing their own search provider is fine; an unattended run holding that key is not.
|
|
27
|
+
//
|
|
28
|
+
// The two must never meet: Pi resolves duplicate tool names first-registration-wins, so a
|
|
29
|
+
// session that loaded both would silently get whichever came first. That is why the moat's
|
|
30
|
+
// packs reach the TUI as `-e` args and the unattended paths as factories — one route each,
|
|
31
|
+
// chosen per process, and config/moat.ts's per-kind table is where the choice is recorded.
|
|
28
32
|
|
|
29
33
|
import { Type } from "typebox";
|
|
30
34
|
import { apiRequest } from "../auth/privateer.ts";
|
|
@@ -224,9 +228,9 @@ export const webFetchToolDefinition = {
|
|
|
224
228
|
};
|
|
225
229
|
|
|
226
230
|
/**
|
|
227
|
-
* Extension factory registering both tools. Used by the
|
|
228
|
-
* session from an explicit `extensionFactories` list
|
|
229
|
-
*
|
|
231
|
+
* Extension factory registering both tools. Used by the unattended paths, which build
|
|
232
|
+
* their session from an explicit `extensionFactories` list (see config/moat.ts); the
|
|
233
|
+
* interactive TUI gets rpiv-web-tools instead, per the SCOPE note above.
|
|
230
234
|
*/
|
|
231
235
|
export function makeWebTools() {
|
|
232
236
|
return (pi: { registerTool?: (def: unknown) => void }): void => {
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// "Am I running inside the harbor daemon?" — a process-level marker, set once by
|
|
2
|
-
// Harbor.start() and read by code that must behave differently there.
|
|
3
|
-
//
|
|
4
|
-
// The harbor daemon shares ~/.privateer/agent with the interactive TUI, so Pi
|
|
5
|
-
// auto-discovers the shipped TUI extensions (extensions/*.ts shims installed by
|
|
6
|
-
// bin/privateer-launch.mjs) into every session the daemon creates. Most of that is
|
|
7
|
-
// harmless, but the gate extension also registers the relay file tools against ITS
|
|
8
|
-
// module-level bridge — the one only `/remote-access` ever attaches a relay to. In the
|
|
9
|
-
// daemon that bridge is permanently unattached, and because Pi resolves duplicate tool
|
|
10
|
-
// names first-registration-wins (discovered extensions load before inline factories), it
|
|
11
|
-
// would shadow the session-scoped pair a live task registers against its own live relay.
|
|
12
|
-
// So the gate stands its file tools down here and each daemon session registers its own
|
|
13
|
-
// (see src/tools/relayFileTools.ts, src/remote/liveTaskSession.ts).
|
|
14
|
-
//
|
|
15
|
-
// An env var rather than a module singleton on purpose: discovered extensions are loaded
|
|
16
|
-
// through jiti with its module cache off, so they may hold a SEPARATE copy of our modules.
|
|
17
|
-
// process.env is the one piece of state both copies are guaranteed to share.
|
|
18
|
-
//
|
|
19
|
-
// IMPORT-SAFETY: no Pi imports, no node builtins — safe to load from anywhere.
|
|
20
|
-
|
|
21
|
-
const ENV = "PRIVATEER_HARBOR_DAEMON";
|
|
22
|
-
|
|
23
|
-
/** Called once by the daemon as it starts. Idempotent. */
|
|
24
|
-
export function markHarborDaemon(): void {
|
|
25
|
-
process.env[ENV] = "1";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** True inside the harbor daemon process (routines, workflows, tasks, live spawns). */
|
|
29
|
-
export function inHarborDaemon(): boolean {
|
|
30
|
-
return process.env[ENV] === "1";
|
|
31
|
-
}
|