diff --git a/lib/philomena/images/image.ex b/lib/philomena/images/image.ex index 0d4e8819..e7894f6e 100644 --- a/lib/philomena/images/image.ex +++ b/lib/philomena/images/image.ex @@ -87,14 +87,9 @@ defmodule Philomena.Images.Image do field :removed_tags, {:array, :any}, default: [], virtual: true field :added_tags, {:array, :any}, default: [], virtual: true - timestamps(inserted_at: :created_at) - end + field :uploaded_image, :string, virtual: true - @doc false - def changeset(image, attrs) do - image - |> cast(attrs, []) - |> validate_required([]) + timestamps(inserted_at: :created_at) end def interaction_data(image) do @@ -106,6 +101,32 @@ defmodule Philomena.Images.Image do } end + @doc false + def changeset(image, attrs) do + image + |> cast(attrs, []) + |> validate_required([]) + end + + def image_changeset(image, attrs) do + image + |> cast(attrs, [ + :image, :image_name, :image_width, :image_height, :image_size, + :image_format, :image_mime_type, :image_aspect_ratio, + :image_orig_sha512_hash, :image_sha512_hash, :uploaded_image + ]) + |> validate_required([ + :image, :image_width, :image_height, :image_size, + :image_format, :image_mime_type, :image_aspect_ratio, + :image_orig_sha512_hash, :image_sha512_hash, :uploaded_image + ]) + |> validate_number(:image_size, greater_than: 0, less_than_or_equal_to: 26214400) + |> validate_number(:image_width, greater_than: 0, less_than_or_equal_to: 32767) + |> validate_number(:image_height, greater_than: 0, less_than_or_equal_to: 32767) + |> validate_length(:image_name, max: 255, count: :bytes) + |> validate_inclusion(:image_mime_type, ~W(image/gif image/jpeg image/png image/svg+xml video/webm)) + end + def source_changeset(image, attrs) do image |> cast(attrs, [:source_url]) diff --git a/lib/philomena/processors.ex b/lib/philomena/processors.ex new file mode 100644 index 00000000..18096f2e --- /dev/null +++ b/lib/philomena/processors.ex @@ -0,0 +1,98 @@ +defmodule Philomena.Processors do + # alias Philomena.Images.Image + # alias Philomena.Repo + alias Philomena.Sha512 + + @mimes %{ + "image/gif" => "image/gif", + "image/jpeg" => "image/jpeg", + "image/png" => "image/png", + "image/svg+xml" => "image/svg+xml", + "video/webm" => "video/webm", + "image/svg" => "image/svg+xml", + "audio/webm" => "video/webm" + } + + @analyzers %{ + "image/gif" => Philomena.Analyzers.Gif, + "image/jpeg" => Philomena.Analyzers.Jpeg, + "image/png" => Philomena.Analyzers.Png, + "image/svg+xml" => Philomena.Analyzers.Svg, + "video/webm" => Philomena.Analyzers.Webm + } + + @processors %{ + "image/gif" => Philomena.Processors.Gif, + "image/jpeg" => Philomena.Processors.Jpeg, + "image/png" => Philomena.Processors.Png, + "image/svg+xml" => Philomena.Processors.Svg, + "video/webm" => Philomena.Processors.Webm + } + + def analysis_to_changes(analysis, file, upload_name) do + {width, height} = analysis.dimensions + %{size: size} = File.stat(file) + sha512 = Sha512.file(file) + filename = build_filename(analysis.extension) + + %{ + "image" => filename, + "image_name" => upload_name, + "image_width" => width, + "image_height" => height, + "image_size" => size, + "image_format" => analysis.extension, + "image_mime_type" => analysis.mime_type, + "image_aspect_ratio" => aspect_ratio(width, height), + "image_orig_sha512_hash" => sha512, + "image_sha512_hash" => sha512, + "uploaded_image" => file + } + end + + def after_upload(image) do + File.cp(image.uploaded_image, Path.join([image_file_root(), image.image])) + end + + defp aspect_ratio(_, 0), do: 0.0 + defp aspect_ratio(w, h), do: w / h + + defp build_filename(extension) do + [ + time_identifier(), + "/", + usec_identifier(), + pid_identifier(), + ".", + extension + ] + |> Enum.join() + end + + defp time_identifier do + now = DateTime.utc_now() + + Enum.join([now.year, now.month, now.day], "/") + end + + defp usec_identifier do + DateTime.utc_now() + |> DateTime.to_unix(:microsecond) + |> to_string() + end + + defp pid_identifier do + self() + |> :erlang.pid_to_list() + |> to_string() + |> String.replace(~r/[^0-9]/, "") + end + + defp image_file_root do + Application.get_env(:philomena, :image_file_root) + end + + defp image_thumbnail_root do + image_file_root() <> "/thumbs" + end +end \ No newline at end of file diff --git a/lib/philomena/processors/gif.ex b/lib/philomena/processors/gif.ex index 3c909de0..b6a8e572 100644 --- a/lib/philomena/processors/gif.ex +++ b/lib/philomena/processors/gif.ex @@ -1,59 +1,93 @@ defmodule Philomena.Processors.Gif do - alias Philomena.Processors.Gif + alias Philomena.Intensities - defstruct [:duration, :file, :palette] + def process(analysis, file, versions) do + dimensions = analysis.dimensions + duration = analysis.duration + preview = preview(duration, file) + palette = palette(file) - def new(analysis, file) do - %Gif{ - file: file, - duration: analysis.duration, - palette: nil + {:ok, intensities} = Intensities.file(preview) + + scaled = Enum.flat_map(versions, &scale_if_smaller(palette, file, dimensions, &1)) + + %{ + intensities: intensities, + thumbnails: scaled ++ [{:copy, preview, "rendered.png"}] } end - def strip(processor) do - {processor, processor.file} + def post_process(_analysis, file) do + %{replace_original: optimize(file)} end - def preview(processor) do - preview = Briefly.create!(extname: ".png") - - {_output, 0} = - System.cmd("ffmpeg", ["-y", "-i", processor.file, "-ss", to_string(processor.duration / 2), "-frames:v", "1", preview]) - - {processor, preview} - end - - def optimize(processor) do + defp optimize(file) do optimized = Briefly.create!(extname: ".gif") {_output, 0} = - System.cmd("gifsicle", ["--careful", "-O2", processor.file, "-o", optimized]) + System.cmd("gifsicle", ["--careful", "-O2", file, "-o", optimized]) - {processor, optimized} + optimized end - def scale(processor, {width, height}) do - processor = generate_palette(processor) - scaled = Briefly.create!(extname: ".gif") + defp preview(duration, file) do + preview = Briefly.create!(extname: ".png") + + {_output, 0} = + System.cmd("ffmpeg", ["-loglevel", "0", "-y", "-i", file, "-ss", to_string(duration / 2), "-frames:v", "1", preview]) + + preview + end + + defp palette(file) do + palette = Briefly.create!(extname: ".png") + + {_output, 0} = + System.cmd("ffmpeg", ["-loglevel", "0", "-y", "-i", file, "-vf", "palettegen=stats_mode=diff", palette]) + + palette + end + + # Generate full version, and WebM and MP4 previews + defp scale_if_smaller(_palette, file, _dimensions, {:full, _target_dim}) do + [{:symlink_original, "full.gif"}] ++ generate_videos(file) + end + + defp scale_if_smaller(palette, file, {width, height}, {thumb_name, {target_width, target_height}}) do + if width > target_width or height > target_height do + scaled = scale(palette, file, {target_width, target_height}) + + [{:copy, scaled, "#{thumb_name}.gif"}] + else + [{:symlink_original, "#{thumb_name}.gif"}] + end + end + + defp scale(palette, file, {width, height}) do + scaled = 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" filter_graph = "#{scale_filter} [x]; [x][1:v] #{palette_filter}" {_output, 0} = - System.cmd("ffmpeg", ["-y", "-i", processor.file, "-i", processor.palette, "-lavfi", filter_graph, scaled]) + System.cmd("ffmpeg", ["-loglevel", "0", "-y", "-i", file, "-i", palette, "-lavfi", filter_graph, scaled]) - {processor, scaled} + scaled end - defp generate_palette(%{palette: nil} = processor) do - palette = Briefly.create!(extname: ".png") + defp generate_videos(file) do + webm = Briefly.create!(extname: ".webm") + mp4 = Briefly.create!(extname: ".mp4") {_output, 0} = - System.cmd("ffmpeg", ["-y", "-i", processor.file, "-vf", "palettegen=stats_mode=diff", palette]) - - %{processor | palette: palette} + System.cmd("ffmpeg", ["-loglevel", "0", "-y", "-i", file, "-pix_fmt", "yuv420p", "-c:v", "libvpx", "-quality", "good", "-b:v", "5M", webm]) + {_output, 0} = + System.cmd("ffmpeg", ["-loglevel", "0", "-y", "-i", file, "-vf", "scale=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-b:v", "5M", mp4]) + + [ + {:copy, webm, "full.webm"}, + {:copy, mp4, "full.mp4"} + ] end - defp generate_palette(processor), do: processor end \ No newline at end of file diff --git a/lib/philomena/processors/jpeg.ex b/lib/philomena/processors/jpeg.ex index 301336a9..2ccd25b8 100644 --- a/lib/philomena/processors/jpeg.ex +++ b/lib/philomena/processors/jpeg.ex @@ -1,43 +1,64 @@ defmodule Philomena.Processors.Jpeg do - alias Philomena.Processors.Jpeg + alias Philomena.Intensities - defstruct [:file] + def process(analysis, file, versions) do + dimensions = analysis.dimensions + stripped = optimize(strip(file)) - def new(_analysis, file) do - %Jpeg{file: file} + {:ok, intensities} = Intensities.file(stripped) + + scaled = Enum.flat_map(versions, &scale_if_smaller(stripped, dimensions, &1)) + + %{ + replace_original: stripped, + intensities: intensities, + thumbnails: scaled + } end - def strip(processor) do + def post_process(_analysis, _file), do: %{} + + defp strip(file) do stripped = Briefly.create!(extname: ".jpg") {_output, 0} = - System.cmd("convert", [processor.file, "-auto-orient", "-strip", stripped]) + System.cmd("convert", [file, "-auto-orient", "-strip", stripped]) - processor = %{processor | file: stripped} - - {processor, stripped} + stripped end - def preview(processor) do - {processor, processor.file} - end - - def optimize(processor) do + defp optimize(file) do optimized = Briefly.create!(extname: ".jpg") {_output, 0} = - System.cmd("jpegtran", ["-optimize", "-outfile", optimized, processor.file]) + System.cmd("jpegtran", ["-optimize", "-outfile", optimized, file]) + + optimized end - def scale(processor, {width, height}) do + defp scale_if_smaller(_file, _dimensions, {:full, _target_dim}) do + [{:symlink_original, "full.jpg"}] + end + + defp scale_if_smaller(file, {width, height}, {thumb_name, {target_width, target_height}}) do + if width > target_width or height > target_height do + scaled = scale(file, {target_width, target_height}) + + [{:copy, scaled, "#{thumb_name}.jpg"}] + else + [{:symlink_original, "#{thumb_name}.jpg"}] + end + end + + defp scale(file, {width, height}) do scaled = Briefly.create!(extname: ".jpg") scale_filter = "scale=w=#{width}:h=#{height}:force_original_aspect_ratio=decrease" {_output, 0} = - System.cmd("ffmpeg", ["-y", "-i", processor.file, "-vf", scale_filter, scaled]) + System.cmd("ffmpeg", ["-loglevel", "0", "-y", "-i", file, "-vf", scale_filter, scaled]) {_output, 0} = System.cmd("jpegtran", ["-optimize", "-outfile", scaled, scaled]) - {processor, scaled} + scaled end end \ No newline at end of file diff --git a/lib/philomena/processors/png.ex b/lib/philomena/processors/png.ex index 22e66bbb..f32817da 100644 --- a/lib/philomena/processors/png.ex +++ b/lib/philomena/processors/png.ex @@ -1,38 +1,55 @@ defmodule Philomena.Processors.Png do - alias Philomena.Processors.Png + alias Philomena.Intensities - defstruct [:file] + def process(analysis, file, versions) do + dimensions = analysis.dimensions - def new(_analysis, file) do - %Png{file: file} + {:ok, intensities} = Intensities.file(file) + + scaled = Enum.flat_map(versions, &scale_if_smaller(file, dimensions, &1)) + + %{ + intensities: intensities, + thumbnails: scaled + } end - def strip(processor) do - {processor, processor.file} + def post_process(_analysis, file) do + %{replace_original: optimize(file)} end - def preview(processor) do - {processor, processor.file} - end - - def optimize(processor) do + defp optimize(file) do optimized = Briefly.create!(extname: ".png") {_output, 0} = - System.cmd("optipng", ["-fix", "-i0", "-o2", processor.file, "-out", optimized]) + System.cmd("optipng", ["-fix", "-i0", "-o2", file, "-out", optimized]) - {processor, optimized} + optimized end - def scale(processor, {width, height}) do + defp scale_if_smaller(_file, _dimensions, {:full, _target_dim}) do + [{:symlink_original, "full.jpg"}] + end + + defp scale_if_smaller(file, {width, height}, {thumb_name, {target_width, target_height}}) do + if width > target_width or height > target_height do + scaled = scale(file, {target_width, target_height}) + + [{:copy, scaled, "#{thumb_name}.jpg"}] + else + [{:symlink_original, "#{thumb_name}.jpg"}] + end + end + + defp scale(file, {width, height}) do scaled = Briefly.create!(extname: ".png") scale_filter = "scale=w=#{width}:h=#{height}:force_original_aspect_ratio=decrease" {_output, 0} = - System.cmd("ffmpeg", ["-y", "-i", processor.file, "-vf", scale_filter, scaled]) + System.cmd("ffmpeg", ["-loglevel", "0", "-y", "-i", file, "-vf", scale_filter, scaled]) {_output, 0} = System.cmd("optipng", ["-i0", "-o1", scaled]) - {processor, scaled} + scaled end end \ No newline at end of file diff --git a/lib/philomena/processors/svg.ex b/lib/philomena/processors/svg.ex index 67255412..ac0db760 100644 --- a/lib/philomena/processors/svg.ex +++ b/lib/philomena/processors/svg.ex @@ -1,41 +1,49 @@ defmodule Philomena.Processors.Svg do - alias Philomena.Processors.Svg + alias Philomena.Intensities - defstruct [:file, :preview] + def process(_analysis, file, versions) do + preview = preview(file) - def new(_analysis, file) do - %Svg{file: file, preview: nil} + {:ok, intensities} = Intensities.file(preview) + + scaled = Enum.flat_map(versions, &scale_if_smaller(file, preview, &1)) + + %{ + intensities: intensities, + thumbnails: scaled ++ [{:copy, preview, "rendered.png"}] + } end - # FIXME - def strip(processor) do - {processor, processor.file} - end + def post_process(_analysis, _file), do: %{} - def preview(processor) do + defp preview(file) do preview = Briefly.create!(extname: ".png") {_output, 0} = - System.cmd("inkscape", [processor.file, "--export-png", preview]) - - processor = %{processor | preview: preview} + System.cmd("inkscape", [file, "--export-png", preview]) - {processor, preview} + preview end - def optimize(processor) do - {processor, processor.file} + defp scale_if_smaller(_file, preview, {:full, _target_dim}) do + [{:symlink_original, "full.svg"}, {:copy, preview, "full.png"}] end - def scale(processor, {width, height}) do + defp scale_if_smaller(_file, preview, {thumb_name, {target_width, target_height}}) do + scaled = scale(preview, {target_width, target_height}) + + [{:copy, scaled, "#{thumb_name}.png"}] + end + + defp scale(preview, {width, height}) do scaled = Briefly.create!(extname: ".png") scale_filter = "scale=w=#{width}:h=#{height}:force_original_aspect_ratio=decrease" {_output, 0} = - System.cmd("ffmpeg", ["-y", "-i", processor.preview, "-vf", scale_filter, scaled]) + System.cmd("ffmpeg", ["-y", "-i", preview, "-vf", scale_filter, scaled]) {_output, 0} = System.cmd("optipng", ["-i0", "-o1", scaled]) - {processor, scaled} + scaled end end \ No newline at end of file diff --git a/lib/philomena/processors/webm.ex b/lib/philomena/processors/webm.ex index 205d223a..ff9b27a1 100644 --- a/lib/philomena/processors/webm.ex +++ b/lib/philomena/processors/webm.ex @@ -1,39 +1,97 @@ defmodule Philomena.Processors.Webm do - alias Philomena.Processors.Webm + alias Philomena.Intensities import Bitwise - defstruct [:duration, :file] + def process(analysis, file, versions) do + dimensions = analysis.dimensions + duration = analysis.duration + preview = preview(duration, file) + palette = gif_palette(file) - def new(analysis, file) do - %Webm{duration: analysis.duration, file: file} + {:ok, intensities} = Intensities.file(preview) + + scaled = Enum.flat_map(versions, &scale_if_smaller(file, palette, dimensions, &1)) + + %{ + intensities: intensities, + thumbnails: scaled ++ [{:copy, preview, "rendered.png"}] + } end - def strip(processor) do - {processor, processor.file} - end + def post_process(_analysis, _file), do: %{} - def preview(processor) do + defp preview(duration, file) do preview = Briefly.create!(extname: ".png") {_output, 0} = - System.cmd("ffmpeg", ["-y", "-i", processor.file, "-ss", to_string(processor.duration / 2), "-frames:v", "1", preview]) - - {processor, preview} + System.cmd("ffmpeg", ["-loglevel", "0", "-y", "-i", file, "-ss", to_string(duration / 2), "-frames:v", "1", preview]) + + preview end - def optimize(processor) do - {processor, processor.file} + defp scale_if_smaller(palette, file, dimensions, {:full, _target_dim}) do + {webm, mp4} = scale_videos(file, palette, dimensions) + + [ + {:copy, webm, "full.webm"}, + {:copy, mp4, "full.mp4"} + ] end - def scale(processor, dimensions) do + defp scale_if_smaller(palette, file, _dimensions, {thumb_name, {target_width, target_height}}) do + {webm, mp4} = scale_videos(file, palette, {target_width, target_height}) + + cond do + thumb_name in [:thumb, :thumb_small, :thumb_tiny] -> + gif = scale_gif(file, palette, {target_width, target_height}) + + [ + {:copy, webm, "#{thumb_name}.webm"}, + {:copy, mp4, "#{thumb_name}.mp4"}, + {:copy, gif, "#{thumb_name}.gif"} + ] + + true -> + [ + {:copy, webm, "#{thumb_name}.webm"}, + {:copy, mp4, "#{thumb_name}.mp4"} + ] + end + end + + defp scale_videos(file, _palette, dimensions) do {width, height} = normalize_dimensions(dimensions) - scaled = Briefly.create!(extname: ".webm") + webm = Briefly.create!(extname: ".webm") + mp4 = Briefly.create!(extname: ".mp4") scale_filter = "scale=w=#{width}:h=#{height}:force_original_aspect_ratio=decrease" {_output, 0} = - System.cmd("ffmpeg", ["-y", "-i", processor.file, "-c:v", "libvpx", "-crf", "10", "-b:v", "5M", "-vf", scale_filter, scaled]) + System.cmd("ffmpeg", ["-loglevel", "0", "-y", "-i", file, "-c:v", "libvpx", "-crf", "10", "-b:v", "5M", "-vf", scale_filter, webm]) + {_output, 0} = + System.cmd("ffmpeg", ["-loglevel", "0", "-y", "-i", file, "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-b:v", "5M", "-vf", scale_filter, mp4]) - {processor, scaled} + {webm, mp4} + end + + defp scale_gif(file, palette, {width, height}) 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" + filter_graph = "#{scale_filter} [x]; [x][1:v] #{palette_filter}" + + {_output, 0} = + System.cmd("ffmpeg", ["-loglevel", "0", "-y", "-i", file, "-i", palette, "-lavfi", filter_graph, gif]) + + gif + end + + defp gif_palette(file) do + palette = Briefly.create!(extname: ".png") + + {_output, 0} = + System.cmd("ffmpeg", ["-loglevel", "0", "-y", "-i", file, "-vf", "palettegen=stats_mode=diff", palette]) + + palette end # Force dimensions to be a multiple of 2. This is required by the diff --git a/lib/philomena/sha512.ex b/lib/philomena/sha512.ex new file mode 100644 index 00000000..4b86ff26 --- /dev/null +++ b/lib/philomena/sha512.ex @@ -0,0 +1,11 @@ +defmodule Philomena.Sha512 do + @spec file(String.t()) :: String.t() + def file(file) do + hash_ref = :crypto.hash_init(:sha512) + + File.stream!(file, [], 10_485_760) + |> Enum.reduce(hash_ref, &:crypto.hash_update(&2, &1)) + |> :crypto.hash_final() + |> Base.encode16(case: :lower) + end +end \ No newline at end of file diff --git a/lib/philomena_web/controllers/image/source_change_controller.ex b/lib/philomena_web/controllers/image/source_change_controller.ex index 689253a8..2fcd1c60 100644 --- a/lib/philomena_web/controllers/image/source_change_controller.ex +++ b/lib/philomena_web/controllers/image/source_change_controller.ex @@ -9,7 +9,7 @@ defmodule PhilomenaWeb.Image.SourceChangeController do plug PhilomenaWeb.CanaryMapPlug, index: :show plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true - def index(conn, params) do + def index(conn, _params) do image = conn.assigns.image source_changes = diff --git a/vagrant/install.bash b/vagrant/install.bash index 377207db..81364dcd 100644 --- a/vagrant/install.bash +++ b/vagrant/install.bash @@ -55,7 +55,7 @@ if ! install_packages build-essential postgresql-11 libpq-dev nginx nodejs \ elasticsearch esl-erlang elixir inotify-tools git \ redis-server automake libtool zlib1g-dev ffmpeg \ libavutil-dev libavcodec-dev libavformat-dev \ - libmagic-dev libpng-dev; then + libmagic-dev libpng-dev gifsicle optipng libjpeg-progs; then >&2 echo "Installation of dependencies failed." exit 1 fi