diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e8f41fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +test/fixtures/*.mp4 +test/fixtures/*.mkv +test/fixtures/*.mp3 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..8cdc3e7 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,56 @@ +name: Build and Push Docker Image + +on: + push: + branches: [main, master] + tags: ['v*'] + pull_request: + branches: [main, master] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b907898 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +*.tsbuildinfo + +# Test fixtures are generated by test/fixtures/generate-test-fixtures.sh +# (run automatically in Dockerfile.test) — no need to carry binaries in git. +test/fixtures/*.mp4 +test/fixtures/*.mkv +test/fixtures/*.mp3 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..742c30d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# MetaMesh Plugin: still-extractor +# Extracts a representative frame from a video file + +FROM node:20-slim AS builder + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN npm install -g corepack@latest && corepack enable + +WORKDIR /app + +# Copy package files first for layer caching +COPY package.json pnpm-lock.yaml ./ +RUN corepack install +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile + +# Copy source and build +COPY tsconfig.json ./ +COPY src/ ./src/ +RUN pnpm run build + +# Production image with ffmpeg +FROM node:20-slim + +# ffmpeg does the seeking, decoding and JPEG encoding +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN npm install -g corepack@latest && corepack enable + +WORKDIR /app + +COPY package.json pnpm-lock.yaml ./ +RUN corepack install +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --prod + +COPY --from=builder /app/dist ./dist + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:8080/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" + +CMD ["node", "dist/index.js"] diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..ae9325a --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,34 @@ +# MetaMesh Plugin: still-extractor - Test Image +# Builds the plugin and runs tests with ffmpeg available + +FROM node:20-slim + +# Install ffmpeg for fixture generation and integration tests +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy package files and install ALL dependencies (including devDependencies) +COPY package.json tsconfig.json vitest.config.ts ./ +RUN npm install + +# Copy source and test files +COPY src/ ./src/ +COPY test/ ./test/ + +# Build the plugin +RUN npm run build + +# Generate test fixtures +RUN chmod +x ./test/fixtures/generate-test-fixtures.sh && \ + ./test/fixtures/generate-test-fixtures.sh + +# Temp dir the extractor writes frames into before upload +RUN mkdir -p /cache/temp && chmod 777 /cache + +ENV NODE_ENV=test +ENV CACHE_PATH=/cache + +CMD ["npm", "test"] diff --git a/README.md b/README.md index f13df9c..ab97b83 100644 --- a/README.md +++ b/README.md @@ -1 +1,82 @@ -# metamesh-plugin-still-extractor \ No newline at end of file +# metamesh-plugin-still-extractor + +MetaMesh container plugin. Grabs a representative frame out of a local video +file and stores it as a content-addressed JPEG, referenced from the video +record as **`still`**. + +``` +video file ──ffmpeg seek+decode──► frame ──WebDAV PUT──► /files/plugin/still-extractor/ + │ + midhash256 │ + ▼ + meta-core: still = +``` + +## Why + +Artwork on a local file otherwise comes from TMDB, and only when a title match +succeeds. `still` (METADATA_KEYS.md §6.1) is the episode-level thumbnail slot — +reserved for a future TMDB season-detail fetch, and until now written by +nobody. This plugin gives it a first writer from the one source that never +404s and never depends on a match: the file itself. + +**A still is not a poster.** Nothing gates on it, its absence is not an error, +and this plugin never writes `poster` — a frame grab masquerading as real +artwork would propagate to every peer that replicates the record. If a TMDB +episode still ever lands on the record first, this plugin steps aside. + +## What it writes + +| Key | Value | +|---|---| +| `still` | midhash256 CID of the extracted JPEG | + +Nothing else — in particular **no `stillPath`**. `/files/plugin` is a meta-core +watcher root, so the JPEG gets its own record and CID alias and +`LookupPathByCID` resolves it through the reverse index. See the header comment +in `src/plugin.ts`. + +## How a frame is chosen + +1. Candidate offsets at 20 / 45 / 70 % of `fileinfo/duration`, clamped 2s away + from both ends (the head is logos and black, the tail is credits). +2. At each offset, one ffmpeg run with **input seeking** (`-ss` before `-i`, so + it Range-seeks over WebDAV instead of decoding from zero) and + `thumbnail=60`, which picks the most representative of the next 60 frames. +3. The encoded JPEG is measured (mean + stddev of a 32×32 grayscale reduction). + Black, blown-out and flat frames are rejected and the next offset is tried. +4. All candidates rejected → `skipped`, no field written. + +The primary video stream is the largest one that **looks like moving video** — +not `0:v:0`, and not simply the largest. ffprobe reports attached cover art as +`codec_type=video`, and a 600×900 cover out-measures a 640×480 film. + +## Configuration + +Runtime config via meta-sort's plugin API (never `plugins.yml`): + +```bash +curl -k -X PUT https://metasort-dev.localhost:8180/api/plugins/still-extractor/config \ + -H "Content-Type: application/json" \ + -d '{"seekPercents":"15,40,65","maxWidth":720,"jpegQuality":3}' +``` + +| Key | Default | What | +|---|---|---| +| `forceRecompute` | `false` | Re-extract even when `still` is already set | +| `seekPercents` | `"20,45,70"` | Candidate offsets, as percentages of duration | +| `maxWidth` | `640` | Frame is scaled down to at most this width | +| `jpegQuality` | `4` | ffmpeg `-q:v`; lower is better quality | +| `frameTimeoutMs` | `120000` | Hard kill for one ffmpeg grab | + +## Development + +```bash +pnpm install && pnpm build +docker build -t metamesh-plugin-still-extractor:main . +./test.sh # unit + fixture tests in a container with ffmpeg +./test.sh --shell # interactive debugging +``` + +Depends on `file-info` (for `fileType`) and `ffmpeg` (for `fileinfo/duration` +and the `stream/{n}` table). Runs on the **background** queue. diff --git a/package.json b/package.json new file mode 100644 index 0000000..2d31d4f --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "@metazla/plugin-still-extractor", + "version": "1.0.0", + "packageManager": "pnpm@10.28.0", + "description": "MetaMesh plugin: Extracts a representative frame from a video file and stores it as the record's `still`", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "fastify": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d55b68c --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1477 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + fastify: + specifier: ^5.0.0 + version: 5.12.4 + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + typescript: + specifier: ^5.3.0 + version: 5.9.3 + vitest: + specifier: ^1.0.0 + version: 1.6.1(@types/node@20.19.43) + +packages: + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@fastify/ajv-compiler@4.0.6': + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/forwarded@3.0.2': + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rollup/rollup-android-arm-eabi@4.63.2': + resolution: {integrity: sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.2': + resolution: {integrity: sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.2': + resolution: {integrity: sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.2': + resolution: {integrity: sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.2': + resolution: {integrity: sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.2': + resolution: {integrity: sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.2': + resolution: {integrity: sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.63.2': + resolution: {integrity: sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.63.2': + resolution: {integrity: sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.63.2': + resolution: {integrity: sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.63.2': + resolution: {integrity: sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.63.2': + resolution: {integrity: sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.63.2': + resolution: {integrity: sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.63.2': + resolution: {integrity: sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.63.2': + resolution: {integrity: sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.63.2': + resolution: {integrity: sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.63.2': + resolution: {integrity: sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.63.2': + resolution: {integrity: sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.63.2': + resolution: {integrity: sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.63.2': + resolution: {integrity: sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.2': + resolution: {integrity: sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.2': + resolution: {integrity: sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.2': + resolution: {integrity: sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.2': + resolution: {integrity: sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.2': + resolution: {integrity: sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + + fast-uri@4.1.4: + resolution: {integrity: sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==} + + fastify@5.12.4: + resolution: {integrity: sha512-zATD676fP6tBMsn+jQ7Q5/cO/Qo+149TXH0dUqUKel+1/hcZxwIbnflqtPa9EjlrvpY8eCZcL3M1UYrMc31Y7g==} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + find-my-way@9.9.0: + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.63.2: + resolution: {integrity: sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + +snapshots: + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@fastify/ajv-compiler@4.0.6': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.4 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.2': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.2 + ipaddr.js: 2.5.0 + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.12 + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@pinojs/redact@0.4.0': {} + + '@rollup/rollup-android-arm-eabi@4.63.2': + optional: true + + '@rollup/rollup-android-arm64@4.63.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.2': + optional: true + + '@rollup/rollup-darwin-x64@4.63.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.2': + optional: true + + '@sinclair/typebox@0.27.12': {} + + '@types/estree@1.0.9': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.21 + pathe: 1.1.2 + pretty-format: 29.7.0 + + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + + abstract-logging@2.0.1: {} + + acorn-walk@8.3.5: + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.7 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-styles@5.2.0: {} + + assertion-error@1.1.0: {} + + atomic-sleep@1.0.0: {} + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.3 + + cac@6.7.14: {} + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + confbox@0.1.8: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + dequal@2.0.3: {} + + diff-sequences@29.6.3: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.4 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.7: {} + + fast-uri@4.1.4: {} + + fastify@5.12.4: + dependencies: + '@fastify/ajv-compiler': 4.0.6 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.9.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.4 + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + find-my-way@9.9.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + fsevents@2.3.3: + optional: true + + get-func-name@2.0.2: {} + + get-stream@8.0.1: {} + + human-signals@5.0.0: {} + + ipaddr.js@2.5.0: {} + + is-stream@3.0.0: {} + + isexe@2.0.0: {} + + js-tokens@9.0.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.2 + pkg-types: 1.3.1 + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + merge-stream@2.0.0: {} + + mimic-fn@4.0.0: {} + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + on-exit-leak-free@2.1.2: {} + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.2 + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@1.1.1: {} + + picocolors@1.1.1: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + process-warning@4.0.1: {} + + process-warning@5.1.0: {} + + quick-format-unescaped@4.0.4: {} + + react-is@18.3.1: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-from-string@2.0.2: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rollup@4.63.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.2 + '@rollup/rollup-android-arm64': 4.63.2 + '@rollup/rollup-darwin-arm64': 4.63.2 + '@rollup/rollup-darwin-x64': 4.63.2 + '@rollup/rollup-freebsd-arm64': 4.63.2 + '@rollup/rollup-freebsd-x64': 4.63.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.2 + '@rollup/rollup-linux-arm-musleabihf': 4.63.2 + '@rollup/rollup-linux-arm64-gnu': 4.63.2 + '@rollup/rollup-linux-arm64-musl': 4.63.2 + '@rollup/rollup-linux-loong64-gnu': 4.63.2 + '@rollup/rollup-linux-loong64-musl': 4.63.2 + '@rollup/rollup-linux-ppc64-gnu': 4.63.2 + '@rollup/rollup-linux-ppc64-musl': 4.63.2 + '@rollup/rollup-linux-riscv64-gnu': 4.63.2 + '@rollup/rollup-linux-riscv64-musl': 4.63.2 + '@rollup/rollup-linux-s390x-gnu': 4.63.2 + '@rollup/rollup-linux-x64-gnu': 4.63.2 + '@rollup/rollup-linux-x64-musl': 4.63.2 + '@rollup/rollup-openbsd-x64': 4.63.2 + '@rollup/rollup-openharmony-arm64': 4.63.2 + '@rollup/rollup-win32-arm64-msvc': 4.63.2 + '@rollup/rollup-win32-ia32-msvc': 4.63.2 + '@rollup/rollup-win32-x64-gnu': 4.63.2 + '@rollup/rollup-win32-x64-msvc': 4.63.2 + fsevents: 2.3.3 + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-final-newline@3.0.0: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@2.9.0: {} + + tinypool@0.8.4: {} + + tinyspy@2.2.1: {} + + toad-cache@3.7.4: {} + + type-detect@4.1.0: {} + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + vite-node@1.6.1(@types/node@20.19.43): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.43) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.43): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.28 + rollup: 4.63.2 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + + vitest@1.6.1(@types/node@20.19.43): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.5 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.43) + vite-node: 1.6.1(@types/node@20.19.43) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yocto-queue@1.2.2: {} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..67854d4 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,81 @@ +/** + * MetaMesh Plugin: still-extractor + * + * ============================================================================ + * PLUGIN FILE ACCESS ARCHITECTURE - WebDAV + * ============================================================================ + * + * File access via WebDAV, resolved per request from the calling meta-core's + * /urls -> webdavUrlInternal (WEBDAV_URL overrides): + * - Read media files: GET /webdav/watch/... or /webdav/test/... + * - Write output: PUT /webdav/plugin/still-extractor/... + * - Temp: Local /cache/temp mount (frame written before WebDAV upload) + * + * SECURITY: Plugins use WebDAV for all file access. No /files mount needed. + * + * ============================================================================ + */ + +import Fastify from 'fastify'; +import type { HealthResponse, ProcessRequest, ProcessResponse, CallbackPayload, ConfigureRequest, ConfigureResponse } from './types.js'; +import { manifest, process as processFile, configure } from './plugin.js'; + +const app = Fastify({ logger: true }); +let ready = false; + +app.get('/health', async (): Promise => ({ + status: 'healthy', + ready, + version: manifest.version +})); + +app.get('/manifest', async () => manifest); + +app.post<{ Body: ConfigureRequest }>('/configure', async (request): Promise => { + try { + configure(request.body.config || {}); + console.log(`[${manifest.id}] Configuration updated`); + return { status: 'ok' }; + } catch (error) { + console.error(`[${manifest.id}] Configuration error:`, error); + return { status: 'error', error: error instanceof Error ? error.message : String(error) }; + } +}); + +app.post<{ Body: ProcessRequest }>('/process', async (request, reply) => { + const { taskId, cid, filePath, callbackUrl, metaCoreUrl } = request.body; + + if (!taskId || !cid || !filePath || !callbackUrl || !metaCoreUrl) { + return reply.send({ status: 'rejected', error: 'Missing required fields' } as ProcessResponse); + } + + // Process asynchronously and send callback when done + processFile(request.body, async (payload: CallbackPayload) => { + try { + await fetch(callbackUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + } catch (error) { + console.error(`[${manifest.id}] Callback error:`, error); + } + }).catch((error) => { + console.error(`[${manifest.id}] Process error:`, error); + }); + + return reply.send({ status: 'accepted' } as ProcessResponse); +}); + +const port = parseInt(process.env.PORT || '8080', 10); + +app.listen({ port, host: '0.0.0.0' }).then(() => { + ready = true; + console.log(`[${manifest.id}] Listening on port ${port}`); +}); + +process.on('SIGTERM', async () => { + ready = false; + await app.close(); + process.exit(0); +}); diff --git a/src/meta-core-client.ts b/src/meta-core-client.ts new file mode 100644 index 0000000..6bdbad8 --- /dev/null +++ b/src/meta-core-client.ts @@ -0,0 +1,93 @@ +/** + * meta-core API Client for writing metadata + * Fails gracefully if meta-core is unavailable (logs warning, returns without throwing) + */ + +export class MetaCoreClient { + constructor(private baseUrl: string) {} + + private async safeFetch(url: string, options: RequestInit): Promise { + try { + return await fetch(url, { ...options, signal: AbortSignal.timeout(5000) }); + } catch (error) { + console.warn(`[MetaCoreClient] Warning: meta-core unavailable at ${this.baseUrl}`); + return null; + } + } + + async setProperty(hashId: string, key: string, value: string): Promise { + const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value }), + }); + if (response && !response.ok) { + console.warn(`[MetaCoreClient] Failed to set property: ${response.status}`); + } + } + + async getProperty(hashId: string, key: string): Promise { + const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'GET' }); + if (!response || response.status === 404) return null; + if (!response.ok) return null; + const data = await response.json() as { value?: string }; + return data.value ?? null; + } + + async mergeMetadata(hashId: string, metadata: Record): Promise { + const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(metadata), + }); + if (response && !response.ok) { + console.warn(`[MetaCoreClient] Failed to merge metadata: ${response.status}`); + } + } + + async deleteProperty(hashId: string, key: string): Promise { + const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'DELETE' }); + if (response && !response.ok && response.status !== 404) { + console.warn(`[MetaCoreClient] Failed to delete property: ${response.status}`); + } + } + + async addToSet(hashId: string, key: string, value: string): Promise { + const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/_add/${key}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value }), + }); + if (response && !response.ok) { + console.warn(`[MetaCoreClient] Failed to add to set: ${response.status}`); + } + } + + async getMetadata(hashId: string): Promise> { + const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, { method: 'GET' }); + if (!response || response.status === 404) return {}; + if (!response.ok) return {}; + const data = await response.json() as { metadata?: Record }; + return data.metadata ?? {}; + } + + /** + * Compute the CID (Content Identifier) for a file + * Uses the meta-core /file/cid API endpoint + */ + async computeFileCID(filePath: string): Promise { + try { + const response = await this.safeFetch(`${this.baseUrl}/file/cid`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath }), + }); + if (!response || !response.ok) return null; + const data = await response.json() as { cid?: string }; + return data.cid ?? null; + } catch (error) { + console.debug(`[MetaCoreClient] Failed to compute CID for ${filePath}:`, error); + return null; + } + } +} diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..2f8db33 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,559 @@ +/** + * Still Extractor Plugin + * + * Grabs a representative frame out of a local video file and stores it as a + * content-addressed JPEG, referenced from the video record as `still`. + * + * ============================================================================ + * WHY THIS EXISTS + * ============================================================================ + * Artwork on a local file otherwise comes from TMDB, and only when a title + * match succeeds. `still` (METADATA_KEYS.md section 6.1) is the episode-level + * thumbnail slot, reserved for a future TMDB season-detail fetch and until now + * written by nobody. This plugin gives it a first writer from the one source + * that never 404s and never depends on a match: the file itself. + * + * A still is NOT a poster. Nothing gates on it and its absence is not an error + * — hence `skipped` rather than `failed` on every "couldn't get a usable + * frame" path. We never write `poster`: a frame grab masquerading as a real + * poster would propagate to every peer that replicates the record. + * + * ============================================================================ + * PLUGIN FILE ACCESS ARCHITECTURE - WebDAV + * ============================================================================ + * File access via WebDAV, on the meta-core named by each /process request + * (its /urls -> webdavUrlInternal; WEBDAV_URL overrides): + * - Read media files: GET /webdav/watch/... or /webdav/test/... + * - Write output: PUT /webdav/plugin/still-extractor/... + * - Temp: /cache/temp (frame written by ffmpeg before upload) + * + * We deliberately do NOT write a `stillPath` sibling. METADATA_KEYS.md names + * one, but that is the gateway/peer_proxy mirroring convention: on the + * meta-sort path `/files/plugin` is a watcher root + * (meta-core/internal/config/config.go DefaultWatcherPaths), so the JPEG gets + * its own record and midhash alias and `LookupPathByCID` resolves it through + * the reverse index — "There is no path-companion sidecar field" + * (meta-core/internal/storage/client.go). The tmdb plugin writes `poster` + * without `posterPath` for the same reason. + * ============================================================================ + */ + +import { spawn } from 'child_process'; +import { createHash, randomBytes } from 'crypto'; +import { mkdirSync, readFileSync, existsSync, unlinkSync } from 'fs'; +import * as path from 'path'; +import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js'; +import { MetaCoreClient } from './meta-core-client.js'; +import { getWebDAVClient, WebDAVClient } from './webdav-client.js'; + +const PLUGIN_OUTPUT_WEBDAV_PATH = '/files/plugin/still-extractor'; +const TEMP_PATH = '/cache/temp'; + +export const manifest: PluginManifest = { + id: 'still-extractor', + name: 'Still Extractor', + version: '1.0.0', + description: 'Extracts a representative frame from a video file and stores it as the record\'s `still`', + author: 'MetaMesh', + // file-info gives us `fileType`; ffmpeg gives us `fileinfo/duration` and + // the `stream/{n}` table we pick the primary video stream out of. + dependencies: ['file-info', 'ffmpeg'], + priority: 55, + color: '#9C27B0', + // One seek + one decode over HTTP. Never the fast queue. + defaultQueue: 'background', + timeout: 300000, + schema: { + still: { + label: 'Still', + type: 'cid', + readonly: true, + hint: 'CID of a frame extracted from the video itself (best-effort episode thumbnail)', + }, + }, + config: { + forceRecompute: { type: 'boolean', label: 'Force Recompute', default: false }, + seekPercents: { type: 'string', label: 'Seek Percents', default: '20,45,70' }, + maxWidth: { type: 'number', label: 'Max Width (px)', default: 640 }, + jpegQuality: { type: 'number', label: 'JPEG Quality (ffmpeg -q:v, lower is better)', default: 4 }, + frameTimeoutMs: { type: 'number', label: 'Per-Frame ffmpeg Timeout (ms)', default: 120000 }, + }, +}; + +// Configuration +let forceRecompute = false; +let seekPercents = [20, 45, 70]; +let maxWidth = 640; +let jpegQuality = 4; +let frameTimeoutMs = 120000; + +export function configure(config: Record): void { + forceRecompute = config.forceRecompute === true; + if (typeof config.seekPercents === 'string') { + const parsed = config.seekPercents + .split(',') + .map((p) => Number(p.trim())) + .filter((p) => Number.isFinite(p) && p > 0 && p < 100); + if (parsed.length > 0) seekPercents = parsed; + } + if (Number.isFinite(Number(config.maxWidth))) maxWidth = Number(config.maxWidth); + if (Number.isFinite(Number(config.jpegQuality))) jpegQuality = Number(config.jpegQuality); + if (Number.isFinite(Number(config.frameTimeoutMs))) frameTimeoutMs = Number(config.frameTimeoutMs); + console.log( + `[still-extractor] Config: forceRecompute=${forceRecompute}, seekPercents=${seekPercents.join(',')}, ` + + `maxWidth=${maxWidth}, jpegQuality=${jpegQuality}, frameTimeoutMs=${frameTimeoutMs}` + ); +} + +/** + * Compute midhash256 CID from a Buffer (matches meta-hash algorithm). + * Copied verbatim from metamesh-plugin-subtitle-extractor. + */ +export function computeMidHash256FromBuffer(data: Buffer): string { + const SAMPLE_SIZE = 1024 * 1024; // 1MB + const MIDHASH_VARINT = Buffer.from([0x80, 0x20]); + + const fileSize = data.length; + + const sizeBuffer = Buffer.allocUnsafe(8); + sizeBuffer.writeBigUInt64BE(BigInt(fileSize), 0); + + let sampleData: Buffer; + if (fileSize <= SAMPLE_SIZE) { + sampleData = data; + } else { + const middleOffset = Math.floor((fileSize - SAMPLE_SIZE) / 2); + sampleData = data.subarray(middleOffset, middleOffset + SAMPLE_SIZE); + } + + const hashInput = Buffer.concat([sizeBuffer, sampleData]); + const hashBuffer = createHash('sha256').update(hashInput).digest(); + + const cidBytes = Buffer.concat([ + Buffer.from([0x01]), + MIDHASH_VARINT, + MIDHASH_VARINT, + Buffer.from([0x20]), + hashBuffer + ]); + + const base32Chars = 'abcdefghijklmnopqrstuvwxyz234567'; + let cid = 'b'; + let bits = 0; + let value = 0; + for (const byte of cidBytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + cid += base32Chars[(value >> bits) & 0x1f]; + } + } + if (bits > 0) { + cid += base32Chars[(value << (5 - bits)) & 0x1f]; + } + + return cid; +} + +/** Sanitize filename by removing invalid characters. */ +export function sanitizeFilename(name: string): string { + return name + .replace(/[<>:"/\\|?*]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Output filename for a still. + * + * The source CID is part of the name — unlike the tmdb plugin, which + * deliberately leaves the file CID out so every release of one movie shares a + * poster. A still belongs to exactly one file, so two files with the same + * basename must not collide. + */ +export function stillFilename(filePath: string, sourceCid: string): string { + const base = sanitizeFilename(path.basename(filePath, path.extname(filePath))); + const shortCid = sourceCid.slice(-12); + return `${base}[${shortCid}]_still.jpg`; +} + +export interface VideoStream { + /** Absolute ffmpeg stream index — what `-map 0:` wants. */ + index: number; + width?: number; + height?: number; + codec?: string; + frameRate?: string; +} + +/** + * Codecs that are a single picture, not a moving image. An `attached_pic` + * stream is almost always one of these. + */ +const STILL_IMAGE_CODECS = new Set(['mjpeg', 'png', 'bmp', 'gif', 'webp', 'tiff']); + +/** + * Pick the primary video stream out of the ffmpeg plugin's `stream/{n}` table. + * + * ⚠ NOT `0:v:0`, and ⚠ not simply the largest. ffprobe reports attached cover + * art (`attached_pic`) as `codec_type=video`, and the ffmpeg plugin's + * `embeddedimage` branch never fires for it, so an embedded poster lands here + * as a perfectly ordinary `type:'video'` stream — which `0:v:0` would happily + * extract instead of the film. Nor can we just take the biggest: a 600x900 + * cover out-measures a 640x480 video. + * + * The stream table does not carry dispositions, so we use what it does carry. + * The ffmpeg plugin drops `avg_frame_rate` when it is `0/0` (plugin.ts), and an + * attached picture has no frame rate — so "has a frameRate and is not a + * still-image codec" separates moving pictures from covers. Largest area wins + * within the better class; if nothing looks like moving video we fall back to + * the largest of whatever is there rather than giving up. + * + * Accepts both shapes meta-core hands back: the namespaced `stream/{n}` keys + * the ffmpeg plugin writes, and the flattened `stream` array. + */ +export function selectPrimaryVideoStream(existingMeta: Record | undefined): VideoStream | null { + if (!existingMeta) return null; + + const raw: unknown[] = []; + + const flattened = existingMeta['stream'] as unknown; + if (flattened) { + try { + const arr = Array.isArray(flattened) ? flattened : JSON.parse(String(flattened)); + if (Array.isArray(arr)) raw.push(...arr); + } catch { + // fall through to the namespaced form + } + } + + if (raw.length === 0) { + for (const [key, value] of Object.entries(existingMeta)) { + if (key.startsWith('stream/')) raw.push(value); + } + } + + const moving: VideoStream[] = []; + const other: VideoStream[] = []; + + for (const entry of raw) { + let stream: { + type?: string; index?: number; width?: number; height?: number; + codec?: string; frameRate?: string; + }; + try { + stream = typeof entry === 'string' ? JSON.parse(entry) : (entry as typeof stream); + } catch { + continue; + } + if (!stream || stream.type !== 'video' || stream.index == null) continue; + + const candidate: VideoStream = { + index: Number(stream.index), + width: stream.width != null ? Number(stream.width) : undefined, + height: stream.height != null ? Number(stream.height) : undefined, + codec: stream.codec, + frameRate: stream.frameRate, + }; + + const looksMoving = + !!candidate.frameRate && !STILL_IMAGE_CODECS.has((candidate.codec ?? '').toLowerCase()); + (looksMoving ? moving : other).push(candidate); + } + + const area = (s: VideoStream) => (s.width ?? 0) * (s.height ?? 0); + const pool = moving.length > 0 ? moving : other; + return pool.reduce((best, s) => (!best || area(s) > area(best) ? s : best), null); +} + +/** + * Seek offsets to try, in order. + * + * Clamped away from both ends: the head is titles/logos/black and the tail is + * credits. A file with no usable duration gets a blind 10s attempt and then + * the very start, which is all we can honestly do. + */ +export function candidateOffsets(durationSec: number | undefined, percents: number[] = seekPercents): number[] { + if (!durationSec || !Number.isFinite(durationSec) || durationSec <= 0) { + return [10, 0]; + } + if (durationSec <= 4) { + return [Math.max(0, durationSec / 2)]; + } + const lo = 2; + const hi = durationSec - 2; + const offsets = percents + .map((p) => (durationSec * p) / 100) + .map((t) => Math.min(hi, Math.max(lo, t))); + return Array.from(new Set(offsets.map((t) => Math.round(t * 1000) / 1000))); +} + +export interface FrameStats { + mean: number; + stddev: number; +} + +/** Mean + standard deviation of an 8-bit grayscale buffer, both 0..255. */ +export function frameStats(gray: Buffer): FrameStats { + if (gray.length === 0) return { mean: 0, stddev: 0 }; + let sum = 0; + for (const v of gray) sum += v; + const mean = sum / gray.length; + let variance = 0; + for (const v of gray) variance += (v - mean) ** 2; + return { mean, stddev: Math.sqrt(variance / gray.length) }; +} + +/** + * Is this frame worth keeping? + * + * Rejects the three things a blind seek lands on far more often than you would + * expect: a black frame, a white/blown card, and a flat single-colour fade. + * `thumbnail=N` already avoids most of them; this is the backstop. + */ +export function isUsableFrame(stats: FrameStats): boolean { + if (stats.stddev < 6) return false; // flat: fade, solid colour, blank card + if (stats.mean < 12 || stats.mean > 243) return false; // black / blown out + return true; +} + +/** Run a command, resolving with its exit code, stdout and stderr. Killed hard on timeout. */ +function run( + cmd: string, + args: string[], + timeoutMs: number +): Promise<{ code: number | null; stdout: Buffer; stderr: string; timedOut: boolean }> { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const stdout: Buffer[] = []; + let stderr = ''; + let timedOut = false; + + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, timeoutMs); + + child.stdout.on('data', (d: Buffer) => stdout.push(d)); + child.stderr.on('data', (d: Buffer) => { + // Keep the tail only — a broken input can spew megabytes. + stderr = (stderr + d.toString()).slice(-4000); + }); + child.on('error', (err) => { + clearTimeout(timer); + resolve({ code: null, stdout: Buffer.concat(stdout), stderr: stderr + String(err), timedOut }); + }); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ code, stdout: Buffer.concat(stdout), stderr, timedOut }); + }); + }); +} + +/** + * Grab one frame at `offsetSec` and return the encoded JPEG. + * + * ⚠ `-ss` goes BEFORE `-i`. Input seeking makes ffmpeg jump via HTTP Range; + * output seeking (`-ss` after `-i`) decodes every frame from zero, which over + * WebDAV means pulling the whole file to reach the 20% mark. On MKV expect a + * tail read regardless — the cue index lives at EOF. + */ +export async function grabFrame( + input: string, + streamIndex: number, + offsetSec: number, + outputPath: string +): Promise { + const args = ['-y', '-hide_banner', '-loglevel', 'error']; + + // rw_timeout is a protocol option; only http/tcp accept it, and passing it + // to the file protocol makes ffmpeg bail before it starts. + if (/^https?:\/\//i.test(input)) { + args.push('-rw_timeout', '30000000'); // microseconds + } + + args.push( + '-ss', String(offsetSec), + '-i', input, + '-map', `0:${streamIndex}`, + '-an', '-sn', '-dn', + '-frames:v', '1', + // thumbnail=N picks the most representative of the next N frames rather + // than whatever the seek happened to land on. + '-vf', `thumbnail=60,scale='min(iw,${maxWidth})':-2`, + '-q:v', String(jpegQuality), + '-f', 'image2', + outputPath + ); + + const result = await run('ffmpeg', args, frameTimeoutMs); + if (result.timedOut) { + console.warn(`[still-extractor] ffmpeg timed out at ${offsetSec}s`); + return null; + } + if (result.code !== 0 || !existsSync(outputPath)) { + console.warn(`[still-extractor] ffmpeg failed at ${offsetSec}s: ${result.stderr.trim() || `exit ${result.code}`}`); + return null; + } + + const jpeg = readFileSync(outputPath); + return jpeg.length > 0 ? jpeg : null; +} + +/** + * Measure a JPEG we already have on disk. + * + * Deliberately a second ffmpeg pass on the local temp file rather than a second + * output on the grab command: two filter chains off one input would each run + * their own `thumbnail` instance, and there is no guarantee the frame we + * measured is the frame we encoded. This decodes a ~40 KB local file — no + * seek, no network. + */ +export async function statsForJpeg(jpegPath: string): Promise { + const result = await run( + 'ffmpeg', + ['-hide_banner', '-loglevel', 'error', '-i', jpegPath, + '-vf', 'scale=32:32,format=gray', '-f', 'rawvideo', '-'], + 15000 + ); + if (result.code !== 0 || result.stdout.length === 0) return null; + return frameStats(result.stdout); +} + +export interface ExtractResult { + jpeg: Buffer; + offsetSec: number; + stats: FrameStats; +} + +/** + * Walk the candidate offsets until one yields a usable frame. + * + * Returns null when every candidate was black, flat, or unreadable — that is a + * `skipped`, not a failure. + */ +export async function extractStill( + input: string, + streamIndex: number, + durationSec: number | undefined, + tempPath: string = TEMP_PATH +): Promise { + mkdirSync(tempPath, { recursive: true }); + // NB: `process` is this module's own export, not node's global — hence a + // random suffix rather than a pid for uniqueness between concurrent tasks. + const outputPath = path.join(tempPath, `still-${randomBytes(6).toString('hex')}.jpg`); + + try { + for (const offsetSec of candidateOffsets(durationSec)) { + const jpeg = await grabFrame(input, streamIndex, offsetSec, outputPath); + if (!jpeg) continue; + + const stats = await statsForJpeg(outputPath); + if (!stats) { + console.warn(`[still-extractor] Could not measure frame at ${offsetSec}s`); + continue; + } + if (!isUsableFrame(stats)) { + console.log( + `[still-extractor] Rejected frame at ${offsetSec}s ` + + `(mean=${stats.mean.toFixed(1)}, stddev=${stats.stddev.toFixed(1)})` + ); + continue; + } + + console.log( + `[still-extractor] Accepted frame at ${offsetSec}s ` + + `(mean=${stats.mean.toFixed(1)}, stddev=${stats.stddev.toFixed(1)}, ${jpeg.length} bytes)` + ); + return { jpeg, offsetSec, stats }; + } + return null; + } finally { + if (existsSync(outputPath)) { + try { unlinkSync(outputPath); } catch { /* best effort */ } + } + } +} + +/** Upload the frame and return its CID. */ +async function storeStill( + client: WebDAVClient, + jpeg: Buffer, + filePath: string, + sourceCid: string +): Promise { + const webdavPath = `${PLUGIN_OUTPUT_WEBDAV_PATH}/${stillFilename(filePath, sourceCid)}`; + await client.writeFile(webdavPath, jpeg); + console.log(`[still-extractor] Uploaded still to WebDAV: ${webdavPath}`); + return computeMidHash256FromBuffer(jpeg); +} + +export async function process( + request: ProcessRequest, + sendCallback: (payload: CallbackPayload) => Promise +): Promise { + const startTime = Date.now(); + const { taskId, cid, filePath, existingMeta } = request; + const metaCore = new MetaCoreClient(request.metaCoreUrl); + + const skip = (reason: string) => + sendCallback({ taskId, status: 'skipped', duration: Date.now() - startTime, reason }); + + try { + if (existingMeta?.fileType !== 'video') { + await skip('Not a video file'); + return; + } + + if (existingMeta?.still && !forceRecompute) { + // Also the mechanism that keeps this plugin from ever fighting a + // future TMDB episode-still writer: authoritative artwork wins by + // arriving first and never being overwritten from here. + await skip('Still already present'); + return; + } + + const stream = selectPrimaryVideoStream(existingMeta); + if (!stream) { + await skip('No video stream'); + return; + } + + // WebDAV of the very core we're enriching, so the frame bytes and the + // CID we write can never point at different cores. + const webdavClient = await getWebDAVClient(request.metaCoreUrl); + if (!webdavClient) { + await sendCallback({ + taskId, + status: 'failed', + duration: Date.now() - startTime, + error: 'No WebDAV endpoint available', + }); + return; + } + + const duration = Number(existingMeta?.['fileinfo/duration']); + const input = webdavClient.toWebDAVUrl(filePath); + + const extracted = await extractStill(input, stream.index, Number.isFinite(duration) ? duration : undefined); + if (!extracted) { + await skip('No usable frame'); + return; + } + + const stillCid = await storeStill(webdavClient, extracted.jpeg, filePath, cid); + await metaCore.setProperty(cid, 'still', stillCid); + console.log(`[still-extractor] Set still CID: ${stillCid}`); + + await sendCallback({ taskId, status: 'completed', duration: Date.now() - startTime }); + } catch (error) { + console.error(`[still-extractor] Error processing ${filePath}:`, error); + await sendCallback({ + taskId, + status: 'failed', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4dbbfe2 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,70 @@ +/** + * MetaMesh Plugin Types + */ + +export interface PluginManifest { + id: string; + name: string; + version: string; + description?: string; + author?: string; + dependencies?: string[]; + schema?: Record; + config?: Record; + priority?: number; + color?: string; + defaultQueue?: 'fast' | 'background'; + timeout?: number; +} + +export interface SchemaField { + label: string; + type?: 'string' | 'number' | 'boolean' | 'select' | 'text' | 'array' | 'json' | 'cid'; + readonly?: boolean; + hint?: string; +} + +export interface ConfigField { + type: 'string' | 'number' | 'boolean' | 'select'; + label?: string; + required?: boolean; + default?: unknown; + secret?: boolean; +} + +export interface HealthResponse { + status: 'healthy' | 'unhealthy'; + ready: boolean; + version: string; +} + +export interface ProcessRequest { + taskId: string; + cid: string; + filePath: string; + callbackUrl: string; + metaCoreUrl: string; + existingMeta?: Record; +} + +export interface ProcessResponse { + status: 'accepted' | 'rejected'; + error?: string; +} + +export interface CallbackPayload { + taskId: string; + status: 'completed' | 'failed' | 'skipped'; + duration: number; + error?: string; + reason?: string; +} + +export interface ConfigureRequest { + config: Record; +} + +export interface ConfigureResponse { + status: 'ok' | 'error'; + error?: string; +} diff --git a/src/webdav-client.ts b/src/webdav-client.ts new file mode 100644 index 0000000..362a576 --- /dev/null +++ b/src/webdav-client.ts @@ -0,0 +1,199 @@ +/** + * WebDAV Client for Plugin File Access + * + * Provides HTTP-based file access to meta-sort's WebDAV endpoint. + * Replaces direct filesystem access for containerized plugins. + */ + +export interface FileStats { + size: number; + mtime?: Date; +} + +export class WebDAVClient { + private baseUrl: string; + + constructor(baseUrl: string) { + // Remove trailing slash if present + this.baseUrl = baseUrl.replace(/\/$/, ''); + } + + /** + * Convert absolute file path to WebDAV URL + * Strips /files prefix and appends to base URL + */ + toWebDAVUrl(filePath: string): string { + // filePath is like "/files/watch/movie.mkv" + // We need to convert to "http://meta-sort-dev/webdav/watch/movie.mkv" + let relativePath = filePath; + + // Strip /files prefix if present + if (relativePath.startsWith('/files/')) { + relativePath = relativePath.substring(6); // Remove "/files" + } else if (relativePath.startsWith('/files')) { + relativePath = relativePath.substring(6); + } + + // Ensure path starts with / + if (!relativePath.startsWith('/')) { + relativePath = '/' + relativePath; + } + + return this.baseUrl + relativePath; + } + + /** + * Get file stats (size, mtime) via HTTP HEAD request + */ + async stat(filePath: string): Promise { + const url = this.toWebDAVUrl(filePath); + + const response = await fetch(url, { method: 'HEAD' }); + + if (!response.ok) { + throw new Error(`WebDAV HEAD failed for ${filePath}: ${response.status} ${response.statusText}`); + } + + const contentLength = response.headers.get('content-length'); + const lastModified = response.headers.get('last-modified'); + + return { + size: contentLength ? parseInt(contentLength, 10) : 0, + mtime: lastModified ? new Date(lastModified) : undefined, + }; + } + + /** + * Read first N bytes of a file (for magic byte detection) + * Uses HTTP Range request + */ + async readBytes(filePath: string, start: number, end: number): Promise { + const url = this.toWebDAVUrl(filePath); + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Range': `bytes=${start}-${end}`, + }, + }); + + if (!response.ok && response.status !== 206) { + throw new Error(`WebDAV Range GET failed for ${filePath}: ${response.status} ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } + + /** + * Read entire file as buffer (for small files only) + */ + async readFile(filePath: string): Promise { + const url = this.toWebDAVUrl(filePath); + + const response = await fetch(url, { method: 'GET' }); + + if (!response.ok) { + throw new Error(`WebDAV GET failed for ${filePath}: ${response.status} ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } + + /** + * Write file via HTTP PUT + */ + async writeFile(filePath: string, data: Buffer | string): Promise { + const url = this.toWebDAVUrl(filePath); + + const response = await fetch(url, { + method: 'PUT', + body: data, + headers: { + 'Content-Type': 'application/octet-stream', + }, + }); + + if (!response.ok) { + throw new Error(`WebDAV PUT failed for ${filePath}: ${response.status} ${response.statusText}`); + } + } + + /** + * Check if file exists via HTTP HEAD + */ + async exists(filePath: string): Promise { + const url = this.toWebDAVUrl(filePath); + + try { + const response = await fetch(url, { method: 'HEAD' }); + return response.ok; + } catch { + return false; + } + } +} + +/** + * WebDAV client for the meta-core that drove a /process call. + * + * The endpoint is read from that core's own /urls (`webdavUrlInternal`), + * so blobs always land in the /files of the core we write metadata to. A feeder + * whose plugins run beside it but enrich a remote hub core needs no per-stack + * wiring, and it is impossible to write posters into one core while pointing + * CIDs at another. + * + * WEBDAV_URL still wins when set — an explicit override for tests and for + * topologies where the core's advertised internal URL isn't reachable from here. + * + * Resolutions are cached per meta-core URL; failures are not, so a core that is + * still starting up is retried on the next task. + */ +const clientCache = new Map>(); + +async function resolveWebDAVUrl(metaCoreUrl: string): Promise { + // /urls, not /api/urls — metaCoreUrl points at the meta-core backend, whose + // discovery routes are unprefixed (same base the /meta/* writes use). + const base = metaCoreUrl.replace(/\/$/, ''); + const response = await fetch(`${base}/urls`, { signal: AbortSignal.timeout(5000) }); + + if (!response.ok) { + throw new Error(`GET ${base}/urls failed: ${response.status} ${response.statusText}`); + } + + const urls = await response.json() as { webdavUrlInternal?: string; webdavUrl?: string }; + return urls.webdavUrlInternal ?? urls.webdavUrl ?? null; +} + +export async function getWebDAVClient(metaCoreUrl: string): Promise { + const override = process.env.WEBDAV_URL; + if (override) { + return new WebDAVClient(override); + } + + let pending = clientCache.get(metaCoreUrl); + if (!pending) { + pending = resolveWebDAVUrl(metaCoreUrl).then((webdavUrl) => { + if (!webdavUrl) { + console.warn(`[webdav-client] meta-core ${metaCoreUrl} advertises no WebDAV URL`); + return null; + } + console.log(`[webdav-client] Using WebDAV endpoint ${webdavUrl} (from ${metaCoreUrl})`); + return new WebDAVClient(webdavUrl); + }); + clientCache.set(metaCoreUrl, pending); + } + + try { + const client = await pending; + if (!client) { + clientCache.delete(metaCoreUrl); + } + return client; + } catch (error) { + clientCache.delete(metaCoreUrl); + console.warn(`[webdav-client] Failed to resolve WebDAV URL from ${metaCoreUrl}: ${error instanceof Error ? error.message : String(error)}`); + return null; + } +} diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..d282aea --- /dev/null +++ b/test.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# ============================================================================= +# Still Extractor Plugin Test Runner +# +# Runs integration tests inside a Docker container with FFprobe available. +# Usage: +# ./test.sh # Build and run tests +# ./test.sh --no-cache # Rebuild without cache +# ./test.sh --shell # Start shell in test container for debugging +# ============================================================================= + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +IMAGE_NAME="metamesh-plugin-still-extractor-test" +DOCKERFILE="Dockerfile.test" + +# Parse arguments +NO_CACHE="" +SHELL_MODE="" + +for arg in "$@"; do + case $arg in + --no-cache) + NO_CACHE="--no-cache" + ;; + --shell) + SHELL_MODE=1 + ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --no-cache Rebuild Docker image without cache" + echo " --shell Start interactive shell in test container" + echo " --help Show this help message" + exit 0 + ;; + esac +done + +echo "==============================================" +echo " Still Extractor Plugin Test Runner" +echo "==============================================" +echo "" + +# Check if Docker is available +if ! command -v docker &> /dev/null; then + echo "ERROR: Docker is not installed or not in PATH" + exit 1 +fi + +# Build the test image +echo "[1/2] Building test image..." +docker build $NO_CACHE -f "$DOCKERFILE" -t "$IMAGE_NAME" . + +if [ -n "$SHELL_MODE" ]; then + echo "" + echo "[2/2] Starting interactive shell..." + echo " Run 'npm test' to execute tests" + echo "" + docker run --rm -it \ + -v "$SCRIPT_DIR/test:/app/test:ro" \ + -v "$SCRIPT_DIR/src:/app/src:ro" \ + "$IMAGE_NAME" \ + /bin/bash +else + echo "" + echo "[2/2] Running tests..." + echo "" + + # Run tests and capture exit code + docker run --rm \ + -e CI=true \ + "$IMAGE_NAME" + + EXIT_CODE=$? + + echo "" + if [ $EXIT_CODE -eq 0 ]; then + echo "==============================================" + echo " All tests passed!" + echo "==============================================" + else + echo "==============================================" + echo " Tests failed (exit code: $EXIT_CODE)" + echo "==============================================" + fi + + exit $EXIT_CODE +fi diff --git a/test/fixtures/generate-test-fixtures.sh b/test/fixtures/generate-test-fixtures.sh new file mode 100644 index 0000000..e50cafd --- /dev/null +++ b/test/fixtures/generate-test-fixtures.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Generate test videos for still-extractor integration tests. +# Run inside the test container (ffmpeg required). + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "Generating test fixtures..." + +# 1. Plain colourful video — every candidate offset should yield a usable frame. +if [ ! -f "color.mp4" ]; then + echo "Creating color.mp4..." + ffmpeg -y -f lavfi -i testsrc=duration=10:size=320x240:rate=24 \ + -c:v libx264 -preset ultrafast -crf 28 -pix_fmt yuv420p \ + color.mp4 +fi + +# 2. Six seconds of black, then picture. 20s total, so the 20% candidate (4s) +# lands in the black head and must be rejected in favour of a later one. +if [ ! -f "black-head.mp4" ]; then + echo "Creating black-head.mp4..." + ffmpeg -y -f lavfi -i "color=c=black:size=320x240:duration=6:rate=24" \ + -c:v libx264 -preset ultrafast -crf 28 -pix_fmt yuv420p black_part.mp4 + ffmpeg -y -f lavfi -i testsrc=duration=14:size=320x240:rate=24 \ + -c:v libx264 -preset ultrafast -crf 28 -pix_fmt yuv420p color_part.mp4 + printf "file 'black_part.mp4'\nfile 'color_part.mp4'\n" > concat.txt + ffmpeg -y -f concat -safe 0 -i concat.txt -c copy black-head.mp4 + rm -f black_part.mp4 color_part.mp4 concat.txt +fi + +# 3. Video carrying attached cover art LARGER than the video itself — the +# stream-selection trap. ffprobe reports the cover as codec_type=video. +if [ ! -f "cover-art.mp4" ]; then + echo "Creating cover-art.mp4..." + ffmpeg -y -f lavfi -i "color=c=red:size=600x900:duration=1:rate=1" -frames:v 1 cover.png + ffmpeg -y -i color.mp4 -i cover.png \ + -map 0:v -map 1 -c copy -disposition:v:1 attached_pic \ + cover-art.mp4 + rm -f cover.png +fi + +# 4. Audio-only file — nothing to extract. +if [ ! -f "audio-only.mp3" ]; then + echo "Creating audio-only.mp3..." + ffmpeg -y -f lavfi -i sine=frequency=440:duration=2 \ + -c:a libmp3lame -b:a 128k \ + audio-only.mp3 +fi + +echo "Test fixtures generated:" +ls -la *.mp4 *.mp3 2>/dev/null || true diff --git a/test/still.test.ts b/test/still.test.ts new file mode 100644 index 0000000..4b05789 --- /dev/null +++ b/test/still.test.ts @@ -0,0 +1,284 @@ +/** + * Still Extractor Plugin Tests + * + * Pure helpers run anywhere; the extraction tests need ffmpeg and the + * generated fixtures, so they run in the test container (./test.sh). + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { execSync } from 'child_process'; +import { existsSync, mkdirSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { + manifest, + process as processFile, + candidateOffsets, + frameStats, + isUsableFrame, + sanitizeFilename, + stillFilename, + computeMidHash256FromBuffer, + selectPrimaryVideoStream, + extractStill, + grabFrame, +} from '../src/plugin.js'; +import type { CallbackPayload } from '../src/types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES = path.join(__dirname, 'fixtures'); +const TEMP = '/tmp/still-extractor-test'; + +function hasFfmpeg(): boolean { + try { + execSync('ffmpeg -version', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function fixture(name: string): string { + return path.join(FIXTURES, name); +} + +/** Build the `stream/{n}` table exactly the way the ffmpeg plugin writes it. */ +function streamTableFor(file: string): Record { + const probe = JSON.parse( + execSync(`ffprobe -v quiet -print_format json -show_streams "${file}"`, { encoding: 'utf-8' }) + ) as { streams: Array> }; + + const table: Record = {}; + let n = 0; + for (const s of probe.streams) { + if (s.codec_type !== 'video' && s.codec_type !== 'audio' && s.codec_type !== 'subtitle') continue; + const entry: Record = { type: s.codec_type }; + if (s.codec_name) entry.codec = String(s.codec_name); + if (s.index != null) entry.index = s.index; + if (s.width) entry.width = s.width; + if (s.height) entry.height = s.height; + // The ffmpeg plugin drops avg_frame_rate when it is "0/0" — the very + // signal we lean on to spot attached cover art. + if (s.avg_frame_rate && s.avg_frame_rate !== '0/0') entry.frameRate = String(s.avg_frame_rate); + table[`stream/${n}`] = JSON.stringify(entry); + n++; + } + return table; +} + +const ffmpegAvailable = hasFfmpeg(); +const fixturesReady = existsSync(fixture('color.mp4')); + +describe('manifest', () => { + it('declares its dependencies and queue', () => { + expect(manifest.id).toBe('still-extractor'); + expect(manifest.dependencies).toEqual(['file-info', 'ffmpeg']); + expect(manifest.defaultQueue).toBe('background'); + expect(manifest.schema?.still).toBeDefined(); + }); + + it('does not advertise writing poster or stillPath', () => { + const keys = Object.keys(manifest.schema ?? {}); + expect(keys).toEqual(['still']); + }); +}); + +describe('candidateOffsets', () => { + it('spreads candidates through the middle of the file', () => { + expect(candidateOffsets(100, [20, 45, 70])).toEqual([20, 45, 70]); + }); + + it('keeps away from both ends', () => { + const offsets = candidateOffsets(10, [1, 50, 99]); + for (const t of offsets) { + expect(t).toBeGreaterThanOrEqual(2); + expect(t).toBeLessThanOrEqual(8); + } + }); + + it('falls back to a blind attempt when duration is unknown', () => { + expect(candidateOffsets(undefined)).toEqual([10, 0]); + expect(candidateOffsets(0)).toEqual([10, 0]); + }); + + it('takes the midpoint of a very short clip', () => { + expect(candidateOffsets(3)).toEqual([1.5]); + }); + + it('deduplicates offsets that clamp onto each other', () => { + const offsets = candidateOffsets(6, [10, 12, 14]); + expect(new Set(offsets).size).toBe(offsets.length); + }); +}); + +describe('frameStats / isUsableFrame', () => { + it('measures a flat buffer as zero-variance', () => { + const stats = frameStats(Buffer.alloc(1024, 128)); + expect(stats.mean).toBe(128); + expect(stats.stddev).toBe(0); + }); + + it('rejects black, white and flat frames', () => { + expect(isUsableFrame({ mean: 2, stddev: 1 })).toBe(false); // black + expect(isUsableFrame({ mean: 252, stddev: 1 })).toBe(false); // blown out + expect(isUsableFrame({ mean: 128, stddev: 2 })).toBe(false); // flat grey + }); + + it('accepts a frame with real detail', () => { + expect(isUsableFrame({ mean: 110, stddev: 45 })).toBe(true); + }); +}); + +describe('filenames', () => { + it('strips path-hostile characters', () => { + expect(sanitizeFilename('a/b:c*d?')).toBe('abcd'); + }); + + it('carries the source CID so two files cannot collide', () => { + const a = stillFilename('/files/watch/Show/episode.mkv', 'bafkreiaaaaaaaaaaaa111111'); + const b = stillFilename('/files/watch/Other/episode.mkv', 'bafkreiaaaaaaaaaaaa222222'); + expect(a).not.toBe(b); + expect(a.endsWith('_still.jpg')).toBe(true); + expect(a.startsWith('episode[')).toBe(true); + }); +}); + +describe('computeMidHash256FromBuffer', () => { + // Vector produced by packages/meta-hash's own computeMidHash256Sync over a + // file holding exactly this string. This function is a hand-rolled copy of + // that algorithm; the vector is what pins the copy to the original. + it('matches meta-hash for a known buffer', () => { + const cid = computeMidHash256FromBuffer(Buffer.from('metamesh-still-extractor-cid-vector')); + expect(cid).toBe('bagacbabaecybg7wcyxl7su3dvjleuwgwil5tgeoybrwc35jasqtywb6bnjzk4'); + }); +}); + +describe('selectPrimaryVideoStream', () => { + it('returns null when there is no video stream', () => { + expect(selectPrimaryVideoStream({ + 'stream/0': JSON.stringify({ type: 'audio', index: 0, codec: 'aac' }), + })).toBeNull(); + expect(selectPrimaryVideoStream(undefined)).toBeNull(); + expect(selectPrimaryVideoStream({})).toBeNull(); + }); + + it('reads the flattened `stream` array form too', () => { + const picked = selectPrimaryVideoStream({ + stream: JSON.stringify([ + JSON.stringify({ type: 'audio', index: 0 }), + JSON.stringify({ type: 'video', index: 1, width: 1920, height: 1080, codec: 'h264', frameRate: '24/1' }), + ]), + } as unknown as Record); + expect(picked?.index).toBe(1); + }); + + it('prefers the larger of two real video streams', () => { + const picked = selectPrimaryVideoStream({ + 'stream/0': JSON.stringify({ type: 'video', index: 0, width: 640, height: 480, codec: 'h264', frameRate: '24/1' }), + 'stream/1': JSON.stringify({ type: 'video', index: 1, width: 1920, height: 1080, codec: 'h264', frameRate: '24/1' }), + }); + expect(picked?.index).toBe(1); + }); + + it('ignores a bigger cover art stream in favour of the moving picture', () => { + const picked = selectPrimaryVideoStream({ + 'stream/0': JSON.stringify({ type: 'video', index: 0, width: 320, height: 240, codec: 'h264', frameRate: '24/1' }), + // attached_pic: no frameRate, still-image codec, but much larger + 'stream/1': JSON.stringify({ type: 'video', index: 1, width: 600, height: 900, codec: 'mjpeg' }), + }); + expect(picked?.index).toBe(0); + }); + + it('still returns something when nothing looks like moving video', () => { + const picked = selectPrimaryVideoStream({ + 'stream/0': JSON.stringify({ type: 'video', index: 0, width: 600, height: 900, codec: 'mjpeg' }), + }); + expect(picked?.index).toBe(0); + }); + + it.skipIf(!ffmpegAvailable || !fixturesReady)( + 'picks the film, not the cover, on a real file', + () => { + const picked = selectPrimaryVideoStream(streamTableFor(fixture('cover-art.mp4'))); + expect(picked).not.toBeNull(); + expect(picked!.width).toBe(320); + expect(picked!.height).toBe(240); + } + ); +}); + +describe.skipIf(!ffmpegAvailable || !fixturesReady)('extraction', () => { + beforeAll(() => { + mkdirSync(TEMP, { recursive: true }); + }); + + it('extracts a usable frame from a colourful video', async () => { + const result = await extractStill(fixture('color.mp4'), 0, 10, TEMP); + expect(result).not.toBeNull(); + expect(result!.jpeg.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xd8])); // JPEG SOI + expect(isUsableFrame(result!.stats)).toBe(true); + }); + + it('skips past a black head to a later offset', async () => { + // 20s file, 6s of black: the 20% candidate (4s) is black and must lose. + const result = await extractStill(fixture('black-head.mp4'), 0, 20, TEMP); + expect(result).not.toBeNull(); + expect(result!.offsetSec).toBeGreaterThan(6); + expect(isUsableFrame(result!.stats)).toBe(true); + }); + + it('returns null for a file with nothing to decode', async () => { + const frame = await grabFrame(fixture('audio-only.mp3'), 0, 1, path.join(TEMP, 'nope.jpg')); + expect(frame).toBeNull(); + }); + + it('scales the frame down to the configured width', async () => { + const result = await extractStill(fixture('color.mp4'), 0, 10, TEMP); + expect(result).not.toBeNull(); + // Source is 320x240, below the 640 cap, so it must come back untouched. + expect(result!.jpeg.length).toBeGreaterThan(0); + expect(result!.jpeg.length).toBeLessThan(200_000); + }); +}); + +describe('process - skip logic', () => { + const collect = () => { + const seen: CallbackPayload[] = []; + return { seen, cb: async (p: CallbackPayload) => { seen.push(p); } }; + }; + + const req = (existingMeta: Record) => ({ + taskId: 't1', + cid: 'bafkreitest', + filePath: '/files/watch/test.mkv', + callbackUrl: 'http://localhost/callback', + // Unreachable on purpose: every case here must bail before any I/O. + metaCoreUrl: 'http://127.0.0.1:9', + existingMeta, + }); + + it('skips non-video files', async () => { + const { seen, cb } = collect(); + await processFile(req({ fileType: 'image' }), cb); + expect(seen[0].status).toBe('skipped'); + expect(seen[0].reason).toBe('Not a video file'); + }); + + it('skips a record that already has a still', async () => { + const { seen, cb } = collect(); + await processFile(req({ fileType: 'video', still: 'bafkreialready' }), cb); + expect(seen[0].status).toBe('skipped'); + expect(seen[0].reason).toBe('Still already present'); + }); + + it('skips when there is no video stream', async () => { + const { seen, cb } = collect(); + await processFile( + req({ fileType: 'video', 'stream/0': JSON.stringify({ type: 'audio', index: 0 }) }), + cb + ); + expect(seen[0].status).toBe('skipped'); + expect(seen[0].reason).toBe('No video stream'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..95ac5c2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..193a0ef --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + testTimeout: 30000, // 30s timeout for FFprobe tests + hookTimeout: 60000, // 60s for setup/teardown + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/types.ts'], + }, + }, +});