初始化 360 全景影片播放器專案並完成 NAS 部署設定
摘要: 將本機開發的 360° 全景影片播放器納入版控,同時把執行環境從 Windows 切換到 Synology NAS,改以 Docker 部署。 根本原因: 專案原本只在有 NVIDIA 顯卡的 Windows 機器上跑,config.json 寫死了 Windows 磁碟機路徑 W:/photo/Badminton,NAS 上無法直接啟動。 另外 DSM 內建的 ffmpeg 拿掉了 VAAPI 編碼器,裸跑只能走 libx264, Celeron J4025 雙核轉 4K 360 影片的速度無法接受。 影響: - 在 NAS 上 npm start 會因為找不到影片資料夾而列不出任何影片 - 即使把路徑改對,轉檔仍只能用 CPU,82 分鐘的 4K 360 影片要跑十幾小時 修法: - config.json 的 videoDir 改為 NAS 實際路徑 /volume1/photo/Badminton - docker-compose.yml 啟用 devices: /dev/dri,讓容器取得 Intel UHD 600 的 render node;容器內 Alpine 版 ffmpeg 保有完整 VAAPI 支援,啟動時 會自動偵測成 vaapi 模式,並保留 libx264 當備援 - .env(不進版控)提供 VIDEO_DIR / CACHE_DIR 等 NAS 路徑給 compose 使用 驗證: 容器啟動 log 顯示「轉檔引擎:VAAPI 硬體編碼(Intel/AMD,/dev/dri)」, /api/config 回傳 modes: ["vaapi","cpu"],/api/videos 正確辨識來源影片為 3840x1920 equirectangular。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
cache
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.example
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
README.md
|
||||
*.log
|
||||
@@ -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 preset:veryfast(預設)/ superfast / ultrafast
|
||||
X264_PRESET=veryfast
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
cache/
|
||||
.env
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
# ffmpeg / ffprobe:偵測 360 metadata 與轉檔。
|
||||
# intel-media-driver / libva-intel-driver:NAS 有 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"]
|
||||
@@ -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,沒有就退回 CPU(libx264)。
|
||||
|
||||
## 操作
|
||||
|
||||
| 動作 | 滑鼠 / 觸控 | 鍵盤 |
|
||||
|---|---|---|
|
||||
| 環視 | 拖曳 | ← → ↑ ↓ |
|
||||
| 縮放(視角) | 滾輪 / 雙指 | `+` `-` |
|
||||
| 播放 / 暫停 | 點一下畫面 | `空白鍵` / `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`;來源檔案變動(大小或修改時間改變)時會自動視為過期並重轉。
|
||||
|
||||
## 部署到 NAS(Docker)
|
||||
|
||||
映像檔以 `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
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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 的 preset;NAS CPU 弱可改 superfast / ultrafast(檔案會稍大)
|
||||
X264_PRESET: ${X264_PRESET:-veryfast}
|
||||
# 本機為 Intel J4025(UHD 600),已啟用 VAAPI 硬體轉檔;
|
||||
# 換到沒有 /dev/dri 的機器時要把下面兩行註解掉,否則容器起不來。
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
+106
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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: 'libx264(CPU)',
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
Generated
+893
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user