initial commit

This commit is contained in:
2026-05-01 21:51:25 -07:00
commit 8b7a1f81ad
11 changed files with 2236 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
npm-debug.log*
.git
.gitignore
.env
.DS_Store
Dockerfile
public/_smoke.avi

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.env
.DS_Store
npm-debug.log*

29
Dockerfile Normal file
View 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
View 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.

View 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
View 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
View 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
View 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
View 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
View 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
View 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);
};
}