commit 2a9e59357e74d3df6605926c5026a8fbc47a13a2 Author: JianMiau Date: Fri Aug 21 10:57:17 2026 +0800 初始化 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) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..33f559c --- /dev/null +++ b/.claude/settings.json @@ -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 +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7ee8ee6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +cache +.git +.gitignore +.env +.env.example +.dockerignore +Dockerfile +docker-compose.yml +README.md +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b8cc16f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..841cbb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +cache/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b16cc57 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2aeee34 --- /dev/null +++ b/README.md @@ -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) +``` diff --git a/config.json b/config.json new file mode 100644 index 0000000..54dc22b --- /dev/null +++ b/config.json @@ -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" } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dac1746 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/lib/probe.js b/lib/probe.js new file mode 100644 index 0000000..3f1d14e --- /dev/null +++ b/lib/probe.js @@ -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); + } +} diff --git a/lib/spherical.js b/lib/spherical.js new file mode 100644 index 0000000..48ed8ad --- /dev/null +++ b/lib/spherical.js @@ -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 = + '' + + 'true' + + 'true' + + '360 Player' + + 'equirectangular' + + ''; + +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(); + } +} diff --git a/lib/transcode.js b/lib/transcode.js new file mode 100644 index 0000000..8aed90b --- /dev/null +++ b/lib/transcode.js @@ -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; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1e299fa --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b50c8e7 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..1d1e96a --- /dev/null +++ b/public/app.js @@ -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 = `${esc(msg)}`; + 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 = `${esc(v.name)} · ${esc(v.error || '無影像資訊')}`; return; } + const tag = i.projection === 'equirectangular' + ? `360° ${i.projectionSource === 'metadata' ? 'metadata' : '比例推測'}` + : '平面'; + el.info.innerHTML = `${esc(v.name)} · ${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 + ? ` +

來源只需讀取一次;對放在網路磁碟上的影片,這比分開轉檔快好幾倍。

` + : ''; + el.panelVariants.innerHTML = allBtn + entries.map(q => { + const playing = q.id === state.quality; + let status, actions = '', bar = ''; + if (q.ready) { + status = `${q.id === 'original' ? '原檔' : '已轉檔'}`; + actions = playing + ? `播放中` + : ``; + if (q.id !== 'original') actions += ``; + } else if (q.job) { + const j = q.job; + status = `${j.status === 'running' ? `轉檔中 ${Math.round(j.progress * 100)}%` : '排隊中'}`; + actions = ``; + bar = `
`; + } else { + status = `尚未轉檔`; + actions = ``; + } + 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 `
+
${esc(q.label)}${status}${actions}
+
${esc(meta)}
${bar} +
`; + }).join('') || '
請先選擇影片
'; + + 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' ? `` : ''; + return `
+
${esc(j.name)}${st}${cancel}
+
${esc(meta)}
+ ${j.status === 'running' ? `
` : ''} + ${j.error ? `
${esc(j.error)}
` : ''} +
`; + }).join('') || '
目前沒有轉檔工作
'; +} + +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(); +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..b9482bf --- /dev/null +++ b/public/index.html @@ -0,0 +1,81 @@ + + + + + +360 Player + + + + +
+
+
360Player
+ + + + + + + +
+
+ +
+ + + +
拖曳環視 · 滾輪/雙指縮放 · 空白鍵播放 · F 全螢幕
+ + + + +
+ +
+ + 0:00 / 0:00 + + + + + + +
+
+
+ + +
+ + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..1b688a8 --- /dev/null +++ b/public/style.css @@ -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; } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..0465e87 --- /dev/null +++ b/server.js @@ -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(); + }); +}