@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/dist/exec.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawn, execFile } from "node:child_process";
|
|
2
|
+
import { attachProgressParser, calculatePercent } from "./progress-parser.js";
|
|
3
|
+
import { ProbeResultSchema } from "./schemas.js";
|
|
4
|
+
/**
|
|
5
|
+
* Run ffmpeg with -nostdin, AbortSignal cancellation, and progress parsing.
|
|
6
|
+
*
|
|
7
|
+
* @param flags - full argv (everything after `ffmpeg`)
|
|
8
|
+
* @param signal - AbortSignal for cancellation
|
|
9
|
+
* @param onProgress - called with percent (0–100), fires at each out_time_us update and on 'end'
|
|
10
|
+
* @param durationSec - total input duration for percent calculation
|
|
11
|
+
*/
|
|
12
|
+
export async function runFfmpeg(flags, signal, onProgress, durationSec) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
if (signal.aborted) {
|
|
15
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const proc = spawn("ffmpeg", ["-nostdin", ...flags], {
|
|
19
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
20
|
+
});
|
|
21
|
+
// Wire up abort
|
|
22
|
+
const onAbort = () => {
|
|
23
|
+
proc.kill("SIGKILL");
|
|
24
|
+
};
|
|
25
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
26
|
+
// Parse progress from stderr
|
|
27
|
+
let lastEmittedPercent = -1;
|
|
28
|
+
attachProgressParser(proc.stderr, (key, value) => {
|
|
29
|
+
if (key === "out_time_us") {
|
|
30
|
+
const percent = calculatePercent(value, durationSec);
|
|
31
|
+
if (percent !== lastEmittedPercent) {
|
|
32
|
+
lastEmittedPercent = percent;
|
|
33
|
+
onProgress(percent);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (key === "progress" && value === "end") {
|
|
37
|
+
if (lastEmittedPercent !== 100) {
|
|
38
|
+
lastEmittedPercent = 100;
|
|
39
|
+
onProgress(100);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
proc.on("close", (code) => {
|
|
44
|
+
signal.removeEventListener("abort", onAbort);
|
|
45
|
+
if (signal.aborted) {
|
|
46
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
47
|
+
}
|
|
48
|
+
else if (code === 0) {
|
|
49
|
+
resolve();
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
reject(new Error(`ffmpeg exited with code ${code}`));
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
proc.on("error", (err) => {
|
|
56
|
+
signal.removeEventListener("abort", onAbort);
|
|
57
|
+
reject(err);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Run ffprobe and return parsed ProbeResult.
|
|
63
|
+
*/
|
|
64
|
+
export async function runProbe(filePath) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
execFile("ffprobe", [
|
|
67
|
+
"-v", "quiet",
|
|
68
|
+
"-print_format", "json",
|
|
69
|
+
"-show_format",
|
|
70
|
+
"-show_streams",
|
|
71
|
+
filePath,
|
|
72
|
+
], { maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
|
73
|
+
if (err) {
|
|
74
|
+
reject(new Error(`ffprobe failed for "${filePath}": ${err.message}`));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const raw = JSON.parse(stdout);
|
|
79
|
+
const result = ProbeResultSchema.parse(raw);
|
|
80
|
+
resolve(result);
|
|
81
|
+
}
|
|
82
|
+
catch (parseErr) {
|
|
83
|
+
reject(new Error(`ffprobe output parse failed for "${filePath}": ${parseErr}`));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { generateYouTubeThumbnail } from "./generate-thumbnail.js";
|
|
3
|
+
import { createInMemoryCRUD } from "./crud.js";
|
|
4
|
+
import { transcodeForYouTube } from "./transcode.js";
|
|
5
|
+
// ── Fixtures ──────────────────────────────────────────────────────
|
|
6
|
+
const GOOD_PROBE = {
|
|
7
|
+
streams: [
|
|
8
|
+
{
|
|
9
|
+
codec_name: "h264",
|
|
10
|
+
codec_type: "video",
|
|
11
|
+
width: 1920,
|
|
12
|
+
height: 1080,
|
|
13
|
+
pix_fmt: "yuv420p",
|
|
14
|
+
field_order: "progressive",
|
|
15
|
+
profile: "High",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
codec_name: "aac",
|
|
19
|
+
codec_type: "audio",
|
|
20
|
+
channels: 2,
|
|
21
|
+
sample_rate: "48000",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
format: {
|
|
25
|
+
filename: "/data/sandbox/user1/input.mp4",
|
|
26
|
+
format_name: "mov,mp4,m4a,3gp,3g2,mj2",
|
|
27
|
+
duration: "120.0",
|
|
28
|
+
size: "50000000",
|
|
29
|
+
bit_rate: "8000000",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
// ── Thumbnail tests ───────────────────────────────────────────────
|
|
33
|
+
describe("generateYouTubeThumbnail", () => {
|
|
34
|
+
it("happy path: generates dual thumbnails", async () => {
|
|
35
|
+
const mockExtract = vi.fn(async () => { });
|
|
36
|
+
const mockGenerate = vi.fn(async () => { });
|
|
37
|
+
const ctx = {
|
|
38
|
+
signal: new AbortController().signal,
|
|
39
|
+
userId: "user1",
|
|
40
|
+
tmpDir: "/data/sandbox/user1/tmp",
|
|
41
|
+
extractFrame: mockExtract,
|
|
42
|
+
generateStyledThumbnail: mockGenerate,
|
|
43
|
+
};
|
|
44
|
+
const result = await generateYouTubeThumbnail({
|
|
45
|
+
videoPath: "/data/sandbox/user1/output.mp4",
|
|
46
|
+
output: {
|
|
47
|
+
landscape: "/data/sandbox/user1/thumb_16x9.jpg",
|
|
48
|
+
portrait: "/data/sandbox/user1/thumb_4x5.jpg",
|
|
49
|
+
},
|
|
50
|
+
}, ctx);
|
|
51
|
+
expect(result.landscape).toBe("/data/sandbox/user1/thumb_16x9.jpg");
|
|
52
|
+
expect(result.portrait).toBe("/data/sandbox/user1/thumb_4x5.jpg");
|
|
53
|
+
// extractFrame called once (single temp still)
|
|
54
|
+
expect(mockExtract).toHaveBeenCalledTimes(1);
|
|
55
|
+
// generateStyledThumbnail called twice (landscape + portrait)
|
|
56
|
+
expect(mockGenerate).toHaveBeenCalledTimes(2);
|
|
57
|
+
// Verify dimensions: first call = 1280x720, second = 1080x1350
|
|
58
|
+
const call0 = mockGenerate.mock.calls[0];
|
|
59
|
+
expect(call0[2]).toBe(1280);
|
|
60
|
+
expect(call0[3]).toBe(720);
|
|
61
|
+
const call1 = mockGenerate.mock.calls[1];
|
|
62
|
+
expect(call1[2]).toBe(1080);
|
|
63
|
+
expect(call1[3]).toBe(1350);
|
|
64
|
+
});
|
|
65
|
+
it("passes overlay text through to generateStyledThumbnail", async () => {
|
|
66
|
+
const mockGenerate = vi.fn(async () => { });
|
|
67
|
+
const result = await generateYouTubeThumbnail({
|
|
68
|
+
videoPath: "/data/sandbox/user1/output.mp4",
|
|
69
|
+
output: {
|
|
70
|
+
landscape: "/data/sandbox/user1/thumb_16x9.jpg",
|
|
71
|
+
portrait: "/data/sandbox/user1/thumb_4x5.jpg",
|
|
72
|
+
},
|
|
73
|
+
overlayText: "MY TITLE",
|
|
74
|
+
}, {
|
|
75
|
+
signal: new AbortController().signal,
|
|
76
|
+
userId: "user1",
|
|
77
|
+
tmpDir: "/data/sandbox/user1/tmp",
|
|
78
|
+
extractFrame: vi.fn(async () => { }),
|
|
79
|
+
generateStyledThumbnail: mockGenerate,
|
|
80
|
+
});
|
|
81
|
+
// Both calls should receive overlay text
|
|
82
|
+
const c0 = mockGenerate.mock.calls[0];
|
|
83
|
+
const c1 = mockGenerate.mock.calls[1];
|
|
84
|
+
expect(c0[5]).toBe("MY TITLE");
|
|
85
|
+
expect(c1[5]).toBe("MY TITLE");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
// ── Transcode + thumbnail wiring test ─────────────────────────────
|
|
89
|
+
describe("transcode with thumbnail wiring", () => {
|
|
90
|
+
it("generates thumbnails when generateThumbnail is provided", async () => {
|
|
91
|
+
const notifications = [];
|
|
92
|
+
const crud = createInMemoryCRUD();
|
|
93
|
+
const mockThumbGen = vi.fn(async () => ({
|
|
94
|
+
landscape: "/data/sandbox/user1/output_thumb_16x9.jpg",
|
|
95
|
+
portrait: "/data/sandbox/user1/output_thumb_4x5.jpg",
|
|
96
|
+
}));
|
|
97
|
+
const ctx = {
|
|
98
|
+
signal: new AbortController().signal,
|
|
99
|
+
userId: "user1",
|
|
100
|
+
notify: (n) => notifications.push(n),
|
|
101
|
+
createAsset: async (a) => { await crud.create(a); },
|
|
102
|
+
runFfmpeg: vi.fn(async (_f, _s, onProgress) => {
|
|
103
|
+
onProgress(50);
|
|
104
|
+
onProgress(100);
|
|
105
|
+
}),
|
|
106
|
+
runProbe: vi.fn(async () => GOOD_PROBE),
|
|
107
|
+
generateThumbnail: mockThumbGen,
|
|
108
|
+
};
|
|
109
|
+
const asset = await transcodeForYouTube({
|
|
110
|
+
inputPath: "/data/sandbox/user1/input.mp4",
|
|
111
|
+
outputPath: "/data/sandbox/user1/output.mp4",
|
|
112
|
+
preset: "yt-1080p-h264",
|
|
113
|
+
}, ctx);
|
|
114
|
+
expect(asset.thumbnailPaths.landscape).toBe("/data/sandbox/user1/output_thumb_16x9.jpg");
|
|
115
|
+
expect(asset.thumbnailPaths.portrait).toBe("/data/sandbox/user1/output_thumb_4x5.jpg");
|
|
116
|
+
expect(mockThumbGen).toHaveBeenCalledTimes(1);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { extractFrame, generateStyledThumbnail } from "./thumbnail.js";
|
|
2
|
+
export interface ThumbnailResult {
|
|
3
|
+
landscape: string;
|
|
4
|
+
portrait: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ThumbnailContext {
|
|
7
|
+
signal: AbortSignal;
|
|
8
|
+
userId: string;
|
|
9
|
+
/** Temp directory for intermediate files. Defaults to os.tmpdir(). */
|
|
10
|
+
tmpDir?: string;
|
|
11
|
+
/** Injected for testability */
|
|
12
|
+
extractFrame?: typeof extractFrame;
|
|
13
|
+
generateStyledThumbnail?: typeof generateStyledThumbnail;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Generate dual YouTube thumbnails (16:9 landscape + 4:5 portrait).
|
|
17
|
+
*
|
|
18
|
+
* Pipeline: validate → extract frame → style + scale to both sizes →
|
|
19
|
+
* clamp each under 2 MB → cleanup temp still.
|
|
20
|
+
*/
|
|
21
|
+
export declare function generateYouTubeThumbnail(reqRaw: unknown, ctx: ThumbnailContext): Promise<ThumbnailResult>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { GenerateYouTubeThumbnailSchema, } from "./schemas.js";
|
|
4
|
+
import { validateSandboxPath } from "./preflight.js";
|
|
5
|
+
import { extractFrame, generateStyledThumbnail } from "./thumbnail.js";
|
|
6
|
+
/**
|
|
7
|
+
* Generate dual YouTube thumbnails (16:9 landscape + 4:5 portrait).
|
|
8
|
+
*
|
|
9
|
+
* Pipeline: validate → extract frame → style + scale to both sizes →
|
|
10
|
+
* clamp each under 2 MB → cleanup temp still.
|
|
11
|
+
*/
|
|
12
|
+
export async function generateYouTubeThumbnail(reqRaw, ctx) {
|
|
13
|
+
const req = GenerateYouTubeThumbnailSchema.parse(reqRaw);
|
|
14
|
+
validateSandboxPath(ctx.userId, req.videoPath);
|
|
15
|
+
validateSandboxPath(ctx.userId, req.output.landscape);
|
|
16
|
+
validateSandboxPath(ctx.userId, req.output.portrait);
|
|
17
|
+
const doExtract = ctx.extractFrame ?? extractFrame;
|
|
18
|
+
const doGenerate = ctx.generateStyledThumbnail ?? generateStyledThumbnail;
|
|
19
|
+
// Extract a single frame as temp PNG
|
|
20
|
+
const tmpDir = ctx.tmpDir ?? "/tmp";
|
|
21
|
+
const tempStill = `${tmpDir}/${randomUUID()}.png`;
|
|
22
|
+
try {
|
|
23
|
+
await doExtract(req.videoPath, req.timestamp, tempStill, ctx.signal);
|
|
24
|
+
// 16:9 landscape (1280×720) — always generated
|
|
25
|
+
await doGenerate(tempStill, req.output.landscape, 1280, 720, req.style, req.overlayText);
|
|
26
|
+
// 4:5 portrait (1080×1350) — always generated
|
|
27
|
+
await doGenerate(tempStill, req.output.portrait, 1080, 1350, req.style, req.overlayText);
|
|
28
|
+
return {
|
|
29
|
+
landscape: req.output.landscape,
|
|
30
|
+
portrait: req.output.portrait,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
// Cleanup temp still (best-effort)
|
|
35
|
+
try {
|
|
36
|
+
await unlink(tempStill);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// ignore — temp file cleanup is best-effort
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./schemas.js";
|
|
2
|
+
export * from "./preset-spec.js";
|
|
3
|
+
export * from "./preflight.js";
|
|
4
|
+
export * from "./types.js";
|
|
5
|
+
export * from "./build-flags.js";
|
|
6
|
+
export * from "./progress-parser.js";
|
|
7
|
+
export * from "./exec.js";
|
|
8
|
+
export * from "./transcode.js";
|
|
9
|
+
export * from "./thumbnail.js";
|
|
10
|
+
export * from "./generate-thumbnail.js";
|
|
11
|
+
export * from "./crud.js";
|
|
12
|
+
export * as pandoc from "./pandoc/index.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./schemas.js";
|
|
2
|
+
export * from "./preset-spec.js";
|
|
3
|
+
export * from "./preflight.js";
|
|
4
|
+
export * from "./types.js";
|
|
5
|
+
export * from "./build-flags.js";
|
|
6
|
+
export * from "./progress-parser.js";
|
|
7
|
+
export * from "./exec.js";
|
|
8
|
+
export * from "./transcode.js";
|
|
9
|
+
export * from "./thumbnail.js";
|
|
10
|
+
export * from "./generate-thumbnail.js";
|
|
11
|
+
export * from "./crud.js";
|
|
12
|
+
export * as pandoc from "./pandoc/index.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ConvertDocument } from "./schemas.js";
|
|
2
|
+
/**
|
|
3
|
+
* Build the full pandoc CLI argument array from a validated request.
|
|
4
|
+
*
|
|
5
|
+
* Order: base args → --from/--to → preset extras → optional overrides → -o output → input
|
|
6
|
+
* This is the Pandoc equivalent of the FFmpeg buildFlags function.
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildPandocArgs(req: ConvertDocument): string[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { PANDOC_PRESET_SPECS, buildPandocBaseArgs } from "./preset-spec.js";
|
|
2
|
+
// ── Pure arg builder — no I/O, fully deterministic ───────────────
|
|
3
|
+
/**
|
|
4
|
+
* Build the full pandoc CLI argument array from a validated request.
|
|
5
|
+
*
|
|
6
|
+
* Order: base args → --from/--to → preset extras → optional overrides → -o output → input
|
|
7
|
+
* This is the Pandoc equivalent of the FFmpeg buildFlags function.
|
|
8
|
+
*/
|
|
9
|
+
export function buildPandocArgs(req) {
|
|
10
|
+
const spec = PANDOC_PRESET_SPECS[req.preset];
|
|
11
|
+
const args = [
|
|
12
|
+
...buildPandocBaseArgs(),
|
|
13
|
+
"--from", spec.from,
|
|
14
|
+
"--to", spec.to,
|
|
15
|
+
...spec.extraArgs,
|
|
16
|
+
];
|
|
17
|
+
// ── Optional overrides from request ──────────────────────────
|
|
18
|
+
if (req.templatePath) {
|
|
19
|
+
args.push("--template", req.templatePath);
|
|
20
|
+
}
|
|
21
|
+
if (req.bibliographyPath) {
|
|
22
|
+
args.push("--bibliography", req.bibliographyPath);
|
|
23
|
+
}
|
|
24
|
+
if (req.cssPath) {
|
|
25
|
+
args.push("--css", req.cssPath);
|
|
26
|
+
}
|
|
27
|
+
// ── Output + input (must be last) ────────────────────────────
|
|
28
|
+
args.push("-o", req.outputPath);
|
|
29
|
+
args.push(req.inputPath);
|
|
30
|
+
return args;
|
|
31
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type PandocDocumentAsset } from "./schemas.js";
|
|
2
|
+
import { type PandocPresetSpec } from "./preset-spec.js";
|
|
3
|
+
import type { PandocNotification } from "./types.js";
|
|
4
|
+
import type { PandocInputCheck, PandocAssertionResult } from "./preflight.js";
|
|
5
|
+
/** Throw if signal is already aborted. */
|
|
6
|
+
export declare function throwIfAborted(signal: AbortSignal): void;
|
|
7
|
+
/** Type guard for AbortError from any source. */
|
|
8
|
+
export declare function isAbortError(err: unknown): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Context provided by the caller (MCP handler, CLI, etc).
|
|
11
|
+
* Keeps the pipeline free of global state and fully testable.
|
|
12
|
+
*/
|
|
13
|
+
export interface ConvertDocumentContext {
|
|
14
|
+
signal: AbortSignal;
|
|
15
|
+
notify: (notification: PandocNotification) => void;
|
|
16
|
+
userId: string;
|
|
17
|
+
createAsset: (asset: PandocDocumentAsset) => Promise<void>;
|
|
18
|
+
/** Injected so tests can mock without real pandoc binary */
|
|
19
|
+
runPandoc: (args: string[], signal: AbortSignal, onProgress: (percent: number) => void, estimatedSteps: number) => Promise<void>;
|
|
20
|
+
/** Injected preflight — async (stat-based) */
|
|
21
|
+
checkInput: (filePath: string) => Promise<PandocInputCheck>;
|
|
22
|
+
/** Injected postflight — async (stat-based) */
|
|
23
|
+
assertOutput: (spec: PandocPresetSpec, outputPath: string, maxOutputBytes: number) => Promise<PandocAssertionResult>;
|
|
24
|
+
/** Injected stat for measuring output size */
|
|
25
|
+
statFile: (filePath: string) => Promise<{
|
|
26
|
+
size: number;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Full Pandoc document conversion pipeline.
|
|
31
|
+
*
|
|
32
|
+
* 1. Validate (Zod parse + sandbox)
|
|
33
|
+
* 2. Preflight input check
|
|
34
|
+
* 3. Build args + run pandoc (with fallback loop for premium presets)
|
|
35
|
+
* 4. Postflight assertion
|
|
36
|
+
* 5. Create asset + notify ready
|
|
37
|
+
*/
|
|
38
|
+
export declare function convertDocument(reqRaw: unknown, ctx: ConvertDocumentContext): Promise<PandocDocumentAsset>;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { ConvertDocumentSchema, } from "./schemas.js";
|
|
3
|
+
import { PANDOC_PRESET_SPECS, } from "./preset-spec.js";
|
|
4
|
+
import { buildPandocArgs } from "./build-args.js";
|
|
5
|
+
import { ensureCorrectExtension, buildOutputMetadata, computeExpiresAt, } from "./output-polish.js";
|
|
6
|
+
// ── Abort helpers (same pattern as FFmpeg) ───────────────────────
|
|
7
|
+
/** Throw if signal is already aborted. */
|
|
8
|
+
export function throwIfAborted(signal) {
|
|
9
|
+
if (signal.aborted) {
|
|
10
|
+
throw new DOMException("Aborted", "AbortError");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Type guard for AbortError from any source. */
|
|
14
|
+
export function isAbortError(err) {
|
|
15
|
+
if (err instanceof DOMException && err.name === "AbortError")
|
|
16
|
+
return true;
|
|
17
|
+
if (err instanceof Error && err.name === "AbortError")
|
|
18
|
+
return true;
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
// ── Main pipeline ────────────────────────────────────────────────
|
|
22
|
+
/**
|
|
23
|
+
* Full Pandoc document conversion pipeline.
|
|
24
|
+
*
|
|
25
|
+
* 1. Validate (Zod parse + sandbox)
|
|
26
|
+
* 2. Preflight input check
|
|
27
|
+
* 3. Build args + run pandoc (with fallback loop for premium presets)
|
|
28
|
+
* 4. Postflight assertion
|
|
29
|
+
* 5. Create asset + notify ready
|
|
30
|
+
*/
|
|
31
|
+
export async function convertDocument(reqRaw, ctx) {
|
|
32
|
+
// ── 1. Validate ─────────────────────────────────────────────────
|
|
33
|
+
throwIfAborted(ctx.signal);
|
|
34
|
+
const req = ConvertDocumentSchema.parse(reqRaw);
|
|
35
|
+
const assetId = randomUUID();
|
|
36
|
+
// Sandbox validation (throws on escape — imported via preflight re-export)
|
|
37
|
+
const { validateSandboxPath } = await import("./preflight.js");
|
|
38
|
+
validateSandboxPath(ctx.userId, req.inputPath);
|
|
39
|
+
validateSandboxPath(ctx.userId, req.outputPath);
|
|
40
|
+
// ── 2. Preflight input check ────────────────────────────────────
|
|
41
|
+
throwIfAborted(ctx.signal);
|
|
42
|
+
const inputCheck = await ctx.checkInput(req.inputPath);
|
|
43
|
+
const allWarnings = [...inputCheck.warnings];
|
|
44
|
+
if (!inputCheck.ok) {
|
|
45
|
+
throw new Error(`Preflight failed: ${inputCheck.warnings.join("; ")}`);
|
|
46
|
+
}
|
|
47
|
+
// Check format compatibility
|
|
48
|
+
const { checkFormatCompatibility } = await import("./preflight.js");
|
|
49
|
+
const formatCheck = checkFormatCompatibility(inputCheck.detectedFormat, PANDOC_PRESET_SPECS[req.preset].from);
|
|
50
|
+
if (formatCheck.warning) {
|
|
51
|
+
allWarnings.push(formatCheck.warning);
|
|
52
|
+
}
|
|
53
|
+
// Check estimated output size (hard reject if over limit)
|
|
54
|
+
if (req.maxOutputBytes > 0) {
|
|
55
|
+
const { estimatePandocOutputBytes } = await import("./preflight.js");
|
|
56
|
+
const est = estimatePandocOutputBytes(inputCheck.sizeBytes, req.preset);
|
|
57
|
+
if (est > req.maxOutputBytes) {
|
|
58
|
+
throw new Error(`Estimated output ${est} bytes exceeds maxOutputBytes ${req.maxOutputBytes}.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const inputMetadata = {
|
|
62
|
+
format: inputCheck.detectedFormat,
|
|
63
|
+
sizeBytes: inputCheck.sizeBytes,
|
|
64
|
+
};
|
|
65
|
+
// ── 3. Convert with fallback loop ───────────────────────────────
|
|
66
|
+
let currentPreset = req.preset;
|
|
67
|
+
const initialSpec = PANDOC_PRESET_SPECS[currentPreset];
|
|
68
|
+
const maxAttempts = initialSpec.isPremium && initialSpec.fallbackTo ? 2 : 1;
|
|
69
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
70
|
+
throwIfAborted(ctx.signal);
|
|
71
|
+
const spec = PANDOC_PRESET_SPECS[currentPreset];
|
|
72
|
+
const args = buildPandocArgs({ ...req, preset: currentPreset });
|
|
73
|
+
// ── Run pandoc (catch errors for fallback) ──────────────────
|
|
74
|
+
let pandocFailed = false;
|
|
75
|
+
try {
|
|
76
|
+
await ctx.runPandoc(args, ctx.signal, (percent) => {
|
|
77
|
+
ctx.notify({
|
|
78
|
+
type: "pandoc:progress",
|
|
79
|
+
assetId,
|
|
80
|
+
percent,
|
|
81
|
+
preset: currentPreset,
|
|
82
|
+
});
|
|
83
|
+
}, spec.estimatedSteps);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
// AbortError always rethrows — no fallback
|
|
87
|
+
if (isAbortError(err))
|
|
88
|
+
throw err;
|
|
89
|
+
if (!spec.fallbackTo || attempt + 1 >= maxAttempts)
|
|
90
|
+
throw err;
|
|
91
|
+
allWarnings.push(`pandoc failed for ${currentPreset}: ${err instanceof Error ? err.message : String(err)}`);
|
|
92
|
+
pandocFailed = true;
|
|
93
|
+
}
|
|
94
|
+
if (!pandocFailed) {
|
|
95
|
+
// ── 4. Postflight assertion ─────────────────────────────────
|
|
96
|
+
throwIfAborted(ctx.signal);
|
|
97
|
+
const assertResult = await ctx.assertOutput(spec, req.outputPath, req.maxOutputBytes);
|
|
98
|
+
if (assertResult.ok) {
|
|
99
|
+
allWarnings.push(...assertResult.warnings);
|
|
100
|
+
// Final 100% progress before asset creation
|
|
101
|
+
ctx.notify({
|
|
102
|
+
type: "pandoc:progress",
|
|
103
|
+
assetId,
|
|
104
|
+
percent: 100,
|
|
105
|
+
preset: currentPreset,
|
|
106
|
+
});
|
|
107
|
+
// One last abort check before creating the asset
|
|
108
|
+
throwIfAborted(ctx.signal);
|
|
109
|
+
return buildAndNotifyAsset(assetId, req, inputMetadata, currentPreset, allWarnings, ctx);
|
|
110
|
+
}
|
|
111
|
+
// Assertion failed
|
|
112
|
+
allWarnings.push(...assertResult.warnings);
|
|
113
|
+
}
|
|
114
|
+
// ── Fallback to guaranteed preset ─────────────────────────────
|
|
115
|
+
if (spec.fallbackTo && attempt + 1 < maxAttempts) {
|
|
116
|
+
ctx.notify({
|
|
117
|
+
type: "pandoc:warning",
|
|
118
|
+
assetId,
|
|
119
|
+
warnings: [
|
|
120
|
+
`${pandocFailed ? "Conversion" : "Assertion"} failed for ${currentPreset}, ` +
|
|
121
|
+
`falling back to ${spec.fallbackTo}.`,
|
|
122
|
+
],
|
|
123
|
+
preset: currentPreset,
|
|
124
|
+
});
|
|
125
|
+
currentPreset = spec.fallbackTo;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// No more fallbacks — if pandoc failed, the error was already thrown above
|
|
129
|
+
// Pandoc succeeded but assertion failed — return with warnings
|
|
130
|
+
throwIfAborted(ctx.signal);
|
|
131
|
+
return buildAndNotifyAsset(assetId, req, inputMetadata, currentPreset, allWarnings, ctx);
|
|
132
|
+
}
|
|
133
|
+
throw new Error("Convert loop exhausted without result.");
|
|
134
|
+
}
|
|
135
|
+
// ── Helper: build asset + fire notifications ─────────────────────
|
|
136
|
+
async function buildAndNotifyAsset(assetId, req, inputMetadata, preset, warnings, ctx) {
|
|
137
|
+
const spec = PANDOC_PRESET_SPECS[preset];
|
|
138
|
+
// ── Output polish ─────────────────────────────────────────────
|
|
139
|
+
const finalPath = ensureCorrectExtension(req.outputPath, spec);
|
|
140
|
+
if (finalPath !== req.outputPath) {
|
|
141
|
+
warnings.push(`Output path auto-corrected from "${req.outputPath}" to "${finalPath}".`);
|
|
142
|
+
}
|
|
143
|
+
// Measure output
|
|
144
|
+
let outputSizeBytes = 0;
|
|
145
|
+
try {
|
|
146
|
+
const s = await ctx.statFile(finalPath);
|
|
147
|
+
outputSizeBytes = s.size;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
warnings.push("Could not stat output file for metadata.");
|
|
151
|
+
}
|
|
152
|
+
const outputMetadata = buildOutputMetadata(spec, outputSizeBytes);
|
|
153
|
+
const asset = {
|
|
154
|
+
id: assetId,
|
|
155
|
+
inputPath: req.inputPath,
|
|
156
|
+
outputPath: finalPath,
|
|
157
|
+
preset,
|
|
158
|
+
inputMetadata,
|
|
159
|
+
outputMetadata,
|
|
160
|
+
warnings,
|
|
161
|
+
expiresAt: computeExpiresAt(),
|
|
162
|
+
};
|
|
163
|
+
await ctx.createAsset(asset);
|
|
164
|
+
ctx.notify({
|
|
165
|
+
type: "pandoc:ready",
|
|
166
|
+
assetId,
|
|
167
|
+
outputPath: finalPath,
|
|
168
|
+
preset,
|
|
169
|
+
sizeBytes: outputSizeBytes,
|
|
170
|
+
});
|
|
171
|
+
return asset;
|
|
172
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PandocDocumentCRUD } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Create an in-memory CRUD store for Pandoc document assets.
|
|
4
|
+
* Optionally persists to a JSON file on every write for dev/debug use.
|
|
5
|
+
*
|
|
6
|
+
* Matches the exact factory pattern from FFmpeg's createInMemoryCRUD.
|
|
7
|
+
*
|
|
8
|
+
* @param persistPath - optional path to a JSON file for persistence
|
|
9
|
+
*/
|
|
10
|
+
export declare function createPandocCRUD(persistPath?: string): PandocDocumentCRUD;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// ── In-memory Pandoc CRUD ────────────────────────────────────────
|
|
4
|
+
/**
|
|
5
|
+
* Create an in-memory CRUD store for Pandoc document assets.
|
|
6
|
+
* Optionally persists to a JSON file on every write for dev/debug use.
|
|
7
|
+
*
|
|
8
|
+
* Matches the exact factory pattern from FFmpeg's createInMemoryCRUD.
|
|
9
|
+
*
|
|
10
|
+
* @param persistPath - optional path to a JSON file for persistence
|
|
11
|
+
*/
|
|
12
|
+
export function createPandocCRUD(persistPath) {
|
|
13
|
+
const store = new Map();
|
|
14
|
+
async function persist() {
|
|
15
|
+
if (!persistPath)
|
|
16
|
+
return;
|
|
17
|
+
await mkdir(path.dirname(persistPath), { recursive: true });
|
|
18
|
+
const data = JSON.stringify(Array.from(store.values()), null, 2);
|
|
19
|
+
await writeFile(persistPath, data, "utf8");
|
|
20
|
+
}
|
|
21
|
+
async function hydrate() {
|
|
22
|
+
if (!persistPath)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
const data = await readFile(persistPath, "utf8");
|
|
26
|
+
const items = JSON.parse(data);
|
|
27
|
+
for (const item of items) {
|
|
28
|
+
store.set(item.id, item);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// File doesn't exist yet — start fresh
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Hydrate on first access (lazy)
|
|
36
|
+
let hydrated = false;
|
|
37
|
+
async function ensureHydrated() {
|
|
38
|
+
if (!hydrated) {
|
|
39
|
+
await hydrate();
|
|
40
|
+
hydrated = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
async create(asset) {
|
|
45
|
+
await ensureHydrated();
|
|
46
|
+
store.set(asset.id, asset);
|
|
47
|
+
await persist();
|
|
48
|
+
return asset;
|
|
49
|
+
},
|
|
50
|
+
async read(id) {
|
|
51
|
+
await ensureHydrated();
|
|
52
|
+
return store.get(id) ?? null;
|
|
53
|
+
},
|
|
54
|
+
async list(filter) {
|
|
55
|
+
await ensureHydrated();
|
|
56
|
+
const all = Array.from(store.values());
|
|
57
|
+
if (filter?.preset) {
|
|
58
|
+
return all.filter((a) => a.preset === filter.preset);
|
|
59
|
+
}
|
|
60
|
+
return all;
|
|
61
|
+
},
|
|
62
|
+
async update(id, patch) {
|
|
63
|
+
await ensureHydrated();
|
|
64
|
+
const existing = store.get(id);
|
|
65
|
+
if (!existing)
|
|
66
|
+
throw new Error(`Pandoc asset ${id} not found.`);
|
|
67
|
+
const updated = { ...existing, ...patch, id }; // id is immutable
|
|
68
|
+
store.set(id, updated);
|
|
69
|
+
await persist();
|
|
70
|
+
return updated;
|
|
71
|
+
},
|
|
72
|
+
async delete(id) {
|
|
73
|
+
await ensureHydrated();
|
|
74
|
+
if (!store.delete(id)) {
|
|
75
|
+
throw new Error(`Pandoc asset ${id} not found.`);
|
|
76
|
+
}
|
|
77
|
+
await persist();
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|