flowviant 0.125.22 → 0.125.23
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/bin/cli.mjs +5 -0
- package/bin/lib/agentBrief.mjs +4 -0
- package/bin/lib/agentTaskKinds.mjs +23 -3
- package/bin/lib/agentTaskTypes.mjs +9 -1
- package/bin/lib/artifactContracts.mjs +71 -1
- package/bin/lib/artifactPolicy.mjs +5 -0
- package/bin/lib/artifactPreview.mjs +4 -3
- package/bin/lib/artifacts.mjs +4 -0
- package/bin/lib/codexFencedMount.mjs +3 -0
- package/bin/lib/knowledgeLibrary.mjs +14 -10
- package/bin/lib/pinnedCapture.mjs +58 -7
- package/bin/lib/projectTools.mjs +4 -1
- package/bin/lib/prompts.mjs +11 -4
- package/bin/lib/videoEncode.mjs +406 -0
- package/bin/lib/videoRender.mjs +529 -0
- package/bin/lib/workAgentTools.mjs +8 -4
- package/bin/lib/workAgentTurnExecution.mjs +13 -1
- package/bin/lib/worktreeDiff.mjs +154 -55
- package/package.json +1 -1
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE VIDEO KIND'S FILE FORMATS, IN PLAIN BYTES (2026-10-26, the video kind).
|
|
3
|
+
*
|
|
4
|
+
* The render tool (videoRender.mjs) photographs an animation page frame by
|
|
5
|
+
* frame in the machine's own browser. What turns those frames into the file a
|
|
6
|
+
* card hands back lives here, with no dependency and no I/O:
|
|
7
|
+
*
|
|
8
|
+
* · `decodePng` — the browser's screenshots (8-bit RGB or RGBA, never
|
|
9
|
+
* interlaced) into pixels;
|
|
10
|
+
* · `createGifEncoder` — a looping GIF89a: each frame cropped to what changed
|
|
11
|
+
* since the last one, its own colour table (the exact colours when there
|
|
12
|
+
* are at most 256, else a median cut), LZW-coded;
|
|
13
|
+
* · `muxMp4` / `muxWebm` — the H.264 or VP9 chunks the browser's own
|
|
14
|
+
* WebCodecs encoder produced, in a progressive MP4 (moov first) or a WebM
|
|
15
|
+
* with a duration and cues, so a <video> knows how long it is and can seek.
|
|
16
|
+
*
|
|
17
|
+
* MEASURED, not assumed (2026-10-26, Chrome 149 and its headless shell, Linux):
|
|
18
|
+
* WebCodecs encodes `avc1.42001f`, `avc1.640028`, `vp09.00.10.08` and `vp8`
|
|
19
|
+
* with exact timestamps, and H.264 output carries its `avcC` record as the
|
|
20
|
+
* decoder config's description. No ffmpeg was on the machine; none is needed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { inflateSync } from 'node:zlib';
|
|
24
|
+
|
|
25
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
26
|
+
|
|
27
|
+
/** A PNG the browser wrote → `{ width, height, channels, data }` (RGB or RGBA
|
|
28
|
+
* bytes, row-major). Throws in words on anything else. */
|
|
29
|
+
export function decodePng(bytes) {
|
|
30
|
+
const buf = Buffer.from(bytes);
|
|
31
|
+
if (buf.length < 33 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('the frame is not a PNG');
|
|
32
|
+
let width = 0, height = 0, depth = 0, type = -1, interlace = 0;
|
|
33
|
+
const idat = [];
|
|
34
|
+
for (let at = 8; at + 8 <= buf.length;) {
|
|
35
|
+
const length = buf.readUInt32BE(at), kind = buf.toString('latin1', at + 4, at + 8);
|
|
36
|
+
const body = buf.subarray(at + 8, at + 8 + length);
|
|
37
|
+
if (kind === 'IHDR') {
|
|
38
|
+
width = body.readUInt32BE(0); height = body.readUInt32BE(4);
|
|
39
|
+
depth = body[8]; type = body[9]; interlace = body[12];
|
|
40
|
+
} else if (kind === 'IDAT') idat.push(body);
|
|
41
|
+
else if (kind === 'IEND') break;
|
|
42
|
+
at += 12 + length;
|
|
43
|
+
}
|
|
44
|
+
const channels = type === 2 ? 3 : type === 6 ? 4 : 0;
|
|
45
|
+
if (depth !== 8 || !channels || interlace) throw new Error(`the frame PNG is an unsupported layout (depth ${depth}, colour type ${type}, interlace ${interlace})`);
|
|
46
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
47
|
+
const stride = width * channels;
|
|
48
|
+
if (raw.length < height * (stride + 1)) throw new Error('the frame PNG is truncated');
|
|
49
|
+
const data = Buffer.alloc(height * stride);
|
|
50
|
+
for (let y = 0; y < height; y++) {
|
|
51
|
+
const filter = raw[y * (stride + 1)];
|
|
52
|
+
const src = y * (stride + 1) + 1, row = y * stride, prev = row - stride;
|
|
53
|
+
for (let x = 0; x < stride; x++) {
|
|
54
|
+
const a = x >= channels ? data[row + x - channels] : 0;
|
|
55
|
+
const b = y > 0 ? data[prev + x] : 0;
|
|
56
|
+
const c = x >= channels && y > 0 ? data[prev + x - channels] : 0;
|
|
57
|
+
let v = raw[src + x];
|
|
58
|
+
if (filter === 1) v += a;
|
|
59
|
+
else if (filter === 2) v += b;
|
|
60
|
+
else if (filter === 3) v += (a + b) >> 1;
|
|
61
|
+
else if (filter === 4) {
|
|
62
|
+
const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
63
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
64
|
+
} else if (filter !== 0) throw new Error(`the frame PNG uses unknown filter ${filter}`);
|
|
65
|
+
data[row + x] = v & 255;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { width, height, channels, data };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Pixels of any channel count → packed RGB (alpha composited over white,
|
|
72
|
+
* which is what an opaque GIF or video frame shows). */
|
|
73
|
+
export function toRgb({ width, height, channels, data }) {
|
|
74
|
+
if (channels === 3) return { width, height, rgb: data };
|
|
75
|
+
const rgb = Buffer.alloc(width * height * 3);
|
|
76
|
+
for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
|
|
77
|
+
const a = data[i + 3];
|
|
78
|
+
rgb[j] = (data[i] * a + 255 * (255 - a)) / 255;
|
|
79
|
+
rgb[j + 1] = (data[i + 1] * a + 255 * (255 - a)) / 255;
|
|
80
|
+
rgb[j + 2] = (data[i + 2] * a + 255 * (255 - a)) / 255;
|
|
81
|
+
}
|
|
82
|
+
return { width, height, rgb };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── GIF ──────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/** The colour table and the index of every pixel of one RGB region: its exact
|
|
88
|
+
* colours when it has 256 or fewer, otherwise a median cut of its 15-bit
|
|
89
|
+
* histogram (no dithering — these are drawn animations, and dither noise
|
|
90
|
+
* would only grow the file). */
|
|
91
|
+
export function quantize(rgb) {
|
|
92
|
+
const count = rgb.length / 3;
|
|
93
|
+
const exact = new Map();
|
|
94
|
+
for (let i = 0; i < rgb.length && exact.size <= 256; i += 3) {
|
|
95
|
+
const key = (rgb[i] << 16) | (rgb[i + 1] << 8) | rgb[i + 2];
|
|
96
|
+
if (!exact.has(key)) exact.set(key, exact.size);
|
|
97
|
+
}
|
|
98
|
+
const indices = new Uint8Array(count);
|
|
99
|
+
if (exact.size <= 256) {
|
|
100
|
+
const palette = Buffer.alloc(exact.size * 3);
|
|
101
|
+
for (const [key, i] of exact) { palette[i * 3] = key >> 16; palette[i * 3 + 1] = (key >> 8) & 255; palette[i * 3 + 2] = key & 255; }
|
|
102
|
+
for (let i = 0, p = 0; p < count; i += 3, p++) indices[p] = exact.get((rgb[i] << 16) | (rgb[i + 1] << 8) | rgb[i + 2]);
|
|
103
|
+
return { palette, indices };
|
|
104
|
+
}
|
|
105
|
+
const hist = new Uint32Array(32768);
|
|
106
|
+
for (let i = 0; i < rgb.length; i += 3) hist[((rgb[i] >> 3) << 10) | ((rgb[i + 1] >> 3) << 5) | (rgb[i + 2] >> 3)]++;
|
|
107
|
+
let bins = [];
|
|
108
|
+
for (let b = 0; b < 32768; b++) if (hist[b]) bins.push(b);
|
|
109
|
+
const channel = (b, c) => (c === 0 ? b >> 10 : c === 1 ? (b >> 5) & 31 : b & 31);
|
|
110
|
+
const boxes = [bins];
|
|
111
|
+
while (boxes.length < 256) {
|
|
112
|
+
// Split the box with the widest channel range (weighted by population).
|
|
113
|
+
let best = -1, bestScore = 0, bestChannel = 0;
|
|
114
|
+
boxes.forEach((box, i) => {
|
|
115
|
+
if (box.length < 2) return;
|
|
116
|
+
for (let c = 0; c < 3; c++) {
|
|
117
|
+
let lo = 31, hi = 0, n = 0;
|
|
118
|
+
for (const b of box) { const v = channel(b, c); if (v < lo) lo = v; if (v > hi) hi = v; n += hist[b]; }
|
|
119
|
+
const score = (hi - lo) * Math.sqrt(n);
|
|
120
|
+
if (score > bestScore) { bestScore = score; best = i; bestChannel = c; }
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
if (best < 0) break;
|
|
124
|
+
const box = boxes[best].sort((x, y) => channel(x, bestChannel) - channel(y, bestChannel));
|
|
125
|
+
let half = 0, total = 0;
|
|
126
|
+
for (const b of box) total += hist[b];
|
|
127
|
+
let cut = 0;
|
|
128
|
+
for (; cut < box.length - 1; cut++) { half += hist[box[cut]]; if (half * 2 >= total) break; }
|
|
129
|
+
// Both halves keep a bin: a box whose weight sits in its LAST bin (one
|
|
130
|
+
// anti-aliased shape on a white page) ran the loop out and split into
|
|
131
|
+
// itself and an empty box, again and again — a palette of 256 empty boxes
|
|
132
|
+
// and one colour, measured on a real render.
|
|
133
|
+
cut = Math.min(cut, box.length - 2);
|
|
134
|
+
boxes.splice(best, 1, box.slice(0, cut + 1), box.slice(cut + 1));
|
|
135
|
+
}
|
|
136
|
+
const boxOf = new Uint8Array(32768);
|
|
137
|
+
const palette = Buffer.alloc(boxes.length * 3);
|
|
138
|
+
const sums = boxes.map(() => [0, 0, 0, 0]);
|
|
139
|
+
boxes.forEach((box, i) => { for (const b of box) boxOf[b] = i; });
|
|
140
|
+
for (let i = 0; i < rgb.length; i += 3) {
|
|
141
|
+
const s = sums[boxOf[((rgb[i] >> 3) << 10) | ((rgb[i + 1] >> 3) << 5) | (rgb[i + 2] >> 3)]];
|
|
142
|
+
s[0] += rgb[i]; s[1] += rgb[i + 1]; s[2] += rgb[i + 2]; s[3]++;
|
|
143
|
+
}
|
|
144
|
+
sums.forEach(([r, g, b, n], i) => { palette[i * 3] = n ? Math.round(r / n) : 0; palette[i * 3 + 1] = n ? Math.round(g / n) : 0; palette[i * 3 + 2] = n ? Math.round(b / n) : 0; });
|
|
145
|
+
for (let i = 0, p = 0; p < count; i += 3, p++) indices[p] = boxOf[((rgb[i] >> 3) << 10) | ((rgb[i + 1] >> 3) << 5) | (rgb[i + 2] >> 3)];
|
|
146
|
+
return { palette, indices };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** GIF's LZW over `indices` at `minCodeSize`, packed LSB-first into 255-byte
|
|
150
|
+
* sub-blocks and terminated. The code-size step follows the decoder's
|
|
151
|
+
* (omggif's encoder, which every browser reads). */
|
|
152
|
+
export function lzwEncode(indices, minCodeSize) {
|
|
153
|
+
const clear = 1 << minCodeSize, eoi = clear + 1;
|
|
154
|
+
const table = new Map();
|
|
155
|
+
let codeSize = minCodeSize + 1, next = eoi + 1;
|
|
156
|
+
const out = [];
|
|
157
|
+
let acc = 0, bits = 0;
|
|
158
|
+
const emit = (code) => {
|
|
159
|
+
acc |= code << bits; bits += codeSize;
|
|
160
|
+
while (bits >= 8) { out.push(acc & 255); acc >>>= 8; bits -= 8; }
|
|
161
|
+
};
|
|
162
|
+
emit(clear);
|
|
163
|
+
let prefix = indices[0];
|
|
164
|
+
for (let i = 1; i < indices.length; i++) {
|
|
165
|
+
const k = indices[i];
|
|
166
|
+
const key = (prefix << 8) | k;
|
|
167
|
+
const hit = table.get(key);
|
|
168
|
+
if (hit !== undefined) { prefix = hit; continue; }
|
|
169
|
+
emit(prefix);
|
|
170
|
+
if (next === 4096) {
|
|
171
|
+
emit(clear);
|
|
172
|
+
next = eoi + 1; codeSize = minCodeSize + 1; table.clear();
|
|
173
|
+
} else {
|
|
174
|
+
if (next >= 1 << codeSize) codeSize++;
|
|
175
|
+
table.set(key, next++);
|
|
176
|
+
}
|
|
177
|
+
prefix = k;
|
|
178
|
+
}
|
|
179
|
+
emit(prefix);
|
|
180
|
+
emit(eoi);
|
|
181
|
+
if (bits > 0) out.push(acc & 255);
|
|
182
|
+
const blocks = [];
|
|
183
|
+
for (let i = 0; i < out.length; i += 255) {
|
|
184
|
+
const part = out.slice(i, i + 255);
|
|
185
|
+
blocks.push(part.length, ...part);
|
|
186
|
+
}
|
|
187
|
+
blocks.push(0);
|
|
188
|
+
return Buffer.from(blocks);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A looping GIF, frame by frame: `add(rgb, delayCs)` takes one full frame's
|
|
193
|
+
* packed RGB and how long it shows (centiseconds); `finish()` returns the
|
|
194
|
+
* file. A frame identical to the one before lengthens it instead of adding
|
|
195
|
+
* one; any other frame is cropped to the rectangle that changed and drawn
|
|
196
|
+
* over the last (disposal 1). `bytes()` is the size so far, so a caller can
|
|
197
|
+
* stop at a ceiling rather than build a file it will refuse.
|
|
198
|
+
*/
|
|
199
|
+
export function createGifEncoder(width, height) {
|
|
200
|
+
const parts = [];
|
|
201
|
+
let size = 0;
|
|
202
|
+
const push = (b) => { parts.push(b); size += b.length; };
|
|
203
|
+
const head = Buffer.alloc(13);
|
|
204
|
+
head.write('GIF89a', 0, 'latin1');
|
|
205
|
+
head.writeUInt16LE(width, 6); head.writeUInt16LE(height, 8);
|
|
206
|
+
head[10] = 0; head[11] = 0; head[12] = 0; // no global table
|
|
207
|
+
push(head);
|
|
208
|
+
// NETSCAPE2.0: loop forever.
|
|
209
|
+
push(Buffer.from([0x21, 0xff, 0x0b, ...Buffer.from('NETSCAPE2.0', 'latin1'), 0x03, 0x01, 0x00, 0x00, 0x00]));
|
|
210
|
+
let previous = null;
|
|
211
|
+
let pending = null; // { image: Buffer, delay }
|
|
212
|
+
const flush = () => {
|
|
213
|
+
if (!pending) return;
|
|
214
|
+
const { x, y, w, h, rgb } = pending.region;
|
|
215
|
+
const { palette, indices } = quantize(rgb);
|
|
216
|
+
let bitsPer = 1;
|
|
217
|
+
while (1 << bitsPer < palette.length / 3) bitsPer++;
|
|
218
|
+
const tableSize = 1 << bitsPer;
|
|
219
|
+
const gce = Buffer.from([0x21, 0xf9, 0x04, 0x04, pending.delay & 255, pending.delay >> 8, 0, 0]);
|
|
220
|
+
const desc = Buffer.alloc(10);
|
|
221
|
+
desc[0] = 0x2c;
|
|
222
|
+
desc.writeUInt16LE(x, 1); desc.writeUInt16LE(y, 3); desc.writeUInt16LE(w, 5); desc.writeUInt16LE(h, 7);
|
|
223
|
+
desc[9] = 0x80 | (bitsPer - 1); // local colour table
|
|
224
|
+
const table = Buffer.alloc(tableSize * 3);
|
|
225
|
+
palette.copy(table);
|
|
226
|
+
push(gce); push(desc); push(table);
|
|
227
|
+
push(Buffer.from([Math.max(2, bitsPer)]));
|
|
228
|
+
push(lzwEncode(indices, Math.max(2, bitsPer)));
|
|
229
|
+
pending = null;
|
|
230
|
+
};
|
|
231
|
+
return {
|
|
232
|
+
add(rgb, delayCs) {
|
|
233
|
+
if (rgb.length !== width * height * 3) throw new Error('a GIF frame is the wrong size');
|
|
234
|
+
const delay = Math.max(1, Math.min(65535, Math.round(delayCs)));
|
|
235
|
+
let x0 = 0, y0 = 0, x1 = width - 1, y1 = height - 1;
|
|
236
|
+
if (previous) {
|
|
237
|
+
x0 = width; y0 = height; x1 = -1; y1 = -1;
|
|
238
|
+
for (let y = 0; y < height; y++) {
|
|
239
|
+
for (let x = 0; x < width; x++) {
|
|
240
|
+
const i = (y * width + x) * 3;
|
|
241
|
+
if (rgb[i] !== previous[i] || rgb[i + 1] !== previous[i + 1] || rgb[i + 2] !== previous[i + 2]) {
|
|
242
|
+
if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (x1 < 0) { pending.delay = Math.min(65535, pending.delay + delay); return; }
|
|
247
|
+
}
|
|
248
|
+
flush();
|
|
249
|
+
const w = x1 - x0 + 1, h = y1 - y0 + 1;
|
|
250
|
+
const region = Buffer.alloc(w * h * 3);
|
|
251
|
+
for (let y = 0; y < h; y++) rgb.copy(region, y * w * 3, ((y0 + y) * width + x0) * 3, ((y0 + y) * width + x1 + 1) * 3);
|
|
252
|
+
pending = { region: { x: x0, y: y0, w, h, rgb: region }, delay };
|
|
253
|
+
previous = Buffer.from(rgb);
|
|
254
|
+
},
|
|
255
|
+
bytes: () => size,
|
|
256
|
+
finish() {
|
|
257
|
+
flush();
|
|
258
|
+
push(Buffer.from([0x3b]));
|
|
259
|
+
return Buffer.concat(parts);
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── MP4 (H.264) ──────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
const u8 = (n) => Buffer.from([n & 255]);
|
|
267
|
+
const u16 = (n) => { const b = Buffer.alloc(2); b.writeUInt16BE(n); return b; };
|
|
268
|
+
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0); return b; };
|
|
269
|
+
const box = (type, ...body) => {
|
|
270
|
+
const content = Buffer.concat(body);
|
|
271
|
+
return Buffer.concat([u32(content.length + 8), Buffer.from(type, 'latin1'), content]);
|
|
272
|
+
};
|
|
273
|
+
const fullBox = (type, version, flags, ...body) => box(type, u8(version), u8(flags >> 16), u16(flags & 0xffff), ...body);
|
|
274
|
+
const MATRIX = Buffer.concat([0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000].map(u32));
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* One H.264 track in a progressive MP4 (moov before mdat). `chunks` are
|
|
278
|
+
* WebCodecs output in order — `{ key, timestamp (µs), duration (µs), data }`
|
|
279
|
+
* with length-prefixed NAL units (`avc: { format: 'avc' }`) — and
|
|
280
|
+
* `description` the decoder config's avcC record.
|
|
281
|
+
*/
|
|
282
|
+
export function muxMp4({ width, height, description, chunks }) {
|
|
283
|
+
if (!description?.length) throw new Error('the H.264 encoder supplied no avcC description');
|
|
284
|
+
if (!chunks.length) throw new Error('the encoder produced no frames');
|
|
285
|
+
const timescale = 1_000_000;
|
|
286
|
+
const durations = chunks.map((c, i) => Math.max(1, Math.round(c.duration ?? ((chunks[i + 1]?.timestamp ?? c.timestamp) - c.timestamp)) || 1));
|
|
287
|
+
const total = durations.reduce((a, b) => a + b, 0);
|
|
288
|
+
const movieMs = Math.round(total / 1000);
|
|
289
|
+
const stts = [];
|
|
290
|
+
for (const d of durations) {
|
|
291
|
+
if (stts.length && stts.at(-1)[1] === d) stts.at(-1)[0]++;
|
|
292
|
+
else stts.push([1, d]);
|
|
293
|
+
}
|
|
294
|
+
const keys = chunks.map((c, i) => (c.key ? i + 1 : 0)).filter(Boolean);
|
|
295
|
+
const build = (mdatOffset) => {
|
|
296
|
+
const avc1 = box('avc1',
|
|
297
|
+
Buffer.alloc(6), u16(1), Buffer.alloc(16), u16(width), u16(height),
|
|
298
|
+
u32(0x00480000), u32(0x00480000), u32(0), u16(1), Buffer.alloc(32), u16(0x0018), u16(0xffff),
|
|
299
|
+
box('avcC', Buffer.from(description)));
|
|
300
|
+
const stbl = box('stbl',
|
|
301
|
+
fullBox('stsd', 0, 0, u32(1), avc1),
|
|
302
|
+
fullBox('stts', 0, 0, u32(stts.length), ...stts.flatMap(([n, d]) => [u32(n), u32(d)])),
|
|
303
|
+
fullBox('stss', 0, 0, u32(keys.length), ...keys.map(u32)),
|
|
304
|
+
fullBox('stsc', 0, 0, u32(1), u32(1), u32(chunks.length), u32(1)),
|
|
305
|
+
fullBox('stsz', 0, 0, u32(0), u32(chunks.length), ...chunks.map((c) => u32(c.data.length))),
|
|
306
|
+
fullBox('stco', 0, 0, u32(1), u32(mdatOffset)));
|
|
307
|
+
const minf = box('minf',
|
|
308
|
+
fullBox('vmhd', 0, 1, u16(0), u16(0), u16(0), u16(0)),
|
|
309
|
+
box('dinf', fullBox('dref', 0, 0, u32(1), fullBox('url ', 0, 1))),
|
|
310
|
+
stbl);
|
|
311
|
+
const mdia = box('mdia',
|
|
312
|
+
fullBox('mdhd', 0, 0, u32(0), u32(0), u32(timescale), u32(total), u16(0x55c4), u16(0)),
|
|
313
|
+
fullBox('hdlr', 0, 0, u32(0), Buffer.from('vide', 'latin1'), Buffer.alloc(12), Buffer.from('VideoHandler\0', 'latin1')),
|
|
314
|
+
minf);
|
|
315
|
+
const trak = box('trak',
|
|
316
|
+
fullBox('tkhd', 0, 3, u32(0), u32(0), u32(1), u32(0), u32(movieMs), Buffer.alloc(8), u16(0), u16(0), u16(0), u16(0), MATRIX, u32(width * 65536), u32(height * 65536)),
|
|
317
|
+
mdia);
|
|
318
|
+
const mvhd = fullBox('mvhd', 0, 0, u32(0), u32(0), u32(1000), u32(movieMs), u32(0x00010000), u16(0x0100), Buffer.alloc(10), MATRIX, Buffer.alloc(24), u32(2));
|
|
319
|
+
return box('moov', mvhd, trak);
|
|
320
|
+
};
|
|
321
|
+
const ftyp = box('ftyp', Buffer.from('isom', 'latin1'), u32(0x200), Buffer.from('isomiso2avc1mp41', 'latin1'));
|
|
322
|
+
const sizing = build(0);
|
|
323
|
+
const moov = build(ftyp.length + sizing.length + 8);
|
|
324
|
+
const payload = Buffer.concat(chunks.map((c) => Buffer.from(c.data)));
|
|
325
|
+
return Buffer.concat([ftyp, moov, u32(payload.length + 8), Buffer.from('mdat', 'latin1'), payload]);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── WebM (VP9 / VP8) ─────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
const ebmlSize = (n) => {
|
|
331
|
+
for (let len = 1; len <= 8; len++) {
|
|
332
|
+
if (n < 2 ** (7 * len) - 1) {
|
|
333
|
+
const b = Buffer.alloc(len);
|
|
334
|
+
let v = n;
|
|
335
|
+
for (let i = len - 1; i >= 0; i--) { b[i] = v % 256; v = Math.floor(v / 256); }
|
|
336
|
+
b[0] |= 0x80 >> (len - 1);
|
|
337
|
+
return b;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
throw new Error('an EBML element is too large');
|
|
341
|
+
};
|
|
342
|
+
const idBytes = (id) => { const hex = id.toString(16); return Buffer.from(hex.length % 2 ? `0${hex}` : hex, 'hex'); };
|
|
343
|
+
const el = (id, ...body) => { const content = Buffer.concat(body); return Buffer.concat([idBytes(id), ebmlSize(content.length), content]); };
|
|
344
|
+
const uint = (n, width = 0) => {
|
|
345
|
+
const bytes = [];
|
|
346
|
+
let v = n;
|
|
347
|
+
do { bytes.unshift(v % 256); v = Math.floor(v / 256); } while (v > 0);
|
|
348
|
+
while (bytes.length < width) bytes.unshift(0);
|
|
349
|
+
return Buffer.from(bytes);
|
|
350
|
+
};
|
|
351
|
+
const float64 = (n) => { const b = Buffer.alloc(8); b.writeDoubleBE(n); return b; };
|
|
352
|
+
const str = (s) => Buffer.from(s, 'latin1');
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* One video track in a WebM, with its duration, a cluster per keyframe and
|
|
356
|
+
* the cues that point at them (so a player can seek). `codec` is
|
|
357
|
+
* `'V_VP9'` or `'V_VP8'`; `chunks` as for `muxMp4`, timestamps in µs.
|
|
358
|
+
*/
|
|
359
|
+
export function muxWebm({ width, height, codec = 'V_VP9', chunks }) {
|
|
360
|
+
if (!chunks.length) throw new Error('the encoder produced no frames');
|
|
361
|
+
const last = chunks.at(-1);
|
|
362
|
+
const durationMs = (last.timestamp + (last.duration ?? 0)) / 1000;
|
|
363
|
+
const header = el(0x1a45dfa3, el(0x4286, uint(1)), el(0x42f7, uint(1)), el(0x42f2, uint(4)), el(0x42f3, uint(8)),
|
|
364
|
+
el(0x4282, str('webm')), el(0x4287, uint(4)), el(0x4285, uint(2)));
|
|
365
|
+
const info = el(0x1549a966, el(0x2ad7b1, uint(1_000_000)), el(0x4d80, str('flowviant')), el(0x5741, str('flowviant')), el(0x4489, float64(durationMs)));
|
|
366
|
+
const tracks = el(0x1654ae6b, el(0xae, el(0xd7, uint(1)), el(0x73c5, uint(1)), el(0x83, uint(1)), el(0x86, str(codec)), el(0x9c, uint(0)),
|
|
367
|
+
el(0xe0, el(0xb0, uint(width)), el(0xba, uint(height)))));
|
|
368
|
+
// A new cluster at every keyframe, and before a block's offset from its
|
|
369
|
+
// cluster could leave a signed 16-bit millisecond.
|
|
370
|
+
const clusters = [];
|
|
371
|
+
for (const c of chunks) {
|
|
372
|
+
const ms = Math.round(c.timestamp / 1000);
|
|
373
|
+
const open = clusters.at(-1);
|
|
374
|
+
if (!open || c.key || ms - open.start > 30_000) clusters.push({ start: ms, blocks: [] });
|
|
375
|
+
const at = clusters.at(-1);
|
|
376
|
+
const rel = Buffer.alloc(2); rel.writeInt16BE(ms - at.start);
|
|
377
|
+
at.blocks.push(el(0xa3, Buffer.from([0x81]), rel, u8(c.key ? 0x80 : 0), Buffer.from(c.data)));
|
|
378
|
+
}
|
|
379
|
+
const clusterBytes = clusters.map((c) => el(0x1f43b675, el(0xe7, uint(c.start)), ...c.blocks));
|
|
380
|
+
// SeekHead with fixed-width positions, so its size does not depend on them.
|
|
381
|
+
const seek = (id, pos) => el(0x4dbb, el(0x53ab, idBytes(id)), el(0x53ac, uint(pos, 8)));
|
|
382
|
+
const seekHeadFor = (infoPos, tracksPos, cuesPos) => el(0x114d9b74, seek(0x1549a966, infoPos), seek(0x1654ae6b, tracksPos), seek(0x1c53bb6b, cuesPos));
|
|
383
|
+
const seekLength = seekHeadFor(0, 0, 0).length;
|
|
384
|
+
const infoPos = seekLength, tracksPos = infoPos + info.length;
|
|
385
|
+
let clusterPos = tracksPos + tracks.length;
|
|
386
|
+
const cuePoints = clusters.map((c, i) => {
|
|
387
|
+
const point = el(0xbb, el(0xb3, uint(c.start)), el(0xb7, el(0xf7, uint(1)), el(0xf1, uint(clusterPos))));
|
|
388
|
+
clusterPos += clusterBytes[i].length;
|
|
389
|
+
return point;
|
|
390
|
+
});
|
|
391
|
+
const cues = el(0x1c53bb6b, ...cuePoints);
|
|
392
|
+
const segmentBody = Buffer.concat([seekHeadFor(infoPos, tracksPos, clusterPos), info, tracks, ...clusterBytes, cues]);
|
|
393
|
+
return Buffer.concat([header, idBytes(0x18538067), ebmlSize(segmentBody.length), segmentBody]);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── What a delivered file must look like ────────────────────────────────
|
|
397
|
+
|
|
398
|
+
/** The container the bytes are, by their own header: 'gif', 'mp4', 'webm'
|
|
399
|
+
* or null. A file whose name and bytes disagree is not a delivery. */
|
|
400
|
+
export function sniffVideo(bytes) {
|
|
401
|
+
const b = Buffer.from(bytes);
|
|
402
|
+
if (b.length >= 6 && /^GIF8[79]a$/.test(b.toString('latin1', 0, 6))) return 'gif';
|
|
403
|
+
if (b.length >= 12 && b.toString('latin1', 4, 8) === 'ftyp') return 'mp4';
|
|
404
|
+
if (b.length >= 4 && b.readUInt32BE(0) === 0x1a45dfa3) return 'webm';
|
|
405
|
+
return null;
|
|
406
|
+
}
|