Video Plugin Architecture

Deep dive into the Video Plugin's architecture, powered by FFmpeg.

Overview

The Video Plugin provides comprehensive video processing capabilities through FFmpeg, the industry-standard multimedia framework.

Key Technologies:

  • FFmpeg: Core video/audio processing engine
  • Node.js child_process: FFmpeg command execution
  • Streaming: Efficient handling of large video files

Plugin Structure

plugins/video/ ├── package.json # Plugin metadata ├── tsconfig.json # TypeScript configuration ├── README.md # Plugin documentation ├── bin/ │ └── cli.js # Standalone CLI entry └── src/ ├── index.ts # Plugin exports ├── register.ts # Plugin registration ├── types.ts # Type definitions ├── utils/ │ ├── ffmpeg-helpers.ts # FFmpeg utility functions │ ├── validation.ts # Input validation │ ├── metadata.ts # Video metadata extraction │ └── presets.ts # Encoding presets └── commands/ ├── transcode.ts # Format/codec conversion ├── resize.ts # Resolution changes ├── trim.ts # Cut video segments ├── concat.ts # Merge videos ├── extract.ts # Extract audio/frames ├── subtitle.ts # Subtitle operations ├── compress.ts # Compression └── thumbnail.ts # Generate thumbnails

Core Dependencies

FFmpeg

Why FFmpeg?

  • Universal: Supports virtually all media formats
  • Powerful: Comprehensive video/audio processing
  • Battle-tested: Industry standard for 20+ years
  • Active: Regular updates and improvements

Installation:

FFmpeg must be installed on the system:

# macOS
brew install ffmpeg

# Ubuntu/Debian
sudo apt install ffmpeg

# Windows
choco install ffmpeg

Official Documentation:
https://ffmpeg.org/documentation.html


FFmpeg Architecture

Input → Demuxer → Decoder → Filter Graph → Encoder → Muxer → Output

Components:

  • Demuxer: Reads container format (MP4, MKV, etc.)
  • Decoder: Decodes video/audio streams
  • Filter Graph: Applies transformations (resize, crop, etc.)
  • Encoder: Encodes to target codec (H.264, VP9, etc.)
  • Muxer: Writes container format

Command Architecture

Command Pattern

All video commands follow this pattern:

import type { Command, CommandContext } from "@mediaproc/core";
import { execFFmpeg } from "../utils/ffmpeg-helpers";

export const transcodeCommand: Command = {
  name: "transcode",
  description: "Convert video format and codec",

  options: [
    {
      name: "codec",
      alias: "c",
      type: "string",
      choices: ["h264", "h265", "vp9", "av1"],
      default: "h264",
      description: "Video codec",
    },
    {
      name: "preset",
      alias: "p",
      type: "string",
      choices: ["ultrafast", "fast", "medium", "slow", "veryslow"],
      default: "medium",
      description: "Encoding preset",
    },
    {
      name: "crf",
      type: "number",
      default: 23,
      description: "Constant Rate Factor (quality)",
    },
  ],

  async handler(context: CommandContext) {
    const { input, options, logger } = context;

    // Validate options
    validateTranscodeOptions(options);

    // Process each input file
    const results = await Promise.all(
      input.map((file) => transcodeVideo(file, options, logger)),
    );

    // Return summary
    return {
      success: true,
      processed: results.filter((r) => r.success).length,
      failed: results.filter((r) => !r.success).length,
    };
  },
};

FFmpeg Command Builder

Build FFmpeg commands programmatically:

function buildFFmpegCommand(
  input: string,
  output: string,
  options: TranscodeOptions,
): string[] {
  const args: string[] = [];

  // Input
  args.push("-i", input);

  // Video codec
  args.push("-c:v", getVideoCodec(options.codec));

  // Encoding preset
  args.push("-preset", options.preset);

  // Quality (CRF)
  args.push("-crf", String(options.crf));

  // Audio codec
  args.push("-c:a", "aac");
  args.push("-b:a", "128k");

  // Output
  args.push(output);

  return args;
}

