From e0511053a4e62b9df47e77026e18535173d212c0 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sun, 21 May 2017 13:26:41 +0200 Subject: [PATCH] Basic PNG generation in Elixir --- config/config.exs | 5 ++ lib/asciinema.ex | 8 +++ lib/asciinema/file_store.ex | 3 + lib/asciinema/file_store/local.ex | 4 ++ lib/asciinema/file_store/s3.ex | 12 ++++ lib/asciinema/png_generator.ex | 10 ++++ lib/asciinema/png_generator/a2png.ex | 55 +++++++++++++++++++ mix.exs | 6 ++ mix.lock | 4 +- web/controllers/asciicast_image_controller.ex | 15 +++++ web/models/asciicast.ex | 1 + web/router.ex | 11 ++++ 12 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 lib/asciinema/png_generator.ex create mode 100644 lib/asciinema/png_generator/a2png.ex create mode 100644 web/controllers/asciicast_image_controller.ex diff --git a/config/config.exs b/config/config.exs index 9497297..a8badfb 100644 --- a/config/config.exs +++ b/config/config.exs @@ -44,6 +44,11 @@ else config :asciinema, Asciinema.FileStore.Local, path: "uploads/" end +config :asciinema, :png_generator, Asciinema.PngGenerator.A2png +config :asciinema, Asciinema.PngGenerator.A2png, bin_path: "./a2png/a2png.sh" + +config :porcelain, goon_warn_if_missing: false + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env}.exs" diff --git a/lib/asciinema.ex b/lib/asciinema.ex index ca8232b..6cad493 100644 --- a/lib/asciinema.ex +++ b/lib/asciinema.ex @@ -14,6 +14,7 @@ defmodule Asciinema do supervisor(Asciinema.Endpoint, []), # Start your own worker by calling: Asciinema.Worker.start_link(arg1, arg2, arg3) # worker(Asciinema.Worker, [arg1, arg2, arg3]), + :poolboy.child_spec(:worker, poolboy_config(), []), ] # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html @@ -28,4 +29,11 @@ defmodule Asciinema do Asciinema.Endpoint.config_change(changed, removed) :ok end + + defp poolboy_config do + [{:name, {:local, :worker}}, + {:worker_module, Asciinema.PngGenerator.A2png}, + {:size, 2}, + {:max_overflow, 0}] + end end diff --git a/lib/asciinema/file_store.ex b/lib/asciinema/file_store.ex index 87e6bf1..8c8527a 100644 --- a/lib/asciinema/file_store.ex +++ b/lib/asciinema/file_store.ex @@ -1,4 +1,7 @@ defmodule Asciinema.FileStore do @doc "Serves file at given path in store" @callback serve_file(conn :: %Plug.Conn{}, path :: String.t, filename :: String.t) :: %Plug.Conn{} + + @doc "Opens the given path in store" + @callback open(path :: String.t) :: {:ok, File.io_device} | {:error, File.posix} end diff --git a/lib/asciinema/file_store/local.ex b/lib/asciinema/file_store/local.ex index 2d68b2b..0dd0a1d 100644 --- a/lib/asciinema/file_store/local.ex +++ b/lib/asciinema/file_store/local.ex @@ -19,6 +19,10 @@ defmodule Asciinema.FileStore.Local do |> halt end + def open(path) do + File.open(base_path() <> path, [:binary, :read]) + end + defp config do Application.get_env(:asciinema, Asciinema.FileStore.Local) end diff --git a/lib/asciinema/file_store/s3.ex b/lib/asciinema/file_store/s3.ex index 8f84d9e..1ffdd9c 100644 --- a/lib/asciinema/file_store/s3.ex +++ b/lib/asciinema/file_store/s3.ex @@ -1,6 +1,7 @@ defmodule Asciinema.FileStore.S3 do @behaviour Asciinema.FileStore import Phoenix.Controller, only: [redirect: 2] + alias ExAws.S3 def serve_file(conn, path, nil) do do_serve_file(conn, path) @@ -18,6 +19,17 @@ defmodule Asciinema.FileStore.S3 do |> redirect(external: url) end + def open(path) do + response = S3.get_object(bucket(), base_path() <> path) |> ExAws.request(region: region()) + + case response do + {:ok, %{body: body}} -> + File.open(body, [:ram, :binary, :read]) + {:error, reason} -> + {:error, reason} + end + end + defp config do Application.get_env(:asciinema, Asciinema.FileStore.S3) end diff --git a/lib/asciinema/png_generator.ex b/lib/asciinema/png_generator.ex new file mode 100644 index 0000000..08ed7b8 --- /dev/null +++ b/lib/asciinema/png_generator.ex @@ -0,0 +1,10 @@ +defmodule Asciinema.PngGenerator do + alias Asciinema.Asciicast + + @doc "Generates PNG image from asciicast and returns path to it" + @callback generate(asciicast :: %Asciicast{}) :: {:ok, String.t} | {:error, term} + + def generate(asciicast) do + Application.get_env(:asciinema, :png_generator).generate(asciicast) + end +end diff --git a/lib/asciinema/png_generator/a2png.ex b/lib/asciinema/png_generator/a2png.ex new file mode 100644 index 0000000..f905f1d --- /dev/null +++ b/lib/asciinema/png_generator/a2png.ex @@ -0,0 +1,55 @@ +defmodule Asciinema.PngGenerator.A2png do + @behaviour Asciinema.PngGenerator + use GenServer + alias Asciinema.Asciicast + + @result_timeout 30000 + @acquire_timeout 5000 + + def generate(%Asciicast{} = asciicast) do + {:ok, tmp_dir_path} = Briefly.create(directory: true) + + :poolboy.transaction( + :worker, + &GenServer.call(&1, {:gen_png, asciicast, tmp_dir_path}, @result_timeout), @acquire_timeout + ) + end + + # GenServer API + + def start_link(_) do + GenServer.start_link(__MODULE__, nil, []) + end + + def init(_) do + {:ok, nil} + end + + def handle_call({:gen_png, asciicast, tmp_dir_path}, _from, state) do + {:reply, do_gen(asciicast, tmp_dir_path), state} + end + + defp do_gen(asciicast, tmp_dir_path) do + path = Asciicast.json_store_path(asciicast) + json_path = Path.join(tmp_dir_path, "tmp.json") + png_path = Path.join(tmp_dir_path, "tmp.png") + snapshot_at = "#{asciicast.duration / 2}" + + with {:ok, file} <- file_store().open(path), + {:ok, _} <- :file.copy(file, json_path), + %{status: 0} <- Porcelain.exec(bin_path(), [json_path, png_path, snapshot_at]) do + {:ok, png_path} + else + otherwise -> + otherwise + end + end + + def bin_path do + Keyword.get(Application.get_env(:asciinema, __MODULE__), :bin_path) + end + + defp file_store do + Application.get_env(:asciinema, :file_store) + end +end diff --git a/mix.exs b/mix.exs index 20818a4..40a1330 100644 --- a/mix.exs +++ b/mix.exs @@ -19,6 +19,7 @@ defmodule Asciinema.Mixfile do def application do [mod: {Asciinema, []}, applications: [ + :briefly, :bugsnag, :cowboy, :ex_aws, @@ -29,6 +30,8 @@ defmodule Asciinema.Mixfile do :phoenix_html, :phoenix_pubsub, :plug_rails_cookie_session_store, + :poolboy, + :porcelain, :postgrex, ]] end @@ -42,6 +45,7 @@ defmodule Asciinema.Mixfile do # Type `mix help deps` for examples and options. defp deps do [ + {:briefly, "~> 0.3"}, {:cowboy, "~> 1.0"}, {:ex_aws, "~> 1.0"}, {:gettext, "~> 0.11"}, @@ -54,6 +58,8 @@ defmodule Asciinema.Mixfile do {:plug_rails_cookie_session_store, "~> 0.1"}, {:plugsnag, "~> 1.3.0"}, {:poison, "~> 2.2"}, + {:poolboy, "~> 1.5"}, + {:porcelain, "~> 2.0"}, {:postgrex, ">= 0.0.0"}, ] end diff --git a/mix.lock b/mix.lock index 81e8903..df129b8 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ -%{"bugsnag": {:hex, :bugsnag, "1.4.0", "fda8c3f550c93568b6e9ac615b1a9be0c1c4e06c7eb0ffb04a133dfaf1e01327", [:mix], [{:httpoison, "~> 0.9", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, +%{"briefly": {:hex, :briefly, "0.3.0", "16e6b76d2070ebc9cbd025fa85cf5dbaf52368c4bd896fb482b5a6b95a540c2f", [:mix], [], "hexpm"}, + "bugsnag": {:hex, :bugsnag, "1.4.0", "fda8c3f550c93568b6e9ac615b1a9be0c1c4e06c7eb0ffb04a133dfaf1e01327", [:mix], [{:httpoison, "~> 0.9", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, "certifi": {:hex, :certifi, "1.0.0", "1c787a85b1855ba354f0b8920392c19aa1d06b0ee1362f9141279620a5be2039", [:rebar3], [], "hexpm"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, @@ -27,6 +28,7 @@ "plugsnag": {:hex, :plugsnag, "1.3.0", "eb974813360c979993205dcbde9a79fd02e3bd38ebe3870f5089e57a14ebaedb", [:mix], [{:bugsnag, "~> 1.3", [hex: :bugsnag, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], [], "hexpm"}, "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, + "porcelain": {:hex, :porcelain, "2.0.3", "2d77b17d1f21fed875b8c5ecba72a01533db2013bd2e5e62c6d286c029150fdc", [:mix], [], "hexpm"}, "postgrex": {:hex, :postgrex, "0.11.2", "139755c1359d3c5c6d6e8b1ea72556d39e2746f61c6ddfb442813c91f53487e8", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.0-rc", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}, "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}} diff --git a/web/controllers/asciicast_image_controller.ex b/web/controllers/asciicast_image_controller.ex new file mode 100644 index 0000000..376ca1d --- /dev/null +++ b/web/controllers/asciicast_image_controller.ex @@ -0,0 +1,15 @@ +defmodule Asciinema.AsciicastImageController do + use Asciinema.Web, :controller + alias Asciinema.{Repo, Asciicast, PngGenerator} + alias Plug.MIME + + def show(conn, %{"id" => id} = _params) do + asciicast = Repo.one!(Asciicast.by_id_or_secret_token(id)) + {:ok, png_path} = PngGenerator.generate(asciicast) + + conn + |> put_resp_header("content-type", MIME.path(png_path)) + |> send_file(200, png_path) + |> halt + end +end diff --git a/web/models/asciicast.ex b/web/models/asciicast.ex index f9297d5..72982b6 100644 --- a/web/models/asciicast.ex +++ b/web/models/asciicast.ex @@ -9,6 +9,7 @@ defmodule Asciinema.Asciicast do field :stdout_frames, :string field :private, :boolean field :secret_token, :string + field :duration, :float end def by_id_or_secret_token(thing) do diff --git a/web/router.ex b/web/router.ex index 679515e..1983820 100644 --- a/web/router.ex +++ b/web/router.ex @@ -21,6 +21,17 @@ defmodule Asciinema.Router do get "/a/:id/json", AsciicastFileController, :show end + pipeline :asciicast_image do + plug :accepts, ["png"] + end + + scope "/", Asciinema do + pipe_through :asciicast_image + + # rewritten by TrailingFormat from /a/123.png to /a/123/png + get "/a/:id/png", AsciicastImageController, :show + end + pipeline :asciicast_animation do plug :accepts, ["html"] end