@mcptoolshop/toolshopstudio 1.1.0-toolshop
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +13 -0
- package/.github/workflows/ci.yml +53 -0
- package/CHANGELOG.md +44 -0
- package/Dockerfile +48 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/assets/logo.png +0 -0
- package/dist/build-flags.d.ts +15 -0
- package/dist/build-flags.js +95 -0
- package/dist/crud.d.ts +8 -0
- package/dist/crud.js +76 -0
- package/dist/engine.test.d.ts +1 -0
- package/dist/engine.test.js +150 -0
- package/dist/exec.d.ts +14 -0
- package/dist/exec.js +87 -0
- package/dist/full.test.d.ts +1 -0
- package/dist/full.test.js +118 -0
- package/dist/generate-thumbnail.d.ts +21 -0
- package/dist/generate-thumbnail.js +42 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/pandoc/build-args.d.ts +8 -0
- package/dist/pandoc/build-args.js +31 -0
- package/dist/pandoc/convert.d.ts +38 -0
- package/dist/pandoc/convert.js +172 -0
- package/dist/pandoc/crud.d.ts +10 -0
- package/dist/pandoc/crud.js +80 -0
- package/dist/pandoc/engine.test.d.ts +1 -0
- package/dist/pandoc/engine.test.js +161 -0
- package/dist/pandoc/exec.d.ts +9 -0
- package/dist/pandoc/exec.js +46 -0
- package/dist/pandoc/full.test.d.ts +1 -0
- package/dist/pandoc/full.test.js +146 -0
- package/dist/pandoc/index.d.ts +10 -0
- package/dist/pandoc/index.js +10 -0
- package/dist/pandoc/output-polish.d.ts +21 -0
- package/dist/pandoc/output-polish.js +43 -0
- package/dist/pandoc/pipeline.test.d.ts +1 -0
- package/dist/pandoc/pipeline.test.js +112 -0
- package/dist/pandoc/preflight.d.ts +39 -0
- package/dist/pandoc/preflight.js +153 -0
- package/dist/pandoc/preset-spec.d.ts +25 -0
- package/dist/pandoc/preset-spec.js +74 -0
- package/dist/pandoc/progress.d.ts +21 -0
- package/dist/pandoc/progress.js +59 -0
- package/dist/pandoc/schemas.d.ts +137 -0
- package/dist/pandoc/schemas.js +44 -0
- package/dist/pandoc/types.d.ts +30 -0
- package/dist/pandoc/types.js +1 -0
- package/dist/pipeline.test.d.ts +1 -0
- package/dist/pipeline.test.js +127 -0
- package/dist/preflight.d.ts +32 -0
- package/dist/preflight.js +121 -0
- package/dist/preset-spec.d.ts +17 -0
- package/dist/preset-spec.js +117 -0
- package/dist/progress-parser.d.ts +33 -0
- package/dist/progress-parser.js +75 -0
- package/dist/schemas.d.ts +851 -0
- package/dist/schemas.js +93 -0
- package/dist/thumbnail.d.ts +35 -0
- package/dist/thumbnail.js +92 -0
- package/dist/transcode.d.ts +31 -0
- package/dist/transcode.js +183 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.js +1 -0
- package/package.json +28 -0
- package/scripts/release.mjs +62 -0
- package/smoke.mjs +222 -0
- package/src/__snapshots__/engine.test.ts.snap +148 -0
- package/src/build-flags.ts +124 -0
- package/src/crud.ts +89 -0
- package/src/engine.test.ts +174 -0
- package/src/exec.ts +105 -0
- package/src/full.test.ts +152 -0
- package/src/generate-thumbnail.ts +83 -0
- package/src/index.ts +12 -0
- package/src/pandoc/build-args.ts +40 -0
- package/src/pandoc/convert.ts +282 -0
- package/src/pandoc/crud.ts +95 -0
- package/src/pandoc/engine.test.ts +224 -0
- package/src/pandoc/exec.ts +55 -0
- package/src/pandoc/full.test.ts +211 -0
- package/src/pandoc/index.ts +10 -0
- package/src/pandoc/output-polish.ts +60 -0
- package/src/pandoc/pipeline.test.ts +170 -0
- package/src/pandoc/preflight.ts +209 -0
- package/src/pandoc/preset-spec.ts +97 -0
- package/src/pandoc/progress.ts +71 -0
- package/src/pandoc/schemas.ts +54 -0
- package/src/pandoc/types.ts +40 -0
- package/src/pipeline.test.ts +167 -0
- package/src/preflight.ts +181 -0
- package/src/preset-spec.ts +136 -0
- package/src/progress-parser.ts +90 -0
- package/src/schemas.ts +107 -0
- package/src/thumbnail.ts +134 -0
- package/src/transcode.ts +272 -0
- package/src/types.ts +43 -0
- package/tsconfig.json +15 -0
package/.dockerignore
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
paths:
|
|
6
|
+
- "package.json"
|
|
7
|
+
- "tsconfig.json"
|
|
8
|
+
- "src/**"
|
|
9
|
+
- "smoke.mjs"
|
|
10
|
+
- ".github/workflows/**"
|
|
11
|
+
pull_request:
|
|
12
|
+
paths:
|
|
13
|
+
- "package.json"
|
|
14
|
+
- "tsconfig.json"
|
|
15
|
+
- "src/**"
|
|
16
|
+
- "smoke.mjs"
|
|
17
|
+
- ".github/workflows/**"
|
|
18
|
+
workflow_dispatch:
|
|
19
|
+
|
|
20
|
+
concurrency:
|
|
21
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
22
|
+
cancel-in-progress: true
|
|
23
|
+
|
|
24
|
+
jobs:
|
|
25
|
+
test:
|
|
26
|
+
name: Typecheck + Test + Smoke
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
|
|
29
|
+
strategy:
|
|
30
|
+
matrix:
|
|
31
|
+
node-version: [22]
|
|
32
|
+
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v4
|
|
35
|
+
|
|
36
|
+
- uses: actions/setup-node@v4
|
|
37
|
+
with:
|
|
38
|
+
node-version: ${{ matrix.node-version }}
|
|
39
|
+
cache: "npm"
|
|
40
|
+
|
|
41
|
+
- run: npm ci
|
|
42
|
+
|
|
43
|
+
- name: Typecheck
|
|
44
|
+
run: npm run typecheck
|
|
45
|
+
|
|
46
|
+
- name: Unit tests (FFmpeg + Pandoc)
|
|
47
|
+
run: npm test
|
|
48
|
+
|
|
49
|
+
- name: Build
|
|
50
|
+
run: npm run build
|
|
51
|
+
|
|
52
|
+
- name: Smoke test (both tools)
|
|
53
|
+
run: npm run smoke
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [1.1.0-toolshop] - 2026-02-19
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Pandoc MCP**: full document conversion tool (5 presets: blog-post, academic-pdf, ebook, slides, newsletter)
|
|
9
|
+
- Pandoc two-tier encoding: premium newsletter with automatic fallback to blog-post
|
|
10
|
+
- Pandoc schema-first design: Zod schemas for ConvertDocument, PandocDocumentAsset, metadata
|
|
11
|
+
- Pandoc buildPandocArgs pure function (deterministic, zero raw flags)
|
|
12
|
+
- Pandoc progress parser reusing FFmpeg line-buffer infrastructure
|
|
13
|
+
- Pandoc pre/post-flight assertions (input validation, output extension/size checks)
|
|
14
|
+
- Pandoc context DI pattern (ConvertDocumentContext) matching FFmpeg's TranscodeContext
|
|
15
|
+
- PandocDocumentCRUD with lazy hydration + optional JSON persistence
|
|
16
|
+
- Output polish helpers (auto-extension, typed metadata, configurable expiry)
|
|
17
|
+
- Unified Docker image with both ffmpeg and pandoc binaries + non-root user
|
|
18
|
+
- GitHub Actions CI (paths-gated, single workflow, typecheck + test + build + smoke)
|
|
19
|
+
- Dual-tool smoke test (FFmpeg + Pandoc end-to-end)
|
|
20
|
+
- 42 unit/integration tests (13 FFmpeg + 29 Pandoc)
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- README refreshed for dual-tool story with both preset tables
|
|
24
|
+
- Docker image relabeled for v1.1.0-toolshop
|
|
25
|
+
- Release script updated with npm publish step
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## [1.0.0-yt-safe] - 2026-02-19
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
- Full YouTube 2026 preset layer (7 presets: guaranteed + premium with fallback)
|
|
33
|
+
- Two-tier encoding: premium H.265 with automatic fallback to guaranteed H.264
|
|
34
|
+
- Closed-GOP locks on all presets (scenecut=0, keyint=60)
|
|
35
|
+
- Dual thumbnail generation (16:9 landscape + 4:5 portrait) with style controls
|
|
36
|
+
- Robust line-buffered ffmpeg progress parser with percent tracking
|
|
37
|
+
- AbortController cancellation propagated to all pipeline stages
|
|
38
|
+
- Pre-flight checks: interlace detection, output size estimation
|
|
39
|
+
- Post-flight assertions: container, codec, profile, pix_fmt, audio spec validation
|
|
40
|
+
- Zod schemas for all inputs/outputs (type-safe end-to-end)
|
|
41
|
+
- In-memory CRUD with optional JSON file persistence
|
|
42
|
+
- Context DI pattern for full testability (13 unit tests + smoke)
|
|
43
|
+
- Multi-stage Docker image with OCI labels
|
|
44
|
+
- Logo integrated
|
package/Dockerfile
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# ── Build stage ────────────────────────────────────────────────────
|
|
2
|
+
FROM node:22-slim AS builder
|
|
3
|
+
|
|
4
|
+
WORKDIR /app
|
|
5
|
+
|
|
6
|
+
COPY package.json package-lock.json tsconfig.json ./
|
|
7
|
+
RUN npm ci --ignore-scripts
|
|
8
|
+
|
|
9
|
+
COPY src/ src/
|
|
10
|
+
RUN npm run build
|
|
11
|
+
|
|
12
|
+
# ── Production stage ───────────────────────────────────────────────
|
|
13
|
+
FROM node:22-slim
|
|
14
|
+
|
|
15
|
+
# Install both runtime binaries
|
|
16
|
+
RUN apt-get update && \
|
|
17
|
+
apt-get install -y --no-install-recommends \
|
|
18
|
+
ffmpeg \
|
|
19
|
+
pandoc \
|
|
20
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
21
|
+
|
|
22
|
+
WORKDIR /app
|
|
23
|
+
|
|
24
|
+
COPY package.json package-lock.json ./
|
|
25
|
+
RUN npm ci --omit=dev --ignore-scripts
|
|
26
|
+
|
|
27
|
+
COPY --from=builder /app/dist/ dist/
|
|
28
|
+
COPY assets/ assets/
|
|
29
|
+
|
|
30
|
+
# Non-root user for sandbox isolation
|
|
31
|
+
RUN useradd -m -u 1001 appuser && \
|
|
32
|
+
mkdir -p /sandbox /tmp/toolshop && \
|
|
33
|
+
chown -R appuser:appuser /app /sandbox /tmp/toolshop
|
|
34
|
+
|
|
35
|
+
USER appuser
|
|
36
|
+
|
|
37
|
+
ENV NODE_ENV=production
|
|
38
|
+
|
|
39
|
+
VOLUME ["/sandbox", "/tmp/toolshop"]
|
|
40
|
+
|
|
41
|
+
LABEL org.opencontainers.image.title="ToolShopStudio" \
|
|
42
|
+
org.opencontainers.image.description="FFmpeg YouTube MCP + Pandoc MCP — dual-tool kit for ordinary creators" \
|
|
43
|
+
org.opencontainers.image.version="1.1.0-toolshop" \
|
|
44
|
+
org.opencontainers.image.logo="assets/logo.png" \
|
|
45
|
+
org.opencontainers.image.source="https://github.com/mcp-tool-shop-org/ToolShopStudio" \
|
|
46
|
+
org.opencontainers.image.licenses="MIT"
|
|
47
|
+
|
|
48
|
+
ENTRYPOINT ["node", "dist/index.js"]
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mcp-tool-shop
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.png" width="400" alt="ToolShopStudio">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">ToolShopStudio</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
Production-grade MCP tools for ordinary creators
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://github.com/mcp-tool-shop-org/ToolShopStudio/actions"><img src="https://github.com/mcp-tool-shop-org/ToolShopStudio/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
13
|
+
<a href="https://www.npmjs.com/package/@mcptoolshop/toolshopstudio"><img src="https://img.shields.io/npm/v/@mcptoolshop/toolshopstudio" alt="npm version"></a>
|
|
14
|
+
<img src="https://img.shields.io/badge/tools-FFmpeg%20%2B%20Pandoc-orange" alt="Tools">
|
|
15
|
+
<img src="https://img.shields.io/badge/tests-42%20passing-brightgreen" alt="Tests">
|
|
16
|
+
<img src="https://img.shields.io/badge/license-MIT-blue" alt="License">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Shipped Today
|
|
22
|
+
|
|
23
|
+
| Tool | What It Does |
|
|
24
|
+
|------|--------------|
|
|
25
|
+
| **FFmpeg YouTube MCP** | YouTube-safe presets (guaranteed + premium with fallback), closed-GOP locks, dual thumbnails |
|
|
26
|
+
| **Pandoc MCP** | Zero-flag document conversion: blog, academic PDF, ebook, slides, newsletter |
|
|
27
|
+
|
|
28
|
+
Both tools share the same frozen surface: **schema-first, sandboxed, observable, context DI, zero raw args**.
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install @mcptoolshop/toolshopstudio
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import {
|
|
38
|
+
transcodeForYouTube,
|
|
39
|
+
createInMemoryCRUD,
|
|
40
|
+
pandoc,
|
|
41
|
+
} from "@mcptoolshop/toolshopstudio";
|
|
42
|
+
|
|
43
|
+
// ── FFmpeg: YouTube-safe transcode ──────────────────────────────
|
|
44
|
+
const ffmpegCrud = createInMemoryCRUD();
|
|
45
|
+
const video = await transcodeForYouTube(
|
|
46
|
+
{ inputPath: "input.mp4", outputPath: "output.mp4", preset: "yt-1080p-h264" },
|
|
47
|
+
{ signal, userId, notify, createAsset: (a) => ffmpegCrud.create(a), runFfmpeg, runProbe },
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// ── Pandoc: document conversion ─────────────────────────────────
|
|
51
|
+
const pandocCrud = pandoc.createPandocCRUD();
|
|
52
|
+
const doc = await pandoc.convertDocument(
|
|
53
|
+
{ inputPath: "thesis.md", outputPath: "thesis.pdf", preset: "academic-pdf" },
|
|
54
|
+
{ signal, userId, notify, createAsset: (a) => pandocCrud.create(a), runPandoc, checkInput, assertOutput, statFile },
|
|
55
|
+
);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## FFmpeg Presets
|
|
59
|
+
|
|
60
|
+
| Preset | Codec | Resolution | CRF | Tier |
|
|
61
|
+
|--------|-------|-----------|-----|------|
|
|
62
|
+
| `yt-1080p-h264` | libx264 | 1920x1080 | 18 | Guaranteed |
|
|
63
|
+
| `yt-1080p-h265` | libx265 | 1920x1080 | 20 | Premium |
|
|
64
|
+
| `yt-4k-h264` | libx264 | 3840x2160 | 18 | Guaranteed |
|
|
65
|
+
| `yt-4k-h265` | libx265 | 3840x2160 | 20 | Premium |
|
|
66
|
+
| `yt-4k-hdr-h265` | libx265 | 3840x2160 | 18 | Premium (HDR) |
|
|
67
|
+
| `yt-shorts-h264` | libx264 | 1080x1920 | 18 | Guaranteed |
|
|
68
|
+
| `yt-shorts-h265` | libx265 | 1080x1920 | 20 | Premium |
|
|
69
|
+
|
|
70
|
+
## Pandoc Presets
|
|
71
|
+
|
|
72
|
+
| Preset | From | To | Output | Tier |
|
|
73
|
+
|--------|------|----|--------|------|
|
|
74
|
+
| `blog-post` | Markdown | HTML5 | `.html` | Guaranteed |
|
|
75
|
+
| `academic-pdf` | Markdown | PDF (XeLaTeX) | `.pdf` | Guaranteed |
|
|
76
|
+
| `ebook` | Markdown | EPUB | `.epub` | Guaranteed |
|
|
77
|
+
| `slides` | Markdown | Reveal.js | `.html` | Guaranteed |
|
|
78
|
+
| `newsletter` | Markdown | HTML5 | `.html` | Premium (falls back to blog-post) |
|
|
79
|
+
|
|
80
|
+
## Architecture
|
|
81
|
+
|
|
82
|
+
- **Schema-first**: Zod schemas for every input/output, fully type-safe
|
|
83
|
+
- **Context DI**: All side effects injected via context objects, 100% mockable
|
|
84
|
+
- **Sandbox isolation**: Path traversal prevention on every file operation
|
|
85
|
+
- **Observable**: Typed notifications (progress, warnings, ready) at every stage
|
|
86
|
+
- **Cancellation**: AbortController propagated to every pipeline checkpoint
|
|
87
|
+
- **Fallback**: Premium presets auto-degrade to guaranteed on failure/assertion mismatch
|
|
88
|
+
|
|
89
|
+
## Docker
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
docker build -t toolshopstudio .
|
|
93
|
+
docker run -v ./sandbox:/sandbox toolshopstudio
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Both `ffmpeg` and `pandoc` binaries are pre-installed in the image.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm install # dependencies
|
|
102
|
+
npm run typecheck # tsc --noEmit
|
|
103
|
+
npm test # vitest (42 tests)
|
|
104
|
+
npm run build # compile to dist/
|
|
105
|
+
npm run smoke # end-to-end smoke (both tools)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
package/assets/logo.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, deterministic ffmpeg flag builder.
|
|
3
|
+
* YouTube 2026 invariant compliant.
|
|
4
|
+
*
|
|
5
|
+
* Takes a transcode request + probe result, returns the full ffmpeg
|
|
6
|
+
* argv (minus the `ffmpeg` binary itself). No side effects.
|
|
7
|
+
*/
|
|
8
|
+
import type { TranscodeForYouTube, ProbeResult } from "./schemas.js";
|
|
9
|
+
/**
|
|
10
|
+
* Build the complete ffmpeg flag array for a YouTube-safe transcode.
|
|
11
|
+
*
|
|
12
|
+
* Order: input → base flags → video filter → codec/pixFmt/profile/crf/maxrate →
|
|
13
|
+
* HDR tags → customBitrate override → closed-GOP params → -y output
|
|
14
|
+
*/
|
|
15
|
+
export declare function buildFlags(req: TranscodeForYouTube, probe: ProbeResult): string[];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, deterministic ffmpeg flag builder.
|
|
3
|
+
* YouTube 2026 invariant compliant.
|
|
4
|
+
*
|
|
5
|
+
* Takes a transcode request + probe result, returns the full ffmpeg
|
|
6
|
+
* argv (minus the `ffmpeg` binary itself). No side effects.
|
|
7
|
+
*/
|
|
8
|
+
import { PRESET_SPECS, buildBaseFlags } from "./preset-spec.js";
|
|
9
|
+
/**
|
|
10
|
+
* Build the complete ffmpeg flag array for a YouTube-safe transcode.
|
|
11
|
+
*
|
|
12
|
+
* Order: input → base flags → video filter → codec/pixFmt/profile/crf/maxrate →
|
|
13
|
+
* HDR tags → customBitrate override → closed-GOP params → -y output
|
|
14
|
+
*/
|
|
15
|
+
export function buildFlags(req, probe) {
|
|
16
|
+
const spec = PRESET_SPECS[req.preset];
|
|
17
|
+
const flags = [];
|
|
18
|
+
// ── Input ───────────────────────────────────────────────────────
|
|
19
|
+
flags.push("-i", req.inputPath);
|
|
20
|
+
// ── Base invariants ─────────────────────────────────────────────
|
|
21
|
+
flags.push(...buildBaseFlags());
|
|
22
|
+
// ── Video filter (orientation-aware) ────────────────────────────
|
|
23
|
+
flags.push("-vf", resolveVf(spec.vf, req.orientationHint, probe));
|
|
24
|
+
// ── Codec, pixel format, profile, CRF, rate control ─────────────
|
|
25
|
+
flags.push("-c:v", spec.codec);
|
|
26
|
+
flags.push("-pix_fmt", spec.pixFmt);
|
|
27
|
+
flags.push("-profile:v", spec.profile);
|
|
28
|
+
flags.push("-crf", String(spec.crf));
|
|
29
|
+
flags.push("-maxrate", spec.maxrate);
|
|
30
|
+
flags.push("-bufsize", spec.bufsize);
|
|
31
|
+
// ── HDR tags (only for HDR presets) ─────────────────────────────
|
|
32
|
+
if (spec.hdrTags) {
|
|
33
|
+
for (const [key, value] of Object.entries(spec.hdrTags)) {
|
|
34
|
+
flags.push(`-${key}`, value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// ── Custom bitrate override ─────────────────────────────────────
|
|
38
|
+
if (req.customBitrate?.videoMbps) {
|
|
39
|
+
flags.push("-b:v", `${req.customBitrate.videoMbps}M`);
|
|
40
|
+
}
|
|
41
|
+
if (req.customBitrate?.audioBitrate) {
|
|
42
|
+
flags.push("-b:a", req.customBitrate.audioBitrate);
|
|
43
|
+
}
|
|
44
|
+
// ── Closed-GOP encoder params ───────────────────────────────────
|
|
45
|
+
if (spec.codec === "libx264" && spec.x264Params) {
|
|
46
|
+
flags.push("-x264-params", spec.x264Params);
|
|
47
|
+
}
|
|
48
|
+
if (spec.codec === "libx265" && spec.x265Params) {
|
|
49
|
+
flags.push("-x265-params", spec.x265Params);
|
|
50
|
+
}
|
|
51
|
+
// ── Progress pipe (stderr key=value parsing) ────────────────────
|
|
52
|
+
flags.push("-progress", "pipe:2");
|
|
53
|
+
// ── Output (overwrite) ──────────────────────────────────────────
|
|
54
|
+
flags.push("-y", req.outputPath);
|
|
55
|
+
return flags;
|
|
56
|
+
}
|
|
57
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the video filter string. If an orientationHint is given
|
|
60
|
+
* and differs from the spec's default, swap width/height in the
|
|
61
|
+
* scale+pad chain. Otherwise use the spec's vf as-is.
|
|
62
|
+
*/
|
|
63
|
+
function resolveVf(specVf, orientationHint, _probe) {
|
|
64
|
+
if (!orientationHint)
|
|
65
|
+
return specVf;
|
|
66
|
+
// Parse the spec's scale dimensions from the vf string
|
|
67
|
+
const scaleMatch = specVf.match(/scale=(\d+):(\d+):force_original_aspect_ratio=decrease,pad=(\d+):(\d+)/);
|
|
68
|
+
if (!scaleMatch)
|
|
69
|
+
return specVf;
|
|
70
|
+
const [, sw, sh, pw, ph] = scaleMatch;
|
|
71
|
+
const specW = Number(sw);
|
|
72
|
+
const specH = Number(sh);
|
|
73
|
+
const specIsLandscape = specW > specH;
|
|
74
|
+
const wantLandscape = orientationHint === "landscape";
|
|
75
|
+
const wantPortrait = orientationHint === "portrait";
|
|
76
|
+
const wantSquare = orientationHint === "square";
|
|
77
|
+
// If hint matches spec orientation, no change needed
|
|
78
|
+
if ((wantLandscape && specIsLandscape) || (wantPortrait && !specIsLandscape)) {
|
|
79
|
+
return specVf;
|
|
80
|
+
}
|
|
81
|
+
// Swap dimensions for orientation mismatch
|
|
82
|
+
if ((wantPortrait && specIsLandscape) || (wantLandscape && !specIsLandscape)) {
|
|
83
|
+
return specVf
|
|
84
|
+
.replace(`scale=${sw}:${sh}`, `scale=${sh}:${sw}`)
|
|
85
|
+
.replace(`pad=${pw}:${ph}`, `pad=${ph}:${pw}`);
|
|
86
|
+
}
|
|
87
|
+
// Square: use min dimension for both
|
|
88
|
+
if (wantSquare) {
|
|
89
|
+
const dim = Math.min(specW, specH);
|
|
90
|
+
return specVf
|
|
91
|
+
.replace(`scale=${sw}:${sh}`, `scale=${dim}:${dim}`)
|
|
92
|
+
.replace(`pad=${pw}:${ph}`, `pad=${dim}:${dim}`);
|
|
93
|
+
}
|
|
94
|
+
return specVf;
|
|
95
|
+
}
|
package/dist/crud.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { YouTubeMediaAssetCRUD } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Create an in-memory CRUD store. Optionally persists to a JSON file
|
|
4
|
+
* on every write for dev/debug use.
|
|
5
|
+
*
|
|
6
|
+
* @param persistPath - optional path to a JSON file for persistence
|
|
7
|
+
*/
|
|
8
|
+
export declare function createInMemoryCRUD(persistPath?: string): YouTubeMediaAssetCRUD;
|
package/dist/crud.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// ── In-memory CRUD ────────────────────────────────────────────────
|
|
4
|
+
/**
|
|
5
|
+
* Create an in-memory CRUD store. Optionally persists to a JSON file
|
|
6
|
+
* on every write for dev/debug use.
|
|
7
|
+
*
|
|
8
|
+
* @param persistPath - optional path to a JSON file for persistence
|
|
9
|
+
*/
|
|
10
|
+
export function createInMemoryCRUD(persistPath) {
|
|
11
|
+
const store = new Map();
|
|
12
|
+
async function persist() {
|
|
13
|
+
if (!persistPath)
|
|
14
|
+
return;
|
|
15
|
+
await mkdir(path.dirname(persistPath), { recursive: true });
|
|
16
|
+
const data = JSON.stringify(Array.from(store.values()), null, 2);
|
|
17
|
+
await writeFile(persistPath, data, "utf8");
|
|
18
|
+
}
|
|
19
|
+
async function hydrate() {
|
|
20
|
+
if (!persistPath)
|
|
21
|
+
return;
|
|
22
|
+
try {
|
|
23
|
+
const data = await readFile(persistPath, "utf8");
|
|
24
|
+
const items = JSON.parse(data);
|
|
25
|
+
for (const item of items) {
|
|
26
|
+
store.set(item.id, item);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// File doesn't exist yet — start fresh
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Hydrate on first access (lazy)
|
|
34
|
+
let hydrated = false;
|
|
35
|
+
async function ensureHydrated() {
|
|
36
|
+
if (!hydrated) {
|
|
37
|
+
await hydrate();
|
|
38
|
+
hydrated = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
async create(asset) {
|
|
43
|
+
await ensureHydrated();
|
|
44
|
+
store.set(asset.id, asset);
|
|
45
|
+
await persist();
|
|
46
|
+
return asset;
|
|
47
|
+
},
|
|
48
|
+
async read(id) {
|
|
49
|
+
await ensureHydrated();
|
|
50
|
+
return store.get(id) ?? null;
|
|
51
|
+
},
|
|
52
|
+
async list(filter) {
|
|
53
|
+
await ensureHydrated();
|
|
54
|
+
const all = Array.from(store.values());
|
|
55
|
+
// userId filter is a no-op for now (assets don't store userId)
|
|
56
|
+
return filter?.userId ? all : all;
|
|
57
|
+
},
|
|
58
|
+
async update(id, patch) {
|
|
59
|
+
await ensureHydrated();
|
|
60
|
+
const existing = store.get(id);
|
|
61
|
+
if (!existing)
|
|
62
|
+
throw new Error(`Asset ${id} not found.`);
|
|
63
|
+
const updated = { ...existing, ...patch, id }; // id is immutable
|
|
64
|
+
store.set(id, updated);
|
|
65
|
+
await persist();
|
|
66
|
+
return updated;
|
|
67
|
+
},
|
|
68
|
+
async delete(id) {
|
|
69
|
+
await ensureHydrated();
|
|
70
|
+
if (!store.delete(id)) {
|
|
71
|
+
throw new Error(`Asset ${id} not found.`);
|
|
72
|
+
}
|
|
73
|
+
await persist();
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildFlags } from "./build-flags.js";
|
|
3
|
+
import { parseProgressLine, calculatePercent, } from "./progress-parser.js";
|
|
4
|
+
// ── Test fixtures ─────────────────────────────────────────────────
|
|
5
|
+
const MINIMAL_PROBE = {
|
|
6
|
+
streams: [
|
|
7
|
+
{
|
|
8
|
+
codec_name: "h264",
|
|
9
|
+
codec_type: "video",
|
|
10
|
+
width: 1920,
|
|
11
|
+
height: 1080,
|
|
12
|
+
pix_fmt: "yuv420p",
|
|
13
|
+
field_order: "progressive",
|
|
14
|
+
r_frame_rate: "30/1",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
codec_name: "aac",
|
|
18
|
+
codec_type: "audio",
|
|
19
|
+
channels: 2,
|
|
20
|
+
sample_rate: "48000",
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
format: {
|
|
24
|
+
filename: "input.mp4",
|
|
25
|
+
format_name: "mov,mp4,m4a,3gp,3g2,mj2",
|
|
26
|
+
duration: "120.0",
|
|
27
|
+
bit_rate: "8000000",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
// ── buildFlags golden snapshots ───────────────────────────────────
|
|
31
|
+
describe("buildFlags", () => {
|
|
32
|
+
it("sdr_1080p: produces correct flags for yt-1080p-h264", () => {
|
|
33
|
+
const req = {
|
|
34
|
+
inputPath: "/sandbox/user1/input.mp4",
|
|
35
|
+
outputPath: "/sandbox/user1/output.mp4",
|
|
36
|
+
preset: "yt-1080p-h264",
|
|
37
|
+
allowFallback: true,
|
|
38
|
+
timeoutSeconds: 3600,
|
|
39
|
+
};
|
|
40
|
+
const flags = buildFlags(req, MINIMAL_PROBE);
|
|
41
|
+
expect(flags).toMatchSnapshot();
|
|
42
|
+
});
|
|
43
|
+
it("shorts_sdr_1080x1920: produces correct flags for yt-shorts-h264", () => {
|
|
44
|
+
const req = {
|
|
45
|
+
inputPath: "/sandbox/user1/short.mp4",
|
|
46
|
+
outputPath: "/sandbox/user1/short_out.mp4",
|
|
47
|
+
preset: "yt-shorts-h264",
|
|
48
|
+
allowFallback: true,
|
|
49
|
+
timeoutSeconds: 3600,
|
|
50
|
+
};
|
|
51
|
+
const flags = buildFlags(req, MINIMAL_PROBE);
|
|
52
|
+
expect(flags).toMatchSnapshot();
|
|
53
|
+
});
|
|
54
|
+
it("hdr_pq_4k: produces correct flags for yt-4k-hdr-h265", () => {
|
|
55
|
+
const req = {
|
|
56
|
+
inputPath: "/sandbox/user1/hdr_input.mp4",
|
|
57
|
+
outputPath: "/sandbox/user1/hdr_output.mp4",
|
|
58
|
+
preset: "yt-4k-hdr-h265",
|
|
59
|
+
allowFallback: true,
|
|
60
|
+
timeoutSeconds: 3600,
|
|
61
|
+
};
|
|
62
|
+
const flags = buildFlags(req, MINIMAL_PROBE);
|
|
63
|
+
expect(flags).toMatchSnapshot();
|
|
64
|
+
// HDR tags must be present
|
|
65
|
+
expect(flags).toContain("-color_primaries");
|
|
66
|
+
expect(flags).toContain("bt2020");
|
|
67
|
+
expect(flags).toContain("-color_trc");
|
|
68
|
+
expect(flags).toContain("smpte2084");
|
|
69
|
+
expect(flags).toContain("-colorspace");
|
|
70
|
+
expect(flags).toContain("bt2020nc");
|
|
71
|
+
});
|
|
72
|
+
it("closed-gop params are present for all presets", () => {
|
|
73
|
+
const presets = [
|
|
74
|
+
"yt-1080p-h264",
|
|
75
|
+
"yt-1080p-h265",
|
|
76
|
+
"yt-4k-h264",
|
|
77
|
+
"yt-4k-h265",
|
|
78
|
+
"yt-4k-hdr-h265",
|
|
79
|
+
"yt-shorts-h264",
|
|
80
|
+
"yt-shorts-h265",
|
|
81
|
+
];
|
|
82
|
+
for (const preset of presets) {
|
|
83
|
+
const req = {
|
|
84
|
+
inputPath: "/in.mp4",
|
|
85
|
+
outputPath: "/out.mp4",
|
|
86
|
+
preset,
|
|
87
|
+
allowFallback: true,
|
|
88
|
+
timeoutSeconds: 3600,
|
|
89
|
+
};
|
|
90
|
+
const flags = buildFlags(req, MINIMAL_PROBE);
|
|
91
|
+
if (preset.includes("h264")) {
|
|
92
|
+
expect(flags).toContain("-x264-params");
|
|
93
|
+
const idx = flags.indexOf("-x264-params");
|
|
94
|
+
expect(flags[idx + 1]).toBe("keyint=60:min-keyint=60:scenecut=0");
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
expect(flags).toContain("-x265-params");
|
|
98
|
+
const idx = flags.indexOf("-x265-params");
|
|
99
|
+
expect(flags[idx + 1]).toBe("open-gop=0:scenecut=0");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
// ── Progress parser ───────────────────────────────────────────────
|
|
105
|
+
describe("progress parser", () => {
|
|
106
|
+
it("handles split chunks correctly", () => {
|
|
107
|
+
// Simulate ffmpeg progress output that arrives in split chunks
|
|
108
|
+
const collected = [];
|
|
109
|
+
// Chunk 1: complete line + partial line
|
|
110
|
+
const chunk1 = "frame=100\nfps=30.0\nout_time_us=333";
|
|
111
|
+
// Chunk 2: rest of partial line + new complete line
|
|
112
|
+
const chunk2 = "3000\nprogress=continue\n";
|
|
113
|
+
// Process chunk1
|
|
114
|
+
let buffer = "";
|
|
115
|
+
buffer += chunk1;
|
|
116
|
+
const lines1 = buffer.split("\n");
|
|
117
|
+
buffer = lines1.pop() ?? "";
|
|
118
|
+
for (const line of lines1) {
|
|
119
|
+
const kv = parseProgressLine(line);
|
|
120
|
+
if (kv)
|
|
121
|
+
collected.push(kv);
|
|
122
|
+
}
|
|
123
|
+
// Process chunk2
|
|
124
|
+
buffer += chunk2;
|
|
125
|
+
const lines2 = buffer.split("\n");
|
|
126
|
+
buffer = lines2.pop() ?? "";
|
|
127
|
+
for (const line of lines2) {
|
|
128
|
+
const kv = parseProgressLine(line);
|
|
129
|
+
if (kv)
|
|
130
|
+
collected.push(kv);
|
|
131
|
+
}
|
|
132
|
+
expect(collected).toEqual([
|
|
133
|
+
{ key: "frame", value: "100" },
|
|
134
|
+
{ key: "fps", value: "30.0" },
|
|
135
|
+
{ key: "out_time_us", value: "3333000" },
|
|
136
|
+
{ key: "progress", value: "continue" },
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
it("calculatePercent clamps to 0-100", () => {
|
|
140
|
+
expect(calculatePercent("0", 120)).toBe(0);
|
|
141
|
+
expect(calculatePercent("60000000", 120)).toBe(50);
|
|
142
|
+
expect(calculatePercent("120000000", 120)).toBe(100);
|
|
143
|
+
// Over 100% — clamped
|
|
144
|
+
expect(calculatePercent("150000000", 120)).toBe(100);
|
|
145
|
+
// Negative — clamped
|
|
146
|
+
expect(calculatePercent("-1000", 120)).toBe(0);
|
|
147
|
+
// Zero duration
|
|
148
|
+
expect(calculatePercent("50000000", 0)).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
package/dist/exec.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ProbeResult } from "./schemas.js";
|
|
2
|
+
/**
|
|
3
|
+
* Run ffmpeg with -nostdin, AbortSignal cancellation, and progress parsing.
|
|
4
|
+
*
|
|
5
|
+
* @param flags - full argv (everything after `ffmpeg`)
|
|
6
|
+
* @param signal - AbortSignal for cancellation
|
|
7
|
+
* @param onProgress - called with percent (0–100), fires at each out_time_us update and on 'end'
|
|
8
|
+
* @param durationSec - total input duration for percent calculation
|
|
9
|
+
*/
|
|
10
|
+
export declare function runFfmpeg(flags: string[], signal: AbortSignal, onProgress: (percent: number) => void, durationSec: number): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Run ffprobe and return parsed ProbeResult.
|
|
13
|
+
*/
|
|
14
|
+
export declare function runProbe(filePath: string): Promise<ProbeResult>;
|