From 4759cc5307fe922834bd71d30675a86b890896de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kalbarczyk?= Date: Tue, 8 Aug 2023 10:57:45 +0200 Subject: [PATCH] Add Kino.Audio and Kino.Video (#311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jonatan KÅ‚osko --- lib/assets/audio/main.js | 23 +++++++++++ lib/assets/video/main.js | 23 +++++++++++ lib/kino/audio.ex | 82 +++++++++++++++++++++++++++++++++++++++ lib/kino/video.ex | 84 ++++++++++++++++++++++++++++++++++++++++ mix.exs | 4 +- test/kino/audio_test.exs | 31 +++++++++++++++ test/kino/video_test.exs | 31 +++++++++++++++ 7 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 lib/assets/audio/main.js create mode 100644 lib/assets/video/main.js create mode 100644 lib/kino/audio.ex create mode 100644 lib/kino/video.ex create mode 100644 test/kino/audio_test.exs create mode 100644 test/kino/video_test.exs diff --git a/lib/assets/audio/main.js b/lib/assets/audio/main.js new file mode 100644 index 00000000..a422f0a8 --- /dev/null +++ b/lib/assets/audio/main.js @@ -0,0 +1,23 @@ +export function init(ctx, [{ type, opts }, content]) { + ctx.root.innerHTML = ` +
+
+ `; +} + +function bufferToBase64(buffer) { + let binaryString = ""; + const bytes = new Uint8Array(buffer); + const length = bytes.byteLength; + + for (let i = 0; i < length; i++) { + binaryString += String.fromCharCode(bytes[i]); + } + + return btoa(binaryString); +}; + +function createDataUrl(content, type){ + return `data:${type};base64,${bufferToBase64(content)}` +}; diff --git a/lib/assets/video/main.js b/lib/assets/video/main.js new file mode 100644 index 00000000..a3412b9f --- /dev/null +++ b/lib/assets/video/main.js @@ -0,0 +1,23 @@ +export function init(ctx, [{ type, opts }, content]) { + ctx.root.innerHTML = ` +
+
+ `; +} + +function bufferToBase64(buffer) { + let binaryString = ""; + const bytes = new Uint8Array(buffer); + const length = bytes.byteLength; + + for (let i = 0; i < length; i++) { + binaryString += String.fromCharCode(bytes[i]); + } + + return btoa(binaryString); +}; + +function createDataUrl(content, type){ + return `data:${type};base64,${bufferToBase64(content)}` +}; diff --git a/lib/kino/audio.ex b/lib/kino/audio.ex new file mode 100644 index 00000000..b29085be --- /dev/null +++ b/lib/kino/audio.ex @@ -0,0 +1,82 @@ +defmodule Kino.Audio do + @moduledoc """ + A kino for rendering a binary audio. + + ## Examples + + content = File.read!("/path/to/audio.wav") + Kino.Audio.new(content, :wav) + + content = File.read!("/path/to/audio.wav") + Kino.Audio.new(content, :wav, autoplay: true, loop: true) + """ + + use Kino.JS, assets_path: "lib/assets/audio" + use Kino.JS.Live + + @type t :: Kino.JS.Live.t() + + @type mime_type :: binary() + @type common_audio_type :: :wav | :mp3 | :mpeg | :ogg + + @doc """ + Creates a new kino displaying the given binary audio. + + The given type be either `:wav`, `:mp3`/`:mpeg`, `:ogg` + or a string with audio MIME type. + + ## Options + + * `:autoplay` - whether the audio should start playing as soon as + it is rendered. Defaults to `false` + + * `:loop` - whether the audio should loop. Defaults to `false` + + * `:muted` - whether the audio should be muted. Defaults to `false` + + """ + @spec new(binary(), common_audio_type() | mime_type(), keyword()) :: t() + def new(content, type, opts \\ []) when is_binary(content) do + opts = + Keyword.validate!(opts, + autoplay: false, + loop: false, + muted: false + ) + + Kino.JS.Live.new(__MODULE__, %{ + content: content, + type: mime_type!(type), + opts: + Enum.reduce(opts, "controls", fn {opt, val}, acc -> + if val do + "#{acc} #{opt}" + else + acc + end + end) + }) + end + + @impl true + def init(assigns, ctx) do + {:ok, assign(ctx, assigns)} + end + + @impl true + def handle_connect(%{assigns: %{content: content, type: type, opts: opts}} = ctx) do + payload = {:binary, %{type: type, opts: opts}, content} + {:ok, payload, ctx} + end + + defp mime_type!(:wav), do: "audio/wav" + defp mime_type!(:mp3), do: "audio/mpeg" + defp mime_type!(:mpeg), do: "audio/mpeg" + defp mime_type!(:ogg), do: "audio/ogg" + defp mime_type!("audio/" <> _ = mime_type), do: mime_type + + defp mime_type!(other) do + raise ArgumentError, + "expected audio type to be either :wav, :mp3, :mpeg, :ogg, or an audio MIME type string, got: #{inspect(other)}" + end +end diff --git a/lib/kino/video.ex b/lib/kino/video.ex new file mode 100644 index 00000000..2575d702 --- /dev/null +++ b/lib/kino/video.ex @@ -0,0 +1,84 @@ +defmodule Kino.Video do + @moduledoc """ + A kino for rendering a binary video. + + ## Examples + + content = File.read!("/path/to/video.mp4") + Kino.Video.new(content, :mp4) + + content = File.read!("/path/to/video.mp4") + Kino.Video.new(content, :mp4, autoplay: true, loop: true) + + """ + + use Kino.JS, assets_path: "lib/assets/video" + use Kino.JS.Live + + @type t :: Kino.JS.Live.t() + + @type mime_type :: binary() + @type common_video_type :: :mp4 | :ogg | :avi | :mwv | :mov + + @doc """ + Creates a new kino displaying the given binary video. + + The given type be either `:mp4`, `:ogg`, `:avi`, `:wmv`, `:mov` + or a string with video MIME type. + + ## Options + + * `:autoplay` - whether the video should start playing as soon as + it is rendered. Defaults to `false` + + * `:loop` - whether the video should loop. Defaults to `false` + + * `:muted` - whether the video should be muted. Defaults to `false` + + """ + @spec new(binary(), common_video_type() | mime_type(), list()) :: t() + def new(content, type, opts \\ []) when is_binary(content) do + opts = + Keyword.validate!(opts, + autoplay: false, + loop: false, + muted: false + ) + + Kino.JS.Live.new(__MODULE__, %{ + content: content, + type: mime_type!(type), + opts: + Enum.reduce(opts, "controls", fn {opt, val}, acc -> + if val do + "#{acc} #{opt}" + else + acc + end + end) + }) + end + + @impl true + def init(assigns, ctx) do + {:ok, assign(ctx, assigns)} + end + + @impl true + def handle_connect(%{assigns: %{content: content, type: type, opts: opts}} = ctx) do + payload = {:binary, %{type: type, opts: opts}, content} + {:ok, payload, ctx} + end + + defp mime_type!(:mp4), do: "video/mp4" + defp mime_type!(:ogg), do: "video/ogg" + defp mime_type!(:avi), do: "video/x-msvideo" + defp mime_type!(:wmv), do: "video/x-ms-wmv" + defp mime_type!(:mov), do: "video/quicktime" + defp mime_type!("video/" <> _ = mime_type), do: mime_type + + defp mime_type!(other) do + raise ArgumentError, + "expected video type to be either :mp4, :ogg, :avi, :wmv, :mov, or an video MIME type string, got: #{inspect(other)}" + end +end diff --git a/mix.exs b/mix.exs index 6cf40ff1..fa901c2e 100644 --- a/mix.exs +++ b/mix.exs @@ -76,7 +76,9 @@ defmodule Kino.MixProject do Kino.Markdown, Kino.Mermaid, Kino.Text, - Kino.Tree + Kino.Tree, + Kino.Audio, + Kino.Video ] ] ] diff --git a/test/kino/audio_test.exs b/test/kino/audio_test.exs new file mode 100644 index 00000000..927e695a --- /dev/null +++ b/test/kino/audio_test.exs @@ -0,0 +1,31 @@ +defmodule Kino.AudioTest do + use Kino.LivebookCase, async: true + + describe "new/2" do + test "raises an error for a non-image MIME type" do + assert_raise ArgumentError, + ~s{expected audio type to be either :wav, :mp3, :mpeg, :ogg, or an audio MIME type string, got: "application/json"}, + fn -> + Kino.Audio.new(<<>>, "application/json") + end + end + + test "raises an error for an invalid type shorthand" do + assert_raise ArgumentError, + "expected audio type to be either :wav, :mp3, :mpeg, :ogg, or an audio MIME type string, got: :invalid", + fn -> + Kino.Audio.new(<<>>, :invalid) + end + end + + test "mime type shorthand and default opts" do + kino = Kino.Audio.new(<<>>, :wav) + assert {:binary, %{type: "audio/wav", opts: "controls"}, <<>>} == connect(kino) + end + + test "custom mime type and custom opts" do + kino = Kino.Audio.new(<<>>, "audio/mp2", loop: true) + assert {:binary, %{type: "audio/mp2", opts: "controls loop"}, <<>>} == connect(kino) + end + end +end diff --git a/test/kino/video_test.exs b/test/kino/video_test.exs new file mode 100644 index 00000000..5f3a6a41 --- /dev/null +++ b/test/kino/video_test.exs @@ -0,0 +1,31 @@ +defmodule Kino.VideoTest do + use Kino.LivebookCase, async: true + + describe "new/2" do + test "raises an error for a non-image MIME type" do + assert_raise ArgumentError, + ~s{expected video type to be either :mp4, :ogg, :avi, :wmv, :mov, or an video MIME type string, got: "application/json"}, + fn -> + Kino.Video.new(<<>>, "application/json") + end + end + + test "raises an error for an invalid type shorthand" do + assert_raise ArgumentError, + "expected video type to be either :mp4, :ogg, :avi, :wmv, :mov, or an video MIME type string, got: :invalid", + fn -> + Kino.Video.new(<<>>, :invalid) + end + end + + test "mime type shorthand and default opts" do + kino = Kino.Video.new(<<>>, :mp4) + assert {:binary, %{type: "video/mp4", opts: "controls"}, <<>>} == connect(kino) + end + + test "custom mime type and custom opts" do + kino = Kino.Video.new(<<>>, "video/h123", loop: true) + assert {:binary, %{type: "video/h123", opts: "controls loop"}, <<>>} == connect(kino) + end + end +end