@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.
Files changed (99) hide show
  1. package/.dockerignore +13 -0
  2. package/.github/workflows/ci.yml +53 -0
  3. package/CHANGELOG.md +44 -0
  4. package/Dockerfile +48 -0
  5. package/LICENSE +21 -0
  6. package/README.md +110 -0
  7. package/assets/logo.png +0 -0
  8. package/dist/build-flags.d.ts +15 -0
  9. package/dist/build-flags.js +95 -0
  10. package/dist/crud.d.ts +8 -0
  11. package/dist/crud.js +76 -0
  12. package/dist/engine.test.d.ts +1 -0
  13. package/dist/engine.test.js +150 -0
  14. package/dist/exec.d.ts +14 -0
  15. package/dist/exec.js +87 -0
  16. package/dist/full.test.d.ts +1 -0
  17. package/dist/full.test.js +118 -0
  18. package/dist/generate-thumbnail.d.ts +21 -0
  19. package/dist/generate-thumbnail.js +42 -0
  20. package/dist/index.d.ts +12 -0
  21. package/dist/index.js +12 -0
  22. package/dist/pandoc/build-args.d.ts +8 -0
  23. package/dist/pandoc/build-args.js +31 -0
  24. package/dist/pandoc/convert.d.ts +38 -0
  25. package/dist/pandoc/convert.js +172 -0
  26. package/dist/pandoc/crud.d.ts +10 -0
  27. package/dist/pandoc/crud.js +80 -0
  28. package/dist/pandoc/engine.test.d.ts +1 -0
  29. package/dist/pandoc/engine.test.js +161 -0
  30. package/dist/pandoc/exec.d.ts +9 -0
  31. package/dist/pandoc/exec.js +46 -0
  32. package/dist/pandoc/full.test.d.ts +1 -0
  33. package/dist/pandoc/full.test.js +146 -0
  34. package/dist/pandoc/index.d.ts +10 -0
  35. package/dist/pandoc/index.js +10 -0
  36. package/dist/pandoc/output-polish.d.ts +21 -0
  37. package/dist/pandoc/output-polish.js +43 -0
  38. package/dist/pandoc/pipeline.test.d.ts +1 -0
  39. package/dist/pandoc/pipeline.test.js +112 -0
  40. package/dist/pandoc/preflight.d.ts +39 -0
  41. package/dist/pandoc/preflight.js +153 -0
  42. package/dist/pandoc/preset-spec.d.ts +25 -0
  43. package/dist/pandoc/preset-spec.js +74 -0
  44. package/dist/pandoc/progress.d.ts +21 -0
  45. package/dist/pandoc/progress.js +59 -0
  46. package/dist/pandoc/schemas.d.ts +137 -0
  47. package/dist/pandoc/schemas.js +44 -0
  48. package/dist/pandoc/types.d.ts +30 -0
  49. package/dist/pandoc/types.js +1 -0
  50. package/dist/pipeline.test.d.ts +1 -0
  51. package/dist/pipeline.test.js +127 -0
  52. package/dist/preflight.d.ts +32 -0
  53. package/dist/preflight.js +121 -0
  54. package/dist/preset-spec.d.ts +17 -0
  55. package/dist/preset-spec.js +117 -0
  56. package/dist/progress-parser.d.ts +33 -0
  57. package/dist/progress-parser.js +75 -0
  58. package/dist/schemas.d.ts +851 -0
  59. package/dist/schemas.js +93 -0
  60. package/dist/thumbnail.d.ts +35 -0
  61. package/dist/thumbnail.js +92 -0
  62. package/dist/transcode.d.ts +31 -0
  63. package/dist/transcode.js +183 -0
  64. package/dist/types.d.ts +33 -0
  65. package/dist/types.js +1 -0
  66. package/package.json +28 -0
  67. package/scripts/release.mjs +62 -0
  68. package/smoke.mjs +222 -0
  69. package/src/__snapshots__/engine.test.ts.snap +148 -0
  70. package/src/build-flags.ts +124 -0
  71. package/src/crud.ts +89 -0
  72. package/src/engine.test.ts +174 -0
  73. package/src/exec.ts +105 -0
  74. package/src/full.test.ts +152 -0
  75. package/src/generate-thumbnail.ts +83 -0
  76. package/src/index.ts +12 -0
  77. package/src/pandoc/build-args.ts +40 -0
  78. package/src/pandoc/convert.ts +282 -0
  79. package/src/pandoc/crud.ts +95 -0
  80. package/src/pandoc/engine.test.ts +224 -0
  81. package/src/pandoc/exec.ts +55 -0
  82. package/src/pandoc/full.test.ts +211 -0
  83. package/src/pandoc/index.ts +10 -0
  84. package/src/pandoc/output-polish.ts +60 -0
  85. package/src/pandoc/pipeline.test.ts +170 -0
  86. package/src/pandoc/preflight.ts +209 -0
  87. package/src/pandoc/preset-spec.ts +97 -0
  88. package/src/pandoc/progress.ts +71 -0
  89. package/src/pandoc/schemas.ts +54 -0
  90. package/src/pandoc/types.ts +40 -0
  91. package/src/pipeline.test.ts +167 -0
  92. package/src/preflight.ts +181 -0
  93. package/src/preset-spec.ts +136 -0
  94. package/src/progress-parser.ts +90 -0
  95. package/src/schemas.ts +107 -0
  96. package/src/thumbnail.ts +134 -0
  97. package/src/transcode.ts +272 -0
  98. package/src/types.ts +43 -0
  99. package/tsconfig.json +15 -0
package/.dockerignore ADDED
@@ -0,0 +1,13 @@
1
+ node_modules
2
+ dist
3
+ *.tsbuildinfo
4
+ .git
5
+ .env
6
+ .env.*
7
+ *.log
8
+ src/**/*.test.ts
9
+ src/__snapshots__
10
+ smoke.mjs
11
+
12
+ # Keep assets/ in Docker image (logo, etc.)
13
+ !assets/
@@ -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
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>;