Skip to main content

lib/attached/processors/metadata_extractors/video/ffmpeg.ex

defmodule Attached.Processors.MetadataExtractors.Video.FFmpeg do
  @moduledoc """
  Extracts width, height, duration, angle, display_aspect_ratio, and
  audio/video channel flags from video files using `ffprobe`
  (ships with ffmpeg).
  """

  @behaviour Attached.Processors.MetadataExtractors.Behaviour

  @video_types ~w(video/mp4 video/mpeg video/ogg video/webm video/quicktime
                  video/x-msvideo video/x-matroska video/3gpp)

  @impl true
  def accept?(content_type), do: content_type in @video_types

  @impl true
  def available?, do: not is_nil(System.find_executable(ffprobe_cmd()))

  @impl true
  def install_hint do
    "Install ffmpeg: `brew install ffmpeg`, `apt install ffmpeg`, or `nix-shell -p ffmpeg`. The `ffprobe` CLI ships with ffmpeg."
  end

  @impl true
  def metadata(input_path) do
    case probe(input_path) do
      {:ok, data} -> extract(data)
      :error -> %{}
    end
  end

  defp extract(data) do
    streams = Map.get(data, "streams", [])
    container = Map.get(data, "format", %{})

    video = Enum.find(streams, &(&1["codec_type"] == "video"))
    audio = Enum.find(streams, &(&1["codec_type"] == "audio"))

    angle = rotation_angle(video)
    rotated = angle in [90, 270, -90, -270]

    raw_w = get_float(video, "width")
    raw_h = get_float(video, "height")
    {w, h} = if rotated, do: {raw_h, raw_w}, else: {raw_w, raw_h}

    %{
      width: w,
      height: h,
      duration: get_float(video, "duration") || get_float(container, "duration"),
      angle: if(angle != 0, do: angle),
      display_aspect_ratio: parse_aspect_ratio(video && video["display_aspect_ratio"]),
      audio: not is_nil(audio),
      video: not is_nil(video)
    }
    |> Enum.reject(fn {_, v} -> is_nil(v) end)
    |> Map.new()
  end

  defp probe(input_path) do
    args = ~w(-print_format json -show_streams -show_format -v error) ++ [input_path]

    try do
      case System.cmd(ffprobe_cmd(), args, stderr_to_stdout: true) do
        {output, 0} ->
          case Jason.decode(output) do
            {:ok, data} -> {:ok, data}
            _ -> :error
          end

        _ ->
          :error
      end
    rescue
      ErlangError -> :error
    end
  end

  defp rotation_angle(nil), do: 0

  defp rotation_angle(video_stream) do
    tags = Map.get(video_stream, "tags", %{})
    side_data = Map.get(video_stream, "side_data_list", [])

    cond do
      rotate = tags["rotate"] ->
        String.to_integer(rotate)

      dm = Enum.find(side_data, &(&1["side_data_type"] == "Display Matrix")) ->
        Map.get(dm, "rotation", 0)

      true ->
        0
    end
  end

  defp parse_aspect_ratio(nil), do: nil

  defp parse_aspect_ratio(s) do
    case String.split(s, ":") do
      [n, d] ->
        case {Integer.parse(n), Integer.parse(d)} do
          {{num, _}, {den, _}} when num > 0 -> [num, den]
          _ -> nil
        end

      _ ->
        nil
    end
  end

  defp get_float(nil, _), do: nil

  defp get_float(map, key) do
    case map[key] do
      nil ->
        nil

      val ->
        case Float.parse(to_string(val)) do
          {f, _} -> f
          :error -> nil
        end
    end
  end

  defp ffprobe_cmd do
    Application.get_env(:attached, :ffmpeg, [])
    |> Keyword.get(:ffprobe_bin, "ffprobe")
  end
end