FFmpeg Execution

Execute FFmpeg with progress tracking:

import { spawn } from "child_process";

async function execFFmpeg(
  args: string[],
  onProgress?: (progress: Progress) => void,
): Promise<void> {
  return new Promise((resolve, reject) => {
    const ffmpeg = spawn("ffmpeg", args, {
      stdio: ["ignore", "pipe", "pipe"],
    });

    let stderr = "";

    ffmpeg.stderr.on("data", (data) => {
      stderr += data.toString();

      // Parse progress
      if (onProgress) {
        const progress = parseFFmpegProgress(stderr);
        if (progress) {
          onProgress(progress);
        }
      }
    });

    ffmpeg.on("close", (code) => {
      if (code === 0) {
        resolve();
      } else {
        reject(new Error(`FFmpeg failed with code ${code}`));
      }
    });

    ffmpeg.on("error", reject);
  });
}

Codec Support

Video Codecs

Supported video codecs and their characteristics:

CodecContainerQualitySpeedSizeHardware Accel
H.264MP4, MKVGoodFastMedium✅ Wide
H.265MP4, MKVExcellentSlowSmall✅ Modern
VP9WebMExcellentVery SlowSmall❌ Limited
AV1MP4, WebMBestSlowestSmallest⚠️ Emerging
ProResMOVExcellentFastLarge✅ Apple

Codec Selection

Choose codec based on use case:

function selectCodec(purpose: string): string {
  const codecMap: Record<string, string> = {
    web: "h264", // Universal web support
    streaming: "h264", // Best compatibility
    archival: "h265", // Small size, high quality
    editing: "prores", // Fast decode, high quality
    youtube: "h264", // Platform recommendation
    "modern-web": "vp9", // Better compression
  };

  return codecMap[purpose] || "h264";
}

Encoding Presets

Quality vs Speed

FFmpeg presets balance encoding speed and compression:

ultrafast ←→ veryslow Fast Slow Large Small Lower Higher Quality Quality

Preset Characteristics:

const presetInfo = {
  ultrafast: {
    speed: "10x realtime",
    size: "100% (baseline)",
    quality: "Acceptable",
    use: "Live streaming, screen recording",
  },

  fast: {
    speed: "5x realtime",
    size: "90%",
    quality: "Good",
    use: "Quick encoding, previews",
  },

  medium: {
    speed: "2x realtime",
    size: "80%",
    quality: "Very good",
    use: "General purpose (default)",
  },

  slow: {
    speed: "1x realtime",
    size: "70%",
    quality: "Excellent",
    use: "Final exports, archival",
  },

  veryslow: {
    speed: "0.5x realtime",
    size: "65%",
    quality: "Best",
    use: "Maximum quality, small size",
  },
};

CRF (Constant Rate Factor)

Quality control via CRF:

CRF Value Quality Use Case ───────────────────────────────────────── 0 Lossless Archival 15-17 Visually lossless Masters 18-23 Excellent Distribution 23-28 Good Streaming 28-35 Acceptable Low bitrate 35+ Poor Avoid

CRF Recommendations:

const crfRecommendations = {
  h264: {
    lossless: 0,
    excellent: 18,
    good: 23,
    acceptable: 28,
  },

  h265: {
    lossless: 0,
    excellent: 20,
    good: 25,
    acceptable: 30,
  },

  vp9: {
    excellent: 15,
    good: 24,
    acceptable: 31,
  },
};

Filter Graphs

FFmpeg Filters

Apply video transformations using filter graphs:

interface FilterOptions {
  scale?: string; // e.g., '1920:1080'
  crop?: string; // e.g., '640:480:0:0'
  fps?: number; // Frame rate
  vflip?: boolean; // Vertical flip
  hflip?: boolean; // Horizontal flip
  rotate?: number; // Rotation angle
  watermark?: string; // Overlay image
}

