OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / media / video-processor.js

355 lines · javascript ref:
import { spawn } from 'bare-subprocess'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'

// Quality levels for HLS (used later in Task 12)
export const HLS_LEVELS = [
  { name: '480p', height: 480, bitrate: '1000k', maxrate: '1200k', profile: 'baseline' },
  { name: '720p', height: 720, bitrate: '2500k', maxrate: '3000k', profile: 'main' },
  { name: '1080p', height: 1080, bitrate: '5000k', maxrate: '6000k', profile: 'high' }
]

export class VideoProcessor {
  constructor (mediaStore, opts = {}) {
    this.mediaStore = mediaStore
    this._queue = []
    this._processing = false
    this._tmpBase = opts.tmpDir || path.join(os.tmpdir(), 'hypernote-video')
    this._ffmpegPath = opts.ffmpegPath || 'ffmpeg'
    this._ffprobePath = opts.ffprobePath || 'ffprobe'
  }

  /**
   * Run a command and return { stdout, stderr, code }
   */
  _exec (cmd, args) {
    return new Promise((resolve, reject) => {
      const proc = spawn(cmd, args)
      const stdout = []
      const stderr = []

      proc.stdout.on('data', (chunk) => stdout.push(chunk))
      proc.stderr.on('data', (chunk) => stderr.push(chunk))

      proc.on('exit', (code) => {
        resolve({
          code,
          stdout: Buffer.concat(stdout).toString(),
          stderr: Buffer.concat(stderr).toString()
        })
      })

      proc.on('error', reject)
    })
  }

  /**
   * Safely parse ffprobe frame rate string like "30/1" or "25"
   */
  _parseFps (str) {
    if (!str) return 30
    const parts = str.split('/')
    const num = Number(parts[0])
    const den = parts.length > 1 ? Number(parts[1]) : 1
    if (!num || !den || !isFinite(num / den)) return 30
    return Math.round(num / den)
  }

  /**
   * Probe a video file — returns { width, height, duration, codec, fps }
   */
  async probe (filePath) {
    const result = await this._exec(this._ffprobePath, [
      '-v', 'quiet',
      '-print_format', 'json',
      '-show_streams',
      '-show_format',
      filePath
    ])

    if (result.code !== 0) {
      throw new Error('ffprobe failed: ' + result.stderr)
    }

    const data = JSON.parse(result.stdout)
    const video = data.streams.find(s => s.codec_type === 'video')
    if (!video) throw new Error('No video stream found')

    const duration = parseFloat(data.format.duration || video.duration || '0')
    const fps = this._parseFps(video.r_frame_rate)

    return {
      width: video.width,
      height: video.height,
      duration,
      codec: video.codec_name,
      fps
    }
  }

  /**
   * Extract a poster frame at the given time (seconds) as JPEG buffer.
   */
  async extractPoster (filePath, timeSeconds) {
    const time = timeSeconds || 2
    const tmpPoster = path.join(this._tmpBase, 'poster-' + Date.now() + '.jpg')

    await fs.promises.mkdir(this._tmpBase, { recursive: true })

    const result = await this._exec(this._ffmpegPath, [
      '-y',
      '-ss', String(time),
      '-i', filePath,
      '-vframes', '1',
      '-q:v', '2',
      tmpPoster
    ])

    if (result.code !== 0) {
      throw new Error('Poster extraction failed: ' + result.stderr)
    }

    const buf = await fs.promises.readFile(tmpPoster)
    await fs.promises.unlink(tmpPoster).catch(() => {})
    return buf
  }

  /**
   * Write a MediaStore blob to a temp file (ffmpeg needs file paths).
   * Returns the temp file path. Caller must clean up.
   */
  async _writeTempFile (hash, ext) {
    const buf = await this.mediaStore.get(hash)
    if (!buf) throw new Error('Blob not found: ' + hash)

    await fs.promises.mkdir(this._tmpBase, { recursive: true })
    const tmpPath = path.join(this._tmpBase, hash.substring(0, 16) + (ext || '.mp4'))
    await fs.promises.writeFile(tmpPath, buf)
    return tmpPath
  }

