import test from 'brittle'
import Corestore from 'corestore'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import { MediaStore } from '../../lib/media-store.js'
import { VideoProcessor } from '../../lib/media/video-processor.js'
import { spawn } from 'bare-subprocess'
function tmpdir () {
const dir = path.join(os.tmpdir(), 'hn-video-test-' + Date.now() + '-' + Math.random().toString(36).slice(2))
fs.mkdirSync(dir, { recursive: true })
return dir
}
function rmrf (dir) {
try {
for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f)
const stat = fs.statSync(fp)
if (stat.isDirectory()) rmrf(fp)
else fs.unlinkSync(fp)
}
fs.rmdirSync(dir)
} catch {}
}
// Generate a tiny test video (2s, 320x240, solid color)
function generateTestVideo (outputPath) {
return new Promise((resolve, reject) => {
const proc = spawn('ffmpeg', [
'-y', '-f', 'lavfi', '-i', 'color=c=blue:s=320x240:d=2',
'-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=stereo',
'-t', '2',
'-c:v', 'libx264', '-profile:v', 'baseline', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '64k',
'-shortest',
outputPath
])
const stderr = []
proc.stderr.on('data', (c) => stderr.push(c))
proc.on('exit', (code) => {
if (code !== 0) reject(new Error('ffmpeg gen failed: ' + Buffer.concat(stderr).toString()))
else resolve()
})
proc.on('error', reject)
})
}
test('VideoProcessor - probe returns video metadata', async (t) => {
const dir = tmpdir()
try {
const videoPath = path.join(dir, 'test.mp4')
await generateTestVideo(videoPath)
const store = new Corestore(path.join(dir, 'store'))
const media = new MediaStore(store)
await media.ready()
const vp = new VideoProcessor(media, { tmpDir: path.join(dir, 'tmp') })
const info = await vp.probe(videoPath)
t.is(info.width, 320)
t.is(info.height, 240)
t.ok(info.duration >= 1.5 && info.duration <= 2.5, 'duration ~2s')
t.is(info.codec, 'h264')
t.ok(info.fps > 0, 'fps > 0')
await store.close()
} finally {
rmrf(dir)
}
})
test('VideoProcessor - extractPoster returns JPEG buffer', async (t) => {
const dir = tmpdir()
try {
const videoPath = path.join(dir, 'test.mp4')
await generateTestVideo(videoPath)
const store = new Corestore(path.join(dir, 'store'))
const media = new MediaStore(store)
await media.ready()
const vp = new VideoProcessor(media, { tmpDir: path.join(dir, 'tmp') })
const posterBuf = await vp.extractPoster(videoPath, 1)
t.ok(posterBuf.length > 0, 'poster buffer not empty')
// JPEG magic bytes
t.is(posterBuf[0], 0xFF, 'JPEG SOI byte 0')
t.is(posterBuf[1], 0xD8, 'JPEG SOI byte 1')
await store.close()
} finally {
rmrf(dir)
}
})
test('VideoProcessor - processBasic stores poster and returns probe', async (t) => {
const dir = tmpdir()
try {
const videoPath = path.join(dir, 'test.mp4')
await generateTestVideo(videoPath)
const store = new Corestore(path.join(dir, 'store'))
const media = new MediaStore(store)
await media.ready()
// Store the test video in MediaStore
const videoBuf = await fs.promises.readFile(videoPath)
const hash = await media.put(videoBuf, 'video/mp4')
const vp = new VideoProcessor(media, { tmpDir: path.join(dir, 'tmp') })
const result = await vp.processBasic(hash)
t.ok(result.probe, 'probe result exists')
t.is(result.probe.width, 320)
t.is(result.probe.height, 240)
t.ok(result.posterHash, 'posterHash exists')
t.is(result.posterHash.length, 64, 'posterHash is SHA-256 hex')
// Verify poster was stored
const posterBuf = await media.get(result.posterHash)
t.ok(posterBuf, 'poster buffer retrievable')
t.is(posterBuf[0], 0xFF, 'poster is JPEG')
await store.close()
} finally {
rmrf(dir)
}
})
test('VideoProcessor - enqueue processes and writes video index', async (t) => {
const dir = tmpdir()
try {
const videoPath = path.join(dir, 'test.mp4')
await generateTestVideo(videoPath)
const store = new Corestore(path.join(dir, 'store'))
const media = new MediaStore(store)
await media.ready()
const videoBuf = await fs.promises.readFile(videoPath)
const hash = await media.put(videoBuf, 'video/mp4')
const vp = new VideoProcessor(media, { tmpDir: path.join(dir, 'tmp') })
const result = await vp.enqueue(hash)
t.ok(result.probe, 'probe result exists')
t.ok(result.posterHash, 'posterHash exists')
t.ok(result.hls, 'hls result exists')
t.ok(result.hls.manifestHash, 'hls manifestHash exists')
// Check video index was written
const status = await vp.getStatus(hash)
t.is(status.status, 'ready')
t.is(status.width, 320)
t.is(status.height, 240)
t.ok(status.poster, 'poster hash in index')
t.ok(status.manifest, 'manifest hash in index')
t.ok(status.levels, 'levels in index')
t.ok(status.levels.length >= 1, 'at least one level')
t.ok(status.levels[0].segments.length > 0, 'segments in level')
// Segments are now {file, hash} objects
t.ok(status.levels[0].segments[0].file, 'segment has file property')
t.ok(status.levels[0].segments[0].hash, 'segment has hash property')
t.is(status.levels[0].segments[0].hash.length, 64, 'segment hash is SHA-256 hex')
t.ok(status.duration > 0, 'duration > 0')
await store.close()
} finally {
rmrf(dir)
}
})
test('VideoProcessor - transcodeHLS generates segments and master playlist', async (t) => {
const dir = tmpdir()
try {
const videoPath = path.join(dir, 'test.mp4')
await generateTestVideo(videoPath)
const store = new Corestore(path.join(dir, 'store'))
const media = new MediaStore(store)
await media.ready()
const vp = new VideoProcessor(media, { tmpDir: path.join(dir, 'tmp') })
// Probe first
const probeInfo = await vp.probe(videoPath)
// Transcode (320x240 source - only generates 240p since below 480p)
const hls = await vp.transcodeHLS(videoPath, probeInfo)
t.ok(hls.manifestHash)
t.is(hls.manifestHash.length, 64)
t.ok(hls.levels.length >= 1)
const level = hls.levels[0]
t.ok(level.name) // '240p'
t.ok(level.m3u8Hash)
t.ok(level.segmentHashes.length > 0) // at least 1 segment for 2s video
// Verify master playlist is stored and valid
const masterBuf = await media.get(hls.manifestHash)
const masterContent = masterBuf.toString()
t.ok(masterContent.includes('#EXTM3U'))
t.ok(masterContent.includes('#EXT-X-STREAM-INF'))
// Verify a segment is stored and non-empty
const firstSeg = level.segmentHashes[0]
const segBuf = await media.get(firstSeg.hash)
t.ok(segBuf.length > 0)
// Verify level playlist is stored
const m3u8Buf = await media.get(level.m3u8Hash)
t.ok(m3u8Buf.toString().includes('#EXTINF'))
await store.close()
} finally {
rmrf(dir)
}
})
test('VideoProcessor - enqueue with HLS writes full video index', async (t) => {
const dir = tmpdir()
try {
const videoPath = path.join(dir, 'test.mp4')
await generateTestVideo(videoPath)
const store = new Corestore(path.join(dir, 'store'))
const media = new MediaStore(store)
await media.ready()
const videoBuf = await fs.promises.readFile(videoPath)
const hash = await media.put(videoBuf, 'video/mp4')
const vp = new VideoProcessor(media, { tmpDir: path.join(dir, 'tmp') })
const result = await vp.enqueue(hash)
t.ok(result.probe)
t.ok(result.posterHash)
t.ok(result.hls)
t.ok(result.hls.manifestHash)
t.ok(result.hls.levels.length >= 1)
// Check video index
const status = await vp.getStatus(hash)
t.is(status.status, 'ready')
t.ok(status.poster)
t.ok(status.manifest)
t.ok(status.levels)
t.ok(status.levels.length >= 1)
t.ok(status.levels[0].segments.length > 0)
await store.close()
} finally {
rmrf(dir)
}
})