function buildFilterGraph(options: FilterOptions): string {
  const filters: string[] = [];

  if (options.scale) {
    filters.push(`scale=${options.scale}`);
  }

  if (options.crop) {
    filters.push(`crop=${options.crop}`);
  }

  if (options.fps) {
    filters.push(`fps=${options.fps}`);
  }

  if (options.vflip) {
    filters.push("vflip");
  }

  if (options.hflip) {
    filters.push("hflip");
  }

  if (options.rotate) {
    filters.push(`rotate=${(options.rotate * Math.PI) / 180}`);
  }

  return filters.join(",");
}

Usage:

ffmpeg -i input.mp4 -vf "scale=1920:1080,fps=30" output.mp4

Complex Filter Graphs

Chain multiple filters:

// Picture-in-picture effect
const filterComplex = `
  [0:v]scale=1920:1080[main];
  [1:v]scale=320:180[pip];
  [main][pip]overlay=W-w-10:H-h-10
`;

const args = [
  "-i",
  "main.mp4",
  "-i",
  "overlay.mp4",
  "-filter_complex",
  filterComplex,
  "output.mp4",
];

Metadata Handling

Reading Metadata

Extract video metadata using ffprobe:

import { execFile } from "child_process";
import { promisify } from "util";

const execFileAsync = promisify(execFile);

async function getVideoMetadata(file: string): Promise<VideoMetadata> {
  const { stdout } = await execFileAsync("ffprobe", [
    "-v",
    "quiet",
    "-print_format",
    "json",
    "-show_format",
    "-show_streams",
    file,
  ]);

  const probe = JSON.parse(stdout);
  const videoStream = probe.streams.find((s) => s.codec_type === "video");
  const audioStream = probe.streams.find((s) => s.codec_type === "audio");

  return {
    // Video
    width: videoStream.width,
    height: videoStream.height,
    codec: videoStream.codec_name,
    fps: eval(videoStream.r_frame_rate), // e.g., "30/1" → 30
    bitrate: parseInt(videoStream.bit_rate),
    duration: parseFloat(probe.format.duration),

    // Audio
    audioCodec: audioStream?.codec_name,
    sampleRate: parseInt(audioStream?.sample_rate),
    channels: audioStream?.channels,

    // File
    format: probe.format.format_name,
    size: parseInt(probe.format.size),
  };
}

Preserving Metadata

Copy metadata to output:

const args = [
  "-i",
  input,
  "-map_metadata",
  "0", // Copy all metadata
  "-c",
  "copy", // Stream copy (no re-encode)
  output,
];

Performance Optimization

Hardware Acceleration

Use GPU encoding when available:

function getHardwareEncoder(codec: string, platform: string): string {
  const hwEncoders = {
    darwin: {
      // macOS
      h264: "h264_videotoolbox",
      h265: "hevc_videotoolbox",
    },
    linux: {
      // Linux
      h264: "h264_nvenc", // NVIDIA
      h265: "hevc_nvenc",
      h264_vaapi: "h264_vaapi", // Intel/AMD
    },
    win32: {
      // Windows
      h264: "h264_nvenc",
      h265: "hevc_nvenc",
      h264_qsv: "h264_qsv", // Intel QuickSync
    },
  };

  return hwEncoders[platform]?.[codec] || codec;
}

Usage:

const codec = getHardwareEncoder('h264', process.platform);
const args = ['-c:v', codec, ...];

Two-Pass Encoding

For optimal quality/size ratio:

async function twoPassEncode(
  input: string,
  output: string,
  options: EncodeOptions,
): Promise<void> {
  // Pass 1: Analyze video
  await execFFmpeg([
    "-i",
    input,
    "-c:v",
    options.codec,
    "-b:v",
    options.bitrate,
    "-pass",
    "1",
    "-f",
    "null",
    "/dev/null",
  ]);

  // Pass 2: Encode with analysis
  await execFFmpeg([
    "-i",
    input,
    "-c:v",
    options.codec,
    "-b:v",
    options.bitrate,
    "-pass",
    "2",
    output,
  ]);
}

Parallel Processing

Process segments in parallel:

