@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/smoke.mjs ADDED
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Smoke test: runs the full pipeline with mocked ffmpeg/ffprobe.
5
+ * No real binaries needed — validates the wiring end-to-end.
6
+ *
7
+ * Usage: node smoke.mjs
8
+ */
9
+
10
+ import { existsSync } from "node:fs";
11
+ import {
12
+ transcodeForYouTube,
13
+ createInMemoryCRUD,
14
+ generateYouTubeThumbnail,
15
+ pandoc,
16
+ } from "./dist/index.js";
17
+
18
+ const MOCK_PROBE = {
19
+ streams: [
20
+ {
21
+ codec_name: "h264",
22
+ codec_type: "video",
23
+ width: 1920,
24
+ height: 1080,
25
+ pix_fmt: "yuv420p",
26
+ field_order: "progressive",
27
+ profile: "High",
28
+ },
29
+ {
30
+ codec_name: "aac",
31
+ codec_type: "audio",
32
+ channels: 2,
33
+ sample_rate: "48000",
34
+ },
35
+ ],
36
+ format: {
37
+ filename: "/data/sandbox/smoke/input.mp4",
38
+ format_name: "mov,mp4,m4a,3gp,3g2,mj2",
39
+ duration: "15.0",
40
+ size: "5000000",
41
+ bit_rate: "2666666",
42
+ },
43
+ };
44
+
45
+ async function main() {
46
+ console.log("=== ToolShopStudio Smoke Test ===\n");
47
+
48
+ const crud = createInMemoryCRUD();
49
+ const notifications = [];
50
+ const ac = new AbortController();
51
+
52
+ // ── Test 1: SDR 1080p transcode ──────────────────────────────
53
+ console.log("[1] SDR 1080p transcode...");
54
+ const asset = await transcodeForYouTube(
55
+ {
56
+ inputPath: "/data/sandbox/smoke/input.mp4",
57
+ outputPath: "/data/sandbox/smoke/output.mp4",
58
+ preset: "yt-1080p-h264",
59
+ allowFallback: true,
60
+ timeoutSeconds: 60,
61
+ },
62
+ {
63
+ signal: ac.signal,
64
+ userId: "smoke",
65
+ notify: (n) => notifications.push(n),
66
+ createAsset: (a) => crud.create(a),
67
+ runFfmpeg: async (_flags, _signal, onProgress) => {
68
+ onProgress(25);
69
+ onProgress(50);
70
+ onProgress(75);
71
+ onProgress(100);
72
+ },
73
+ runProbe: async () => MOCK_PROBE,
74
+ },
75
+ );
76
+
77
+ console.log(` Asset ID: ${asset.id}`);
78
+ console.log(` Warnings: ${asset.warnings.length}`);
79
+ console.log(` Notifications: ${notifications.length}`);
80
+
81
+ // Verify CRUD
82
+ const stored = await crud.read(asset.id);
83
+ if (!stored) throw new Error("CRUD read failed");
84
+ console.log(" CRUD read: OK");
85
+
86
+ const listed = await crud.list();
87
+ if (listed.length !== 1) throw new Error("CRUD list failed");
88
+ console.log(" CRUD list: OK");
89
+
90
+ // ── Test 2: Shorts preset ──────────────────────────────────
91
+ console.log("\n[2] Shorts H264 transcode...");
92
+ const shortsAsset = await transcodeForYouTube(
93
+ {
94
+ inputPath: "/data/sandbox/smoke/short.mp4",
95
+ outputPath: "/data/sandbox/smoke/short_out.mp4",
96
+ preset: "yt-shorts-h264",
97
+ },
98
+ {
99
+ signal: ac.signal,
100
+ userId: "smoke",
101
+ notify: () => {},
102
+ createAsset: (a) => crud.create(a),
103
+ runFfmpeg: async (_flags, _signal, onProgress) => {
104
+ onProgress(100);
105
+ },
106
+ runProbe: async () => MOCK_PROBE,
107
+ },
108
+ );
109
+ console.log(` Asset ID: ${shortsAsset.id}`);
110
+ console.log(" OK");
111
+
112
+ // ── Test 3: Verify ready notification shape ──────────────────
113
+ console.log("\n[3] Verify notification shape...");
114
+ const ready = notifications.find((n) => n.type === "youtube:ready");
115
+ if (!ready) throw new Error("No youtube:ready notification");
116
+ if (ready.type !== "youtube:ready") throw new Error("Wrong type");
117
+ if (!ready.assetId) throw new Error("Missing assetId");
118
+ if (!ready.outputPath) throw new Error("Missing outputPath");
119
+ console.log(" youtube:ready shape: OK");
120
+
121
+ // ── Test 4: Verify logo asset exists ────────────────────────
122
+ console.log("\n[4] Verify logo asset...");
123
+ if (!existsSync("assets/logo.png")) throw new Error("assets/logo.png missing");
124
+ console.log(" assets/logo.png: OK");
125
+
126
+ // ── Test 5: Pandoc blog-post conversion ──────────────────────
127
+ console.log("\n[5] Pandoc blog-post conversion...");
128
+ const pandocCrud = pandoc.createPandocCRUD();
129
+ const pandocNotifications = [];
130
+
131
+ const pandocAsset = await pandoc.convertDocument(
132
+ {
133
+ inputPath: "/data/sandbox/smoke/doc.md",
134
+ outputPath: "/data/sandbox/smoke/doc.html",
135
+ preset: "blog-post",
136
+ },
137
+ {
138
+ signal: ac.signal,
139
+ userId: "smoke",
140
+ notify: (n) => pandocNotifications.push(n),
141
+ createAsset: (a) => pandocCrud.create(a),
142
+ runPandoc: async (_args, _signal, onProgress) => {
143
+ onProgress(25);
144
+ onProgress(50);
145
+ onProgress(99);
146
+ },
147
+ checkInput: async () => ({
148
+ ok: true,
149
+ warnings: [],
150
+ sizeBytes: 2048,
151
+ detectedFormat: "markdown",
152
+ }),
153
+ assertOutput: async () => ({ ok: true, warnings: [] }),
154
+ statFile: async () => ({ size: 4096 }),
155
+ },
156
+ );
157
+
158
+ console.log(` Asset ID: ${pandocAsset.id}`);
159
+ console.log(` Preset: ${pandocAsset.preset}`);
160
+ console.log(` Output format: ${pandocAsset.outputMetadata.format}`);
161
+ console.log(` Output size: ${pandocAsset.outputMetadata.sizeBytes}`);
162
+
163
+ // Verify Pandoc CRUD
164
+ const pandocStored = await pandocCrud.read(pandocAsset.id);
165
+ if (!pandocStored) throw new Error("Pandoc CRUD read failed");
166
+ console.log(" Pandoc CRUD read: OK");
167
+
168
+ // Verify Pandoc ready notification
169
+ const pandocReady = pandocNotifications.find((n) => n.type === "pandoc:ready");
170
+ if (!pandocReady) throw new Error("No pandoc:ready notification");
171
+ if (!pandocReady.assetId) throw new Error("Missing pandoc assetId");
172
+ console.log(" pandoc:ready shape: OK");
173
+
174
+ // ── Test 6: Pandoc academic-pdf conversion ─────────────────
175
+ console.log("\n[6] Pandoc academic-pdf conversion...");
176
+ const pdfAsset = await pandoc.convertDocument(
177
+ {
178
+ inputPath: "/data/sandbox/smoke/thesis.md",
179
+ outputPath: "/data/sandbox/smoke/thesis.pdf",
180
+ preset: "academic-pdf",
181
+ },
182
+ {
183
+ signal: ac.signal,
184
+ userId: "smoke",
185
+ notify: () => {},
186
+ createAsset: (a) => pandocCrud.create(a),
187
+ runPandoc: async (_args, _signal, onProgress) => {
188
+ onProgress(99);
189
+ },
190
+ checkInput: async () => ({
191
+ ok: true,
192
+ warnings: [],
193
+ sizeBytes: 10240,
194
+ detectedFormat: "markdown",
195
+ }),
196
+ assertOutput: async () => ({ ok: true, warnings: [] }),
197
+ statFile: async () => ({ size: 8192 }),
198
+ },
199
+ );
200
+ console.log(` Asset ID: ${pdfAsset.id}`);
201
+ console.log(` Output format: ${pdfAsset.outputMetadata.format}`);
202
+ console.log(" OK");
203
+
204
+ // Verify both Pandoc assets in CRUD
205
+ const allPandocAssets = await pandocCrud.list();
206
+ if (allPandocAssets.length !== 2) throw new Error(`Expected 2 Pandoc assets, got ${allPandocAssets.length}`);
207
+ console.log(" Pandoc CRUD list (2 assets): OK");
208
+
209
+ // ── Summary ──────────────────────────────────────────────────
210
+ const allAssets = await crud.list();
211
+ console.log(`\n=== Smoke Test PASSED ===`);
212
+ console.log(` FFmpeg assets: ${allAssets.length}`);
213
+ console.log(` Pandoc assets: ${allPandocAssets.length}`);
214
+ console.log(` FFmpeg notifications: ${notifications.length}`);
215
+ console.log(` Pandoc notifications: ${pandocNotifications.length}`);
216
+ }
217
+
218
+ main().catch((err) => {
219
+ console.error("\n=== Smoke Test FAILED ===");
220
+ console.error(err);
221
+ process.exit(1);
222
+ });
@@ -0,0 +1,148 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`buildFlags > hdr_pq_4k: produces correct flags for yt-4k-hdr-h265 1`] = `
4
+ [
5
+ "-i",
6
+ "/sandbox/user1/hdr_input.mp4",
7
+ "-movflags",
8
+ "+faststart",
9
+ "-avoid_negative_ts",
10
+ "make_zero",
11
+ "-fflags",
12
+ "+genpts",
13
+ "-video_track_timescale",
14
+ "90000",
15
+ "-g",
16
+ "60",
17
+ "-keyint_min",
18
+ "60",
19
+ "-c:a",
20
+ "aac",
21
+ "-ar",
22
+ "48000",
23
+ "-ac",
24
+ "2",
25
+ "-b:a",
26
+ "192k",
27
+ "-vf",
28
+ "scale=3840:2160:force_original_aspect_ratio=decrease,pad=3840:2160:(ow-iw)/2:(oh-ih)/2",
29
+ "-c:v",
30
+ "libx265",
31
+ "-pix_fmt",
32
+ "yuv420p10le",
33
+ "-profile:v",
34
+ "main10",
35
+ "-crf",
36
+ "18",
37
+ "-maxrate",
38
+ "40M",
39
+ "-bufsize",
40
+ "80M",
41
+ "-color_primaries",
42
+ "bt2020",
43
+ "-color_trc",
44
+ "smpte2084",
45
+ "-colorspace",
46
+ "bt2020nc",
47
+ "-x265-params",
48
+ "open-gop=0:scenecut=0",
49
+ "-progress",
50
+ "pipe:2",
51
+ "-y",
52
+ "/sandbox/user1/hdr_output.mp4",
53
+ ]
54
+ `;
55
+
56
+ exports[`buildFlags > sdr_1080p: produces correct flags for yt-1080p-h264 1`] = `
57
+ [
58
+ "-i",
59
+ "/sandbox/user1/input.mp4",
60
+ "-movflags",
61
+ "+faststart",
62
+ "-avoid_negative_ts",
63
+ "make_zero",
64
+ "-fflags",
65
+ "+genpts",
66
+ "-video_track_timescale",
67
+ "90000",
68
+ "-g",
69
+ "60",
70
+ "-keyint_min",
71
+ "60",
72
+ "-c:a",
73
+ "aac",
74
+ "-ar",
75
+ "48000",
76
+ "-ac",
77
+ "2",
78
+ "-b:a",
79
+ "192k",
80
+ "-vf",
81
+ "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2",
82
+ "-c:v",
83
+ "libx264",
84
+ "-pix_fmt",
85
+ "yuv420p",
86
+ "-profile:v",
87
+ "high",
88
+ "-crf",
89
+ "18",
90
+ "-maxrate",
91
+ "12M",
92
+ "-bufsize",
93
+ "24M",
94
+ "-x264-params",
95
+ "keyint=60:min-keyint=60:scenecut=0",
96
+ "-progress",
97
+ "pipe:2",
98
+ "-y",
99
+ "/sandbox/user1/output.mp4",
100
+ ]
101
+ `;
102
+
103
+ exports[`buildFlags > shorts_sdr_1080x1920: produces correct flags for yt-shorts-h264 1`] = `
104
+ [
105
+ "-i",
106
+ "/sandbox/user1/short.mp4",
107
+ "-movflags",
108
+ "+faststart",
109
+ "-avoid_negative_ts",
110
+ "make_zero",
111
+ "-fflags",
112
+ "+genpts",
113
+ "-video_track_timescale",
114
+ "90000",
115
+ "-g",
116
+ "60",
117
+ "-keyint_min",
118
+ "60",
119
+ "-c:a",
120
+ "aac",
121
+ "-ar",
122
+ "48000",
123
+ "-ac",
124
+ "2",
125
+ "-b:a",
126
+ "192k",
127
+ "-vf",
128
+ "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2",
129
+ "-c:v",
130
+ "libx264",
131
+ "-pix_fmt",
132
+ "yuv420p",
133
+ "-profile:v",
134
+ "high",
135
+ "-crf",
136
+ "18",
137
+ "-maxrate",
138
+ "12M",
139
+ "-bufsize",
140
+ "24M",
141
+ "-x264-params",
142
+ "keyint=60:min-keyint=60:scenecut=0",
143
+ "-progress",
144
+ "pipe:2",
145
+ "-y",
146
+ "/sandbox/user1/short_out.mp4",
147
+ ]
148
+ `;
@@ -0,0 +1,124 @@
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
+
9
+ import type { TranscodeForYouTube, ProbeResult } from "./schemas.js";
10
+ import { PRESET_SPECS, buildBaseFlags } from "./preset-spec.js";
11
+
12
+ /**
13
+ * Build the complete ffmpeg flag array for a YouTube-safe transcode.
14
+ *
15
+ * Order: input → base flags → video filter → codec/pixFmt/profile/crf/maxrate →
16
+ * HDR tags → customBitrate override → closed-GOP params → -y output
17
+ */
18
+ export function buildFlags(
19
+ req: TranscodeForYouTube,
20
+ probe: ProbeResult,
21
+ ): string[] {
22
+ const spec = PRESET_SPECS[req.preset];
23
+ const flags: string[] = [];
24
+
25
+ // ── Input ───────────────────────────────────────────────────────
26
+ flags.push("-i", req.inputPath);
27
+
28
+ // ── Base invariants ─────────────────────────────────────────────
29
+ flags.push(...buildBaseFlags());
30
+
31
+ // ── Video filter (orientation-aware) ────────────────────────────
32
+ flags.push("-vf", resolveVf(spec.vf, req.orientationHint, probe));
33
+
34
+ // ── Codec, pixel format, profile, CRF, rate control ─────────────
35
+ flags.push("-c:v", spec.codec);
36
+ flags.push("-pix_fmt", spec.pixFmt);
37
+ flags.push("-profile:v", spec.profile);
38
+ flags.push("-crf", String(spec.crf));
39
+ flags.push("-maxrate", spec.maxrate);
40
+ flags.push("-bufsize", spec.bufsize);
41
+
42
+ // ── HDR tags (only for HDR presets) ─────────────────────────────
43
+ if (spec.hdrTags) {
44
+ for (const [key, value] of Object.entries(spec.hdrTags)) {
45
+ flags.push(`-${key}`, value);
46
+ }
47
+ }
48
+
49
+ // ── Custom bitrate override ─────────────────────────────────────
50
+ if (req.customBitrate?.videoMbps) {
51
+ flags.push("-b:v", `${req.customBitrate.videoMbps}M`);
52
+ }
53
+ if (req.customBitrate?.audioBitrate) {
54
+ flags.push("-b:a", req.customBitrate.audioBitrate);
55
+ }
56
+
57
+ // ── Closed-GOP encoder params ───────────────────────────────────
58
+ if (spec.codec === "libx264" && spec.x264Params) {
59
+ flags.push("-x264-params", spec.x264Params);
60
+ }
61
+ if (spec.codec === "libx265" && spec.x265Params) {
62
+ flags.push("-x265-params", spec.x265Params);
63
+ }
64
+
65
+ // ── Progress pipe (stderr key=value parsing) ────────────────────
66
+ flags.push("-progress", "pipe:2");
67
+
68
+ // ── Output (overwrite) ──────────────────────────────────────────
69
+ flags.push("-y", req.outputPath);
70
+
71
+ return flags;
72
+ }
73
+
74
+ // ── Helpers ─────────────────────────────────────────────────────────
75
+
76
+ /**
77
+ * Resolve the video filter string. If an orientationHint is given
78
+ * and differs from the spec's default, swap width/height in the
79
+ * scale+pad chain. Otherwise use the spec's vf as-is.
80
+ */
81
+ function resolveVf(
82
+ specVf: string,
83
+ orientationHint: TranscodeForYouTube["orientationHint"],
84
+ _probe: ProbeResult,
85
+ ): string {
86
+ if (!orientationHint) return specVf;
87
+
88
+ // Parse the spec's scale dimensions from the vf string
89
+ const scaleMatch = specVf.match(
90
+ /scale=(\d+):(\d+):force_original_aspect_ratio=decrease,pad=(\d+):(\d+)/,
91
+ );
92
+ if (!scaleMatch) return specVf;
93
+
94
+ const [, sw, sh, pw, ph] = scaleMatch;
95
+ const specW = Number(sw);
96
+ const specH = Number(sh);
97
+ const specIsLandscape = specW > specH;
98
+
99
+ const wantLandscape = orientationHint === "landscape";
100
+ const wantPortrait = orientationHint === "portrait";
101
+ const wantSquare = orientationHint === "square";
102
+
103
+ // If hint matches spec orientation, no change needed
104
+ if ((wantLandscape && specIsLandscape) || (wantPortrait && !specIsLandscape)) {
105
+ return specVf;
106
+ }
107
+
108
+ // Swap dimensions for orientation mismatch
109
+ if ((wantPortrait && specIsLandscape) || (wantLandscape && !specIsLandscape)) {
110
+ return specVf
111
+ .replace(`scale=${sw}:${sh}`, `scale=${sh}:${sw}`)
112
+ .replace(`pad=${pw}:${ph}`, `pad=${ph}:${pw}`);
113
+ }
114
+
115
+ // Square: use min dimension for both
116
+ if (wantSquare) {
117
+ const dim = Math.min(specW, specH);
118
+ return specVf
119
+ .replace(`scale=${sw}:${sh}`, `scale=${dim}:${dim}`)
120
+ .replace(`pad=${pw}:${ph}`, `pad=${dim}:${dim}`);
121
+ }
122
+
123
+ return specVf;
124
+ }
package/src/crud.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { YouTubeMediaAsset } from "./schemas.js";
4
+ import type { YouTubeMediaAssetCRUD } from "./types.js";
5
+
6
+ // ── In-memory CRUD ────────────────────────────────────────────────
7
+
8
+ /**
9
+ * Create an in-memory CRUD store. Optionally persists to a JSON file
10
+ * on every write for dev/debug use.
11
+ *
12
+ * @param persistPath - optional path to a JSON file for persistence
13
+ */
14
+ export function createInMemoryCRUD(
15
+ persistPath?: string,
16
+ ): YouTubeMediaAssetCRUD {
17
+ const store = new Map<string, YouTubeMediaAsset>();
18
+
19
+ async function persist(): Promise<void> {
20
+ if (!persistPath) return;
21
+ await mkdir(path.dirname(persistPath), { recursive: true });
22
+ const data = JSON.stringify(Array.from(store.values()), null, 2);
23
+ await writeFile(persistPath, data, "utf8");
24
+ }
25
+
26
+ async function hydrate(): Promise<void> {
27
+ if (!persistPath) return;
28
+ try {
29
+ const data = await readFile(persistPath, "utf8");
30
+ const items: YouTubeMediaAsset[] = JSON.parse(data);
31
+ for (const item of items) {
32
+ store.set(item.id, item);
33
+ }
34
+ } catch {
35
+ // File doesn't exist yet — start fresh
36
+ }
37
+ }
38
+
39
+ // Hydrate on first access (lazy)
40
+ let hydrated = false;
41
+ async function ensureHydrated(): Promise<void> {
42
+ if (!hydrated) {
43
+ await hydrate();
44
+ hydrated = true;
45
+ }
46
+ }
47
+
48
+ return {
49
+ async create(asset: YouTubeMediaAsset): Promise<YouTubeMediaAsset> {
50
+ await ensureHydrated();
51
+ store.set(asset.id, asset);
52
+ await persist();
53
+ return asset;
54
+ },
55
+
56
+ async read(id: string): Promise<YouTubeMediaAsset | null> {
57
+ await ensureHydrated();
58
+ return store.get(id) ?? null;
59
+ },
60
+
61
+ async list(filter?: { userId?: string }): Promise<YouTubeMediaAsset[]> {
62
+ await ensureHydrated();
63
+ const all = Array.from(store.values());
64
+ // userId filter is a no-op for now (assets don't store userId)
65
+ return filter?.userId ? all : all;
66
+ },
67
+
68
+ async update(
69
+ id: string,
70
+ patch: Partial<YouTubeMediaAsset>,
71
+ ): Promise<YouTubeMediaAsset> {
72
+ await ensureHydrated();
73
+ const existing = store.get(id);
74
+ if (!existing) throw new Error(`Asset ${id} not found.`);
75
+ const updated = { ...existing, ...patch, id }; // id is immutable
76
+ store.set(id, updated);
77
+ await persist();
78
+ return updated;
79
+ },
80
+
81
+ async delete(id: string): Promise<void> {
82
+ await ensureHydrated();
83
+ if (!store.delete(id)) {
84
+ throw new Error(`Asset ${id} not found.`);
85
+ }
86
+ await persist();
87
+ },
88
+ };
89
+ }