From c616fbff5db4e5df3d828933e5f347a8e10179d6 Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 19 Jun 2024 16:55:45 -0400 Subject: [PATCH] Timebase independent video GIF previewer --- lib/philomena_media/gif_preview.ex | 117 +++++++++++++++++++++++++ lib/philomena_media/processors/webm.ex | 55 ++---------- 2 files changed, 123 insertions(+), 49 deletions(-) create mode 100644 lib/philomena_media/gif_preview.ex diff --git a/lib/philomena_media/gif_preview.ex b/lib/philomena_media/gif_preview.ex new file mode 100644 index 00000000..f1c6fce6 --- /dev/null +++ b/lib/philomena_media/gif_preview.ex @@ -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 diff --git a/lib/philomena_media/processors/webm.ex b/lib/philomena_media/processors/webm.ex index c86e1969..ad446645 100644 --- a/lib/philomena_media/processors/webm.ex +++ b/lib/philomena_media/processors/webm.ex @@ -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