feat: rewrite in Go

This commit is contained in:
2026-07-13 17:47:00 +07:00
parent a7d5177100
commit 1421b854d4
34 changed files with 1714 additions and 1825 deletions
+1 -1
View File
@@ -13,4 +13,4 @@ docs
data
frames
.vscode
video.mp4
TrollSSH
+25 -13
View File
@@ -1,35 +1,47 @@
HOST=0.0.0.0
PORT=22
# Source video (auto-discovers video.* if unset). Only used to build frames.json.
VIDEO_PATH=video.mp4
# Generation settings
# Stored frame resolution in pixels. Higher = sharper but bigger .tsf files.
# regenerate your frames after changing it.
FRAME_RESOLUTION=512
# Playback settings.
# Playthroughs before the session is closed (0, unlimited).
MAX_LOOP=5
# Weather to loop the video or play random frames after completion. Options: loop | random
# Whether to keep looping the same frame set or pick a random one after each
# playthrough. Options: loop | random
PLAYBACK_MODE=random
# Let viewers switch frame sets with arrow keys
# Let viewers switch frame sets with arrow keys.
ALLOW_USER_CONTROL=true
# Min ms between arrow-key switches; ignores faster repeats.
SWITCH_DEBOUNCE_MS=120
# Minimum ms between arrow-key switches
SWITCH_DEBOUNCE_MS=500
# Pause in ms after the fake login before playback starts.
LOGIN_DELAY=1500
# ASCII rendering (live, no frame rebuild).
# CHARSET: detailed|standard|simple|blocks or custom ramp.
BRIGHTNESS_THRESHOLD=40
# ASCII rendering (applied live)
# CHARSET: detailed | standard | simple | blocks, or a custom ramp string.
CHARSET=blocks
# Pixels darker than this (0-100) render as the darkest ramp character.
BRIGHTNESS_THRESHOLD=40
# Invert the brightness ramp (for light terminal backgrounds).
INVERT=false
# Max rendered width/height in characters.
MAX_DIMENSION=1080
# Stored frame resolution (px).
# Higher = sharper but bigger frames.json. Rebuild required.
FRAME_RESOLUTION=512
# Connection limits. New connections over a limit are dropped immediately.
# Max simultaneous connections from a single client IP.
MAX_CONNECTIONS=10
# Max simultaneous connections across all clients combined.
MAX_TOTAL_CONNECTIONS=1000
MAX_AUTH_ATTEMPTS=6
# Login attempts allowed per connection before it is disconnected.
# (Empty password counts as an attempt.)
MAX_AUTH_ATTEMPTS=3
# SSH handshake deadline in ms (0 to disable).
HANDSHAKE_TIMEOUT=10000
# Log attempted usernames/passwords.
LOG_CREDENTIALS=true
# Minimum log level to emit: debug | info | warn | error
LOG_LEVEL=info
+30 -42
View File
@@ -12,33 +12,30 @@ permissions:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
bun-version: ["1.2"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-go@v5
with:
bun-version: ${{ matrix.bun-version }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Lint
run: bun run lint
go-version-file: go.mod
- name: Format check
run: bun run format
run: test -z "$(gofmt -l src)"
- name: Vet
run: go vet ./src/
- name: Staticcheck
uses: dominikh/staticcheck-action@v1
with:
install-go: false
- name: Build
run: go build -o trollssh ./src
- name: Unit tests
run: bun test
run: go test -race ./src/
docker:
needs: test
@@ -48,16 +45,6 @@ jobs:
- uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: trollssh:ci
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Determine push conditions
id: should_push
run: |
@@ -67,15 +54,15 @@ jobs:
echo "push=false" >> "$GITHUB_OUTPUT"
fi
- name: Determine image tag
if: steps.should_push.outputs.push == 'true'
id: tag
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/yuzuzensai/trollssh
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern=v{{version}}
type=sha,prefix=sha-,format=short
- name: Log in to GHCR
if: steps.should_push.outputs.push == 'true'
@@ -85,12 +72,13 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image
if: steps.should_push.outputs.push == 'true'
- name: Build (and push) image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/yuzuzensai/trollssh:${{ steps.tag.outputs.tag }}
push: ${{ steps.should_push.outputs.push == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
+5 -1
View File
@@ -138,4 +138,8 @@ dist
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.pnp.*
# Go build output
/TrollSSH
TrollSSH
*.exe
-4
View File
@@ -1,4 +0,0 @@
dist/
config/
node_modules/
bun.lock
-7
View File
@@ -1,7 +0,0 @@
{
"tabWidth": 4,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "es5"
}
+13 -12
View File
@@ -1,14 +1,15 @@
FROM oven/bun:1.2-slim
WORKDIR /home/bun/app
RUN apt-get update -y && \
apt-get install -y ffmpeg && \
rm -rf /var/lib/apt/lists/*
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
COPY tsconfig.json ./
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY src ./src
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /trollssh ./src
CMD ["bun", "src/index.ts"]
FROM alpine:3.22
WORKDIR /home/app
RUN apk add --no-cache ffmpeg
COPY --from=build /trollssh /usr/local/bin/trollssh
CMD ["trollssh"]
-348
View File
@@ -1,348 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "trollssh",
"dependencies": {
"fluent-ffmpeg": "^2.1.3",
"sharp": "^0.35.3",
"ssh2": "^1.17.0",
"sshpk": "^1.18.0",
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/bun": "^1.3.14",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/node": "^26.1.1",
"@types/sharp": "^0.31.1",
"@types/ssh2": "^1.15.5",
"@types/sshpk": "^1.17.5",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.5",
"typescript": "6.0.3",
"typescript-eslint": "^8.63.0",
},
},
},
"packages": {
"@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="],
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
"@types/asn1": ["@types/asn1@0.2.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/fluent-ffmpeg": ["@types/fluent-ffmpeg@2.1.28", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/sharp": ["@types/sharp@0.31.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag=="],
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
"@types/sshpk": ["@types/sshpk@1.17.5", "", { "dependencies": { "@types/asn1": "*", "@types/node": "*" } }, "sha512-khJbOFAOnyLaqOoGIlDCF2ZghIb0jM1K1N0rRoKL0ObqUMwfkicu5zlJ5Wsp+Xn2EUduVwONXwsgbGk+COaCug=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.63.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/type-utils": "8.63.0", "@typescript-eslint/utils": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.63.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.63.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0" } }, "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/utils": "8.63.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.63.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="],
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
"async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="],
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"fluent-ffmpeg": ["fluent-ffmpeg@2.1.3", "", { "dependencies": { "async": "^0.2.9", "which": "^1.1.1" } }, "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q=="],
"getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nan": ["nan@2.28.0", "", {}, "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
"sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"typescript-eslint": ["typescript-eslint@8.63.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.63.0", "@typescript-eslint/parser": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/utils": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@types/asn1/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/fluent-ffmpeg/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/sharp/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/sshpk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="],
"bun-types/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"@types/asn1/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@types/fluent-ffmpeg/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@types/sharp/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@types/sshpk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
}
}
+2 -2
View File
@@ -9,6 +9,6 @@ services:
- .env
volumes:
# Host keys + banner/fakelogin/goodbye text assets (persisted).
- ./data:/home/bun/app/data
- ./data:/home/app/data
# Pre-generated frame sets
- ./frames:/home/bun/app/frames
- ./frames:/home/app/frames
-21
View File
@@ -1,21 +0,0 @@
// @ts-check
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
eslintConfigPrettier,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
},
},
{
ignores: ["dist/**", "config/**", "node_modules/**"],
}
);
+11
View File
@@ -0,0 +1,11 @@
module github.com/YuzuZensai/TrollSSH
go 1.25.0
require (
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.54.0
golang.org/x/image v0.44.0
)
require golang.org/x/sys v0.47.0 // indirect
+10
View File
@@ -0,0 +1,10 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
-42
View File
@@ -1,42 +0,0 @@
{
"name": "trollssh",
"version": "1.0.0",
"main": "src/index.ts",
"repository": "https://github.com/YuzuZensai/TrollSSH.git",
"author": "Yuzu <yuzu@kirameki.cafe>",
"license": "GPL-3.0",
"engines": {
"bun": ">=1.2.0"
},
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"test": "bun test",
"docker-build": "docker build . -t ghcr.io/yuzuzensai/trollssh:latest",
"docker-push": "docker push ghcr.io/yuzuzensai/trollssh:latest"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/bun": "^1.3.14",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/node": "^26.1.1",
"@types/sharp": "^0.31.1",
"@types/ssh2": "^1.15.5",
"@types/sshpk": "^1.17.5",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.5",
"typescript": "6.0.3",
"typescript-eslint": "^8.63.0"
},
"dependencies": {
"fluent-ffmpeg": "^2.1.3",
"sharp": "^0.35.3",
"ssh2": "^1.17.0",
"sshpk": "^1.18.0"
}
}
+218
View File
@@ -0,0 +1,218 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestTSFRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.tsf")
original := &FramesContainer{
Frames: [][]byte{{0, 1, 255}, {10, 20, 30}},
FPS: 29.97,
}
if err := writeTSF(path, original); err != nil {
t.Fatalf("writeTSF: %v", err)
}
fc, err := loadTSF(path)
if err != nil {
t.Fatalf("loadTSF: %v", err)
}
if fc.FPS != 29.97 {
t.Errorf("fps = %v", fc.FPS)
}
if len(fc.Frames) != 2 {
t.Fatalf("frames = %d", len(fc.Frames))
}
if string(fc.Frames[0]) != string([]byte{0, 1, 255}) {
t.Errorf("frame0 = %v", fc.Frames[0])
}
if string(fc.Frames[1]) != string([]byte{10, 20, 30}) {
t.Errorf("frame1 = %v", fc.Frames[1])
}
}
func TestTSFInvalid(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.tsf")
os.WriteFile(path, []byte("not a tsf file"), 0o644)
if _, err := loadTSF(path); err == nil {
t.Error("expected error for garbage input")
}
// Valid container but no frames.
writeTSF(path, &FramesContainer{FPS: 30})
if _, err := loadTSF(path); err == nil {
t.Error("expected error for empty frames")
}
// Valid container but fps <= 0.
writeTSF(path, &FramesContainer{Frames: [][]byte{{1}}, FPS: 0})
if _, err := loadTSF(path); err == nil {
t.Error("expected error for fps<=0")
}
// Truncated payload.
writeTSF(path, &FramesContainer{Frames: [][]byte{{1, 2, 3, 4}}, FPS: 30})
raw, _ := os.ReadFile(path)
os.WriteFile(path, raw[:len(raw)-2], 0o644)
if _, err := loadTSF(path); err == nil {
t.Error("expected error for truncated file")
}
}
func TestLoadConfigDefaults(t *testing.T) {
t.Setenv("HOST", "")
t.Setenv("PORT", "")
t.Setenv("PLAYBACK_MODE", "")
t.Setenv("LOGIN_DELAY", "")
cfg := loadConfig()
if cfg.Host != "0.0.0.0" {
t.Errorf("host = %q", cfg.Host)
}
if cfg.Port != 22 {
t.Errorf("port = %d", cfg.Port)
}
if cfg.PlaybackMode != PlaybackLoop {
t.Errorf("playbackMode = %q", cfg.PlaybackMode)
}
if cfg.Charset != "detailed" {
t.Errorf("charset = %q", cfg.Charset)
}
if cfg.LoginDelay != 1500*time.Millisecond {
t.Errorf("loginDelay = %v", cfg.LoginDelay)
}
}
func TestLoadConfigClamping(t *testing.T) {
t.Setenv("PORT", "999999")
t.Setenv("BRIGHTNESS_THRESHOLD", "-5")
cfg := loadConfig()
if cfg.Port != 65535 {
t.Errorf("port clamp = %d", cfg.Port)
}
if cfg.BrightnessThreshold != 0 {
t.Errorf("brightness clamp = %d", cfg.BrightnessThreshold)
}
}
func TestLoadConfigInvalidFallsBack(t *testing.T) {
t.Setenv("PORT", "not-a-number")
t.Setenv("INVERT", "yes-please")
t.Setenv("PLAYBACK_MODE", "shuffle")
cfg := loadConfig()
if cfg.Port != 22 {
t.Errorf("port = %d, want default 22", cfg.Port)
}
if cfg.Invert {
t.Error("invert should fall back to false")
}
if cfg.PlaybackMode != PlaybackLoop {
t.Errorf("playbackMode = %q, want default loop", cfg.PlaybackMode)
}
}
func TestEnvDurationMs(t *testing.T) {
t.Setenv("D", "250")
if got := envDurationMs("D", time.Second); got != 250*time.Millisecond {
t.Errorf("250 = %v, want 250ms", got)
}
t.Setenv("D", "-10")
if got := envDurationMs("D", time.Second); got != 0 {
t.Errorf("negative = %v, want 0", got)
}
t.Setenv("D", "banana")
if got := envDurationMs("D", time.Second); got != time.Second {
t.Errorf("invalid = %v, want fallback 1s", got)
}
}
func TestPlaybackModeRandom(t *testing.T) {
t.Setenv("PLAYBACK_MODE", "RaNdOm")
if loadConfig().PlaybackMode != PlaybackRandom {
t.Error("expected random")
}
}
func TestResolveCharset(t *testing.T) {
if got := resolveCharset("blocks"); got != " ░▒▓█" {
t.Errorf("blocks preset = %q", got)
}
if got := resolveCharset("XYZ"); got != "XYZ" {
t.Errorf("custom ramp = %q", got)
}
if got := resolveCharset(""); !strings.HasPrefix(got, " .") {
t.Errorf("default = %q", got)
}
}
func TestFrameToAscii(t *testing.T) {
// Below threshold -> first ramp char; full brightness -> last.
opts := asciiOptions{brightnessThreshold: 40, charset: "standard"}
ramp := []rune(resolveCharset("standard"))
out := []rune(frameToAscii([]byte{0, 255}, opts))
if out[0] != ramp[0] {
t.Errorf("dark px = %q, want %q", out[0], ramp[0])
}
if out[1] != ramp[len(ramp)-1] {
t.Errorf("bright px = %q, want %q", out[1], ramp[len(ramp)-1])
}
}
func TestFrameToAsciiInvert(t *testing.T) {
opts := asciiOptions{brightnessThreshold: 40, charset: "standard", invert: true}
ramp := []rune(resolveCharset("standard"))
out := []rune(frameToAscii([]byte{255}, opts))
if out[0] != ramp[0] {
t.Errorf("inverted bright = %q, want %q", out[0], ramp[0])
}
}
func TestConnectionTracker(t *testing.T) {
tr := newConnectionTracker()
tr.increment("1.2.3.4")
tr.increment("1.2.3.4")
if !tr.hasReachedLimits("1.2.3.4", 2, 100) {
t.Error("expected per-ip limit reached")
}
tr.decrement("1.2.3.4")
tr.decrement("1.2.3.4")
if tr.totalCount() != 0 {
t.Errorf("total = %d", tr.totalCount())
}
if tr.hasReachedLimits("1.2.3.4", 2, 100) {
t.Error("should be cleared")
}
}
func TestClampDimension(t *testing.T) {
if clampDimension(0, 100) != 1 {
t.Error("floor")
}
if clampDimension(500, 100) != 100 {
t.Error("ceil")
}
if clampDimension(50, 100) != 50 {
t.Error("passthrough")
}
}
func TestParseDimsPtyReq(t *testing.T) {
// "xterm" + cols=100 rows=40 + widthpx + heightpx
payload := []byte{
0, 0, 0, 5, 'x', 't', 'e', 'r', 'm',
0, 0, 0, 100,
0, 0, 0, 40,
0, 0, 0, 0,
0, 0, 0, 0,
}
cols, rows, ok := parseDims(payload)
if !ok || cols != 100 || rows != 40 {
t.Errorf("parseDims = %d,%d,%v", cols, rows, ok)
}
}
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
type PlaybackMode string
const (
PlaybackLoop PlaybackMode = "loop"
PlaybackRandom PlaybackMode = "random"
)
type Config struct {
Host string
Port int
MaxLoop int
PlaybackMode PlaybackMode
AllowUserControl bool
SwitchDebounce time.Duration
LoginDelay time.Duration
MaxConnections int
MaxTotalConnections int
MaxAuthAttempts int
HandshakeTimeout time.Duration
MaxDimension int
FrameResolution int
BrightnessThreshold int
Charset string
Invert bool
LogCredentials bool
}
func warnInvalid(name, value string, fallback any) {
logWarn(fmt.Sprintf("Invalid %s=%q, using default %v", name, sanitize(value), fallback))
}
func envString(name, fallback string) string {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
return value
}
func envInt(name string, fallback, min, max int) int {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil {
warnInvalid(name, raw, fallback)
return fallback
}
if parsed < min {
return min
}
if parsed > max {
return max
}
return parsed
}
func envBool(name string, fallback bool) bool {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
parsed, err := strconv.ParseBool(strings.ToLower(raw))
if err != nil {
warnInvalid(name, raw, fallback)
return fallback
}
return parsed
}
func envDurationMs(name string, fallback time.Duration) time.Duration {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
ms, err := strconv.Atoi(raw)
if err != nil {
warnInvalid(name, raw, fallback)
return fallback
}
if ms < 0 {
ms = 0
}
return time.Duration(ms) * time.Millisecond
}
func envPlaybackMode(name string, fallback PlaybackMode) PlaybackMode {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
switch PlaybackMode(strings.ToLower(raw)) {
case PlaybackLoop:
return PlaybackLoop
case PlaybackRandom:
return PlaybackRandom
}
warnInvalid(name, raw, fallback)
return fallback
}
func loadConfig() Config {
const maxInt = int(^uint(0) >> 1)
return Config{
Host: envString("HOST", "0.0.0.0"),
Port: envInt("PORT", 22, 1, 65535),
MaxLoop: envInt("MAX_LOOP", 5, 0, maxInt),
PlaybackMode: envPlaybackMode("PLAYBACK_MODE", PlaybackLoop),
AllowUserControl: envBool("ALLOW_USER_CONTROL", true),
SwitchDebounce: envDurationMs("SWITCH_DEBOUNCE_MS", 120*time.Millisecond),
LoginDelay: envDurationMs("LOGIN_DELAY", 1500*time.Millisecond),
MaxConnections: envInt("MAX_CONNECTIONS", 10, 1, maxInt),
MaxTotalConnections: envInt("MAX_TOTAL_CONNECTIONS", 1000, 1, maxInt),
MaxAuthAttempts: envInt("MAX_AUTH_ATTEMPTS", 6, 1, maxInt),
HandshakeTimeout: envDurationMs("HANDSHAKE_TIMEOUT", 10*time.Second),
MaxDimension: envInt("MAX_DIMENSION", 512, 1, 4096),
FrameResolution: envInt("FRAME_RESOLUTION", 360, 16, 1080),
BrightnessThreshold: envInt("BRIGHTNESS_THRESHOLD", 40, 0, 100),
Charset: envString("CHARSET", "detailed"),
Invert: envBool("INVERT", false),
LogCredentials: envBool("LOG_CREDENTIALS", false),
}
}
func loadOptionalTextFile(filePath string) (string, bool) {
data, err := os.ReadFile(filePath)
if err != nil {
return "", false
}
return string(data), true
}
-86
View File
@@ -1,86 +0,0 @@
import { describe, test, expect } from "bun:test";
import { loadConfig } from "./config";
describe("loadConfig", () => {
test("returns defaults when env vars are unset", () => {
expect(loadConfig({})).toEqual({
host: "0.0.0.0",
port: 22,
maxLoop: 5,
playbackMode: "loop",
allowUserControl: true,
switchDebounceMs: 120,
loginDelay: 1500,
maxConnections: 10,
maxTotalConnections: 1000,
maxAuthAttempts: 6,
handshakeTimeout: 10000,
maxDimension: 512,
frameResolution: 360,
brightnessThreshold: 40,
charset: "detailed",
invert: false,
logCredentials: false,
videoPath: undefined,
});
});
test("overrides defaults from env vars", () => {
expect(
loadConfig({
HOST: "127.0.0.1",
PORT: "2222",
MAX_LOOP: "3",
PLAYBACK_MODE: "random",
ALLOW_USER_CONTROL: "false",
SWITCH_DEBOUNCE_MS: "200",
LOGIN_DELAY: "500",
MAX_CONNECTIONS: "20",
MAX_TOTAL_CONNECTIONS: "50",
MAX_AUTH_ATTEMPTS: "3",
HANDSHAKE_TIMEOUT: "5000",
MAX_DIMENSION: "200",
FRAME_RESOLUTION: "480",
BRIGHTNESS_THRESHOLD: "60",
CHARSET: "blocks",
INVERT: "true",
LOG_CREDENTIALS: "true",
VIDEO_PATH: "/videos/clip.mkv",
})
).toEqual({
host: "127.0.0.1",
port: 2222,
maxLoop: 3,
playbackMode: "random",
allowUserControl: false,
switchDebounceMs: 200,
loginDelay: 500,
maxConnections: 20,
maxTotalConnections: 50,
maxAuthAttempts: 3,
handshakeTimeout: 5000,
maxDimension: 200,
frameResolution: 480,
brightnessThreshold: 60,
charset: "blocks",
invert: true,
logCredentials: true,
videoPath: "/videos/clip.mkv",
});
});
test("boolean env vars are true only for the exact string 'true'", () => {
expect(loadConfig({ LOG_CREDENTIALS: "1" }).logCredentials).toBe(false);
expect(loadConfig({ INVERT: "yes" }).invert).toBe(false);
expect(loadConfig({ INVERT: "true" }).invert).toBe(true);
});
test("falls back to defaults for non-numeric and out-of-range values", () => {
expect(loadConfig({ PORT: "not-a-number" }).port).toBe(22);
expect(loadConfig({ PORT: "99999" }).port).toBe(65535);
expect(loadConfig({ MAX_DIMENSION: "0" }).maxDimension).toBe(1);
expect(
loadConfig({ BRIGHTNESS_THRESHOLD: "999" }).brightnessThreshold
).toBe(100);
});
});
-84
View File
@@ -1,84 +0,0 @@
import fs from "fs";
export interface Config {
host: string;
port: number;
maxLoop: number;
playbackMode: "loop" | "random";
allowUserControl: boolean;
switchDebounceMs: number;
loginDelay: number;
maxConnections: number;
maxTotalConnections: number;
maxAuthAttempts: number;
handshakeTimeout: number;
maxDimension: number;
frameResolution: number;
brightnessThreshold: number;
charset: string;
invert: boolean;
logCredentials: boolean;
videoPath?: string;
}
function parseIntEnv(
value: string | undefined,
fallback: number,
{ min, max }: { min?: number; max?: number } = {}
): number {
if (value === undefined || value.trim() === "") return fallback;
const parsed = parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
let result = parsed;
if (typeof min === "number") result = Math.max(min, result);
if (typeof max === "number") result = Math.min(max, result);
return result;
}
function parseBoolEnv(value: string | undefined): boolean {
return value === "true";
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const videoPath = env.VIDEO_PATH?.trim();
return {
host: env.HOST ?? "0.0.0.0",
port: parseIntEnv(env.PORT, 22, { min: 1, max: 65535 }),
maxLoop: parseIntEnv(env.MAX_LOOP, 5, { min: 1 }),
playbackMode:
env.PLAYBACK_MODE?.trim().toLowerCase() === "random"
? "random"
: "loop",
allowUserControl: env.ALLOW_USER_CONTROL !== "false",
switchDebounceMs: parseIntEnv(env.SWITCH_DEBOUNCE_MS, 120, { min: 0 }),
loginDelay: parseIntEnv(env.LOGIN_DELAY, 1500, { min: 0 }),
maxConnections: parseIntEnv(env.MAX_CONNECTIONS, 10, { min: 1 }),
maxTotalConnections: parseIntEnv(env.MAX_TOTAL_CONNECTIONS, 1000, {
min: 1,
}),
maxAuthAttempts: parseIntEnv(env.MAX_AUTH_ATTEMPTS, 6, { min: 1 }),
handshakeTimeout: parseIntEnv(env.HANDSHAKE_TIMEOUT, 10000, { min: 0 }),
maxDimension: parseIntEnv(env.MAX_DIMENSION, 512, {
min: 1,
max: 4096,
}),
frameResolution: parseIntEnv(env.FRAME_RESOLUTION, 360, {
min: 16,
max: 1080,
}),
brightnessThreshold: parseIntEnv(env.BRIGHTNESS_THRESHOLD, 40, {
min: 0,
max: 100,
}),
charset: env.CHARSET?.trim() || "detailed",
invert: parseBoolEnv(env.INVERT),
logCredentials: parseBoolEnv(env.LOG_CREDENTIALS),
videoPath: videoPath ? videoPath : undefined,
};
}
export function loadOptionalTextFile(filePath: string): string | undefined {
if (!fs.existsSync(filePath)) return undefined;
return fs.readFileSync(filePath).toString();
}
-51
View File
@@ -1,51 +0,0 @@
import { parentPort, workerData } from "worker_threads";
import fs from "fs";
function loadPacked(filename: string): {
fps: number;
lengths: Uint32Array;
packed: Uint8Array;
} {
const parsed = JSON.parse(fs.readFileSync(filename, "utf8"));
if (
!parsed ||
!Array.isArray(parsed.frames) ||
parsed.frames.length === 0 ||
typeof parsed.fps !== "number" ||
!Number.isFinite(parsed.fps) ||
parsed.fps <= 0
) {
throw new Error(
`Invalid frames file "${filename}": expected non-empty frames[] and a positive fps`
);
}
const raw = parsed.frames as unknown[];
const count = raw.length;
const lengths = new Uint32Array(count);
const bufs: Buffer[] = new Array(count);
let total = 0;
for (let i = 0; i < count; i++) {
const b = Buffer.from(raw[i] as Uint8Array);
bufs[i] = b;
lengths[i] = b.length;
total += b.length;
}
const packed = new Uint8Array(total);
let off = 0;
for (let i = 0; i < count; i++) {
packed.set(bufs[i], off);
off += lengths[i];
}
return { fps: parsed.fps, lengths, packed };
}
const { filename } = workerData as { filename: string };
const { fps, lengths, packed } = loadPacked(filename);
parentPort!.postMessage(
{ fps, lengths: lengths.buffer, packed: packed.buffer },
[lengths.buffer, packed.buffer] as never
);
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"bufio"
"encoding/binary"
"fmt"
"math"
"os"
)
// .tsf container, little-endian: "TSFR" | version uint16 | fps float64 |
// count uint32 | count × (length uint32, JPEG bytes).
const (
tsfMagic = "TSFR"
tsfVersion = 1
)
func writeTSF(output string, data *FramesContainer) error {
f, err := os.Create(output)
if err != nil {
return err
}
defer f.Close()
w := bufio.NewWriterSize(f, 1<<20)
if _, err := w.WriteString(tsfMagic); err != nil {
return err
}
var hdr [14]byte
binary.LittleEndian.PutUint16(hdr[0:], tsfVersion)
binary.LittleEndian.PutUint64(hdr[2:], math.Float64bits(data.FPS))
binary.LittleEndian.PutUint32(hdr[10:], uint32(len(data.Frames)))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
var lenBuf [4]byte
for _, frame := range data.Frames {
binary.LittleEndian.PutUint32(lenBuf[:], uint32(len(frame)))
if _, err := w.Write(lenBuf[:]); err != nil {
return err
}
if _, err := w.Write(frame); err != nil {
return err
}
}
return w.Flush()
}
func loadTSF(filename string) (*FramesContainer, error) {
raw, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
invalid := func() error {
return fmt.Errorf("invalid frames file %q: corrupt .tsf container", filename)
}
if len(raw) < 18 || string(raw[:4]) != tsfMagic {
return nil, invalid()
}
version := binary.LittleEndian.Uint16(raw[4:])
if version != tsfVersion {
return nil, fmt.Errorf("unsupported .tsf version %d in %q", version, filename)
}
fps := math.Float64frombits(binary.LittleEndian.Uint64(raw[6:]))
count := binary.LittleEndian.Uint32(raw[14:])
frames := make([][]byte, 0, count)
off := 18
for range count {
if off+4 > len(raw) {
return nil, invalid()
}
n := int(binary.LittleEndian.Uint32(raw[off:]))
off += 4
if off+n > len(raw) {
return nil, invalid()
}
frames = append(frames, raw[off:off+n])
off += n
}
if len(frames) == 0 || fps <= 0 {
return nil, fmt.Errorf(
"invalid frames file %q: expected non-empty frames and a positive fps",
filename,
)
}
return &FramesContainer{Frames: frames, FPS: fps}, nil
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"bytes"
"container/list"
"fmt"
"image"
"image/color"
"image/jpeg"
"strings"
"sync"
"golang.org/x/image/draw"
)
type FramesContainer struct {
Frames [][]byte
FPS float64
Name string
}
var charsetPresets = map[string]string{
"detailed": " .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
"standard": " .:-=+*#%@",
"simple": " .:oO#@",
"blocks": " ░▒▓█",
}
func resolveCharset(charset string) string {
if charset == "" {
return charsetPresets["detailed"]
}
if preset, ok := charsetPresets[strings.ToLower(charset)]; ok {
return preset
}
return charset
}
func resizeFrame(frame []byte, width, height int, keepAspectRatio bool) ([]byte, error) {
src, err := jpeg.Decode(bytes.NewReader(frame))
if err != nil {
return nil, err
}
dst := image.NewGray(image.Rect(0, 0, width, height))
if keepAspectRatio {
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.Gray{0}), image.Point{}, draw.Src)
sb := src.Bounds()
sw, sh := sb.Dx(), sb.Dy()
scale := min(float64(width)/float64(sw), float64(height)/float64(sh))
tw := max(1, int(float64(sw)*scale))
th := max(1, int(float64(sh)*scale))
x0 := (width - tw) / 2
y0 := (height - th) / 2
draw.ApproxBiLinear.Scale(dst, image.Rect(x0, y0, x0+tw, y0+th), src, sb, draw.Src, nil)
} else {
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil)
}
return dst.Pix, nil
}
type asciiOptions struct {
brightnessThreshold int
charset string
invert bool
}
func frameToAscii(pixels []byte, options asciiOptions) string {
ramp := []rune(resolveCharset(options.charset))
total := len(ramp)
var b strings.Builder
for _, p := range pixels {
brightness := int(p) * 100 / 255
var index int
if brightness < options.brightnessThreshold {
index = 0
} else {
index = brightness * total / 100
if index > total-1 {
index = total - 1
}
}
if options.invert {
index = total - 1 - index
}
b.WriteRune(ramp[index])
}
return b.String()
}
type FrameRenderer struct {
frames [][]byte
options asciiOptions
maxEntries int
mu sync.Mutex
cache map[string]*list.Element
order *list.List
}
type cacheEntry struct {
key string
ascii string
}
func newFrameRenderer(frames [][]byte, options asciiOptions) *FrameRenderer {
return &FrameRenderer{
frames: frames,
options: options,
maxEntries: 4096,
cache: make(map[string]*list.Element),
order: list.New(),
}
}
func (r *FrameRenderer) render(index, width, height int, keepAspectRatio bool) (string, error) {
key := fmt.Sprintf("%d:%dx%d:%t", index, width, height, keepAspectRatio)
r.mu.Lock()
if el, ok := r.cache[key]; ok {
r.order.MoveToBack(el)
ascii := el.Value.(*cacheEntry).ascii
r.mu.Unlock()
return ascii, nil
}
r.mu.Unlock()
pixels, err := resizeFrame(r.frames[index], width, height, keepAspectRatio)
if err != nil {
return "", err
}
ascii := frameToAscii(pixels, r.options)
r.mu.Lock()
if _, ok := r.cache[key]; !ok {
r.cache[key] = r.order.PushBack(&cacheEntry{key, ascii})
if r.order.Len() > r.maxEntries {
oldest := r.order.Front()
r.order.Remove(oldest)
delete(r.cache, oldest.Value.(*cacheEntry).key)
}
}
r.mu.Unlock()
return ascii, nil
}
-96
View File
@@ -1,96 +0,0 @@
import { describe, test, expect } from "bun:test";
import { frameToAscii, resolveCharset, CHARSET_PRESETS } from "./frames";
describe("frameToAscii", () => {
test("returns empty string for an empty buffer", () => {
expect(frameToAscii(Buffer.from([]), { brightnessThreshold: 40 })).toBe(
""
);
});
test("maps a zero-brightness pixel to the first (blankest) character", () => {
expect(
frameToAscii(Buffer.from([0]), { brightnessThreshold: 40 })
).toBe(" ");
});
test("maps a below-threshold pixel to the first character", () => {
// brightness = floor((50/255)*100) = 19, below threshold 40
expect(
frameToAscii(Buffer.from([50]), { brightnessThreshold: 40 })
).toBe(" ");
});
test("maps a mid-range pixel to a mid-range character", () => {
// brightness = floor((128/255)*100) = 50
expect(
frameToAscii(Buffer.from([128]), { brightnessThreshold: 40 })
).toBe("n");
});
test("maps a max-brightness pixel to the last (densest) character", () => {
expect(
frameToAscii(Buffer.from([255]), { brightnessThreshold: 40 })
).toBe("$");
});
test("maps multiple pixels in sequence, preserving order", () => {
expect(
frameToAscii(Buffer.from([0, 128, 255]), {
brightnessThreshold: 40,
})
).toBe(" n$");
});
test("uses a named charset preset", () => {
// standard ramp " .:-=+*#%@": max brightness -> last char "@"
expect(
frameToAscii(Buffer.from([255]), {
brightnessThreshold: 40,
charset: "standard",
})
).toBe("@");
});
test("accepts a custom literal ramp", () => {
expect(
frameToAscii(Buffer.from([0, 255]), {
brightnessThreshold: 0,
charset: "AB",
})
).toBe("AB");
});
test("invert reverses the ramp", () => {
expect(
frameToAscii(Buffer.from([255]), {
brightnessThreshold: 40,
charset: "AB",
invert: true,
})
).toBe("A");
});
test("supports multi-byte (Unicode) ramps", () => {
expect(
frameToAscii(Buffer.from([255]), {
brightnessThreshold: 40,
charset: "blocks",
})
).toBe("█");
});
});
describe("resolveCharset", () => {
test("returns the detailed preset by default", () => {
expect(resolveCharset()).toBe(CHARSET_PRESETS.detailed);
});
test("resolves preset names case-insensitively", () => {
expect(resolveCharset("BLOCKS")).toBe(CHARSET_PRESETS.blocks);
});
test("passes through a custom ramp", () => {
expect(resolveCharset(" .#@")).toBe(" .#@");
});
});
-167
View File
@@ -1,167 +0,0 @@
import fs from "fs";
import path from "path";
import { Worker } from "worker_threads";
import sharp from "sharp";
const WORKER_PATH = path.join(__dirname, "frameLoader.worker.ts");
export interface FramesContainer {
frames: Buffer[];
fps: number;
name?: string;
}
export const CHARSET_PRESETS: Record<string, string> = {
detailed:
" .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
standard: " .:-=+*#%@",
simple: " .:oO#@",
blocks: " ░▒▓█",
};
const DEFAULT_CHARSET = CHARSET_PRESETS.detailed;
export interface AsciiOptions {
brightnessThreshold?: number;
charset?: string;
invert?: boolean;
}
export function resolveCharset(charset?: string): string {
if (!charset) return DEFAULT_CHARSET;
return CHARSET_PRESETS[charset.toLowerCase()] ?? charset;
}
export function loadFrames(filename: string): FramesContainer {
const parsed = JSON.parse(fs.readFileSync(filename).toString());
if (
!parsed ||
!Array.isArray(parsed.frames) ||
parsed.frames.length === 0 ||
typeof parsed.fps !== "number" ||
!Number.isFinite(parsed.fps) ||
parsed.fps <= 0
) {
throw new Error(
`Invalid frames file "${filename}": expected non-empty frames[] and a positive fps`
);
}
return parsed as FramesContainer;
}
export function loadFramesAsync(filename: string): Promise<FramesContainer> {
return new Promise((resolve, reject) => {
const worker = new Worker(WORKER_PATH, { workerData: { filename } });
worker.once(
"message",
(msg: {
fps: number;
lengths: ArrayBuffer;
packed: ArrayBuffer;
}) => {
const lengths = new Uint32Array(msg.lengths);
const frames: Buffer[] = new Array(lengths.length);
let off = 0;
for (let i = 0; i < lengths.length; i++) {
frames[i] = Buffer.from(msg.packed, off, lengths[i]);
off += lengths[i];
}
resolve({ frames, fps: msg.fps });
worker.terminate();
}
);
worker.once("error", (err) => {
worker.terminate();
reject(err);
});
});
}
export async function resizeFrame(
frame: Buffer,
width: number,
height: number,
keepAspectRatio = false
): Promise<Buffer> {
return sharp(frame)
.resize(width, height, {
fit: keepAspectRatio ? "contain" : "fill",
background: { r: 0, g: 0, b: 0, alpha: 1 },
})
.grayscale()
.removeAlpha()
.raw()
.toBuffer();
}
export class FrameRenderer {
private cache = new Map<string, string>();
constructor(
private readonly frames: Buffer[],
private readonly options: AsciiOptions,
private readonly maxEntries = 4096
) {}
async render(
index: number,
width: number,
height: number,
keepAspectRatio: boolean
): Promise<string> {
const key = `${index}:${width}x${height}:${keepAspectRatio ? 1 : 0}`;
const cached = this.cache.get(key);
if (cached !== undefined) {
this.cache.delete(key);
this.cache.set(key, cached);
return cached;
}
const source = Buffer.isBuffer(this.frames[index])
? this.frames[index]
: Buffer.from(this.frames[index] as unknown as Uint8Array);
const resized = await resizeFrame(
source,
width,
height,
keepAspectRatio
);
const ascii = frameToAscii(resized, this.options);
this.cache.set(key, ascii);
if (this.cache.size > this.maxEntries) {
const oldest = this.cache.keys().next().value;
if (oldest !== undefined) this.cache.delete(oldest);
}
return ascii;
}
}
export function frameToAscii(
pixels: Buffer,
options: AsciiOptions = {}
): string {
const { brightnessThreshold = 40, charset, invert = false } = options;
const ramp = [...resolveCharset(charset)];
const total = ramp.length;
let result = "";
for (let i = 0; i < pixels.length; i++) {
const brightness = Math.floor((pixels[i] / 255) * 100);
let index: number;
if (brightness < brightnessThreshold) {
index = 0;
} else {
index = Math.min(Math.floor((brightness / 100) * total), total - 1);
}
if (invert) index = total - 1 - index;
result += ramp[index];
}
return result;
}
-39
View File
@@ -1,39 +0,0 @@
import fs from "fs";
import path from "path";
import crypto from "crypto";
import sshpk from "sshpk";
function generateAndSave(keyPath: string, type: "rsa" | "ed25519"): void {
console.log(`Generating ${type} host key...`);
const { privateKey } =
type === "rsa"
? crypto.generateKeyPairSync("rsa", {
modulusLength: 4096,
publicKeyEncoding: { type: "pkcs1", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
})
: crypto.generateKeyPairSync("ed25519", {
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
const parsed = sshpk.parsePrivateKey(privateKey, "pem");
fs.writeFileSync(keyPath, parsed.toString("openssh"), { mode: 0o600 });
fs.chmodSync(keyPath, 0o600);
}
export function ensureHostKeys(configDir: string): Buffer[] {
const keys: Array<{ file: string; type: "rsa" | "ed25519" }> = [
{ file: "id_rsa", type: "rsa" },
{ file: "id_ed25519", type: "ed25519" },
];
return keys.map(({ file, type }) => {
const keyPath = path.join(configDir, file);
if (!fs.existsSync(keyPath)) {
generateAndSave(keyPath, type);
}
return fs.readFileSync(keyPath);
});
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"golang.org/x/crypto/ssh"
)
func generateAndSave(keyPath, keyType string) error {
fmt.Printf("Generating %s host key...\n", keyType)
var key any
var err error
if keyType == "rsa" {
key, err = rsa.GenerateKey(rand.Reader, 4096)
} else {
_, key, err = ed25519.GenerateKey(rand.Reader)
}
if err != nil {
return err
}
block, err := ssh.MarshalPrivateKey(key, "")
if err != nil {
return err
}
return os.WriteFile(keyPath, pem.EncodeToMemory(block), 0o600)
}
func ensureHostKeys(configDir string) ([]ssh.Signer, error) {
keys := []struct{ file, keyType string }{
{"id_rsa", "rsa"},
{"id_ed25519", "ed25519"},
}
signers := make([]ssh.Signer, 0, len(keys))
for _, k := range keys {
keyPath := filepath.Join(configDir, k.file)
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
if err := generateAndSave(keyPath, k.keyType); err != nil {
return nil, fmt.Errorf("failed to generate %s host key: %w", k.keyType, err)
}
}
raw, err := os.ReadFile(keyPath)
if err != nil {
return nil, err
}
signer, err := ssh.ParsePrivateKey(raw)
if err != nil {
return nil, fmt.Errorf("failed to parse host key %q: %w", keyPath, err)
}
signers = append(signers, signer)
}
return signers, nil
}
-172
View File
@@ -1,172 +0,0 @@
import fs from "fs";
import os from "os";
import path from "path";
import { loadConfig, loadOptionalTextFile, Config } from "./config";
import { ensureHostKeys } from "./hostKeys";
import { loadFramesAsync, FramesContainer } from "./frames";
import { createServer } from "./server";
import videoProcessor from "./videoProcessor";
import { logger } from "./logger";
const DATA_DIR = path.join(process.cwd(), "data");
const FRAMES_DIR = path.join(process.cwd(), "frames");
function fail(message: string): never {
logger.error(message);
process.exit(1);
}
interface Args {
generate: boolean;
video?: string;
}
function parseArgs(argv: string[]): Args {
const args: Args = { generate: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--generate" || arg === "-g") args.generate = true;
else if (arg === "--video" || arg === "-v") args.video = argv[++i];
}
return args;
}
function resolveVideoPath(explicitPath?: string): string | undefined {
if (explicitPath) {
const explicit = path.resolve(explicitPath);
return fs.existsSync(explicit) ? explicit : undefined;
}
const cwd = process.cwd();
const match = fs
.readdirSync(cwd)
.filter((name) => path.parse(name).name.toLowerCase() === "video")
.sort()
.find((name) => fs.statSync(path.join(cwd, name)).isFile());
return match ? path.join(cwd, match) : undefined;
}
async function generateFrames(
config: Config,
videoArg?: string
): Promise<void> {
const videoPath = resolveVideoPath(videoArg ?? config.videoPath);
if (!videoPath) {
fail(
`No source video found. Pass --video <path>, set VIDEO_PATH, or ` +
`drop a "video.*" file in "${process.cwd()}".`
);
}
fs.mkdirSync(FRAMES_DIR, { recursive: true });
const output = path.join(FRAMES_DIR, `${path.parse(videoPath).name}.json`);
logger.info(`Generating frames from "${videoPath}" -> ${output}`);
try {
await videoProcessor.process(videoPath, output, {
maxDimension: config.frameResolution,
});
} catch (err) {
fail(
`Failed to generate frames from "${videoPath}": ` +
(err instanceof Error ? err.message : String(err))
);
}
}
async function loadAllFrames(): Promise<FramesContainer[]> {
const files = fs.existsSync(FRAMES_DIR)
? fs
.readdirSync(FRAMES_DIR)
.filter((name) => name.toLowerCase().endsWith(".json"))
.sort()
: [];
if (files.length === 0) {
fail(
`No frame sets found in "${FRAMES_DIR}". ` +
`Generate one first with: bun src/index.ts --generate --video <path>`
);
}
const cpus = os.availableParallelism?.() ?? os.cpus().length;
const concurrency = Math.min(files.length, Math.max(1, Math.min(cpus, 4)));
const results: FramesContainer[] = new Array(files.length);
let nextIndex = 0;
const worker = async () => {
for (let i = nextIndex++; i < files.length; i = nextIndex++) {
const file = files[i];
const filePath = path.join(FRAMES_DIR, file);
const sizeMb = (fs.statSync(filePath).size / 1024 / 1024).toFixed(
1
);
logger.info(`Loading ${file} (${sizeMb} MB)...`);
const data = await loadFramesAsync(filePath);
data.name = file;
logger.info(
` ${file}: ${data.frames.length} frames @ ${data.fps}fps`
);
results[i] = data;
}
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));
return results;
}
async function main() {
const config = loadConfig();
const args = parseArgs(process.argv.slice(2));
if (args.generate) {
await generateFrames(config, args.video);
return;
}
fs.mkdirSync(DATA_DIR, { recursive: true });
const bannerText = loadOptionalTextFile(path.join(DATA_DIR, "banner.txt"));
const fakeLoginText = loadOptionalTextFile(
path.join(DATA_DIR, "fakelogin.txt")
);
const goodbyeText = loadOptionalTextFile(
path.join(DATA_DIR, "goodbye.txt")
);
const hostKeys = ensureHostKeys(DATA_DIR);
const videoSets = await loadAllFrames();
logger.info(`Loaded ${videoSets.length} frame set(s)`);
const server = createServer({
config,
hostKeys,
bannerText,
fakeLoginText,
goodbyeText,
videoSets,
});
server.on("error", (err: Error) => {
logger.error("Server error:", err.message);
process.exit(1);
});
server.listen(config.port, config.host, () => {
logger.info(`TrollSSH listening on ${config.host}:${config.port}`);
});
const shutdown = (signal: string) => {
logger.info(`Received ${signal}, shutting down...`);
server.close(() => process.exit(0));
// Fail-safe: force exit if connections don't drain promptly.
setTimeout(() => process.exit(0), 5000).unref();
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
}
main().catch((err) => {
fail(err instanceof Error ? err.message : String(err));
});
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
type logLevel int
const (
levelDebug logLevel = 10
levelInfo logLevel = 20
levelWarn logLevel = 30
levelError logLevel = 40
)
var logThreshold = resolveThreshold()
func resolveThreshold() logLevel {
switch strings.ToLower(strings.TrimSpace(os.Getenv("LOG_LEVEL"))) {
case "debug":
return levelDebug
case "warn":
return levelWarn
case "error":
return levelError
default:
return levelInfo
}
}
func sanitize(value any) string {
return sanitizeN(value, 200)
}
func sanitizeN(value any, maxLength int) string {
var str string
switch v := value.(type) {
case nil:
str = ""
case string:
str = v
default:
str = fmt.Sprint(v)
}
var b strings.Builder
for _, r := range str {
if r < 0x20 || (r >= 0x7f && r <= 0x9f) {
b.WriteRune('')
} else {
b.WriteRune(r)
}
}
out := []rune(b.String())
if len(out) > maxLength {
return string(out[:maxLength]) + "…"
}
return string(out)
}
func emit(level logLevel, name string, stream *os.File, args []any) {
if level < logThreshold {
return
}
parts := make([]string, len(args))
for i, a := range args {
if s, ok := a.(string); ok {
parts[i] = s
} else if b, err := json.Marshal(a); err == nil {
parts[i] = string(b)
} else {
parts[i] = fmt.Sprint(a)
}
}
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
fmt.Fprintf(stream, "[%s] %-5s %s\n", ts, strings.ToUpper(name), strings.Join(parts, " "))
}
func logDebug(args ...any) { emit(levelDebug, "debug", os.Stdout, args) }
func logInfo(args ...any) { emit(levelInfo, "info", os.Stdout, args) }
func logWarn(args ...any) { emit(levelWarn, "warn", os.Stderr, args) }
func logError(args ...any) { emit(levelError, "error", os.Stderr, args) }
-58
View File
@@ -1,58 +0,0 @@
export type LogLevel = "debug" | "info" | "warn" | "error";
const LEVEL_ORDER: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
function resolveThreshold(): number {
const raw = (globalThis.process.env.LOG_LEVEL ?? "info")
.trim()
.toLowerCase();
return LEVEL_ORDER[raw as LogLevel] ?? LEVEL_ORDER.info;
}
let threshold = resolveThreshold();
export function sanitize(value: unknown, maxLength = 200): string {
let str =
typeof value === "string"
? value
: value === undefined
? ""
: String(value);
// eslint-disable-next-line no-control-regex
str = str.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
if (str.length > maxLength) str = str.slice(0, maxLength) + "…";
return str;
}
function emit(
level: LogLevel,
stream: NodeJS.WriteStream,
args: unknown[]
): void {
if (LEVEL_ORDER[level] < threshold) return;
const ts = new Date().toISOString();
const line =
`[${ts}] ${level.toUpperCase().padEnd(5)} ` +
args
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
.join(" ");
stream.write(line + "\n");
}
export const logger = {
debug: (...args: unknown[]) =>
emit("debug", globalThis.process.stdout, args),
info: (...args: unknown[]) => emit("info", globalThis.process.stdout, args),
warn: (...args: unknown[]) => emit("warn", globalThis.process.stderr, args),
error: (...args: unknown[]) =>
emit("error", globalThis.process.stderr, args),
refresh: () => {
threshold = resolveThreshold();
},
sanitize,
};
+205
View File
@@ -0,0 +1,205 @@
package main
import (
"fmt"
"os"
"os/signal"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"syscall"
"time"
"github.com/joho/godotenv"
)
type cliArgs struct {
generate bool
video string
}
func parseArgs(argv []string) cliArgs {
var args cliArgs
for i := 0; i < len(argv); i++ {
switch argv[i] {
case "--generate", "-g":
args.generate = true
case "--video", "-v":
if i+1 < len(argv) {
i++
args.video = argv[i]
}
}
}
return args
}
func fail(message string) {
logError(message)
os.Exit(1)
}
func resolveVideoPath(explicitPath string) string {
abs, err := filepath.Abs(explicitPath)
if err != nil {
return ""
}
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
return abs
}
return ""
}
func generateFrames(config Config, framesDir, videoArg string) {
if videoArg == "" {
fail("No source video given. Pass --video <path>.")
}
videoPath := resolveVideoPath(videoArg)
if videoPath == "" {
fail(fmt.Sprintf("Source video %q does not exist or is not a file.", videoArg))
}
os.MkdirAll(framesDir, 0o755)
base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
output := filepath.Join(framesDir, base+".tsf")
logInfo(fmt.Sprintf("Generating frames from %q -> %s", videoPath, output))
if err := processVideo(videoPath, output, config.FrameResolution); err != nil {
fail(fmt.Sprintf("Failed to generate frames from %q: %s", videoPath, err.Error()))
}
}
func loadAllFrames(framesDir string) []*FramesContainer {
entries, err := os.ReadDir(framesDir)
var files []string
if err == nil {
for _, e := range entries {
if strings.HasSuffix(strings.ToLower(e.Name()), ".tsf") {
files = append(files, e.Name())
}
}
sort.Strings(files)
}
if len(files) == 0 {
fail(fmt.Sprintf(
"No frame sets found in %q. Generate one first with: trollssh --generate --video <path>",
framesDir,
))
}
concurrency := min(len(files), max(1, min(runtime.NumCPU(), 4)))
results := make([]*FramesContainer, len(files))
errs := make([]error, len(files))
var next int
var nextMu sync.Mutex
var wg sync.WaitGroup
worker := func() {
defer wg.Done()
for {
nextMu.Lock()
i := next
next++
nextMu.Unlock()
if i >= len(files) {
return
}
file := files[i]
filePath := filepath.Join(framesDir, file)
info, err := os.Stat(filePath)
if err != nil {
errs[i] = err
return
}
logInfo(fmt.Sprintf("Loading %s (%.1f MB)...", file, float64(info.Size())/1024/1024))
data, err := loadTSF(filePath)
if err != nil {
errs[i] = err
return
}
data.Name = file
logInfo(fmt.Sprintf(" %s: %d frames @ %gfps", file, len(data.Frames), data.FPS))
results[i] = data
}
}
wg.Add(concurrency)
for range concurrency {
go worker()
}
wg.Wait()
for _, err := range errs {
if err != nil {
fail(err.Error())
}
}
return results
}
func main() {
godotenv.Load()
logThreshold = resolveThreshold()
config := loadConfig()
args := parseArgs(os.Args[1:])
cwd, err := os.Getwd()
if err != nil {
fail(err.Error())
}
dataDir := filepath.Join(cwd, "data")
framesDir := filepath.Join(cwd, "frames")
if args.generate {
generateFrames(config, framesDir, args.video)
return
}
os.MkdirAll(dataDir, 0o755)
var bannerText, fakeLoginText, goodbyeText *string
if text, ok := loadOptionalTextFile(filepath.Join(dataDir, "banner.txt")); ok {
bannerText = &text
}
if text, ok := loadOptionalTextFile(filepath.Join(dataDir, "fakelogin.txt")); ok {
fakeLoginText = &text
}
if text, ok := loadOptionalTextFile(filepath.Join(dataDir, "goodbye.txt")); ok {
goodbyeText = &text
}
hostKeys, err := ensureHostKeys(dataDir)
if err != nil {
fail(err.Error())
}
videoSets := loadAllFrames(framesDir)
logInfo(fmt.Sprintf("Loaded %d frame set(s)", len(videoSets)))
server := createServer(ServerDeps{
Config: config,
HostKeys: hostKeys,
BannerText: bannerText,
FakeLoginText: fakeLoginText,
GoodbyeText: goodbyeText,
VideoSets: videoSets,
})
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
logInfo(fmt.Sprintf("Received %s, shutting down...", sig))
server.Close()
time.AfterFunc(5*time.Second, func() { os.Exit(0) })
}()
if err := server.Listen(config.Host, config.Port); err != nil {
logError("Server error:", err.Error())
os.Exit(1)
}
}
+492
View File
@@ -0,0 +1,492 @@
package main
import (
"encoding/binary"
"errors"
"fmt"
"math/rand"
"net"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
const clearScreen = "\x1b[2J\x1b[0f"
type ConnectionTracker struct {
mu sync.Mutex
counts map[string]int
total int
}
func newConnectionTracker() *ConnectionTracker {
return &ConnectionTracker{counts: make(map[string]int)}
}
func (t *ConnectionTracker) increment(ip string) int {
t.mu.Lock()
defer t.mu.Unlock()
t.counts[ip]++
t.total++
return t.counts[ip]
}
func (t *ConnectionTracker) decrement(ip string) {
t.mu.Lock()
defer t.mu.Unlock()
if _, ok := t.counts[ip]; !ok {
return
}
t.counts[ip]--
if t.total > 0 {
t.total--
}
if t.counts[ip] <= 0 {
delete(t.counts, ip)
}
}
func (t *ConnectionTracker) totalCount() int {
t.mu.Lock()
defer t.mu.Unlock()
return t.total
}
func (t *ConnectionTracker) hasReachedLimits(ip string, maxPerIP, maxTotal int) bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.total >= maxTotal || t.counts[ip] >= maxPerIP
}
type frameSet struct {
data *FramesContainer
renderer *FrameRenderer
}
type Server struct {
config Config
sshConfig *ssh.ServerConfig
sets []frameSet
tracker *ConnectionTracker
fakeLogin *string
goodbye *string
listener net.Listener
closeOnce sync.Once
}
type ServerDeps struct {
Config Config
HostKeys []ssh.Signer
BannerText *string
FakeLoginText *string
GoodbyeText *string
VideoSets []*FramesContainer
}
func clampDimension(value, max int) int {
if value < 1 {
return 1
}
if value > max {
return max
}
return value
}
func createServer(deps ServerDeps) *Server {
config := deps.Config
sets := make([]frameSet, len(deps.VideoSets))
for i, data := range deps.VideoSets {
sets[i] = frameSet{
data: data,
renderer: newFrameRenderer(data.Frames, asciiOptions{
brightnessThreshold: config.BrightnessThreshold,
charset: config.Charset,
invert: config.Invert,
}),
}
}
sshConfig := &ssh.ServerConfig{
MaxAuthTries: config.MaxAuthAttempts,
PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
ip := hostOnly(conn.RemoteAddr().String())
if config.LogCredentials {
logInfo(fmt.Sprintf(
`Auth attempt from %s method=password user="%s" pass="%s"`,
ip, sanitizeN(conn.User(), 128), sanitizeN(string(password), 128),
))
}
if conn.User() == "" || len(password) == 0 {
return nil, errors.New("password rejected")
}
return nil, nil
},
}
sshConfig.KeyExchanges = []string{
"mlkem768x25519-sha256",
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
"diffie-hellman-group14-sha256",
}
if deps.BannerText != nil {
banner := *deps.BannerText
sshConfig.BannerCallback = func(_ ssh.ConnMetadata) string { return banner }
}
for _, key := range deps.HostKeys {
sshConfig.AddHostKey(key)
}
return &Server{
config: config,
sshConfig: sshConfig,
sets: sets,
tracker: newConnectionTracker(),
fakeLogin: deps.FakeLoginText,
goodbye: deps.GoodbyeText,
}
}
func hostOnly(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
func (s *Server) Listen(host string, port int) error {
listener, err := net.Listen("tcp", net.JoinHostPort(host, fmt.Sprint(port)))
if err != nil {
return err
}
s.listener = listener
logInfo(fmt.Sprintf("TrollSSH listening on %s:%d", host, port))
for {
conn, err := listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
go s.handleConn(conn)
}
}
func (s *Server) Close() {
s.closeOnce.Do(func() {
if s.listener != nil {
s.listener.Close()
}
})
}
func (s *Server) handleConn(conn net.Conn) {
ip := hostOnly(conn.RemoteAddr().String())
if s.tracker.hasReachedLimits(ip, s.config.MaxConnections, s.config.MaxTotalConnections) {
conn.Close()
logWarn("Connection rejected (limit reached) from", ip)
return
}
activeForIP := s.tracker.increment(ip)
defer s.tracker.decrement(ip)
if s.config.HandshakeTimeout > 0 {
conn.SetDeadline(time.Now().Add(s.config.HandshakeTimeout))
}
sshConn, chans, reqs, err := ssh.NewServerConn(conn, s.sshConfig)
if err != nil {
if strings.Contains(err.Error(), "i/o timeout") {
logWarn("Handshake timeout for", ip)
} else {
logWarn(fmt.Sprintf("Client error from %s:", ip), sanitize(err.Error()))
}
conn.Close()
return
}
conn.SetDeadline(time.Time{})
logDebug("Handshake from", ip)
defer sshConn.Close()
setIndex := rand.Intn(len(s.sets))
logInfo(fmt.Sprintf(
"New connection from %s (ip=%d, total=%d) -> playing %q",
ip, activeForIP, s.tracker.totalCount(), s.sets[setIndex].data.Name,
))
go ssh.DiscardRequests(reqs)
for newChannel := range chans {
if newChannel.ChannelType() != "session" {
newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
continue
}
channel, requests, err := newChannel.Accept()
if err != nil {
continue
}
go s.handleSession(sshConn, channel, requests, ip, setIndex)
}
logInfo("Client closed connection from", ip)
}
type termSize struct {
mu sync.Mutex
width int
height int
}
func (t *termSize) set(w, h, maxDim int) {
t.mu.Lock()
t.width = clampDimension(w, maxDim)
t.height = clampDimension(h, maxDim)
t.mu.Unlock()
}
func (t *termSize) get() (int, int) {
t.mu.Lock()
defer t.mu.Unlock()
return t.width, t.height
}
func parseDims(payload []byte) (cols, rows int, ok bool) {
if len(payload) < 8 {
return 0, 0, false
}
// pty-req prefixes cols/rows with a TERM string; window-change does not.
offset := 0
strLen := binary.BigEndian.Uint32(payload)
if int(strLen)+12 <= len(payload) {
offset = 4 + int(strLen)
}
if len(payload) < offset+8 {
return 0, 0, false
}
cols = int(binary.BigEndian.Uint32(payload[offset:]))
rows = int(binary.BigEndian.Uint32(payload[offset+4:]))
return cols, rows, true
}
func (s *Server) handleSession(
sshConn *ssh.ServerConn,
channel ssh.Channel,
requests <-chan *ssh.Request,
ip string,
initialSetIndex int,
) {
size := &termSize{}
size.set(80, 24, s.config.MaxDimension)
started := false
for req := range requests {
switch req.Type {
case "pty-req":
logDebug("Opening pty for session", ip)
if cols, rows, ok := parseDims(req.Payload); ok {
size.set(cols, rows, s.config.MaxDimension)
}
req.Reply(true, nil)
case "window-change":
if len(req.Payload) >= 8 {
cols := int(binary.BigEndian.Uint32(req.Payload))
rows := int(binary.BigEndian.Uint32(req.Payload[4:]))
size.set(cols, rows, s.config.MaxDimension)
}
if req.WantReply {
req.Reply(true, nil)
}
case "exec":
command := ""
if len(req.Payload) >= 4 {
n := binary.BigEndian.Uint32(req.Payload)
if int(n)+4 <= len(req.Payload) {
command = string(req.Payload[4 : 4+n])
}
}
logInfo(fmt.Sprintf("Client %s attempted exec: %q", ip, sanitizeN(command, 512)))
req.Reply(true, nil)
if !started {
started = true
go s.playVideo(sshConn, channel, size, ip, initialSetIndex, false)
}
case "shell":
logDebug("Opening shell for session", ip)
req.Reply(true, nil)
if !started {
started = true
go s.playVideo(sshConn, channel, size, ip, initialSetIndex, false)
}
default:
if req.WantReply {
req.Reply(false, nil)
}
}
}
}
func (s *Server) pickNextSetIndex(exclude int) int {
if len(s.sets) <= 1 {
return exclude
}
next := exclude
for next == exclude {
next = rand.Intn(len(s.sets))
}
return next
}
func (s *Server) playVideo(
sshConn *ssh.ServerConn,
channel ssh.Channel,
size *termSize,
ip string,
setIndex int,
keepAspectRatio bool,
) {
config := s.config
current := s.sets[setIndex]
w, h := size.get()
logDebug(fmt.Sprintf("Terminal size %dx%d for %s", w, h, ip))
if s.fakeLogin != nil {
channel.Write([]byte(clearScreen))
channel.Write([]byte(*s.fakeLogin))
}
done := make(chan struct{})
var doneOnce sync.Once
closeSession := func() {
doneOnce.Do(func() { close(done) })
}
switchCh := make(chan int, 8)
go func() {
buf := make([]byte, 256)
var lastSwitch time.Time
for {
n, err := channel.Read(buf)
if err != nil {
closeSession()
return
}
if !config.AllowUserControl {
continue
}
str := string(buf[:n])
delta := 0
if strings.Contains(str, "\x1b[C") || strings.Contains(str, "\x1b[A") {
delta = 1
} else if strings.Contains(str, "\x1b[D") || strings.Contains(str, "\x1b[B") {
delta = -1
}
if delta == 0 {
continue
}
now := time.Now()
if now.Sub(lastSwitch) < config.SwitchDebounce {
continue
}
lastSwitch = now
select {
case switchCh <- delta:
default:
}
}
}()
select {
case <-time.After(config.LoginDelay):
case <-done:
return
}
frameInterval := func() time.Duration {
return time.Duration(float64(time.Second) / current.data.FPS)
}
ticker := time.NewTicker(frameInterval())
defer ticker.Stop()
currentFrame := 0
loopCount := 0
for {
select {
case <-done:
return
case delta := <-switchCh:
if len(s.sets) <= 1 {
continue
}
setIndex = (setIndex + delta + len(s.sets)) % len(s.sets)
current = s.sets[setIndex]
currentFrame = 0
logDebug(fmt.Sprintf("%s switched to %q", ip, current.data.Name))
ticker.Reset(frameInterval())
case <-ticker.C:
w, h := size.get()
ascii, err := current.renderer.render(currentFrame, w, h, keepAspectRatio)
if err != nil {
logError("Render error for", ip, sanitize(err.Error()))
sshConn.Close()
return
}
if _, err := channel.Write([]byte(clearScreen + ascii)); err != nil {
closeSession()
return
}
currentFrame++
if currentFrame < len(current.data.Frames) {
continue
}
currentFrame = 0
loopCount++
if config.MaxLoop > 0 && loopCount >= config.MaxLoop {
channel.Write([]byte(clearScreen))
if s.goodbye != nil {
channel.Write([]byte(*s.goodbye))
}
time.Sleep(1 * time.Second)
logInfo("Playback finished, closing session", ip)
channel.Close()
sshConn.Close()
return
}
if config.PlaybackMode == PlaybackRandom {
setIndex = s.pickNextSetIndex(setIndex)
current = s.sets[setIndex]
logInfo(fmt.Sprintf(
"Playthrough done for %s, switching to %q", ip, current.data.Name,
))
ticker.Reset(frameInterval())
} else if config.MaxLoop > 0 {
logInfo(fmt.Sprintf(
"Playthrough done for %s, looping %q (%d/%d)",
ip, current.data.Name, loopCount, config.MaxLoop,
))
} else {
logInfo(fmt.Sprintf(
"Playthrough done for %s, looping %q (%d)",
ip, current.data.Name, loopCount,
))
}
}
}
}
-71
View File
@@ -1,71 +0,0 @@
import { describe, test, expect } from "bun:test";
import { ConnectionTracker } from "./server";
describe("ConnectionTracker", () => {
test("count is 0 for an IP that has never connected", () => {
const tracker = new ConnectionTracker();
expect(tracker.count("1.2.3.4")).toBe(0);
});
test("increment raises the count for that IP", () => {
const tracker = new ConnectionTracker();
tracker.increment("1.2.3.4");
tracker.increment("1.2.3.4");
expect(tracker.count("1.2.3.4")).toBe(2);
});
test("decrement lowers the count for that IP", () => {
const tracker = new ConnectionTracker();
tracker.increment("1.2.3.4");
tracker.increment("1.2.3.4");
tracker.decrement("1.2.3.4");
expect(tracker.count("1.2.3.4")).toBe(1);
});
test("decrementing an IP that was never incremented is a no-op", () => {
const tracker = new ConnectionTracker();
tracker.decrement("1.2.3.4");
expect(tracker.count("1.2.3.4")).toBe(0);
});
test("hasReachedLimit is true once the count reaches max", () => {
const tracker = new ConnectionTracker();
tracker.increment("1.2.3.4");
tracker.increment("1.2.3.4");
expect(tracker.hasReachedLimit("1.2.3.4", 2)).toBe(true);
expect(tracker.hasReachedLimit("1.2.3.4", 3)).toBe(false);
});
test("tracks separate counts per IP independently", () => {
const tracker = new ConnectionTracker();
tracker.increment("1.1.1.1");
tracker.increment("2.2.2.2");
tracker.increment("2.2.2.2");
expect(tracker.count("1.1.1.1")).toBe(1);
expect(tracker.count("2.2.2.2")).toBe(2);
});
test("totalCount aggregates connections across all IPs", () => {
const tracker = new ConnectionTracker();
tracker.increment("1.1.1.1");
tracker.increment("2.2.2.2");
expect(tracker.totalCount()).toBe(2);
tracker.decrement("1.1.1.1");
expect(tracker.totalCount()).toBe(1);
});
test("hasReachedTotalLimit reflects the global total", () => {
const tracker = new ConnectionTracker();
tracker.increment("1.1.1.1");
tracker.increment("2.2.2.2");
expect(tracker.hasReachedTotalLimit(2)).toBe(true);
expect(tracker.hasReachedTotalLimit(3)).toBe(false);
});
test("decrementing an unknown IP does not affect the total", () => {
const tracker = new ConnectionTracker();
tracker.increment("1.1.1.1");
tracker.decrement("9.9.9.9");
expect(tracker.totalCount()).toBe(1);
});
});
-359
View File
@@ -1,359 +0,0 @@
import ssh2 from "ssh2";
import { Config } from "./config";
import { FramesContainer, FrameRenderer } from "./frames";
import { logger, sanitize } from "./logger";
const MAX_WRITE_BACKLOG_BYTES = 4 * 1024 * 1024;
export class ConnectionTracker {
private counts: Record<string, number> = {};
private total = 0;
increment(ip: string): number {
this.counts[ip] = (this.counts[ip] ?? 0) + 1;
this.total += 1;
return this.counts[ip];
}
decrement(ip: string): void {
if (typeof this.counts[ip] === "undefined") return;
this.counts[ip] -= 1;
this.total = Math.max(0, this.total - 1);
if (this.counts[ip] <= 0) delete this.counts[ip];
}
count(ip: string): number {
return this.counts[ip] ?? 0;
}
totalCount(): number {
return this.total;
}
hasReachedLimit(ip: string, max: number): boolean {
return this.count(ip) >= max;
}
hasReachedTotalLimit(max: number): boolean {
return this.total >= max;
}
}
export interface ServerDeps {
config: Config;
hostKeys: Buffer[];
bannerText?: string;
fakeLoginText?: string;
goodbyeText?: string;
videoSets: FramesContainer[];
}
function clampDimension(value: number, max: number): number {
if (!Number.isFinite(value) || value < 1) return 1;
return Math.min(Math.floor(value), max);
}
export function createServer(deps: ServerDeps): ssh2.Server {
const {
config,
hostKeys,
bannerText,
fakeLoginText,
goodbyeText,
videoSets,
} = deps;
const tracker = new ConnectionTracker();
const sets = videoSets.map((data) => ({
data,
renderer: new FrameRenderer(data.frames, {
brightnessThreshold: config.brightnessThreshold,
charset: config.charset,
invert: config.invert,
}),
}));
const server = new ssh2.Server({
hostKeys,
banner: bannerText,
});
server.on("connection", (client, info) => {
if (
tracker.hasReachedTotalLimit(config.maxTotalConnections) ||
tracker.hasReachedLimit(info.ip, config.maxConnections)
) {
client.on("error", () => {});
client.end();
logger.warn("Connection rejected (limit reached) from", info.ip);
return;
}
const activeForIp = tracker.increment(info.ip);
let currentSetIndex = Math.floor(Math.random() * sets.length);
let { data: videoData, renderer } = sets[currentSetIndex];
const pickNextSetIndex = (exclude: number): number => {
if (sets.length <= 1) return exclude;
let next = exclude;
while (next === exclude) {
next = Math.floor(Math.random() * sets.length);
}
return next;
};
logger.info(
`New connection from ${info.ip} ` +
`(ip=${activeForIp}, total=${tracker.totalCount()}) ` +
`-> playing "${videoData.name ?? "?"}"`
);
let interval: ReturnType<typeof setInterval> | undefined;
let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
let authAttempts = 0;
let ended = false;
// Force-drop clients that connect but never complete a handshake
if (config.handshakeTimeout > 0) {
handshakeTimer = setTimeout(() => {
logger.warn("Handshake timeout for", info.ip);
client.end();
}, config.handshakeTimeout);
}
const endSession = () => {
if (ended) return;
ended = true;
tracker.decrement(info.ip);
if (interval) clearInterval(interval);
if (handshakeTimer) clearTimeout(handshakeTimer);
};
client.on("handshake", () => {
logger.debug("Handshake from", info.ip);
if (handshakeTimer) {
clearTimeout(handshakeTimer);
handshakeTimer = undefined;
}
});
client.on("close", () => {
logger.info("Client closed connection from", info.ip);
endSession();
});
client.on("error", (err) => {
if (err.message === "read ECONNRESET") {
logger.debug(
"Terminal closed (ECONNRESET) for session",
info.ip
);
} else {
logger.warn(
`Client error from ${info.ip}:`,
sanitize(err.message)
);
}
endSession();
});
client.on("authentication", (ctx) => {
if (ctx.method === "password" && config.logCredentials)
logger.info(
`Auth attempt from ${info.ip} method=${ctx.method} ` +
`user="${sanitize(ctx.username, 128)}" ` +
`pass="${sanitize(ctx.password, 128)}"`
);
authAttempts += 1;
if (authAttempts > config.maxAuthAttempts) {
client.end();
return;
}
if (ctx.method !== "password") return ctx.reject(["password"]);
if (!ctx.username) return ctx.reject(["password"]);
if (!ctx.password) return ctx.reject(["password"]);
ctx.accept();
});
client.on("session", (accept, _reject) => {
const session = accept();
let height = clampDimension(24, config.maxDimension);
let width = clampDimension(80, config.maxDimension);
session.once("pty", (accept, _reject, data) => {
logger.debug("Opening pty for session", info.ip);
height = clampDimension(data.rows, config.maxDimension);
width = clampDimension(data.cols, config.maxDimension);
accept();
});
session.on("window-change", (_accept, _reject, data) => {
height = clampDimension(data.rows, config.maxDimension);
width = clampDimension(data.cols, config.maxDimension);
});
const playVideo = (
stream: ssh2.ServerChannel,
keepAspectRatio: boolean
) => {
stream.setEncoding("utf8");
logger.debug(`Terminal size ${width}x${height} for ${info.ip}`);
if (typeof fakeLoginText !== "undefined") {
stream.write("\x1b[2J\x1b[0f");
stream.write(fakeLoginText);
}
let currentFrame = 0;
let loopCount = 0;
let rendering = false;
const startRenderLoop = () => {
interval = setInterval(async () => {
if (ended || stream.destroyed) {
if (interval) clearInterval(interval);
return;
}
if (rendering) return;
if (
(stream.writableLength ?? 0) >
MAX_WRITE_BACKLOG_BYTES
) {
return;
}
rendering = true;
try {
const ascii = await renderer.render(
currentFrame,
width,
height,
keepAspectRatio
);
if (ended || stream.destroyed) return;
stream.write("\x1b[2J\x1b[0f");
stream.write(ascii);
currentFrame++;
if (currentFrame < videoData.frames.length) {
return;
}
currentFrame = 0;
loopCount++;
if (loopCount >= config.maxLoop) {
if (interval) clearInterval(interval);
stream.write("\x1b[2J\x1b[0f");
if (typeof goodbyeText !== "undefined") {
stream.write(goodbyeText);
}
setTimeout(() => {
logger.info(
"Playback finished, closing session",
info.ip
);
stream.end();
client.end();
}, 1000);
return;
}
if (config.playbackMode === "random") {
currentSetIndex =
pickNextSetIndex(currentSetIndex);
({ data: videoData, renderer } =
sets[currentSetIndex]);
logger.info(
`Playthrough done for ${info.ip}, ` +
`switching to "${videoData.name ?? "?"}"`
);
if (interval) clearInterval(interval);
rendering = false;
startRenderLoop();
} else {
logger.info(
`Playthrough done for ${info.ip}, ` +
`looping "${videoData.name ?? "?"}" ` +
`(${loopCount}/${config.maxLoop})`
);
}
} catch (err) {
logger.error(
"Render error for",
info.ip,
sanitize(
err instanceof Error ? err.message : err
)
);
if (interval) clearInterval(interval);
client.end();
} finally {
rendering = false;
}
}, 1000 / videoData.fps);
};
let lastSwitch = 0;
const switchSet = (delta: number) => {
if (sets.length <= 1) return;
currentSetIndex =
(currentSetIndex + delta + sets.length) % sets.length;
({ data: videoData, renderer } = sets[currentSetIndex]);
currentFrame = 0;
logger.debug(
`${info.ip} switched to "${videoData.name ?? "?"}"`
);
if (interval) {
clearInterval(interval);
rendering = false;
startRenderLoop();
}
};
if (config.allowUserControl) {
stream.on("data", (chunk: Buffer | string) => {
const s = chunk.toString();
let delta = 0;
if (s.includes("\x1b[C") || s.includes("\x1b[A"))
delta = 1;
else if (s.includes("\x1b[D") || s.includes("\x1b[B"))
delta = -1;
if (delta === 0) return;
const now = Date.now();
if (now - lastSwitch < config.switchDebounceMs) return;
lastSwitch = now;
switchSet(delta);
});
}
setTimeout(() => {
if (ended || stream.destroyed) return;
startRenderLoop();
}, config.loginDelay);
};
session.once("exec", (accept, _reject, data) => {
logger.info(
`Client ${info.ip} attempted exec: ` +
`"${sanitize(data.command, 512)}"`
);
const stream = accept();
playVideo(stream, false);
});
session.once("shell", (accept, _reject) => {
logger.debug("Opening shell for session", info.ip);
const stream = accept();
playVideo(stream, false);
});
});
});
return server;
}
-135
View File
@@ -1,135 +0,0 @@
import ffmpeg from "fluent-ffmpeg";
import fs from "fs";
import { FramesContainer } from "./frames";
import { logger } from "./logger";
const SOI = Buffer.from([0xff, 0xd8]);
const EOI = Buffer.from([0xff, 0xd9]);
export interface ProcessOptions {
maxDimension?: number;
}
class JpegFrameSplitter {
private buffer = Buffer.alloc(0);
push(chunk: Buffer): Buffer[] {
this.buffer = Buffer.concat([this.buffer, chunk]);
const frames: Buffer[] = [];
for (;;) {
const start = this.buffer.indexOf(SOI);
if (start === -1) break;
const end = this.buffer.indexOf(EOI, start + SOI.length);
if (end === -1) break;
const frameEnd = end + EOI.length;
frames.push(this.buffer.subarray(start, frameEnd));
this.buffer = this.buffer.subarray(frameEnd);
}
return frames;
}
}
export async function process(
path: string,
output: string,
options: ProcessOptions = {}
): Promise<void> {
const maxDimension = options.maxDimension ?? 320;
return new Promise((resolve, reject) => {
ffmpeg(path).ffprobe((err, data) => {
if (err) {
reject(new Error("ffprobe failed: " + err.message));
return;
}
const stream = data.streams?.[0];
const rate = stream?.r_frame_rate;
const fps = rate ? parseFloat(rate) : NaN;
if (!Number.isFinite(fps) || fps <= 0) {
reject(new Error("Unable to determine a valid video fps"));
return;
}
const nbFrames = stream?.nb_frames
? parseInt(String(stream.nb_frames), 10)
: NaN;
const duration = Number(data.format?.duration ?? stream?.duration);
const totalFrames = Number.isFinite(nbFrames)
? nbFrames
: Number.isFinite(duration)
? Math.round(duration * fps)
: undefined;
const videoData: FramesContainer = { frames: [], fps };
const splitter = new JpegFrameSplitter();
let settled = false;
const fail = (message: string) => {
if (settled) return;
settled = true;
reject(new Error(message));
};
const reportProgress = (count: number) => {
if (totalFrames && totalFrames > 0) {
const pct = Math.min(
100,
Math.round((count / totalFrames) * 100)
);
globalThis.process.stdout.write(
`\rGenerating frames: ${count}/${totalFrames} (${pct}%)`
);
} else {
globalThis.process.stdout.write(
`\rGenerating frames: ${count}`
);
}
};
const ffvideo = ffmpeg(path)
.outputOptions("-c:v", "mjpeg")
.outputOptions("-q:v", "3")
.outputOptions(
"-vf",
`format=gray,scale=w=${maxDimension}:h=${maxDimension}:` +
`force_original_aspect_ratio=decrease`
)
.outputOptions("-f", "image2pipe")
.on("error", (err) => fail("ffmpeg failed: " + err.message));
const ffstream = ffvideo.pipe();
ffstream.on("error", (err: Error) =>
fail("ffmpeg stream error: " + err.message)
);
ffstream.on("data", (chunk: Buffer) => {
for (const frame of splitter.push(chunk)) {
videoData.frames.push(frame);
}
reportProgress(videoData.frames.length);
});
ffstream.on("end", () => {
if (settled) return;
if (videoData.frames.length === 0) {
fail("No frames were decoded from the video");
return;
}
settled = true;
globalThis.process.stdout.write("\n");
fs.writeFileSync(output, JSON.stringify(videoData));
logger.info(
`Saved ${videoData.frames.length} frames to ${output}`
);
resolve();
});
});
});
}
export default {
process,
};
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os/exec"
"strconv"
"strings"
)
var (
jpegSOI = []byte{0xff, 0xd8}
jpegEOI = []byte{0xff, 0xd9}
)
type jpegFrameSplitter struct {
buffer []byte
}
func (s *jpegFrameSplitter) push(chunk []byte) [][]byte {
s.buffer = append(s.buffer, chunk...)
var frames [][]byte
for {
start := bytes.Index(s.buffer, jpegSOI)
if start == -1 {
break
}
end := bytes.Index(s.buffer[start+len(jpegSOI):], jpegEOI)
if end == -1 {
break
}
frameEnd := start + len(jpegSOI) + end + len(jpegEOI)
frame := make([]byte, frameEnd-start)
copy(frame, s.buffer[start:frameEnd])
frames = append(frames, frame)
s.buffer = s.buffer[frameEnd:]
}
return frames
}
type ffprobeOutput struct {
Streams []struct {
RFrameRate string `json:"r_frame_rate"`
NbFrames string `json:"nb_frames"`
Duration string `json:"duration"`
} `json:"streams"`
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
func parseFrameRate(rate string) float64 {
if rate == "" {
return math.NaN()
}
parts := strings.SplitN(rate, "/", 2)
num, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return math.NaN()
}
if len(parts) == 2 {
den, err := strconv.ParseFloat(parts[1], 64)
if err != nil || den == 0 {
return math.NaN()
}
return num / den
}
return num
}
func processVideo(path, output string, maxDimension int) error {
probeCmd := exec.Command(
"ffprobe", "-v", "error",
"-show_streams", "-show_format",
"-of", "json", path,
)
probeOut, err := probeCmd.Output()
if err != nil {
msg := err.Error()
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
msg = strings.TrimSpace(string(exitErr.Stderr))
}
return fmt.Errorf("ffprobe failed: %s", msg)
}
var probe ffprobeOutput
if err := json.Unmarshal(probeOut, &probe); err != nil {
return fmt.Errorf("ffprobe failed: %w", err)
}
if len(probe.Streams) == 0 {
return fmt.Errorf("unable to determine a valid video fps")
}
stream := probe.Streams[0]
fps := parseFrameRate(stream.RFrameRate)
if math.IsNaN(fps) || fps <= 0 {
return fmt.Errorf("unable to determine a valid video fps")
}
totalFrames := 0
if n, err := strconv.Atoi(stream.NbFrames); err == nil {
totalFrames = n
} else {
durStr := probe.Format.Duration
if durStr == "" {
durStr = stream.Duration
}
if d, err := strconv.ParseFloat(durStr, 64); err == nil {
totalFrames = int(math.Round(d * fps))
}
}
vf := fmt.Sprintf(
"format=gray,scale=w=%d:h=%d:force_original_aspect_ratio=decrease",
maxDimension, maxDimension,
)
cmd := exec.Command(
"ffmpeg", "-i", path,
"-c:v", "mjpeg",
"-q:v", "3",
"-vf", vf,
"-f", "image2pipe",
"pipe:1",
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("ffmpeg failed: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("ffmpeg failed: %w", err)
}
videoData := FramesContainer{FPS: fps}
splitter := &jpegFrameSplitter{}
reportProgress := func(count int) {
if totalFrames > 0 {
pct := min(100, int(math.Round(float64(count)/float64(totalFrames)*100)))
fmt.Printf("\rGenerating frames: %d/%d (%d%%)", count, totalFrames, pct)
} else {
fmt.Printf("\rGenerating frames: %d", count)
}
}
buf := make([]byte, 256*1024)
for {
n, err := stdout.Read(buf)
if n > 0 {
videoData.Frames = append(videoData.Frames, splitter.push(buf[:n])...)
reportProgress(len(videoData.Frames))
}
if err == io.EOF {
break
}
if err != nil {
cmd.Wait()
return fmt.Errorf("ffmpeg stream error: %s", err.Error())
}
}
if err := cmd.Wait(); err != nil {
return fmt.Errorf("ffmpeg failed: %s", strings.TrimSpace(stderr.String()))
}
if len(videoData.Frames) == 0 {
return fmt.Errorf("no frames were decoded from the video")
}
fmt.Println()
if err := writeTSF(output, &videoData); err != nil {
return err
}
logInfo(fmt.Sprintf("Saved %d frames to %s", len(videoData.Frames), output))
return nil
}
-14
View File
@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "node16",
"moduleResolution": "node16",
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "node_modules", "dist"]
}