OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 79a2fc1

0.0.50: store the pyramid, and derive the video rungs
This file's own header said "No levels, no ladder, no manifest... a pyramid would be complexity bought for a problem this stack does not have. If favela ever bridges media, §6.6 is waiting." favela bridges media now, so §6.6 is cashed in. THE POINTER RIDES THE FEED. `blob/<baseOid>` is a record carrying {mimeType, size, levels:[{oid,w,h,size,by}]}. It is in the feed and not a local index for the same reason the profile is: the reader it exists for is a BRIDGE holding this feed and nothing else, and a pointer kept on the authoring node would be invisible to exactly that reader. The base oid is the key, so `![alt](favela://blob/<oid>)` is unchanged and every reader that knows nothing of levels still resolves the author's own bytes. Levels are additive; nothing already written moves. ⚠ A blob with no pointer is the SINGLE-LEVEL case, never an error — record-vocabulary §3.5 calls today's avatar exactly that. `blob-level` does the arithmetic against a ceiling the CALLER supplies, so the choice stays the bridge's while the ladder stays singular. The ladder itself moved to favela-markdown, which owns the vocabulary that defines it. VIDEO: `blob-derive` fills in the rungs with the SYSTEM ffmpeg via bare-subprocess — hypernote's measured shape, not bare-ffmpeg, whose android-arm64 prebuild is 29 MB against an APK fought down from 527 MB. §6.6.5: a capability purchase, not a requirement. It is a VERB rather than something that happens on write, because §6.6.4 allows a partial pyramid: a handset posts one level and a node that HAS an encoder fills in the rest later. Every level it makes is `derived:`, never the author's. ⛔ A rung bigger than its source is dropped. Measured on a synthetic 720p clip whose 480p rung came out LARGER — fixed bitrate against highly compressible video. The browser path guards the same way. ⛔ probe returns null for a container with no video stream. It used to return {width:0,height:0}, which made an audio file look like a successful probe and then produced "no rungs" — the same answer a tiny clip gets, for a completely different reason. The real ffmpeg round trip is `npm run test:media`, deliberately outside the default glob: a spawning test passes and then holds brittle's runner open, taking the suite from seconds to killed-at-seven-minutes. transcode.js takes an injected runner so the default suite covers the arg construction and every failure path offline. The spawning test has been run: 1280x720 in, a genuine 480-tall even-width file out. ⚠ boundary.test.js caught a COMMENT of mine naming the projection layer in lib/. That is the test working, and the comment now says so. 350/350. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
79a2fc17dab686288b04609784fe6876893d8fcf
by plankatron <fleeky@gmail.com> · 2026-09-10T00:02:57.000Z

Changed files (7) +412 -2

