初始化 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:
2026-08-21 10:57:17 +08:00
co-authored by Claude Opus 5
commit 2a9e59357e
17 changed files with 2966 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
{
"permissions": {
"deny": [
"Bash(rm -rf *)",
"Bash(rm -fr *)",
"Bash(rm -r *)",
"Bash(rm -R *)",
"Bash(rm -f *)",
"Bash(sudo *)",
"Bash(dd *)",
"Bash(mkfs*)",
"Bash(diskutil erase*)",
"Bash(chmod 777 *)",
"Bash(chmod -R 777 *)",
"Bash(git reset --hard*)",
"Bash(git push --force*)",
"Bash(git push -f *)",
"Bash(git clean -f*)",
"Bash(git branch -D*)",
"Bash(shutdown*)",
"Bash(reboot*)",
"Bash(: >*)",
"Bash(truncate *)"
],
"defaultMode": "bypassPermissions"
},
"skipDangerousModePermissionPrompt": true
}
+11
View File
@@ -0,0 +1,11 @@
node_modules
cache
.git
.gitignore
.env
.env.example
.dockerignore
Dockerfile
docker-compose.yml
README.md
*.log
+16
View File
@@ -0,0 +1,16 @@
# 複製成 .env 後依 NAS 實際路徑修改;docker compose 會自動讀取 .env
# 影片資料夾在 NAS 上的路徑(會以唯讀方式掛進容器的 /videos)
VIDEO_DIR=/volume1/photo/Badminton
# 轉檔輸出與快取的存放位置(容器的 /cache;每部影片三種畫質約需原檔 70% 的空間)
CACHE_DIR=/volume1/docker/360_player/cache
# 對外埠號(瀏覽器連 http://NAS-IP:8360
PORT=8360
# 時區
TZ=Asia/Taipei
# CPU 轉檔的 libx264 presetveryfast(預設)/ superfast / ultrafast
X264_PRESET=veryfast
+3
View File
@@ -0,0 +1,3 @@
node_modules/
cache/
.env
+30
View File
@@ -0,0 +1,30 @@
FROM node:22-alpine
# ffmpeg / ffprobe:偵測 360 metadata 與轉檔。
# intel-media-driver / libva-intel-driverNAS 有 Intel 內顯並把 /dev/dri 掛進來時,可用 VAAPI 硬體轉檔。
# tini:正確回收 ffmpeg 子程序、把 docker stop 的 SIGTERM 轉給 node。
RUN apk add --no-cache ffmpeg intel-media-driver libva-intel-driver tini
WORKDIR /app
ENV NODE_ENV=production \
PORT=8360 \
HOST=0.0.0.0 \
VIDEO_DIR=/videos \
CACHE_DIR=/cache
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY server.js config.json ./
COPY lib ./lib
COPY public ./public
VOLUME ["/videos", "/cache"]
EXPOSE 8360
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:8360/api/config >/dev/null || exit 1
ENTRYPOINT ["tini", "--"]
CMD ["node", "server.js"]
+123
View File
@@ -0,0 +1,123 @@
# 360 Player
在瀏覽器裡播放 360° 全景影片的本機網頁播放器。
從下拉選單挑選 `W:\photo\Badminton` 裡的影片、選擇播放畫質;低畫質版本由 ffmpeg(NVIDIA NVENC)轉出並快取在本機。
## 啟動
```bash
npm install # 第一次
npm start # 或 node server.js
```
啟動後終端機會列出網址,例如 `http://localhost:8360`;同一區網的其他裝置(手機、平板)可用列出的 `http://192.168.x.x:8360` 連線。
需求:Node.js 18+、`ffmpeg` / `ffprobe` 在 PATH 裡(轉檔用)。有 NVIDIA 顯卡時自動使用 NVDEC + NVENC,沒有就退回 CPUlibx264)。
## 操作
| 動作 | 滑鼠 / 觸控 | 鍵盤 |
|---|---|---|
| 環視 | 拖曳 | ← → ↑ ↓ |
| 縮放(視角) | 滾輪 / 雙指 | `+` `-` |
| 播放 / 暫停 | 點一下畫面 | `空白鍵` / `K` |
| 快退 / 快進 10 秒 | — | `J` / `L` |
| 逐格 | — | `,` / `.` |
| 全螢幕 | 雙擊畫面 | `F` |
| 靜音 | — | `M` |
| 重置視角 | ⌖ 按鈕 | `0` |
- 上方「360° / 平面」按鈕可手動切換投影方式(影片沒有 360 metadata 但是 2:1 比例時會自動判斷為 360)。
- 每部影片的播放位置、偏好畫質、音量都會記住。
## 畫質與轉檔
「畫質」下拉選單列出原始檔與 `config.json` 裡定義的每一級畫質:
- 已轉檔的畫質會顯示 ✓ 與檔案大小,選取後立即切換(保留目前播放進度)。
- 尚未轉檔的畫質選取後會**加入轉檔佇列**,完成時畫面會跳出通知與「切換」按鈕。
- 「轉檔」面板可以看進度(fps、速度、剩餘時間)、取消、刪除已轉檔檔案,
以及**「一次轉出全部畫質」**:來源只讀一次就同時輸出全部畫質。
> 來源放在網路磁碟(例如 RaiDrive)時,讀取速度通常才是瓶頸,
> 而不是 GPU。這種情況下請用「一次轉出全部畫質」,時間和只轉一種幾乎相同。
轉檔輸出:H.264 + AAC、`faststart`(moov 在檔頭,可立即開播、順暢拖動),
並重新寫入 Spherical Video V1 metadata,所以用 VLC / YouTube 開也會被認成 360 影片。
轉出的檔案放在 `cache/`,檔名 `<原檔名>__<畫質id>.mp4`;來源檔案變動(大小或修改時間改變)時會自動視為過期並重轉。
## 部署到 NASDocker
映像檔以 `node:22-alpine` 為基礎,內含 ffmpeg;影片資料夾與快取都用 volume 掛進容器:
| 容器內路徑 | 用途 | 對應環境變數 |
|---|---|---|
| `/videos` | 影片資料夾(唯讀) | `VIDEO_DIR` |
| `/cache` | 轉檔輸出與 probe 快取(需可寫) | `CACHE_DIR` |
```bash
# 1. 把整個專案資料夾複製到 NAS,例如 /volume1/docker/360_player
# 2. 建立 .env,填 NAS 上的實際路徑
cp .env.example .env && vi .env
# 3. 建置並啟動
docker compose up -d --build
# 4. 瀏覽器開 http://NAS-IP:8360
```
Synology Container Manager:「專案」→「新增」→ 選這個資料夾,它會讀取 `docker-compose.yml`
環境變數可在「環境」分頁填(等同 `.env`)。
**NAS 上轉檔速度**
- 沒有 GPU 時走 `libx264`CPU)。NAS 的 CPU 把 4K 360 影片同時轉成三種畫質大約只有個位數 fps,
82 分鐘的影片可能要跑 5–10 小時(可在背景跑,轉檔面板會顯示剩餘時間)。
CPU 很弱可把 `.env``X264_PRESET` 改成 `superfast``ultrafast`
- NAS 有 Intel 內顯時,把 `docker-compose.yml``devices: /dev/dri` 兩行取消註解,
啟動時會自動偵測並改用 VAAPI 硬體編碼(此路徑尚未在實機驗證,偵測失敗會自動退回 CPU)。
- **最快的做法**:在有 NVIDIA 顯卡的電腦上先跑 `npm start` 轉完,再把 `cache/` 裡的
`*.mp4``*.mp4.json` 複製到 NAS 的 `CACHE_DIR`。快取檔只認來源檔的大小與修改時間(容許 2 秒誤差),
所以兩邊看到同一個檔案就能直接共用。
## 設定(`config.json`
```json
{
"port": 8360,
"host": "0.0.0.0",
"videoDir": "W:/photo/Badminton",
"cacheDir": "./cache",
"extensions": [".mp4", ".mov", ".m4v", ".webm"],
"qualities": [
{ "id": "2560", "label": "高", "width": 2560, "bitrate": "10M", "maxrate": "13M" },
{ "id": "1920", "label": "中", "width": 1920, "bitrate": "5M", "maxrate": "7M" },
{ "id": "1280", "label": "低", "width": 1280, "bitrate": "2.5M", "maxrate": "3.5M" }
]
}
```
- `qualities`:每一級只要給寬度,高度依原始比例計算;比原始影片寬的級別會自動略過。
可加 `"nvencPreset": "p6"` 之類的欄位調整 NVENC 預設(預設 `p4`)。
- 環境變數:`PORT=9000` 覆寫埠號;`CONFIG=other.json` 使用另一份設定;`LOG_STREAM=0` 關閉串流請求 log。
## API(給進階使用)
| 方法 | 路徑 | 說明 |
|---|---|---|
| GET | `/api/videos` | 影片清單(含 ffprobe 資訊、各畫質狀態) |
| GET | `/stream/:name?q=original\|<畫質id>` | 影片串流(支援 Range) |
| POST | `/api/transcode` | `{ "name", "quality" }` / `{ "name", "qualities": [] }` / `{ "name", "all": true }` |
| GET | `/api/jobs` | 轉檔工作 |
| DELETE | `/api/jobs/:id` | 取消工作 |
| DELETE | `/api/jobs/finished` | 清除已完成 |
| DELETE | `/api/variant?name=&quality=` | 刪除轉檔檔案 |
| GET | `/api/events` | Server-Sent Events`jobs`(進度)、`finished` |
## 專案結構
```
server.js Express:清單、Range 串流、轉檔 API、SSE
lib/probe.js ffprobe 包裝 + 永續快取(含 360 metadata 判斷)
lib/transcode.js ffmpeg 工作佇列(一次解碼多輸出;CUDA → NVENC → CPU 備援)
lib/spherical.js 把 Spherical Video V1 metadata 注回 MP4
public/ 前端(Three.js 球體貼圖播放器)
cache/ 轉檔輸出與 probe 快取(已 gitignore
```
+12
View File
@@ -0,0 +1,12 @@
{
"port": 8360,
"host": "0.0.0.0",
"videoDir": "/volume1/photo/Badminton",
"cacheDir": "./cache",
"extensions": [".mp4", ".mov", ".m4v", ".webm"],
"qualities": [
{ "id": "2560", "label": "高", "width": 2560, "bitrate": "10M", "maxrate": "13M" },
{ "id": "1920", "label": "中", "width": 1920, "bitrate": "5M", "maxrate": "7M" },
{ "id": "1280", "label": "低", "width": 1280, "bitrate": "2.5M", "maxrate": "3.5M" }
]
}
+23
View File
@@ -0,0 +1,23 @@
services:
360-player:
container_name: 360-player
build:
context: .
dockerfile: Dockerfile
image: 360-player:latest
restart: unless-stopped
ports:
- "${PORT:-8360}:8360"
volumes:
# 影片資料夾(NAS 上的實際路徑)→ 容器內 /videos,唯讀
- "${VIDEO_DIR:-/volume1/photo/Badminton}:/videos:ro"
# 轉檔輸出 + probe 快取 → 容器內 /cache(需要可寫)
- "${CACHE_DIR:-./cache}:/cache"
environment:
TZ: ${TZ:-Asia/Taipei}
# CPU 轉檔時 libx264 的 presetNAS CPU 弱可改 superfast / ultrafast(檔案會稍大)
X264_PRESET: ${X264_PRESET:-veryfast}
# 本機為 Intel J4025UHD 600),已啟用 VAAPI 硬體轉檔;
# 換到沒有 /dev/dri 的機器時要把下面兩行註解掉,否則容器起不來。
devices:
- /dev/dri:/dev/dri
+106
View File
@@ -0,0 +1,106 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
/** Run ffprobe and return a compact description of the file. */
export function ffprobe(file) {
return new Promise((resolve, reject) => {
const args = [
'-v', 'error',
'-show_entries',
'stream=index,codec_type,codec_name,width,height,r_frame_rate,bit_rate,channels,duration:stream_side_data_list:stream_tags=rotate:format=duration,size,bit_rate',
'-of', 'json',
file,
];
const p = spawn('ffprobe', args, { windowsHide: true });
let out = '', err = '';
p.stdout.on('data', d => (out += d));
p.stderr.on('data', d => (err += d));
p.on('error', reject);
p.on('close', code => {
if (code !== 0) return reject(new Error(err.trim() || `ffprobe exited ${code}`));
try { resolve(summarize(JSON.parse(out))); }
catch (e) { reject(e); }
});
});
}
function parseFps(r) {
if (!r) return null;
const [a, b] = r.split('/').map(Number);
return b ? +(a / b).toFixed(3) : a;
}
function summarize(j) {
const v = (j.streams || []).find(s => s.codec_type === 'video');
const a = (j.streams || []).find(s => s.codec_type === 'audio');
const fmt = j.format || {};
const info = {
duration: +(fmt.duration || v?.duration || 0),
size: +(fmt.size || 0),
bitrate: +(fmt.bit_rate || 0),
video: v ? {
codec: v.codec_name,
width: v.width,
height: v.height,
fps: parseFps(v.r_frame_rate),
bitrate: +(v.bit_rate || 0),
} : null,
audio: a ? { codec: a.codec_name, channels: a.channels } : null,
projection: 'flat',
projectionSource: 'none',
stereo: 'mono',
};
const sph = (v?.side_data_list || []).find(s => s.side_data_type === 'Spherical Mapping');
if (sph) {
info.projection = sph.projection === 'equirectangular' ? 'equirectangular' : (sph.projection || 'unknown');
info.projectionSource = 'metadata';
info.sphericalYaw = sph.yaw || 0;
info.sphericalPitch = sph.pitch || 0;
info.sphericalRoll = sph.roll || 0;
} else if (v && v.width >= 2048 && Math.abs(v.width / v.height - 2) < 0.02) {
// No metadata, but a 2:1 frame at this size is almost certainly an equirect panorama.
info.projection = 'equirectangular';
info.projectionSource = 'aspect-guess';
}
const st = (v?.side_data_list || []).find(s => s.side_data_type === 'Stereo 3D');
if (st && st.type && st.type !== '2D') info.stereo = st.type;
return info;
}
/** Small persistent cache keyed on path + size + mtime so we don't re-probe on restart. */
export class ProbeCache {
constructor(file) {
this.file = file;
this.map = new Map();
this.loaded = false;
this.saving = null;
}
async load() {
try {
const j = JSON.parse(await fs.readFile(this.file, 'utf8'));
for (const [k, v] of Object.entries(j)) this.map.set(k, v);
} catch { /* first run */ }
this.loaded = true;
}
async get(file, stat) {
if (!this.loaded) await this.load();
const key = `${file}|${stat.size}|${Math.floor(stat.mtimeMs)}`;
if (this.map.has(key)) return this.map.get(key);
const info = await ffprobe(file);
this.map.set(key, info);
this.save();
return info;
}
save() {
// Debounce writes; a stale cache file only costs a re-probe.
if (this.saving) return;
this.saving = setTimeout(async () => {
this.saving = null;
try {
await fs.mkdir(path.dirname(this.file), { recursive: true });
await fs.writeFile(this.file, JSON.stringify(Object.fromEntries(this.map), null, 1));
} catch (e) { console.warn('probe cache save failed:', e.message); }
}, 500);
}
}
+160
View File
@@ -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();
}
}
+387
View File
@@ -0,0 +1,387 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import { EventEmitter } from 'node:events';
import { ffprobe } from './probe.js';
import { injectSphericalV1 } from './spherical.js';
export const MODE_LABEL = {
cuda: 'NVDEC + CUDA 縮放 + NVENC',
nvenc: 'CPU 解碼/縮放 + NVENC',
vaapi: 'VAAPI 硬體編碼(Intel/AMD/dev/dri',
cpu: 'libx264CPU',
};
const VAAPI_DEVICE = process.env.VAAPI_DEVICE || '/dev/dri/renderD128';
const X264_PRESET = process.env.X264_PRESET || 'veryfast';
function tryFfmpeg(args, timeoutMs = 20000) {
return new Promise(resolve => {
const p = spawn('ffmpeg', ['-hide_banner', '-v', 'error', '-y', ...args], { windowsHide: true });
const t = setTimeout(() => { p.kill(); resolve(false); }, timeoutMs);
p.on('error', () => { clearTimeout(t); resolve(false); });
p.on('close', code => { clearTimeout(t); resolve(code === 0); });
});
}
/** Figure out which encode pipelines work on this machine, best first. */
export async function detectModes() {
const modes = [];
const src = ['-f', 'lavfi', '-i', 'testsrc=s=256x128:d=0.1:r=30'];
if (await tryFfmpeg([...src, '-vf', 'hwupload_cuda,scale_cuda=128:64', '-c:v', 'h264_nvenc', '-f', 'null', '-'])) {
modes.push('cuda', 'nvenc');
} else if (await tryFfmpeg([...src, '-c:v', 'h264_nvenc', '-f', 'null', '-'])) {
modes.push('nvenc');
}
// Intel/AMD iGPU via VAAPI (typical for a NAS with /dev/dri passed into the container).
if (await fs.access(VAAPI_DEVICE).then(() => true, () => false) &&
await tryFfmpeg(['-vaapi_device', VAAPI_DEVICE, ...src,
'-vf', 'format=nv12,hwupload,scale_vaapi=128:64', '-c:v', 'h264_vaapi', '-f', 'null', '-'])) {
modes.push('vaapi');
}
modes.push('cpu');
return modes;
}
/** "7M" * 2 -> "14M" (keeps the unit suffix). */
function scaleRate(rate, factor) {
const m = /^([\d.]+)\s*([kKmM]?)$/.exec(String(rate));
if (!m) return rate;
return `${Math.round(parseFloat(m[1]) * factor)}${m[2]}`;
}
/**
* One ffmpeg run, one decode of the source, N scaled outputs. Reading the
* source is usually the slow part (network drive), so every extra quality is
* nearly free when produced in the same pass.
*/
function buildArgs({ mode, src, targets, audioCodec }) {
const a = ['-y', '-hide_banner', '-loglevel', 'error', '-nostats'];
if (mode === 'cuda') a.push('-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda');
if (mode === 'vaapi') a.push('-vaapi_device', VAAPI_DEVICE, '-hwaccel', 'vaapi', '-hwaccel_output_format', 'vaapi');
a.push('-i', src);
for (const t of targets) {
a.push('-map', '0:v:0', '-map', '0:a?', '-sn', '-dn');
if (mode === 'cuda') a.push('-vf', `scale_cuda=${t.width}:${t.height}`);
// format=nv12|vaapi,hwupload: works whether the decoder ran on the GPU or fell back to software.
else if (mode === 'vaapi') a.push('-vf', `format=nv12|vaapi,hwupload,scale_vaapi=${t.width}:${t.height}:format=nv12`);
else if (mode === 'nvenc') a.push('-vf', `scale=${t.width}:${t.height}:flags=bicubic,format=nv12`);
else a.push('-vf', `scale=${t.width}:${t.height}:flags=bicubic,format=yuv420p`);
const bufsize = scaleRate(t.maxrate, 2);
if (mode === 'cpu') {
a.push('-c:v', 'libx264', '-preset', X264_PRESET, '-crf', '23',
'-maxrate', t.maxrate, '-bufsize', bufsize, '-profile:v', 'high', '-g', '60');
} else if (mode === 'vaapi') {
a.push('-c:v', 'h264_vaapi', '-rc_mode', 'VBR', '-b:v', t.bitrate, '-maxrate', t.maxrate, '-bufsize', bufsize,
'-profile:v', 'high', '-g', '60', '-bf', '2');
} else {
a.push('-c:v', 'h264_nvenc', '-preset', t.nvencPreset || 'p4', '-rc', 'vbr',
'-b:v', t.bitrate, '-maxrate', t.maxrate, '-bufsize', bufsize,
'-profile:v', 'high', '-g', '60', '-bf', '2', '-spatial-aq', '1');
}
if (audioCodec === 'aac') a.push('-c:a', 'copy');
else a.push('-c:a', 'aac', '-b:a', '160k');
a.push('-movflags', '+faststart', t.part);
}
a.push('-progress', 'pipe:1');
return a;
}
export class Transcoder extends EventEmitter {
/**
* @param {object} o
* @param {string} o.cacheDir
* @param {Array} o.qualities config.qualities
* @param {(name:string)=>Promise<{file:string,stat:import('fs').Stats,info:object}>} o.resolveSource
*/
constructor({ cacheDir, qualities, resolveSource }) {
super();
this.cacheDir = cacheDir;
this.qualities = new Map(qualities.map(q => [q.id, q]));
this.resolveSource = resolveSource;
this.modes = ['cpu'];
this.jobs = new Map(); // id -> job
this.queue = []; // job ids waiting
this.running = null; // job currently encoding
}
async init() {
await fs.mkdir(this.cacheDir, { recursive: true });
this.modes = await detectModes();
// Remove leftovers from a previous crash.
for (const f of await fs.readdir(this.cacheDir)) {
if (f.endsWith('.part.mp4') || f.endsWith('.sph.tmp')) await fs.rm(path.join(this.cacheDir, f), { force: true });
}
return this.modes;
}
variantPath(name, qid) {
return path.join(this.cacheDir, `${name}__${qid}.mp4`);
}
/** Is a finished, up-to-date variant on disk? Stale ones (source changed) are deleted. */
async variantStatus(name, qid, sourceStat) {
const out = this.variantPath(name, qid);
try {
const meta = JSON.parse(await fs.readFile(out + '.json', 'utf8'));
const st = await fs.stat(out);
// mtime tolerance of 2 s: SMB/WebDAV/FAT round timestamps, and the cache may be copied
// between the PC (GPU transcode) and the NAS (serving) that see the same source file.
if (meta.sourceSize === sourceStat.size && Math.abs(meta.sourceMtime - sourceStat.mtimeMs) < 2000) {
return { ready: true, size: st.size, mode: meta.mode, createdAt: meta.createdAt, spherical: !!meta.spherical };
}
await this.deleteVariant(name, qid);
} catch { /* not there */ }
return { ready: false };
}
async deleteVariant(name, qid) {
const job = this.findJob(name, qid);
if (job) this.cancel(job.id);
const out = this.variantPath(name, qid);
await fs.rm(out, { force: true });
await fs.rm(out + '.json', { force: true });
}
/** Active (queued/running) job that will produce this variant, if any. */
findJob(name, qid) {
for (const j of this.jobs.values()) {
if (j.name === name && (j.status === 'queued' || j.status === 'running') && j.targets.some(t => t.quality === qid)) return j;
}
return null;
}
list() {
return [...this.jobs.values()].map(publicJob);
}
/**
* Queue one job that produces every quality in `qids` from a single read of
* the source. Qualities that already exist or are already in flight are skipped.
*/
async enqueue(name, qids) {
const { file, stat, info } = await this.resolveSource(name);
if (!info?.video) throw httpError(400, '無法讀取影片資訊');
const targets = [];
const skipped = [];
for (const qid of qids) {
const q = this.qualities.get(qid);
if (!q) throw httpError(400, `未知的畫質 ${qid}`);
if (q.width >= info.video.width) { skipped.push(`${q.label}:不小於原始解析度`); continue; }
if (this.findJob(name, qid)) { skipped.push(`${q.label}:已在佇列中`); continue; }
if ((await this.variantStatus(name, qid, stat)).ready) { skipped.push(`${q.label}:已存在`); continue; }
const height = Math.round((q.width * info.video.height) / info.video.width / 2) * 2;
targets.push({
quality: qid, label: q.label, width: q.width, height,
bitrate: q.bitrate, maxrate: q.maxrate, nvencPreset: q.nvencPreset,
final: this.variantPath(name, qid),
part: this.variantPath(name, qid).replace(/\.mp4$/, '.part.mp4'),
done: false,
});
}
if (!targets.length) throw httpError(409, skipped.length ? `沒有需要轉檔的畫質(${skipped.join('')}` : '沒有需要轉檔的畫質');
targets.sort((a, b) => b.width - a.width);
const job = {
id: crypto.randomBytes(6).toString('hex'),
name, targets, skipped,
src: file, sourceStat: { size: stat.size, mtime: Math.floor(stat.mtimeMs) },
duration: info.duration || 0,
audioCodec: info.audio?.codec || null,
spherical: info.projection === 'equirectangular',
status: 'queued', progress: 0, fps: 0, speed: 0, eta: null, outTime: 0,
mode: null, attempt: 0, note: null, error: null,
createdAt: Date.now(), startedAt: null, finishedAt: null,
_proc: null,
};
this.jobs.set(job.id, job);
this.queue.push(job.id);
this.emit('update');
this.pump();
return publicJob(job);
}
cancel(id) {
const job = this.jobs.get(id);
if (!job) throw httpError(404, '找不到工作');
if (job.status === 'queued') {
this.queue = this.queue.filter(x => x !== id);
job.status = 'cancelled';
job.finishedAt = Date.now();
this.emit('update');
} else if (job.status === 'running') {
job.status = 'cancelled';
job._proc?.kill();
}
return publicJob(job);
}
/** Drop finished entries from the list. */
clearFinished() {
for (const [id, j] of this.jobs) {
if (j.status === 'done' || j.status === 'error' || j.status === 'cancelled') this.jobs.delete(id);
}
this.emit('update');
}
async pump() {
if (this.running || !this.queue.length) return;
const job = this.jobs.get(this.queue.shift());
if (!job || job.status !== 'queued') return this.pump();
this.running = job;
job.status = 'running';
job.startedAt = Date.now();
this.emit('update');
try {
await this.run(job);
} catch (e) {
job.status = 'error';
job.error = e.message;
}
job.finishedAt = Date.now();
this.running = null;
this.emit('update');
this.emit('finished', publicJob(job));
this.pump();
}
async removeParts(job) {
for (const t of job.targets) await fs.rm(t.part, { force: true });
}
run(job) {
return new Promise(resolve => {
const attempt = (i) => {
const mode = this.modes[i];
job.mode = mode;
job.attempt = i + 1;
job.progress = 0; job.outTime = 0; job.fps = 0; job.speed = 0; job.eta = null;
const args = buildArgs({ mode, src: job.src, targets: job.targets, audioCodec: job.audioCodec });
const p = spawn('ffmpeg', args, { windowsHide: true });
job._proc = p;
let buf = '';
const stderr = [];
p.stdout.on('data', d => {
buf += d;
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
this.progressLine(job, line);
}
});
p.stderr.on('data', d => {
stderr.push(String(d));
if (stderr.length > 40) stderr.shift();
});
p.on('error', e => stderr.push(e.message));
p.on('close', async code => {
job._proc = null;
if (job.status === 'cancelled') {
await this.removeParts(job);
return resolve();
}
if (code === 0) {
try {
for (const t of job.targets) await this.finalize(job, t, mode);
job.status = 'done';
job.progress = 1;
job.eta = 0;
job.note = null;
} catch (e) {
job.status = 'error';
job.error = `寫入輸出失敗:${e.message}`;
await this.removeParts(job);
}
return resolve();
}
const errText = stderr.join('').trim().split(/\r?\n/).filter(Boolean).slice(-3).join(' | ');
await this.removeParts(job);
if (i + 1 < this.modes.length) {
job.note = `${MODE_LABEL[mode]} 失敗(${errText || 'exit ' + code}),改用 ${MODE_LABEL[this.modes[i + 1]]}`;
console.warn(`[transcode] ${job.name}: ${job.note}`);
this.emit('update');
return attempt(i + 1);
}
job.status = 'error';
job.error = errText || `ffmpeg exited ${code}`;
resolve();
});
};
attempt(0);
});
}
/** Re-attach 360 metadata (ffmpeg drops it on re-encode), verify, then publish the variant. */
async finalize(job, t, mode) {
let spherical = false;
if (job.spherical) {
job.note = `寫入 360 metadata${t.label})…`;
this.emit('update');
const tmp = t.final.replace(/\.mp4$/, '.sph.tmp');
try {
const r = await injectSphericalV1(t.part, tmp);
if (r.injected) {
const check = await ffprobe(tmp);
if (check.projectionSource === 'metadata' && check.duration > 0) {
await fs.rm(t.part, { force: true });
await fs.rename(tmp, t.part);
spherical = true;
} else {
console.warn(`[transcode] spherical 注入後驗證失敗,保留未注入版本 (${job.name} ${t.quality})`);
await fs.rm(tmp, { force: true });
}
} else {
console.warn(`[transcode] 未注入 spherical metadata${r.reason}`);
}
} catch (e) {
console.warn(`[transcode] spherical 注入失敗:${e.message}`);
await fs.rm(tmp, { force: true });
}
}
await fs.writeFile(t.final + '.json', JSON.stringify({
sourceSize: job.sourceStat.size, sourceMtime: job.sourceStat.mtime,
width: t.width, height: t.height, mode, spherical, createdAt: Date.now(),
}));
await fs.rename(t.part, t.final);
t.done = true;
}
progressLine(job, line) {
const eq = line.indexOf('=');
if (eq < 0) return;
const key = line.slice(0, eq);
const val = line.slice(eq + 1).trim();
switch (key) {
case 'out_time_us': job.outTime = Number(val) / 1e6; break;
case 'out_time_ms': if (!job.outTime) job.outTime = Number(val) / 1e6; break; // older ffmpeg: actually µs
case 'fps': job.fps = parseFloat(val) || 0; break;
case 'speed': job.speed = parseFloat(val) || 0; break;
case 'progress': {
if (job.duration > 0) {
job.progress = Math.min(0.999, job.outTime / job.duration);
job.eta = job.speed > 0 ? Math.max(0, (job.duration - job.outTime) / job.speed) : null;
}
this.emit('update');
break;
}
}
}
}
function publicJob(j) {
const { _proc, src, sourceStat, ...rest } = j;
rest.targets = j.targets.map(({ quality, label, width, height, done }) => ({ quality, label, width, height, done }));
return rest;
}
function httpError(status, message) {
const e = new Error(message);
e.status = status;
return e;
}
+893
View File
@@ -0,0 +1,893 @@
{
"name": "360-player",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "360-player",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"express": "^5.2.1",
"three": "^0.185.1"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
"license": "MIT",
"dependencies": {
"content-type": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "360-player",
"version": "1.0.0",
"description": "Local web-based 360° video player with quality transcoding",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"license": "MIT",
"dependencies": {
"express": "^5.2.1",
"three": "^0.185.1"
}
}
+666
View File
@@ -0,0 +1,666 @@
import * as THREE from 'three';
// ---------------------------------------------------------------- helpers
const $ = (s) => document.querySelector(s);
const el = {
stage: $('#stage'), canvas: $('#gl'), video: $('#video'),
videoSel: $('#videoSelect'), qualSel: $('#qualitySelect'), refreshBtn: $('#refreshBtn'),
projBtn: $('#projBtn'), panelBtn: $('#panelBtn'), panelBadge: $('#panelBadge'),
panel: $('#panel'), panelClose: $('#panelClose'), panelVideoName: $('#panelVideoName'),
panelVariants: $('#panelVariants'), panelJobs: $('#panelJobs'), clearJobsBtn: $('#clearJobsBtn'),
encoderInfo: $('#encoderInfo'),
controls: $('#controls'), seek: $('#seek'), playBtn: $('#playBtn'), time: $('#time'),
muteBtn: $('#muteBtn'), vol: $('#vol'), rate: $('#rate'), resetBtn: $('#resetBtn'), fsBtn: $('#fsBtn'),
bigPlay: $('#bigPlay'), toast: $('#toast'), info: $('#info'), hint: $('#hint'), spinner: $('#spinner'),
};
const video = el.video;
const LS = {
get(k, d = null) { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v); } catch { return d; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ } },
};
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
function fmtTime(s) {
if (!isFinite(s) || s < 0) s = 0;
s = Math.floor(s);
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
const mm = h ? String(m).padStart(2, '0') : String(m);
return `${h ? h + ':' : ''}${mm}:${String(sec).padStart(2, '0')}`;
}
function fmtBytes(b) {
if (!b) return '—';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
return `${b.toFixed(i >= 3 ? 2 : i >= 2 ? 1 : 0)} ${u[i]}`;
}
const fmtBps = (bps) => bps ? `${(bps / 1e6).toFixed(1)} Mbps` : '—';
const fmtRate = (r) => String(r).replace(/[mM]$/, ' Mbps').replace(/[kK]$/, ' kbps');
const esc = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
async function api(url, opts = {}) {
const r = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || `${r.status} ${r.statusText}`);
return j;
}
let toastTimer = null;
function toast(msg, { action = null, timeout = 4500, error = false } = {}) {
clearTimeout(toastTimer);
el.toast.innerHTML = `<span>${esc(msg)}</span>`;
el.toast.classList.toggle('error', error);
if (action) {
const b = document.createElement('button');
b.textContent = action.label;
b.onclick = () => { hideToast(); action.fn(); };
el.toast.appendChild(b);
}
el.toast.hidden = false;
if (timeout) toastTimer = setTimeout(hideToast, timeout);
}
function hideToast() { el.toast.hidden = true; }
// ---------------------------------------------------------------- state
const state = {
config: null,
videos: [],
jobs: [],
current: null, // entry from /api/videos
quality: 'original',
projection: 'equirectangular',
lon: 0, lat: 0, fov: 75,
velLon: 0, velLat: 0,
pendingSeek: null,
wantPlay: false,
};
// ---------------------------------------------------------------- three.js
const renderer = new THREE.WebGLRenderer({ canvas: el.canvas, antialias: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1100);
const geometry = new THREE.SphereGeometry(500, 80, 50);
geometry.scale(-1, 1, 1); // view from the inside
const texture = new THREE.VideoTexture(video);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
const sphere = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ map: texture }));
scene.add(sphere);
const lookTarget = new THREE.Vector3();
let needsRender = true;
function resize() {
const w = el.stage.clientWidth, h = el.stage.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
needsRender = true;
}
new ResizeObserver(resize).observe(el.stage);
function applyCamera() {
state.lat = clamp(state.lat, -85, 85);
const phi = THREE.MathUtils.degToRad(state.lat);
const theta = THREE.MathUtils.degToRad(state.lon);
// lon = 0 looks at the centre of the equirect image; +lon turns right.
lookTarget.set(-Math.cos(phi) * Math.cos(theta), Math.sin(phi), -Math.cos(phi) * Math.sin(theta));
camera.lookAt(lookTarget);
if (camera.fov !== state.fov) { camera.fov = state.fov; camera.updateProjectionMatrix(); }
}
let hasNewFrame = false;
if ('requestVideoFrameCallback' in video) {
const onFrame = () => { hasNewFrame = true; video.requestVideoFrameCallback(onFrame); };
video.requestVideoFrameCallback(onFrame);
}
function loop() {
requestAnimationFrame(loop);
if (state.projection !== 'equirectangular') return;
// inertia after a fling
if (!dragging && (Math.abs(state.velLon) > 0.02 || Math.abs(state.velLat) > 0.02)) {
state.lon += state.velLon; state.lat += state.velLat;
state.velLon *= 0.92; state.velLat *= 0.92;
needsRender = true;
}
const playing = !video.paused && !video.ended && video.readyState >= 2;
const frameReady = 'requestVideoFrameCallback' in video ? hasNewFrame : playing;
if (!needsRender && !frameReady) return;
hasNewFrame = false; needsRender = false;
applyCamera();
renderer.render(scene, camera);
}
// ---------------------------------------------------------------- look controls
let dragging = false;
const pointers = new Map();
let downX = 0, downY = 0, downT = 0, lastX = 0, lastY = 0, lastT = 0, pinchDist = 0, pinchFov = 75;
function degPerPx() { return state.fov / el.stage.clientHeight; }
el.canvas.addEventListener('pointerdown', e => {
el.canvas.setPointerCapture(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 1) {
dragging = true; el.canvas.classList.add('dragging');
downX = lastX = e.clientX; downY = lastY = e.clientY; downT = lastT = performance.now();
state.velLon = state.velLat = 0;
} else if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
pinchDist = Math.hypot(a.x - b.x, a.y - b.y);
pinchFov = state.fov;
}
wake();
});
el.canvas.addEventListener('pointermove', e => {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (pinchDist > 0) { state.fov = clamp(pinchFov * (pinchDist / d), 30, 110); needsRender = true; }
return;
}
if (!dragging) return;
const dx = e.clientX - lastX, dy = e.clientY - lastY;
const s = degPerPx();
state.lon -= dx * s; // grab-and-drag: image follows the pointer
state.lat += dy * s;
const now = performance.now(), dt = Math.max(1, now - lastT);
state.velLon = -dx * s * (16 / dt);
state.velLat = dy * s * (16 / dt);
lastX = e.clientX; lastY = e.clientY; lastT = now;
needsRender = true;
});
function endPointer(e) {
if (!pointers.has(e.pointerId)) return;
pointers.delete(e.pointerId);
if (pointers.size === 0) {
dragging = false; el.canvas.classList.remove('dragging');
const moved = Math.hypot(e.clientX - downX, e.clientY - downY);
const quick = performance.now() - downT < 350;
if (performance.now() - lastT > 80) state.velLon = state.velLat = 0; // held still before release
if (moved < 6 && quick && e.type === 'pointerup') {
if (e.pointerType === 'mouse') togglePlay();
else if (el.stage.classList.contains('idle')) wake(); else el.stage.classList.add('idle');
}
} else if (pointers.size === 1) {
const p = [...pointers.values()][0];
lastX = p.x; lastY = p.y; lastT = performance.now();
state.velLon = state.velLat = 0;
}
}
el.canvas.addEventListener('pointerup', endPointer);
el.canvas.addEventListener('pointercancel', endPointer);
el.canvas.addEventListener('wheel', e => {
e.preventDefault();
state.fov = clamp(state.fov + e.deltaY * 0.05, 30, 110);
needsRender = true; wake();
}, { passive: false });
el.canvas.addEventListener('dblclick', toggleFullscreen);
video.addEventListener('dblclick', toggleFullscreen);
video.addEventListener('click', () => { if (state.projection === 'flat') togglePlay(); });
function resetView() { state.lon = 0; state.lat = 0; state.fov = 75; state.velLon = state.velLat = 0; needsRender = true; }
// ---------------------------------------------------------------- projection
function setProjection(p) {
state.projection = p;
el.stage.classList.toggle('flat', p === 'flat');
el.projBtn.textContent = p === 'flat' ? '平面' : '360°';
el.projBtn.classList.toggle('active', p !== 'flat');
el.hint.style.display = p === 'flat' ? 'none' : '';
if (p !== 'flat') { resize(); needsRender = true; }
}
el.projBtn.addEventListener('click', () => {
setProjection(state.projection === 'flat' ? 'equirectangular' : 'flat');
if (state.current) LS.set('proj:' + state.current.name, state.projection);
});
// ---------------------------------------------------------------- playback
function togglePlay() {
if (!video.src) return;
if (video.paused) video.play().catch(err => toast(`無法播放:${err.message}`, { error: true }));
else video.pause();
}
el.playBtn.addEventListener('click', togglePlay);
el.bigPlay.addEventListener('click', togglePlay);
function updatePlayUI() {
const paused = video.paused;
el.playBtn.textContent = paused ? '▶' : '❚❚';
el.bigPlay.hidden = !paused || !video.src;
if (paused) wake();
}
video.addEventListener('play', () => { updatePlayUI(); needsRender = true; });
video.addEventListener('pause', updatePlayUI);
video.addEventListener('ended', updatePlayUI);
let seeking = false;
video.addEventListener('loadedmetadata', () => {
if (state.pendingSeek != null && isFinite(video.duration)) {
video.currentTime = clamp(state.pendingSeek, 0, Math.max(0, video.duration - 1));
}
state.pendingSeek = null;
updateTime();
updatePlayUI();
});
video.addEventListener('loadeddata', () => { needsRender = true; });
video.addEventListener('seeked', () => { needsRender = true; });
video.addEventListener('timeupdate', () => { if (!seeking) updateTime(); savePosition(); });
video.addEventListener('progress', updateBuffered);
video.addEventListener('durationchange', updateTime);
video.addEventListener('waiting', () => { el.spinner.hidden = false; });
video.addEventListener('seeking', () => { el.spinner.hidden = false; });
video.addEventListener('playing', () => { el.spinner.hidden = true; });
video.addEventListener('canplay', () => { el.spinner.hidden = true; });
video.addEventListener('error', () => {
el.spinner.hidden = true;
const code = video.error?.code;
const msg = {
1: '載入被中止', 2: '網路錯誤,無法讀取影片', 3: '解碼失敗(影片可能損壞或編碼不支援)',
4: '瀏覽器不支援這個影片格式/編碼(例如 HEVC),請改用轉檔後的畫質',
}[code] || '未知錯誤';
toast(`播放失敗:${msg}`, { error: true, timeout: 10000 });
});
function updateTime() {
const d = video.duration || 0, t = video.currentTime || 0;
el.time.textContent = `${fmtTime(t)} / ${fmtTime(d)}`;
const pct = d ? (t / d) * 100 : 0;
el.seek.value = String(Math.round(pct * 10));
el.seek.style.setProperty('--played', pct + '%');
updateBuffered();
}
function updateBuffered() {
const d = video.duration || 0;
let end = 0;
for (let i = 0; i < video.buffered.length; i++) {
if (video.buffered.start(i) <= video.currentTime + 0.5 && video.buffered.end(i) > end) end = video.buffered.end(i);
}
el.seek.style.setProperty('--buffered', (d ? (end / d) * 100 : 0) + '%');
}
el.seek.addEventListener('input', () => {
seeking = true;
const d = video.duration || 0;
const t = (el.seek.value / 1000) * d;
el.time.textContent = `${fmtTime(t)} / ${fmtTime(d)}`;
el.seek.style.setProperty('--played', (el.seek.value / 10) + '%');
});
el.seek.addEventListener('change', () => {
seeking = false;
const d = video.duration || 0;
if (d) video.currentTime = (el.seek.value / 1000) * d;
});
function seekBy(sec) {
if (!video.duration) return;
video.currentTime = clamp(video.currentTime + sec, 0, video.duration);
wake();
}
let saveTimer = 0;
function savePosition() {
const now = Date.now();
if (now - saveTimer < 3000 || !state.current || !video.duration) return;
saveTimer = now;
LS.set('pos:' + state.current.name, video.currentTime);
}
// volume / rate
video.volume = LS.get('volume', 1);
video.muted = LS.get('muted', false);
el.vol.value = video.volume;
function updateVolumeUI() {
el.muteBtn.textContent = video.muted || video.volume === 0 ? '🔇' : video.volume < 0.5 ? '🔉' : '🔊';
el.vol.value = video.muted ? 0 : video.volume;
}
el.vol.addEventListener('input', () => { video.volume = +el.vol.value; video.muted = video.volume === 0; });
el.muteBtn.addEventListener('click', () => { video.muted = !video.muted; });
video.addEventListener('volumechange', () => { updateVolumeUI(); LS.set('volume', video.volume); LS.set('muted', video.muted); });
updateVolumeUI();
el.rate.addEventListener('change', () => { video.playbackRate = +el.rate.value; });
el.resetBtn.addEventListener('click', resetView);
// fullscreen
function toggleFullscreen() {
if (document.fullscreenElement) document.exitFullscreen();
else if (el.stage.requestFullscreen) el.stage.requestFullscreen().catch(() => {});
else if (video.webkitEnterFullscreen && state.projection === 'flat') video.webkitEnterFullscreen();
}
el.fsBtn.addEventListener('click', toggleFullscreen);
// auto-hide controls
let idleTimer = null;
function wake() {
el.stage.classList.remove('idle');
clearTimeout(idleTimer);
idleTimer = setTimeout(() => { if (!video.paused && !el.controls.matches(':hover')) el.stage.classList.add('idle'); }, 2500);
}
el.stage.addEventListener('pointermove', wake);
el.stage.addEventListener('pointerdown', wake);
el.controls.addEventListener('pointermove', wake);
wake();
// keyboard
document.addEventListener('keydown', e => {
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(e.target.tagName) || e.ctrlKey || e.metaKey || e.altKey) return;
const look = state.projection !== 'flat';
switch (e.key) {
case ' ': case 'k': e.preventDefault(); togglePlay(); break;
case 'f': toggleFullscreen(); break;
case 'm': video.muted = !video.muted; break;
case 'j': seekBy(-10); break;
case 'l': seekBy(10); break;
case ',': seekBy(-1 / 30); break;
case '.': seekBy(1 / 30); break;
case '0': resetView(); break;
case '+': case '=': state.fov = clamp(state.fov - 5, 30, 110); needsRender = true; break;
case '-': state.fov = clamp(state.fov + 5, 30, 110); needsRender = true; break;
case 'ArrowLeft': e.preventDefault(); if (look) { state.lon -= 5; needsRender = true; } else seekBy(-5); break;
case 'ArrowRight': e.preventDefault(); if (look) { state.lon += 5; needsRender = true; } else seekBy(5); break;
case 'ArrowUp': e.preventDefault(); if (look) { state.lat += 5; needsRender = true; } else video.volume = clamp(video.volume + 0.1, 0, 1); break;
case 'ArrowDown': e.preventDefault(); if (look) { state.lat -= 5; needsRender = true; } else video.volume = clamp(video.volume - 0.1, 0, 1); break;
default: return;
}
wake();
});
// ---------------------------------------------------------------- sources & quality
function streamUrl(name, q) { return `/stream/${encodeURIComponent(name)}?q=${encodeURIComponent(q)}`; }
function loadSource({ keepTime = false } = {}) {
const v = state.current;
if (!v) return;
if (keepTime && video.src) {
state.pendingSeek = video.currentTime;
state.wantPlay = !video.paused && !video.ended;
}
el.spinner.hidden = false;
video.src = streamUrl(v.name, state.quality);
video.load();
if (state.wantPlay) video.play().catch(() => {});
state.wantPlay = false;
updatePlayUI();
}
/** Quality entries for the current video: original + every applicable variant. */
function qualityEntries(v) {
if (!v) return [];
const list = [{
id: 'original', label: '原始', ready: true,
width: v.info?.video?.width, height: v.info?.video?.height,
size: v.size, bitrateText: fmtBps(v.info?.bitrate),
}];
for (const q of state.config.qualities) {
const var_ = v.variants?.[q.id];
if (!var_) continue;
list.push({
id: q.id, label: q.label, ready: !!var_.ready,
width: var_.width, height: var_.height, size: var_.size, bitrateText: fmtRate(var_.bitrate),
job: activeJobFor(v.name, q.id),
});
}
return list;
}
function activeJobFor(name, qid) {
return state.jobs.find(j => j.name === name && (j.status === 'queued' || j.status === 'running') && j.targets.some(t => t.quality === qid));
}
const targetsText = (job) => job.targets.map(t => `${t.label} ${t.width}×${t.height}`).join('');
function renderQualitySelect() {
const entries = qualityEntries(state.current);
el.qualSel.innerHTML = '';
for (const q of entries) {
const o = document.createElement('option');
o.value = q.id;
let text = `${q.label} ${q.width}×${q.height} · ${q.bitrateText}`;
if (q.ready) text += q.id === 'original' ? ` · ${fmtBytes(q.size)}` : ` · ${fmtBytes(q.size)}`;
else if (q.job) text += q.job.status === 'running' ? ` · 轉檔中 ${Math.round(q.job.progress * 100)}%` : ' · 排隊中';
else text += ' · 尚未轉檔(選取即開始)';
o.textContent = text;
el.qualSel.appendChild(o);
}
el.qualSel.value = state.quality;
el.qualSel.disabled = !entries.length;
}
async function setQuality(qid, { silent = false } = {}) {
const entry = qualityEntries(state.current).find(q => q.id === qid);
if (!entry) return;
if (entry.ready) {
if (qid !== state.quality) {
state.quality = qid;
LS.set('qualityPref', qid);
loadSource({ keepTime: true });
if (!silent) toast(`切換到 ${entry.label} ${entry.width}×${entry.height}`);
}
} else if (entry.job) {
toast(entry.job.status === 'running'
? `${entry.label} 正在轉檔:${Math.round(entry.job.progress * 100)}%`
: `${entry.label} 已在排隊中`);
} else {
await requestTranscode(state.current.name, { quality: qid });
}
renderQualitySelect();
}
el.qualSel.addEventListener('change', () => setQuality(el.qualSel.value));
/** payload: { quality } | { qualities } | { all: true } */
async function requestTranscode(name, payload) {
try {
const job = await api('/api/transcode', { method: 'POST', body: JSON.stringify({ name, ...payload }) });
toast(`已加入轉檔佇列:${targetsText(job)},完成後會通知你`, { timeout: 6000 });
} catch (e) {
toast(`無法開始轉檔:${e.message}`, { error: true, timeout: 8000 });
}
}
function pickQuality(v) {
const entries = qualityEntries(v);
const pref = LS.get('qualityPref', 'original');
const hit = entries.find(q => q.id === pref && q.ready);
return hit ? hit.id : 'original';
}
// ---------------------------------------------------------------- videos
async function loadVideos() {
try {
state.videos = await api('/api/videos');
} catch (e) {
toast(`無法讀取影片清單:${e.message}`, { error: true, timeout: 10000 });
state.videos = [];
}
const cur = state.current?.name;
el.videoSel.innerHTML = '';
if (!state.videos.length) {
const o = document.createElement('option');
o.textContent = '(資料夾裡沒有影片)';
el.videoSel.appendChild(o);
}
for (const v of state.videos) {
const o = document.createElement('option');
o.value = v.name;
const res = v.info?.video ? ` (${v.info.video.width}×${v.info.video.height}` + (v.info.projection === 'equirectangular' ? ', 360°)' : ')') : (v.error ? ' (無法讀取)' : '');
o.textContent = `${v.name}${res}`;
el.videoSel.appendChild(o);
}
if (cur) {
el.videoSel.value = cur;
state.current = state.videos.find(v => v.name === cur) || null;
}
renderQualitySelect();
renderPanel();
renderInfo();
}
function selectVideo(name) {
const v = state.videos.find(x => x.name === name);
if (!v) return;
state.current = v;
el.videoSel.value = name;
LS.set('lastVideo', name);
const autoProj = v.info?.projection === 'equirectangular' ? 'equirectangular' : 'flat';
setProjection(LS.get('proj:' + name, autoProj));
resetView();
state.quality = pickQuality(v);
const pos = LS.get('pos:' + name, 0);
state.pendingSeek = pos > 5 && v.info?.duration && pos < v.info.duration - 10 ? pos : null;
loadSource();
renderQualitySelect();
renderInfo();
renderPanel();
el.stage.focus({ preventScroll: true });
}
el.videoSel.addEventListener('change', () => { state.wantPlay = true; selectVideo(el.videoSel.value); });
el.refreshBtn.addEventListener('click', async () => { await loadVideos(); toast('已重新掃描資料夾'); });
function renderInfo() {
const v = state.current;
if (!v) { el.info.innerHTML = ''; return; }
const i = v.info;
if (!i?.video) { el.info.innerHTML = `<b>${esc(v.name)}</b> · ${esc(v.error || '無影像資訊')}`; return; }
const tag = i.projection === 'equirectangular'
? `<span class="tag sph">360° ${i.projectionSource === 'metadata' ? 'metadata' : '比例推測'}</span>`
: '<span class="tag">平面</span>';
el.info.innerHTML = `<b>${esc(v.name)}</b> · ${i.video.width}×${i.video.height} · ${i.video.fps || '?'} fps · ${fmtBps(i.bitrate)} · ${fmtTime(i.duration)}${tag}`;
}
// ---------------------------------------------------------------- panel
el.panelBtn.addEventListener('click', () => { el.panel.hidden = !el.panel.hidden; renderPanel(); });
el.panelClose.addEventListener('click', () => { el.panel.hidden = true; });
el.clearJobsBtn.addEventListener('click', () => api('/api/jobs/finished', { method: 'DELETE' }).catch(() => {}));
function renderJobsBadge() {
const active = state.jobs.filter(j => j.status === 'queued' || j.status === 'running').length;
el.panelBadge.textContent = active;
el.panelBadge.hidden = !active;
}
function renderPanel() {
renderJobsBadge();
if (el.panel.hidden) return;
const v = state.current;
el.panelVideoName.textContent = v ? v.name : '—';
const entries = qualityEntries(v);
const pending = entries.filter(q => !q.ready && !q.job);
const allBtn = pending.length >= 2
? `<button class="btn primary" data-act="transcode-all" style="width:100%;margin-bottom:10px">一次轉出全部畫質(${pending.map(q => q.label).join('')}</button>
<p class="muted">來源只需讀取一次;對放在網路磁碟上的影片,這比分開轉檔快好幾倍。</p>`
: '';
el.panelVariants.innerHTML = allBtn + entries.map(q => {
const playing = q.id === state.quality;
let status, actions = '', bar = '';
if (q.ready) {
status = `<span class="status ready">${q.id === 'original' ? '原檔' : '已轉檔'}</span>`;
actions = playing
? `<span class="status">播放中</span>`
: `<button class="btn sm primary" data-act="play" data-q="${q.id}">播放</button>`;
if (q.id !== 'original') actions += `<button class="btn sm danger" data-act="delete" data-q="${q.id}">刪除</button>`;
} else if (q.job) {
const j = q.job;
status = `<span class="status ${j.status}">${j.status === 'running' ? `轉檔中 ${Math.round(j.progress * 100)}%` : '排隊中'}</span>`;
actions = `<button class="btn sm" data-act="cancel" data-job="${j.id}">取消</button>`;
bar = `<div class="bar"><i style="width:${(j.progress * 100).toFixed(1)}%"></i></div>`;
} else {
status = `<span class="status">尚未轉檔</span>`;
actions = `<button class="btn sm primary" data-act="transcode" data-q="${q.id}">轉檔</button>`;
}
const meta = [`${q.width}×${q.height}`, q.bitrateText, q.size ? fmtBytes(q.size) : null,
q.job?.status === 'running' ? `${q.job.fps.toFixed(0)} fps · ${q.job.speed.toFixed(2)}× · 剩餘 ${q.job.eta != null ? fmtTime(q.job.eta) : '…'}` : null,
q.job?.note || null,
].filter(Boolean).join(' · ');
return `<div class="variant">
<div class="top"><span class="name">${esc(q.label)}</span>${status}<span class="actions">${actions}</span></div>
<div class="meta">${esc(meta)}</div>${bar}
</div>`;
}).join('') || '<div class="empty">請先選擇影片</div>';
const jobs = [...state.jobs].sort((a, b) => b.createdAt - a.createdAt);
el.panelJobs.innerHTML = jobs.map(j => {
const st = { queued: '排隊中', running: `轉檔中 ${Math.round(j.progress * 100)}%`, done: '完成', error: '失敗', cancelled: '已取消' }[j.status] || j.status;
const meta = [targetsText(j),
j.status === 'running' ? `${j.fps.toFixed(0)} fps · ${j.speed.toFixed(2)}× · 剩餘 ${j.eta != null ? fmtTime(j.eta) : '…'}` : null,
j.mode ? { cuda: 'GPU', nvenc: 'NVENC', cpu: 'CPU' }[j.mode] : null,
j.status === 'done' && j.startedAt && j.finishedAt ? `耗時 ${fmtTime((j.finishedAt - j.startedAt) / 1000)}` : null,
j.note || null,
].filter(Boolean).join(' · ');
const cancel = j.status === 'queued' || j.status === 'running' ? `<button class="btn sm" data-act="cancel" data-job="${j.id}">取消</button>` : '';
return `<div class="job">
<div class="top"><span class="name">${esc(j.name)}</span><span class="status ${j.status}">${st}</span><span class="actions">${cancel}</span></div>
<div class="meta">${esc(meta)}</div>
${j.status === 'running' ? `<div class="bar"><i style="width:${(j.progress * 100).toFixed(1)}%"></i></div>` : ''}
${j.error ? `<div class="err">${esc(j.error)}</div>` : ''}
</div>`;
}).join('') || '<div class="empty">目前沒有轉檔工作</div>';
}
el.panel.addEventListener('click', async e => {
const b = e.target.closest('button[data-act]');
if (!b) return;
const { act, q, job } = b.dataset;
try {
if (act === 'play') await setQuality(q);
else if (act === 'transcode') await requestTranscode(state.current.name, { quality: q });
else if (act === 'transcode-all') await requestTranscode(state.current.name, { all: true });
else if (act === 'cancel') await api(`/api/jobs/${job}`, { method: 'DELETE' });
else if (act === 'delete') {
await api(`/api/variant?name=${encodeURIComponent(state.current.name)}&quality=${encodeURIComponent(q)}`, { method: 'DELETE' });
if (state.quality === q) { state.quality = 'original'; loadSource({ keepTime: true }); }
await loadVideos();
toast('已刪除轉檔檔案');
}
} catch (err) {
toast(err.message, { error: true });
}
});
// ---------------------------------------------------------------- live updates
function connectSSE() {
const es = new EventSource('/api/events');
es.addEventListener('jobs', e => {
state.jobs = JSON.parse(e.data);
renderQualitySelect();
renderPanel();
});
es.addEventListener('finished', async e => {
const job = JSON.parse(e.data);
await loadVideos();
if (job.status === 'done') {
if (state.current?.name === job.name) {
const best = job.targets[0]; // targets are sorted widest first
toast(`${targetsText(job)} 轉檔完成`, {
timeout: 15000,
action: { label: `切換到「${best.label}`, fn: () => setQuality(best.quality) },
});
} else {
toast(`${job.name}${targetsText(job)} 轉檔完成`, { timeout: 8000 });
}
} else if (job.status === 'error') {
toast(`轉檔失敗(${job.name}):${job.error}`, { error: true, timeout: 15000 });
}
});
es.onerror = () => { /* EventSource reconnects by itself */ };
}
// ---------------------------------------------------------------- boot
(async function init() {
try {
state.config = await api('/api/config');
} catch (e) {
toast(`無法連線到伺服器:${e.message}`, { error: true, timeout: 0 });
return;
}
el.encoderInfo.textContent = `轉檔引擎:${state.config.encoder.label}  資料夾:${state.config.videoDir}`;
await loadVideos();
connectSSE();
const last = LS.get('lastVideo');
const pick = state.videos.find(v => v.name === last) || state.videos[0];
if (pick) selectVideo(pick.name);
resize();
loop();
})();
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>360 Player</title>
<link rel="stylesheet" href="/style.css">
<script type="importmap">{ "imports": { "three": "/vendor/three/three.module.js" } }</script>
</head>
<body>
<div id="app">
<header id="topbar">
<div class="brand">360<span>Player</span></div>
<label class="field">
<span class="field-label">影片</span>
<select id="videoSelect" title="選擇影片"></select>
<button id="refreshBtn" class="icon-btn" title="重新掃描資料夾"></button>
</label>
<label class="field">
<span class="field-label">畫質</span>
<select id="qualitySelect" title="選擇播放畫質"></select>
</label>
<button id="projBtn" class="pill" title="切換 360° / 平面 顯示">360°</button>
<button id="panelBtn" class="pill" title="轉檔與畫質管理">轉檔 <span id="panelBadge" class="badge" hidden></span></button>
<div id="info" class="info"></div>
</header>
<main id="stage" tabindex="0">
<canvas id="gl"></canvas>
<video id="video" playsinline preload="metadata"></video>
<div id="hint" class="hint">拖曳環視 · 滾輪/雙指縮放 · 空白鍵播放 · F 全螢幕</div>
<div id="spinner" class="spinner" hidden></div>
<button id="bigPlay" class="big-play" title="播放"></button>
<div id="toast" class="toast" hidden></div>
<div id="controls" class="controls">
<input id="seek" type="range" min="0" max="1000" value="0" step="1" aria-label="進度">
<div class="row">
<button id="playBtn" class="ctl" title="播放/暫停 (空白鍵)"></button>
<span id="time" class="time">0:00 / 0:00</span>
<span class="spacer"></span>
<button id="muteBtn" class="ctl" title="靜音 (M)">🔊</button>
<input id="vol" class="vol" type="range" min="0" max="1" step="0.02" value="1" aria-label="音量">
<select id="rate" class="ctl-select" title="播放速度">
<option value="0.5">0.5×</option>
<option value="0.75">0.75×</option>
<option value="1" selected>1×</option>
<option value="1.25">1.25×</option>
<option value="1.5">1.5×</option>
<option value="2">2×</option>
</select>
<button id="resetBtn" class="ctl" title="重置視角 (0)"></button>
<button id="fsBtn" class="ctl" title="全螢幕 (F)"></button>
</div>
</div>
</main>
<aside id="panel" class="panel" hidden>
<div class="panel-head">
<h2>轉檔 / 畫質</h2>
<button id="panelClose" class="icon-btn" title="關閉"></button>
</div>
<div class="panel-body">
<p class="muted" id="encoderInfo"></p>
<h3 id="panelVideoName"></h3>
<div id="panelVariants" class="variants"></div>
<div class="panel-section-head">
<h3>轉檔佇列</h3>
<button id="clearJobsBtn" class="link-btn">清除已完成</button>
</div>
<div id="panelJobs" class="jobs"></div>
</div>
</aside>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>
+178
View File
@@ -0,0 +1,178 @@
:root {
--bg: #0b0d10;
--bar: #14181d;
--fg: #e8ebef;
--muted: #8a939e;
--line: #252b33;
--accent: #4cc2ff;
--accent-2: #ffb347;
--danger: #ff6b6b;
--ok: #5ddc8a;
--radius: 8px;
font-family: system-ui, -apple-system, "Segoe UI", "Noto Sans TC", "Microsoft JhengHei", sans-serif;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--fg); overflow: hidden; }
button, select, input { font: inherit; color: inherit; }
button { cursor: pointer; }
h2, h3 { margin: 0; font-weight: 600; }
#app { height: 100%; display: grid; grid-template-rows: auto 1fr; }
/* ---------- top bar ---------- */
#topbar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 8px 12px; background: var(--bar); border-bottom: 1px solid var(--line);
}
.brand { font-weight: 800; letter-spacing: .5px; font-size: 18px; color: var(--accent); }
.brand span { color: var(--fg); font-weight: 300; margin-left: 4px; }
.field { display: flex; align-items: center; gap: 6px; min-width: 0; }
.field-label { color: var(--muted); font-size: 13px; white-space: nowrap; }
select {
background: #1d232a; border: 1px solid var(--line); border-radius: var(--radius);
padding: 6px 28px 6px 10px; max-width: 46vw; text-overflow: ellipsis;
appearance: none; -webkit-appearance: none;
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position: calc(100% - 14px) 55%, calc(100% - 9px) 55%;
background-size: 5px 5px, 5px 5px; background-repeat: no-repeat;
}
select:focus { outline: 1px solid var(--accent); }
.icon-btn {
background: transparent; border: 1px solid var(--line); border-radius: var(--radius);
width: 32px; height: 32px; color: var(--muted);
}
.icon-btn:hover { color: var(--fg); border-color: var(--muted); }
.pill {
background: #1d232a; border: 1px solid var(--line); border-radius: 999px;
padding: 5px 12px; color: var(--fg); position: relative;
}
.pill:hover { border-color: var(--muted); }
.pill.active { background: var(--accent); color: #06202e; border-color: var(--accent); font-weight: 600; }
.badge {
display: inline-block; min-width: 18px; padding: 0 5px; margin-left: 4px;
background: var(--accent-2); color: #2b1a00; border-radius: 999px; font-size: 11px; font-weight: 700; line-height: 18px;
}
.badge[hidden] { display: none; }
.info { margin-left: auto; color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.info b { color: var(--fg); font-weight: 600; }
.tag { display: inline-block; padding: 1px 6px; border-radius: 4px; background: #27313b; color: var(--fg); font-size: 11px; margin-left: 6px; }
.tag.sph { background: #123a4c; color: var(--accent); }
/* ---------- stage ---------- */
#stage { position: relative; background: #000; overflow: hidden; outline: none; user-select: none; -webkit-user-select: none; }
#gl, #video { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#gl { touch-action: none; cursor: grab; }
#gl.dragging { cursor: grabbing; }
#video { object-fit: contain; background: #000; }
#stage.flat #gl { display: none; }
#stage:not(.flat) #video { opacity: 0; pointer-events: none; width: 1px; height: 1px; }
.hint {
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,.55); color: #ddd; font-size: 12px; padding: 6px 12px; border-radius: 999px;
pointer-events: none; transition: opacity .4s; backdrop-filter: blur(4px);
}
.spinner {
position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; margin: -22px 0 0 -22px;
border: 3px solid rgba(255,255,255,.2); border-top-color: var(--accent); border-radius: 50%;
animation: spin .8s linear infinite; pointer-events: none;
}
.spinner[hidden] { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.big-play {
position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%);
width: 84px; height: 84px; border-radius: 50%; border: 0;
background: rgba(20,24,29,.75); color: #fff; font-size: 34px; padding-left: 8px;
box-shadow: 0 6px 30px rgba(0,0,0,.5); backdrop-filter: blur(6px);
}
.big-play:hover { background: var(--accent); color: #06202e; }
.big-play[hidden] { display: none; }
.toast {
position: absolute; left: 50%; bottom: 84px; transform: translateX(-50%);
background: #1d232a; border: 1px solid var(--line); color: var(--fg);
padding: 10px 14px; border-radius: var(--radius); font-size: 14px; max-width: min(90vw, 520px);
display: flex; gap: 12px; align-items: center; box-shadow: 0 8px 30px rgba(0,0,0,.5); z-index: 5;
}
.toast[hidden] { display: none; }
.toast.error { border-color: var(--danger); }
.toast button { background: var(--accent); color: #06202e; border: 0; border-radius: 6px; padding: 4px 10px; font-weight: 600; white-space: nowrap; }
/* ---------- player controls ---------- */
.controls {
position: absolute; left: 0; right: 0; bottom: 0; padding: 24px 14px 10px;
background: linear-gradient(to top, rgba(0,0,0,.8), rgba(0,0,0,0));
transition: opacity .25s; z-index: 3;
}
#stage.idle .controls, #stage.idle .hint { opacity: 0; pointer-events: none; }
#stage.idle #gl { cursor: none; }
.row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.spacer { flex: 1; }
.ctl { background: transparent; border: 0; color: #fff; font-size: 18px; width: 36px; height: 36px; border-radius: 6px; }
.ctl:hover { background: rgba(255,255,255,.12); }
.time { font-variant-numeric: tabular-nums; font-size: 13px; color: #ddd; }
.ctl-select { background: rgba(255,255,255,.1); border: 0; border-radius: 6px; padding: 4px 22px 4px 8px; font-size: 13px; }
.vol { width: 90px; accent-color: var(--accent); }
#seek {
--played: 0%; --buffered: 0%;
width: 100%; height: 6px; appearance: none; -webkit-appearance: none; background: transparent; cursor: pointer; margin: 0;
}
#seek::-webkit-slider-runnable-track {
height: 6px; border-radius: 3px;
background: linear-gradient(to right, var(--accent) var(--played), rgba(255,255,255,.45) var(--played), rgba(255,255,255,.45) var(--buffered), rgba(255,255,255,.18) var(--buffered));
}
#seek::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #fff; margin-top: -4px;
box-shadow: 0 0 0 2px rgba(0,0,0,.3);
}
#seek::-moz-range-track {
height: 6px; border-radius: 3px;
background: linear-gradient(to right, var(--accent) var(--played), rgba(255,255,255,.45) var(--played), rgba(255,255,255,.45) var(--buffered), rgba(255,255,255,.18) var(--buffered));
}
#seek::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: #fff; border: 0; }
/* ---------- side panel ---------- */
.panel {
position: fixed; top: 0; right: 0; bottom: 0; width: min(420px, 100vw);
background: var(--bar); border-left: 1px solid var(--line); z-index: 20;
display: flex; flex-direction: column; box-shadow: -10px 0 40px rgba(0,0,0,.5);
}
.panel[hidden] { display: none; }
.panel-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--line); }
.panel-body { padding: 14px 16px; overflow: auto; flex: 1; }
.panel-body h3 { font-size: 14px; margin: 10px 0 8px; word-break: break-all; }
.panel-section-head { display: flex; align-items: center; justify-content: space-between; margin-top: 22px; }
.muted { color: var(--muted); font-size: 12px; margin: 0 0 8px; }
.link-btn { background: none; border: 0; color: var(--accent); font-size: 12px; padding: 0; }
.link-btn:hover { text-decoration: underline; }
.variant, .job {
border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; background: #10141a;
}
.variant .top, .job .top { display: flex; align-items: center; gap: 8px; }
.variant .name, .job .name { font-weight: 600; }
.variant .meta, .job .meta { color: var(--muted); font-size: 12px; margin-top: 3px; word-break: break-all; }
.variant .actions, .job .actions { margin-left: auto; display: flex; gap: 6px; }
.btn {
background: #1d232a; border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; font-size: 13px;
}
.btn:hover { border-color: var(--muted); }
.btn.primary { background: var(--accent); color: #06202e; border-color: var(--accent); font-weight: 600; }
.btn.danger { color: var(--danger); }
.btn.sm { padding: 2px 8px; font-size: 12px; }
.status { font-size: 12px; padding: 1px 7px; border-radius: 999px; background: #27313b; white-space: nowrap; }
.status.ready, .status.done { background: #153f2a; color: var(--ok); }
.status.running { background: #123a4c; color: var(--accent); }
.status.queued { background: #3d3117; color: var(--accent-2); }
.status.error { background: #4a1d1d; color: var(--danger); }
.bar { height: 6px; background: #232a33; border-radius: 3px; margin-top: 8px; overflow: hidden; }
.bar > i { display: block; height: 100%; background: var(--accent); width: 0; transition: width .3s; }
.job .err { color: var(--danger); font-size: 12px; margin-top: 4px; word-break: break-all; }
.empty { color: var(--muted); font-size: 13px; padding: 6px 0; }
@media (max-width: 760px) {
#topbar { gap: 8px; padding: 6px 8px; }
.brand, .info, .field-label, .hint, .vol, #resetBtn { display: none; }
select { max-width: 40vw; }
}
+234
View File
@@ -0,0 +1,234 @@
import express from 'express';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { ProbeCache } from './lib/probe.js';
import { Transcoder, MODE_LABEL } from './lib/transcode.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// CONFIG=path/to/other.json lets you run a second instance (e.g. for tests) without touching config.json.
const configPath = path.resolve(__dirname, process.env.CONFIG || 'config.json');
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
// Environment overrides (used by the Docker image: /videos, /cache).
if (process.env.PORT) config.port = Number(process.env.PORT);
if (process.env.HOST) config.host = process.env.HOST;
if (process.env.VIDEO_DIR) config.videoDir = process.env.VIDEO_DIR;
if (process.env.CACHE_DIR) config.cacheDir = process.env.CACHE_DIR;
const VIDEO_DIR = path.resolve(__dirname, config.videoDir);
const CACHE_DIR = path.resolve(__dirname, config.cacheDir);
const EXT = new Set((config.extensions || ['.mp4']).map(e => e.toLowerCase()));
const QUALITY_IDS = new Set(config.qualities.map(q => q.id));
const probeCache = new ProbeCache(path.join(CACHE_DIR, 'probe-cache.json'));
const isSafeName = (n) =>
typeof n === 'string' && n.length > 0 && n !== '.' && n !== '..' &&
!n.includes('/') && !n.includes('\\') && path.basename(n) === n;
async function resolveSource(name) {
if (!isSafeName(name)) throw Object.assign(new Error('檔名不合法'), { status: 400 });
const file = path.join(VIDEO_DIR, name);
let stat;
try { stat = await fs.stat(file); } catch { throw Object.assign(new Error('找不到影片'), { status: 404 }); }
const info = await probeCache.get(file, stat);
return { file, stat, info };
}
const transcoder = new Transcoder({ cacheDir: CACHE_DIR, qualities: config.qualities, resolveSource });
/** Run async fn over items with bounded concurrency, preserving order. */
async function mapLimit(items, limit, fn) {
const out = new Array(items.length);
let i = 0;
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); }
}));
return out;
}
async function listVideos() {
let entries;
try { entries = await fs.readdir(VIDEO_DIR, { withFileTypes: true }); }
catch (e) { throw Object.assign(new Error(`無法讀取資料夾 ${VIDEO_DIR}${e.message}`), { status: 500 }); }
const names = entries
.filter(e => e.isFile() && EXT.has(path.extname(e.name).toLowerCase()))
.map(e => e.name)
.sort((a, b) => a.localeCompare(b, 'zh-Hant', { numeric: true }));
return mapLimit(names, 4, describeVideo);
}
async function describeVideo(name) {
const file = path.join(VIDEO_DIR, name);
const stat = await fs.stat(file);
let info = null, error = null;
try { info = await probeCache.get(file, stat); }
catch (e) { error = e.message; }
const variants = {};
if (info?.video) {
for (const q of config.qualities) {
if (q.width >= info.video.width) continue; // would not shrink anything
const height = Math.round((q.width * info.video.height) / info.video.width / 2) * 2;
variants[q.id] = {
label: q.label, width: q.width, height, bitrate: q.bitrate,
...(await transcoder.variantStatus(name, q.id, stat)),
};
}
}
return { name, size: stat.size, mtime: stat.mtimeMs, info, error, variants };
}
const app = express();
app.disable('x-powered-by');
app.use(express.json());
// Access log for media requests (set LOG_STREAM=0 to silence).
if (process.env.LOG_STREAM !== '0') {
app.use('/stream', (req, res, next) => {
const t0 = Date.now();
res.on('close', () => {
const sent = res.socket ? res.socket.bytesWritten : 0;
console.log(`[stream] ${res.statusCode} ${decodeURIComponent(req.url)} range=${req.headers.range || '-'} ` +
`${(res.getHeader('content-length') || '?')}B ${Date.now() - t0}ms${res.writableFinished ? '' : ' (aborted)'}`);
});
next();
});
}
app.get('/api/config', (req, res) => {
res.json({
videoDir: VIDEO_DIR,
cacheDir: CACHE_DIR,
qualities: config.qualities,
encoder: { modes: transcoder.modes, label: MODE_LABEL[transcoder.modes[0]] },
});
});
app.get('/api/videos', async (req, res) => {
res.json(await listVideos());
});
app.get('/api/jobs', (req, res) => res.json(transcoder.list()));
/**
* Body: { name, quality: "1920" } | { name, qualities: ["2560","1920"] } | { name, all: true }
* `all` = every applicable quality that is not ready yet, produced in one pass.
*/
app.post('/api/transcode', async (req, res) => {
const { name, quality, qualities, all } = req.body || {};
if (!isSafeName(name)) return res.status(400).json({ error: '檔名不合法' });
let qids;
if (all) {
const v = await describeVideo(name);
qids = Object.entries(v.variants).filter(([, s]) => !s.ready).map(([id]) => id);
if (!qids.length) return res.status(409).json({ error: '所有畫質都已經轉檔完成' });
} else {
qids = Array.isArray(qualities) ? qualities.map(String) : [String(quality)];
if (!qids.length || qids.some(q => !QUALITY_IDS.has(q))) return res.status(400).json({ error: '未知的畫質' });
}
res.json(await transcoder.enqueue(name, qids));
});
app.delete('/api/jobs/finished', (req, res) => { transcoder.clearFinished(); res.json({ ok: true }); });
app.delete('/api/jobs/:id', (req, res) => res.json(transcoder.cancel(req.params.id)));
app.delete('/api/variant', async (req, res) => {
const { name, quality } = req.query;
if (!isSafeName(name)) return res.status(400).json({ error: '檔名不合法' });
if (!QUALITY_IDS.has(String(quality))) return res.status(400).json({ error: '未知的畫質' });
await transcoder.deleteVariant(name, String(quality));
res.json({ ok: true });
});
// ---- Server-sent events: job progress pushed to every open page ----
const sseClients = new Set();
let sseTimer = null;
function broadcast(type, data) {
const payload = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
for (const c of sseClients) c.write(payload);
}
transcoder.on('update', () => {
if (sseTimer) return; // throttle progress spam to ~4/s
sseTimer = setTimeout(() => { sseTimer = null; broadcast('jobs', transcoder.list()); }, 250);
});
transcoder.on('finished', job => broadcast('finished', job));
app.get('/api/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 2000\n\n');
res.write(`event: jobs\ndata: ${JSON.stringify(transcoder.list())}\n\n`);
sseClients.add(res);
const ping = setInterval(() => res.write(': ping\n\n'), 20000);
req.on('close', () => { clearInterval(ping); sseClients.delete(res); });
});
// ---- Media streaming with HTTP Range support ----
const MIME = { '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.mov': 'video/mp4', '.webm': 'video/webm' };
app.get('/stream/:name', async (req, res) => {
const name = req.params.name;
if (!isSafeName(name)) return res.status(400).end();
const q = String(req.query.q || 'original');
let file;
if (q === 'original') file = path.join(VIDEO_DIR, name);
else if (QUALITY_IDS.has(q)) file = transcoder.variantPath(name, q);
else return res.status(400).end();
try { await fs.access(file); } catch { return res.status(404).end(); }
res.sendFile(file, {
acceptRanges: true,
cacheControl: false,
etag: false,
lastModified: true,
dotfiles: 'allow',
headers: {
'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
'Cache-Control': 'no-cache',
},
}, err => {
// Client aborts (seeking, closing the tab) surface here; nothing to do.
if (err && !res.headersSent && err.code !== 'ECONNABORTED') res.status(err.status || 500).end();
});
});
app.use('/vendor/three', express.static(path.join(__dirname, 'node_modules/three/build'), { maxAge: '1d' }));
app.use(express.static(path.join(__dirname, 'public')));
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'server error' });
});
// ---- Start ----
await probeCache.load();
const modes = await transcoder.init();
const server = app.listen(config.port, config.host, () => {
console.log(`360 Player`);
console.log(` 影片資料夾 : ${VIDEO_DIR}`);
console.log(` 轉檔快取 : ${CACHE_DIR}`);
console.log(` 轉檔引擎 : ${MODE_LABEL[modes[0]]} (備援: ${modes.slice(1).map(m => MODE_LABEL[m]).join(' → ') || '無'})`);
console.log(` 網址 : http://localhost:${config.port}`);
for (const [ifname, addrs] of Object.entries(os.networkInterfaces())) {
for (const a of addrs) {
if (a.family === 'IPv4' && !a.internal) console.log(` http://${a.address}:${config.port} (${ifname})`);
}
}
});
server.keepAliveTimeout = 65000;
// Graceful stop (docker stop / Ctrl+C): kill the running ffmpeg so no half-written .part survives.
for (const sig of ['SIGTERM', 'SIGINT']) {
process.on(sig, () => {
console.log(`收到 ${sig},關閉中…`);
if (transcoder.running) transcoder.cancel(transcoder.running.id);
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 3000).unref();
});
}