初始化 360 全景影片播放器專案並完成 NAS 部署設定
摘要: 將本機開發的 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>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user