OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / test / unit / video-processor.test.js

260 lines · javascript ref:
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)
  }
})