  /**
   * Process a video: probe it, extract poster, store poster in MediaStore.
   * Returns { probe, posterHash }
   */
  async processBasic (originalHash) {
    // Write to temp file
    const info = await this.mediaStore.getInfo(originalHash)
    const ext = info && info.mimeType === 'video/webm' ? '.webm' : '.mp4'
    const tmpPath = await this._writeTempFile(originalHash, ext)

    try {
      // Probe
      const probeResult = await this.probe(tmpPath)

      // Extract poster
      const posterTime = Math.min(2, probeResult.duration * 0.1)
      const posterBuf = await this.extractPoster(tmpPath, posterTime)
      const posterHash = await this.mediaStore.put(posterBuf, 'image/jpeg')

      return { probe: probeResult, posterHash }
    } finally {
      await fs.promises.unlink(tmpPath).catch(() => {})
    }
  }

  /**
   * Enqueue a video for full processing (probe + poster + HLS).
   * Returns a promise that resolves when processing is complete.
   */
  enqueue (originalHash) {
    return new Promise((resolve, reject) => {
      this._queue.push({ originalHash, resolve, reject })
      this._processNext()
    })
  }

  async _processNext () {
    if (this._processing || this._queue.length === 0) return
    this._processing = true

    const { originalHash, resolve, reject } = this._queue.shift()

    try {
      // Write temp file
      const info = await this.mediaStore.getInfo(originalHash)
      const ext = info && info.mimeType === 'video/webm' ? '.webm' : '.mp4'
      const tmpPath = await this._writeTempFile(originalHash, ext)

      try {
        // Probe
        const probeResult = await this.probe(tmpPath)

        // Poster
        const posterTime = Math.min(2, probeResult.duration * 0.1)
        const posterBuf = await this.extractPoster(tmpPath, posterTime)
        const posterHash = await this.mediaStore.put(posterBuf, 'image/jpeg')

        // HLS transcode
        const hls = await this.transcodeHLS(tmpPath, probeResult)

        // Write video index
        const videoIndex = {
          status: 'ready',
          poster: posterHash,
          manifest: hls.manifestHash,
          width: probeResult.width,
          height: probeResult.height,
          duration: probeResult.duration,
          codec: probeResult.codec,
          fps: probeResult.fps,
          levels: hls.levels.map(l => ({
            name: l.name,
            height: l.height,
            bitrate: l.bitrate,
            m3u8: l.m3u8Hash,
            segments: l.segmentHashes.map(s => ({ file: s.file, hash: s.hash }))
          })),
          processedAt: Date.now()
        }

        await this.mediaStore._index.put('video:' + originalHash, videoIndex)

        resolve({ probe: probeResult, posterHash, hls })
      } finally {
        await fs.promises.unlink(tmpPath).catch(() => {})
      }
    } catch (err) {
      await this.mediaStore._index.put('video:' + originalHash, {
        status: 'failed',
        error: err.message,
        processedAt: Date.now()
      }).catch(() => {})
      reject(err)
    } finally {
      this._processing = false
      this._processNext()
    }
  }

  /**
   * Transcode a video file to HLS at multiple quality levels.
   * Only generates levels <= source resolution (never upscale).
   * Returns { levels: [{ name, m3u8Hash, segmentHashes }], manifestHash }
   */
  async transcodeHLS (filePath, probeInfo) {
    const srcHeight = probeInfo.height

    // Filter levels to not exceed source resolution
    let levels = HLS_LEVELS.filter(l => l.height <= srcHeight)
    if (levels.length === 0) {
      // Source is smaller than 480p — generate single native-res variant
      levels = [{
        name: srcHeight + 'p',
        height: srcHeight,
        bitrate: '1000k',
        maxrate: '1200k',
        profile: 'baseline'
      }]
    }

    const hlsDir = path.join(this._tmpBase, 'hls-' + Date.now())
    await fs.promises.mkdir(hlsDir, { recursive: true })

    const levelResults = []

    try {
      for (const level of levels) {
        const levelDir = path.join(hlsDir, level.name)
        await fs.promises.mkdir(levelDir, { recursive: true })

        const playlistPath = path.join(levelDir, level.name + '.m3u8')
        const segmentPattern = path.join(levelDir, level.name + '_%03d.ts')
        const bufsize = parseInt(level.bitrate) * 2 + 'k'

        const result = await this._exec(this._ffmpegPath, [
          '-y', '-i', filePath,
          '-vf', 'scale=-2:' + level.height,
          '-c:v', 'libx264', '-profile:v', level.profile,
          '-b:v', level.bitrate, '-maxrate', level.maxrate, '-bufsize', bufsize,
          '-c:a', 'aac', '-b:a', '128k', '-ac', '2',
          '-hls_time', '6',
          '-hls_list_size', '0',
          '-hls_segment_filename', segmentPattern,
          '-f', 'hls',
          playlistPath
        ])

        if (result.code !== 0) {
          throw new Error('HLS transcode failed for ' + level.name + ': ' + result.stderr.slice(-500))
        }

        // Read and store all .ts segment files
        const segmentHashes = []
        const files = await fs.promises.readdir(levelDir)
        const tsFiles = files.filter(f => f.endsWith('.ts')).sort()

        for (const tsFile of tsFiles) {
          const segBuf = await fs.promises.readFile(path.join(levelDir, tsFile))
          const segHash = await this.mediaStore.put(segBuf, 'video/mp2t')
          segmentHashes.push({ file: tsFile, hash: segHash })
        }

        // Read the .m3u8 playlist
        const m3u8Content = await fs.promises.readFile(playlistPath, 'utf-8')
        const m3u8Buf = Buffer.from(m3u8Content, 'utf-8')
        const m3u8Hash = await this.mediaStore.put(m3u8Buf, 'application/vnd.apple.mpegurl')

        levelResults.push({
          name: level.name,
          height: level.height,
          bitrate: level.bitrate,
          m3u8Hash,
          segmentHashes
        })
      }

      // Build master.m3u8
      const masterLines = ['#EXTM3U']
      for (const level of levelResults) {
        const bandwidth = parseInt(level.bitrate) * 1000
        masterLines.push('#EXT-X-STREAM-INF:BANDWIDTH=' + bandwidth + ',RESOLUTION=' + Math.round(level.height * probeInfo.width / probeInfo.height / 2) * 2 + 'x' + level.height)
        // Reference the level playlist by its hash (will be rewritten at serve time)
        masterLines.push(level.m3u8Hash)
      }
      const masterContent = masterLines.join('\n') + '\n'
      const masterBuf = Buffer.from(masterContent, 'utf-8')
      const manifestHash = await this.mediaStore.put(masterBuf, 'application/vnd.apple.mpegurl')

      return { levels: levelResults, manifestHash }
    } finally {
      // Clean up temp HLS directory
      await this._rmrf(hlsDir)
    }
  }

  /**
   * Recursively delete a temporary directory.
   */
  async _rmrf (dir) {
    try {
      const entries = await fs.promises.readdir(dir, { withFileTypes: true })
      for (const entry of entries) {
        const full = path.join(dir, entry.name)
        if (entry.isDirectory()) {
          await this._rmrf(full)
        } else {
          await fs.promises.unlink(full)
        }
      }
      await fs.promises.rmdir(dir)
    } catch {}
  }

  /**
   * Get video processing status.
   */
  async getStatus (originalHash) {
    const entry = await this.mediaStore._index.get('video:' + originalHash)
    return entry ? entry.value : null
  }
}

export default VideoProcessor