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