Files

161 lines
6.1 KiB
JavaScript
Raw Permalink Normal View History

/**
* 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 =
'<?xml version="1.0"?><rdf:SphericalVideo\n' +
'xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"\n' +
'xmlns:GSpherical="http://ns.google.com/videos/1.0/spherical/">' +
'<GSpherical:Spherical>true</GSpherical:Spherical>' +
'<GSpherical:Stitched>true</GSpherical:Stitched>' +
'<GSpherical:StitchingSoftware>360 Player</GSpherical:StitchingSoftware>' +
'<GSpherical:ProjectionType>equirectangular</GSpherical:ProjectionType>' +
'</rdf:SphericalVideo>';
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();
}
}