mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-27 13:47:58 +01:00
Timebase independent video GIF previewer
This commit is contained in:
parent
ad2b4b004c
commit
c616fbff5d
2 changed files with 123 additions and 49 deletions
117
lib/philomena_media/gif_preview.ex
Normal file
117
lib/philomena_media/gif_preview.ex
Normal 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
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue