@hadialmarzooq/agent-media-ffmpeg 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hadi Almarzooq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,114 @@
1
+ import { MediaMetadata, FfmpegCapabilities, MediaPlan, VerificationReport } from '@hadialmarzooq/agent-media-core';
2
+
3
+ interface FfmpegOptions {
4
+ ffmpegPath?: string;
5
+ ffprobePath?: string;
6
+ timeoutMs?: number;
7
+ }
8
+ declare function inspectMedia(input: string, options?: FfmpegOptions): Promise<MediaMetadata>;
9
+
10
+ declare function getCapabilities(options?: FfmpegOptions): Promise<FfmpegCapabilities>;
11
+
12
+ interface CompiledOperation {
13
+ executable: string;
14
+ args: string[];
15
+ }
16
+ /** Compile semantic Media IR into a deterministic FFmpeg invocation. */
17
+ declare function compilePlan(plan: MediaPlan, source: MediaMetadata, output: string): CompiledOperation;
18
+ declare function extensionForPlan(plan: MediaPlan): string;
19
+
20
+ type MediaProgressPhase = 'inspecting' | 'planning' | 'executing' | 'verifying' | 'completed';
21
+ /** A monotonic progress event safe to surface through SDK, CLI, and MCP adapters. */
22
+ interface MediaProgress {
23
+ phase: MediaProgressPhase;
24
+ percent: number;
25
+ message: string;
26
+ processedSeconds?: number;
27
+ totalSeconds?: number;
28
+ speed?: number;
29
+ }
30
+ type ProgressCallback = (progress: MediaProgress) => void;
31
+
32
+ interface ExecuteOptions extends FfmpegOptions {
33
+ output: string;
34
+ sourceMetadata?: MediaMetadata;
35
+ overwrite?: boolean;
36
+ allowedOutputDirectory?: string;
37
+ signal?: AbortSignal;
38
+ onProgress?: ProgressCallback;
39
+ }
40
+ interface ExecutionResult {
41
+ output: string;
42
+ operation: CompiledOperation;
43
+ }
44
+ declare function executePlan(planInput: MediaPlan, options: ExecuteOptions): Promise<ExecutionResult>;
45
+
46
+ interface WorkflowOptions extends FfmpegOptions {
47
+ input: string;
48
+ output: string;
49
+ overwrite?: boolean;
50
+ allowedOutputDirectory?: string;
51
+ signal?: AbortSignal;
52
+ onProgress?: ProgressCallback;
53
+ }
54
+ interface MakeVerticalOptions extends WorkflowOptions {
55
+ width?: number;
56
+ height?: number;
57
+ trimStartSeconds?: number;
58
+ durationSeconds?: number;
59
+ maxSizeMB?: number;
60
+ audio?: 'preserve' | 'remove';
61
+ }
62
+ interface OptimizeForWebOptions extends WorkflowOptions {
63
+ trimStartSeconds?: number;
64
+ durationSeconds?: number;
65
+ maxSizeMB?: number;
66
+ audio?: 'preserve' | 'remove';
67
+ quality?: 'high' | 'balanced' | 'small';
68
+ }
69
+ interface NormalizeOptions extends WorkflowOptions {
70
+ trimStartSeconds?: number;
71
+ durationSeconds?: number;
72
+ audio?: 'preserve' | 'remove';
73
+ }
74
+ interface ExtractAudioOptions extends WorkflowOptions {
75
+ format?: 'm4a' | 'mp3' | 'wav';
76
+ trimStartSeconds?: number;
77
+ durationSeconds?: number;
78
+ }
79
+ interface ExtractFrameOptions extends WorkflowOptions {
80
+ atSeconds?: number;
81
+ format?: 'jpg' | 'png';
82
+ }
83
+ interface WorkflowResult {
84
+ source: MediaMetadata;
85
+ plan: MediaPlan;
86
+ serializedPlan: string;
87
+ output: MediaMetadata;
88
+ verification: VerificationReport;
89
+ }
90
+ /**
91
+ * Inspect, plan, execute, and verify a high-compatibility 9:16 video in one semantic workflow.
92
+ * The returned Media IR remains portable and replayable; this convenience API does not bypass it.
93
+ */
94
+ declare function makeVertical(options: MakeVerticalOptions): Promise<WorkflowResult>;
95
+ /**
96
+ * Inspect, plan, execute, and verify a web-optimized video: balanced quality,
97
+ * H.264/yuv420p, faststart, and an optional maximum file size.
98
+ */
99
+ declare function optimizeForWeb(options: OptimizeForWebOptions): Promise<WorkflowResult>;
100
+ /**
101
+ * Inspect, plan, execute, and verify a normalized high-compatibility copy without
102
+ * changing dimensions or aspect ratio. Ensures H.264, yuv420p, and faststart.
103
+ */
104
+ declare function normalize(options: NormalizeOptions): Promise<WorkflowResult>;
105
+ /**
106
+ * Inspect, plan, execute, and verify audio extraction from any media source.
107
+ */
108
+ declare function extractAudio(options: ExtractAudioOptions): Promise<WorkflowResult>;
109
+ /**
110
+ * Inspect, plan, execute, and verify a still frame extraction from a video source.
111
+ */
112
+ declare function extractFrame(options: ExtractFrameOptions): Promise<WorkflowResult>;
113
+
114
+ export { type CompiledOperation, type ExecuteOptions, type ExecutionResult, type ExtractAudioOptions, type ExtractFrameOptions, type FfmpegOptions, type MakeVerticalOptions, type MediaProgress, type MediaProgressPhase, type NormalizeOptions, type OptimizeForWebOptions, type ProgressCallback, type WorkflowOptions, type WorkflowResult, compilePlan, executePlan, extensionForPlan, extractAudio, extractFrame, getCapabilities, inspectMedia, makeVertical, normalize, optimizeForWeb };
package/dist/index.js ADDED
@@ -0,0 +1,774 @@
1
+ // src/capabilities.ts
2
+ import { MediaError } from "@hadialmarzooq/agent-media-core";
3
+
4
+ // src/process.ts
5
+ import { spawn } from "child_process";
6
+ async function runProcess(executable, args, options = 3e4) {
7
+ return new Promise((resolve3, reject) => {
8
+ const { timeoutMs, signal, onStdout } = typeof options === "number" ? { timeoutMs: options, onStdout: void 0 } : options;
9
+ if (signal?.aborted) {
10
+ resolve3({ stdout: "", stderr: "", exitCode: -1, timedOut: false, aborted: true });
11
+ return;
12
+ }
13
+ const child = spawn(executable, [...args], { stdio: ["ignore", "pipe", "pipe"] });
14
+ let stdout = "";
15
+ let stderr = "";
16
+ let timedOut = false;
17
+ let aborted = false;
18
+ const stopForAbort = () => {
19
+ aborted = true;
20
+ child.kill("SIGTERM");
21
+ };
22
+ const timer = setTimeout(() => {
23
+ timedOut = true;
24
+ child.kill("SIGTERM");
25
+ }, timeoutMs ?? 3e4);
26
+ signal?.addEventListener("abort", stopForAbort, { once: true });
27
+ child.stdout.setEncoding("utf8").on("data", (chunk) => {
28
+ stdout += chunk;
29
+ onStdout?.(chunk);
30
+ });
31
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
32
+ stderr += chunk;
33
+ });
34
+ child.once("error", (error) => {
35
+ clearTimeout(timer);
36
+ signal?.removeEventListener("abort", stopForAbort);
37
+ reject(error);
38
+ });
39
+ child.once("close", (exitCode) => {
40
+ clearTimeout(timer);
41
+ signal?.removeEventListener("abort", stopForAbort);
42
+ resolve3({ stdout, stderr, exitCode: exitCode ?? -1, timedOut, aborted });
43
+ });
44
+ });
45
+ }
46
+
47
+ // src/capabilities.ts
48
+ async function getCapabilities(options = {}) {
49
+ try {
50
+ const [version, encoders, filters, hardware] = await Promise.all([
51
+ runProcess(options.ffmpegPath ?? "ffmpeg", ["-version"], options.timeoutMs),
52
+ runProcess(options.ffmpegPath ?? "ffmpeg", ["-hide_banner", "-encoders"], options.timeoutMs),
53
+ runProcess(options.ffmpegPath ?? "ffmpeg", ["-hide_banner", "-filters"], options.timeoutMs),
54
+ runProcess(options.ffmpegPath ?? "ffmpeg", ["-hide_banner", "-hwaccels"], options.timeoutMs)
55
+ ]);
56
+ if ([version, encoders, filters, hardware].some((result) => result.exitCode !== 0)) {
57
+ throw new Error("FFmpeg returned a non-zero exit code.");
58
+ }
59
+ const versionLine = version.stdout.split("\n")[0] ?? "";
60
+ const match = /ffmpeg version\s+([^\s]+)/i.exec(versionLine);
61
+ return {
62
+ ffmpegVersion: match?.[1] ?? "unknown",
63
+ encoders: {
64
+ // The current compiler emits libx264, so advertise only the implementation it can use.
65
+ h264: hasCapability(encoders.stdout, /\blibx264\b/),
66
+ hevc: hasCapability(encoders.stdout, /\b(?:libx265|hevc_videotoolbox|hevc_nvenc)\b/),
67
+ av1: hasCapability(encoders.stdout, /\b(?:libaom-av1|libsvtav1|av1_nvenc)\b/),
68
+ aac: hasCapability(encoders.stdout, /\baac\b/)
69
+ },
70
+ hardwareAcceleration: hardware.stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("Hardware acceleration")),
71
+ filters: {
72
+ scale: hasCapability(filters.stdout, /\bscale\b/),
73
+ crop: hasCapability(filters.stdout, /\bcrop\b/),
74
+ concat: hasCapability(filters.stdout, /\bconcat\b/),
75
+ subtitles: hasCapability(filters.stdout, /\bsubtitles\b/)
76
+ }
77
+ };
78
+ } catch (error) {
79
+ throw new MediaError({
80
+ code: "FFMPEG_NOT_FOUND",
81
+ message: "FFmpeg capabilities could not be detected.",
82
+ context: { executable: options.ffmpegPath ?? "ffmpeg" },
83
+ suggestedActions: ["Install FFmpeg and ensure ffmpeg is on PATH."],
84
+ debug: { backend: "ffmpeg", stderr: error instanceof Error ? error.message : String(error) }
85
+ });
86
+ }
87
+ }
88
+ function hasCapability(output, pattern) {
89
+ return pattern.test(output);
90
+ }
91
+
92
+ // src/compiler.ts
93
+ import { extname } from "path";
94
+ import { MediaError as MediaError2 } from "@hadialmarzooq/agent-media-core";
95
+ function compilePlan(plan, source, output) {
96
+ const specialStep = plan.steps.find(
97
+ (step) => step.operation === "extract-audio" || step.operation === "extract-frame" || step.operation === "concatenate"
98
+ );
99
+ if (specialStep !== void 0) return compileSpecial(plan, specialStep, source, output);
100
+ if (plan.steps.length === 0) {
101
+ return {
102
+ executable: "ffmpeg",
103
+ args: [
104
+ "-hide_banner",
105
+ "-nostdin",
106
+ "-y",
107
+ "-i",
108
+ plan.source.path,
109
+ "-map",
110
+ "0",
111
+ "-c",
112
+ "copy",
113
+ output
114
+ ]
115
+ };
116
+ }
117
+ const args = ["-hide_banner", "-nostdin", "-y"];
118
+ const trim = plan.steps.find((step) => step.operation === "trim");
119
+ if (trim?.operation === "trim") {
120
+ args.push("-ss", String(trim.startSeconds));
121
+ if (trim.endSeconds !== void 0) args.push("-to", String(trim.endSeconds));
122
+ }
123
+ args.push("-i", plan.source.path);
124
+ const filters = plan.steps.flatMap((step) => filtersForStep(step, source));
125
+ if (filters.length > 0) args.push("-vf", filters.join(","));
126
+ const encode = plan.steps.find((step) => step.operation === "encode");
127
+ if (encode?.operation === "encode") {
128
+ args.push(...encodingArgs(encode, source, plan));
129
+ } else {
130
+ args.push("-c:v", "libx264", "-c:a", "aac");
131
+ }
132
+ if (plan.expectations.audio === "remove") args.push("-an");
133
+ args.push("-movflags", "+faststart", output);
134
+ return { executable: "ffmpeg", args };
135
+ }
136
+ function compileSpecial(plan, step, source, output) {
137
+ if (step.operation === "extract-audio") {
138
+ return {
139
+ executable: "ffmpeg",
140
+ args: [
141
+ "-hide_banner",
142
+ "-nostdin",
143
+ "-y",
144
+ "-i",
145
+ plan.source.path,
146
+ "-vn",
147
+ "-c:a",
148
+ audioCodec(step.format),
149
+ output
150
+ ]
151
+ };
152
+ }
153
+ if (step.operation === "extract-frame") {
154
+ return {
155
+ executable: "ffmpeg",
156
+ args: [
157
+ "-hide_banner",
158
+ "-nostdin",
159
+ "-y",
160
+ "-ss",
161
+ String(step.atSeconds),
162
+ "-i",
163
+ plan.source.path,
164
+ "-frames:v",
165
+ "1",
166
+ "-q:v",
167
+ "2",
168
+ output
169
+ ]
170
+ };
171
+ }
172
+ if (step.operation === "concatenate") {
173
+ const args = ["-hide_banner", "-nostdin", "-y"];
174
+ for (const input of step.inputs) args.push("-i", input);
175
+ const hasVideo = source.video !== void 0;
176
+ const hasAudio = source.audio.present;
177
+ const labels = step.inputs.map((_, index) => `${hasVideo ? `[${index}:v]` : ""}${hasAudio ? `[${index}:a]` : ""}`).join("");
178
+ const outputs = `${hasVideo ? "[v]" : ""}${hasAudio ? "[a]" : ""}`;
179
+ args.push(
180
+ "-filter_complex",
181
+ `${labels}concat=n=${step.inputs.length}:v=${hasVideo ? 1 : 0}:a=${hasAudio ? 1 : 0}${outputs}`
182
+ );
183
+ if (hasVideo) args.push("-map", "[v]", "-c:v", "libx264");
184
+ if (hasAudio) {
185
+ args.push("-map", "[a]", "-c:a", hasVideo ? "aac" : audioCodecForOutput(output));
186
+ }
187
+ args.push(output);
188
+ return { executable: "ffmpeg", args };
189
+ }
190
+ throw new MediaError2({
191
+ code: "INVALID_PLAN",
192
+ message: `Unsupported special operation: ${step.operation}.`
193
+ });
194
+ }
195
+ function filtersForStep(step, source) {
196
+ if (step.operation === "resize") return [`scale=${step.width}:${step.height}:flags=lanczos`];
197
+ if (step.operation !== "reframe") return [];
198
+ if (source.video === void 0)
199
+ throw new MediaError2({
200
+ code: "UNSUPPORTED_INPUT",
201
+ message: "Reframing requires a video stream."
202
+ });
203
+ const [ratioWidth, ratioHeight] = step.aspectRatio.split(":").map(Number);
204
+ if (ratioWidth === void 0 || ratioHeight === void 0)
205
+ throw new MediaError2({ code: "INVALID_PLAN", message: "Invalid aspect ratio in plan." });
206
+ const targetRatio = ratioWidth / ratioHeight;
207
+ const sourceRatio = source.video.width / source.video.height;
208
+ if (sourceRatio > targetRatio) {
209
+ const width = even(source.video.height * targetRatio);
210
+ return [`crop=${width}:${source.video.height}:(iw-${width})/2:0`];
211
+ }
212
+ const height = even(source.video.width / targetRatio);
213
+ return [`crop=${source.video.width}:${height}:0:(ih-${height})/2`];
214
+ }
215
+ function encodingArgs(step, source, plan) {
216
+ const args = ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac"];
217
+ const crf = step.profile === "high-quality" ? "18" : step.profile === "high-compatibility" ? "23" : "21";
218
+ args.push("-crf", crf, "-preset", "medium");
219
+ if (step.maxSizeMB !== void 0) {
220
+ const duration = plan.expectations.durationSeconds ?? source.durationSeconds;
221
+ if (duration !== void 0 && duration > 0) {
222
+ const totalKbps = Math.max(64, Math.floor(step.maxSizeMB * 8e3 * 0.94 / duration));
223
+ const videoKbps = Math.max(32, totalKbps - 128);
224
+ args.push(
225
+ "-b:v",
226
+ `${videoKbps}k`,
227
+ "-maxrate",
228
+ `${videoKbps}k`,
229
+ "-bufsize",
230
+ `${videoKbps * 2}k`
231
+ );
232
+ }
233
+ }
234
+ return args;
235
+ }
236
+ function audioCodec(format) {
237
+ return format === "mp3" ? "libmp3lame" : format === "wav" ? "pcm_s16le" : "aac";
238
+ }
239
+ function audioCodecForOutput(output) {
240
+ const extension = extname(output).slice(1).toLowerCase();
241
+ return extension === "mp3" || extension === "wav" ? audioCodec(extension) : audioCodec("m4a");
242
+ }
243
+ function even(value) {
244
+ return Math.max(2, Math.floor(value / 2) * 2);
245
+ }
246
+ function extensionForPlan(plan) {
247
+ const special = plan.steps.find(
248
+ (step) => step.operation === "extract-audio" || step.operation === "extract-frame"
249
+ );
250
+ if (special?.operation === "extract-audio") return `.${special.format}`;
251
+ if (special?.operation === "extract-frame") return `.${special.format}`;
252
+ return extname(plan.source.path) || ".mp4";
253
+ }
254
+
255
+ // src/executor.ts
256
+ import { access, constants, rm } from "fs/promises";
257
+ import { dirname, relative, resolve as resolve2 } from "path";
258
+ import { MediaError as MediaError4, validatePlan } from "@hadialmarzooq/agent-media-core";
259
+
260
+ // src/inspect.ts
261
+ import { stat } from "fs/promises";
262
+ import { basename, resolve } from "path";
263
+ import { MediaError as MediaError3 } from "@hadialmarzooq/agent-media-core";
264
+ async function inspectMedia(input, options = {}) {
265
+ const path = resolve(input);
266
+ let sourceStat;
267
+ try {
268
+ sourceStat = await stat(path);
269
+ } catch {
270
+ throw new MediaError3({
271
+ code: "UNSUPPORTED_INPUT",
272
+ message: `The input file does not exist: ${basename(input)}.`,
273
+ context: { input: path },
274
+ suggestedActions: ["Check the source path and permissions."]
275
+ });
276
+ }
277
+ let result;
278
+ try {
279
+ result = await runProcess(
280
+ options.ffprobePath ?? "ffprobe",
281
+ ["-v", "error", "-show_format", "-show_streams", "-of", "json", path],
282
+ options.timeoutMs
283
+ );
284
+ } catch (error) {
285
+ throw new MediaError3({
286
+ code: "FFMPEG_NOT_FOUND",
287
+ message: "ffprobe could not be started.",
288
+ context: { executable: options.ffprobePath ?? "ffprobe" },
289
+ suggestedActions: ["Install FFmpeg and ensure ffprobe is on PATH."],
290
+ debug: { backend: "ffmpeg", stderr: error instanceof Error ? error.message : String(error) }
291
+ });
292
+ }
293
+ if (result.exitCode !== 0) {
294
+ throw new MediaError3({
295
+ code: "PROBE_FAILED",
296
+ message: "ffprobe could not read the input media.",
297
+ context: { input: path },
298
+ suggestedActions: [
299
+ "Run inspect to confirm the file is readable.",
300
+ "Try a supported media container."
301
+ ],
302
+ debug: { backend: "ffmpeg", stderr: result.stderr }
303
+ });
304
+ }
305
+ const probe = JSON.parse(result.stdout);
306
+ const streams = probe.streams ?? [];
307
+ const videoStream = streams.find((stream) => stream.codec_type === "video");
308
+ const audioStream = streams.find((stream) => stream.codec_type === "audio");
309
+ const video = normalizeVideo(videoStream);
310
+ const audio = normalizeAudio(audioStream);
311
+ const kind = video ? probe.format?.format_name?.split(",").includes("image2") ? "image" : "video" : audio.present ? "audio" : "unknown";
312
+ const durationSeconds = toFiniteNumber(probe.format?.duration);
313
+ return {
314
+ path,
315
+ kind,
316
+ ...durationSeconds === void 0 ? {} : { durationSeconds },
317
+ ...probe.format?.format_name === void 0 ? {} : { container: probe.format.format_name.split(",")[0] },
318
+ sizeBytes: sourceStat.size,
319
+ ...video === void 0 ? {} : { video },
320
+ audio
321
+ };
322
+ }
323
+ function normalizeVideo(stream) {
324
+ if (stream?.width === void 0 || stream.height === void 0) return void 0;
325
+ const fps = parseFraction(stream.r_frame_rate);
326
+ const rotation = stream.side_data_list?.find((data) => data.rotation !== void 0)?.rotation;
327
+ const tagRotation = stream.tags?.rotate === void 0 ? void 0 : Number(stream.tags.rotate);
328
+ return {
329
+ width: stream.width,
330
+ height: stream.height,
331
+ aspectRatio: simplifyAspectRatio(stream.width, stream.height),
332
+ ...fps === void 0 ? {} : { fps },
333
+ ...stream.codec_name === void 0 ? {} : { codec: stream.codec_name },
334
+ ...stream.pix_fmt === void 0 ? {} : { pixelFormat: stream.pix_fmt },
335
+ ...rotation === void 0 && tagRotation === void 0 ? {} : { rotationDegrees: Math.round(rotation ?? tagRotation ?? 0) }
336
+ };
337
+ }
338
+ function normalizeAudio(stream) {
339
+ if (stream === void 0) return { present: false };
340
+ const sampleRate = toFiniteNumber(stream.sample_rate);
341
+ return {
342
+ present: true,
343
+ ...stream.codec_name === void 0 ? {} : { codec: stream.codec_name },
344
+ ...sampleRate === void 0 ? {} : { sampleRate },
345
+ ...stream.channels === void 0 ? {} : { channels: stream.channels }
346
+ };
347
+ }
348
+ function parseFraction(value) {
349
+ if (value === void 0) return void 0;
350
+ const [numerator, denominator] = value.split("/").map(Number);
351
+ if (numerator === void 0 || denominator === void 0 || !Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator === 0)
352
+ return void 0;
353
+ return numerator / denominator;
354
+ }
355
+ function toFiniteNumber(value) {
356
+ const number = Number(value);
357
+ return Number.isFinite(number) ? number : void 0;
358
+ }
359
+ function simplifyAspectRatio(width, height) {
360
+ const divisor = gcd(width, height);
361
+ return `${width / divisor}:${height / divisor}`;
362
+ }
363
+ function gcd(left, right) {
364
+ return right === 0 ? left : gcd(right, left % right);
365
+ }
366
+
367
+ // src/progress.ts
368
+ function createExecutionProgressReporter(totalSeconds, onProgress) {
369
+ let buffer = "";
370
+ let fields = {};
371
+ let lastPercent = -1;
372
+ let lastProcessedSeconds = -1;
373
+ const emit2 = (percent, message, processedSeconds, speed) => {
374
+ const normalizedPercent = Math.max(lastPercent, Math.min(100, Math.round(percent)));
375
+ if (normalizedPercent === lastPercent && (processedSeconds === void 0 || processedSeconds - lastProcessedSeconds < 0.25)) {
376
+ return;
377
+ }
378
+ lastPercent = normalizedPercent;
379
+ if (processedSeconds !== void 0) lastProcessedSeconds = processedSeconds;
380
+ safelyNotify(onProgress, {
381
+ phase: "executing",
382
+ percent: normalizedPercent,
383
+ message,
384
+ ...processedSeconds === void 0 ? {} : { processedSeconds },
385
+ ...totalSeconds === void 0 ? {} : { totalSeconds },
386
+ ...speed === void 0 ? {} : { speed }
387
+ });
388
+ };
389
+ const flushLine = (line) => {
390
+ const separator = line.indexOf("=");
391
+ if (separator === -1) return;
392
+ const key = line.slice(0, separator);
393
+ const value = line.slice(separator + 1);
394
+ fields[key] = value;
395
+ if (key !== "progress") return;
396
+ const processedSeconds = parseProcessedSeconds(fields);
397
+ const speed = parseSpeed(fields.speed);
398
+ const percent = processedSeconds === void 0 || totalSeconds === void 0 || totalSeconds <= 0 ? 0 : Math.min(99, processedSeconds / totalSeconds * 100);
399
+ emit2(percent, "FFmpeg is executing the media plan.", processedSeconds, speed);
400
+ fields = {};
401
+ };
402
+ return {
403
+ start: () => emit2(0, "FFmpeg started executing the media plan.", 0),
404
+ write: (chunk) => {
405
+ buffer += chunk;
406
+ let newline = buffer.indexOf("\n");
407
+ while (newline !== -1) {
408
+ flushLine(buffer.slice(0, newline).replace(/\r$/, ""));
409
+ buffer = buffer.slice(newline + 1);
410
+ newline = buffer.indexOf("\n");
411
+ }
412
+ },
413
+ complete: () => emit2(100, "FFmpeg completed the media plan.", totalSeconds, void 0)
414
+ };
415
+ }
416
+ function safelyNotify(onProgress, progress) {
417
+ try {
418
+ onProgress?.(progress);
419
+ } catch {
420
+ }
421
+ }
422
+ function parseProcessedSeconds(fields) {
423
+ const microseconds = Number(fields.out_time_us);
424
+ if (Number.isFinite(microseconds) && microseconds >= 0) return microseconds / 1e6;
425
+ const timestamp = fields.out_time;
426
+ if (timestamp === void 0) return void 0;
427
+ const match = /^(\d+):(\d+):(\d+(?:\.\d+)?)$/.exec(timestamp);
428
+ if (match === null) return void 0;
429
+ const hours = Number(match[1]);
430
+ const minutes = Number(match[2]);
431
+ const seconds = Number(match[3]);
432
+ const value = hours * 3600 + minutes * 60 + seconds;
433
+ return Number.isFinite(value) ? value : void 0;
434
+ }
435
+ function parseSpeed(value) {
436
+ if (value === void 0) return void 0;
437
+ const speed = Number(value.replace(/x$/, ""));
438
+ return Number.isFinite(speed) ? speed : void 0;
439
+ }
440
+
441
+ // src/executor.ts
442
+ async function executePlan(planInput, options) {
443
+ const plan = validatePlan(planInput);
444
+ const output = resolve2(options.output);
445
+ if (output === resolve2(plan.source.path)) {
446
+ throw new MediaError4({
447
+ code: "PATH_NOT_ALLOWED",
448
+ message: "Output must not overwrite the source path.",
449
+ context: { source: plan.source.path, output },
450
+ suggestedActions: ["Choose a distinct output path."]
451
+ });
452
+ }
453
+ if (options.allowedOutputDirectory !== void 0 && !isWithin(output, resolve2(options.allowedOutputDirectory))) {
454
+ throw new MediaError4({
455
+ code: "PATH_NOT_ALLOWED",
456
+ message: "Output is outside the allowed output directory.",
457
+ context: { output, allowedOutputDirectory: resolve2(options.allowedOutputDirectory) },
458
+ suggestedActions: ["Choose a path within the configured output directory."]
459
+ });
460
+ }
461
+ if (!options.overwrite && await exists(output)) {
462
+ throw new MediaError4({
463
+ code: "OUTPUT_EXISTS",
464
+ message: "The output path already exists.",
465
+ context: { output },
466
+ suggestedActions: ["Choose a different output path or explicitly enable overwrite."]
467
+ });
468
+ }
469
+ const sourceMetadata = options.sourceMetadata ?? await inspectMedia(plan.source.path, {
470
+ ...options.ffprobePath === void 0 ? {} : { ffprobePath: options.ffprobePath },
471
+ ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
472
+ });
473
+ if (resolve2(sourceMetadata.path) !== resolve2(plan.source.path)) {
474
+ throw new MediaError4({
475
+ code: "INVALID_PLAN",
476
+ message: "Source metadata does not describe the Media Plan source.",
477
+ context: { planSource: plan.source.path, metadataSource: sourceMetadata.path },
478
+ suggestedActions: ["Inspect the planned source and pass that metadata to executePlan."]
479
+ });
480
+ }
481
+ await preflightConcatenation(plan, sourceMetadata, options);
482
+ const operation = compilePlan(plan, sourceMetadata, output);
483
+ const progress = createExecutionProgressReporter(
484
+ executionDuration(plan, sourceMetadata),
485
+ options.onProgress
486
+ );
487
+ progress.start();
488
+ let result;
489
+ try {
490
+ result = await runProcess(
491
+ options.ffmpegPath ?? operation.executable,
492
+ progressArgs(operation.args),
493
+ {
494
+ ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
495
+ ...options.signal === void 0 ? {} : { signal: options.signal },
496
+ onStdout: progress.write
497
+ }
498
+ );
499
+ } catch (error) {
500
+ throw new MediaError4({
501
+ code: "FFMPEG_NOT_FOUND",
502
+ message: "FFmpeg could not be started for plan execution.",
503
+ context: { executable: options.ffmpegPath ?? operation.executable },
504
+ suggestedActions: ["Install FFmpeg and ensure ffmpeg is on PATH."],
505
+ debug: { backend: "ffmpeg", stderr: error instanceof Error ? error.message : String(error) }
506
+ });
507
+ }
508
+ if (result.aborted) {
509
+ await removePartialOutput(output);
510
+ throw new MediaError4({
511
+ code: "OPERATION_CANCELLED",
512
+ message: "Media execution was cancelled.",
513
+ context: { input: plan.source.path, output },
514
+ suggestedActions: ["Create a new execution request when ready."]
515
+ });
516
+ }
517
+ if (result.timedOut) {
518
+ await removePartialOutput(output);
519
+ throw new MediaError4({
520
+ code: "OPERATION_TIMEOUT",
521
+ message: "Media execution exceeded its configured timeout.",
522
+ context: { input: plan.source.path, output, timeoutMs: options.timeoutMs ?? 3e4 },
523
+ suggestedActions: ["Use a longer timeout or a smaller media operation."],
524
+ debug: { backend: "ffmpeg", stderr: result.stderr }
525
+ });
526
+ }
527
+ if (result.exitCode !== 0) {
528
+ await removePartialOutput(output);
529
+ throw new MediaError4({
530
+ code: "EXECUTION_FAILED",
531
+ message: "FFmpeg could not execute the media plan.",
532
+ context: { input: plan.source.path, output, directory: dirname(output) },
533
+ suggestedActions: ["Inspect the source and plan, then retry with a supported target."],
534
+ debug: { backend: "ffmpeg", stderr: result.stderr }
535
+ });
536
+ }
537
+ progress.complete();
538
+ return { output, operation };
539
+ }
540
+ function progressArgs(args) {
541
+ const result = [...args];
542
+ const insertionPoint = result.indexOf("-nostdin") + 1;
543
+ result.splice(insertionPoint, 0, "-progress", "pipe:1", "-nostats");
544
+ return result;
545
+ }
546
+ function executionDuration(plan, source) {
547
+ if (plan.expectations.durationSeconds !== void 0) return plan.expectations.durationSeconds;
548
+ const trim = plan.steps.find((step) => step.operation === "trim");
549
+ if (trim?.operation !== "trim") return source.durationSeconds;
550
+ if (trim.endSeconds !== void 0) return trim.endSeconds - trim.startSeconds;
551
+ return source.durationSeconds === void 0 ? void 0 : Math.max(0, source.durationSeconds - trim.startSeconds);
552
+ }
553
+ async function preflightConcatenation(plan, source, options) {
554
+ const concatenate = plan.steps.find((step) => step.operation === "concatenate");
555
+ if (concatenate?.operation !== "concatenate") return;
556
+ const metadata = await Promise.all(
557
+ concatenate.inputs.map(async (input, index) => {
558
+ if (index === 0) return source;
559
+ return inspectMedia(input, {
560
+ ...options.ffprobePath === void 0 ? {} : { ffprobePath: options.ffprobePath },
561
+ ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
562
+ });
563
+ })
564
+ );
565
+ const baseline = metadata[0];
566
+ if (baseline === void 0) return;
567
+ for (const [index, candidate] of metadata.entries()) {
568
+ if (index === 0) continue;
569
+ const incompatibleFields = streamDifferences(baseline, candidate);
570
+ if (incompatibleFields.length === 0) continue;
571
+ throw new MediaError4({
572
+ code: "UNSUPPORTED_INPUT",
573
+ message: "Concatenation inputs have incompatible stream layouts.",
574
+ context: {
575
+ input: concatenate.inputs[index],
576
+ inputIndex: index,
577
+ incompatibleFields
578
+ },
579
+ suggestedActions: [
580
+ "Normalize the listed stream properties before concatenation.",
581
+ "Use inputs with matching video and audio stream layouts."
582
+ ]
583
+ });
584
+ }
585
+ }
586
+ function streamDifferences(baseline, candidate) {
587
+ const differences = [];
588
+ compare(
589
+ differences,
590
+ "video.present",
591
+ baseline.video !== void 0,
592
+ candidate.video !== void 0
593
+ );
594
+ compare(differences, "audio.present", baseline.audio.present, candidate.audio.present);
595
+ if (baseline.video !== void 0 && candidate.video !== void 0) {
596
+ compare(differences, "video.width", baseline.video.width, candidate.video.width);
597
+ compare(differences, "video.height", baseline.video.height, candidate.video.height);
598
+ compare(differences, "video.fps", baseline.video.fps, candidate.video.fps);
599
+ compare(
600
+ differences,
601
+ "video.pixelFormat",
602
+ baseline.video.pixelFormat,
603
+ candidate.video.pixelFormat
604
+ );
605
+ }
606
+ if (baseline.audio.present && candidate.audio.present) {
607
+ compare(differences, "audio.sampleRate", baseline.audio.sampleRate, candidate.audio.sampleRate);
608
+ compare(differences, "audio.channels", baseline.audio.channels, candidate.audio.channels);
609
+ }
610
+ return differences;
611
+ }
612
+ function compare(differences, field, baseline, candidate) {
613
+ if (baseline !== candidate) differences.push(field);
614
+ }
615
+ async function removePartialOutput(path) {
616
+ try {
617
+ await rm(path, { force: true });
618
+ } catch {
619
+ }
620
+ }
621
+ async function exists(path) {
622
+ try {
623
+ await access(path, constants.F_OK);
624
+ return true;
625
+ } catch {
626
+ return false;
627
+ }
628
+ }
629
+ function isWithin(path, directory) {
630
+ const pathRelative = relative(directory, path);
631
+ return pathRelative === "" || !pathRelative.startsWith("..") && !pathRelative.includes("..\\");
632
+ }
633
+
634
+ // src/workflows.ts
635
+ import { MediaError as MediaError5, planMedia, serializePlan, verifyMedia } from "@hadialmarzooq/agent-media-core";
636
+ async function makeVertical(options) {
637
+ const dimensions = verticalDimensions(options.width, options.height);
638
+ const source = await inspectPhase(options, "vertical media");
639
+ const plan = await planningPhase(options, "vertical media", source, {
640
+ aspectRatio: "9:16",
641
+ width: dimensions.width,
642
+ height: dimensions.height,
643
+ compatibility: "high",
644
+ ...options.trimStartSeconds === void 0 ? {} : { trimStartSeconds: options.trimStartSeconds },
645
+ ...options.durationSeconds === void 0 ? {} : { durationSeconds: options.durationSeconds },
646
+ ...options.maxSizeMB === void 0 ? {} : { maxSizeMB: options.maxSizeMB },
647
+ ...audioGoal(options, source)
648
+ });
649
+ return executeAndVerify(options, source, plan, "Vertical media is verified and ready.");
650
+ }
651
+ async function optimizeForWeb(options) {
652
+ const source = await inspectPhase(options, "web-optimized media");
653
+ const plan = await planningPhase(options, "web-optimized media", source, {
654
+ compatibility: "high",
655
+ quality: options.quality ?? "balanced",
656
+ ...options.trimStartSeconds === void 0 ? {} : { trimStartSeconds: options.trimStartSeconds },
657
+ ...options.durationSeconds === void 0 ? {} : { durationSeconds: options.durationSeconds },
658
+ ...options.maxSizeMB === void 0 ? {} : { maxSizeMB: options.maxSizeMB },
659
+ ...audioGoal(options, source)
660
+ });
661
+ return executeAndVerify(options, source, plan, "Web-optimized media is verified and ready.");
662
+ }
663
+ async function normalize(options) {
664
+ const source = await inspectPhase(options, "normalized media");
665
+ const plan = await planningPhase(options, "normalized media", source, {
666
+ compatibility: "high",
667
+ ...options.trimStartSeconds === void 0 ? {} : { trimStartSeconds: options.trimStartSeconds },
668
+ ...options.durationSeconds === void 0 ? {} : { durationSeconds: options.durationSeconds },
669
+ ...audioGoal(options, source)
670
+ });
671
+ return executeAndVerify(options, source, plan, "Normalized media is verified and ready.");
672
+ }
673
+ async function extractAudio(options) {
674
+ const source = await inspectPhase(options, "audio extraction");
675
+ const plan = await planningPhase(options, "audio extraction", source, {
676
+ extractAudio: { format: options.format ?? "m4a" },
677
+ ...options.trimStartSeconds === void 0 ? {} : { trimStartSeconds: options.trimStartSeconds },
678
+ ...options.durationSeconds === void 0 ? {} : { durationSeconds: options.durationSeconds }
679
+ });
680
+ return executeAndVerify(options, source, plan, "Audio extraction is verified and ready.");
681
+ }
682
+ async function extractFrame(options) {
683
+ const source = await inspectPhase(options, "frame extraction");
684
+ const plan = await planningPhase(options, "frame extraction", source, {
685
+ extractFrame: {
686
+ atSeconds: options.atSeconds ?? 0,
687
+ format: options.format ?? "jpg"
688
+ }
689
+ });
690
+ return executeAndVerify(options, source, plan, "Frame extraction is verified and ready.");
691
+ }
692
+ function verticalDimensions(width, height) {
693
+ if (width === void 0 !== (height === void 0)) {
694
+ throw new MediaError5({
695
+ code: "INVALID_PLAN",
696
+ message: "Custom vertical dimensions require both width and height.",
697
+ suggestedActions: ["Provide both dimensions or use the default 1080x1920 output."]
698
+ });
699
+ }
700
+ return { width: width ?? 1080, height: height ?? 1920 };
701
+ }
702
+ function ffmpegOptions(options) {
703
+ return {
704
+ ...options.ffmpegPath === void 0 ? {} : { ffmpegPath: options.ffmpegPath },
705
+ ...options.ffprobePath === void 0 ? {} : { ffprobePath: options.ffprobePath },
706
+ ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
707
+ };
708
+ }
709
+ function audioGoal(options, source) {
710
+ if (options.audio !== void 0) return { audio: options.audio };
711
+ return source.audio.present ? { audio: "preserve" } : {};
712
+ }
713
+ async function inspectPhase(options, label) {
714
+ emit(options.onProgress, "inspecting", 0, `Inspecting the source media for ${label}.`);
715
+ const source = await inspectMedia(options.input, ffmpegOptions(options));
716
+ emit(options.onProgress, "inspecting", 10, "Source inspection completed.");
717
+ return source;
718
+ }
719
+ async function planningPhase(options, label, source, goals) {
720
+ emit(options.onProgress, "planning", 15, `Creating a semantic plan for ${label}.`);
721
+ const plan = planMedia({
722
+ source,
723
+ capabilities: await getCapabilities(ffmpegOptions(options)),
724
+ goals
725
+ });
726
+ emit(options.onProgress, "planning", 20, `${label} plan is ready.`);
727
+ return plan;
728
+ }
729
+ async function executeAndVerify(options, source, plan, completionMessage) {
730
+ const executeOptions = {
731
+ output: options.output,
732
+ sourceMetadata: source,
733
+ ...options.ffmpegPath === void 0 ? {} : { ffmpegPath: options.ffmpegPath },
734
+ ...options.ffprobePath === void 0 ? {} : { ffprobePath: options.ffprobePath },
735
+ ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
736
+ ...options.overwrite === void 0 ? {} : { overwrite: options.overwrite },
737
+ ...options.allowedOutputDirectory === void 0 ? {} : { allowedOutputDirectory: options.allowedOutputDirectory },
738
+ ...options.signal === void 0 ? {} : { signal: options.signal },
739
+ onProgress: (progress) => {
740
+ const percent = 20 + Math.round(progress.percent * 0.7);
741
+ safelyNotify(options.onProgress, { ...progress, percent });
742
+ }
743
+ };
744
+ const execution = await executePlan(plan, executeOptions);
745
+ emit(options.onProgress, "verifying", 92, "Inspecting and verifying the output.");
746
+ const output = await inspectMedia(execution.output, ffmpegOptions(options));
747
+ const verification = verifyMedia(output, plan.expectations);
748
+ if (!verification.passed) {
749
+ throw new MediaError5({
750
+ code: "VERIFICATION_FAILED",
751
+ message: "The workflow completed, but the output did not satisfy its plan.",
752
+ context: { output: execution.output, verification },
753
+ suggestedActions: ["Inspect the failed checks, adjust the semantic goals, and retry."]
754
+ });
755
+ }
756
+ emit(options.onProgress, "completed", 100, completionMessage);
757
+ const serializedPlan = serializePlan(plan);
758
+ return { source, plan, serializedPlan, output, verification };
759
+ }
760
+ function emit(onProgress, phase, percent, message) {
761
+ safelyNotify(onProgress, { phase, percent, message });
762
+ }
763
+ export {
764
+ compilePlan,
765
+ executePlan,
766
+ extensionForPlan,
767
+ extractAudio,
768
+ extractFrame,
769
+ getCapabilities,
770
+ inspectMedia,
771
+ makeVertical,
772
+ normalize,
773
+ optimizeForWeb
774
+ };
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@hadialmarzooq/agent-media-ffmpeg",
3
+ "version": "0.1.0",
4
+ "description": "Safe FFmpeg execution, progress, and verified media workflows for software agents.",
5
+ "keywords": [
6
+ "media",
7
+ "agents",
8
+ "ffmpeg",
9
+ "video",
10
+ "mcp"
11
+ ],
12
+ "homepage": "https://github.com/HadiAlMarzooq/agent-media#readme",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/HadiAlMarzooq/agent-media.git",
16
+ "directory": "packages/ffmpeg"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/HadiAlMarzooq/agent-media/issues"
20
+ },
21
+ "license": "MIT",
22
+ "engines": {
23
+ "node": ">=22.0.0"
24
+ },
25
+ "type": "module",
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "dependencies": {
41
+ "@hadialmarzooq/agent-media-core": "0.1.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup src/index.ts --format esm --dts",
45
+ "typecheck": "tsc --noEmit"
46
+ }
47
+ }