lib/reader.ex

defmodule Y4mReader do
  @moduledoc """
  Read properties and frames from a *.y4m file
  """
  defstruct [:file, :properties]
  alias Y4mReader.HeaderParser
  alias Y4mReader.FrameIterator

  @doc """
  Read file header from the IO `device` and return Y4mReader.
  """
  @spec read(atom | pid) :: %__MODULE__{} | {:invalid_header, binary()}
  def read(file) do
    header = IO.read(file, :line)

    case HeaderParser.parse(header) do
      {:invalid_header, details} ->
        {:invalid_header, details}

      {:ok, props} ->
        %__MODULE__{
          file: file,
          properties: props
        }
    end
  end

  defp plane_lengths(:C444, width, height),
    do: [
      width * height,
      width * height,
      width * height
    ]

  defp plane_lengths(:C420, width, height),
    do: [
      width * height,
      trunc(width * height / 2),
      trunc(width * height / 2)
    ]

  defp plane_lengths(_not_supported, _width, _height),
    # we only support C444, C420
    do: {:error, :unsupported_colorspace}

  @doc """
  Read the next frame.
  """
  @spec next_frame(%__MODULE__{}) :: :eof | list(binary()) | {:error, atom}
  def next_frame(%__MODULE__{properties: %{color_space: cs, width: w, height: h}} = dec) do
    [y_plane_len, u_plane_len, v_plane_len] =
      case plane_lengths(cs, w, h) do
        [y_len, u_len, v_len] -> [y_len, u_len, v_len]
        {:error, :unsupported_colorspace} -> {:error, :unsupported_colorspace}
      end

    # read six bytes and look for magic frame header
    case IO.binread(dec.file, 6) do
      "FRAME\n" ->
        # read the three color planes from the file
        y_plane = IO.binread(dec.file, y_plane_len)
        u_plane = IO.binread(dec.file, u_plane_len)
        v_plane = IO.binread(dec.file, v_plane_len)

        # convert them to lists
        [y_plane, u_plane, v_plane]

      :eof ->
        # end of file
        :eof

      {:error, reason} ->
        # error from binread
        {:error, reason}

      _not_FRAME ->
        # unexpected file format
        {:error, :parsing_error}
    end
  end

  @doc """
  Return iterator over all frames in this file stream.
  """
  @spec iter_frames(%__MODULE__{}) :: Enumerable.t()
  def iter_frames(%__MODULE__{} = dec) do
    FrameIterator.init(dec)
  end
end

defmodule Y4mReader.HeaderParser do
  @moduledoc """
  Parser to deserialize y4m file header.
  """

  # TODO: explore options to infer color_space from file length
  @default_values %{color_space: :C420}

  @doc """
  Deserialize first line in y4m file.
  """
  @spec parse(binary) :: {:ok, any()} | {:invalid_header, binary()}
  def parse(header_line) do
    case String.split(header_line) do
      ["YUV4MPEG2" | rest] ->
        case continue(rest, @default_values) do
          %{} = props -> {:ok, props}
          {:error, reason} -> {:invalid_header, reason}
        end

      _ ->
        {:invalid_header, "unexpected beginning of file"}
    end
  end

  defp continue([], props), do: props

  defp continue([<<"W", width::binary>> | t], props) do
    case Integer.parse(width) do
      {width, _} -> continue(t, Map.put(props, :width, width))
      :error -> {:error, "cannot parse width: #{width}"}
    end
  end

  defp continue([<<"H", height::binary>> | t], props) do
    case Integer.parse(height) do
      {height, _} -> continue(t, Map.put(props, :height, height))
      :error -> {:error, "cannot parse height: #{height}"}
    end
  end

  defp continue([<<"F", frame_rate::binary>> | t], props) do
    with [nom, den] <- String.split(frame_rate, ":"),
         [{nom, rest}, {den, rest}] <- Enum.map([nom, den], &Integer.parse/1) do
      continue(t, Map.put(props, :frame_rate, [nom, den]))
    else
      _ -> {:error, "cannot parse frame rate: #{frame_rate}"}
    end
  end

  defp continue([<<"I", interlacing::binary>> | t], props) do
    case interlacing do
      "p" -> continue(t, Map.put(props, :interlacing, :progressive))
      "t" -> continue(t, Map.put(props, :interlacing, :top_field_first))
      "b" -> continue(t, Map.put(props, :interlacing, :bottom_field_first))
      "m" -> continue(t, Map.put(props, :interlacing, :mixed))
      _ -> {:error, "unknown interlacing: #{interlacing}"}
    end
  end

  defp continue([<<"A", aspect_ratio::binary>> | t], props) do
    with [nom, den] <- String.split(aspect_ratio, ":"),
         [{nom, rest}, {den, rest}] <- Enum.map([nom, den], &Integer.parse/1) do
      continue(t, Map.put(props, :aspect_ratio, [nom, den]))
    else
      _ -> {:error, "cannot parse aspect ration: #{aspect_ratio}"}
    end
  end

  defp continue([<<"C", color_space::binary>> | t], props) do
    case color_space do
      "420jpeg" -> continue(t, Map.put(props, :color_space, :C420jpeg))
      "420paldv" -> continue(t, Map.put(props, :color_space, :C420paldv))
      "420" -> continue(t, Map.put(props, :color_space, :C420))
      "422" -> continue(t, Map.put(props, :color_space, :C422))
      "444" -> continue(t, Map.put(props, :color_space, :C444))
      "mono" -> continue(t, Map.put(props, :color_space, :Cmono))
      _ -> {:error, "unknown color space: #{color_space}"}
    end
  end

  defp continue([<<"X", param::binary>> | t], props) do
    [name, value] =
      param
      |> String.split("=")

    params = Map.get(props, :params, [])
    continue(t, Map.put(props, :params, [[name, value] | params]))
  end
end

defmodule Y4mReader.FrameIterator do
  defstruct [:decoder]

  @spec init(%Y4mReader{}) :: %Y4mReader.FrameIterator{}
  def init(dec) do
    %__MODULE__{decoder: dec}
  end
end

defimpl Enumerable, for: Y4mReader.FrameIterator do
  alias Y4mReader.FrameIterator

  @spec slice(%Y4mReader.FrameIterator{}) :: none
  def slice(_iter) do
    raise RuntimeError, "FrameIterator only supports reduce"
  end

  @spec member?(%FrameIterator{}, any) :: none
  def member?(_iter, _e) do
    raise RuntimeError, "FrameIterator only supports reduce"
  end

  @spec count(%FrameIterator{}) :: none
  def count(_) do
    raise RuntimeError, "FrameIterator only supports reduce"
  end

  def reduce(%FrameIterator{}, {:halt, acc}, _fun) do
    {:halted, acc}
  end

  def reduce(%FrameIterator{} = iter, {:suspend, acc}, fun) do
    {:suspended, acc, &reduce(iter, &1, fun)}
  end

  def reduce(%FrameIterator{decoder: dec} = is, {:cont, acc}, fun) do
    case Y4mReader.next_frame(dec) do
      [y, u, v] -> reduce(is, fun.([y, u, v], acc), fun)
      :eof -> {:done, acc}
    end
  end
end