lib/ffmpex.ex

defmodule FFmpex do
  @moduledoc """
  Create and execute ffmpeg CLI commands.

  The API is a builder, building up the list of options
  per-file, per-stream(-per-file), and globally.

  Note that adding options is backwards from using
  the ffmpeg CLI; when using ffmpeg CLI, you specify the options
  before each file.
  But with FFmpex (this library), you add the file/stream first, then
  add the relevant options afterward.

  ## Example

      import FFmpex
      use FFmpex.Options

      command =
        FFmpex.new_command
        |> add_global_option(option_y())
        |> add_input_file("/path/to/input.avi")
        |> add_output_file("/path/to/output.avi")
          |> add_stream_specifier(stream_type: :video)
            |> add_stream_option(option_b("64k"))
          |> add_file_option(option_maxrate("128k"))
          |> add_file_option(option_bufsize("64k"))

      :ok = execute(command)
  """
  alias FFmpex.Command
  alias FFmpex.File
  alias FFmpex.Option
  alias FFmpex.StreamSpecifier

  @doc """
  Begin a new blank (no options) ffmpeg command.
  """
  def new_command, do: %Command{}

  @doc """
  Add an input file to the command.
  """
  def add_input_file(%Command{files: files} = command, %File{} = file) do
    file = %File{file | type: :input}
    %Command{command | files: [file | files]}
  end

  def add_input_file(%Command{files: files} = command, file_path) when is_binary(file_path) do
    file = %File{type: :input, path: file_path}
    %Command{command | files: [file | files]}
  end

  @doc """
  Add an output file to the command.
  """
  def add_output_file(%Command{files: files} = command, %File{} = file) do
    file = %File{file | type: :output}
    %Command{command | files: [file | files]}
  end

  def add_output_file(%Command{files: files} = command, file_path) when is_binary(file_path) do
    file = %File{type: :output, path: file_path}
    %Command{command | files: [file | files]}
  end

  @doc """
  Outputs to stdout, so it can be used directly from `execute/1`'s output

  ##### this option cannot be used with output files
  """
  def to_stdout(%Command{files: files} = command) do
    file = %File{type: :output, path: "-"}
    %Command{command | files: [file | files]}
  end

  @doc """
  Add a stream specifier to the most recent file.
  The stream specifier is used as a target for per-stream options.

  ## Example

      add_stream_specifier(command, stream_type: :video)

  ## Options

  * `:stream_index` - 0-based integer index for the stream
  * `:stream_type` - One of `:video`, `:video_without_pics`, `:audio`, `:subtitle`, `:data`, `:attachments`
  * `:program_id` - ID for the program
  * `:stream_id` - Stream id (e.g. PID in MPEG-TS container)
  * `:metadata_key` - Match streams with the given metadata tag
  * `:metadata_value` - Match streams with the given metadata value. Must also specify `:metadata_key`.
  * `:usable` - Matches streams with usable configuration, the codec must be defined and the essential information such as video dimension or audio sample rate must be present.
  """
  @spec add_stream_specifier(command :: Command.t, opts :: Keyword.t) :: Command.t
  def add_stream_specifier(%Command{files: [file | files]} = command, opts) do
    stream_specifier = struct(StreamSpecifier, opts)
    file = %File{file | stream_specifiers: [stream_specifier | file.stream_specifiers]}
    %Command{command | files: [file | files]}
  end

  @doc """
  Add a global option that applies to the entire command.
  """
  def add_global_option(%Command{global_options: options} = command, %Option{contexts: contexts} = option) do
    validate_contexts!(contexts, :global)
    %Command{command | global_options: [option | options]}
  end

  @doc """
  Add a per-file option to the command.

  Applies to the most recently added file.
  """
  def add_file_option(%Command{files: [file | files]} = command, %Option{contexts: contexts} = option) do
    %{type: file_io_type} = file
    validate_contexts!(contexts, file_io_type)

    file = %File{file | options: [option | file.options]}
    %Command{command | files: [file | files]}
  end

  @doc """
  Add a per-stream option to the command.

  Applies to the most recently added stream specifier, of the most recently added file.
  """
  def add_stream_option(%Command{files: [file | files]} = command, %Option{contexts: contexts} = option) do
    %{type: file_io_type} = file
    validate_contexts!(contexts, file_io_type)

    %File{stream_specifiers: [stream_specifier | stream_specifiers]} = file
    stream_specifier = %StreamSpecifier{stream_specifier | options: [option | stream_specifier.options]}
    file = %File{file | stream_specifiers: [stream_specifier | stream_specifiers]}
    %Command{command | files: [file | files]}
  end

  @doc """
  Execute the command using ffmpeg CLI.

  Returns `{:ok, output}` on success, or `{:error, {cmd_output, exit_status}}` on error.
  """
  @spec execute(command :: Command.t) :: {:ok, binary()} | {:error, {Collectable.t, exit_status :: non_neg_integer}}
  def execute(%Command{} = command) do
    {executable, args} = prepare(command)

    Rambo.run(executable, args, log: false)
    |> format_output()
  end

  @doc """
  Prepares the command to be executed, by converting the `%Command{}` into
  proper parameters to be feeded to `System.cmd/3` or `Port.open/2`.

  Under normal circumstances `FFmpex.execute/1` should be used, use `prepare`
  only when converted args are needed to be feeded in a custom execution method.

  Returns `{ffmpeg_executable_path, list_of_args}`.
  """
  @spec prepare(command :: Command.t) :: {binary() | nil, list(binary)}
  def prepare(%Command{files: files, global_options: options}) do
    options = Enum.map(options, &arg_for_option/1)
    cmd_args = List.flatten([options, options_list(files)])
    {ffmpeg_path(), cmd_args}
  end

  defp options_list(files) do
    input_files = Enum.filter(files, fn %File{type: type} -> type == :input end)
    output_files = Enum.filter(files, fn %File{type: type} -> type == :output end)
    options_list(input_files, output_files)
  end

  defp options_list(input_files, output_files, acc \\ [])
  defp options_list([], [], acc), do: List.flatten(acc)
  defp options_list(input_files, [output_file | output_files], acc) do
    acc = [File.command_arguments(output_file), output_file.path | acc]
    options_list(input_files, output_files, acc)
  end
  defp options_list([input_file | input_files], [], acc) do
    acc = [File.command_arguments(input_file), "-i", input_file.path | acc]
    options_list(input_files, [], acc)
  end

  defp arg_for_option(%Option{name: name, require_arg: false, argument: nil}) do
    ~w(#{name})
  end
  defp arg_for_option(%Option{name: name, argument: arg}) when not is_nil(arg) do
    ~w(#{name} #{arg})
  end

  defp validate_contexts!(:unspecified, _), do: :ok
  defp validate_contexts!(contexts, required) when is_list(contexts) do
    unless Enum.member?(contexts, required), do: raise ArgumentError
  end

  defp format_output({:ok, %{out: stdout}}), do:
    {:ok, stdout}
  defp format_output({:error, reason}) when is_binary(reason), do:
    {:error, {reason, 1}}
  defp format_output({:error, %{err: stderr, status: exit_status}}), do:
    {:error, {stderr, exit_status}}

  # Read ffmpeg path from config. If unspecified, assume `ffmpeg` is in env $PATH.
  defp ffmpeg_path do
    case Application.get_env(:ffmpex, :ffmpeg_path, nil) do
      nil -> System.find_executable("ffmpeg")
      path -> path
    end
  end
end