tryharness 0.7.25 → 0.7.28
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/cli.mjs +41 -3
- package/lib/asks.mjs +260 -0
- package/lib/cards.mjs +2 -1
- package/lib/coordinator.mjs +444 -68
- package/lib/daemon.mjs +535 -88
- package/lib/goals.mjs +199 -0
- package/lib/lane.mjs +187 -0
- package/lib/listen.mjs +674 -0
- package/lib/mcp-stdio.mjs +107 -11
- package/lib/members.json +2 -2
- package/lib/nudge.mjs +3 -1
- package/lib/phones.mjs +21 -0
- package/lib/places.mjs +38 -0
- package/lib/runner.mjs +11 -1
- package/lib/store.mjs +73 -1
- package/lib/swarm.mjs +128 -3
- package/lib/t3.mjs +16 -2
- package/lib/video.mjs +332 -0
- package/package.json +1 -1
package/lib/video.mjs
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
// What an agent on this computer sees of a video (watch_video): where it cuts, a few frames from every shot as
|
|
6
|
+
// pictures it can look at, and the words on screen in them. ffmpeg reads the video; judging it is the agent's own work.
|
|
7
|
+
|
|
8
|
+
// Shots are found from each frame's colours, not its pixels: camera shake, flicker and fast motion move pixels but
|
|
9
|
+
// keep the colours, while a cut changes them and they stay changed. ffmpeg's own scene score split one shaking shot
|
|
10
|
+
// into ten in the content factory's cuts and missed two real cuts between pastel scenes.
|
|
11
|
+
const SCAN_FPS = 30;
|
|
12
|
+
const SCAN_SIDE = 64;
|
|
13
|
+
// 8 levels each of red, green and blue.
|
|
14
|
+
const BINS = 512;
|
|
15
|
+
// Frames averaged on each side of a candidate cut.
|
|
16
|
+
const BLOCK = 3;
|
|
17
|
+
// The share of the picture's colours that must change for a new shot. In the content factory's videos real cuts
|
|
18
|
+
// changed 0.26 or more and a card popping in 0.18 at most; a panel or large graphic appearing can pass it.
|
|
19
|
+
const CUT_CHANGE = 0.25;
|
|
20
|
+
// A flash shorter than this stays part of the shot it interrupts.
|
|
21
|
+
const SHORTEST_SHOT = 0.2;
|
|
22
|
+
const DEFAULT_FRAMES = 16;
|
|
23
|
+
const MAX_FRAMES = 40;
|
|
24
|
+
// Finding cuts decodes every frame, so a longer stretch is watched from its start only.
|
|
25
|
+
const LONGEST_STRETCH = 600;
|
|
26
|
+
// Pictures in the answer stay small enough for 16 to fit an agent's context; the saved copies are for a closer look.
|
|
27
|
+
const LOOK_SIDE = 640;
|
|
28
|
+
const SAVED_SIDE = 1280;
|
|
29
|
+
const KEEP_FOR_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
const round = (seconds) => Math.round(seconds * 100) / 100;
|
|
32
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
33
|
+
const lengthOf = (shot) => shot.end - shot.start;
|
|
34
|
+
const lastLine = (text) => String(text).trim().split("\n").at(-1) || "";
|
|
35
|
+
|
|
36
|
+
/** Runs a tool to its end: { stdout, stderr }. A missing ffmpeg says how to get it. */
|
|
37
|
+
function run(command, args) {
|
|
38
|
+
return new Promise((resolve, reject) =>
|
|
39
|
+
execFile(
|
|
40
|
+
command,
|
|
41
|
+
args,
|
|
42
|
+
{ maxBuffer: 64 * 1024 * 1024 },
|
|
43
|
+
(error, stdout, stderr) => {
|
|
44
|
+
if (error?.code === "ENOENT")
|
|
45
|
+
reject(
|
|
46
|
+
new Error(
|
|
47
|
+
"watching a video needs ffmpeg on this computer (brew install ffmpeg)"
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
else if (error)
|
|
51
|
+
reject(
|
|
52
|
+
new Error(`${command} could not read it: ${lastLine(stderr)}`)
|
|
53
|
+
);
|
|
54
|
+
else resolve({ stdout, stderr });
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function probe(file) {
|
|
61
|
+
const { stdout } = await run("ffprobe", [
|
|
62
|
+
"-v",
|
|
63
|
+
"error",
|
|
64
|
+
"-show_entries",
|
|
65
|
+
"format=duration:stream=codec_type,width,height,avg_frame_rate",
|
|
66
|
+
"-of",
|
|
67
|
+
"json",
|
|
68
|
+
file,
|
|
69
|
+
]);
|
|
70
|
+
const { format, streams = [] } = JSON.parse(stdout);
|
|
71
|
+
const picture = streams.find((stream) => stream.codec_type === "video");
|
|
72
|
+
const seconds = Number(format?.duration);
|
|
73
|
+
if (!picture || !(seconds > 0))
|
|
74
|
+
throw new Error(`${path.basename(file)} is not a video`);
|
|
75
|
+
const [frames, per] = String(picture.avg_frame_rate).split("/").map(Number);
|
|
76
|
+
return {
|
|
77
|
+
seconds: round(seconds),
|
|
78
|
+
width: picture.width,
|
|
79
|
+
height: picture.height,
|
|
80
|
+
fps: per ? round(frames / per) : null,
|
|
81
|
+
sound: streams.some((stream) => stream.codec_type === "audio"),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Each frame's colour histogram, SCAN_FPS frames a second through [from, to), from a small copy of the picture. */
|
|
86
|
+
async function colourHistograms(file, from, to) {
|
|
87
|
+
const child = spawn("ffmpeg", [
|
|
88
|
+
...["-v", "error", "-ss", String(from), "-to", String(to), "-i", file],
|
|
89
|
+
...["-an", "-sn", "-vf", `fps=${SCAN_FPS},scale=${SCAN_SIDE}:${SCAN_SIDE}`],
|
|
90
|
+
...["-pix_fmt", "rgb24", "-f", "rawvideo", "pipe:1"],
|
|
91
|
+
]);
|
|
92
|
+
let said = "";
|
|
93
|
+
child.stderr.on("data", (chunk) => (said += chunk));
|
|
94
|
+
const exited = new Promise((resolve, reject) => {
|
|
95
|
+
child.on("error", () =>
|
|
96
|
+
reject(new Error("watching a video needs ffmpeg on this computer"))
|
|
97
|
+
);
|
|
98
|
+
child.on("close", resolve);
|
|
99
|
+
});
|
|
100
|
+
const frameBytes = SCAN_SIDE * SCAN_SIDE * 3;
|
|
101
|
+
const histograms = [];
|
|
102
|
+
let pending = Buffer.alloc(0);
|
|
103
|
+
for await (const chunk of child.stdout) {
|
|
104
|
+
pending = Buffer.concat([pending, chunk]);
|
|
105
|
+
for (; pending.length >= frameBytes; pending = pending.subarray(frameBytes))
|
|
106
|
+
histograms.push(histogram(pending.subarray(0, frameBytes)));
|
|
107
|
+
}
|
|
108
|
+
if ((await exited) !== 0)
|
|
109
|
+
throw new Error(`ffmpeg could not read it: ${lastLine(said)}`);
|
|
110
|
+
return histograms;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function histogram(rgb) {
|
|
114
|
+
const counts = new Uint16Array(BINS);
|
|
115
|
+
for (let at = 0; at < rgb.length; at += 3)
|
|
116
|
+
counts[
|
|
117
|
+
((rgb[at] >> 5) << 6) | ((rgb[at + 1] >> 5) << 3) | (rgb[at + 2] >> 5)
|
|
118
|
+
]++;
|
|
119
|
+
return counts;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The share of colour that differs between two runs of frames, [a, b) and [b, c): 0 the same, 1 nothing shared. */
|
|
123
|
+
function change(histograms, a, b, c) {
|
|
124
|
+
let differs = 0;
|
|
125
|
+
for (let bin = 0; bin < BINS; bin++) {
|
|
126
|
+
let before = 0;
|
|
127
|
+
let after = 0;
|
|
128
|
+
for (let i = a; i < b; i++) before += histograms[i][bin];
|
|
129
|
+
for (let i = b; i < c; i++) after += histograms[i][bin];
|
|
130
|
+
differs += Math.abs(before / (b - a) - after / (c - b));
|
|
131
|
+
}
|
|
132
|
+
return differs / 2 / (SCAN_SIDE * SCAN_SIDE);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The frames a new shot starts on: where the frames after differ from the frames before by CUT_CHANGE or more, the
|
|
137
|
+
* largest such change within a shortest shot either way, placed on the single frame that changes most.
|
|
138
|
+
*/
|
|
139
|
+
function cutFrames(histograms) {
|
|
140
|
+
const count = histograms.length;
|
|
141
|
+
const around = (i) =>
|
|
142
|
+
i === 0
|
|
143
|
+
? 0
|
|
144
|
+
: change(
|
|
145
|
+
histograms,
|
|
146
|
+
Math.max(0, i - BLOCK),
|
|
147
|
+
i,
|
|
148
|
+
Math.min(count, i + BLOCK)
|
|
149
|
+
);
|
|
150
|
+
const changes = histograms.map((_, i) => around(i));
|
|
151
|
+
const reach = Math.round(SHORTEST_SHOT * SCAN_FPS);
|
|
152
|
+
const cuts = [];
|
|
153
|
+
for (let i = 1; i < count; i++) {
|
|
154
|
+
if (changes[i] < CUT_CHANGE) continue;
|
|
155
|
+
const start = Math.max(1, i - reach);
|
|
156
|
+
const nearby = changes.slice(start, i + reach + 1);
|
|
157
|
+
const firstLargest = start + nearby.indexOf(Math.max(...nearby));
|
|
158
|
+
if (firstLargest === i) cuts.push(sharpestFrame(histograms, i));
|
|
159
|
+
}
|
|
160
|
+
return cuts;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function sharpestFrame(histograms, i) {
|
|
164
|
+
let best = i;
|
|
165
|
+
let most = -1;
|
|
166
|
+
const last = Math.min(histograms.length - 1, i + BLOCK - 1);
|
|
167
|
+
for (let j = Math.max(1, i - BLOCK + 1); j <= last; j++) {
|
|
168
|
+
const step = change(histograms, j - 1, j, j + 1);
|
|
169
|
+
if (step > most) [best, most] = [j, step];
|
|
170
|
+
}
|
|
171
|
+
return best;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The times a new shot starts within [from, to). */
|
|
175
|
+
async function cutTimes(file, from, to) {
|
|
176
|
+
const histograms = await colourHistograms(file, from, to);
|
|
177
|
+
// Seeking the input restarts its clock at zero, so each time is counted from `from`.
|
|
178
|
+
return cutFrames(histograms).map((frame) => from + frame / SCAN_FPS);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function shotsBetween(from, to, cuts) {
|
|
182
|
+
const starts = [from];
|
|
183
|
+
for (const cut of cuts)
|
|
184
|
+
if (cut - starts.at(-1) >= SHORTEST_SHOT && to - cut >= SHORTEST_SHOT)
|
|
185
|
+
starts.push(cut);
|
|
186
|
+
return starts.map((start, index) => ({
|
|
187
|
+
start: round(start),
|
|
188
|
+
end: round(starts[index + 1] ?? to),
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** `count` items spread evenly through a list, first and last included. */
|
|
193
|
+
const evenly = (list, count) =>
|
|
194
|
+
Array.from(
|
|
195
|
+
{ length: count },
|
|
196
|
+
(_, i) => list[Math.round((i * (list.length - 1)) / Math.max(1, count - 1))]
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Where to take pictures: one inside every shot (an even spread of shots when there are more shots than pictures), and
|
|
201
|
+
* the pictures left over shared out by shot length, so a long take is seen more than once.
|
|
202
|
+
*/
|
|
203
|
+
function frameTimes(shots, count) {
|
|
204
|
+
const numbered = shots.map((shot, index) => ({ shot, number: index + 1 }));
|
|
205
|
+
const chosen = numbered.length > count ? evenly(numbered, count) : numbered;
|
|
206
|
+
const spare = count - chosen.length;
|
|
207
|
+
const total = chosen.reduce((sum, { shot }) => sum + lengthOf(shot), 0);
|
|
208
|
+
const shares = chosen.map(({ shot }) => (spare * lengthOf(shot)) / total);
|
|
209
|
+
const extra = shares.map(Math.floor);
|
|
210
|
+
const left = spare - extra.reduce((sum, n) => sum + n, 0);
|
|
211
|
+
shares
|
|
212
|
+
.map((share, i) => ({ part: share - extra[i], i }))
|
|
213
|
+
.sort((a, b) => b.part - a.part)
|
|
214
|
+
.slice(0, left)
|
|
215
|
+
.forEach(({ i }) => extra[i]++);
|
|
216
|
+
return chosen.flatMap(({ shot, number }, i) => {
|
|
217
|
+
const pictures = 1 + extra[i];
|
|
218
|
+
return Array.from({ length: pictures }, (_, k) => ({
|
|
219
|
+
at: round(shot.start + ((k + 0.5) / pictures) * lengthOf(shot)),
|
|
220
|
+
shot: number,
|
|
221
|
+
}));
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Scales the longer side down to `side` pixels, never up.
|
|
226
|
+
const fit = (side) =>
|
|
227
|
+
`scale='if(gte(iw,ih),min(iw,${side}),-2)':'if(gte(iw,ih),-2,min(ih,${side}))'`;
|
|
228
|
+
|
|
229
|
+
/** One frame at `at` seconds, saved twice: large at `saved` and small at `look`. */
|
|
230
|
+
function takePicture(file, at, saved, look) {
|
|
231
|
+
return run("ffmpeg", [
|
|
232
|
+
"-v",
|
|
233
|
+
"error",
|
|
234
|
+
"-y",
|
|
235
|
+
"-ss",
|
|
236
|
+
String(at),
|
|
237
|
+
"-i",
|
|
238
|
+
file,
|
|
239
|
+
"-filter_complex",
|
|
240
|
+
`[0:v]split[a][b];[a]${fit(SAVED_SIDE)}[saved];[b]${fit(LOOK_SIDE)}[look]`,
|
|
241
|
+
...["-map", "[saved]", "-frames:v", "1", "-update", "1", "-q:v", "3"],
|
|
242
|
+
saved,
|
|
243
|
+
...["-map", "[look]", "-frames:v", "1", "-update", "1", "-q:v", "5"],
|
|
244
|
+
look,
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function readText(ocr, picture) {
|
|
249
|
+
const { lines } = await ocr.read(fs.readFileSync(picture), {
|
|
250
|
+
lane: "handoff",
|
|
251
|
+
});
|
|
252
|
+
return lines.map((line) => line.text).join("\n");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function pruneOld(folder) {
|
|
256
|
+
for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
|
|
257
|
+
const dir = path.join(folder, entry.name);
|
|
258
|
+
// Another watch may have removed it a moment ago.
|
|
259
|
+
const made = fs.statSync(dir, { throwIfNoEntry: false })?.mtimeMs;
|
|
260
|
+
if (entry.isDirectory() && Date.now() - made > KEEP_FOR_MS)
|
|
261
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function summarise(shots, video, watched) {
|
|
266
|
+
const span = round(watched.to - watched.from);
|
|
267
|
+
const part =
|
|
268
|
+
span < video.seconds - 0.05
|
|
269
|
+
? ` (${watched.from} s to ${watched.to} s of ${video.seconds} s)`
|
|
270
|
+
: "";
|
|
271
|
+
if (shots.length === 1) return `one shot of ${span} s${part}`;
|
|
272
|
+
const lengths = shots.map(lengthOf);
|
|
273
|
+
const shortest = round(Math.min(...lengths));
|
|
274
|
+
const longest = round(Math.max(...lengths));
|
|
275
|
+
const average = round(span / shots.length);
|
|
276
|
+
return `${shots.length} shots in ${span} s${part}: ${shortest} s to ${longest} s long, ${average} s on average`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* A video as an agent can watch it: { file, seconds, width, height, fps, sound, watched: { from, to }, shots: [{ start,
|
|
281
|
+
* end }], summary, frames: [{ at, shot, path, look, text? }] }, times in seconds from the start of the file. `look` is a
|
|
282
|
+
* small JPEG for the answer and `path` a larger one for a closer look, both kept a day under `out`; `text` is what the
|
|
283
|
+
* relay's OCR read in the frame, when `ocr` is given. Throws when ffmpeg is missing or the file is not a video.
|
|
284
|
+
*/
|
|
285
|
+
export async function watchVideo(
|
|
286
|
+
file,
|
|
287
|
+
{ from = 0, to, frames = DEFAULT_FRAMES, ocr = null, out }
|
|
288
|
+
) {
|
|
289
|
+
const video = await probe(file);
|
|
290
|
+
const start = clamp(Number(from) || 0, 0, video.seconds);
|
|
291
|
+
const end = Math.min(
|
|
292
|
+
Number(to) || video.seconds,
|
|
293
|
+
video.seconds,
|
|
294
|
+
start + LONGEST_STRETCH
|
|
295
|
+
);
|
|
296
|
+
if (end <= start)
|
|
297
|
+
throw new Error(`there is nothing between ${start} s and ${end} s`);
|
|
298
|
+
const shots = shotsBetween(start, end, await cutTimes(file, start, end));
|
|
299
|
+
const count = clamp(
|
|
300
|
+
Math.round(Number(frames)) || DEFAULT_FRAMES,
|
|
301
|
+
1,
|
|
302
|
+
MAX_FRAMES
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
fs.mkdirSync(out, { recursive: true });
|
|
306
|
+
pruneOld(out);
|
|
307
|
+
const name = path.basename(file, path.extname(file)).replace(/[^\w-]+/g, "-");
|
|
308
|
+
const dir = fs.mkdtempSync(
|
|
309
|
+
path.join(out, `${name.slice(0, 40) || "video"}-`)
|
|
310
|
+
);
|
|
311
|
+
const pictures = [];
|
|
312
|
+
for (const { at, shot } of frameTimes(shots, count)) {
|
|
313
|
+
const stem = path.join(dir, at.toFixed(2).replace(".", "s"));
|
|
314
|
+
const saved = `${stem}.jpg`;
|
|
315
|
+
const look = `${stem}-look.jpg`;
|
|
316
|
+
await takePicture(file, at, saved, look);
|
|
317
|
+
// A time in the last frame's gap can come back empty: that picture is skipped.
|
|
318
|
+
if (!fs.existsSync(look)) continue;
|
|
319
|
+
const text = ocr ? await readText(ocr, saved) : undefined;
|
|
320
|
+
pictures.push({ at, shot, path: saved, look, text });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const watched = { from: round(start), to: round(end) };
|
|
324
|
+
return {
|
|
325
|
+
file,
|
|
326
|
+
...video,
|
|
327
|
+
watched,
|
|
328
|
+
shots,
|
|
329
|
+
summary: summarise(shots, video, watched),
|
|
330
|
+
frames: pictures,
|
|
331
|
+
};
|
|
332
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tryharness",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.28",
|
|
4
4
|
"description": "Harness Relay: the daemon that turns the agents on this machine into your personal swarm. Memory, history, and work handed between any of them.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tryharness": "cli.mjs"
|