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