OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 9512560

Add HLS transcoding to VideoProcessor with multi-level adaptive streaming
Adds transcodeHLS method that generates HLS segments at quality levels not exceeding source resolution, stores all .ts segments and .m3u8 playlists in MediaStore, and builds a master manifest. Updates _processNext to run HLS transcode as part of the enqueue pipeline, writing a full video index with levels, segments, and manifest hash. Includes 2 new tests (6 total). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
95125607688d8ee58fb017459baf48da525097c2
by plankatron <fleeky@gmail.com> · 2026-03-19T18:08:07.000Z

Changed files (2) +246 -13

lib/media/video-processor.js +155 -13
……@@ -172,21 +172,49 @@ export class VideoProcessor {
172172 const { originalHash, resolve, reject } = this._queue.shift()
173173
174174 try {
175 const result = await this.processBasic(originalHash)
175 // Write temp file
176 const info = await this.mediaStore.getInfo(originalHash)
177 const ext = info && info.mimeType === 'video/webm' ? '.webm' : '.mp4'
178 const tmpPath = await this._writeTempFile(originalHash, ext)
176179
177 // Store video index
178 await this.mediaStore._index.put('video:' + originalHash, {
179 status: 'ready',
180 poster: result.posterHash,
181 width: result.probe.width,
182 height: result.probe.height,
183 duration: result.probe.duration,
184 codec: result.probe.codec,
185 fps: result.probe.fps,
186 processedAt: Date.now()
187 })
180 try {
181 // Probe
182 const probeResult = await this.probe(tmpPath)
183
184 // Poster
185 const posterTime = Math.min(2, probeResult.duration * 0.1)
186 const posterBuf = await this.extractPoster(tmpPath, posterTime)
187 const posterHash = await this.mediaStore.put(posterBuf, 'image/jpeg')
188
189 // HLS transcode
190 const hls = await this.transcodeHLS(tmpPath, probeResult)
188191
189 resolve(result)
192 // Write video index
193 const videoIndex = {
194 status: 'ready',
195 poster: posterHash,
196 manifest: hls.manifestHash,
197 width: probeResult.width,
198 height: probeResult.height,
199 duration: probeResult.duration,
200 codec: probeResult.codec,
201 fps: probeResult.fps,
202 levels: hls.levels.map(l => ({
203 name: l.name,
204 height: l.height,
205 bitrate: l.bitrate,
206 m3u8: l.m3u8Hash,
207 segments: l.segmentHashes.map(s => s.hash)
208 })),
209 processedAt: Date.now()
210 }
211
212 await this.mediaStore._index.put('video:' + originalHash, videoIndex)
213
214 resolve({ probe: probeResult, posterHash, hls })
215 } finally {
216 await fs.promises.unlink(tmpPath).catch(() => {})
217 }
190218 } catch (err) {
191219 await this.mediaStore._index.put('video:' + originalHash, {
192220 status: 'failed',
……@@ -200,6 +228,120 @@ export class VideoProcessor {
200228 }
201229 }
202230
231 /**
232 * Transcode a video file to HLS at multiple quality levels.
233 * Only generates levels <= source resolution (never upscale).
234 * Returns { levels: [{ name, m3u8Hash, segmentHashes }], manifestHash }
235 */
236 async transcodeHLS (filePath, probeInfo) {
237 const srcHeight = probeInfo.height
238
239 // Filter levels to not exceed source resolution
240 let levels = HLS_LEVELS.filter(l => l.height <= srcHeight)
241 if (levels.length === 0) {
242 // Source is smaller than 480p — generate single native-res variant
243 levels = [{
244 name: srcHeight + 'p',
245 height: srcHeight,
246 bitrate: '1000k',
247 maxrate: '1200k',
248 profile: 'baseline'
249 }]
250 }
251
252 const hlsDir = path.join(this._tmpBase, 'hls-' + Date.now())
253 await fs.promises.mkdir(hlsDir, { recursive: true })
254
255 const levelResults = []
256
257 try {
258 for (const level of levels) {
259 const levelDir = path.join(hlsDir, level.name)
260 await fs.promises.mkdir(levelDir, { recursive: true })
261
262 const playlistPath = path.join(levelDir, level.name + '.m3u8')
263 const segmentPattern = path.join(levelDir, level.name + '_%03d.ts')
264 const bufsize = parseInt(level.bitrate) * 2 + 'k'
265
266 const result = await this._exec(this._ffmpegPath, [
267 '-y', '-i', filePath,
268 '-vf', 'scale=-2:' + level.height,
269 '-c:v', 'libx264', '-profile:v', level.profile,
270 '-b:v', level.bitrate, '-maxrate', level.maxrate, '-bufsize', bufsize,
271 '-c:a', 'aac', '-b:a', '128k', '-ac', '2',
272 '-hls_time', '6',
273 '-hls_list_size', '0',
274 '-hls_segment_filename', segmentPattern,
275 '-f', 'hls',
276 playlistPath
277 ])
278
279 if (result.code !== 0) {
280 throw new Error('HLS transcode failed for ' + level.name + ': ' + result.stderr.slice(-500))
281 }
282
283 // Read and store all .ts segment files
284 const segmentHashes = []
285 const files = await fs.promises.readdir(levelDir)
286 const tsFiles = files.filter(f => f.endsWith('.ts')).sort()
287
288 for (const tsFile of tsFiles) {
289 const segBuf = await fs.promises.readFile(path.join(levelDir, tsFile))
290 const segHash = await this.mediaStore.put(segBuf, 'video/mp2t')
291 segmentHashes.push({ file: tsFile, hash: segHash })
292 }
293
294 // Read the .m3u8 playlist
295 const m3u8Content = await fs.promises.readFile(playlistPath, 'utf-8')
296 const m3u8Buf = Buffer.from(m3u8Content, 'utf-8')
297 const m3u8Hash = await this.mediaStore.put(m3u8Buf, 'application/vnd.apple.mpegurl')
298
299 levelResults.push({
300 name: level.name,
301 height: level.height,
302 bitrate: level.bitrate,
303 m3u8Hash,
304 segmentHashes
305 })
306 }
307
308 // Build master.m3u8
309 const masterLines = ['#EXTM3U']
310 for (const level of levelResults) {
311 const bandwidth = parseInt(level.bitrate) * 1000
312 masterLines.push('#EXT-X-STREAM-INF:BANDWIDTH=' + bandwidth + ',RESOLUTION=' + Math.round(level.height * probeInfo.width / probeInfo.height / 2) * 2 + 'x' + level.height)
313 // Reference the level playlist by its hash (will be rewritten at serve time)
314 masterLines.push(level.m3u8Hash)
315 }
316 const masterContent = masterLines.join('\n') + '\n'
317 const masterBuf = Buffer.from(masterContent, 'utf-8')
318 const manifestHash = await this.mediaStore.put(masterBuf, 'application/vnd.apple.mpegurl')
319
320 return { levels: levelResults, manifestHash }
321 } finally {
322 // Clean up temp HLS directory
323 await this._rmrf(hlsDir)
324 }
325 }
326
327 /**
328 * Recursively delete a temporary directory.
329 */
330 async _rmrf (dir) {
331 try {
332 const entries = await fs.promises.readdir(dir, { withFileTypes: true })
333 for (const entry of entries) {
334 const full = path.join(dir, entry.name)
335 if (entry.isDirectory()) {
336 await this._rmrf(full)
337 } else {
338 await fs.promises.unlink(full)
339 }
340 }
341 await fs.promises.rmdir(dir)
342 } catch {}
343 }
344
203345 /**
204346 * Get video processing status.
205347 */
test/unit/video-processor.test.js +91 -0
……@@ -148,6 +148,8 @@ test('VideoProcessor - enqueue processes and writes video index', async (t) => {
148148
149149 t.ok(result.probe, 'probe result exists')
150150 t.ok(result.posterHash, 'posterHash exists')
151 t.ok(result.hls, 'hls result exists')
152 t.ok(result.hls.manifestHash, 'hls manifestHash exists')
151153
152154 // Check video index was written
153155 const status = await vp.getStatus(hash)
……@@ -155,6 +157,10 @@ test('VideoProcessor - enqueue processes and writes video index', async (t) => {
155157 t.is(status.width, 320)
156158 t.is(status.height, 240)
157159 t.ok(status.poster, 'poster hash in index')
160 t.ok(status.manifest, 'manifest hash in index')
161 t.ok(status.levels, 'levels in index')
162 t.ok(status.levels.length >= 1, 'at least one level')
163 t.ok(status.levels[0].segments.length > 0, 'segments in level')
158164 t.ok(status.duration > 0, 'duration > 0')
159165
160166 await store.close()
……@@ -162,3 +168,88 @@ test('VideoProcessor - enqueue processes and writes video index', async (t) => {
162168 rmrf(dir)
163169 }
164170})
171
172test('VideoProcessor - transcodeHLS generates segments and master playlist', async (t) => {
173 const dir = tmpdir()
174 try {
175 const videoPath = path.join(dir, 'test.mp4')
176 await generateTestVideo(videoPath)
177
178 const store = new Corestore(path.join(dir, 'store'))
179 const media = new MediaStore(store)
180 await media.ready()
181
182 const vp = new VideoProcessor(media, { tmpDir: path.join(dir, 'tmp') })
183
184 // Probe first
185 const probeInfo = await vp.probe(videoPath)
186
187 // Transcode (320x240 source - only generates 240p since below 480p)
188 const hls = await vp.transcodeHLS(videoPath, probeInfo)
189
190 t.ok(hls.manifestHash)
191 t.is(hls.manifestHash.length, 64)
192 t.ok(hls.levels.length >= 1)
193
194 const level = hls.levels[0]
195 t.ok(level.name) // '240p'
196 t.ok(level.m3u8Hash)
197 t.ok(level.segmentHashes.length > 0) // at least 1 segment for 2s video
198
199 // Verify master playlist is stored and valid
200 const masterBuf = await media.get(hls.manifestHash)
201 const masterContent = masterBuf.toString()
202 t.ok(masterContent.includes('#EXTM3U'))
203 t.ok(masterContent.includes('#EXT-X-STREAM-INF'))
204
205 // Verify a segment is stored and non-empty
206 const firstSeg = level.segmentHashes[0]
207 const segBuf = await media.get(firstSeg.hash)
208 t.ok(segBuf.length > 0)
209
210 // Verify level playlist is stored
211 const m3u8Buf = await media.get(level.m3u8Hash)
212 t.ok(m3u8Buf.toString().includes('#EXTINF'))
213
214 await store.close()
215 } finally {
216 rmrf(dir)
217 }
218})
219
220test('VideoProcessor - enqueue with HLS writes full video index', async (t) => {
221 const dir = tmpdir()
222 try {
223 const videoPath = path.join(dir, 'test.mp4')
224 await generateTestVideo(videoPath)
225
226 const store = new Corestore(path.join(dir, 'store'))
227 const media = new MediaStore(store)
228 await media.ready()
229
230 const videoBuf = await fs.promises.readFile(videoPath)
231 const hash = await media.put(videoBuf, 'video/mp4')
232
233 const vp = new VideoProcessor(media, { tmpDir: path.join(dir, 'tmp') })
234 const result = await vp.enqueue(hash)
235
236 t.ok(result.probe)
237 t.ok(result.posterHash)
238 t.ok(result.hls)
239 t.ok(result.hls.manifestHash)
240 t.ok(result.hls.levels.length >= 1)
241
242 // Check video index
243 const status = await vp.getStatus(hash)
244 t.is(status.status, 'ready')
245 t.ok(status.poster)
246 t.ok(status.manifest)
247 t.ok(status.levels)
248 t.ok(status.levels.length >= 1)
249 t.ok(status.levels[0].segments.length > 0)
250
251 await store.close()
252 } finally {
253 rmrf(dir)
254 }
255})