async function parallelEncode(
  input: string,
  output: string,
  options: EncodeOptions,
): Promise<void> {
  // 1. Get duration
  const metadata = await getVideoMetadata(input);
  const segmentDuration = 60; // 60 seconds per segment
  const numSegments = Math.ceil(metadata.duration / segmentDuration);

  // 2. Encode segments in parallel
  const segments = await Promise.all(
    Array.from({ length: numSegments }, (_, i) => {
      const start = i * segmentDuration;
      return encodeSegment(input, start, segmentDuration, options);
    }),
  );

  // 3. Concatenate segments
  await concatenateSegments(segments, output);
}

Error Handling

FFmpeg Errors

Parse and handle FFmpeg errors:

function parseFFmpegError(stderr: string): string {
  // Extract meaningful error
  const errorPatterns = [
    /Error .* (.*)/,
    /Invalid .* (.*)/,
    /(.*): No such file or directory/,
    /Unsupported codec (.*)/,
  ];

  for (const pattern of errorPatterns) {
    const match = stderr.match(pattern);
    if (match) {
      return match[1];
    }
  }

  return "Unknown FFmpeg error";
}

Validation

Validate video files before processing:

async function validateVideo(file: string): Promise<void> {
  try {
    const metadata = await getVideoMetadata(file);

    if (!metadata.width || !metadata.height) {
      throw new Error("Invalid video file");
    }

    if (metadata.duration === 0) {
      throw new Error("Video has zero duration");
    }
  } catch (error) {
    throw new Error(`Validation failed: ${error.message}`);
  }
}

Progress Tracking

Parse FFmpeg Progress

Extract progress from FFmpeg output:

interface Progress {
  frame: number;
  fps: number;
  time: number;
  speed: string;
  percentage: number;
}

function parseFFmpegProgress(stderr: string): Progress | null {
  const frameMatch = stderr.match(/frame=\s*(\d+)/);
  const fpsMatch = stderr.match(/fps=\s*([\d.]+)/);
  const timeMatch = stderr.match(/time=(\d+):(\d+):(\d+\.\d+)/);
  const speedMatch = stderr.match(/speed=\s*([\d.]+)x/);

  if (!timeMatch) return null;

  const [, hours, minutes, seconds] = timeMatch;
  const currentTime =
    parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseFloat(seconds);

  return {
    frame: frameMatch ? parseInt(frameMatch[1]) : 0,
    fps: fpsMatch ? parseFloat(fpsMatch[1]) : 0,
    time: currentTime,
    speed: speedMatch ? speedMatch[1] : "0",
    percentage: (currentTime / totalDuration) * 100,
  };
}

Progress Reporting

Display progress to user:

async function transcodeWithProgress(
  input: string,
  output: string,
  options: any,
): Promise<void> {
  const metadata = await getVideoMetadata(input);
  const progressBar = createProgressBar(metadata.duration);

  await execFFmpeg(args, (progress) => {
    progressBar.update(progress.time);

    console.log(
      `Frame: ${progress.frame} | ` +
        `FPS: ${progress.fps} | ` +
        `Speed: ${progress.speed}x`,
    );
  });

  progressBar.stop();
}

Testing

Unit Tests

Test command logic:

describe("Transcode Command", () => {
  it("should build correct FFmpeg command", () => {
    const args = buildFFmpegCommand("input.mp4", "output.mp4", {
      codec: "h264",
      preset: "medium",
      crf: 23,
    });

    expect(args).toContain("-c:v");
    expect(args).toContain("libx264");
    expect(args).toContain("-preset");
    expect(args).toContain("medium");
  });
});

Integration Tests

Test with actual videos:

describe("Video Processing", () => {
  it("should transcode video", async () => {
    await transcodeVideo("test.mp4", {
      codec: "h264",
      preset: "fast",
      crf: 23,
    });

    const metadata = await getVideoMetadata("test-transcoded.mp4");
    expect(metadata.codec).toBe("h264");
  });
});


Learn More

Found an issue? Help us improve this page.

Edit on GitHub →