diff --git a/.env.example b/.env.example index b8cc16f..820ff80 100644 --- a/.env.example +++ b/.env.example @@ -6,11 +6,25 @@ VIDEO_DIR=/volume1/photo/Badminton # 轉檔輸出與快取的存放位置(容器的 /cache;每部影片三種畫質約需原檔 70% 的空間) CACHE_DIR=/volume1/docker/360_player/cache -# 對外埠號(瀏覽器連 http://NAS-IP:8360) +# HTTP 埠(區網用 IP 直連;憑證是網域名,用 IP 連 HTTPS 一定會跳警告) PORT=8360 +# HTTPS 埠(由 nginx 容器終結 TLS 後轉給 app) +HTTPS_PORT=8443 + # 時區 TZ=Asia/Taipei # CPU 轉檔的 libx264 preset:veryfast(預設)/ superfast / ultrafast X264_PRESET=veryfast + +# --- SSL --- +# 憑證放的資料夾(唯讀掛進 nginx 容器的 /etc/nginx/certs)。 +# DSM 續期後直接覆蓋這裡的檔案即可,nginx 會自己 reload,不用重開容器。 +SSL_CERT_DIR=/volume1/docker/certs +SSL_CERT_FILE_NAME=cert.pem +SSL_CHAIN_FILE_NAME=chain.pem +SSL_KEY_FILE_NAME=privkey.pem + +# 憑證上的網域;用 _ 表示不限(任何 Host 都接) +NGINX_SERVER_NAME=jianmiau.tk diff --git a/README.md b/README.md index 2aeee34..dd06a46 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # 360 Player 在瀏覽器裡播放 360° 全景影片的本機網頁播放器。 -從下拉選單挑選 `W:\photo\Badminton` 裡的影片、選擇播放畫質;低畫質版本由 ffmpeg(NVIDIA NVENC)轉出並快取在本機。 +從下拉選單挑選影片資料夾(`config.json` 的 `videoDir`,NAS 上是 `/volume1/photo/Badminton`)裡的影片、選擇播放畫質;低畫質版本由 ffmpeg(NVIDIA NVENC)轉出並快取在本機。 ## 啟動 @@ -48,12 +48,14 @@ npm start # 或 node server.js ## 部署到 NAS(Docker) -映像檔以 `node:22-alpine` 為基礎,內含 ffmpeg;影片資料夾與快取都用 volume 掛進容器: +兩個容器:`360-player` 是應用本體(`node:22-alpine`,內含 ffmpeg), +`360-player-web` 是 nginx,負責終結 TLS 再轉給前者。資料夾都用 volume 掛進容器: -| 容器內路徑 | 用途 | 對應環境變數 | -|---|---|---| -| `/videos` | 影片資料夾(唯讀) | `VIDEO_DIR` | -| `/cache` | 轉檔輸出與 probe 快取(需可寫) | `CACHE_DIR` | +| 容器 | 容器內路徑 | 用途 | 對應環境變數 | +|---|---|---|---| +| `360-player` | `/videos` | 影片資料夾(唯讀) | `VIDEO_DIR` | +| `360-player` | `/cache` | 轉檔輸出與 probe 快取(需可寫) | `CACHE_DIR` | +| `360-player-web` | `/etc/nginx/certs` | SSL 憑證(唯讀) | `SSL_CERT_DIR` | ```bash # 1. 把整個專案資料夾複製到 NAS,例如 /volume1/docker/360_player @@ -61,18 +63,48 @@ npm start # 或 node server.js cp .env.example .env && vi .env # 3. 建置並啟動 docker compose up -d --build -# 4. 瀏覽器開 http://NAS-IP:8360 +# 4. 瀏覽器開 https://你的網域:8443(或區網 http://NAS-IP:8360) ``` Synology Container Manager:「專案」→「新增」→ 選這個資料夾,它會讀取 `docker-compose.yml`, 環境變數可在「環境」分頁填(等同 `.env`)。 +### SSL + +TLS 由 `360-player-web`(nginx)終結,Node 那端維持純 HTTP,所以應用程式碼裡沒有任何憑證邏輯。 +憑證放在 `SSL_CERT_DIR`(預設 `/volume1/docker/certs`,和其他專案共用同一份), +唯讀掛進 nginx 容器: + +``` +cert.pem 伺服器憑證 +chain.pem 中介憑證 +privkey.pem 私鑰 +``` + +檔名可用 `SSL_CERT_FILE_NAME` / `SSL_CHAIN_FILE_NAME` / `SSL_KEY_FILE_NAME` 改。 +容器啟動時 entrypoint 會把 `cert.pem` + `chain.pem` 接成 fullchain(順便去掉 CRLF), +並用 `inotifywait` 盯著這個資料夾 —— **DSM 續期後直接覆蓋檔案就好,nginx 會自己 reload,不用重開容器**。 +啟動 log 會印出憑證的 subject 和到期日。 + +兩點注意: + +- 憑證是綁網域的,**用區網 IP 連 HTTPS 一定會跳警告**。所以 compose 同時保留了 + HTTP 的 `${PORT}:8360`:區網走 http、外網走 https。想只留 HTTPS 就把那段 `ports` 刪掉。 +- nginx 這邊關掉了 `proxy_buffering` 並把 `proxy_max_temp_file_size` 設為 0。 + 影片是靠 HTTP Range 串流的,開著緩衝會讓 nginx 先把整段回應寫成暫存檔再吐出去, + 幾 GB 的來源會直接塞爆容器磁碟。`/api/events`(SSE)另外把 `proxy_read_timeout` + 拉到 24 小時,否則轉檔跑到一半進度就不再更新。 + **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)。 +- NAS 有 Intel 內顯時,`docker-compose.yml` 裡的 `devices: /dev/dri` 要打開, + 啟動時會自動偵測並改用 VAAPI 硬體編碼,偵測失敗則自動退回 CPU。 + 已在 Celeron J4025(UHD Graphics 600)+ DSM 實機驗證:容器啟動 log 顯示 + `轉檔引擎:VAAPI 硬體編碼`,`/api/config` 回傳 `modes: ["vaapi","cpu"]`。 + 注意 DSM 內建的 ffmpeg 拿掉了 VAAPI 編碼器,**直接在 NAS 上 `npm start` 只會走 CPU**, + 硬體轉檔只有在容器裡才有。 - **最快的做法**:在有 NVIDIA 顯卡的電腦上先跑 `npm start` 轉完,再把 `cache/` 裡的 `*.mp4` 與 `*.mp4.json` 複製到 NAS 的 `CACHE_DIR`。快取檔只認來源檔的大小與修改時間(容許 2 秒誤差), 所以兩邊看到同一個檔案就能直接共用。 @@ -83,7 +115,7 @@ Synology Container Manager:「專案」→「新增」→ 選這個資料夾 { "port": 8360, "host": "0.0.0.0", - "videoDir": "W:/photo/Badminton", + "videoDir": "/volume1/photo/Badminton", "cacheDir": "./cache", "extensions": [".mp4", ".mov", ".m4v", ".webm"], "qualities": [ @@ -119,5 +151,6 @@ lib/probe.js ffprobe 包裝 + 永續快取(含 360 metadata 判斷) lib/transcode.js ffmpeg 工作佇列(一次解碼多輸出;CUDA → NVENC → CPU 備援) lib/spherical.js 把 Spherical Video V1 metadata 注回 MP4 public/ 前端(Three.js 球體貼圖播放器) +docker/nginx/ nginx 映像檔:終結 TLS,反向代理到 app(憑證變更自動 reload) cache/ 轉檔輸出與 probe 快取(已 gitignore) ``` diff --git a/docker-compose.yml b/docker-compose.yml index dac1746..4981541 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,11 @@ services: image: 360-player:latest restart: unless-stopped ports: + # 區網用 IP 直連的 HTTP(憑證是網域名,IP 連一定會跳警告,所以留一條明文)。 + # 想只走 HTTPS 的話把這兩行刪掉,改用下面的 expose 就好。 - "${PORT:-8360}:8360" + expose: + - "8360" volumes: # 影片資料夾(NAS 上的實際路徑)→ 容器內 /videos,唯讀 - "${VIDEO_DIR:-/volume1/photo/Badminton}:/videos:ro" @@ -21,3 +25,28 @@ services: # 換到沒有 /dev/dri 的機器時要把下面兩行註解掉,否則容器起不來。 devices: - /dev/dri:/dev/dri + + 360-player-web: + container_name: 360-player-web + build: + context: . + dockerfile: docker/nginx/Dockerfile + image: 360-player-web:latest + restart: unless-stopped + depends_on: + - 360-player + ports: + - "${HTTPS_PORT:-8443}:8443" + environment: + TZ: ${TZ:-Asia/Taipei} + NGINX_PORT: 8443 + NGINX_SERVER_NAME: ${NGINX_SERVER_NAME:-_} + SSL_CERT_DIR: /etc/nginx/certs + SSL_CERT_FILE_NAME: ${SSL_CERT_FILE_NAME:-cert.pem} + SSL_CHAIN_FILE_NAME: ${SSL_CHAIN_FILE_NAME:-chain.pem} + SSL_KEY_FILE_NAME: ${SSL_KEY_FILE_NAME:-privkey.pem} + UPSTREAM_HOST: 360-player + UPSTREAM_PORT: 8360 + volumes: + # 憑證來源與 badminton-scoreboard 共用;DSM 續期後 entrypoint 會自動 reload + - "${SSL_CERT_DIR:-/volume1/docker/certs}:/etc/nginx/certs:ro" diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile new file mode 100644 index 0000000..990ef46 --- /dev/null +++ b/docker/nginx/Dockerfile @@ -0,0 +1,10 @@ +FROM nginx:1.27-alpine + +# inotify-tools:憑證續期後自動重載 nginx,不用手動重啟容器。 +RUN apk add --no-cache inotify-tools + +COPY docker/nginx/entrypoint.sh /entrypoint.sh + +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh new file mode 100644 index 0000000..5655d0f --- /dev/null +++ b/docker/nginx/entrypoint.sh @@ -0,0 +1,124 @@ +#!/bin/sh +set -eu + +NGINX_PORT="${NGINX_PORT:-8443}" +NGINX_SERVER_NAME="${NGINX_SERVER_NAME:-_}" +SSL_CERT_DIR="${SSL_CERT_DIR:-/etc/nginx/certs}" +SSL_CERT_FILE_NAME="${SSL_CERT_FILE_NAME:-cert.pem}" +SSL_CHAIN_FILE_NAME="${SSL_CHAIN_FILE_NAME:-chain.pem}" +SSL_KEY_FILE_NAME="${SSL_KEY_FILE_NAME:-privkey.pem}" +UPSTREAM_HOST="${UPSTREAM_HOST:-360-player}" +UPSTREAM_PORT="${UPSTREAM_PORT:-8360}" + +GENERATED_DIR="/etc/nginx/generated" +GENERATED_CERT_PATH="${GENERATED_DIR}/fullchain.pem" +GENERATED_KEY_PATH="${GENERATED_DIR}/privkey.pem" + +mkdir -p "${GENERATED_DIR}" + +normalize_pem_file() { + pem_path="$1" + + # 去掉 CRLF 並確保結尾有換行,否則 cert 和 chain 接起來會黏成一行、nginx 讀不到。 + awk ' + { + sub(/\r$/, "") + print + has_content = 1 + } + END { + if (has_content) { + print "" + } + } + ' "${pem_path}" +} + +build_cert_bundle() { + cert_path="${SSL_CERT_DIR}/${SSL_CERT_FILE_NAME}" + chain_path="${SSL_CERT_DIR}/${SSL_CHAIN_FILE_NAME}" + key_path="${SSL_CERT_DIR}/${SSL_KEY_FILE_NAME}" + + if [ ! -f "${cert_path}" ]; then + echo "Missing certificate file: ${cert_path}" >&2 + exit 1 + fi + + if [ ! -f "${chain_path}" ]; then + echo "Missing chain file: ${chain_path}" >&2 + exit 1 + fi + + if [ ! -f "${key_path}" ]; then + echo "Missing key file: ${key_path}" >&2 + exit 1 + fi + + normalize_pem_file "${cert_path}" > "${GENERATED_CERT_PATH}" + normalize_pem_file "${chain_path}" >> "${GENERATED_CERT_PATH}" + cp "${key_path}" "${GENERATED_KEY_PATH}" + + # 到期日印在 log 裡,容器一起來就看得到憑證還剩多久。 + openssl x509 -in "${cert_path}" -noout -subject -enddate 2>/dev/null || true +} + +write_nginx_config() { + cat > /etc/nginx/conf.d/default.conf </dev/null || true +} + +trap cleanup INT TERM + +nginx -g 'daemon off;' & +NGINX_PID=$! + +wait "${NGINX_PID}"