摘要: 將本機開發的 360° 全景影片播放器納入版控,同時把執行環境從 Windows 切換到 Synology NAS,改以 Docker 部署。 根本原因: 專案原本只在有 NVIDIA 顯卡的 Windows 機器上跑,config.json 寫死了 Windows 磁碟機路徑 W:/photo/Badminton,NAS 上無法直接啟動。 另外 DSM 內建的 ffmpeg 拿掉了 VAAPI 編碼器,裸跑只能走 libx264, Celeron J4025 雙核轉 4K 360 影片的速度無法接受。 影響: - 在 NAS 上 npm start 會因為找不到影片資料夾而列不出任何影片 - 即使把路徑改對,轉檔仍只能用 CPU,82 分鐘的 4K 360 影片要跑十幾小時 修法: - config.json 的 videoDir 改為 NAS 實際路徑 /volume1/photo/Badminton - docker-compose.yml 啟用 devices: /dev/dri,讓容器取得 Intel UHD 600 的 render node;容器內 Alpine 版 ffmpeg 保有完整 VAAPI 支援,啟動時 會自動偵測成 vaapi 模式,並保留 libx264 當備援 - .env(不進版控)提供 VIDEO_DIR / CACHE_DIR 等 NAS 路徑給 compose 使用 驗證: 容器啟動 log 顯示「轉檔引擎:VAAPI 硬體編碼(Intel/AMD,/dev/dri)」, /api/config 回傳 modes: ["vaapi","cpu"],/api/videos 正確辨識來源影片為 3840x1920 equirectangular。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
161 lines
6.1 KiB
JavaScript
161 lines
6.1 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
}
|