philomena/lib/philomena_media/gif_preview.ex
2024-07-02 00:25:44 -04:00

114 lines
3.4 KiB
Elixir

defmodule PhilomenaMedia.GifPreview do
@moduledoc """
GIF preview generation for video files.
"""
@type duration :: float()
@type dimensions :: {pos_integer(), pos_integer()}
@type num_images :: integer()
@type target_framerate :: 1..50
@type opts :: [
num_images: num_images(),
target_framerate: target_framerate()
]
@doc """
Generate a GIF preview of the given video input with evenly-spaced sample points.
The input should have pre-computed duration `duration`. The `dimensions`
are a `{target_width, target_height}` tuple.
Depending on the input file, this may take a long time to process.
Options:
- `:target_framerate` - framerate of the output GIF, must be between 1 and 50.
Default 2.
- `:num_images` - number of images to sample from the video.
Default is determined by the duration:
* 90 or above: 20 images
* 30 or above: 10 images
* 1 or above: 5 images
* otherwise: 2 images
"""
@spec preview(Path.t(), Path.t(), duration(), dimensions(), opts()) :: :ok
def preview(video, gif, duration, dimensions, opts \\ []) do
target_framerate = Keyword.get(opts, :target_framerate, 2)
num_images =
Keyword.get_lazy(opts, :num_images, fn ->
cond do
duration >= 90 -> 20
duration >= 30 -> 10
duration >= 1 -> 5
true -> 2
end
end)
{_output, 0} =
System.cmd(
"ffmpeg",
commands(video, gif, clamp(duration), dimensions, num_images, target_framerate)
)
:ok
end
@spec commands(Path.t(), Path.t(), duration(), dimensions(), num_images(), target_framerate()) ::
[String.t()]
defp commands(video, gif, duration, {target_width, target_height}, num_images, target_framerate) do
# Compute range [0, num_images)
image_range = 0..(num_images - 1)
# Generate input list in the following form:
# -ss 0.0 -i input.webm
input_arguments =
Enum.flat_map(image_range, &["-ss", "#{&1 * duration / num_images}", "-i", video])
# Generate graph in the following form:
# [0:v] trim=end_frame=1 [t0]; [1:v] trim=end_frame=1 [t1] ...
trim_filters =
Enum.map_join(image_range, ";", &"[#{&1}:v] trim=end_frame=1 [t#{&1}]")
# Generate graph in the following form:
# [t0][t1]... concat=n=10 [concat]
concat_input_pads =
Enum.map_join(image_range, "", &"[t#{&1}]")
concat_filter =
"#{concat_input_pads} concat=n=#{num_images}, settb=1/#{target_framerate}, setpts=N [concat]"
scale_filter =
"[concat] scale=width=#{target_width}:height=#{target_height},setsar=1 [scale]"
split_filter = "[scale] split [s0][s1]"
palettegen_filter =
"[s0] palettegen=stats_mode=single:max_colors=255:reserve_transparent=1 [palettegen]"
paletteuse_filter =
"[s1][palettegen] paletteuse=dither=bayer:bayer_scale=5:new=1:alpha_threshold=255"
filter_graph =
[
trim_filters,
concat_filter,
scale_filter,
split_filter,
palettegen_filter,
paletteuse_filter
]
|> Enum.join(";")
# Delay in centiseconds - otherwise it will be computed incorrectly
final_delay = 100.0 / target_framerate
["-loglevel", "0", "-y"]
|> Kernel.++(input_arguments)
|> Kernel.++(["-lavfi", filter_graph])
|> Kernel.++(["-f", "gif", "-final_delay", "#{final_delay}", gif])
end
defp clamp(duration) when duration <= 0, do: 1.0
defp clamp(duration), do: duration
end