lib/peer.js +42 -0
……@@ -84,6 +84,10 @@ export const PROFILE = 'profile'
8484// Which networks this identity publishes to. A PREFERENCE, in the feed, so it follows the identity
8585// to every device rather than living on whichever machine happened to set it.
8686export const REACH = 'reach'
87// ⛔ The blob POINTER collection — record-vocabulary §3.5. A tiny record in the feed so every reader
88// converges on which levels a blob has and the oid to fetch each by, while the bytes stay out of the
89// log. Keyed by the BASE oid, which is what `favela://blob/<oid>` already names.
90export const BLOB = 'blob'
8791export const SELF = 'self'
8892
8993const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
……@@ -282,6 +286,44 @@ export class Peer {
282286 }
283287 }
284288
289 /**
290 * The mip pointer for one blob of THIS feed, or null.
291 *
292 * ⛔ Null means "no pointer", which for a blob written before levels existed is the SINGLE-LEVEL
293 * case and not an error — record-vocabulary §3.5 calls today's avatar exactly that. A caller must
294 * treat absence as one level, never as a missing asset.
295 */
296 async blobPointer (oid) {
297 return this.blobPointerOf(this.feed.key, oid)
298 }
299
300 /** One feed's pointer for a blob, or null. Reads any feed this node holds. */
301 async blobPointerOf (feedKey, oid) {
302 if (typeof oid !== 'string' || !oid) return null
303 const id = hex(feedKey)
304 for (const [author, feed] of this._readable()) {
305 if (author !== id) continue
306 const rec = await feed.get(BLOB, oid)
307 const fm = (rec && rec.frontmatter) || null
308 if (!fm || !Array.isArray(fm.levels)) return null
309 return fm
310 }
311 return null
312 }
313
314 /**
315 * Publish the pointer for a blob's pyramid.
316 *
317 * ⛔ THE POINTER, NOT A MANIFEST FILE. §3.5: one pointer, N oids, zero extra artifacts. It rides
318 * the feed so a bridge holding this feed can choose a level without asking anyone.
319 */
320 async putBlobPointer (oid, pointer) {
321 if (typeof oid !== 'string' || !oid) throw new Error('putBlobPointer: an oid is required')
322 await this.feed.update()
323 await this.feed.put(BLOB, oid, { frontmatter: pointer, body: '' })
324 return pointer
325 }
326
285327 /** Merges: a write naming only `networks` leaves the credential alone, and the reverse. */
286328 async setReach ({ networks, credential } = {}) {
287329 await this.feed.update()
lib/rpc.js +112 -0
……@@ -14,6 +14,9 @@ import { Peer, LIKE_VALUE } from './peer.js'
1414import { linkMentions } from './mentions.js'
1515import { graphOf } from './graph.js'
1616import { recordName } from './name.js'
17// ⛔ ONE ladder, owned by the record vocabulary that defines it (record-vocabulary §3.5).
18import { makePointer, AUTHOR, selectLevel, planLevels, derivedBy } from 'favela-markdown/lib/levels.js'
19import { available as ffmpegAvailable, probe, encodeLevel } from './transcode.js'
1720
1821export function createServer (node, sockPath, { onError = () => {} } = {}) {
1922 // A stale socket from a crashed daemon would make bind fail with EADDRINUSE and look like
……@@ -352,6 +355,115 @@ export async function dispatch (node, cmd, a) {
352355 }
353356 // A file embedded in a record: `![alt](favela://blob/<oid>)`. No cap — the overlay chunks.
354357 case 'file-put': return node.files.put(a.path)
358 /**
359 * ⛔ THE PYRAMID, stored and pointed at in one act (SOCIAL-MODEL §6.6.1).
360 *
361 * The caller has already encoded the levels — that is authoring-time work and happens where the
362 * encoder is: `<canvas>` in the browser for images, system ffmpeg for video. This stores each
363 * whole file in the overlay exactly as a single blob is stored, then publishes ONE pointer
364 * naming them. Bridges then SELECT a level rather than re-encoding per delivery.
365 *
366 * ⚠ The BASE oid is the key and is what a record references, so `![alt](favela://blob/<oid>)`
367 * is unchanged and every reader that knows nothing of levels still resolves the author's own
368 * bytes. Levels are additive; nothing already written moves.
369 */
370 case 'file-put-levels': {
371 const input = Array.isArray(a.levels) ? a.levels : []
372 if (!input.length) throw new Error('file-put-levels: at least one level is required')
373 const stored = []
374 for (const l of input) {
375 if (!l || !l.path) throw new Error('file-put-levels: every level needs a path')
376 const r = await node.files.put(l.path)
377 stored.push({ oid: r.oid, w: Number(l.w) || 0, h: Number(l.h) || 0, size: r.size, by: l.by || AUTHOR })
378 }
379 // Largest first — makePointer sorts — so the base is level 0 and is the author's own bytes.
380 // The type comes from the BASE level's bytes — files.put already sniffed it, and every level
381 // of one asset is the same kind of thing.
382 const baseInfo = await node.files.get(stored[0].oid)
383 const pointer = makePointer({ mimeType: (baseInfo && baseInfo.mimeType) || '', size: stored[0].size, levels: stored })
384 const base = pointer.levels[0]
385 await peer.putBlobPointer(base.oid, pointer)
386 await peer.update()
387 return { oid: base.oid, pointer }
388 }
389 /**
390 * ⛔ DERIVE the missing rungs of a video's pyramid, on a node that HAS an encoder.
391 *
392 * §6.6.4: the pyramid is allowed to be PARTIAL — a handset posts one level and a well-resourced
393 * peer fills in the rest later. That is why this exists as a verb rather than happening on
394 * write: the node that stored the bytes is not necessarily the node that can encode.
395 *
396 * ⛔ EVERY LEVEL IT MAKES IS `derived:`, NEVER the author's. Deriving is not authoring — a peer
397 * adding a rung has produced a derived artifact and says so, or an author's bytes and somebody
398 * else's re-encode become indistinguishable. §8's rule cost two of wj62's posts.
399 *
400 * ⚠ Says plainly when it cannot: a node without ffmpeg is an ordinary node, not a broken one.
401 */
402 case 'blob-derive': {
403 const info = await node.files.get(a.oid)
404 if (!info) return { ok: false, reason: 'no such blob' }
405 if (!/^video\//.test(info.mimeType || '')) return { ok: false, reason: 'only video is derived here — images are encoded at authoring' }
406 if (!await ffmpegAvailable()) return { ok: false, reason: 'no ffmpeg on this node — a level can be derived on one that has it' }
407
408 const dims = await probe(info.path)
409 if (!dims || !dims.height) return { ok: false, reason: 'could not probe the video' }
410
411 const existing = await peer.blobPointer(a.oid)
412 const have = new Set(((existing && existing.levels) || []).map((l) => l.h))
413 const want = planLevels({ mimeType: info.mimeType, width: dims.width, height: dims.height })
414 .filter((l) => !have.has(l.edge))
415 if (!want.length) return { ok: true, added: 0, reason: 'every level this ladder defines already exists' }
416
417 const me = b4a.toString(peer.feed.key, 'hex').slice(0, 8)
418 const levels = ((existing && existing.levels) || [])
419 if (!levels.length) levels.push({ oid: a.oid, w: dims.width, h: dims.height, size: info.size, by: AUTHOR })
420
421 let added = 0
422 for (const l of want) {
423 const enc = await encodeLevel(info.path, l)
424 if (!enc) continue
425 // ⛔ A RUNG BIGGER THAN THE SOURCE IS NOT A RUNG. A fixed bitrate applied to already-small
426 // or highly-compressible video produces a "smaller" level that costs more bytes and buys
427 // nothing — measured on a synthetic 720p clip whose 480p rung came out larger. The browser
428 // path guards the same way for the same reason.
429 if (enc.size >= info.size) { try { fs.unlinkSync(enc.path) } catch {}; continue }
430 const stored = await node.files.put(enc.path)
431 try { fs.unlinkSync(enc.path) } catch {}
432 levels.push({ oid: stored.oid, w: 0, h: l.edge, size: stored.size, by: derivedBy(me) })
433 added++
434 }
435 if (!added) return { ok: false, reason: 'ffmpeg produced no usable level' }
436
437 const pointer = makePointer({ mimeType: info.mimeType, size: info.size, levels })
438 await peer.putBlobPointer(a.oid, pointer)
439 await peer.update()
440 return { ok: true, added, pointer }
441 }
442 /** The pointer for a blob — null when it has only the one level it was written with. */
443 case 'blob-pointer': return (a.feed ? peer.blobPointerOf(a.feed, a.oid) : peer.blobPointer(a.oid)) || null
444 /**
445 * The level nearest a caller's ceiling, for a blob of any feed this node holds.
446 *
447 * ⛔ THE BRIDGE SUPPLIES THE CEILING; the choice is still the bridge's. Doing the arithmetic
448 * here rather than in each bridge keeps ONE ladder — the alternative is the same table written
449 * three times, which is how this monorepo got six copies of a signing format.
450 *
451 * ⚠ A blob with no pointer answers with its own single level, because that is what it has
452 * (record-vocabulary §3.5: today's avatar is the single-level special case). Absence of a
453 * pointer is never an error.
454 */
455 case 'blob-level': {
456 const pointer = a.feed ? await peer.blobPointerOf(a.feed, a.oid) : await peer.blobPointer(a.oid)
457 if (!pointer) {
458 const info = await node.files.get(a.oid)
459 if (!info) return { level: null, reason: 'no such blob' }
460 const fits = (info.size || 0) <= (a.maxBytes || Infinity)
461 return fits
462 ? { level: { oid: a.oid, w: 0, h: 0, size: info.size, by: AUTHOR }, reason: 'single level' }
463 : { level: null, reason: 'the only level is ' + info.size + ' bytes against a ceiling of ' + a.maxBytes }
464 }
465 return selectLevel(pointer, { maxBytes: a.maxBytes || Infinity, maxEdge: a.maxEdge || Infinity })
466 }
355467 case 'file-get': {
356468 const r = await node.files.get(a.oid)
357469 return r || { found: false }
lib/transcode.js +108 -0
……@@ -0,0 +1,108 @@
1// transcode.js — deriving the video rungs of a mip pyramid with the SYSTEM ffmpeg.
2//
3// ⛔ A SYSTEM BINARY, not a native addon, and that is the measured choice rather than a default.
4// SOCIAL-MODEL §6.6.5: hypernote already ships this exact shape (`lib/media/video-processor.js`
5// spawns `ffmpeg`), and `bare-ffmpeg`'s android-arm64 prebuild is 29 MB against an APK that
6// hyper-social #39 fought from 527 MB down to 117 MB. It is "a capability purchase, not a
7// requirement" — buy it where the ladder is actually produced, which today is a server.
8//
9// ⛔ SO THE PYRAMID IS ALLOWED TO BE PARTIAL (§6.6.4). A phone posts one level; a peer that HAS an
10// encoder derives the rest later. That is why every level records who made it: a level this file
11// produces is `derived:`, never the author's, and §8's "never re-encode a record you did not author"
12// is the rule it exists to keep enforceable.
13//
14// ⚠ Video is the case §6.6.5 says still needs a decision on handsets. This solves the server case
15// only, and says so rather than implying the whole problem is closed.
16
17import { spawn } from 'bare-subprocess'
18import fs from '#node:fs'
19import { join } from '#node:path'
20import os from '#node:os'
21
22// ⛔ INJECTABLE, like every other module in this fleet that touches the outside: a routing surface
23// is testable with no port bound because the network is a parameter, and spawning is the same kind
24// of edge. It also matters concretely — a test that really spawns ffmpeg passes and then holds the
25// runner open for ever, so a suite containing one cannot be the suite people run.
26//
27// ⚠ The projection layer is deliberately not named here, even in prose: test/boundary.test.js scans
28// this directory for its vocabulary, and it caught this comment. That is the test working.
29export const systemRun = (cmd, args) => new Promise((resolve) => {
30 let out = ''
31 let err = ''
32 let proc
33 try {
34 proc = spawn(cmd, args)
35 } catch (e) {
36 // ⛔ A MISSING BINARY IS AN ANSWER, not a crash. A node without ffmpeg simply cannot derive a
37 // level, and the caller must be able to say that plainly.
38 return resolve({ code: -1, out: '', err: 'cannot run ' + cmd + ': ' + ((e && e.message) || e) })
39 }
40 proc.stdout && proc.stdout.on('data', (c) => { out += c })
41 proc.stderr && proc.stderr.on('data', (c) => { err += c })
42 proc.on('error', (e) => resolve({ code: -1, out, err: (e && e.message) || String(e) }))
43 // ⚠ `close`, not `exit`. exit fires when the child dies; close fires once its stdio has drained,
44 // and resolving on the earlier one left the streams open — the suite passed and then hung for
45 // five minutes with nothing to say it had finished.
46 proc.on('close', (code) => {
47 // ⛔ DESTROY THE PIPES. A closed child still leaves its stdio handles registered with the loop,
48 // and bare will not exit while they are open — the suite passed and then hung, which reads
49 // exactly like a failure and is not one.
50 for (const st of [proc.stdout, proc.stderr, proc.stdin]) { try { st && st.destroy() } catch {} }
51 resolve({ code, out, err })
52 })
53})
54
55export async function available ({ ffmpegPath = 'ffmpeg', ffprobePath = 'ffprobe', run = systemRun } = {}) {
56 const a = await run(ffmpegPath, ['-version'])
57 const b = await run(ffprobePath, ['-version'])
58 return a.code === 0 && b.code === 0
59}
60
61/** `{ width, height, duration }` or null. Never throws. */
62export async function probe (path, { ffprobePath = 'ffprobe', run = systemRun } = {}) {
63 const r = await run(ffprobePath, [
64 '-v', 'error', '-select_streams', 'v:0',
65 '-show_entries', 'stream=width,height', '-show_entries', 'format=duration',
66 '-of', 'json', path
67 ])
68 if (r.code !== 0) return null
69 try {
70 const j = JSON.parse(r.out)
71 const s = (j.streams && j.streams[0]) || {}
72 const width = Number(s.width) || 0
73 const height = Number(s.height) || 0
74 // ⛔ NO VIDEO STREAM IS NOT A ZERO-SIZED VIDEO. Returning {width:0,height:0} let an audio file or
75 // a corrupt container look like a probe that succeeded, and planLevels would then answer "no
76 // rungs" for the same reason it answers that for a tiny clip — two very different facts wearing
77 // one shape.
78 if (!width || !height) return null
79 return { width, height, duration: Number(j.format && j.format.duration) || 0 }
80 } catch { return null }
81}
82
83/**
84 * Encode ONE rung. Resolves `{ path, width, height }` or null — never throws.
85 *
86 * ⚠ `-2` on the width keeps the aspect and rounds to an even number, which h264 requires; asking
87 * for an odd width fails at encode time with a message about chroma that reads like a corrupt file.
88 */
89export async function encodeLevel (srcPath, level, { ffmpegPath = 'ffmpeg', dir = null, run = systemRun, stat = null } = {}) {
90 const out = join(dir || os.tmpdir(), 'favela-level-' + level.name + '-' + Date.now() + '.mp4')
91 const r = await run(ffmpegPath, [
92 '-y', '-i', srcPath,
93 '-vf', 'scale=-2:' + level.edge,
94 '-c:v', 'libx264', '-profile:v', level.profile,
95 '-b:v', level.bitrate, '-maxrate', level.maxrate, '-bufsize', level.maxrate,
96 '-c:a', 'aac', '-b:a', '128k',
97 // faststart so a player can begin before the whole file arrives — the same reason HLS exists.
98 '-movflags', '+faststart',
99 out
100 ])
101 if (r.code !== 0) { try { fs.unlinkSync(out) } catch {}; return null }
102 let size = 0
103 try { size = stat ? stat(out) : fs.statSync(out).size } catch { return null }
104 if (!size) { try { fs.unlinkSync(out) } catch {}; return null }
105 return { path: out, width: 0, height: level.edge, size }
106}
107
108export default { available, probe, encodeLevel, systemRun }
package.json +4 -2
……@@ -1,10 +1,11 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.49",
3 "version": "0.0.50",
44 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
7 "test": "brittle-bare test/*.test.js"
7 "test": "brittle-bare test/*.test.js",
8 "test:media": "brittle-bare test/transcode.spawn.js"
89 },
910 "imports": {
1011 "#node:fs": {
……@@ -37,6 +38,7 @@
3738 "bare-os": "^3.9.3",
3839 "bare-path": "^3.1.2",
3940 "bare-process": "^4.5.1",
41 "bare-subprocess": "^6.1.0",
4042 "bip39-mnemonic": "^2.2.0",
4143 "blind-pairing": "^2.3.1",
4244 "compact-encoding": "^3.3.0",
test/profile.test.js +37 -0
……@@ -319,3 +319,40 @@ test('the banner travels in the published record, not a local registry', async (
319319 const rec = await p.feed.get('profile', 'self')
320320 t.is(rec.frontmatter.banner, 'c'.repeat(64), '⛔ in the FEED — a registry does not replicate')
321321})
322
323// ── the mip pointer ───────────────────────────────────────────────────────────────────────────
324//
325// ⛔ It rides the FEED, not a local index. A bridge choosing a level holds this feed and nothing
326// else; a pointer kept only on the authoring node would be invisible to exactly the reader it
327// exists for. Same reasoning that put the profile in the feed rather than a registry.
328test('a blob pointer travels in the feed and reads back', async (t) => {
329 const p = await mkPeer()
330 t.teardown(() => p.close())
331
332 const pointer = {
333 mimeType: 'image/png',
334 size: 900,
335 levels: [
336 { oid: 'a'.repeat(64), w: 2000, h: 1500, size: 900, by: 'author' },
337 { oid: 'b'.repeat(64), w: 640, h: 480, size: 200, by: 'derived:ap-bridge' }
338 ]
339 }
340 await p.putBlobPointer('a'.repeat(64), pointer)
341 await p.update()
342
343 const back = await p.blobPointer('a'.repeat(64))
344 t.is(back.levels.length, 2, 'both levels survive the round trip')
345 t.is(back.levels[0].by, 'author')
346 t.is(back.levels[1].by, 'derived:ap-bridge', '⛔ who derived it survives too, or the rule is unenforceable')
347
348 const rec = await p.feed.get('blob', 'a'.repeat(64))
349 t.ok(rec, 'and it is a record in the feed, which is what makes it replicate')
350})
351
352// ⛔ record-vocabulary §3.5 calls today's avatar the single-level special case.
353test('a blob written before levels existed reads as absent, not broken', async (t) => {
354 const p = await mkPeer()
355 t.teardown(() => p.close())
356 t.is(await p.blobPointer('f'.repeat(64)), null,
357 '⛔ null means one level — a caller must not read it as a missing asset')
358})
test/transcode.spawn.js +63 -0
……@@ -0,0 +1,63 @@
1// The video rungs — real ffmpeg, real bytes. Skips itself on a node without an encoder, because
2// §6.6.4 says a node without one is an ordinary node and the pyramid is allowed to be partial.
3import test from 'brittle'
4import fs from '#node:fs'
5import os from '#node:os'
6import { join } from '#node:path'
7import { available, probe, encodeLevel } from '../lib/transcode.js'
8import { planLevels, VIDEO_LEVELS } from 'favela-markdown/lib/levels.js'
9
10const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-transcode-'))
11
12// A real 720p clip, generated rather than fixtured — a checked-in binary would be the only opaque
13// thing in the suite, and ffmpeg is already required for the test to mean anything.
14async function makeClip (dir) {
15 const { spawn } = await import('bare-subprocess')
16 const out = join(dir, 'src.mp4')
17 await new Promise((resolve) => {
18 const p = spawn('ffmpeg', ['-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=15:duration=1',
19 '-c:v', 'libx264', '-pix_fmt', 'yuv420p', out])
20 // ⛔ DRAIN BOTH PIPES. ffmpeg writes its whole progress report to stderr; with nothing reading
21 // it the pipe fills and the child blocks for ever. Resolve on `close` so the stdio has drained
22 // before we move on — `exit` fires while it is still open, which left the runner hanging.
23 p.stdout && p.stdout.on('data', () => {})
24 p.stderr && p.stderr.on('data', () => {})
25 p.on('close', resolve)
26 p.on('error', resolve)
27 })
28 return fs.existsSync(out) ? out : null
29}
30
31test('★ the video ladder: probe, then encode the rungs below the source', async (t) => {
32 if (!await available()) {
33 t.pass('no ffmpeg on this node — skipped, which is a supported state and not a failure')
34 return
35 }
36 const dir = tmp()
37 const src = await makeClip(dir)
38 t.ok(src, 'generated a source clip')
39
40 const dims = await probe(src)
41 t.is(dims.height, 720, 'probed the real height')
42 t.is(dims.width, 1280)
43
44 // ⛔ NEVER UPSCALE — 720p source yields the 480p rung and nothing above it.
45 const want = planLevels({ mimeType: 'video/mp4', width: dims.width, height: dims.height })
46 t.alike(want.map((l) => l.name), ['480p'], 'only the rung genuinely below the source')
47
48 const enc = await encodeLevel(src, want[0])
49 t.ok(enc && enc.size > 0, 'encoded a real file')
50 const back = await probe(enc.path)
51 t.is(back.height, 480, '⛔ and it is actually 480 tall, not merely named so')
52 t.ok(back.width % 2 === 0, 'even width — h264 refuses an odd one')
53 // ⚠ NOT asserted to be smaller. A synthetic testsrc compresses to ~128 kb/s, so a fixed-bitrate
54 // 480p rung comes out LARGER — which is real behaviour, not a test artifact, and is exactly why
55 // blob-derive drops a rung that does not beat its source. Asserting "smaller" here would have
56 // been asserting something ffmpeg never promised.
57 t.ok(enc.size > 0, 'and it has bytes; whether it BEATS the source is what blob-derive checks')
58})
59
60test('a node without ffmpeg answers rather than throwing', async (t) => {
61 const r = await probe('/nonexistent.mp4', { ffprobePath: 'definitely-not-a-real-binary' })
62 t.is(r, null, '⛔ null, not an exception — a missing encoder is a state, not a crash')
63})
test/transcode.test.js +46 -0
……@@ -0,0 +1,46 @@
1// The video rungs, offline. No ffmpeg, no spawn, no hang.
2//
3// ⛔ THE REAL ROUND TRIP LIVES IN `test/transcode.spawn.js`, run by `npm run test:media`. A test that
4// actually spawns ffmpeg passes and then holds brittle's runner open for ever — verified: the whole
5// 346-test suite went from finishing in seconds to being killed at seven minutes. A suite nobody can
6// run is worse than one that mocks a boundary, so the boundary is injected and the spawning test is
7// opt-in. It has been run and it works: 1280x720 in, a genuine 480-tall even-width file out.
8import test from 'brittle'
9import { probe, encodeLevel, available } from '../lib/transcode.js'
10
11const ok = (out) => async () => ({ code: 0, out, err: '' })
12const fails = async () => ({ code: 1, out: '', err: 'boom' })
13
14test('probe reads the real stream dimensions out of ffprobe json', async (t) => {
15 const run = ok(JSON.stringify({ streams: [{ width: 1920, height: 1080 }], format: { duration: '12.5' } }))
16 t.alike(await probe('/x.mp4', { run }), { width: 1920, height: 1080, duration: 12.5 })
17})
18
19test('⛔ probe answers null rather than throwing on anything unusable', async (t) => {
20 t.is(await probe('/x.mp4', { run: fails }), null, 'a non-zero exit')
21 t.is(await probe('/x.mp4', { run: ok('not json') }), null, 'output that is not json')
22 t.is(await probe('/x.mp4', { run: ok('{}') }), null, 'json with no stream')
23 t.is(await available({ run: fails }), false, '⛔ and a node without an encoder is a state, not a crash')
24})
25
26test('★ encodeLevel asks for an EVEN width and the level’s real height', async (t) => {
27 let args = null
28 const run = async (cmd, a) => { args = a; return { code: 0, out: '', err: '' } }
29 const r = await encodeLevel('/src.mp4', { name: '480p', edge: 480, bitrate: '1000k', maxrate: '1200k', profile: 'baseline' },
30 { run, stat: () => 4242 })
31
32 const vf = args[args.indexOf('-vf') + 1]
33 t.is(vf, 'scale=-2:480', '⛔ -2 keeps the aspect AND rounds even — h264 refuses an odd width, and')
34 t.is(args[args.indexOf('-profile:v') + 1], 'baseline', 'the rung carries its own profile')
35 t.is(args[args.indexOf('-b:v') + 1], '1000k', 'and its own bitrate')
36 t.ok(args.includes('+faststart'), 'faststart, so a player can begin before the file has arrived')
37 t.is(r.size, 4242)
38 t.is(r.height, 480)
39})
40
41test('encodeLevel returns null when ffmpeg fails or writes nothing', async (t) => {
42 t.is(await encodeLevel('/src.mp4', { name: '480p', edge: 480, bitrate: '1k', maxrate: '1k', profile: 'baseline' },
43 { run: fails, stat: () => 10 }), null, 'a non-zero exit')
44 t.is(await encodeLevel('/src.mp4', { name: '480p', edge: 480, bitrate: '1k', maxrate: '1k', profile: 'baseline' },
45 { run: ok(''), stat: () => 0 }), null, '⛔ and a zero-byte file is not a level')
46})