/** * Inject Google "Spherical Video V1" metadata into an MP4 so that any player * (VLC, YouTube, this app) recognises the file as an equirectangular 360 video. * * ffmpeg drops the spherical side data when re-encoding, so we add the uuid * box back ourselves: it lives at the end of the video `trak`, and because * moov usually precedes mdat (faststart) every chunk offset after the * insertion point has to be shifted by the box size. */ import fs from 'node:fs/promises'; const SPHERICAL_UUID = Buffer.from('ffcc8263f8554a938814587a02521fdd', 'hex'); const XML = '' + 'true' + 'true' + '360 Player' + 'equirectangular' + ''; function makeUuidBox() { const payload = Buffer.from(XML, 'utf8'); const box = Buffer.alloc(8 + 16 + payload.length); box.writeUInt32BE(box.length, 0); box.write('uuid', 4, 'latin1'); SPHERICAL_UUID.copy(box, 8); payload.copy(box, 24); return box; } /** Iterate the boxes inside buf[start, end). */ function* boxes(buf, start, end) { let p = start; while (p + 8 <= end) { let size = buf.readUInt32BE(p); const type = buf.toString('latin1', p + 4, p + 8); let headerSize = 8; if (size === 1) { size = Number(buf.readBigUInt64BE(p + 8)); headerSize = 16; } else if (size === 0) size = end - p; if (size < headerSize || p + size > end) throw new Error(`box ${type} @${p} 大小不正確`); yield { pos: p, size, type, headerSize, end: p + size }; p += size; } } const kids = (buf, b) => boxes(buf, b.pos + b.headerSize, b.end); function child(buf, b, type) { for (const c of kids(buf, b)) if (c.type === type) return c; return null; } function descend(buf, b, ...types) { for (const t of types) { b = b && child(buf, b, t); } return b; } async function copyRange(src, dst, from, to) { const chunk = Buffer.alloc(4 * 1024 * 1024); let pos = from; while (pos < to) { const { bytesRead } = await src.read(chunk, 0, Math.min(chunk.length, to - pos), pos); if (!bytesRead) throw new Error('讀取檔案時提前結束'); await dst.write(chunk, 0, bytesRead); pos += bytesRead; } } /** * Write a copy of `file` with spherical metadata to `outFile`. * Returns { injected: true } or { injected: false, reason } (outFile untouched). */ export async function injectSphericalV1(file, outFile) { let fh = await fs.open(file, 'r'); try { const { size: fileSize } = await fh.stat(); // Top-level scan for moov. let moov = null; const hdr = Buffer.alloc(16); for (let pos = 0; pos + 8 <= fileSize;) { const { bytesRead } = await fh.read(hdr, 0, 16, pos); if (bytesRead < 8) break; let size = hdr.readUInt32BE(0); const type = hdr.toString('latin1', 4, 8); let headerSize = 8; if (size === 1) { size = Number(hdr.readBigUInt64BE(8)); headerSize = 16; } else if (size === 0) size = fileSize - pos; if (size < headerSize) break; if (type === 'moov') { moov = { pos, size, headerSize }; break; } pos += size; } if (!moov) return { injected: false, reason: '找不到 moov' }; if (moov.headerSize !== 8) return { injected: false, reason: 'moov 使用 64-bit size' }; if (moov.size > 512 * 1024 * 1024) return { injected: false, reason: 'moov 過大' }; const buf = Buffer.alloc(moov.size); await fh.read(buf, 0, moov.size, moov.pos); const moovBox = { pos: 0, size: moov.size, type: 'moov', headerSize: 8, end: moov.size }; const traks = [...kids(buf, moovBox)].filter(c => c.type === 'trak'); const videoTrak = traks.find(t => { const hdlr = descend(buf, t, 'mdia', 'hdlr'); return hdlr && buf.toString('latin1', hdlr.pos + 16, hdlr.pos + 20) === 'vide'; }); if (!videoTrak) return { injected: false, reason: '找不到視訊 trak' }; if (videoTrak.headerSize !== 8) return { injected: false, reason: 'trak 使用 64-bit size' }; for (const c of kids(buf, videoTrak)) { if (c.type === 'uuid' && buf.subarray(c.pos + 8, c.pos + 24).equals(SPHERICAL_UUID)) { return { injected: false, reason: '已經有 spherical metadata' }; } } const uuid = makeUuidBox(); const delta = uuid.length; const insertAt = moov.pos + videoTrak.end; // absolute file offset of the insertion // Shift every chunk offset that points past the insertion point. for (const t of traks) { const stbl = descend(buf, t, 'mdia', 'minf', 'stbl'); if (!stbl) continue; for (const c of kids(buf, stbl)) { if (c.type === 'stco') { const n = buf.readUInt32BE(c.pos + 12); for (let i = 0; i < n; i++) { const o = c.pos + 16 + i * 4; const v = buf.readUInt32BE(o); if (v >= insertAt) { if (v + delta > 0xffffffff) return { injected: false, reason: 'stco 偏移量溢位' }; buf.writeUInt32BE(v + delta, o); } } } else if (c.type === 'co64') { const n = buf.readUInt32BE(c.pos + 12); const at = BigInt(insertAt), d = BigInt(delta); for (let i = 0; i < n; i++) { const o = c.pos + 16 + i * 8; const v = buf.readBigUInt64BE(o); if (v >= at) buf.writeBigUInt64BE(v + d, o); } } } } buf.writeUInt32BE(moov.size + delta, 0); buf.writeUInt32BE(videoTrak.size + delta, videoTrak.pos); const newMoov = Buffer.concat([buf.subarray(0, videoTrak.end), uuid, buf.subarray(videoTrak.end)]); const out = await fs.open(outFile, 'w'); try { await copyRange(fh, out, 0, moov.pos); await out.write(newMoov); await copyRange(fh, out, moov.pos + moov.size, fileSize); } finally { await out.close(); } return { injected: true }; } finally { await fh.close(); } }