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