/** * Media capability pack (bare-media + bare-ffmpeg). * Registered by default via register-default-packs.mjs. */ const path = require('bare-path'); const fs = require('bare-fs'); const b4a = require('b4a'); const { resolveAllowedPath, ensureJobDir, sanitizeId, getCapJobsRoot } = require('./paths.js'); const { createLiveEncodeCommands } = require('./live-encode.js'); /** Soft limit so JSON + base64 stays under Chrome NMH ~1 MB */ const MAX_CHUNK_CHARS = 700000; /** @type {Map} */ const jobs = new Map(); let nextJobSeq = 0; function makeJobId(prefix) { return `${prefix}_${Date.now()}_${nextJobSeq++}`; } function bufferToBase64(buf) { return b4a.toString(buf, 'base64'); } function base64ToBuffer(b64) { return b4a.from(b64, 'base64'); } /** Use the returned bytes object itself — never Buffer.buffer (shared pool). */ function asBytes(value) { if (value == null) return value; if (typeof value.byteLength === 'number') return value; if (value.buffer && typeof value.buffer.byteLength === 'number') return value.buffer; return value; } /** * Emit base64 payload in NMH-safe chunks. * @returns {number} number of chunks */ function emitBase64Chunks(emit, pack, jobId, buffer, extra) { const b64 = typeof buffer === 'string' ? buffer : bufferToBase64(buffer); let index = 0; for (let offset = 0; offset < b64.length; offset += MAX_CHUNK_CHARS) { emit('cap-chunk', { pack, jobId, index, data: b64.slice(offset, offset + MAX_CHUNK_CHARS), ...(extra || {}), }); index++; } return index; } function createMediaPack(bareMedia, ffmpeg) { const { image, video } = bareMedia; const live = ffmpeg ? createLiveEncodeCommands(bareMedia, ffmpeg) : null; async function writeInputIfNeeded(payload, jobId) { if (payload.dataBase64) { const dir = ensureJobDir(jobId); const name = payload.filename || 'input.bin'; const dest = resolveAllowedPath(name, jobId); fs.writeFileSync(dest, base64ToBuffer(payload.dataBase64)); return dest; } if (payload.path) { return resolveAllowedPath(payload.path, payload.jobId || jobId); } throw new Error('path or dataBase64 required'); } const commands = { async info(ctx) { const { payload, reply } = ctx; const jobId = payload.jobId || makeJobId('info'); const inputPath = await writeInputIfNeeded(payload, jobId); try { const meta = await video(inputPath).metadata(); reply({ ok: true, jobId, kind: 'video', width: meta.width, height: meta.height, duration: meta.duration, codec: meta.codec, avgFramerate: meta.avgFramerate, rotation: meta.rotation, displayRotation: meta.displayRotation, }); } catch (videoErr) { try { const meta = await image(inputPath).metadata(); reply({ ok: true, jobId, kind: 'image', metadata: meta }); } catch (_) { reply({ ok: false, error: videoErr.message || 'Unable to probe media' }); } } }, async imageTransform(ctx) { const { payload, reply, emit } = ctx; const jobId = payload.jobId || makeJobId('img'); jobs.set(jobId, { cancelled: false }); try { const inputPath = await writeInputIfNeeded(payload, jobId); const mimetype = payload.mimetype || payload.format || 'image/webp'; let pipe = image(inputPath).decode({ maxFrames: payload.maxFrames }); if (payload.orientate) pipe = pipe.orientate(); if (payload.maxWidth || payload.maxHeight) { pipe = pipe.resize({ maxWidth: payload.maxWidth, maxHeight: payload.maxHeight }); } if (payload.crop) { pipe = pipe.crop(payload.crop); } const encoded = await pipe.encode({ mimetype, maxBytes: payload.maxBytes, }); if (jobs.get(jobId)?.cancelled) { reply({ ok: false, error: 'cancelled', jobId }); return; } const buf = asBytes(encoded); if (payload.returnPath) { const outName = payload.outName || `out.${mimetype.split('/')[1] || 'webp'}`; const outPath = resolveAllowedPath(outName, jobId); fs.writeFileSync(outPath, buf); reply({ ok: true, jobId, path: path.relative(getCapJobsRoot(), outPath), mimetype }); return; } reply({ ok: true, jobId, mimetype, streaming: true }); const chunks = emitBase64Chunks(emit, 'media', jobId, buf, { mimetype }); emit('cap-end', { pack: 'media', jobId, chunks, mimetype }); } catch (err) { reply({ ok: false, error: err.message, jobId }); emit('cap-error', { pack: 'media', jobId, message: err.message }); } finally { jobs.delete(jobId); } }, async extractFrame(ctx) { const { payload, reply, emit } = ctx; const jobId = payload.jobId || makeJobId('frame'); jobs.set(jobId, { cancelled: false }); try { const inputPath = await writeInputIfNeeded(payload, jobId); const frameIndex = payload.frameIndex != null ? payload.frameIndex : 0; const mimetype = payload.mimetype || 'image/webp'; const rgba = await video(inputPath).extractFrames({ frameIndex }); if (!rgba) throw new Error('No frame extracted'); if (jobs.get(jobId)?.cancelled) { reply({ ok: false, error: 'cancelled', jobId }); return; } const encoded = await image.encode(rgba, { mimetype, maxBytes: payload.maxBytes }); const buf = asBytes(encoded); if (payload.returnPath) { const outName = payload.outName || `frame-${frameIndex}.webp`; const outPath = resolveAllowedPath(outName, jobId); fs.writeFileSync(outPath, buf); reply({ ok: true, jobId, path: path.relative(getCapJobsRoot(), outPath), mimetype, frameIndex }); return; } reply({ ok: true, jobId, mimetype, frameIndex, streaming: true }); const chunks = emitBase64Chunks(emit, 'media', jobId, buf, { mimetype, frameIndex }); emit('cap-end', { pack: 'media', jobId, chunks, mimetype, frameIndex }); } catch (err) { reply({ ok: false, error: err.message, jobId }); emit('cap-error', { pack: 'media', jobId, message: err.message }); } finally { jobs.delete(jobId); } }, async transcode(ctx) { const { payload, reply, emit } = ctx; const jobId = payload.jobId || makeJobId('xcode'); jobs.set(jobId, { cancelled: false }); try { const inputPath = await writeInputIfNeeded(payload, jobId); ensureJobDir(jobId); const format = payload.format || 'webm'; const outName = payload.outName || `out.${format === 'matroska' ? 'mkv' : format}`; const outPath = resolveAllowedPath(outName, jobId); reply({ ok: true, jobId, status: 'started', format }); const opts = { format }; if (payload.width) opts.width = payload.width; if (payload.height) opts.height = payload.height; const out = fs.createWriteStream(outPath); let bytes = 0; try { for await (const chunk of video(inputPath).transcode(opts)) { if (jobs.get(jobId)?.cancelled) { out.destroy(); emit('cap-error', { pack: 'media', jobId, message: 'cancelled' }); return; } const buf = asBytes(chunk.buffer != null && chunk.byteLength == null ? chunk.buffer : chunk); out.write(buf); bytes += buf.length || 0; emit('cap-chunk', { pack: 'media', jobId, progress: true, bytes, }); } await new Promise((resolve, reject) => { out.end((err) => (err ? reject(err) : resolve())); }); emit('cap-end', { pack: 'media', jobId, path: path.relative(getCapJobsRoot(), outPath), format, bytes, }); } catch (err) { try { out.destroy(); } catch (_) {} emit('cap-error', { pack: 'media', jobId, message: err.message }); } } catch (err) { reply({ ok: false, error: err.message, jobId }); emit('cap-error', { pack: 'media', jobId, message: err.message }); } finally { jobs.delete(jobId); } }, async cancel(ctx) { const { payload, reply } = ctx; const jobId = payload.jobId || payload.sessionId; if (!jobId) { reply({ ok: false, error: 'jobId required' }); return; } const job = jobs.get(jobId); if (job) job.cancelled = true; const liveCancelled = live ? live.cancelSession(jobId) : false; reply({ ok: true, jobId, cancelled: !!(job || liveCancelled) }); }, async writeInput(ctx) { const { payload, reply } = ctx; const jobId = sanitizeId(payload.jobId || makeJobId('up')); ensureJobDir(jobId); const name = payload.filename || 'input.bin'; const dest = resolveAllowedPath(name, jobId); const buf = base64ToBuffer(payload.dataBase64 || ''); if (payload.append) { fs.appendFileSync(dest, buf); } else { fs.writeFileSync(dest, buf); } reply({ ok: true, jobId, path: path.relative(getCapJobsRoot(), dest), bytes: buf.length, }); }, async readOutput(ctx) { const { payload, reply, emit } = ctx; const jobId = payload.jobId || makeJobId('read'); const filePath = resolveAllowedPath(payload.path, payload.underJobId || null); const buf = fs.readFileSync(filePath); const mimetype = payload.mimetype || 'application/octet-stream'; reply({ ok: true, jobId, mimetype, streaming: true, size: buf.length }); const chunks = emitBase64Chunks(emit, 'media', jobId, buf, { mimetype }); emit('cap-end', { pack: 'media', jobId, chunks, mimetype, path: payload.path }); }, }; if (live) { commands.encodeStart = live.encodeStart; commands.encodePushFrame = live.encodePushFrame; commands.encodePush = live.encodePush; commands.encodeSubscribe = live.encodeSubscribe; commands.encodeStop = live.encodeStop; } return { id: 'media', commands, onLoad() { ensureJobDir('_ready'); }, cleanup() { if (live) live.cleanupAll(); }, }; } module.exports = { createMediaPack, jobs };