Merge pull request #295 from philomena-dev/gif-preview

Timebase independent video GIF previewer
This commit is contained in:
liamwhite 2024-06-21 16:40:13 -04:00 committed by GitHub
commit 1d81e35b68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 123 additions and 49 deletions

View file

@ -0,0 +1,117 @@
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 of the largest dimensions desired,
and the image will be resized to fit inside the box of those dimensions,
preserving aspect ratio.
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}:" <>
"force_original_aspect_ratio=decrease [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

View file

@ -3,6 +3,7 @@ defmodule PhilomenaMedia.Processors.Webm do
alias PhilomenaMedia.Intensities
alias PhilomenaMedia.Analyzers.Result
alias PhilomenaMedia.GifPreview
alias PhilomenaMedia.Processors.Processor
alias PhilomenaMedia.Processors
import Bitwise
@ -28,12 +29,11 @@ defmodule PhilomenaMedia.Processors.Webm do
duration = analysis.duration
stripped = strip(file)
preview = preview(duration, stripped)
palette = gif_palette(stripped, duration)
mp4 = scale_mp4_only(stripped, dimensions, dimensions)
{:ok, intensities} = Intensities.file(preview)
scaled = Enum.flat_map(versions, &scale(stripped, palette, duration, dimensions, &1))
scaled = Enum.flat_map(versions, &scale(stripped, duration, dimensions, &1))
mp4 = [{:copy, mp4, "full.mp4"}]
[
@ -82,12 +82,12 @@ defmodule PhilomenaMedia.Processors.Webm do
stripped
end
defp scale(file, palette, duration, dimensions, {thumb_name, target_dimensions}) do
defp scale(file, duration, dimensions, {thumb_name, target_dimensions}) do
{webm, mp4} = scale_videos(file, dimensions, target_dimensions)
cond do
thumb_name in [:thumb, :thumb_small, :thumb_tiny] ->
gif = scale_gif(file, palette, duration, target_dimensions)
gif = scale_gif(file, duration, target_dimensions)
[
{:copy, webm, "#{thumb_name}.webm"},
@ -199,53 +199,14 @@ defmodule PhilomenaMedia.Processors.Webm do
mp4
end
defp scale_gif(file, palette, duration, {width, height}) do
defp scale_gif(file, duration, dimensions) do
gif = Briefly.create!(extname: ".gif")
scale_filter = "scale=w=#{width}:h=#{height}:force_original_aspect_ratio=decrease"
palette_filter = "paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
rate_filter = rate_filter(duration)
filter_graph = "[0:v]#{scale_filter},#{rate_filter}[x];[x][1:v]#{palette_filter}"
{_output, 0} =
System.cmd("ffmpeg", [
"-loglevel",
"0",
"-y",
"-i",
file,
"-i",
palette,
"-lavfi",
filter_graph,
"-r",
"2",
gif
])
GifPreview.preview(file, gif, duration, dimensions)
gif
end
defp gif_palette(file, duration) do
palette = Briefly.create!(extname: ".png")
palette_filter = "palettegen=stats_mode=diff"
rate_filter = rate_filter(duration)
filter_graph = "#{rate_filter},#{palette_filter}"
{_output, 0} =
System.cmd("ffmpeg", [
"-loglevel",
"0",
"-y",
"-i",
file,
"-vf",
filter_graph,
palette
])
palette
end
# x264 requires image dimensions to be a multiple of 2
# -2 = ~1
def box_dimensions({width, height}, {target_width, target_height}) do
@ -255,8 +216,4 @@ defmodule PhilomenaMedia.Processors.Webm do
{new_width, new_height}
end
# Avoid division by zero
def rate_filter(duration) when duration > 0.5, do: "fps=1/#{duration / 10},settb=1/2,setpts=N"
def rate_filter(_duration), do: "setpts=N/TB/2"
end