diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d2c8b0fb1..9b6d6232b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -6,8 +6,7 @@ on:
- main
env:
otp: "25.0"
- # TODO: update to v1.14.2 once it is out
- elixir: "main"
+ elixir: "1.14.2"
jobs:
main:
runs-on: ubuntu-latest
diff --git a/.github/workflows/uffizzi-build.yml b/.github/workflows/uffizzi-build.yml
index 3338c95f6..919d9a3b7 100644
--- a/.github/workflows/uffizzi-build.yml
+++ b/.github/workflows/uffizzi-build.yml
@@ -3,6 +3,10 @@ on:
pull_request:
types: [opened, synchronize, reopened, closed]
+env:
+ otp: "25.0"
+ elixir: "1.14.0"
+
jobs:
build-application:
name: Build PR image
@@ -13,6 +17,42 @@ jobs:
steps:
- name: Checkout git repo
uses: actions/checkout@v3
+ # --- START build assets
+ - name: Install Erlang & Elixir
+ uses: erlef/setup-beam@v1
+ with:
+ otp-version: ${{ env.otp }}
+ elixir-version: ${{ env.elixir }}
+ - name: Cache Mix
+ uses: actions/cache@v3
+ with:
+ path: |
+ deps
+ _build
+ key: ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-
+ # Note: we need to get Phoenix and LV because package.json points to them directly
+ - name: Install mix dependencies
+ run: mix deps.get
+ - name: Install Node
+ uses: actions/setup-node@v3
+ with:
+ node-version: "18.x"
+ - name: Cache npm dependencies
+ uses: actions/cache@v3
+ with:
+ path: ~/.npm
+ key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-node-
+ - name: Clean generated assets
+ run: rm -rf static/{js,css}
+ - name: Install npm dependencies
+ run: npm ci --prefix assets
+ - name: Build assets
+ run: npm run deploy --prefix assets
+ # --- END build assets
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Generate UUID image name
diff --git a/assets/js/hooks/image_input.js b/assets/js/hooks/image_input.js
index 70b3f0074..43e79e5ef 100644
--- a/assets/js/hooks/image_input.js
+++ b/assets/js/hooks/image_input.js
@@ -3,6 +3,7 @@ import {
getAttributeOrThrow,
parseInteger,
} from "../lib/attribute";
+import { base64ToBuffer, bufferToBase64 } from "../lib/utils";
const dropClasses = ["bg-yellow-100", "border-yellow-300"];
@@ -258,18 +259,6 @@ function imageDataToRGBBuffer(imageData) {
return bytes.buffer;
}
-function bufferToBase64(buffer) {
- let binaryString = "";
- const bytes = new Uint8Array(buffer);
- const length = bytes.byteLength;
-
- for (let i = 0; i < length; i++) {
- binaryString += String.fromCharCode(bytes[i]);
- }
-
- return btoa(binaryString);
-}
-
function imageInfoToElement(imageInfo) {
if (imageInfo.format === "png" || imageInfo.format === "jpeg") {
const src = `data:image/${imageInfo.format};base64,${imageInfo.data}`;
@@ -282,7 +271,7 @@ function imageInfoToElement(imageInfo) {
const canvas = document.createElement("canvas");
canvas.height = imageInfo.height;
canvas.width = imageInfo.width;
- const buffer = bufferFromBase64(imageInfo.data);
+ const buffer = base64ToBuffer(imageInfo.data);
const imageData = imageDataFromRGBBuffer(
buffer,
imageInfo.width,
@@ -310,16 +299,4 @@ function imageDataFromRGBBuffer(buffer, width, height) {
return new ImageData(data, width, height);
}
-function bufferFromBase64(base64) {
- const binaryString = atob(base64);
- const bytes = new Uint8Array(binaryString.length);
- const length = bytes.byteLength;
-
- for (let i = 0; i < length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
-
- return bytes.buffer;
-}
-
export default ImageInput;
diff --git a/assets/js/hooks/image_output.js b/assets/js/hooks/image_output.js
new file mode 100644
index 000000000..c2ae5fb5d
--- /dev/null
+++ b/assets/js/hooks/image_output.js
@@ -0,0 +1,82 @@
+import { base64ToBuffer } from "../lib/utils";
+
+/**
+ * A hook for the image output.
+ *
+ * Automatically converts image URLs from image/x-pixel to image/png.
+ */
+const ImageOutput = {
+ mounted() {
+ this.updateSrc();
+ },
+
+ updated() {
+ this.updateSrc();
+ },
+
+ updateSrc() {
+ const dataUrl = this.el.src;
+ const prefix = "data:image/x-pixel;base64,";
+
+ const base64Data = dataUrl.slice(prefix.length);
+
+ if (dataUrl.startsWith(prefix)) {
+ const buffer = base64ToBuffer(base64Data);
+
+ const view = new DataView(buffer);
+ const height = view.getUint32(0, false);
+ const width = view.getUint32(4, false);
+ const channels = view.getUint8(8);
+ const pixelBuffer = buffer.slice(9);
+
+ const imageData = imageDataFromPixelBuffer(
+ pixelBuffer,
+ width,
+ height,
+ channels
+ );
+
+ const canvas = document.createElement("canvas");
+ canvas.height = height;
+ canvas.width = width;
+ canvas.getContext("2d").putImageData(imageData, 0, 0);
+ const pngDataUrl = canvas.toDataURL("image/png");
+
+ this.el.src = pngDataUrl;
+ }
+ },
+};
+
+function imageDataFromPixelBuffer(buffer, width, height, channels) {
+ const pixelCount = width * height;
+ const bytes = new Uint8Array(buffer);
+ const data = new Uint8ClampedArray(pixelCount * 4);
+
+ for (let i = 0; i < pixelCount; i++) {
+ if (channels === 1) {
+ data[i * 4] = bytes[i];
+ data[i * 4 + 1] = bytes[i];
+ data[i * 4 + 2] = bytes[i];
+ data[i * 4 + 3] = 255;
+ } else if (channels === 2) {
+ data[i * 4] = bytes[i * 2];
+ data[i * 4 + 1] = bytes[i * 2];
+ data[i * 4 + 2] = bytes[i * 2];
+ data[i * 4 + 3] = bytes[i * 2 + 1];
+ } else if (channels === 3) {
+ data[i * 4] = bytes[i * 3];
+ data[i * 4 + 1] = bytes[i * 3 + 1];
+ data[i * 4 + 2] = bytes[i * 3 + 2];
+ data[i * 4 + 3] = 255;
+ } else if (channels === 4) {
+ data[i * 4] = bytes[i * 4];
+ data[i * 4 + 1] = bytes[i * 4 + 1];
+ data[i * 4 + 2] = bytes[i * 4 + 2];
+ data[i * 4 + 3] = bytes[i * 4 + 3];
+ }
+ }
+
+ return new ImageData(data, width, height);
+}
+
+export default ImageOutput;
diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js
index 01ea9f6df..e25e999e7 100644
--- a/assets/js/hooks/index.js
+++ b/assets/js/hooks/index.js
@@ -7,6 +7,7 @@ import FocusOnUpdate from "./focus_on_update";
import Headline from "./headline";
import Highlight from "./highlight";
import ImageInput from "./image_input";
+import ImageOutput from "./image_output";
import JSView from "./js_view";
import KeyboardControl from "./keyboard_control";
import MarkdownRenderer from "./markdown_renderer";
@@ -27,6 +28,7 @@ export default {
Headline,
Highlight,
ImageInput,
+ ImageOutput,
JSView,
KeyboardControl,
MarkdownRenderer,
diff --git a/assets/js/lib/utils.js b/assets/js/lib/utils.js
index 2e54fb7e3..90a429623 100644
--- a/assets/js/lib/utils.js
+++ b/assets/js/lib/utils.js
@@ -189,3 +189,33 @@ const htmlEscapes = {
export function escapeHtml(string) {
return (string || "").replace(/[&<>"']/g, (char) => htmlEscapes[char]);
}
+
+/**
+ * Encodes the given binary buffer into base64 string.
+ */
+export function bufferToBase64(buffer) {
+ let binaryString = "";
+ const bytes = new Uint8Array(buffer);
+ const length = bytes.byteLength;
+
+ for (let i = 0; i < length; i++) {
+ binaryString += String.fromCharCode(bytes[i]);
+ }
+
+ return btoa(binaryString);
+}
+
+/**
+ * Decodes a base64 string into a binary buffer.
+ */
+export function base64ToBuffer(base64) {
+ const binaryString = atob(base64);
+ const bytes = new Uint8Array(binaryString.length);
+ const length = bytes.byteLength;
+
+ for (let i = 0; i < length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+
+ return bytes.buffer;
+}
diff --git a/lib/livebook_web/live/output.ex b/lib/livebook_web/live/output.ex
index 07947e67a..993b39ff4 100644
--- a/lib/livebook_web/live/output.ex
+++ b/lib/livebook_web/live/output.ex
@@ -63,7 +63,7 @@ defmodule LivebookWeb.Output do
assigns = %{id: id, content: content, mime_type: mime_type}
~H"""
-
+
"""
end
diff --git a/lib/livebook_web/live/output/image_component.ex b/lib/livebook_web/live/output/image_component.ex
index 2760df34f..20626112e 100644
--- a/lib/livebook_web/live/output/image_component.ex
+++ b/lib/livebook_web/live/output/image_component.ex
@@ -4,7 +4,13 @@ defmodule LivebookWeb.Output.ImageComponent do
@impl true
def render(assigns) do
~H"""
-
+
"""
end