Saving the output image as a GIF
Browsers can load GIF images to the canvas but cannot output GIF images.
To work around this browser limiation we can use the postprocessImageBlob
property on the createDefaultImageWriter
function to convert images to different formats.
The third-party library gif.js can convert JPEGs and PNGs to GIFs, we'll use it in the postprocessImageBlob
hook.
In the example below gif.js needs to be added using a script tag, see the gif.js documentation for more information.
<!DOCTYPE html>
<head>
<link rel="stylesheet" href="./pintura.css" />
</head>
<img src="" alt="" />
<style>
.pintura-editor {
height: 600px;
}
</style>
<div id="editor"></div>
<script type="module">
import { appendDefaultEditor, processImage } from './pintura.js';
const editor = appendDefaultEditor('#editor', {
src: 'image.jpeg',
imageWriter: {
postprocessImageBlob: ({ blob }) =>
new Promise((resolve) => {
// Load the output blob as an image so we can load it with gif.js
const image = new Image();
image.onload = () => {
// The image has loaded, let's create a GIF
const gif = new GIF();
gif.addFrame(image);
gif.on('finished', (gifBlob) => {
// clean up URL to our output image
URL.revokeObjectURL(blob);
// return the GIF to the editor
resolve(gifBlob);
});
gif.render();
};
// Convert the blob to a URL so we can load it as an image
image.src = URL.createObjectURL(blob);
}),
},
});
editor.on('process', (imageState) => {
document.querySelector('img').src = URL.createObjectURL(
imageState.dest
);
});
</script>
import '@pqina/pintura/pintura.css';
import './App.css';
import { useState } from 'react';
import { PinturaEditor } from '@pqina/react-pintura';
import { processImage, getEditorDefaults } from '@pqina/pintura';
const editorDefaults = getEditorDefaults({
imageWriter: {
postprocessImageBlob: ({ blob }) =>
new Promise((resolve) => {
// Load the output blob as an image so we can load it with gif.js
const image = new Image();
image.onload = () => {
// The image has loaded, let's create a GIF
const gif = new GIF();
gif.addFrame(image);
gif.on('finished', (gifBlob) => {
// clean up URL to our output image
URL.revokeObjectURL(blob);
// return the GIF to the editor
resolve(gifBlob);
});
gif.render();
};
// Convert the blob to a URL so we can load it as an image
image.src = URL.createObjectURL(blob);
}),
},
});
function App() {
const [editorResult, setEditorResult] = useState(undefined);
const handleEditorProcess = (imageState) => {
setEditorResult(URL.createObjectURL(imageState.dest));
};
return (
<div className="App">
{editorResult && <img alt="" src={editorResult} />}
<PinturaEditor
{...editorDefaults}
src={'image.jpeg'}
onProcess={handleEditorProcess}
/>
</div>
);
}
export default App;
.pintura-editor {
height: 600px;
}
<template>
<div>
<img v-if="editorResult" alt="" :src="editorResult" />
<PinturaEditor
v-bind="editorDefaults"
src="image.jpeg"
v-on:pintura:process="handleEditorProcess($event)"
/>
</div>
</template>
<script>
import { PinturaEditor } from '@pqina/vue-pintura';
import { processImage, getEditorDefaults } from '@pqina/pintura';
export default {
name: 'App',
components: {
PinturaEditor,
},
data() {
return {
editorResult: undefined,
editorDefaults: getEditorDefaults({
imageWriter: {
postprocessImageBlob: ({ blob }) =>
new Promise((resolve) => {
// Load the output blob as an image so we can load it with gif.js
const image = new Image();
image.onload = () => {
// The image has loaded, let's create a GIF
const gif = new GIF();
gif.addFrame(image);
gif.on('finished', (gifBlob) => {
// clean up URL to our output image
URL.revokeObjectURL(blob);
// return the GIF to the editor
resolve(gifBlob);
});
gif.render();
};
// Convert the blob to a URL so we can load it as an image
image.src = URL.createObjectURL(blob);
}),
},
}),
};
},
methods: {
handleEditorProcess: function (imageState) {
this.editorResult = URL.createObjectURL(imageState.dest);
},
},
};
</script>
<style>
@import '@pqina/pintura/pintura.css';
.pintura-editor {
height: 600px;
}
</style>
<script>
import { PinturaEditor } from '@pqina/svelte-pintura';
import { processImage, getEditorDefaults } from '@pqina/pintura';
import '@pqina/pintura/pintura.css';
let editorResult = undefined;
let editorDefaults = getEditorDefaults({
imageWriter: {
postprocessImageBlob: ({ blob }) =>
new Promise((resolve) => {
// Load the output blob as an image so we can load it with gif.js
const image = new Image();
image.onload = () => {
// The image has loaded, let's create a GIF
const gif = new GIF();
gif.addFrame(image);
gif.on('finished', (gifBlob) => {
// clean up URL to our output image
URL.revokeObjectURL(blob);
// return the GIF to the editor
resolve(gifBlob);
});
gif.render();
};
// Convert the blob to a URL so we can load it as an image
image.src = URL.createObjectURL(blob);
}),
},
});
const handleEditorProcess = (event) => {
const imageState = event.detail;
editorResult = URL.createObjectURL(imageState.dest);
};
</script>
<div>
{#if editorResult} <img alt="" src={editorResult} /> {/if}
<PinturaEditor
{...editorDefaults}
src={'image.jpeg'}
on:process={handleEditorProcess}
/>
</div>
<style>
div :global(.pintura-editor) {
height: 600px;
}
</style>
import { Component } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { processImage, getEditorDefaults } from '@pqina/pintura';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
constructor(private domSanitizer: DomSanitizer) {}
editorResult?: string = undefined;
editorDefaults: any = getEditorDefaults({
imageWriter: {
postprocessImageBlob: ({ blob }) =>
new Promise((resolve) => {
// Load the output blob as an image so we can load it with gif.js
const image = new Image();
image.onload = () => {
// The image has loaded, let's create a GIF
const gif = new GIF();
gif.addFrame(image);
gif.on('finished', (gifBlob) => {
// clean up URL to our output image
URL.revokeObjectURL(blob);
// return the GIF to the editor
resolve(gifBlob);
});
gif.render();
};
// Convert the blob to a URL so we can load it as an image
image.src = <string>(
this.domSanitizer.bypassSecurityTrustResourceUrl(
URL.createObjectURL(blob)
)
);
}),
},
});
handleEditorProcess(imageState: any): void {
this.editorResult = <string>(
this.domSanitizer.bypassSecurityTrustResourceUrl(
URL.createObjectURL(imageState.dest)
)
);
}
}
<img *ngIf="editorResult" [src]="editorResult" alt="" />
<pintura-editor
[options]="editorDefaults"
src="image.jpeg"
(process)="handleEditorProcess($event)"
></pintura-editor>
::ng-deep .pintura-editor {
height: 600px;
}
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { AngularPinturaModule } from '@pqina/angular-pintura';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, AngularPinturaModule],
exports: [AppComponent],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
<!DOCTYPE html>
<head>
<link rel="stylesheet" href="./pintura/pintura.css" />
</head>
<script src="./jquery.js"></script>
<script src="./jquery-pintura/useEditorWithJQuery-iife.js"></script>
<script src="./pintura/pintura-iife.js"></script>
<img src="" alt="" />
<style>
.pintura-editor {
height: 600px;
}
</style>
<div id="editor"></div>
<script>
useEditorWithJQuery(jQuery, pintura);
$(function () {
var { processImage } = $.fn.pintura;
var editor = $('#editor').pinturaDefault({
src: 'image.jpeg',
imageWriter: {
postprocessImageBlob: ({ blob }) =>
new Promise((resolve) => {
// Load the output blob as an image so we can load it with gif.js
const image = new Image();
image.onload = () => {
// The image has loaded, let's create a GIF
const gif = new GIF();
gif.addFrame(image);
gif.on('finished', (gifBlob) => {
// clean up URL to our output image
URL.revokeObjectURL(blob);
// return the GIF to the editor
resolve(gifBlob);
});
gif.render();
};
// Convert the blob to a URL so we can load it as an image
image.src = URL.createObjectURL(blob);
}),
},
});
editor.on('pintura:process', function (event) {
const imageState = event.detail;
$('img').attr('src', URL.createObjectURL(imageState.dest));
});
});
</script>
Editing Animated GIFs
We can take this a step further to enable editing animated GIFs. When editing animated GIFs we'll have to access each frame of the GIF, apply changes, and then generate a new GIF.
We'll also have to create a custom image processor.
To get the individual frames of the GIF we'll add omggif.js to our project.
<!DOCTYPE html>
<head>
<link rel="stylesheet" href="./pintura.css" />
</head>
<img src="" alt="" />
<style>
.pintura-editor {
height: 600px;
}
</style>
<div id="editor"></div>
<script type="module">
import {
appendDefaultEditor,
processDefaultImage,
createDefaultImageWriter,
} from './pintura.js';
const framesFromGIF = (blob) => {
return new Promise(async (resolve) => {
const ab = await blob.arrayBuffer();
// use the GifReader supplied by omggif.js to get the frames
const gifReader = new GifReader(new Uint8Array(ab));
const frameCount = gifReader.numFrames();
const frames = [];
for (let i = 0; i < frameCount; i++) {
const currentFrame = gifReader.frameInfo(i);
const image = new ImageData(
currentFrame.width,
currentFrame.height
);
gifReader.decodeAndBlitFrameRGBA(i, image.data);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = image.width;
canvas.height = image.height;
ctx.putImageData(image, 0, 0);
frames[i] = {
...currentFrame,
canvas,
};
}
resolve(frames);
});
};
const gifFromFrames = (frames) => {
return new Promise(async (resolve) => {
// use GIF supplied by gif.js to create animated gif
const gif = new GIF();
// done
gif.on('finished', resolve);
// add frames
frames.forEach((frame) => {
gif.addFrame(frame.imageData, { delay: frame.delay * 10 });
});
gif.render();
});
};
const editor = appendDefaultEditor('#editor', {
src: 'image.jpeg',
imageWriter: [
// our custom imageWriter has only one step,
// read the gif and pass it to the custom processor
[
async (state, options, onprogress) => {
const { src, imageState } = state;
// get all the frame data and info from the animated GIF
const frames = await framesFromGIF(src);
// apply our image transforms to each frame
for (const [index, frame] of frames.entries()) {
const { dest } = await processDefaultImage(
frame.canvas,
{
imageWriter: createDefaultImageWriter({
format: 'imageData',
}),
imageState,
}
);
// set frame output image data
frame.imageData = dest;
}
// generate a new animated GIF and set it as output image
state.dest = await gifFromFrames(frames);
return state;
},
'generate-animated-gif',
],
],
});
editor.on('process', (imageState) => {
document.querySelector('img').src = URL.createObjectURL(
imageState.dest
);
});
</script>
import '@pqina/pintura/pintura.css';
import './App.css';
import { useState } from 'react';
import { PinturaEditor } from '@pqina/react-pintura';
import {
processDefaultImage,
getEditorDefaults,
createDefaultImageWriter,
} from '@pqina/pintura';
const editorDefaults = getEditorDefaults({
imageWriter: [
// our custom imageWriter has only one step,
// read the gif and pass it to the custom processor
[
async (state, options, onprogress) => {
const { src, imageState } = state;
// get all the frame data and info from the animated GIF
const frames = await framesFromGIF(src);
// apply our image transforms to each frame
for (const [index, frame] of frames.entries()) {
const { dest } = await processDefaultImage(frame.canvas, {
imageWriter: createDefaultImageWriter({
format: 'imageData',
}),
imageState,
});
// set frame output image data
frame.imageData = dest;
}
// generate a new animated GIF and set it as output image
state.dest = await gifFromFrames(frames);
return state;
},
'generate-animated-gif',
],
],
});
const framesFromGIF = (blob) => {
return new Promise(async (resolve) => {
const ab = await blob.arrayBuffer();
// use the GifReader supplied by omggif.js to get the frames
const gifReader = new GifReader(new Uint8Array(ab));
const frameCount = gifReader.numFrames();
const frames = [];
for (let i = 0; i < frameCount; i++) {
const currentFrame = gifReader.frameInfo(i);
const image = new ImageData(
currentFrame.width,
currentFrame.height
);
gifReader.decodeAndBlitFrameRGBA(i, image.data);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = image.width;
canvas.height = image.height;
ctx.putImageData(image, 0, 0);
frames[i] = {
...currentFrame,
canvas,
};
}
resolve(frames);
});
};
const gifFromFrames = (frames) => {
return new Promise(async (resolve) => {
// use GIF supplied by gif.js to create animated gif
const gif = new GIF();
// done
gif.on('finished', resolve);
// add frames
frames.forEach((frame) => {
gif.addFrame(frame.imageData, { delay: frame.delay * 10 });
});
gif.render();
});
};
function App() {
const [editorResult, setEditorResult] = useState(undefined);
const handleEditorProcess = (imageState) => {
setEditorResult(URL.createObjectURL(imageState.dest));
};
return (
<div className="App">
{editorResult && <img alt="" src={editorResult} />}
<PinturaEditor
{...editorDefaults}
src={'image.jpeg'}
onProcess={handleEditorProcess}
/>
</div>
);
}
export default App;
.pintura-editor {
height: 600px;
}
<template>
<div>
<img v-if="editorResult" alt="" :src="editorResult" />
<PinturaEditor
v-bind="editorDefaults"
src="image.jpeg"
v-on:pintura:process="handleEditorProcess($event)"
/>
</div>
</template>
<script>
import { PinturaEditor } from '@pqina/vue-pintura';
import {
processDefaultImage,
getEditorDefaults,
createDefaultImageWriter,
} from '@pqina/pintura';
const framesFromGIF = function (blob) {
return new Promise(async (resolve) => {
const ab = await blob.arrayBuffer();
// use the GifReader supplied by omggif.js to get the frames
const gifReader = new GifReader(new Uint8Array(ab));
const frameCount = gifReader.numFrames();
const frames = [];
for (let i = 0; i < frameCount; i++) {
const currentFrame = gifReader.frameInfo(i);
const image = new ImageData(
currentFrame.width,
currentFrame.height
);
gifReader.decodeAndBlitFrameRGBA(i, image.data);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = image.width;
canvas.height = image.height;
ctx.putImageData(image, 0, 0);
frames[i] = {
...currentFrame,
canvas,
};
}
resolve(frames);
});
};
const gifFromFrames = function (frames) {
return new Promise(async (resolve) => {
// use GIF supplied by gif.js to create animated gif
const gif = new GIF();
// done
gif.on('finished', resolve);
// add frames
frames.forEach((frame) => {
gif.addFrame(frame.imageData, { delay: frame.delay * 10 });
});
gif.render();
});
};
export default {
name: 'App',
components: {
PinturaEditor,
},
data() {
return {
editorResult: undefined,
editorDefaults: getEditorDefaults({
imageWriter: [
// our custom imageWriter has only one step,
// read the gif and pass it to the custom processor
[
async (state, options, onprogress) => {
const { src, imageState } = state;
// get all the frame data and info from the animated GIF
const frames = await framesFromGIF(src);
// apply our image transforms to each frame
for (const [index, frame] of frames.entries()) {
const { dest } = await processDefaultImage(
frame.canvas,
{
imageWriter: createDefaultImageWriter({
format: 'imageData',
}),
imageState,
}
);
// set frame output image data
frame.imageData = dest;
}
// generate a new animated GIF and set it as output image
state.dest = await gifFromFrames(frames);
return state;
},
'generate-animated-gif',
],
],
}),
};
},
methods: {
handleEditorProcess: function (imageState) {
this.editorResult = URL.createObjectURL(imageState.dest);
},
},
};
</script>
<style>
@import '@pqina/pintura/pintura.css';
.pintura-editor {
height: 600px;
}
</style>
<script>
import { PinturaEditor } from '@pqina/svelte-pintura';
import {
processDefaultImage,
getEditorDefaults,
createDefaultImageWriter,
} from '@pqina/pintura';
import '@pqina/pintura/pintura.css';
const framesFromGIF = (blob) => {
return new Promise(async (resolve) => {
const ab = await blob.arrayBuffer();
// use the GifReader supplied by omggif.js to get the frames
const gifReader = new GifReader(new Uint8Array(ab));
const frameCount = gifReader.numFrames();
const frames = [];
for (let i = 0; i < frameCount; i++) {
const currentFrame = gifReader.frameInfo(i);
const image = new ImageData(
currentFrame.width,
currentFrame.height
);
gifReader.decodeAndBlitFrameRGBA(i, image.data);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = image.width;
canvas.height = image.height;
ctx.putImageData(image, 0, 0);
frames[i] = {
...currentFrame,
canvas,
};
}
resolve(frames);
});
};
const gifFromFrames = (frames) => {
return new Promise(async (resolve) => {
// use GIF supplied by gif.js to create animated gif
const gif = new GIF();
// done
gif.on('finished', resolve);
// add frames
frames.forEach((frame) => {
gif.addFrame(frame.imageData, { delay: frame.delay * 10 });
});
gif.render();
});
};
let editorResult = undefined;
let editorDefaults = getEditorDefaults({
imageWriter: [
// our custom imageWriter has only one step,
// read the gif and pass it to the custom processor
[
async (state, options, onprogress) => {
const { src, imageState } = state;
// get all the frame data and info from the animated GIF
const frames = await framesFromGIF(src);
// apply our image transforms to each frame
for (const [index, frame] of frames.entries()) {
const { dest } = await processDefaultImage(
frame.canvas,
{
imageWriter: createDefaultImageWriter({
format: 'imageData',
}),
imageState,
}
);
// set frame output image data
frame.imageData = dest;
}
// generate a new animated GIF and set it as output image
state.dest = await gifFromFrames(frames);
return state;
},
'generate-animated-gif',
],
],
});
const handleEditorProcess = (event) => {
const imageState = event.detail;
editorResult = URL.createObjectURL(imageState.dest);
};
</script>
<div>
{#if editorResult} <img alt="" src={editorResult} /> {/if}
<PinturaEditor
{...editorDefaults}
src={'image.jpeg'}
on:process={handleEditorProcess}
/>
</div>
<style>
div :global(.pintura-editor) {
height: 600px;
}
</style>
import { Component } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import {
processDefaultImage,
getEditorDefaults,
createDefaultImageWriter,
} from '@pqina/pintura';
const framesFromGIF = (blob: Blob): Promise<any[]> => {
return new Promise(async (resolve) => {
const ab = await blob.arrayBuffer();
// use the GifReader supplied by omggif.js to get the frames
const gifReader = new GifReader(new Uint8Array(ab));
const frameCount = gifReader.numFrames();
const frames = [];
for (let i = 0; i < frameCount; i++) {
const currentFrame = gifReader.frameInfo(i);
const image = new ImageData(
currentFrame.width,
currentFrame.height
);
gifReader.decodeAndBlitFrameRGBA(i, image.data);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = image.width;
canvas.height = image.height;
ctx.putImageData(image, 0, 0);
frames[i] = {
...currentFrame,
canvas,
};
}
resolve(frames);
});
};
const gifFromFrames = (frames: any[]): Promise<Blob> => {
return new Promise(async (resolve) => {
// use GIF supplied by gif.js to create animated gif
const gif = new GIF();
// done
gif.on('finished', resolve);
// add frames
frames.forEach((frame) => {
gif.addFrame(frame.imageData, { delay: frame.delay * 10 });
});
gif.render();
});
};
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
constructor(private domSanitizer: DomSanitizer) {}
editorResult?: string = undefined;
editorDefaults: any = getEditorDefaults({
imageWriter: [
// our custom imageWriter has only one step,
// read the gif and pass it to the custom processor
[
async (state, options, onprogress) => {
const { src, imageState } = state;
// get all the frame data and info from the animated GIF
const frames = await framesFromGIF(src);
// apply our image transforms to each frame
for (const [index, frame] of frames.entries()) {
const { dest } = await processDefaultImage(
frame.canvas,
{
imageWriter: createDefaultImageWriter({
format: 'imageData',
}),
imageState,
}
);
// set frame output image data
frame.imageData = dest;
}
// generate a new animated GIF and set it as output image
state.dest = await gifFromFrames(frames);
return state;
},
'generate-animated-gif',
],
],
});
handleEditorProcess(imageState: any): void {
this.editorResult = <string>(
this.domSanitizer.bypassSecurityTrustResourceUrl(
URL.createObjectURL(imageState.dest)
)
);
}
}
<img *ngIf="editorResult" [src]="editorResult" alt="" />
<pintura-editor
[options]="editorDefaults"
src="image.jpeg"
(process)="handleEditorProcess($event)"
></pintura-editor>
::ng-deep .pintura-editor {
height: 600px;
}
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { AngularPinturaModule } from '@pqina/angular-pintura';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, AngularPinturaModule],
exports: [AppComponent],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
<!DOCTYPE html>
<head>
<link rel="stylesheet" href="./pintura/pintura.css" />
</head>
<script src="./jquery.js"></script>
<script src="./jquery-pintura/useEditorWithJQuery-iife.js"></script>
<script src="./pintura/pintura-iife.js"></script>
<img src="" alt="" />
<style>
.pintura-editor {
height: 600px;
}
</style>
<div id="editor"></div>
<script>
useEditorWithJQuery(jQuery, pintura);
$(function () {
var { processDefaultImage, createDefaultImageWriter } = $.fn.pintura;
const framesFromGIF = (blob) => {
return new Promise(async (resolve) => {
const ab = await blob.arrayBuffer();
// use the GifReader supplied by omggif.js to get the frames
const gifReader = new GifReader(new Uint8Array(ab));
const frameCount = gifReader.numFrames();
const frames = [];
for (let i = 0; i < frameCount; i++) {
const currentFrame = gifReader.frameInfo(i);
const image = new ImageData(
currentFrame.width,
currentFrame.height
);
gifReader.decodeAndBlitFrameRGBA(i, image.data);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = image.width;
canvas.height = image.height;
ctx.putImageData(image, 0, 0);
frames[i] = {
...currentFrame,
canvas,
};
}
resolve(frames);
});
};
const gifFromFrames = (frames) => {
return new Promise(async (resolve) => {
// use GIF supplied by gif.js to create animated gif
const gif = new GIF();
// done
gif.on('finished', resolve);
// add frames
frames.forEach((frame) => {
gif.addFrame(frame.imageData, { delay: frame.delay * 10 });
});
gif.render();
});
};
var editor = $('#editor').pinturaDefault({
src: 'image.jpeg',
imageWriter: [
// our custom imageWriter has only one step,
// read the gif and pass it to the custom processor
[
async (state, options, onprogress) => {
const { src, imageState } = state;
// get all the frame data and info from the animated GIF
const frames = await framesFromGIF(src);
// apply our image transforms to each frame
for (const [index, frame] of frames.entries()) {
const { dest } = await processDefaultImage(
frame.canvas,
{
imageWriter: createDefaultImageWriter({
format: 'imageData',
}),
imageState,
}
);
// set frame output image data
frame.imageData = dest;
}
// generate a new animated GIF and set it as output image
state.dest = await gifFromFrames(frames);
return state;
},
'generate-animated-gif',
],
],
});
editor.on('pintura:process', function (event) {
const imageState = event.detail;
$('img').attr('src', URL.createObjectURL(imageState.dest));
});
});
</script>