| … | … | @@ -84,6 +84,10 @@ export const PROFILE = 'profile' |
| 84 | 84 | // Which networks this identity publishes to. A PREFERENCE, in the feed, so it follows the identity |
| 85 | 85 | // to every device rather than living on whichever machine happened to set it. |
| 86 | 86 | export 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. | |
| 90 | export const BLOB = 'blob' | |
| 87 | 91 | export const SELF = 'self' |
| 88 | 92 | |
| 89 | 93 | const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex')) |
| … | … | @@ -282,6 +286,44 @@ export class Peer { |
| 282 | 286 | } |
| 283 | 287 | } |
| 284 | 288 | |
| 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 | ||
| 285 | 327 | /** Merges: a write naming only `networks` leaves the credential alone, and the reverse. */ |
| 286 | 328 | async setReach ({ networks, credential } = {}) { |
| 287 | 329 | await this.feed.update() |
| … | … | @@ -14,6 +14,9 @@ import { Peer, LIKE_VALUE } from './peer.js' |
| 14 | 14 | import { linkMentions } from './mentions.js' |
| 15 | 15 | import { graphOf } from './graph.js' |
| 16 | 16 | import { recordName } from './name.js' |
| 17 | // ⛔ ONE ladder, owned by the record vocabulary that defines it (record-vocabulary §3.5). | |
| 18 | import { makePointer, AUTHOR, selectLevel, planLevels, derivedBy } from 'favela-markdown/lib/levels.js' | |
| 19 | import { available as ffmpegAvailable, probe, encodeLevel } from './transcode.js' | |
| 17 | 20 | |
| 18 | 21 | export function createServer (node, sockPath, { onError = () => {} } = {}) { |
| 19 | 22 | // 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) { |
| 352 | 355 | } |
| 353 | 356 | // A file embedded in a record: ``. No cap — the overlay chunks. |
| 354 | 357 | 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 `` | |
| 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 | } | |
| 355 | 467 | case 'file-get': { |
| 356 | 468 | const r = await node.files.get(a.oid) |
| 357 | 469 | return r || { found: false } |
| … | … | @@ -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 | ||
| 17 | import { spawn } from 'bare-subprocess' | |
| 18 | import fs from '#node:fs' | |
| 19 | import { join } from '#node:path' | |
| 20 | import 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. | |
| 29 | export 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 | ||
| 55 | export 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. */ | |
| 62 | export 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 | */ | |
| 89 | export 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 | ||
| 108 | export default { available, probe, encodeLevel, systemRun } |
| … | … | @@ -1,10 +1,11 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.49", | |
| 3 | "version": "0.0.50", | |
| 4 | 4 | "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| 7 | "test": "brittle-bare test/*.test.js" | |
| 7 | "test": "brittle-bare test/*.test.js", | |
| 8 | "test:media": "brittle-bare test/transcode.spawn.js" | |
| 8 | 9 | }, |
| 9 | 10 | "imports": { |
| 10 | 11 | "#node:fs": { |
| … | … | @@ -37,6 +38,7 @@ |
| 37 | 38 | "bare-os": "^3.9.3", |
| 38 | 39 | "bare-path": "^3.1.2", |
| 39 | 40 | "bare-process": "^4.5.1", |
| 41 | "bare-subprocess": "^6.1.0", | |
| 40 | 42 | "bip39-mnemonic": "^2.2.0", |
| 41 | 43 | "blind-pairing": "^2.3.1", |
| 42 | 44 | "compact-encoding": "^3.3.0", |
| … | … | @@ -319,3 +319,40 @@ test('the banner travels in the published record, not a local registry', async ( |
| 319 | 319 | const rec = await p.feed.get('profile', 'self') |
| 320 | 320 | t.is(rec.frontmatter.banner, 'c'.repeat(64), '⛔ in the FEED — a registry does not replicate') |
| 321 | 321 | }) |
| 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. | |
| 328 | test('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. | |
| 353 | test('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 | }) |
| … | … | @@ -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. | |
| 3 | import test from 'brittle' | |
| 4 | import fs from '#node:fs' | |
| 5 | import os from '#node:os' | |
| 6 | import { join } from '#node:path' | |
| 7 | import { available, probe, encodeLevel } from '../lib/transcode.js' | |
| 8 | import { planLevels, VIDEO_LEVELS } from 'favela-markdown/lib/levels.js' | |
| 9 | ||
| 10 | const 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. | |
| 14 | async 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 | ||
| 31 | test('★ 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 | ||
| 60 | test('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 | }) |
| … | … | @@ -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. | |
| 8 | import test from 'brittle' | |
| 9 | import { probe, encodeLevel, available } from '../lib/transcode.js' | |
| 10 | ||
| 11 | const ok = (out) => async () => ({ code: 0, out, err: '' }) | |
| 12 | const fails = async () => ({ code: 1, out: '', err: 'boom' }) | |
| 13 | ||
| 14 | test('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 | ||
| 19 | test('⛔ 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 | ||
| 26 | test('★ 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 | ||
| 41 | test('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 | }) |