From 105b434cc259a147b003450d86c0b31eefd76527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 30 Oct 2023 16:10:25 +0000 Subject: [PATCH 01/30] feat: Initial commit. #1 --- config/config.exs | 52 ++++++++++++ config/dev.exs | 65 +++++++++++++++ config/prod.exs | 18 +++++ config/runtime.exs | 82 +++++++++++++++++++ config/test.exs | 14 ++++ lib/app.ex | 9 +++ lib/app/application.ex | 34 ++++++++ lib/app_web.ex | 111 ++++++++++++++++++++++++++ lib/app_web/controllers/error_json.ex | 15 ++++ lib/app_web/endpoint.ex | 46 +++++++++++ lib/app_web/router.ex | 27 +++++++ lib/app_web/telemetry.ex | 69 ++++++++++++++++ mix.exs | 64 +++++++++++++++ 13 files changed, 606 insertions(+) create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/runtime.exs create mode 100644 config/test.exs create mode 100644 lib/app.ex create mode 100644 lib/app/application.ex create mode 100644 lib/app_web.ex create mode 100644 lib/app_web/controllers/error_json.ex create mode 100644 lib/app_web/endpoint.ex create mode 100644 lib/app_web/router.ex create mode 100644 lib/app_web/telemetry.ex create mode 100644 mix.exs diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..c025bd5 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,52 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +# Configures the endpoint +config :app, AppWeb.Endpoint, + url: [host: "localhost"], + render_errors: [ + formats: [html: AppWeb.ErrorHTML, json: AppWeb.ErrorJSON], + layout: false + ], + pubsub_server: App.PubSub, + live_view: [signing_salt: "euyclMQ2"] + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.14.41", + default: [ + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +# Configure tailwind (the version is required) +config :tailwind, + version: "3.2.4", + default: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..e0be8f8 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,65 @@ +import Config + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we use it +# with esbuild to bundle .js and .css sources. +config :app, AppWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "btTsEy6WVagm+4u+ZrbwVg6F48ZfgpePZx70twE9SSyPZKkZHiaYa77bFUWV4Vw5", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} + ] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Watch static and templates for browser reloading. +config :app, AppWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"lib/app_web/(controllers|live|components)/.*(ex|heex)$" + ] + ] + +# Enable dev routes for dashboard and mailbox +config :app, dev_routes: true + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..608ef13 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,18 @@ +import Config + +# For production, don't forget to configure the url host +# to something meaningful, Phoenix uses this information +# when generating URLs. + +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix phx.digest` task, +# which you should run after static files are built and +# before starting your production server. +config :app, AppWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..894e9a3 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,82 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/app start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :app, AppWeb.Endpoint, server: true +end + +if config_env() == :prod do + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :app, AppWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base + + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :app, AppWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your endpoint, ensuring + # no data is ever sent via http, always redirecting to https: + # + # config :app, AppWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..20a13c9 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,14 @@ +import Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :app, AppWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "d422JqbVTXef5vPy90SakC4QcPN76fRi6wLm+pUnC09eFxWUjPbTKe0dVmpGpI5N", + server: false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/lib/app.ex b/lib/app.ex new file mode 100644 index 0000000..a10dc06 --- /dev/null +++ b/lib/app.ex @@ -0,0 +1,9 @@ +defmodule App do + @moduledoc """ + App keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/lib/app/application.ex b/lib/app/application.ex new file mode 100644 index 0000000..d15ed4d --- /dev/null +++ b/lib/app/application.ex @@ -0,0 +1,34 @@ +defmodule App.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + # Start the Telemetry supervisor + AppWeb.Telemetry, + # Start the PubSub system + {Phoenix.PubSub, name: App.PubSub}, + # Start the Endpoint (http/https) + AppWeb.Endpoint + # Start a worker by calling: App.Worker.start_link(arg) + # {App.Worker, arg} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: App.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + AppWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/app_web.ex b/lib/app_web.ex new file mode 100644 index 0000000..512ce55 --- /dev/null +++ b/lib/app_web.ex @@ -0,0 +1,111 @@ +defmodule AppWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use AppWeb, :controller + use AppWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: AppWeb.Layouts] + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {AppWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # HTML escaping functionality + import Phoenix.HTML + # Core UI components and translation + import AppWeb.CoreComponents + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: AppWeb.Endpoint, + router: AppWeb.Router, + statics: AppWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/app_web/controllers/error_json.ex b/lib/app_web/controllers/error_json.ex new file mode 100644 index 0000000..000c0bf --- /dev/null +++ b/lib/app_web/controllers/error_json.ex @@ -0,0 +1,15 @@ +defmodule AppWeb.ErrorJSON do + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/lib/app_web/endpoint.ex b/lib/app_web/endpoint.ex new file mode 100644 index 0000000..5d76228 --- /dev/null +++ b/lib/app_web/endpoint.ex @@ -0,0 +1,46 @@ +defmodule AppWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :app + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_app_key", + signing_salt: "DbbfLLfE", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :app, + gzip: false, + only: AppWeb.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug AppWeb.Router +end diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex new file mode 100644 index 0000000..2a76aaa --- /dev/null +++ b/lib/app_web/router.ex @@ -0,0 +1,27 @@ +defmodule AppWeb.Router do + use AppWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, {AppWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", AppWeb do + pipe_through :browser + + get "/", PageController, :home + end + + # Other scopes may use custom stacks. + # scope "/api", AppWeb do + # pipe_through :api + # end +end diff --git a/lib/app_web/telemetry.ex b/lib/app_web/telemetry.ex new file mode 100644 index 0000000..c46627c --- /dev/null +++ b/lib/app_web/telemetry.ex @@ -0,0 +1,69 @@ +defmodule AppWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_join.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {AppWeb, :count_users, []} + ] + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..7eb54da --- /dev/null +++ b/mix.exs @@ -0,0 +1,64 @@ +defmodule App.MixProject do + use Mix.Project + + def project do + [ + app: :app, + version: "0.1.0", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {App.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.7.0"}, + {:phoenix_html, "~> 3.3"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 0.18.16"}, + {:heroicons, "~> 0.5"}, + {:floki, ">= 0.30.0", only: :test}, + {:esbuild, "~> 0.5", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev}, + {:telemetry_metrics, "~> 0.6"}, + {:telemetry_poller, "~> 1.0"}, + {:jason, "~> 1.2"}, + {:plug_cowboy, "~> 2.5"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "assets.setup", "assets.build"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind default", "esbuild default"], + "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] + ] + end +end From c8acd8873ec2619079ab0d7f23d750f063b8b323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 30 Oct 2023 16:18:06 +0000 Subject: [PATCH 02/30] chore: Adding rest of initial setup and README. #1 --- .formatter.exs | 5 + .gitignore | 41 +- README.md | 259 ++++++- assets/css/app.css | 5 + assets/js/app.js | 41 ++ assets/tailwind.config.js | 26 + assets/vendor/topbar.js | 165 +++++ lib/app_web/components/core_components.ex | 650 ++++++++++++++++++ lib/app_web/components/layouts.ex | 5 + lib/app_web/components/layouts/app.html.heex | 43 ++ lib/app_web/components/layouts/root.html.heex | 17 + lib/app_web/controllers/error_html.ex | 19 + lib/app_web/controllers/page_controller.ex | 9 + lib/app_web/controllers/page_html.ex | 5 + .../controllers/page_html/home.html.heex | 237 +++++++ mix.lock | 28 + priv/static/favicon.ico | Bin 0 -> 1258 bytes priv/static/robots.txt | 5 + test/app_web/controllers/error_html_test.exs | 14 + test/app_web/controllers/error_json_test.exs | 12 + .../controllers/page_controller_test.exs | 8 + test/support/conn_case.ex | 37 + test/test_helper.exs | 1 + 23 files changed, 1623 insertions(+), 9 deletions(-) create mode 100644 .formatter.exs create mode 100644 assets/css/app.css create mode 100644 assets/js/app.js create mode 100644 assets/tailwind.config.js create mode 100644 assets/vendor/topbar.js create mode 100644 lib/app_web/components/core_components.ex create mode 100644 lib/app_web/components/layouts.ex create mode 100644 lib/app_web/components/layouts/app.html.heex create mode 100644 lib/app_web/components/layouts/root.html.heex create mode 100644 lib/app_web/controllers/error_html.ex create mode 100644 lib/app_web/controllers/page_controller.ex create mode 100644 lib/app_web/controllers/page_html.ex create mode 100644 lib/app_web/controllers/page_html/home.html.heex create mode 100644 mix.lock create mode 100644 priv/static/favicon.ico create mode 100644 priv/static/robots.txt create mode 100644 test/app_web/controllers/error_html_test.exs create mode 100644 test/app_web/controllers/error_json_test.exs create mode 100644 test/app_web/controllers/page_controller_test.exs create mode 100644 test/support/conn_case.ex create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..e945e12 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:phoenix], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] +] diff --git a/.gitignore b/.gitignore index b263cd1..8073c0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,37 @@ -/_build -/cover -/deps -/doc +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. /.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). *.ez -*.beam -/config/*.secret.exs -.elixir_ls/ + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +app-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + diff --git a/README.md b/README.md index f103a53..7e437e0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,257 @@ -# image-classifier -Classify images and attempt to extract data from or describe their contents +
+ +# Image classifier in `Elixir` + +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dwyl/image-classifier/ci.yml?label=build&style=flat-square&branch=main) +[![codecov.io](https://img.shields.io/codecov/c/github/dwyl/image-classifier/main.svg?style=flat-square)](https://codecov.io/github/dwyl/image-classifier?branch=main) +[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat-square)](https://github.com/dwyl/image-classifier/issues) +[![HitCount](https://hits.dwyl.com/dwyl/image-classifier.svg?style=flat-square&show=unique)](https://hits.dwyl.com/dwyl/image-classifier) + +Classify your images using +machine learning models +within `Phoenix + Liveview`! + +
+ +
+ +- [Image classifier in `Elixir`](#image-classifier-in-elixir) +- [Why? 🤷](#why-) +- [What? 💭](#what-) +- [Who? 👤](#who-) +- [How? 💻](#how-) + - [Prerequisites](#prerequisites) + - [0. Creating a fresh `Phoenix` project](#0-creating-a-fresh-phoenix-project) + - [1. Adding `LiveView` capabilities to our project](#1-adding-liveview-capabilities-to-our-project) +- [_Please_ Star the repo! ⭐️](#please-star-the-repo-️) + + +
+ +# Why? 🤷 + +Building our +[app](https://github.com/dwyl/app), +we consider `images` an _essential_ +medium of communication. + +By adding a way of classifying images, +we make it *easy* for people +to suggest meta tags to describe images +so they become **searchable**. + + +# What? 💭 + +This run-through will create a simple +`Phoenix LiveView` web application +that will allow you to choose/drag an image +and classify the image. + + +# Who? 👤 + +This tutorial is aimed at `LiveView` beginners +that want to grasp how to do image classifying +within a `Phoenix` application. + +If you are completely new to `Phoenix` and `LiveView`, +we recommend you follow the **`LiveView` _Counter_ Tutorial**: +[dwyl/phoenix-liveview-counter-tutorial](https://github.com/dwyl/phoenix-liveview-counter-tutorial) + + +# How? 💻 + +In this chapter, we'll go over the development process +of this small application. +You'll learn how to do this *yourself*, +so grab some coffee and let's get cracking! + + +## Prerequisites + +This tutorial requires you have `Elixir` and `Phoenix` installed. +If you you don't, please see +[how to install Elixir](https://github.com/dwyl/learn-elixir#installation) +and +[Phoenix](https://hexdocs.pm/phoenix/installation.html#phoenix). + +We assume you know the basics of `Phoenix` +and have *some* knowledge of how it works. +If you don't, +we *highly suggest* you follow our other tutorials first. +e.g: +[github.com/dwyl/**phoenix-chat-example**](https://github.com/dwyl/phoenix-chat-example) + +In addition to this, +**_some_ knowledge of `AWS`** - +what it is, what an `S3` bucket is/does - +**is assumed**. + +> **Note**: if you have questions or get stuck, +> please open an issue! +> [/dwyl/image-classifier/issues](https://github.com/dwyl/image-classifier/issues) + + +## 0. Creating a fresh `Phoenix` project + +Let's create a fresh `Phoenix` project. +Run the following command in a given folder: + +```sh +mix phx.new . --app app --no-dashboard --no-ecto --no-gettext --no-mailer +``` + +We're running [`mix phx.new`](https://hexdocs.pm/phoenix/Mix.Tasks.Phx.New.html) +to generate a new project without a dashboard +and mailer (email) service, +since we don't need those in our project. + +After this, +if you run `mix phx.server` to run your server, +you should be able to see the following page. + +

+ +

+ +We're ready to start implementing! + + +## 1. Adding `LiveView` capabilities to our project + +As it stands, +our project is not using `LiveView`. +Let's fix this. + +In `lib/app_web/router.ex`, +change the `scope "/"` to the following. + +```elixir + scope "/", AppWeb do + pipe_through :browser + + live "/", ImgupLive + end +``` + +Instead of using the `PageController`, +we are going to be creating `ImgupLive`, +a `LiveView` file. + +Let's create our `LiveView` files. +Inside `lib/app_web`, +create a folder called `live` +and create the following file +`imgup_live.ex`. + +```elixir +defmodule AppWeb.ImgupLive do + use AppWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:uploaded_files, []) + |> allow_upload(:image_list, accept: ~w(image/*), max_entries: 6, chunk_size: 64_000)} + end +end +``` + +This is a simple `LiveView` controller +with the `mount/3` function +where we use the +[`allow_upload/3`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#allow_upload/3) +function, +which is needed to allow file uploads in `LiveView`. + +In the same `live` folder, +create a file called `imgup_live.html.heex` +and use the following code. + +```html +<.flash_group flash={@flash} /> +
+
+
+
+
+

Image Upload

+

Drag your images and they'll be uploaded to the cloud! ☁️

+ +
+ +
+
+
+ +
+ +

or drag and drop

+
+

PNG, JPG, GIF up to 10MB

+
+
+
+
+
+
+ +
+ + +
+
+
+
+``` + +This is a simple HTML form that uses +[`Tailwind CSS`](https://github.com/dwyl/learn-tailwind) +to enhance the presentation of the upload form. +We'll also remove the unused header of the page layout, +while we're at it. + +Locate the file `lib/app_web/components/layouts/app.html.heex` +and remove the `
` class. +The file should only have the following code: + +```html +
+
+ <.flash_group flash={@flash} /> + <%= @inner_content %> +
+
+``` + +Now you can safely delete the `lib/app_web/controllers` folder, +which is no longer used. + +If you run `mix phx.server`, +you should see the following screen: + +

+ +

+ +This means we've successfully added `LiveView` +and changed our view! +We can now start implementing file uploads! 🗳️ + +> If you want to see the changes made to the project, +> check [b414b11](https://github.com/dwyl/imgup/pull/55/commits). + + +# _Please_ Star the repo! ⭐️ + +If you find this package/repo useful, +please star on GitHub, so that we know! ⭐ + +Thank you! 🙏 \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..378c8f9 --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,5 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* This file is for your main application CSS */ diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..df0cdd9 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,41 @@ +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 0000000..e3bf241 --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,26 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +const plugin = require("tailwindcss/plugin") + +module.exports = { + content: [ + "./js/**/*.js", + "../lib/*_web.ex", + "../lib/*_web/**/*.*ex" + ], + theme: { + extend: { + colors: { + brand: "#FD4F00", + } + }, + }, + plugins: [ + require("@tailwindcss/forms"), + plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), + plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])) + ] +} diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js new file mode 100644 index 0000000..4195727 --- /dev/null +++ b/assets/vendor/topbar.js @@ -0,0 +1,165 @@ +/** + * @license MIT + * topbar 2.0.0, 2023-02-04 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/lib/app_web/components/core_components.ex b/lib/app_web/components/core_components.ex new file mode 100644 index 0000000..dad6364 --- /dev/null +++ b/lib/app_web/components/core_components.ex @@ -0,0 +1,650 @@ +defmodule AppWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + The components in this module use Tailwind CSS, a utility-first CSS framework. + See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to + customize the generated components in this module. + + Icons are provided by [heroicons](https://heroicons.com), using the + [heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project. + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + + @doc """ + Renders a modal. + + ## Examples + + <.modal id="confirm-modal"> + Are you sure? + <:confirm>OK + <:cancel>Cancel + + + JS commands may be passed to the `:on_cancel` and `on_confirm` attributes + for the caller to react to each button press, for example: + + <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}> + Are you sure you? + <:confirm>OK + <:cancel>Cancel + + """ + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :on_cancel, JS, default: %JS{} + attr :on_confirm, JS, default: %JS{} + + slot :inner_block, required: true + slot :title + slot :subtitle + slot :confirm + slot :cancel + + def modal(assigns) do + ~H""" + @@ -22,7 +22,6 @@ within `Phoenix + Liveview`! - [How? 💻](#how-) - [Prerequisites](#prerequisites) - [0. Creating a fresh `Phoenix` project](#0-creating-a-fresh-phoenix-project) - - [1. Adding `LiveView` capabilities to our project](#1-adding-liveview-capabilities-to-our-project) - [_Please_ Star the repo! ⭐️](#please-star-the-repo-️) @@ -44,14 +43,14 @@ so they become **searchable**. # What? 💭 This run-through will create a simple -`Phoenix LiveView` web application +`Phoenix` web application that will allow you to choose/drag an image and classify the image. # Who? 👤 -This tutorial is aimed at `LiveView` beginners +This tutorial is aimed at `Phoenix` beginners that want to grasp how to do image classifying within a `Phoenix` application. @@ -118,135 +117,8 @@ you should be able to see the following page. We're ready to start implementing! -## 1. Adding `LiveView` capabilities to our project -As it stands, -our project is not using `LiveView`. -Let's fix this. -In `lib/app_web/router.ex`, -change the `scope "/"` to the following. - -```elixir - scope "/", AppWeb do - pipe_through :browser - - live "/", ImgupLive - end -``` - -Instead of using the `PageController`, -we are going to be creating `ImgupLive`, -a `LiveView` file. - -Let's create our `LiveView` files. -Inside `lib/app_web`, -create a folder called `live` -and create the following file -`imgup_live.ex`. - -```elixir -defmodule AppWeb.ImgupLive do - use AppWeb, :live_view - - @impl true - def mount(_params, _session, socket) do - {:ok, - socket - |> assign(:uploaded_files, []) - |> allow_upload(:image_list, accept: ~w(image/*), max_entries: 6, chunk_size: 64_000)} - end -end -``` - -This is a simple `LiveView` controller -with the `mount/3` function -where we use the -[`allow_upload/3`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#allow_upload/3) -function, -which is needed to allow file uploads in `LiveView`. - -In the same `live` folder, -create a file called `imgup_live.html.heex` -and use the following code. - -```html -<.flash_group flash={@flash} /> -
-
-
-
-
-

Image Upload

-

Drag your images and they'll be uploaded to the cloud! ☁️

- -
- -
-
-
- -
- -

or drag and drop

-
-

PNG, JPG, GIF up to 10MB

-
-
-
-
-
-
- -
- - -
-
-
-
-``` - -This is a simple HTML form that uses -[`Tailwind CSS`](https://github.com/dwyl/learn-tailwind) -to enhance the presentation of the upload form. -We'll also remove the unused header of the page layout, -while we're at it. - -Locate the file `lib/app_web/components/layouts/app.html.heex` -and remove the `
` class. -The file should only have the following code: - -```html -
-
- <.flash_group flash={@flash} /> - <%= @inner_content %> -
-
-``` - -Now you can safely delete the `lib/app_web/controllers` folder, -which is no longer used. - -If you run `mix phx.server`, -you should see the following screen: - -

- -

- -This means we've successfully added `LiveView` -and changed our view! -We can now start implementing file uploads! 🗳️ - -> If you want to see the changes made to the project, -> check [b414b11](https://github.com/dwyl/imgup/pull/55/commits). # _Please_ Star the repo! ⭐️ From 6fcc31cc002f77380a4d9af58270ad10ad50679e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 30 Oct 2023 18:27:40 +0000 Subject: [PATCH 04/30] chore: Initial setup. #1 --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ config/config.exs | 3 +++ 2 files changed, 45 insertions(+) diff --git a/README.md b/README.md index f945fc8..43129db 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ within `Phoenix`! - [How? 💻](#how-) - [Prerequisites](#prerequisites) - [0. Creating a fresh `Phoenix` project](#0-creating-a-fresh-phoenix-project) + - [1. Installing initial dependencies](#1-installing-initial-dependencies) - [_Please_ Star the repo! ⭐️](#please-star-the-repo-️) @@ -117,8 +118,49 @@ you should be able to see the following page. We're ready to start implementing! +## 1. Installing initial dependencies +Now that we're ready to go, +let's start by adding some dependencies. +Head over to `mix.exs` +and add the following dependencies +to the `deps` section. + +```elixir +{:bumblebee, "~> 0.4.2"}, +{:exla, "~> 0.6.1"} +``` + +- [**`bumblebee`**](https://github.com/elixir-nx/bumblebee), +a framework that will allows us to integrate +[`Transformer Models`](https://huggingface.co/docs/transformers/index) in `Phoenix`. +`Transformers` (from [Hugging Face](https://huggingface.co/)) +are APIs that allow us to easily download and train pretrained models. +`Bumblebee` aims to support all Transformer Models, +however some are lacking. +You may check which ones are supported by visiting +`Bumblebee`'s repository +or visiting https://jonatanklosko-bumblebee-tools.hf.space/apps/repository-inspector +and checking if the model is currently supported. + +- [**`EXLA`**](https://hexdocs.pm/exla/EXLA.html), +Elixir implementation of [Google's XLA](https://www.tensorflow.org/xla/), +a compiler that provides faster linear algebra calculations +with `TensorFlow` models. +This backend compiler is needed for [`Nx`](https://github.com/elixir-nx/nx), +a framework that allows support for tensors and numerical definitions +in Elixir. +We are installing `EXLA` because allows us to compile models +*just-in-time* and run them on CPU and/or GPU. + +In `config/config.exs`, +let's add our `:nx` configuration +to use `EXLA`. + +```elixir +config :nx, default_backend: EXLA.Backend +``` # _Please_ Star the repo! ⭐️ diff --git a/config/config.exs b/config/config.exs index c025bd5..0f34bf1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -7,6 +7,9 @@ # General application configuration import Config +# Tells `NX` to use `EXLA` as backend +config :nx, default_backend: EXLA.Backend + # Configures the endpoint config :app, AppWeb.Endpoint, url: [host: "localhost"], From da928701df542c87ed9fb43862c1979e2d683963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 30 Oct 2023 19:59:16 +0000 Subject: [PATCH 05/30] chore: Adding LiveView. #1 --- README.md | 117 +++++++++ lib/app_web/components/layouts/app.html.heex | 37 --- lib/app_web/controllers/error_html.ex | 19 -- lib/app_web/controllers/error_json.ex | 15 -- lib/app_web/controllers/page_controller.ex | 9 - lib/app_web/controllers/page_html.ex | 5 - .../controllers/page_html/home.html.heex | 237 ------------------ lib/app_web/live/page_live.ex | 8 + lib/app_web/live/page_live.html.heex | 35 +++ lib/app_web/router.ex | 2 +- 10 files changed, 161 insertions(+), 323 deletions(-) delete mode 100644 lib/app_web/controllers/error_html.ex delete mode 100644 lib/app_web/controllers/error_json.ex delete mode 100644 lib/app_web/controllers/page_controller.ex delete mode 100644 lib/app_web/controllers/page_html.ex delete mode 100644 lib/app_web/controllers/page_html/home.html.heex create mode 100644 lib/app_web/live/page_live.ex create mode 100644 lib/app_web/live/page_live.html.heex diff --git a/README.md b/README.md index 43129db..3ee992c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ within `Phoenix`! - [Prerequisites](#prerequisites) - [0. Creating a fresh `Phoenix` project](#0-creating-a-fresh-phoenix-project) - [1. Installing initial dependencies](#1-installing-initial-dependencies) + - [2. Adding `LiveView` capabilities to our project](#2-adding-liveview-capabilities-to-our-project) - [_Please_ Star the repo! ⭐️](#please-star-the-repo-️) @@ -162,6 +163,122 @@ to use `EXLA`. config :nx, default_backend: EXLA.Backend ``` +## 2. Adding `LiveView` capabilities to our project + +As it stands, +our project is not using `LiveView`. +Let's fix this. + +In `lib/app_web/router.ex`, +change the `scope "/"` to the following. + +```elixir + scope "/", AppWeb do + pipe_through :browser + + live "/", PageLive + end +``` + +Instead of using the `PageController`, +we are going to be creating `ImgupLive`, +a `LiveView` file. + +Let's create our `LiveView` files. +Inside `lib/app_web`, +create a folder called `live` +and create the following file +`page_live.ex`. + +```elixir +defmodule AppWeb.PageLive do + use AppWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end +end + +``` + +This is a simple `LiveView` controller. + +In the same `live` folder, +create a file called `page_live.html.heex` +and use the following code. + +```html +<.flash_group flash={@flash} /> +
+
+
+
+
+

Image Classifier

+

Drag your images and we'll run an AI model to caption it!

+ +
+ +
+
+
+ +
+ +

or drag and drop

+
+

PNG, JPG, GIF up to 10MB

+
+
+
+ +
+
+
+
+
+
+``` + +This is a simple HTML form that uses +[`Tailwind CSS`](https://github.com/dwyl/learn-tailwind) +to enhance the presentation of the upload form. +We'll also remove the unused header of the page layout, +while we're at it. + +Locate the file `lib/app_web/components/layouts/app.html.heex` +and remove the `
` class. +The file should only have the following code: + +```html +
+
+ <.flash_group flash={@flash} /> + <%= @inner_content %> +
+
+``` + +Now you can safely delete the `lib/app_web/controllers` folder, +which is no longer used. + +If you run `mix phx.server`, +you should see the following screen: + +

+ +

+ +This means we've successfully added `LiveView` +and changed our view! + + # _Please_ Star the repo! ⭐️ diff --git a/lib/app_web/components/layouts/app.html.heex b/lib/app_web/components/layouts/app.html.heex index aa3878d..fec9a04 100644 --- a/lib/app_web/components/layouts/app.html.heex +++ b/lib/app_web/components/layouts/app.html.heex @@ -1,40 +1,3 @@ -
- -
<.flash_group flash={@flash} /> diff --git a/lib/app_web/controllers/error_html.ex b/lib/app_web/controllers/error_html.ex deleted file mode 100644 index f04c674..0000000 --- a/lib/app_web/controllers/error_html.ex +++ /dev/null @@ -1,19 +0,0 @@ -defmodule AppWeb.ErrorHTML do - use AppWeb, :html - - # If you want to customize your error pages, - # uncomment the embed_templates/1 call below - # and add pages to the error directory: - # - # * lib/app_web/controllers/error_html/404.html.heex - # * lib/app_web/controllers/error_html/500.html.heex - # - # embed_templates "error_html/*" - - # The default is to render a plain text page based on - # the template name. For example, "404.html" becomes - # "Not Found". - def render(template, _assigns) do - Phoenix.Controller.status_message_from_template(template) - end -end diff --git a/lib/app_web/controllers/error_json.ex b/lib/app_web/controllers/error_json.ex deleted file mode 100644 index 000c0bf..0000000 --- a/lib/app_web/controllers/error_json.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule AppWeb.ErrorJSON do - # If you want to customize a particular status code, - # you may add your own clauses, such as: - # - # def render("500.json", _assigns) do - # %{errors: %{detail: "Internal Server Error"}} - # end - - # By default, Phoenix returns the status message from - # the template name. For example, "404.json" becomes - # "Not Found". - def render(template, _assigns) do - %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} - end -end diff --git a/lib/app_web/controllers/page_controller.ex b/lib/app_web/controllers/page_controller.ex deleted file mode 100644 index df51b09..0000000 --- a/lib/app_web/controllers/page_controller.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule AppWeb.PageController do - use AppWeb, :controller - - def home(conn, _params) do - # The home page is often custom made, - # so skip the default app layout. - render(conn, :home, layout: false) - end -end diff --git a/lib/app_web/controllers/page_html.ex b/lib/app_web/controllers/page_html.ex deleted file mode 100644 index f7d5a30..0000000 --- a/lib/app_web/controllers/page_html.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule AppWeb.PageHTML do - use AppWeb, :html - - embed_templates "page_html/*" -end diff --git a/lib/app_web/controllers/page_html/home.html.heex b/lib/app_web/controllers/page_html/home.html.heex deleted file mode 100644 index 6a7480d..0000000 --- a/lib/app_web/controllers/page_html/home.html.heex +++ /dev/null @@ -1,237 +0,0 @@ -<.flash_group flash={@flash} /> - -
-
- -

- Phoenix Framework - - v1.7 - -

-

- Peace of mind from prototype to production. -

-

- Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. -

- -
-
diff --git a/lib/app_web/live/page_live.ex b/lib/app_web/live/page_live.ex new file mode 100644 index 0000000..023e79a --- /dev/null +++ b/lib/app_web/live/page_live.ex @@ -0,0 +1,8 @@ +defmodule AppWeb.PageLive do + use AppWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end +end diff --git a/lib/app_web/live/page_live.html.heex b/lib/app_web/live/page_live.html.heex new file mode 100644 index 0000000..69edbbc --- /dev/null +++ b/lib/app_web/live/page_live.html.heex @@ -0,0 +1,35 @@ +<.flash_group flash={@flash} /> +
+
+
+
+
+

Image Classifier

+

Drag your images and we'll run an AI model to caption it!

+ +
+ +
+
+
+ +
+ +

or drag and drop

+
+

PNG, JPG, GIF up to 10MB

+
+
+
+ +
+
+
+
+
+
diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index 2a76aaa..ece89fa 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -17,7 +17,7 @@ defmodule AppWeb.Router do scope "/", AppWeb do pipe_through :browser - get "/", PageController, :home + live "/", PageLive end # Other scopes may use custom stacks. From 35de4c9b1f5cbf308998bb8e47305dd8e2e24d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 30 Oct 2023 21:11:40 +0000 Subject: [PATCH 06/30] feat: Automatically consuming entries. #1 --- README.md | 161 +++++++++++++++++++++++++++ lib/app_web/live/page_live.ex | 41 ++++++- lib/app_web/live/page_live.html.heex | 50 +++++---- 3 files changed, 229 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3ee992c..7959707 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ within `Phoenix`! - [0. Creating a fresh `Phoenix` project](#0-creating-a-fresh-phoenix-project) - [1. Installing initial dependencies](#1-installing-initial-dependencies) - [2. Adding `LiveView` capabilities to our project](#2-adding-liveview-capabilities-to-our-project) + - [3. Receiving image files](#3-receiving-image-files) - [_Please_ Star the repo! ⭐️](#please-star-the-repo-️) @@ -279,6 +280,166 @@ This means we've successfully added `LiveView` and changed our view! +## 3. Receiving image files + +Now, let's start by receiving some image files. +In order to classify them, we need to have access to begin with, +right? + +With `LiveView`, +we can easily do this by using +[`allow_upload/3`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#allow_upload/3) +when mounting our `LiveView`. +With this function, we can easily accept +file uploads with progress. +We can define file types, max number of entries, +max file size, +validate the uploaded file and much more! + +Firstly, +let's make some changes to +`lib/app_web/live/page_live.html.heex`. + +```html +<.flash_group flash={@flash} /> +
+
+
+
+

Image Upload

+

Drag your images and they'll be uploaded to the cloud! ☁️

+

You may add up to <%= @uploads.image_list.max_entries %> exhibits at a time.

+ + +
+ +
+
+
+ +
+ +

or drag and drop

+
+

PNG, JPG, GIF up to 10MB

+
+
+
+
+
+
+
+
+``` + +We've added a few features: + +- used [`<.live_file_input/>`](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#live_file_input/1) +for `LiveView` file upload. +We've wrapped this component +with an element that is annotated with the `phx-drop-target` attribute +pointing to the DOM `id` of the file input. +- because `<.live_file_input/>` is being used, +we need to annotate its wrapping element +with `phx-submit` and `phx-change`, +as per https://hexdocs.pm/phoenix_live_view/uploads.html#render-reactive-elements. + +Because we've added these bindings, +we need to add the event handlers in +`lib/app_web/live/imgup_live.ex`. +Open it and update it to: + +```elixir +defmodule AppWeb.PageLive do + use AppWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:uploaded_files, []) + |> allow_upload(:image_list, + accept: ~w(image/*), + auto_upload: true, + progress: &handle_progress/3, + max_entries: 1, + chunk_size: 64_000 + )} + end + + @impl true + def handle_event("validate", _params, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("remove-selected", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :image_list, ref)} + end + + @impl true + def handle_event("save", _params, socket) do + {:noreply, socket} + end + + defp handle_progress(:image_list, entry, socket) do + if entry.done? do + uploaded_file = + consume_uploaded_entry(socket, entry, fn %{} = _meta -> + {:ok, entry} + end) + end + + {:noreply, socket} + end +end +``` + +- when `mount/3`ing the LiveView, +we are creating a list of uploaded images and assigning it to the socket +`uploaded_files`. +Additionally, we are using the `allow_upload/3` function to define our upload configuration. +The most important settings here are `auto_upload` set to `true` +and the `progress` fields. +By configuring these two properties, +we are telling `LiveView` that *whenever the person uploads a file*, +**it is processed immediately and consumed**. +- the `progress` field is handled by the `handle_progress/3` function. +We consume the file in this function by using +[`consume_uploaded_entry/3`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#consume_uploaded_entry/3). +Whilst consuming the entry/file, +we can access its path and then use it to our heart's content. +*For now*, we don't need to use it. +But we will in the future to feed our image classifier with it! +After the callback function is executed, +this function "consumes the entry", +essentially deleting the image from the temporary folder +and removing it from the uploaded files list. +- the `"validate"`, `"remove-selected"`, `"save"` event handlers +are called whenever the person uploads the image, +wants to remove it from the list of uploaded images +and when wants to submit the form, +respectively. +You may see that we're not doing much with these handlers; +we're simply replying with a `:noreply` +because we don't need to do anything with them. + +And that's it! +If you run `mix phx.server`, +nothing will change. +However, if you print the `uploaded_file` # _Please_ Star the repo! ⭐️ diff --git a/lib/app_web/live/page_live.ex b/lib/app_web/live/page_live.ex index 023e79a..fbb1c17 100644 --- a/lib/app_web/live/page_live.ex +++ b/lib/app_web/live/page_live.ex @@ -3,6 +3,45 @@ defmodule AppWeb.PageLive do @impl true def mount(_params, _session, socket) do - {:ok, socket} + {:ok, + socket + |> assign(:uploaded_files, []) + |> allow_upload(:image_list, + accept: ~w(image/*), + auto_upload: true, + progress: &handle_progress/3, + max_entries: 1, + chunk_size: 64_000 + )} + end + + @impl true + def handle_event("validate", _params, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("remove-selected", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :image_list, ref)} + end + + @impl true + def handle_event("save", _params, socket) do + {:noreply, socket} + end + + defp handle_progress(:image_list, entry, socket) do + if entry.done? do + uploaded_file = + consume_uploaded_entry(socket, entry, fn %{} = meta -> + file_path = meta.path + + # Do something with file path and then consume entry. + # It will remove the uploaded file from the temporary folder and remove it from the uploaded_files list + {:ok, entry} + end) + end + + {:noreply, socket} end end diff --git a/lib/app_web/live/page_live.html.heex b/lib/app_web/live/page_live.html.heex index 69edbbc..32140fd 100644 --- a/lib/app_web/live/page_live.html.heex +++ b/lib/app_web/live/page_live.html.heex @@ -1,35 +1,41 @@ <.flash_group flash={@flash} />
-
-
-
-

Image Classifier

-

Drag your images and we'll run an AI model to caption it!

+
+
+

Image Upload

+

Drag your images and they'll be uploaded to the cloud! ☁️

+

You may add up to <%= @uploads.image_list.max_entries %> exhibits at a time.

-
+ +
-
-
-
- -
- -

or drag and drop

-
-

PNG, JPG, GIF up to 10MB

+
+
+
+ +
+ +

or drag and drop

+

PNG, JPG, GIF up to 10MB

-
- +
From 449da9446b3f68b3fa0c782f2a144148036ae36b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Tue, 31 Oct 2023 21:42:58 +0000 Subject: [PATCH 07/30] chore: Trying to use JS to show image preview. --- assets/js/app.js | 106 +++++++++++++++++++++++---- lib/app_web/live/page_live.ex | 19 ++--- lib/app_web/live/page_live.html.heex | 88 +++++++++++++--------- 3 files changed, 152 insertions(+), 61 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index df0cdd9..0bb1f1d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -16,26 +16,106 @@ // // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. -import "phoenix_html" +import "phoenix_html"; // Establish Phoenix Socket and LiveView configuration. -import {Socket} from "phoenix" -import {LiveSocket} from "phoenix_live_view" -import topbar from "../vendor/topbar" - -let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") -let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) +import { Socket } from "phoenix"; +import { LiveSocket } from "phoenix_live_view"; +import topbar from "../vendor/topbar"; // Show progress bar on live navigation and form submits -topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) -window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) -window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) +topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); +window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300)); +window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); + +let hooks = {} +hooks.ImageInput = { + mounted() { + this.boundHeight = parseInt(this.el.dataset.height); + this.boundWidth = parseInt(this.el.dataset.width); + this.inputEl = this.el.querySelector(`#image-input`); + this.previewEl = this.el.querySelector(`#image-preview`); + + this.el.addEventListener("click", (e) => this.inputEl.click()); + this.inputEl.addEventListener("change", (e) => this.loadFile(event.target.files)); + this.el.addEventListener("dragover", (e) => { + e.stopPropagation(); + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }); + this.el.addEventListener("drop", (e) => { + e.stopPropagation(); + e.preventDefault(); + this.loadFile(e.dataTransfer.files); + }); + }, + + loadFile(files) { + const file = files && files[0]; + if (!file) { + return; + } + const reader = new FileReader(); + reader.onload = (readerEvent) => { + const imgEl = document.createElement("img"); + imgEl.addEventListener("load", (loadEvent) => { + this.setPreview(imgEl); + const blob = this.canvasToBlob(this.toCanvas(imgEl)); + this.upload("image", [blob]); + }); + imgEl.src = readerEvent.target.result; + }; + reader.readAsDataURL(file); + }, + + setPreview(imgEl) { + const previewImgEl = imgEl.cloneNode(); + previewImgEl.style.maxHeight = "100%"; + this.previewEl.replaceChildren(previewImgEl); + }, + + toCanvas(imgEl) { + // We resize the image, such that it fits in the configured height x width, but + // keep the aspect ratio. We could also easily crop, pad or squash the image, if desired + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const widthScale = this.boundWidth / imgEl.width; + const heightScale = this.boundHeight / imgEl.height; + const scale = Math.min(widthScale, heightScale); + canvas.width = Math.round(imgEl.width * scale); + canvas.height = Math.round(imgEl.height * scale); + ctx.drawImage(imgEl, 0, 0, imgEl.width, imgEl.height, 0, 0, canvas.width, canvas.height); + return canvas; + }, + + canvasToBlob(canvas) { + const imageData = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height); + const buffer = this.imageDataToRGBBuffer(imageData); + const meta = new ArrayBuffer(8); + const view = new DataView(meta); + view.setUint32(0, canvas.height, false); + view.setUint32(4, canvas.width, false); + return new Blob([meta, buffer], { type: "application/octet-stream" }); + }, + + imageDataToRGBBuffer(imageData) { + const pixelCount = imageData.width * imageData.height; + const bytes = new Uint8ClampedArray(pixelCount * 3); + for (let i = 0; i < pixelCount; i++) { + bytes[i * 3] = imageData.data[i * 4]; + bytes[i * 3 + 1] = imageData.data[i * 4 + 1]; + bytes[i * 3 + 2] = imageData.data[i * 4 + 2]; + } + return bytes.buffer; + }, +}; // connect if there are any LiveViews on the page -liveSocket.connect() +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); +let liveSocket = new LiveSocket("/live", Socket, { hooks: hooks, params: { _csrf_token: csrfToken } }); +liveSocket.connect(); // expose liveSocket on window for web console debug logs and latency simulation: // >> liveSocket.enableDebug() // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session // >> liveSocket.disableLatencySim() -window.liveSocket = liveSocket - +window.liveSocket = liveSocket; diff --git a/lib/app_web/live/page_live.ex b/lib/app_web/live/page_live.ex index fbb1c17..999f646 100644 --- a/lib/app_web/live/page_live.ex +++ b/lib/app_web/live/page_live.ex @@ -5,8 +5,8 @@ defmodule AppWeb.PageLive do def mount(_params, _session, socket) do {:ok, socket - |> assign(:uploaded_files, []) - |> allow_upload(:image_list, + |> assign(label: nil, running: false, task_ref: nil) + |> allow_upload(:image, accept: ~w(image/*), auto_upload: true, progress: &handle_progress/3, @@ -16,21 +16,14 @@ defmodule AppWeb.PageLive do end @impl true - def handle_event("validate", _params, socket) do + def handle_event("noop", %{}, socket) do + dbg("what") {:noreply, socket} end - @impl true - def handle_event("remove-selected", %{"ref" => ref}, socket) do - {:noreply, cancel_upload(socket, :image_list, ref)} - end - - @impl true - def handle_event("save", _params, socket) do - {:noreply, socket} - end + defp handle_progress(:image, entry, socket) do + dbg("bru") - defp handle_progress(:image_list, entry, socket) do if entry.done? do uploaded_file = consume_uploaded_entry(socket, entry, fn %{} = meta -> diff --git a/lib/app_web/live/page_live.html.heex b/lib/app_web/live/page_live.html.heex index 32140fd..5d44e0c 100644 --- a/lib/app_web/live/page_live.html.heex +++ b/lib/app_web/live/page_live.html.heex @@ -1,41 +1,59 @@ -<.flash_group flash={@flash} />
-
-
-
-

Image Upload

-

Drag your images and they'll be uploaded to the cloud! ☁️

-

You may add up to <%= @uploads.image_list.max_entries %> exhibits at a time.

+
+
+
+

Image Classification

+

+ Do simple classification with this LiveView + demo, powered by Bumblebee. +

- -
- -
-
+
-
- -
- -

or drag and drop

-
-

PNG, JPG, GIF up to 10MB

-
+ +
+ <.live_file_input upload={@uploads.image} class="hidden" /> + +
+
+ Drag an image file here or click to open file browser +
+
+
+ + + +
+ Description: + + <%= if @running do %> +
+
+
+
+
+ <% else %> + <%= if @label do %> + <%= @label %> + <% else %> + Waiting for image input. + <% end %> + <% end %>
-
-
+
-
-
+
From 0895a066b3e65bc30ace17864a1bdcce2f7093a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Tue, 31 Oct 2023 22:03:34 +0000 Subject: [PATCH 08/30] chore: Using LiveView's original functions to preview image. --- assets/js/app.js | 106 ++++----------------------- lib/app_web/live/page_live.ex | 19 +++-- lib/app_web/live/page_live.html.heex | 88 +++++++++------------- 3 files changed, 61 insertions(+), 152 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 0bb1f1d..df0cdd9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -16,106 +16,26 @@ // // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. -import "phoenix_html"; +import "phoenix_html" // Establish Phoenix Socket and LiveView configuration. -import { Socket } from "phoenix"; -import { LiveSocket } from "phoenix_live_view"; -import topbar from "../vendor/topbar"; +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" -// Show progress bar on live navigation and form submits -topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); -window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300)); -window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); - -let hooks = {} -hooks.ImageInput = { - mounted() { - this.boundHeight = parseInt(this.el.dataset.height); - this.boundWidth = parseInt(this.el.dataset.width); - this.inputEl = this.el.querySelector(`#image-input`); - this.previewEl = this.el.querySelector(`#image-preview`); - - this.el.addEventListener("click", (e) => this.inputEl.click()); - this.inputEl.addEventListener("change", (e) => this.loadFile(event.target.files)); - this.el.addEventListener("dragover", (e) => { - e.stopPropagation(); - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - }); - this.el.addEventListener("drop", (e) => { - e.stopPropagation(); - e.preventDefault(); - this.loadFile(e.dataTransfer.files); - }); - }, - - loadFile(files) { - const file = files && files[0]; - if (!file) { - return; - } - const reader = new FileReader(); - reader.onload = (readerEvent) => { - const imgEl = document.createElement("img"); - imgEl.addEventListener("load", (loadEvent) => { - this.setPreview(imgEl); - const blob = this.canvasToBlob(this.toCanvas(imgEl)); - this.upload("image", [blob]); - }); - imgEl.src = readerEvent.target.result; - }; - reader.readAsDataURL(file); - }, - - setPreview(imgEl) { - const previewImgEl = imgEl.cloneNode(); - previewImgEl.style.maxHeight = "100%"; - this.previewEl.replaceChildren(previewImgEl); - }, +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) - toCanvas(imgEl) { - // We resize the image, such that it fits in the configured height x width, but - // keep the aspect ratio. We could also easily crop, pad or squash the image, if desired - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - const widthScale = this.boundWidth / imgEl.width; - const heightScale = this.boundHeight / imgEl.height; - const scale = Math.min(widthScale, heightScale); - canvas.width = Math.round(imgEl.width * scale); - canvas.height = Math.round(imgEl.height * scale); - ctx.drawImage(imgEl, 0, 0, imgEl.width, imgEl.height, 0, 0, canvas.width, canvas.height); - return canvas; - }, - - canvasToBlob(canvas) { - const imageData = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height); - const buffer = this.imageDataToRGBBuffer(imageData); - const meta = new ArrayBuffer(8); - const view = new DataView(meta); - view.setUint32(0, canvas.height, false); - view.setUint32(4, canvas.width, false); - return new Blob([meta, buffer], { type: "application/octet-stream" }); - }, - - imageDataToRGBBuffer(imageData) { - const pixelCount = imageData.width * imageData.height; - const bytes = new Uint8ClampedArray(pixelCount * 3); - for (let i = 0; i < pixelCount; i++) { - bytes[i * 3] = imageData.data[i * 4]; - bytes[i * 3 + 1] = imageData.data[i * 4 + 1]; - bytes[i * 3 + 2] = imageData.data[i * 4 + 2]; - } - return bytes.buffer; - }, -}; +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) // connect if there are any LiveViews on the page -let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); -let liveSocket = new LiveSocket("/live", Socket, { hooks: hooks, params: { _csrf_token: csrfToken } }); -liveSocket.connect(); +liveSocket.connect() // expose liveSocket on window for web console debug logs and latency simulation: // >> liveSocket.enableDebug() // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session // >> liveSocket.disableLatencySim() -window.liveSocket = liveSocket; +window.liveSocket = liveSocket + diff --git a/lib/app_web/live/page_live.ex b/lib/app_web/live/page_live.ex index 999f646..fbb1c17 100644 --- a/lib/app_web/live/page_live.ex +++ b/lib/app_web/live/page_live.ex @@ -5,8 +5,8 @@ defmodule AppWeb.PageLive do def mount(_params, _session, socket) do {:ok, socket - |> assign(label: nil, running: false, task_ref: nil) - |> allow_upload(:image, + |> assign(:uploaded_files, []) + |> allow_upload(:image_list, accept: ~w(image/*), auto_upload: true, progress: &handle_progress/3, @@ -16,14 +16,21 @@ defmodule AppWeb.PageLive do end @impl true - def handle_event("noop", %{}, socket) do - dbg("what") + def handle_event("validate", _params, socket) do {:noreply, socket} end - defp handle_progress(:image, entry, socket) do - dbg("bru") + @impl true + def handle_event("remove-selected", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :image_list, ref)} + end + + @impl true + def handle_event("save", _params, socket) do + {:noreply, socket} + end + defp handle_progress(:image_list, entry, socket) do if entry.done? do uploaded_file = consume_uploaded_entry(socket, entry, fn %{} = meta -> diff --git a/lib/app_web/live/page_live.html.heex b/lib/app_web/live/page_live.html.heex index 5d44e0c..32140fd 100644 --- a/lib/app_web/live/page_live.html.heex +++ b/lib/app_web/live/page_live.html.heex @@ -1,59 +1,41 @@ +<.flash_group flash={@flash} />
-
-
-
-

Image Classification

-

- Do simple classification with this LiveView - demo, powered by Bumblebee. -

+
+
+
+

Image Upload

+

Drag your images and they'll be uploaded to the cloud! ☁️

+

You may add up to <%= @uploads.image_list.max_entries %> exhibits at a time.

- -
- -
- <.live_file_input upload={@uploads.image} class="hidden" /> - -
-
- Drag an image file here or click to open file browser -
-
-
-
+ +
- -
- Description: - - <%= if @running do %> -
-
-
-
-
- <% else %> - <%= if @label do %> - <%= @label %> - <% else %> - Waiting for image input. - <% end %> - <% end %> +
+
+
+ +
+ +

or drag and drop

+
+

PNG, JPG, GIF up to 10MB

+
-
+
+
-
+
+
From db05b5f6744632c4a03930f85d57793c6be828f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Wed, 1 Nov 2023 15:18:06 +0000 Subject: [PATCH 09/30] chore: Changing text --- README.md | 8 +++++--- lib/app_web/live/page_live.html.heex | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7959707..683ea4e 100644 --- a/README.md +++ b/README.md @@ -306,9 +306,11 @@ let's make some changes to
-

Image Upload

-

Drag your images and they'll be uploaded to the cloud! ☁️

-

You may add up to <%= @uploads.image_list.max_entries %> exhibits at a time.

+

Image Classification

+

+ Do simple classification with this LiveView + demo, powered by Bumblebee. +

diff --git a/lib/app_web/live/page_live.html.heex b/lib/app_web/live/page_live.html.heex index 32140fd..e66e5ff 100644 --- a/lib/app_web/live/page_live.html.heex +++ b/lib/app_web/live/page_live.html.heex @@ -3,9 +3,11 @@
-

Image Upload

-

Drag your images and they'll be uploaded to the cloud! ☁️

-

You may add up to <%= @uploads.image_list.max_entries %> exhibits at a time.

+

Image Classification

+

+ Do simple classification with this LiveView + demo, powered by Bumblebee. +

From 9c09eb02365ce0dd7e1c1d4405daed74873c5535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Wed, 1 Nov 2023 20:16:28 +0000 Subject: [PATCH 10/30] feat: Adding basic prediction with resnet-50. --- config/config.exs | 15 +++++ lib/app/application.ex | 12 ++++ lib/app_web/live/page_live.ex | 95 ++++++++++++++++++++++++---- lib/app_web/live/page_live.html.heex | 20 ++++++ mix.exs | 11 +++- mix.lock | 20 ++++++ 6 files changed, 161 insertions(+), 12 deletions(-) diff --git a/config/config.exs b/config/config.exs index 0f34bf1..5e43d32 100644 --- a/config/config.exs +++ b/config/config.exs @@ -50,6 +50,21 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +# Mogrify images +config :mogrify, mogrify_command: [ + path: "magick", + args: ["mogrify"] +] +config :mogrify, convert_command: [ + path: "magick", + args: ["convert"] +] +config :mogrify, identify_command: [ + path: "magick", + args: ["identify"] +] + + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/lib/app/application.ex b/lib/app/application.ex index d15ed4d..5e5c1e0 100644 --- a/lib/app/application.ex +++ b/lib/app/application.ex @@ -12,6 +12,7 @@ defmodule App.Application do AppWeb.Telemetry, # Start the PubSub system {Phoenix.PubSub, name: App.PubSub}, + {Nx.Serving, serving: serving(), name: ImageClassifier}, # Start the Endpoint (http/https) AppWeb.Endpoint # Start a worker by calling: App.Worker.start_link(arg) @@ -24,6 +25,17 @@ defmodule App.Application do Supervisor.start_link(children, opts) end + def serving do + {:ok, model_info} = Bumblebee.load_model({:hf, "microsoft/resnet-50"}) + {:ok, featurizer} = Bumblebee.load_featurizer({:hf, "microsoft/resnet-50"}) + + Bumblebee.Vision.image_classification(model_info, featurizer, + top_k: 1, + compile: [batch_size: 10], + defn_options: [compiler: EXLA] + ) + end + # Tell Phoenix to update the endpoint configuration # whenever the application is updated. @impl true diff --git a/lib/app_web/live/page_live.ex b/lib/app_web/live/page_live.ex index fbb1c17..6588aaf 100644 --- a/lib/app_web/live/page_live.ex +++ b/lib/app_web/live/page_live.ex @@ -1,11 +1,12 @@ defmodule AppWeb.PageLive do use AppWeb, :live_view + import Mogrify @impl true def mount(_params, _session, socket) do {:ok, socket - |> assign(:uploaded_files, []) + |> assign(label: nil, running: false, task_ref: nil) |> allow_upload(:image_list, accept: ~w(image/*), auto_upload: true, @@ -30,18 +31,90 @@ defmodule AppWeb.PageLive do {:noreply, socket} end - defp handle_progress(:image_list, entry, socket) do + def handle_progress(:image_list, entry, socket) do if entry.done? do - uploaded_file = - consume_uploaded_entry(socket, entry, fn %{} = meta -> - file_path = meta.path - - # Do something with file path and then consume entry. - # It will remove the uploaded file from the temporary folder and remove it from the uploaded_files list - {:ok, entry} - end) + socket + |> consume_uploaded_entry(entry, fn %{} = meta -> + # Resizes the image in-line + # open(meta.path) |> resize("224x224") |> save(in_place: true) + {:ok, vimage} = Vix.Vips.Image.new_from_file(meta.path) + + {:ok, flattened} = flatten(vimage) + {:ok, srgb} = to_colorspace(flattened, :VIPS_INTERPRETATION_sRGB) + {:ok, tensor} = to_nx(srgb, shape: :hwc) + end) + |> case do + tensor -> + task = Task.async(fn -> Nx.Serving.batched_run(ImageClassifier, tensor) end) + + {:noreply, assign(socket, running: true, task_ref: task.ref)} + + _ -> + {:noreply, socket} + end + else + {:noreply, socket} end + end - {:noreply, socket} + defp flatten(image) do + if Vix.Vips.Image.has_alpha?(image) do + Vix.Vips.Operation.flatten(image) + else + {:ok, image} + end + end + + defp to_colorspace(image, colorspace) do + Vix.Vips.Operation.colourspace(image, colorspace) + end + + def to_nx(image, options \\ []) do + {to_shape, options} = Keyword.pop(options, :shape, @default_shape) + + with {:ok, tensor} <- Vix.Vips.Image.write_to_tensor(image), + {:ok, shape, names} <- maybe_reshape_tensor(tensor, to_shape) do + %Vix.Tensor{data: binary, type: type} = tensor + + binary + |> Nx.from_binary(type, options) + |> Nx.reshape(shape, names: names) + |> wrap(:ok) + end + end + + # write_to_tensor writes in height, widght, bands format. No reshape + # is required. + defp maybe_reshape_tensor(%Vix.Tensor{shape: shape}, :hwc), + do: {:ok, shape, [:height, :width, :bands]} + + defp maybe_reshape_tensor(%Vix.Tensor{shape: shape}, :hwb), + do: {:ok, shape, [:height, :width, :bands]} + + defp maybe_reshape_tensor(%Vix.Tensor{} = tensor, :whb), + do: maybe_reshape_tensor(tensor, :whc) + + # We need to reshape the tensor since the default is + # :hwc + defp maybe_reshape_tensor(%Vix.Tensor{shape: {x, y, bands}}, :whc), + do: {:ok, {y, x, bands}, [:width, :height, :bands]} + + defp maybe_reshape_tensor(_tensor, shape) do + {:error, + "Invalid shape. Allowable shapes are :whb, :whc, :hwc and :hwb. Found #{inspect(shape)}"} + end + + defp wrap(item, atom) do + {atom, item} + end + + defp decode_as_tensor(<>) do + data |> Nx.from_binary(:u8) |> Nx.reshape({height, width, 3}) + end + + def handle_info({ref, result}, %{assigns: %{task_ref: ref}} = socket) do + Process.demonitor(ref, [:flush]) + %{predictions: [%{label: label}]} = result + {:noreply, assign(socket, label: label, running: false)} end end diff --git a/lib/app_web/live/page_live.html.heex b/lib/app_web/live/page_live.html.heex index e66e5ff..209488f 100644 --- a/lib/app_web/live/page_live.html.heex +++ b/lib/app_web/live/page_live.html.heex @@ -37,6 +37,26 @@
+ + +
+ Description: + + <%= if @running do %> +
+
+
+
+
+ <% else %> + <%= if @label do %> + <%= @label %> + <% else %> + Waiting for image input. + <% end %> + <% end %> +
+
diff --git a/mix.exs b/mix.exs index 7eb54da..cfec533 100644 --- a/mix.exs +++ b/mix.exs @@ -43,7 +43,16 @@ defmodule App.MixProject do {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 1.0"}, {:jason, "~> 1.2"}, - {:plug_cowboy, "~> 2.5"} + {:plug_cowboy, "~> 2.5"}, + + # Bumblebee imports + {:bumblebee, "~> 0.4.2"}, + {:exla, "~> 0.6.1"}, + {:nx, "~> 0.6.2"}, + + # Image + {:mogrify, "~> 0.9.3"}, + {:vix, "~> 0.23.1"} ] end diff --git a/mix.lock b/mix.lock index 3a5728a..18526c9 100644 --- a/mix.lock +++ b/mix.lock @@ -1,14 +1,25 @@ %{ + "axon": {:hex, :axon, "0.6.0", "fd7560079581e4cedebaf0cd5f741d6ac3516d06f204ebaf1283b1093bf66ff6", [:mix], [{:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}, {:kino_vega_lite, "~> 0.1.7", [hex: :kino_vega_lite, repo: "hexpm", optional: true]}, {:nx, "~> 0.6.0", [hex: :nx, repo: "hexpm", optional: false]}, {:polaris, "~> 0.1", [hex: :polaris, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1", [hex: :table_rex, repo: "hexpm", optional: true]}], "hexpm", "204e7aeb50d231a30b25456adf17bfbaae33fe7c085e03793357ac3bf62fd853"}, + "bumblebee": {:hex, :bumblebee, "0.4.2", "bf2a68be6fd3eaf3244bcc3fc985b0b2286b0a975764ecb30e39e73e2a329415", [:mix], [{:axon, "~> 0.6.0", [hex: :axon, repo: "hexpm", optional: false]}, {:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.6.2", [hex: :nx, repo: "hexpm", optional: false]}, {:nx_image, "~> 0.1.0", [hex: :nx_image, repo: "hexpm", optional: false]}, {:nx_signal, "~> 0.2.0", [hex: :nx_signal, repo: "hexpm", optional: false]}, {:progress_bar, "~> 3.0", [hex: :progress_bar, repo: "hexpm", optional: false]}, {:safetensors, "~> 0.1.2", [hex: :safetensors, repo: "hexpm", optional: false]}, {:tokenizers, "~> 0.4", [hex: :tokenizers, repo: "hexpm", optional: false]}, {:unpickler, "~> 0.1.0", [hex: :unpickler, repo: "hexpm", optional: false]}, {:unzip, "0.8.0", [hex: :unzip, repo: "hexpm", optional: false]}], "hexpm", "0e05b981562a12aeb91bdf01722f0cb4301e6462e80e3ffb126641027fc0977f"}, "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.8", "933a5f4da3b19ee56539a076076ce4d7716d64efc8db46fd066996a7e46e2bfd", [:mix], [{:elixir_make, "~> 0.7.3", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "176bdf4366956e456bf761b54ad70bc4103d0269ca9558fd7cee93d1b3f116db"}, + "complex": {:hex, :complex, "0.5.0", "af2d2331ff6170b61bb738695e481b27a66780e18763e066ee2cd863d0b1dd92", [:mix], [], "hexpm", "2683bd3c184466cfb94fad74cbfddfaa94b860e27ad4ca1bffe3bff169d91ef1"}, "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"}, "esbuild": {:hex, :esbuild, "0.7.1", "fa0947e8c3c3c2f86c9bf7e791a0a385007ccd42b86885e8e893bdb6631f5169", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "66661cdf70b1378ee4dc16573fcee67750b59761b2605a0207c267ab9d19f13c"}, + "exla": {:hex, :exla, "0.6.1", "a4400933a04d018c5fb508c75a080c73c3c1986f6c16a79bbfee93ba22830d4d", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nx, "~> 0.6.1", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.5.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "f0e95b0f91a937030cf9fcbe900c9d26933cb31db2a26dfc8569aa239679e6d4"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"}, "heroicons": {:hex, :heroicons, "0.5.3", "ee8ae8335303df3b18f2cc07f46e1cb6e761ba4cf2c901623fbe9a28c0bc51dd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "a210037e8a09ac17e2a0a0779d729e89c821c944434c3baa7edfc1f5b32f3502"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "mogrify": {:hex, :mogrify, "0.9.3", "238c782f00271dace01369ad35ae2e9dd020feee3443b9299ea5ea6bed559841", [:mix], [], "hexpm", "0189b1e1de27455f2b9ae8cf88239cefd23d38de9276eb5add7159aea51731e6"}, + "nx": {:hex, :nx, "0.6.2", "f1d137f477b1a6f84f8db638f7a6d5a0f8266caea63c9918aa4583db38ebe1d6", [:mix], [{:complex, "~> 0.5", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ac913b68d53f25f6eb39bddcf2d2cd6ea2e9bcb6f25cf86a79e35d0411ba96ad"}, + "nx_image": {:hex, :nx_image, "0.1.1", "69cf0d2fd873d12b028583aa49b5e0a25f6aca307afc337a5d871851a20fba1d", [:mix], [{:nx, "~> 0.4", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "55c8206a822237f6027168f11214e3887263c5b8a1f8e0634eea82c96e5093e3"}, + "nx_signal": {:hex, :nx_signal, "0.2.0", "e1ca0318877b17c81ce8906329f5125f1e2361e4c4235a5baac8a95ee88ea98e", [:mix], [{:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "7247e5e18a177a59c4cb5355952900c62fdeadeb2bad02a9a34237b68744e2bb"}, "phoenix": {:hex, :phoenix, "1.7.9", "9a2b873e2cb3955efdd18ad050f1818af097fa3f5fc3a6aaba666da36bdd3f02", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83e32da028272b4bfd076c61a964e6d2b9d988378df2f1276a0ed21b13b5e997"}, "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, @@ -18,11 +29,20 @@ "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, + "polaris": {:hex, :polaris, "0.1.0", "dca61b18e3e801ecdae6ac9f0eca5f19792b44a5cb4b8d63db50fc40fc038d22", [:mix], [{:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "13ef2b166650e533cb24b10e2f3b8ab4f2f449ba4d63156e8c569527f206e2c2"}, + "progress_bar": {:hex, :progress_bar, "3.0.0", "f54ff038c2ac540cfbb4c2bfe97c75e7116ead044f3c2b10c9f212452194b5cd", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "6981c2b25ab24aecc91a2dc46623658e1399c21a2ae24db986b90d678530f2b7"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.7.0", "5d0834fc06dbc76dd1034482f17b1797df0dba9b491cef8bb045fcaca94bcade", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "fdf43a6835f4e4de5bfbc4c019bfb8c46d124bd4635fefa3e20d9a2bbbec1512"}, + "safetensors": {:hex, :safetensors, "0.1.2", "849434fea20b2ed14b92e74205a925d86039c4ef53efe861e5c7b574c3ba8fa6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "298a5c82e34fc3b955464b89c080aa9a2625a47d69148d51113771e19166d4e0"}, "tailwind": {:hex, :tailwind, "0.1.10", "21ed80ae1f411f747ee513470578acaaa1d0eb40170005350c5b0b6d07e2d624", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "e0fc474dfa8ed7a4573851ac69c5fd3ca70fbb0a5bada574d1d657ebc6f2f1f1"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, + "tokenizers": {:hex, :tokenizers, "0.4.0", "140283ca74a971391ddbd83cd8cbdb9bd03736f37a1b6989b82d245a95e1eb97", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "ef1a9824f5a893cd3b831c0e5b3d72caa250d2ec462035cc6afef6933b13a82e"}, + "unpickler": {:hex, :unpickler, "0.1.0", "c2262c0819e6985b761e7107546cef96a485f401816be5304a65fdd200d5bd6a", [:mix], [], "hexpm", "e2b3f61e62406187ac52afead8a63bfb4e49394028993f3c4c42712743cab79e"}, + "unzip": {:hex, :unzip, "0.8.0", "ee21d87c21b01567317387dab4228ac570ca15b41cfc221a067354cbf8e68c4d", [:mix], [], "hexpm", "ffa67a483efcedcb5876971a50947222e104d5f8fea2c4a0441e6f7967854827"}, + "vix": {:hex, :vix, "0.23.1", "f0cacb0334a0b4d12fbd7d8b14c78e27bb3cb47c977f5f9abc66162499d03160", [:make, :mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "394d757017392fcbc594fe59fb8f9a7051c18c6fd42859513d7e0e1dfe429f53"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"}, + "xla": {:hex, :xla, "0.5.1", "8ba4c2c51c1a708ff54e9d4f88158c1a75b7f2cb3e5db02bf222b5b3852afffd", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "82a2490f6e9a76c8a29d1aedb47f07c59e3d5081095eac5a74db34d46c8212bc"}, } From bc83032b679e97174591a788fee226d5f511df5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 2 Nov 2023 01:47:35 +0000 Subject: [PATCH 11/30] chore: Removing mogrify and simplifying code. --- config/config.exs | 14 ---- lib/app_web/live/page_live.ex | 119 ++++++++++----------------- lib/app_web/live/page_live.html.heex | 2 +- mix.exs | 1 - mix.lock | 1 - 5 files changed, 44 insertions(+), 93 deletions(-) diff --git a/config/config.exs b/config/config.exs index 5e43d32..e3e1cfa 100644 --- a/config/config.exs +++ b/config/config.exs @@ -50,20 +50,6 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason -# Mogrify images -config :mogrify, mogrify_command: [ - path: "magick", - args: ["mogrify"] -] -config :mogrify, convert_command: [ - path: "magick", - args: ["convert"] -] -config :mogrify, identify_command: [ - path: "magick", - args: ["identify"] -] - # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/lib/app_web/live/page_live.ex b/lib/app_web/live/page_live.ex index 6588aaf..1ef821f 100644 --- a/lib/app_web/live/page_live.ex +++ b/lib/app_web/live/page_live.ex @@ -1,6 +1,6 @@ defmodule AppWeb.PageLive do use AppWeb, :live_view - import Mogrify + alias Vix.Vips.Image, as: Vimage @impl true def mount(_params, _session, socket) do @@ -12,109 +12,76 @@ defmodule AppWeb.PageLive do auto_upload: true, progress: &handle_progress/3, max_entries: 1, - chunk_size: 64_000 + chunk_size: 2_000, + max_file_size: 8_000 )} end @impl true - def handle_event("validate", _params, socket) do - {:noreply, socket} - end - - @impl true - def handle_event("remove-selected", %{"ref" => ref}, socket) do - {:noreply, cancel_upload(socket, :image_list, ref)} - end - - @impl true - def handle_event("save", _params, socket) do + def handle_event("noop", _params, socket) do {:noreply, socket} end def handle_progress(:image_list, entry, socket) do if entry.done? do - socket - |> consume_uploaded_entry(entry, fn %{} = meta -> - # Resizes the image in-line - # open(meta.path) |> resize("224x224") |> save(in_place: true) - {:ok, vimage} = Vix.Vips.Image.new_from_file(meta.path) - {:ok, flattened} = flatten(vimage) - {:ok, srgb} = to_colorspace(flattened, :VIPS_INTERPRETATION_sRGB) - {:ok, tensor} = to_nx(srgb, shape: :hwc) + # Consume the entry and get the tensor to feed to classifier + tensor = consume_uploaded_entry(socket, entry, fn %{} = meta -> + {:ok, vimage} = Vix.Vips.Image.new_from_file(meta.path) + pre_process_image(vimage) end) - |> case do - tensor -> - task = Task.async(fn -> Nx.Serving.batched_run(ImageClassifier, tensor) end) - {:noreply, assign(socket, running: true, task_ref: task.ref)} + # Create an async task to classify the image + task = Task.async(fn -> Nx.Serving.batched_run(ImageClassifier, tensor) end) - _ -> - {:noreply, socket} - end + # Update socket assigns to show spinner whilst task is running + {:noreply, assign(socket, running: true, task_ref: task.ref)} else {:noreply, socket} end end - defp flatten(image) do - if Vix.Vips.Image.has_alpha?(image) do - Vix.Vips.Operation.flatten(image) - else - {:ok, image} - end - end - - defp to_colorspace(image, colorspace) do - Vix.Vips.Operation.colourspace(image, colorspace) - end - - def to_nx(image, options \\ []) do - {to_shape, options} = Keyword.pop(options, :shape, @default_shape) + @impl true + def handle_info({ref, result}, %{assigns: %{task_ref: ref}} = socket) do + # This is called everytime an Async Task is created. + # We flush it here. + Process.demonitor(ref, [:flush]) - with {:ok, tensor} <- Vix.Vips.Image.write_to_tensor(image), - {:ok, shape, names} <- maybe_reshape_tensor(tensor, to_shape) do - %Vix.Tensor{data: binary, type: type} = tensor + # And then destructure the result from the classifier. + %{predictions: [%{label: label}]} = result - binary - |> Nx.from_binary(type, options) - |> Nx.reshape(shape, names: names) - |> wrap(:ok) - end + # Update the socket assigns with result and stopping spinner. + {:noreply, assign(socket, label: label, running: false)} end - # write_to_tensor writes in height, widght, bands format. No reshape - # is required. - defp maybe_reshape_tensor(%Vix.Tensor{shape: shape}, :hwc), - do: {:ok, shape, [:height, :width, :bands]} + defp pre_process_image(%Vimage{} = image) do - defp maybe_reshape_tensor(%Vix.Tensor{shape: shape}, :hwb), - do: {:ok, shape, [:height, :width, :bands]} + # If the image has an alpha channel, we flatten the alpha out of the image -------- + {:ok, flattened_image} = case Vix.Vips.Image.has_alpha?(image) do + true -> Vix.Vips.Operation.flatten(image) + false -> {:ok, image} + end - defp maybe_reshape_tensor(%Vix.Tensor{} = tensor, :whb), - do: maybe_reshape_tensor(tensor, :whc) + # Convert the image to sRGB colourspace ---------------- + {:ok, srgb_image} = Vix.Vips.Operation.colourspace(flattened_image, :VIPS_INTERPRETATION_sRGB) - # We need to reshape the tensor since the default is - # :hwc - defp maybe_reshape_tensor(%Vix.Tensor{shape: {x, y, bands}}, :whc), - do: {:ok, {y, x, bands}, [:width, :height, :bands]} + # Converting image to tensor ---------------- - defp maybe_reshape_tensor(_tensor, shape) do - {:error, - "Invalid shape. Allowable shapes are :whb, :whc, :hwc and :hwb. Found #{inspect(shape)}"} - end + {:ok, tensor} = Vix.Vips.Image.write_to_tensor(image) - defp wrap(item, atom) do - {atom, item} - end + # We reshape the tensor given a specific format. + # In this case, we are using {height, width, channels/bands}. + # If you want to use {width, height, channels/bands}, + # you need format = `[:width, :height, :bands]` and shape = `{y, x, bands}`. + %Vix.Tensor{data: binary, type: type, shape: {x, y, bands}} = tensor + format = [:height, :width, :bands] + shape = {x, y, bands} - defp decode_as_tensor(<>) do - data |> Nx.from_binary(:u8) |> Nx.reshape({height, width, 3}) - end + final_tensor = + binary + |> Nx.from_binary(type) + |> Nx.reshape(shape, names: format) - def handle_info({ref, result}, %{assigns: %{task_ref: ref}} = socket) do - Process.demonitor(ref, [:flush]) - %{predictions: [%{label: label}]} = result - {:noreply, assign(socket, label: label, running: false)} + {:ok, final_tensor} end end diff --git a/lib/app_web/live/page_live.html.heex b/lib/app_web/live/page_live.html.heex index 209488f..b3cdc0b 100644 --- a/lib/app_web/live/page_live.html.heex +++ b/lib/app_web/live/page_live.html.heex @@ -23,7 +23,7 @@
@@ -335,7 +356,7 @@ let's make some changes to

or drag and drop

-

PNG, JPG, GIF up to 10MB

+

PNG, JPG, GIF up to 5MB

@@ -371,7 +392,7 @@ defmodule AppWeb.PageLive do def mount(_params, _session, socket) do {:ok, socket - |> assign(:uploaded_files, []) + |> assign(label: nil, running: false, task_ref: nil) |> allow_upload(:image_list, accept: ~w(image/*), auto_upload: true, @@ -410,14 +431,18 @@ end ``` - when `mount/3`ing the LiveView, -we are creating a list of uploaded images and assigning it to the socket -`uploaded_files`. +we are creating three socket assigns: +`label` pertains to the model prediction; +`running` is a boolean referring to whether the model is running or not; +`task_ref` refers to the reference of the task that was created for image classification +(we'll delve into this further later down the line). Additionally, we are using the `allow_upload/3` function to define our upload configuration. The most important settings here are `auto_upload` set to `true` and the `progress` fields. By configuring these two properties, we are telling `LiveView` that *whenever the person uploads a file*, **it is processed immediately and consumed**. + - the `progress` field is handled by the `handle_progress/3` function. We consume the file in this function by using [`consume_uploaded_entry/3`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#consume_uploaded_entry/3). @@ -429,6 +454,7 @@ After the callback function is executed, this function "consumes the entry", essentially deleting the image from the temporary folder and removing it from the uploaded files list. + - the `"validate"`, `"remove-selected"`, `"save"` event handlers are called whenever the person uploads the image, wants to remove it from the list of uploaded images @@ -441,7 +467,560 @@ because we don't need to do anything with them. And that's it! If you run `mix phx.server`, nothing will change. -However, if you print the `uploaded_file` + + +# 4. Integrating `Bumblebee` + +Now here comes the fun part! +It's time to do some image classification! 🎉 + + +## 4.1 `Nx` configuration + +We first need to add some initial setup in the +`lib/app/application.ex` file. +Head over there and and change +the `start` function like so: + +```elixir + @impl true + def start(_type, _args) do + children = [ + # Start the Telemetry supervisor + AppWeb.Telemetry, + # Start the PubSub system + {Phoenix.PubSub, name: App.PubSub}, + {Nx.Serving, serving: serving(), name: ImageClassifier}, + # Start the Endpoint (http/https) + AppWeb.Endpoint + # Start a worker by calling: App.Worker.start_link(arg) + # {App.Worker, arg} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: App.Supervisor] + Supervisor.start_link(children, opts) + end + + def serving do + {:ok, model_info} = Bumblebee.load_model({:hf, "microsoft/resnet-50"}) + {:ok, featurizer} = Bumblebee.load_featurizer({:hf, "microsoft/resnet-50"}) + + Bumblebee.Vision.image_classification(model_info, featurizer, + top_k: 1, + compile: [batch_size: 10], + defn_options: [compiler: EXLA] + ) + end +``` + +We are using [`Nx.Serving`](https://hexdocs.pm/nx/Nx.Serving.html), +which simply allows us to encapsulates tasks, +be it networking, machine learning, data processing or any other task. + +In this specific case, +we are using it to **batch requests**. +This is extremely useful and important +because we are using models that typically run on GPU. +The GPU is *really good* at **parallelizing tasks**. +Therefore, instead of sending an image classification request one by one, +we can *batch them*/bundle them together as much as we can +and then send it over. + +We can define the `batch_size` and `batch_timeout` with `Nx.Serving`. +We're going to use the default values, +hence why we're not explicitly defining them. + +With `Nx.Serving`, we define a `serving/0` function +that is then used by it, +which in turn is executed in the supervision tree. + +In the `serving/0` function, +we are loading the [`ResNet-50`](https://huggingface.co/microsoft/resnet-50) model +and its featurizer. + +> [!NOTE] +> +> A `featurizer` can be seen as a [`Feature Extractor`](https://huggingface.co/docs/transformers/main_classes/feature_extractor). +> It is essentially a component that is responsible for converting input data +> into a format that can be processed by a pre-trained language model. +> +> It takes raw information and performs various transformations, +> such as [tokenization](https://neptune.ai/blog/tokenization-in-nlp), +> [padding](https://www.baeldung.com/cs/deep-neural-networks-padding), +> and encoding to prepare the data for model training or inference. + +Lastly, this function returns +a builds serving for image classification +by calling [`image_classification/3`](https://hexdocs.pm/bumblebee/Bumblebee.Vision.html#image_classification/3), +where we can define our compiler and task batch size. +We've given our serving function the name `ImageClassifier`. + + +## 4.2 `Async` processing the image for classification + +Now we're ready to send the image to the model +and get a prediction of it! + +Every time we upload an image, +we are going to run **async processing**. +This means that the task responsible for image classification +will be created asynchronously, +meaning that the LiveView *won't have to wait* for this task to finish +to continue working. + +For this scenario, +we are going to be using the +[`Task` module](https://hexdocs.pm/elixir/1.14/Task.html) +to spawn processes to complete this task. + +Go to `lib/app_web/live/page_live.ex` +and change the following code. + +```elixir + def handle_progress(:image_list, entry, socket) do + if entry.done? do + + # Consume the entry and get the tensor to feed to classifier + tensor = consume_uploaded_entry(socket, entry, fn %{} = meta -> + {:ok, vimage} = Vix.Vips.Image.new_from_file(meta.path) + pre_process_image(vimage) + end) + + # Create an async task to classify the image + task = Task.async(fn -> Nx.Serving.batched_run(ImageClassifier, tensor) end) + + # Update socket assigns to show spinner whilst task is running + {:noreply, assign(socket, running: true, task_ref: task.ref)} + else + {:noreply, socket} + end + end + + @impl true + def handle_info({ref, result}, %{assigns: %{task_ref: ref}} = socket) do + # This is called everytime an Async Task is created. + # We flush it here. + Process.demonitor(ref, [:flush]) + + # And then destructure the result from the classifier. + %{predictions: [%{label: label}]} = result + + # Update the socket assigns with result and stopping spinner. + {:noreply, assign(socket, label: label, running: false)} + end +``` + +> [!NOTE] +> +> The `pre_process_image/1` function is yet to be defined. +> We'll do that in the following section. + +In the `handle_progress/3` function, +whilst we are consuming the image, +we are first converting it to a +[`Vix.Vips.Image`](https://hexdocs.pm/vix/Vix.Vips.Image.html) struct. +using the file path. +We then feed this image to the `pre_process_image/1` function that we'll implement later. + +What's important is to notice this line: + +```elixir +task = Task.async(fn -> Nx.Serving.batched_run(ImageClassifier, tensor) end) +``` + +We are using [`Task.async/1`](https://hexdocs.pm/elixir/1.12/Task.html#async/1) +to call our `Nx.Serving` build function `ImageClassifier` we've defined earlier, +thus initiating a batched run with the image tensor. +While the task is spawned, +we update the socket assigns with the reference to the task (`:task_ref`) +and update the `:running` assign to `true`, +so we can show a spinner or a loading animation. + +When the task is spawned using `Task.async/1`, +a couple of things happen in the background. +The new process is monitored by the caller (our `LiveView`), +which means that the caller will receive a `{:DOWN, ref, :process, object, reason}` message once the process it is monitoring dies. +And, a link is created between both processes. + +Therefore, +we **don't need to use [`Task.await/2`](https://hexdocs.pm/elixir/1.12/Task.html#await/2)**. +Instead, we create a new handelr to receive the aforementioned. +That's what we're doing in the +`handle_info({ref, result}, %{assigns: %{task_ref: ref}} = socket)` function. +The received message contains a `{ref, result}` tuple, +where `ref` is the monitor’s reference. +We use this reference to stop monitoring the task, +since we received the result we needed from our task +and we can discard an exit message. + +In this same function, we destructure the prediction +from the model and assign it to the socket assign `:label` +and set `:running` to `false`. + +Quite beautiful, isn't it? +With this, we don't have to worry if the person closes the browser tab. +The process dies (as does our `LiveView`), +and the work is automatically cancelled, +meaning no resources are spent on a process from which nobody expects +the model result anymore. + + +### 4.2.1 Considerations regarding `async` processes + +When a task is spawned using `Task.async/2`, +**it is linked to the caller**. +Which means that they're related: if one dies, the other does too. + +We ought to take this into account when developing our application. +If we don't have control over the result of the task, +and we don't want our `LiveView` to crash if the task crashes, +we must use a different alternative to spawn our task - +[`Task.Supervisor.async_nolink/3`](https://hexdocs.pm/elixir/1.14/Task.Supervisor.html#async_nolink/3) +can be used for this effect, +meaning we can use it if we want to make sure +our `LiveView` won't die and the error is reported, +even if the task crashes. + +We've chosen `Task.async/2` for this very reason. +We are doing something **that takes time/is expensive** +and we **want to stop the task if `LiveView` is closed/crashes**. +However, if you are building something +like a report that has to be generated even if the person closes the browser tab, +this is not the right solution. + + +## 4.3 Image pre-processing + +As we've noted before, +we need to **pre-process the image before feeding it to the model**. +For this, we have three main steps: + +- removing the [`alpha` ](https://en.wikipedia.org/wiki/Alpha_compositing) +out of the image, flattening it out. +- convert the image to `sRGB` [colourspace](https://en.wikipedia.org/wiki/Color_space). +This is needed to ensure that the image is consistent +and aligns with the model's training data images. +- set the representation of the image as tensor +to `height, width, bands`. +The image tensor will then be organized as a three-dimensional array, +where the first dimension represents the height of the image, +the second refers to the width of the image, +and the third pertains to the different +[spectral bands/channels of the image](https://en.wikipedia.org/wiki/Multispectral_imaging). + +Our `pre_processing/3` function will implement these three steps. +Let's go over it now! + +In `lib/app_web/live/page_live.ex`, +add this piece of code. + +```elixir + defp pre_process_image(%Vimage{} = image) do + + # If the image has an alpha channel, we flatten the alpha out of the image -------- + {:ok, flattened_image} = case Vix.Vips.Image.has_alpha?(image) do + true -> Vix.Vips.Operation.flatten(image) + false -> {:ok, image} + end + + # Convert the image to sRGB colourspace ---------------- + {:ok, srgb_image} = Vix.Vips.Operation.colourspace(flattened_image, :VIPS_INTERPRETATION_sRGB) + + # Converting image to tensor ---------------- + {:ok, tensor} = Vix.Vips.Image.write_to_tensor(srgb_image) + + # We reshape the tensor given a specific format. + # In this case, we are using {height, width, channels/bands}. + # If you want to use {width, height, channels/bands}, + # you need format = `[:width, :height, :bands]` and shape = `{y, x, bands}`. + %Vix.Tensor{data: binary, type: type, shape: {x, y, bands}} = tensor + format = [:height, :width, :bands] + shape = {x, y, bands} + + final_tensor = + binary + |> Nx.from_binary(type) + |> Nx.reshape(shape, names: format) + + {:ok, final_tensor} + end +``` + +The function receives a `Vix` image, +as detailed earlier. +We use [`flatten/1`](https://hexdocs.pm/vix/Vix.Vips.Operation.html#flatten/2) +to flatten the alpha out of the image. + +The resulting image has its colourspaced changed +by calling [`colourspace/3`](https://hexdocs.pm/vix/Vix.Vips.Operation.html#colourspace/3), +where we change the to `sRGB`. + +The colourspace-altered image is then converted to a [tensor](https://hexdocs.pm/vix/Vix.Tensor.html), +by calling [`write_to_tensor/1`](https://hexdocs.pm/vix/Vix.Vips.Image.html#write_to_tensor/1). + +We then [reshape](https://hexdocs.pm/nx/Nx.html#reshape/3) +the tensor according to the format that was previously mentioned. + +This function returns the processed tensor, +that is then used as input to the model. + + +## 4.4 Updating the view + +All that's left is updating the view +to reflect these changes we've made to the `LiveView`. +Head over to `lib/app_web/live/page_live.html.heex` +and change it to this. + +```html +<.flash_group flash={@flash} /> +
+
+
+
+

Image Classification

+

+ Do simple classification with this LiveView + demo, powered by Bumblebee. +

+ + +
+
+
+
+ +
+ +

or drag and drop

+
+

PNG, JPG, GIF up to 5MB

+
+
+
+
+ + +
+ Description: + + <%= if @running do %> +
+
+
+
+
+ <% else %> + <%= if @label do %> + <%= @label %> + <% else %> + Waiting for image input. + <% end %> + <% end %> +
+ +
+
+
+
+``` + +In these changes, +we've added the output of the model in the form of text. +We are rendering a spinner +if the `:running` socket assign is set to true. +Otherwise, +we add the `:label`, which holds the prediction made by the model. + +You may have also noticed that +we've changed the `phx` event handlers +to `noop`. +This is simply to simplify the `LiveView`. + +Head over to `lib/app_web/live/page_live.ex`. +You can now remove the `"validate"`, `"save"` +and `"remove-selected"` handlers, +because we're not going to be needing them. +Replace them with this handler: + +```elixir + @impl true + def handle_event("noop", _params, socket) do + {:noreply, socket} + end +``` + + +## 4.5 Check it out! + +And that's it! +Our app is now *functional* 🎉. + +If you run the app, +you can drag and drop or select an image. +After this, a task will be spawned that will run the model +against the image that was submitted. + +After the prediction is made, it's then shown to the person! + +

+ +

+ +You can and **should** try other models. +`ResNet-50` is just one of the many that are supported by `Bumblebee`. +You can see the supported models in https://github.com/elixir-nx/bumblebee#model-support. + + +# 4.6 Considerations on user images + +To maintain the app as simple as possible, +we are receiving the image from the person as is. +Although we are processing the image, +we are doing it so **it is processable by the model**. + +We have to understand that: +- in most cases, **full-resolution images are not necessary**, +because neural networks work on much smaller inputs +(e.g. `ResNet-50` works with `224px x 224px` images). +This means that a lot of data is unnecessarily uploaded over the network, +increasing workload on the server to potentially downsize a large image. +- decoding an image requires an additional package, +meaning more work on the server. + +We can avoid both of these downsides by moving this work to the client. +We can leverage the [`Canvas API` ](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) +to decode and downsize this image in the client-side, +reducing server workload. + +You can see an example implementation of this technique +in `Bumblebee`'s repository +at https://github.com/elixir-nx/bumblebee/blob/main/examples/phoenix/image_classification.exs. + + +## 5. Final touches + +Although our app is functional, +we can make it **better**. 🎨 + + +### 5.1 Setting max file size + +In order to better control user input, +we should add a limit to the size of the image that is being uploaded. +It will be easier on our server and ultimately save costs. + +Let's add a cap of `5MB` to our app! +Fortunately for you, this is super simple! +You just need to add the `max_file_size` +to the `allow_uploads/2` function +when mounting the `LiveView`! + +```elixir + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(label: nil, running: false, task_ref: nil) + |> allow_upload(:image_list, + accept: ~w(image/*), + auto_upload: true, + progress: &handle_progress/3, + max_entries: 1, + chunk_size: 64_000, + max_file_size: 5_000_000 # add this + )} + end +``` + +And that's it! +The number is in `bytes`, +hence why we set it as `5_000_000`. + + +### 5.2 Show errors + +In case a person uploads an image that is too large, +we should show this feedback to the person! + +For this, we can leverage the +[`upload_errors/2`](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#upload_errors/2) +function. +This function will return the entry errors for an upload. +We need to add an handler for one of these errors to show it first. + +Head over `lib/app_web/live/page_live.ex` +and add the following line. + +```elixir + def error_to_string(:too_large), do: "Image too large. Upload a smaller image up to 5MB." +``` + +Now, add the following section below the upload form +inside `lib/app_web/live/page_live.html.heex`. + +```html + +<%= for entry <- @uploads.image_list.entries do %> +
+ <%= for err <- upload_errors(@uploads.image_list, entry) do %> +
+
+
+ +
+
+

+ <%= error_to_string(err) %> +

+
+
+
+ <% end %> +
+<% end %> +``` + +We are iterating over the errors returned by `upload_errors/2` +and invoking `error_to_string/1`, +which we've just defined in our `LiveView`. + +Now, if you run the app +and try to upload an image that is too large, +an error will show up. + +Awesome! 🎉 + + +

+ +

+ + # _Please_ Star the repo! ⭐️ diff --git a/lib/app_web/live/page_live.ex b/lib/app_web/live/page_live.ex index f565185..e43368b 100644 --- a/lib/app_web/live/page_live.ex +++ b/lib/app_web/live/page_live.ex @@ -12,8 +12,8 @@ defmodule AppWeb.PageLive do auto_upload: true, progress: &handle_progress/3, max_entries: 1, - chunk_size: 2_000, - max_file_size: 10_000_000 + chunk_size: 64_000, + max_file_size: 5_000_000 )} end @@ -54,6 +54,8 @@ defmodule AppWeb.PageLive do {:noreply, assign(socket, label: label, running: false)} end + def error_to_string(:too_large), do: "Image too large. Upload a smaller image up to 10MB." + defp pre_process_image(%Vimage{} = image) do # If the image has an alpha channel, we flatten the alpha out of the image -------- @@ -83,6 +85,4 @@ defmodule AppWeb.PageLive do {:ok, final_tensor} end - - def error_to_string(:too_large), do: "Image too large. Upload a smaller image up to 10MB." end diff --git a/lib/app_web/live/page_live.html.heex b/lib/app_web/live/page_live.html.heex index 188ee59..f03036b 100644 --- a/lib/app_web/live/page_live.html.heex +++ b/lib/app_web/live/page_live.html.heex @@ -31,7 +31,7 @@

or drag and drop

-

PNG, JPG, GIF up to 10MB

+

PNG, JPG, GIF up to 5MB

From a4f742ebbb033810053b4e00201d86106ca876ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 6 Nov 2023 16:46:41 +0000 Subject: [PATCH 14/30] chore: Setting up testing. --- coveralls.json | 16 ++++++++++++++ mix.exs | 21 ++++++++++++++++--- mix.lock | 1 + test/app_web/controllers/error_html_test.exs | 14 ------------- test/app_web/controllers/error_json_test.exs | 12 ----------- .../controllers/page_controller_test.exs | 8 ------- test/app_web/live/page_live_test.exs | 11 ++++++++++ 7 files changed, 46 insertions(+), 37 deletions(-) create mode 100644 coveralls.json delete mode 100644 test/app_web/controllers/error_html_test.exs delete mode 100644 test/app_web/controllers/error_json_test.exs delete mode 100644 test/app_web/controllers/page_controller_test.exs create mode 100644 test/app_web/live/page_live_test.exs diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 0000000..acebc66 --- /dev/null +++ b/coveralls.json @@ -0,0 +1,16 @@ +{ + "skip_files": [ + "test/", + "lib/app.ex", + "lib/app/application.ex", + "lib/app_web.ex", + "lib/app/repo.ex", + "lib/app/release.ex", + "lib/app_web/views/app_view.ex", + "lib/app_web/views/init_view.ex", + "lib/app_web/views/layout_view.ex", + "lib/app_web/views/error_helpers.ex", + "lib/app_web/endpoint.ex", + "lib/app_web/telemetry.ex" + ] + } \ No newline at end of file diff --git a/mix.exs b/mix.exs index abf1939..a0ad097 100644 --- a/mix.exs +++ b/mix.exs @@ -9,7 +9,15 @@ defmodule App.MixProject do elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, aliases: aliases(), - deps: deps() + deps: deps(), + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + c: :test, + coveralls: :test, + "coveralls.json": :test, + "coveralls.html": :test, + t: :test + ] ] end @@ -51,7 +59,10 @@ defmodule App.MixProject do {:nx, "~> 0.6.2"}, # Image - {:vix, "~> 0.23.1"} + {:vix, "~> 0.23.1"}, + + # Testing + {:excoveralls, "~> 0.15", only: [:test, :dev]}, ] end @@ -66,7 +77,11 @@ defmodule App.MixProject do setup: ["deps.get", "assets.setup", "assets.build"], "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], "assets.build": ["tailwind default", "esbuild default"], - "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] + "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"], + test: ["test"], + t: ["test"], + c: ["coveralls.html"], + s: ["phx.server"] ] end end diff --git a/mix.lock b/mix.lock index 75242a2..204f924 100644 --- a/mix.lock +++ b/mix.lock @@ -10,6 +10,7 @@ "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"}, "esbuild": {:hex, :esbuild, "0.7.1", "fa0947e8c3c3c2f86c9bf7e791a0a385007ccd42b86885e8e893bdb6631f5169", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "66661cdf70b1378ee4dc16573fcee67750b59761b2605a0207c267ab9d19f13c"}, + "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, "exla": {:hex, :exla, "0.6.1", "a4400933a04d018c5fb508c75a080c73c3c1986f6c16a79bbfee93ba22830d4d", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nx, "~> 0.6.1", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.5.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "f0e95b0f91a937030cf9fcbe900c9d26933cb31db2a26dfc8569aa239679e6d4"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"}, diff --git a/test/app_web/controllers/error_html_test.exs b/test/app_web/controllers/error_html_test.exs deleted file mode 100644 index fab2b79..0000000 --- a/test/app_web/controllers/error_html_test.exs +++ /dev/null @@ -1,14 +0,0 @@ -defmodule AppWeb.ErrorHTMLTest do - use AppWeb.ConnCase, async: true - - # Bring render_to_string/4 for testing custom views - import Phoenix.Template - - test "renders 404.html" do - assert render_to_string(AppWeb.ErrorHTML, "404", "html", []) == "Not Found" - end - - test "renders 500.html" do - assert render_to_string(AppWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" - end -end diff --git a/test/app_web/controllers/error_json_test.exs b/test/app_web/controllers/error_json_test.exs deleted file mode 100644 index ebc82d7..0000000 --- a/test/app_web/controllers/error_json_test.exs +++ /dev/null @@ -1,12 +0,0 @@ -defmodule AppWeb.ErrorJSONTest do - use AppWeb.ConnCase, async: true - - test "renders 404" do - assert AppWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} - end - - test "renders 500" do - assert AppWeb.ErrorJSON.render("500.json", %{}) == - %{errors: %{detail: "Internal Server Error"}} - end -end diff --git a/test/app_web/controllers/page_controller_test.exs b/test/app_web/controllers/page_controller_test.exs deleted file mode 100644 index 659fc1f..0000000 --- a/test/app_web/controllers/page_controller_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule AppWeb.PageControllerTest do - use AppWeb.ConnCase - - test "GET /", %{conn: conn} do - conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" - end -end diff --git a/test/app_web/live/page_live_test.exs b/test/app_web/live/page_live_test.exs new file mode 100644 index 0000000..092bda8 --- /dev/null +++ b/test/app_web/live/page_live_test.exs @@ -0,0 +1,11 @@ +defmodule AppWeb.PageLiveTest do + use AppWeb.ConnCase + import Phoenix.LiveViewTest + + test "connected mount", %{conn: conn} do + conn = get(conn, "/") + assert html_response(conn, 200) =~ "Image Classification" + + {:ok, _view, _html} = live(conn) + end +end From 01a89ff62c8b9240bae42ed62442cddafdf4b04e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 6 Nov 2023 17:53:57 +0000 Subject: [PATCH 15/30] chore: Updating tests. --- coveralls.json | 1 + lib/app_web/router.ex | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/coveralls.json b/coveralls.json index acebc66..b1550d0 100644 --- a/coveralls.json +++ b/coveralls.json @@ -10,6 +10,7 @@ "lib/app_web/views/init_view.ex", "lib/app_web/views/layout_view.ex", "lib/app_web/views/error_helpers.ex", + "lib/app_web/components", "lib/app_web/endpoint.ex", "lib/app_web/telemetry.ex" ] diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index ece89fa..16f50fc 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -10,9 +10,9 @@ defmodule AppWeb.Router do plug :put_secure_browser_headers end - pipeline :api do - plug :accepts, ["json"] - end + # pipeline :api do + # plug :accepts, ["json"] + # end scope "/", AppWeb do pipe_through :browser From bf9cacdff316d475824cc58d1fca92bc7a52a2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 6 Nov 2023 19:08:59 +0000 Subject: [PATCH 16/30] chore: Updating dependencies. --- mix.exs | 20 ++++++++++---------- mix.lock | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mix.exs b/mix.exs index a0ad097..0bcec48 100644 --- a/mix.exs +++ b/mix.exs @@ -40,18 +40,18 @@ defmodule App.MixProject do # Type `mix help deps` for examples and options. defp deps do [ - {:phoenix, "~> 1.7.0"}, - {:phoenix_html, "~> 3.3"}, - {:phoenix_live_reload, "~> 1.2", only: :dev}, - {:phoenix_live_view, "~> 0.18.16"}, - {:heroicons, "~> 0.5"}, - {:floki, ">= 0.30.0", only: :test}, - {:esbuild, "~> 0.5", runtime: Mix.env() == :dev}, - {:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev}, + {:phoenix, "~> 1.7.10"}, + {:phoenix_html, "~> 3.3.3"}, + {:phoenix_live_reload, "~> 1.4.1", only: :dev}, + {:phoenix_live_view, "~> 0.20.1"}, + {:heroicons, "~> 0.5.3"}, + {:floki, ">= 0.35.2", only: :test}, + {:esbuild, "~> 0.8.1", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2.2", runtime: Mix.env() == :dev}, {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 1.0"}, - {:jason, "~> 1.2"}, - {:plug_cowboy, "~> 2.5"}, + {:jason, "~> 1.4"}, + {:plug_cowboy, "~> 2.6.1"}, # Bumblebee imports {:bumblebee, "~> 0.4.2"}, diff --git a/mix.lock b/mix.lock index 204f924..def88f6 100644 --- a/mix.lock +++ b/mix.lock @@ -9,7 +9,7 @@ "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"}, - "esbuild": {:hex, :esbuild, "0.7.1", "fa0947e8c3c3c2f86c9bf7e791a0a385007ccd42b86885e8e893bdb6631f5169", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "66661cdf70b1378ee4dc16573fcee67750b59761b2605a0207c267ab9d19f13c"}, + "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, "exla": {:hex, :exla, "0.6.1", "a4400933a04d018c5fb508c75a080c73c3c1986f6c16a79bbfee93ba22830d4d", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nx, "~> 0.6.1", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.5.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "f0e95b0f91a937030cf9fcbe900c9d26933cb31db2a26dfc8569aa239679e6d4"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, @@ -20,10 +20,10 @@ "nx": {:hex, :nx, "0.6.2", "f1d137f477b1a6f84f8db638f7a6d5a0f8266caea63c9918aa4583db38ebe1d6", [:mix], [{:complex, "~> 0.5", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ac913b68d53f25f6eb39bddcf2d2cd6ea2e9bcb6f25cf86a79e35d0411ba96ad"}, "nx_image": {:hex, :nx_image, "0.1.1", "69cf0d2fd873d12b028583aa49b5e0a25f6aca307afc337a5d871851a20fba1d", [:mix], [{:nx, "~> 0.4", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "55c8206a822237f6027168f11214e3887263c5b8a1f8e0634eea82c96e5093e3"}, "nx_signal": {:hex, :nx_signal, "0.2.0", "e1ca0318877b17c81ce8906329f5125f1e2361e4c4235a5baac8a95ee88ea98e", [:mix], [{:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "7247e5e18a177a59c4cb5355952900c62fdeadeb2bad02a9a34237b68744e2bb"}, - "phoenix": {:hex, :phoenix, "1.7.9", "9a2b873e2cb3955efdd18ad050f1818af097fa3f5fc3a6aaba666da36bdd3f02", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83e32da028272b4bfd076c61a964e6d2b9d988378df2f1276a0ed21b13b5e997"}, + "phoenix": {:hex, :phoenix, "1.7.10", "02189140a61b2ce85bb633a9b6fd02dff705a5f1596869547aeb2b2b95edd729", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "cf784932e010fd736d656d7fead6a584a4498efefe5b8227e9f383bf15bb79d0"}, "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.18", "1f38fbd7c363723f19aad1a04b5490ff3a178e37daaf6999594d5f34796c47fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a5810d0472f3189ede6d2a95bda7f31c6113156b91784a3426cb0ab6a6d85214"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.1", "92a37acf07afca67ac98bd326532ba8f44ad7d4bdf3e4361b03f7f02594e5ae9", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be494fd1215052729298b0e97d5c2ce8e719c00854b82cd8cf15c1cd7fcf6294"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.3", "32de561eefcefa951aead30a1f94f1b5f0379bc9e340bb5c667f65f1edfa4326", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "16f4b6588a4152f3cc057b9d0c0ba7e82ee23afa65543da535313ad8d25d8e2c"}, "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, @@ -34,7 +34,7 @@ "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.7.0", "5d0834fc06dbc76dd1034482f17b1797df0dba9b491cef8bb045fcaca94bcade", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "fdf43a6835f4e4de5bfbc4c019bfb8c46d124bd4635fefa3e20d9a2bbbec1512"}, "safetensors": {:hex, :safetensors, "0.1.2", "849434fea20b2ed14b92e74205a925d86039c4ef53efe861e5c7b574c3ba8fa6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "298a5c82e34fc3b955464b89c080aa9a2625a47d69148d51113771e19166d4e0"}, - "tailwind": {:hex, :tailwind, "0.1.10", "21ed80ae1f411f747ee513470578acaaa1d0eb40170005350c5b0b6d07e2d624", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "e0fc474dfa8ed7a4573851ac69c5fd3ca70fbb0a5bada574d1d657ebc6f2f1f1"}, + "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, From 564bcc642e3b674eb942876e6a6e24dbac751159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 6 Nov 2023 20:26:51 +0000 Subject: [PATCH 17/30] chore: Adding testing support system. --- README.md | 2 +- lib/app_web/live/page_live.html.heex | 2 +- priv/static/images/corrupted.jpg | Bin 0 -> 68197 bytes priv/static/images/empty.jpg | 0 priv/static/images/phoenix.png | Bin 0 -> 13900 bytes test/app_web/live/page_live_test.exs | 23 +++++++++++++++++++ test/support/upload_support.ex | 32 +++++++++++++++++++++++++++ 7 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 priv/static/images/corrupted.jpg create mode 100644 priv/static/images/empty.jpg create mode 100644 priv/static/images/phoenix.png create mode 100644 test/support/upload_support.ex diff --git a/README.md b/README.md index 53acc4a..20c3d0c 100644 --- a/README.md +++ b/README.md @@ -799,7 +799,7 @@ and change it to this.