initial commit
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
Dockerfile
|
||||||
|
public/_smoke.avi
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY public ./public
|
||||||
|
COPY server ./server
|
||||||
|
COPY README.md ./
|
||||||
|
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
57
README.md
Normal file
57
README.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Frame Stream Player
|
||||||
|
|
||||||
|
A small web app that plays a remote video stream without using browser video decoding. The server uses `ffmpeg` to decode the input URL into:
|
||||||
|
|
||||||
|
- an MP3 audio stream served to a normal `<audio>` element
|
||||||
|
- timed JPEG image frames sent over a WebSocket and painted onto a `<canvas>`
|
||||||
|
|
||||||
|
This is meant for machines where image and audio decoding work but browser video decoding is unavailable or unreliable.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:3000`, paste a direct HTTP(S) stream URL, and click `Next`.
|
||||||
|
|
||||||
|
You need an `ffmpeg` binary with decoders for the stream's video and audio codecs. If your stream is H.264 or HEVC, make sure your installed `ffmpeg` actually includes those decoders. You can point the app at a different binary with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
FFMPEG_PATH=/path/to/ffmpeg npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -t frame-stream-player .
|
||||||
|
docker run --rm -p 3000:3000 frame-stream-player
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:3000`.
|
||||||
|
|
||||||
|
For Docker Compose:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose-example.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The app uses CPU decoding by default, so no video device is required. The compose example includes commented VAAPI/NVIDIA passthrough options for future hardware-accelerated `ffmpeg` setups.
|
||||||
|
|
||||||
|
## Tuning
|
||||||
|
|
||||||
|
The UI intentionally hides these settings, but the backend still supports them through `POST /api/session`.
|
||||||
|
|
||||||
|
- Frame rate defaults to `24fps`. Lower it if the client cannot keep up.
|
||||||
|
- Max width defaults to `960px`. Lower it first if bandwidth or image decode is the bottleneck.
|
||||||
|
- JPEG quality uses ffmpeg's `-q:v` scale, where lower is better. `5` is the default, `2` is high quality, and `18` is rough but lighter.
|
||||||
|
- Audio defaults to MP3 at `160k`.
|
||||||
|
|
||||||
|
## Tradeoffs
|
||||||
|
|
||||||
|
JPEG frames are used instead of PNG or GIF. PNG is usually too large for 24fps video, and GIF has poor quality and weak timing control. JPEG is simple, browser-native, streamable per frame, and lets the audio element act as the playback clock.
|
||||||
|
|
||||||
|
The current implementation starts separate `ffmpeg` workers for audio and frames. That is simple and works well for direct files and many HTTP streams, but live streams can have small startup offset differences. The input URL is proxied through a short local URL before it is handed to `ffmpeg`, so query-string tokens are not exposed in `ffmpeg` process arguments.
|
||||||
|
|
||||||
|
Arbitrary URLs are still fetched by your server, so do not expose this app publicly without adding authentication and URL allowlisting.
|
||||||
22
docker-compose-example.yml
Normal file
22
docker-compose-example.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
frame-stream-player:
|
||||||
|
build: .
|
||||||
|
image: frame-stream-player:latest
|
||||||
|
container_name: frame-stream-player
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
NODE_ENV: production
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
# CPU decoding is the default and does not need device passthrough.
|
||||||
|
#
|
||||||
|
# Optional Intel/AMD VAAPI device passthrough:
|
||||||
|
# devices:
|
||||||
|
# - "/dev/dri:/dev/dri"
|
||||||
|
#
|
||||||
|
# Optional NVIDIA passthrough with Docker Compose v2:
|
||||||
|
# gpus: all
|
||||||
849
package-lock.json
generated
Normal file
849
package-lock.json
generated
Normal file
@@ -0,0 +1,849 @@
|
|||||||
|
{
|
||||||
|
"name": "carplay",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "carplay",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "^3.1.2",
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"http-errors": "^2.0.0",
|
||||||
|
"iconv-lite": "^0.7.0",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"qs": "^6.14.1",
|
||||||
|
"raw-body": "^3.0.1",
|
||||||
|
"type-is": "^2.0.1"
|
||||||
|
},
|
||||||
|
"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.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"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.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||||
|
"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.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
|
"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.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||||
|
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-list": "^1.0.0",
|
||||||
|
"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/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.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"media-typer": "^1.1.0",
|
||||||
|
"mime-types": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "carplay",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Decode video streams on the server and play them in the browser as audio plus image frames.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "server/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server/index.js",
|
||||||
|
"dev": "node --watch server/index.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
413
public/app.js
Normal file
413
public/app.js
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
const elements = {
|
||||||
|
entryScreen: document.querySelector('#entry-screen'),
|
||||||
|
playerScreen: document.querySelector('#player-screen'),
|
||||||
|
form: document.querySelector('#stream-form'),
|
||||||
|
url: document.querySelector('#stream-url'),
|
||||||
|
next: document.querySelector('#next'),
|
||||||
|
entryMessage: document.querySelector('#entry-message'),
|
||||||
|
audio: document.querySelector('#audio'),
|
||||||
|
canvas: document.querySelector('#screen'),
|
||||||
|
stage: document.querySelector('#video-stage'),
|
||||||
|
loader: document.querySelector('#loader'),
|
||||||
|
playerMessage: document.querySelector('#player-message'),
|
||||||
|
controls: document.querySelector('#controls'),
|
||||||
|
back: document.querySelector('#back'),
|
||||||
|
playPause: document.querySelector('#play-pause'),
|
||||||
|
mute: document.querySelector('#mute'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = elements.canvas.getContext('2d', { alpha: false });
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
generation: 0,
|
||||||
|
session: null,
|
||||||
|
websocket: null,
|
||||||
|
frames: [],
|
||||||
|
currentBitmap: null,
|
||||||
|
raf: 0,
|
||||||
|
frameCount: 0,
|
||||||
|
controlsVisible: true,
|
||||||
|
hideControlsTimer: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
elements.form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setEntryMessage('');
|
||||||
|
setFormBusy(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
stopSession({ showEntry: false });
|
||||||
|
|
||||||
|
const response = await fetch('/api/session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: elements.url.value }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error ?? 'Failed to create stream session.');
|
||||||
|
}
|
||||||
|
|
||||||
|
showPlayer();
|
||||||
|
startSession(payload);
|
||||||
|
} catch (error) {
|
||||||
|
stopSession();
|
||||||
|
setEntryMessage(error.message);
|
||||||
|
} finally {
|
||||||
|
setFormBusy(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.stage.addEventListener('pointerup', (event) => {
|
||||||
|
if (!state.session || event.target.closest('.controls')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setControlsVisible(!state.controlsVisible);
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.back.addEventListener('click', () => {
|
||||||
|
stopSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.playPause.addEventListener('click', () => {
|
||||||
|
if (elements.audio.paused) {
|
||||||
|
void playAudio();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.audio.pause();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.mute.addEventListener('click', () => {
|
||||||
|
elements.audio.muted = !elements.audio.muted;
|
||||||
|
syncControlLabels();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.audio.addEventListener('play', () => {
|
||||||
|
startRenderLoop();
|
||||||
|
syncControlLabels();
|
||||||
|
scheduleControlsHide();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.audio.addEventListener('pause', () => {
|
||||||
|
syncControlLabels();
|
||||||
|
setControlsVisible(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.audio.addEventListener('playing', () => {
|
||||||
|
elements.loader.hidden = state.frameCount > 0;
|
||||||
|
clearPlayerMessage();
|
||||||
|
scheduleControlsHide();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.audio.addEventListener('waiting', () => {
|
||||||
|
if (state.session) {
|
||||||
|
elements.loader.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.audio.addEventListener('error', () => {
|
||||||
|
if (state.session) {
|
||||||
|
showPlayerMessage('Audio failed');
|
||||||
|
setControlsVisible(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function startSession(session) {
|
||||||
|
const generation = state.generation;
|
||||||
|
state.session = session;
|
||||||
|
state.frameCount = 0;
|
||||||
|
clearFrameQueue();
|
||||||
|
syncControlLabels();
|
||||||
|
setControlsVisible(true);
|
||||||
|
elements.loader.hidden = false;
|
||||||
|
clearPlayerMessage();
|
||||||
|
|
||||||
|
const websocketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const websocketUrl = `${websocketProtocol}//${window.location.host}/frames/${session.id}`;
|
||||||
|
const websocket = new WebSocket(websocketUrl);
|
||||||
|
websocket.binaryType = 'arraybuffer';
|
||||||
|
state.websocket = websocket;
|
||||||
|
|
||||||
|
websocket.addEventListener('message', (event) => {
|
||||||
|
if (generation !== state.generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
handleControlMessage(event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleFramePacket(event.data, generation);
|
||||||
|
});
|
||||||
|
|
||||||
|
websocket.addEventListener('close', () => {
|
||||||
|
if (state.session?.id === session.id) {
|
||||||
|
showPlayerMessage('Stream ended');
|
||||||
|
setControlsVisible(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
websocket.addEventListener('error', () => {
|
||||||
|
if (state.session?.id === session.id) {
|
||||||
|
showPlayerMessage('Stream failed');
|
||||||
|
setControlsVisible(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.audio.src = `/audio/${session.id}`;
|
||||||
|
elements.audio.load();
|
||||||
|
void playAudio();
|
||||||
|
startRenderLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playAudio() {
|
||||||
|
try {
|
||||||
|
await elements.audio.play();
|
||||||
|
clearPlayerMessage();
|
||||||
|
} catch {
|
||||||
|
showPlayerMessage('Tap play');
|
||||||
|
setControlsVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFramePacket(packet, generation) {
|
||||||
|
if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new DataView(packet, 0, 8).getFloat64(0, true);
|
||||||
|
const blob = new Blob([packet.slice(8)], { type: 'image/jpeg' });
|
||||||
|
const bitmap = await decodeImage(blob);
|
||||||
|
|
||||||
|
if (generation !== state.generation) {
|
||||||
|
releaseImage(bitmap);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.frames.push({ timestamp, bitmap });
|
||||||
|
state.frameCount += 1;
|
||||||
|
trimFrameQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleControlMessage(rawMessage) {
|
||||||
|
let message;
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = JSON.parse(rawMessage);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'error') {
|
||||||
|
showPlayerMessage('Stream failed');
|
||||||
|
setControlsVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'end') {
|
||||||
|
showPlayerMessage('Stream ended');
|
||||||
|
setControlsVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRenderLoop() {
|
||||||
|
if (state.raf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
drawReadyFrames();
|
||||||
|
state.raf = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
|
||||||
|
state.raf = requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawReadyFrames() {
|
||||||
|
if (!state.session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameLeadSeconds = 1 / Math.max(1, state.session.options.fps);
|
||||||
|
const targetTime = elements.audio.currentTime + frameLeadSeconds;
|
||||||
|
let drew = false;
|
||||||
|
|
||||||
|
while (state.frames.length > 0 && state.frames[0].timestamp <= targetTime) {
|
||||||
|
const frame = state.frames.shift();
|
||||||
|
|
||||||
|
if (state.currentBitmap) {
|
||||||
|
releaseImage(state.currentBitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.currentBitmap = frame.bitmap;
|
||||||
|
drawBitmap(frame.bitmap);
|
||||||
|
drew = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drew) {
|
||||||
|
elements.loader.hidden = true;
|
||||||
|
clearPlayerMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBitmap(bitmap) {
|
||||||
|
const width = bitmap.width || bitmap.naturalWidth;
|
||||||
|
const height = bitmap.height || bitmap.naturalHeight;
|
||||||
|
|
||||||
|
if (elements.canvas.width !== width || elements.canvas.height !== height) {
|
||||||
|
elements.canvas.width = width;
|
||||||
|
elements.canvas.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.drawImage(bitmap, 0, 0, elements.canvas.width, elements.canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimFrameQueue() {
|
||||||
|
const fps = state.session?.options.fps ?? 24;
|
||||||
|
const maxQueuedFrames = Math.max(60, Math.ceil(fps * 8));
|
||||||
|
const overflow = state.frames.length - maxQueuedFrames;
|
||||||
|
|
||||||
|
if (overflow <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = state.frames.splice(0, overflow);
|
||||||
|
|
||||||
|
for (const frame of removed) {
|
||||||
|
releaseImage(frame.bitmap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSession({ showEntry: shouldShowEntry = true } = {}) {
|
||||||
|
state.generation += 1;
|
||||||
|
state.session = null;
|
||||||
|
state.frameCount = 0;
|
||||||
|
clearHideControlsTimer();
|
||||||
|
|
||||||
|
if (state.websocket) {
|
||||||
|
state.websocket.close(1000, 'client stopped');
|
||||||
|
state.websocket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.audio.pause();
|
||||||
|
elements.audio.removeAttribute('src');
|
||||||
|
elements.audio.load();
|
||||||
|
|
||||||
|
if (state.raf) {
|
||||||
|
cancelAnimationFrame(state.raf);
|
||||||
|
state.raf = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFrameQueue();
|
||||||
|
context.clearRect(0, 0, elements.canvas.width, elements.canvas.height);
|
||||||
|
elements.loader.hidden = true;
|
||||||
|
clearPlayerMessage();
|
||||||
|
setControlsVisible(true);
|
||||||
|
syncControlLabels();
|
||||||
|
|
||||||
|
if (shouldShowEntry) {
|
||||||
|
showEntry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFrameQueue() {
|
||||||
|
for (const frame of state.frames) {
|
||||||
|
releaseImage(frame.bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.frames = [];
|
||||||
|
|
||||||
|
if (state.currentBitmap) {
|
||||||
|
releaseImage(state.currentBitmap);
|
||||||
|
state.currentBitmap = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decodeImage(blob) {
|
||||||
|
if ('createImageBitmap' in window) {
|
||||||
|
return createImageBitmap(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = new Image();
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.src = url;
|
||||||
|
await image.decode();
|
||||||
|
return image;
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseImage(image) {
|
||||||
|
if (typeof image?.close === 'function') {
|
||||||
|
image.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setControlsVisible(visible) {
|
||||||
|
state.controlsVisible = visible;
|
||||||
|
elements.stage.classList.toggle('controls-hidden', !visible);
|
||||||
|
elements.controls.setAttribute('aria-hidden', String(!visible));
|
||||||
|
|
||||||
|
if (visible && !elements.audio.paused) {
|
||||||
|
scheduleControlsHide();
|
||||||
|
} else {
|
||||||
|
clearHideControlsTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleControlsHide() {
|
||||||
|
clearHideControlsTimer();
|
||||||
|
state.hideControlsTimer = window.setTimeout(() => {
|
||||||
|
if (state.session && !elements.audio.paused) {
|
||||||
|
setControlsVisible(false);
|
||||||
|
}
|
||||||
|
}, 2400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHideControlsTimer() {
|
||||||
|
if (state.hideControlsTimer) {
|
||||||
|
window.clearTimeout(state.hideControlsTimer);
|
||||||
|
state.hideControlsTimer = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncControlLabels() {
|
||||||
|
elements.playPause.textContent = elements.audio.paused ? 'Play' : 'Pause';
|
||||||
|
elements.mute.textContent = elements.audio.muted ? 'Unmute' : 'Mute';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEntry() {
|
||||||
|
elements.playerScreen.hidden = true;
|
||||||
|
elements.entryScreen.hidden = false;
|
||||||
|
elements.url.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPlayer() {
|
||||||
|
elements.entryScreen.hidden = true;
|
||||||
|
elements.playerScreen.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPlayerMessage(message) {
|
||||||
|
elements.playerMessage.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPlayerMessage() {
|
||||||
|
elements.playerMessage.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEntryMessage(message) {
|
||||||
|
elements.entryMessage.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormBusy(isBusy) {
|
||||||
|
elements.url.disabled = isBusy;
|
||||||
|
elements.next.disabled = isBusy;
|
||||||
|
}
|
||||||
45
public/index.html
Normal file
45
public/index.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Frame Stream Player</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="app">
|
||||||
|
<section id="entry-screen" class="entry-screen" aria-label="Stream URL">
|
||||||
|
<form id="stream-form" class="url-form">
|
||||||
|
<label class="sr-only" for="stream-url">Video stream URL</label>
|
||||||
|
<input
|
||||||
|
id="stream-url"
|
||||||
|
name="url"
|
||||||
|
type="url"
|
||||||
|
placeholder="Video stream URL"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<button id="next" type="submit">Next</button>
|
||||||
|
</form>
|
||||||
|
<div id="entry-message" class="message" role="status" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="player-screen" class="player-screen" aria-label="Player" hidden>
|
||||||
|
<div id="video-stage" class="video-stage">
|
||||||
|
<canvas id="screen" width="960" height="540" aria-label="Video frame"></canvas>
|
||||||
|
<div id="loader" class="loader" aria-hidden="true"></div>
|
||||||
|
<div id="player-message" class="player-message" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<div id="controls" class="controls">
|
||||||
|
<button id="back" type="button" class="control-button">Back</button>
|
||||||
|
<button id="play-pause" type="button" class="control-button primary">Pause</button>
|
||||||
|
<button id="mute" type="button" class="control-button">Mute</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<audio id="audio" preload="none"></audio>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/app.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
253
public/styles.css
Normal file
253
public/styles.css
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #050505;
|
||||||
|
--fg: #f6f1e8;
|
||||||
|
--soft: rgba(246, 241, 232, 0.72);
|
||||||
|
--glass: rgba(10, 10, 10, 0.62);
|
||||||
|
--glass-strong: rgba(10, 10, 10, 0.82);
|
||||||
|
--line: rgba(246, 241, 232, 0.18);
|
||||||
|
--accent: #e8a84f;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
.app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: "Avenir Next", "Trebuchet MS", Verdana, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-screen {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100%;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 24% 22%, rgba(232, 168, 79, 0.28), transparent 25rem),
|
||||||
|
radial-gradient(circle at 78% 74%, rgba(79, 135, 232, 0.20), transparent 28rem),
|
||||||
|
linear-gradient(135deg, #070707 0%, #11100d 48%, #030303 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form {
|
||||||
|
display: flex;
|
||||||
|
width: min(760px, 100%);
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 24px 100px rgba(0, 0, 0, 0.45);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
color: var(--fg);
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form input::placeholder {
|
||||||
|
color: rgba(246, 241, 232, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form button,
|
||||||
|
.control-button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--fg);
|
||||||
|
background: rgba(255, 255, 255, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form button {
|
||||||
|
min-width: 7rem;
|
||||||
|
padding: 1rem 1.3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: var(--fg);
|
||||||
|
color: #070707;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
left: 50%;
|
||||||
|
max-width: min(720px, calc(100% - 2rem));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
color: var(--soft);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-stage {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: manipulation;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
position: absolute;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border: 3px solid rgba(246, 241, 232, 0.18);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-message {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.25rem;
|
||||||
|
left: 50%;
|
||||||
|
max-width: min(720px, calc(100% - 2rem));
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--glass-strong);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-message:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
right: 1.25rem;
|
||||||
|
bottom: 1.25rem;
|
||||||
|
left: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--glass);
|
||||||
|
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.42);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: opacity 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-stage.controls-hidden .controls {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button {
|
||||||
|
min-width: 6rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button.primary {
|
||||||
|
min-width: 8rem;
|
||||||
|
border-color: transparent;
|
||||||
|
color: #070707;
|
||||||
|
background: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
audio {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.url-form {
|
||||||
|
border-radius: 28px;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form input,
|
||||||
|
.url-form button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.8rem 0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
538
server/index.js
Normal file
538
server/index.js
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import { WebSocket, WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const publicDir = path.join(__dirname, '..', 'public');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = createServer(app);
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT ?? 3000);
|
||||||
|
const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg';
|
||||||
|
const SESSION_TTL_MS = 60 * 60 * 1000;
|
||||||
|
const MAX_WS_BUFFER_BYTES = 12 * 1024 * 1024;
|
||||||
|
const JPEG_SOI = Buffer.from([0xff, 0xd8]);
|
||||||
|
const JPEG_EOI = Buffer.from([0xff, 0xd9]);
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
fps: 24,
|
||||||
|
width: 960,
|
||||||
|
quality: 5,
|
||||||
|
audioBitrate: '160k',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessions = new Map();
|
||||||
|
const sourceTokens = new Map();
|
||||||
|
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
app.use(express.json({ limit: '32kb' }));
|
||||||
|
app.use(express.static(publicDir));
|
||||||
|
|
||||||
|
app.get('/api/health', (_request, response) => {
|
||||||
|
response.json({ ok: true, ffmpeg: FFMPEG_PATH });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/session', (request, response) => {
|
||||||
|
let url;
|
||||||
|
|
||||||
|
try {
|
||||||
|
url = parseStreamUrl(request.body?.url);
|
||||||
|
} catch (error) {
|
||||||
|
response.status(400).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = parsePlaybackOptions(request.body);
|
||||||
|
const id = randomUUID();
|
||||||
|
|
||||||
|
sessions.set(id, {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
options,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastUsedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
response.status(201).json({ id, options });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.all('/_source/:token', async (request, response) => {
|
||||||
|
const source = sourceTokens.get(request.params.token);
|
||||||
|
const session = source ? getSession(source.sessionId) : null;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
response.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const cleanup = once(() => controller.abort());
|
||||||
|
response.on('close', cleanup);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = new Headers();
|
||||||
|
const range = request.get('range');
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
headers.set('range', range);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set('accept-encoding', 'identity');
|
||||||
|
|
||||||
|
const upstream = await fetch(session.url, {
|
||||||
|
method: request.method === 'HEAD' ? 'HEAD' : 'GET',
|
||||||
|
headers,
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.status(upstream.status);
|
||||||
|
copyUpstreamHeaders(upstream.headers, response);
|
||||||
|
|
||||||
|
if (request.method === 'HEAD' || !upstream.body) {
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Readable.fromWeb(upstream.body).on('error', (error) => {
|
||||||
|
if (!response.destroyed) {
|
||||||
|
response.destroy(error);
|
||||||
|
}
|
||||||
|
}).pipe(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Source proxy failed: ${error.message}`);
|
||||||
|
|
||||||
|
if (!response.headersSent) {
|
||||||
|
response.status(502).json({ error: 'Failed to fetch source stream.' });
|
||||||
|
} else {
|
||||||
|
response.destroy(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/audio/:sessionId', (request, response) => {
|
||||||
|
const session = getSession(request.params.sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
response.status(404).json({ error: 'Unknown or expired session.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = createAudioWorker(session);
|
||||||
|
const releaseWorker = once(worker.release);
|
||||||
|
const ffmpeg = spawnFfmpeg(worker.args);
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
response.set({
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Content-Type': 'audio/mpeg',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
});
|
||||||
|
response.flushHeaders();
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (chunk) => {
|
||||||
|
stderr = appendTail(stderr, chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.stdout.pipe(response);
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
releaseWorker();
|
||||||
|
console.error(`Failed to start ffmpeg audio worker: ${error.message}`);
|
||||||
|
if (!response.writableEnded) {
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', (code, signal) => {
|
||||||
|
releaseWorker();
|
||||||
|
|
||||||
|
if (code && code !== 255) {
|
||||||
|
console.warn(`ffmpeg audio worker exited with code ${code}: ${redactSecrets(stderr)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
console.warn(`ffmpeg audio worker stopped by ${signal}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.writableEnded) {
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = once(() => stopProcess(ffmpeg));
|
||||||
|
request.on('close', cleanup);
|
||||||
|
response.on('close', cleanup);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('upgrade', (request, socket, head) => {
|
||||||
|
const host = request.headers.host ?? 'localhost';
|
||||||
|
const { pathname } = new URL(request.url ?? '/', `http://${host}`);
|
||||||
|
const match = pathname.match(/^\/frames\/([0-9a-f-]+)$/i);
|
||||||
|
|
||||||
|
if (!match || !getSession(match[1])) {
|
||||||
|
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.handleUpgrade(request, socket, head, (websocket) => {
|
||||||
|
wss.emit('connection', websocket, request, match[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on('connection', (websocket, _request, sessionId) => {
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
websocket.close(1008, 'Unknown session');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = createFrameWorker(session);
|
||||||
|
const releaseWorker = once(worker.release);
|
||||||
|
const ffmpeg = spawnFfmpeg(worker.args);
|
||||||
|
const { fps, quality, width } = session.options;
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
let frameIndex = 0;
|
||||||
|
let skippedFrames = 0;
|
||||||
|
let stderr = '';
|
||||||
|
let closedByClient = false;
|
||||||
|
|
||||||
|
sendJson(websocket, {
|
||||||
|
type: 'ready',
|
||||||
|
codec: 'jpeg',
|
||||||
|
fps,
|
||||||
|
quality,
|
||||||
|
width,
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.stdout.on('data', (chunk) => {
|
||||||
|
buffer = Buffer.concat([buffer, chunk]);
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const start = buffer.indexOf(JPEG_SOI);
|
||||||
|
|
||||||
|
if (start === -1) {
|
||||||
|
buffer = Buffer.alloc(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = buffer.indexOf(JPEG_EOI, start + JPEG_SOI.length);
|
||||||
|
|
||||||
|
if (end === -1) {
|
||||||
|
buffer = start === 0 ? buffer : buffer.subarray(start);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jpeg = buffer.subarray(start, end + JPEG_EOI.length);
|
||||||
|
buffer = buffer.subarray(end + JPEG_EOI.length);
|
||||||
|
const timestamp = frameIndex / fps;
|
||||||
|
frameIndex += 1;
|
||||||
|
|
||||||
|
if (websocket.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (websocket.bufferedAmount > MAX_WS_BUFFER_BYTES) {
|
||||||
|
skippedFrames += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packet = Buffer.allocUnsafe(8 + jpeg.length);
|
||||||
|
packet.writeDoubleLE(timestamp, 0);
|
||||||
|
jpeg.copy(packet, 8);
|
||||||
|
websocket.send(packet, { binary: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (chunk) => {
|
||||||
|
stderr = appendTail(stderr, chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
releaseWorker();
|
||||||
|
sendJson(websocket, {
|
||||||
|
type: 'error',
|
||||||
|
message: `Failed to start ffmpeg: ${error.message}`,
|
||||||
|
});
|
||||||
|
websocket.close(1011, 'ffmpeg start failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', (code, signal) => {
|
||||||
|
releaseWorker();
|
||||||
|
|
||||||
|
if (websocket.readyState === WebSocket.OPEN && !closedByClient) {
|
||||||
|
sendJson(websocket, {
|
||||||
|
type: 'end',
|
||||||
|
code,
|
||||||
|
signal,
|
||||||
|
skippedFrames,
|
||||||
|
message: summarizeFfmpegExit(code, signal, stderr),
|
||||||
|
});
|
||||||
|
websocket.close(1000, 'ffmpeg exited');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
websocket.on('close', () => {
|
||||||
|
closedByClient = true;
|
||||||
|
stopProcess(ffmpeg);
|
||||||
|
});
|
||||||
|
|
||||||
|
websocket.on('error', () => {
|
||||||
|
closedByClient = true;
|
||||||
|
stopProcess(ffmpeg);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [id, session] of sessions) {
|
||||||
|
if (now - session.lastUsedAt > SESSION_TTL_MS) {
|
||||||
|
sessions.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [token, source] of sourceTokens) {
|
||||||
|
if (now - source.createdAt > SESSION_TTL_MS) {
|
||||||
|
sourceTokens.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60 * 1000).unref();
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Frame stream app listening at http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseStreamUrl(value) {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
throw new Error('A stream URL is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length > 4096) {
|
||||||
|
throw new Error('The stream URL is too long.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new URL(value.trim());
|
||||||
|
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
throw new Error('Only http and https stream URLs are supported.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlaybackOptions(body) {
|
||||||
|
return {
|
||||||
|
fps: clampInteger(body?.fps, defaults.fps, 1, 30),
|
||||||
|
width: clampInteger(body?.width, defaults.width, 160, 1920),
|
||||||
|
quality: clampInteger(body?.quality, defaults.quality, 2, 18),
|
||||||
|
audioBitrate: parseAudioBitrate(body?.audioBitrate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInteger(value, fallback, min, max) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(max, Math.max(min, Math.round(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAudioBitrate(value) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return defaults.audioBitrate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /^\d{2,3}k$/i.test(value) ? value.toLowerCase() : defaults.audioBitrate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSession(sessionId) {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastUsedAt = Date.now();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAudioWorker(session) {
|
||||||
|
const source = createSourceInput(session.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
args: buildAudioArgs(session, source.url),
|
||||||
|
release: source.release,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFrameWorker(session) {
|
||||||
|
const source = createSourceInput(session.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
args: buildFrameArgs(session, source.url),
|
||||||
|
release: source.release,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSourceInput(sessionId) {
|
||||||
|
const token = randomUUID();
|
||||||
|
sourceTokens.set(token, { sessionId, createdAt: Date.now() });
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${getListeningPort()}/_source/${token}`,
|
||||||
|
release: () => sourceTokens.delete(token),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getListeningPort() {
|
||||||
|
const address = server.address();
|
||||||
|
return typeof address === 'object' && address ? address.port : PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAudioArgs(session, inputUrl) {
|
||||||
|
return [
|
||||||
|
'-hide_banner',
|
||||||
|
'-nostdin',
|
||||||
|
'-loglevel',
|
||||||
|
'warning',
|
||||||
|
'-re',
|
||||||
|
'-i',
|
||||||
|
inputUrl,
|
||||||
|
'-vn',
|
||||||
|
'-map',
|
||||||
|
'0:a:0?',
|
||||||
|
'-ac',
|
||||||
|
'2',
|
||||||
|
'-ar',
|
||||||
|
'48000',
|
||||||
|
'-codec:a',
|
||||||
|
'libmp3lame',
|
||||||
|
'-b:a',
|
||||||
|
session.options.audioBitrate,
|
||||||
|
'-f',
|
||||||
|
'mp3',
|
||||||
|
'pipe:1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFrameArgs(session, inputUrl) {
|
||||||
|
const { fps, quality, width } = session.options;
|
||||||
|
const videoFilter = `fps=${fps},scale='min(${width},iw)':-2:flags=bicubic`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'-hide_banner',
|
||||||
|
'-nostdin',
|
||||||
|
'-loglevel',
|
||||||
|
'warning',
|
||||||
|
'-re',
|
||||||
|
'-i',
|
||||||
|
inputUrl,
|
||||||
|
'-an',
|
||||||
|
'-vf',
|
||||||
|
videoFilter,
|
||||||
|
'-codec:v',
|
||||||
|
'mjpeg',
|
||||||
|
'-q:v',
|
||||||
|
String(quality),
|
||||||
|
'-f',
|
||||||
|
'image2pipe',
|
||||||
|
'pipe:1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnFfmpeg(args) {
|
||||||
|
return spawn(FFMPEG_PATH, args, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUpstreamHeaders(upstreamHeaders, response) {
|
||||||
|
for (const header of [
|
||||||
|
'accept-ranges',
|
||||||
|
'content-length',
|
||||||
|
'content-range',
|
||||||
|
'content-type',
|
||||||
|
'etag',
|
||||||
|
'last-modified',
|
||||||
|
]) {
|
||||||
|
const value = upstreamHeaders.get(header);
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
response.setHeader(header, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.setHeader('cache-control', 'no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(websocket, payload) {
|
||||||
|
if (websocket.readyState === WebSocket.OPEN) {
|
||||||
|
websocket.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTail(current, chunk) {
|
||||||
|
const next = current + chunk.toString('utf8');
|
||||||
|
return next.length > 4000 ? next.slice(-4000) : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeFfmpegExit(code, signal, stderr) {
|
||||||
|
if (signal) {
|
||||||
|
return `ffmpeg stopped by ${signal}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 0 || code === null) {
|
||||||
|
return 'Frame stream ended.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = redactSecrets(stderr).trim();
|
||||||
|
return detail ? `ffmpeg exited with code ${code}: ${detail}` : `ffmpeg exited with code ${code}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactSecrets(text) {
|
||||||
|
return text.replace(/([?&](?:api_key|apikey|access_token|token|key)=)[^&\s]+/gi, '$1[redacted]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopProcess(child) {
|
||||||
|
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}, 1500).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
function once(callback) {
|
||||||
|
let called = false;
|
||||||
|
|
||||||
|
return (...args) => {
|
||||||
|
if (called) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
called = true;
|
||||||
|
callback(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user