@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/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
+ }
@@ -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 {};