Packages
image
0.72.0
0.72.0
0.71.0
0.70.0
0.69.0
0.68.0
0.67.0
0.67.0-dev
retired
0.66.0
0.65.0
0.64.0
0.63.0
0.62.1
0.62.0
0.61.1
0.61.0
0.60.0
0.59.3
0.59.2
0.59.1
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.2
0.55.1
retired
0.55.0
0.54.4
0.54.3
0.54.2
0.54.1
0.54.0
0.53.0
0.52.3
0.52.2
0.52.1
0.52.0
retired
0.51.0
0.50.0
0.49.0
0.48.1
0.48.0
0.47.0
0.46.0
0.45.0
0.44.0
0.43.2
0.43.1
0.43.0
0.42.0
0.41.0
0.40.0
0.39.3
0.39.2
0.39.1
0.39.0
0.38.4
0.38.3
0.38.2
0.38.1
0.38.0
0.37.0
0.36.2
0.36.1
0.36.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.1
0.31.0
0.30.0
0.29.0
0.28.2
0.28.1
0.28.0
0.27.0
0.26.0
0.25.1
0.25.0
0.24.1
0.24.0
0.23.2
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
retired
0.19.0
0.18.1
0.18.0
0.17.0
0.16.0
0.15.0
0.14.4
0.14.2
0.14.1
0.14.0
retired
0.13.1
0.13.0
0.12.0
0.11.0
0.10.0
0.10.0-rc.0
retired
0.9.0
retired
0.8.0
0.7.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.0
0.1.0
An approachable image processing library primarily based upon Vix and libvips that is NIF-based, fast, multi-threaded, pipelined and has a low memory footprint.
Current section
Files
Jump to
Current section
Files
lib/image/video.ex
if Image.xav_configured?() do
defmodule Image.Video do
@moduledoc """
Functions to extract frames from a video file or device as
images using [Xav](https://hex.pm/packages/xav), an Elixir
wrapper around FFmpeg.
Frames can be extracted by frame number or by millisecond
offset with `Image.Video.image_from_video/2`. Streams of
frames can be produced with `Image.Video.stream!/2`.
A video must first be opened with `Image.Video.open/2`. The
underlying Xav reader is garbage-collected, so explicit
`close/1` is no longer required, but the function is provided
as a no-op for source compatibility.
The pattern can be wrapped by `Image.Video.with_video/2` which
opens a video, executes a function with the video reference,
and (since closing is no longer required) simply discards the
reference at the end.
## Note
This module is only available if the optional dependency
[Xav](https://hex.pm/packages/xav) is in your `mix.exs`. Xav
in turn requires FFmpeg ≥ 6.0 to be installed on the system.
## Migration from the eVision-backed implementation
Earlier releases of `Image` used `:evision` (OpenCV) for
video frame extraction. From version 0.66.0 the implementation
is FFmpeg-based via `:xav`. The public function shapes are the
same with these intentional differences:
* The opaque video struct is `%Image.Video{}` rather than
`%Evision.VideoCapture{}`. Pattern-match on the new struct
module if your code does so.
* Backend selection (the `:backend` option to `open/2`) has
been removed. FFmpeg is the only backend.
* Camera input is now opened with a device path string rather
than an integer index. `:default_camera` still works on
Linux (resolves to `/dev/video0`) and on macOS (resolves to
AVFoundation device 0). Other camera indices need an
explicit device string.
* Frame-based seeking (`seek(video, frame: n)` and
`image_from_video(video, frame: n)`) is now implemented as
a time-based seek to `n / fps` followed by zero or more
`next_frame` calls to land on the exact frame. For
keyframe-only files this is exact; for inter-frame
compressed files (the common case) the behaviour is the
same since FFmpeg seeks to the nearest keyframe and decodes
forward.
"""
alias Vix.Vips.Image, as: Vimage
@typedoc """
The valid options for `Image.Video.seek/2` and
`Image.Video.image_from_video/2`.
"""
@type seek_options :: [frame: non_neg_integer()] | [millisecond: non_neg_integer()]
@typedoc """
A video source. Either a file path / URL accepted by FFmpeg,
`:default_camera` for the system's first webcam, or an
explicit device path / integer index.
"""
@type source :: Path.t() | :default_camera | non_neg_integer() | String.t()
@typedoc """
Options for `Image.Video.open/2`. Currently empty — the
`:backend` option supported by the previous eVision-backed
implementation has been removed.
"""
@type open_options :: []
@typedoc """
Options for `Image.Video.stream!/2`.
"""
@type stream_options :: [
{:start, non_neg_integer()}
| {:finish, integer()}
| {:step, pos_integer()}
| {:frame, non_neg_integer() | nil}
| {:millisecond, non_neg_integer() | nil}
]
@typedoc """
The representation of an open video.
`:reader` holds the underlying `Xav.Reader` struct. The
derived `:fps`, `:duration_seconds`, `:frame_count`, `:width`,
and `:height` fields are computed at open time so callers can
pattern-match without re-querying FFmpeg.
"""
@type t :: %__MODULE__{
reader: Xav.Reader.t() | nil,
source: source(),
fps: float(),
duration_seconds: float(),
frame_count: non_neg_integer(),
width: pos_integer() | nil,
height: pos_integer() | nil
}
defstruct [:reader, :source, :fps, :duration_seconds, :frame_count, :width, :height]
@doc subject: "Guard"
@doc "Guards that a frame offset is valid for a video"
defguard is_frame(frame, frame_count)
when (is_integer(frame) and frame >= 0 and frame <= frame_count - 1) or
(is_integer(frame) and frame_count == 0)
@doc subject: "Guard"
@doc "Guards that a millisecond count is valid for a video"
defguard is_valid_millis(millis, duration_seconds)
when is_integer(millis) and millis >= 0 and
(millis <= trunc(duration_seconds * 1000) - 1 or duration_seconds == 0)
@doc subject: "Guard"
@doc "Guards that a stream identifier is valid for a video device"
defguard is_stream(stream_id)
when (is_integer(stream_id) and stream_id >= 0) or stream_id == :default_camera
# ----- with_video --------------------------------------------------------
@doc """
Opens a video, calls the given function with the video
reference, and discards the reference when the function
returns.
### Arguments
* `source` is the filename of a video file, a URL accepted
by FFmpeg, or a device specifier — see `open/2`.
* `fun` is a 1-arity function called with the open
`%Image.Video{}` struct.
### Returns
* The result of `fun.(video)` or
* `{:error, reason}` if the video could not be opened.
### Example
iex> result = Image.Video.with_video("./test/support/video/video_sample.mp4", &Image.Video.image_from_video/1)
iex> match?({:ok, %Vix.Vips.Image{}}, result)
true
"""
@doc subject: "Load and save"
@spec with_video(source(), (t() -> any())) :: any()
def with_video(source, fun) when is_function(fun, 1) do
case open(source) do
{:ok, video} ->
try do
fun.(video)
after
close(video)
end
{:error, _} = err ->
err
end
end
# ----- open / open! ------------------------------------------------------
@doc """
Opens a video for frame extraction.
### Arguments
* `source` is one of:
* a file path to a video file;
* a URL accepted by FFmpeg (`http://`, `https://`,
`rtmp://`, `rtsp://`, …);
* the atom `:default_camera` for the system's first webcam.
Resolves to `/dev/video0` on Linux. On macOS this is
passed to FFmpeg's AVFoundation input;
* a non-negative integer camera index. Resolves to
`/dev/videoN` on Linux. Use a device path string on
other platforms;
* a device path string interpreted by FFmpeg directly.
* `options` is a keyword list. Currently no options are
defined; the `:backend` option supported by previous
releases has been removed.
### Returns
* `{:ok, %Image.Video{}}` on success or
* `{:error, %Image.Error{}}`.
### Example
iex> {:ok, video} = Image.Video.open("./test/support/video/video_sample.mp4")
iex> video.fps
30.0
"""
@doc subject: "Load and save"
@spec open(source(), open_options()) :: {:ok, t()} | {:error, Image.error()}
def open(source, options \\ [])
def open(source, _options) when is_binary(source) do
do_open(source, source, device?: false)
end
def open(:default_camera, _options) do
device = default_camera_path()
do_open(device, :default_camera, device?: true)
end
def open(camera, _options) when is_integer(camera) and camera >= 0 do
device = camera_path(camera)
do_open(device, camera, device?: true)
end
@doc """
Opens a video for frame extraction, raising on error.
See `open/2`.
### Example
iex> video = Image.Video.open!("./test/support/video/video_sample.mp4")
iex> video.fps
30.0
"""
@doc subject: "Load and save"
@spec open!(source()) :: t() | no_return()
def open!(source) do
case open(source) do
{:ok, video} -> video
{:error, error} -> raise error
end
end
defp do_open(path_or_device, source, xav_options) do
case Xav.Reader.new(path_or_device, xav_options) do
{:ok, reader} ->
{:ok, build(reader, source)}
{:error, reason} ->
{:error,
Image.Error.wrap(reason,
operation: :video_open,
path: path_or_device
)}
end
end
defp build(%Xav.Reader{} = reader, source) do
fps = framerate_to_fps(reader.framerate)
duration_seconds = reader.duration * 1.0
%__MODULE__{
reader: reader,
source: source,
fps: fps,
duration_seconds: duration_seconds,
frame_count: trunc(fps * duration_seconds),
width: nil,
height: nil
}
end
defp framerate_to_fps({num, den}) when is_integer(num) and is_integer(den) and den > 0,
do: num / den
defp framerate_to_fps(_), do: 0.0
# ----- close -------------------------------------------------------------
@doc """
Closes a video.
Xav's reader is garbage-collected so explicit close is not
required. This function is provided for source compatibility
with the previous implementation: it returns
`{:ok, %Image.Video{reader: nil}}` so subsequent operations
against the same struct will fail with a clear error.
### Arguments
* `video` is any `t:Image.Video.t/0` returned from `open/2`.
### Returns
* `{:ok, video}` where `video.reader` is now `nil`.
### Example
iex> video = Image.Video.open!("./test/support/video/video_sample.mp4")
iex> {:ok, closed} = Image.Video.close(video)
iex> closed.reader
nil
"""
@doc subject: "Load and save"
@spec close(t()) :: {:ok, t()}
def close(%__MODULE__{} = video) do
{:ok, %{video | reader: nil}}
end
@doc """
Closes a video, raising on error.
See `close/1`.
### Example
iex> video = Image.Video.open!("./test/support/video/video_sample.mp4")
iex> closed = Image.Video.close!(video)
iex> closed.reader
nil
"""
@doc subject: "Load and save"
@spec close!(t()) :: t()
def close!(%__MODULE__{} = video) do
{:ok, closed} = close(video)
closed
end
# ----- stream! -----------------------------------------------------------
@doc """
Returns a `Stream` of images from a video.
### Arguments
* `video` is any `t:Image.Video.t/0` returned from `open/2`.
* `options` is a keyword list of options.
### Options
* `:frame` — start frame offset (default `0`).
* `:millisecond` — start millisecond offset.
* `:start` — same as `:frame` (kept for back-compat).
* `:finish` — last frame offset, inclusive. Default `-1`
(meaning to the end of the video).
* `:step` — number of frames to advance between yielded
frames. Default `1`.
Only one of `:frame` / `:millisecond` may be supplied.
### Returns
* A `Stream` that produces `t:Vix.Vips.Image.t/0` images
lazily as enumerated.
### Example
iex> video = Image.Video.open!("./test/support/video/video_sample.mp4")
iex> video |> Image.Video.stream!(start: 0, finish: 2) |> Enum.count()
3
"""
@doc subject: "Load and save"
@spec stream!(t(), stream_options()) :: Enumerable.t()
def stream!(video, options \\ [])
def stream!(%__MODULE__{reader: nil}, _options) do
raise Image.Error,
reason: :video_closed,
message: "Video has been closed"
end
def stream!(%__MODULE__{} = video, options) do
start_frame = start_frame(video, options)
finish_frame = finish_frame(video, options)
step = step(options)
Stream.resource(
fn -> {video, start_frame, finish_frame, step, true} end,
&advance_stream/1,
fn _state -> :ok end
)
end
defp start_frame(video, options) do
millisecond = Keyword.get(options, :millisecond)
frame = Keyword.get(options, :frame)
cond do
millisecond && frame ->
raise Image.Error,
"Only one of :frame and :millisecond may be supplied. " <>
"Found frame: #{inspect(frame)} and millisecond: #{inspect(millisecond)}"
millisecond ->
millisecond_to_frame(video, millisecond)
frame ->
frame
true ->
Keyword.get(options, :start, 0)
end
end
defp finish_frame(video, options) do
case Keyword.get(options, :finish, -1) do
-1 ->
video.frame_count - 1
n when is_integer(n) and n >= 0 ->
n
other ->
raise Image.Error,
"Invalid :finish option. Must be -1 or a non-negative integer. " <>
"Found #{inspect(other)}"
end
end
defp step(options) do
case Keyword.get(options, :step, 1) do
n when is_integer(n) and n >= 1 ->
n
other ->
raise Image.Error,
"Invalid :step option. Must be a positive integer. Found #{inspect(other)}"
end
end
defp advance_stream({video, current, finish, _step, _seek_first}) when current > finish do
{:halt, video}
end
defp advance_stream({video, current, finish, step, true}) do
_ = seek_to_frame(video, current)
emit_current_frame(video, current, finish, step)
end
defp advance_stream({video, current, finish, step, false}) do
# Advance step - 1 frames (already at the previous yielded frame),
# then yield.
Enum.each(1..(step - 1)//1, fn _ -> Xav.Reader.next_frame(video.reader) end)
emit_current_frame(video, current, finish, step)
end
defp emit_current_frame(video, current, finish, step) do
case Xav.Reader.next_frame(video.reader) do
{:ok, frame} ->
case frame_to_image(frame) do
{:ok, image} -> {[image], {video, current + step, finish, step, false}}
{:error, reason} -> raise Image.Error, reason
end
{:error, :eof} ->
{:halt, video}
end
end
# ----- seek --------------------------------------------------------------
@doc """
Seeks the video head to a frame or millisecond offset.
Note that seeking is not supported on live video streams
such as a webcam.
### Arguments
* `video` is any `t:Image.Video.t/0` returned from `open/2`.
* `options` is a keyword list with **exactly one** of:
* `frame: non_neg_integer()` — seek to a frame offset.
* `millisecond: non_neg_integer()` — seek to a millisecond
offset.
### Returns
* `{:ok, video}` on success or
* `{:error, %Image.Error{}}`.
### Example
iex> {:ok, video} = Image.Video.open("./test/support/video/video_sample.mp4")
iex> {:ok, _} = Image.Video.seek(video, frame: 0)
iex> {:ok, _} = Image.Video.seek(video, millisecond: 1_000)
iex> {:error, %Image.Error{reason: :negative_offset}} = Image.Video.seek(video, frame: -1)
iex> :ok
:ok
"""
@doc subject: "Operation"
@spec seek(t(), seek_options()) :: {:ok, t()} | {:error, Image.error()}
def seek(video, options)
def seek(%__MODULE__{reader: nil}, _options) do
{:error, video_closed_error()}
end
def seek(%__MODULE__{} = video, [{:frame, frame}])
when is_frame(frame, video.frame_count) do
seconds = frame / max(video.fps, 1.0)
case Xav.Reader.seek(video.reader, seconds) do
:ok -> {:ok, video}
{:error, reason} -> {:error, Image.Error.wrap(reason, operation: :video_seek)}
end
end
def seek(%__MODULE__{} = video, [{:millisecond, millis}])
when is_valid_millis(millis, video.duration_seconds) do
case Xav.Reader.seek(video.reader, millis / 1000) do
:ok -> {:ok, video}
{:error, reason} -> {:error, Image.Error.wrap(reason, operation: :video_seek)}
end
end
def seek(%__MODULE__{}, [{unit, offset}])
when unit in [:frame, :millisecond] and is_integer(offset) and offset < 0 do
message =
"Offset for #{inspect(unit)} must be a non-negative integer. Found #{inspect(offset)}"
{:error, %Image.Error{reason: :negative_offset, value: offset, message: message}}
end
def seek(%__MODULE__{}, [{unit, _offset}]) when unit in [:frame, :millisecond] do
{:error,
%Image.Error{
reason: :frame_out_of_range,
message: "Offset for #{inspect(unit)} is too large"
}}
end
def seek(%__MODULE__{}, options) do
message =
"Options must be either `frame: frame_offset` or " <>
"`millisecond: millisecond_offset`. Found #{inspect(options)}"
{:error, %Image.Error{reason: :invalid_seek_options, value: options, message: message}}
end
@doc """
Seeks the video head to a frame or millisecond offset,
raising on error. See `seek/2`.
### Example
iex> video = Image.Video.open!("./test/support/video/video_sample.mp4")
iex> %Image.Video{} = Image.Video.seek!(video, frame: 10)
iex> :ok
:ok
"""
@doc subject: "Operation"
@spec seek!(t(), seek_options()) :: t() | no_return()
def seek!(video, options) do
case seek(video, options) do
{:ok, video} -> video
{:error, error} -> raise error
end
end
# ----- scrub -------------------------------------------------------------
@doc """
Advances the video head by `frames` frames without
decoding them as images.
### Arguments
* `video` is any `t:Image.Video.t/0` returned from `open/2`.
* `frames` is the number of frames to advance.
### Returns
* `{:ok, video}` after advancing or
* `{:error, %Image.Error{}}`.
### Example
iex> {:ok, video} = Image.Video.open("./test/support/video/video_sample.mp4")
iex> {:ok, video} = Image.Video.scrub(video, 5)
iex> {:ok, _image} = Image.Video.image_from_video(video)
iex> :ok
:ok
"""
@doc subject: "Operation"
@spec scrub(t(), pos_integer()) :: {:ok, t()} | {:error, Image.error()}
def scrub(%__MODULE__{reader: nil}, _frames) do
{:error, video_closed_error()}
end
def scrub(%__MODULE__{} = video, frames)
when is_integer(frames) and frames > 0 do
Enum.each(1..frames, fn _ -> Xav.Reader.next_frame(video.reader) end)
{:ok, video}
end
# ----- image_from_video --------------------------------------------------
@doc """
Reads a single frame from a video as an `t:Vix.Vips.Image.t/0`.
### Arguments
* `video` is any `t:Image.Video.t/0` returned from `open/2`.
* `options` is `[]`, `[frame: n]`, or `[millisecond: n]`.
### Returns
* `{:ok, image}` or
* `{:error, %Image.Error{}}`.
### Example
iex> {:ok, video} = Image.Video.open("./test/support/video/video_sample.mp4")
iex> {:ok, _image} = Image.Video.image_from_video(video)
iex> {:ok, _image} = Image.Video.image_from_video(video, frame: 0)
iex> {:ok, _image} = Image.Video.image_from_video(video, millisecond: 1_000)
iex> {:error, %Image.Error{reason: :negative_offset}} = Image.Video.image_from_video(video, frame: -1)
iex> {:error, %Image.Error{reason: :frame_out_of_range}} = Image.Video.image_from_video(video, frame: 500)
iex> :ok
:ok
"""
@doc subject: "Operation"
@spec image_from_video(t(), seek_options()) ::
{:ok, Vimage.t()} | {:error, Image.error()}
def image_from_video(video, options \\ [])
def image_from_video(%__MODULE__{reader: nil}, _options) do
{:error, video_closed_error()}
end
def image_from_video(%__MODULE__{} = video, []) do
case Xav.Reader.next_frame(video.reader) do
{:ok, frame} ->
frame_to_image(frame)
{:error, :eof} ->
{:error,
%Image.Error{
reason: :end_of_stream,
message: "Reached end of video stream"
}}
end
end
def image_from_video(%__MODULE__{} = video, options) do
with {:ok, video} <- seek(video, options) do
image_from_video(video, [])
end
end
@doc """
Reads a single frame from a video as an
`t:Vix.Vips.Image.t/0`, raising on error. See
`image_from_video/2`.
### Example
iex> video = Image.Video.open!("./test/support/video/video_sample.mp4")
iex> image = Image.Video.image_from_video!(video, frame: 0)
iex> Image.shape(image)
{1920, 1080, 3}
"""
@doc subject: "Operation"
@spec image_from_video!(t(), seek_options()) :: Vimage.t() | no_return()
def image_from_video!(video, options \\ []) do
case image_from_video(video, options) do
{:ok, image} -> image
{:error, error} -> raise error
end
end
# ----- frame conversion --------------------------------------------------
@doc false
def frame_to_image(%Xav.Frame{
type: :video,
data: data,
width: width,
height: height,
format: format
})
when format in [:rgb24, :bgr24] do
bands = 3
case Vix.Vips.Image.new_from_binary(data, width, height, bands, :VIPS_FORMAT_UCHAR) do
{:ok, image} ->
if format == :bgr24 do
Image.Vips.Operation.bandjoin([image[2], image[1], image[0]])
else
{:ok, image}
end
{:error, reason} ->
{:error, Image.Error.wrap(reason, operation: :frame_to_image)}
end
end
def frame_to_image(%Xav.Frame{format: format}) do
{:error,
%Image.Error{
reason: :unsupported_frame_format,
value: format,
message: "Unsupported video frame format: #{inspect(format)}"
}}
end
# ----- helpers -----------------------------------------------------------
defp millisecond_to_frame(%__MODULE__{fps: fps}, millis) do
trunc(millis / 1000 * fps)
end
defp seek_to_frame(%__MODULE__{} = video, 0), do: {:ok, video}
defp seek_to_frame(%__MODULE__{} = video, frame) do
seconds = frame / max(video.fps, 1.0)
_ = Xav.Reader.seek(video.reader, seconds)
{:ok, video}
end
defp default_camera_path do
case :os.type() do
{:unix, :darwin} -> "0"
{:unix, _} -> "/dev/video0"
{:win32, _} -> "video=0"
end
end
defp camera_path(index) do
case :os.type() do
{:unix, :darwin} -> Integer.to_string(index)
{:unix, _} -> "/dev/video#{index}"
{:win32, _} -> "video=#{index}"
end
end
defp video_closed_error do
%Image.Error{
reason: :video_closed,
message: "Video has been closed"
}
end
end
end