build: compile release APK and update dependencies (expo-image-manipulator/loader)
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
||||
import { useReleasingSharedObject } from 'expo-modules-core';
|
||||
import { SharedRef } from 'expo-modules-core/types';
|
||||
|
||||
import { Action, ImageResult, SaveFormat, SaveOptions } from './ImageManipulator.types';
|
||||
import { ImageManipulatorContext } from './ImageManipulatorContext';
|
||||
import ExpoImageManipulator from './NativeImageManipulatorModule';
|
||||
import { validateArguments } from './validators';
|
||||
|
||||
// @needsAudit
|
||||
/**
|
||||
* Manipulate the image provided via `uri`. Available modifications are rotating, flipping (mirroring),
|
||||
* resizing and cropping. Each invocation results in a new file. With one invocation you can provide
|
||||
* a set of actions to perform over the image. Overwriting the source file would not have an effect
|
||||
* in displaying the result as images are cached.
|
||||
* @param uri URI of the file to manipulate. Should be on the local file system or a base64 data URI.
|
||||
* @param actions An array of objects representing manipulation options. Each object should have
|
||||
* __only one__ of the keys that corresponds to specific transformation.
|
||||
* @param saveOptions A map defining how modified image should be saved.
|
||||
* @return Promise which fulfils with [`ImageResult`](#imageresult) object.
|
||||
* @deprecated It has been replaced by the new, contextual and object-oriented API.
|
||||
* Use [`ImageManipulator.manipulate`](#manipulatesource) or [`useImageManipulator`](#useimagemanipulatorsource) instead.
|
||||
*/
|
||||
export async function manipulateAsync(
|
||||
uri: string,
|
||||
actions: Action[] = [],
|
||||
saveOptions: SaveOptions = {}
|
||||
): Promise<ImageResult> {
|
||||
validateArguments(uri, actions, saveOptions);
|
||||
|
||||
const { format = SaveFormat.JPEG, ...rest } = saveOptions;
|
||||
const context = ExpoImageManipulator.manipulate(uri);
|
||||
|
||||
for (const action of actions) {
|
||||
if ('resize' in action) {
|
||||
context.resize(action.resize);
|
||||
} else if ('rotate' in action) {
|
||||
context.rotate(action.rotate);
|
||||
} else if ('flip' in action) {
|
||||
context.flip(action.flip);
|
||||
} else if ('crop' in action) {
|
||||
context.crop(action.crop);
|
||||
} else if ('extent' in action && context.extent) {
|
||||
context.extent(action.extent);
|
||||
}
|
||||
}
|
||||
const image = await context.renderAsync();
|
||||
const result = await image.saveAsync({ format, ...rest });
|
||||
|
||||
// These shared objects will not be used anymore, so free up some memory.
|
||||
context.release();
|
||||
image.release();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useImageManipulator(source: string | SharedRef<'image'>): ImageManipulatorContext {
|
||||
return useReleasingSharedObject(() => ExpoImageManipulator.manipulate(source), [source]);
|
||||
}
|
||||
|
||||
export { ExpoImageManipulator as ImageManipulator };
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import type { NativeModule } from 'expo';
|
||||
import { SharedRef } from 'expo-modules-core/types';
|
||||
|
||||
import type { ImageManipulatorContext } from './ImageManipulatorContext';
|
||||
import ImageRef from './ImageRef';
|
||||
|
||||
// @needsAudit
|
||||
export type ImageResult = {
|
||||
/**
|
||||
* An URI to the modified image (usable as the source for an `Image` or `Video` element).
|
||||
*/
|
||||
uri: string;
|
||||
/**
|
||||
* Width of the image or video.
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Height of the image or video.
|
||||
*/
|
||||
height: number;
|
||||
/**
|
||||
* It is included if the `base64` save option was truthy, and is a string containing the
|
||||
* JPEG/PNG (depending on `format`) data of the image in Base64. Prepend that with `'data:image/xxx;base64,'`
|
||||
* to get a data URI, which you can use as the source for an `Image` element for example
|
||||
* (where `xxx` is `jpeg` or `png`).
|
||||
*/
|
||||
base64?: string;
|
||||
};
|
||||
|
||||
// @needsAudit
|
||||
export type ActionResize = {
|
||||
/**
|
||||
* Values correspond to the result image dimensions. If you specify only one value, the other will
|
||||
* be calculated automatically to preserve image ratio.
|
||||
*/
|
||||
resize: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
};
|
||||
|
||||
// @needsAudit
|
||||
export type ActionRotate = {
|
||||
/**
|
||||
* Degrees to rotate the image. Rotation is clockwise when the value is positive and
|
||||
* counter-clockwise when negative.
|
||||
*/
|
||||
rotate: number;
|
||||
};
|
||||
|
||||
// @docsMissing
|
||||
export enum FlipType {
|
||||
Vertical = 'vertical',
|
||||
Horizontal = 'horizontal',
|
||||
}
|
||||
|
||||
// @needsAudit
|
||||
export type ActionFlip = {
|
||||
/**
|
||||
* An axis on which image will be flipped. Only one flip per transformation is available. If you
|
||||
* want to flip according to both axes then provide two separate transformations.
|
||||
*/
|
||||
flip: FlipType;
|
||||
};
|
||||
|
||||
// @needsAudit
|
||||
export type ActionCrop = {
|
||||
/**
|
||||
* Fields specify top-left corner and dimensions of a crop rectangle.
|
||||
*/
|
||||
crop: {
|
||||
originX: number;
|
||||
originY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
|
||||
// @needsAudit
|
||||
export type ActionExtent = {
|
||||
/**
|
||||
* Set the image size and offset. If the image is enlarged, unfilled areas are set to the `backgroundColor`.
|
||||
* To position the image, use `originX` and `originY`.
|
||||
*
|
||||
* @platform web
|
||||
*/
|
||||
extent: {
|
||||
backgroundColor?: string | null;
|
||||
originX?: number;
|
||||
originY?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
|
||||
// @docsMissing
|
||||
export type Action = ActionResize | ActionRotate | ActionFlip | ActionCrop | ActionExtent;
|
||||
|
||||
// @docsMissing
|
||||
export enum SaveFormat {
|
||||
JPEG = 'jpeg',
|
||||
PNG = 'png',
|
||||
WEBP = 'webp',
|
||||
}
|
||||
|
||||
// @needsAudit
|
||||
/**
|
||||
* A map defining how modified image should be saved.
|
||||
*/
|
||||
export type SaveOptions = {
|
||||
/**
|
||||
* Whether to also include the image data in Base64 format.
|
||||
*/
|
||||
base64?: boolean;
|
||||
/**
|
||||
* A value in range `0.0` - `1.0` specifying compression level of the result image. `1` means
|
||||
* no compression (highest quality) and `0` the highest compression (lowest quality).
|
||||
*/
|
||||
compress?: number;
|
||||
/**
|
||||
* Specifies what type of compression should be used and what is the result file extension.
|
||||
* `SaveFormat.PNG` compression is lossless but slower, `SaveFormat.JPEG` is faster but the image
|
||||
* has visible artifacts. Defaults to `SaveFormat.JPEG`
|
||||
*/
|
||||
format?: SaveFormat;
|
||||
};
|
||||
|
||||
export declare class ImageManipulator extends NativeModule {
|
||||
/**
|
||||
* @hidden
|
||||
*/
|
||||
Context: typeof ImageManipulatorContext;
|
||||
|
||||
/**
|
||||
* @hidden
|
||||
*/
|
||||
Image: typeof ImageRef;
|
||||
|
||||
/**
|
||||
* Loads an image from the given URI and creates a new image manipulation context.
|
||||
*/
|
||||
manipulate(source: string | SharedRef<'image'>): ImageManipulatorContext;
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { SharedObject } from 'expo';
|
||||
|
||||
import type { ActionCrop, ActionExtent } from './ImageManipulator.types';
|
||||
import type { ImageRef } from './ImageRef';
|
||||
import ExpoImageManipulator from './NativeImageManipulatorModule';
|
||||
|
||||
/**
|
||||
* A context for an image manipulation. It provides synchronous, chainable functions that schedule transformations on the original image to the background thread.
|
||||
* Use an asynchronous [`renderAsync`](#renderasync) to await for all transformations to finish and access the final image.
|
||||
*/
|
||||
export declare class ImageManipulatorContext extends SharedObject {
|
||||
/**
|
||||
* Resizes the image to the given size.
|
||||
* @param size Values correspond to the result image dimensions. If you specify only one value, the other will
|
||||
* be calculated automatically to preserve image ratio.
|
||||
*/
|
||||
resize(size: { width?: number | null; height?: number | null }): ImageManipulatorContext;
|
||||
|
||||
/**
|
||||
* Rotates the image by the given number of degrees.
|
||||
* @param degrees Degrees to rotate the image. Rotation is clockwise when the value is positive and
|
||||
* counter-clockwise when negative.
|
||||
*/
|
||||
rotate(degrees: number): ImageManipulatorContext;
|
||||
|
||||
/**
|
||||
* Flips the image vertically or horizontally.
|
||||
* @param flipType An axis on which image will be flipped. Only one flip per transformation is available. If you
|
||||
* want to flip according to both axes then provide two separate transformations.
|
||||
*/
|
||||
flip(flipType: 'vertical' | 'horizontal'): ImageManipulatorContext;
|
||||
|
||||
/**
|
||||
* Crops the image to the given rectangle's origin and size.
|
||||
* @param rect Fields specify top-left corner and dimensions of a crop rectangle.
|
||||
*/
|
||||
crop(rect: ActionCrop['crop']): ImageManipulatorContext;
|
||||
|
||||
/**
|
||||
* Set the image size and offset. If the image is enlarged, unfilled areas are set to the `backgroundColor`.
|
||||
* To position the image, use `originX` and `originY`.
|
||||
*
|
||||
* @platform web
|
||||
*/
|
||||
extent(options: ActionExtent['extent']): ImageManipulatorContext;
|
||||
|
||||
/**
|
||||
* Resets the manipulator context to the originally loaded image.
|
||||
*/
|
||||
reset(): ImageManipulatorContext;
|
||||
|
||||
/**
|
||||
* Awaits for all manipulation tasks to finish and resolves with a reference to the resulted native image.
|
||||
*/
|
||||
renderAsync(): Promise<ImageRef>;
|
||||
}
|
||||
|
||||
export default ExpoImageManipulator.Context as typeof ImageManipulatorContext;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { SharedRef } from 'expo';
|
||||
|
||||
import type { ImageResult, SaveOptions } from './ImageManipulator.types';
|
||||
import ExpoImageManipulator from './NativeImageManipulatorModule';
|
||||
|
||||
/**
|
||||
* A reference to a native instance of the image.
|
||||
*/
|
||||
export declare class ImageRef extends SharedRef<'image'> {
|
||||
/**
|
||||
* Width of the image.
|
||||
*/
|
||||
width: number;
|
||||
|
||||
/**
|
||||
* Height of the image.
|
||||
*/
|
||||
height: number;
|
||||
|
||||
/**
|
||||
* Saves the image to the file system in the cache directory.
|
||||
* @param options A map defining how modified image should be saved.
|
||||
*/
|
||||
saveAsync(options?: SaveOptions): Promise<ImageResult>;
|
||||
}
|
||||
|
||||
export default ExpoImageManipulator.ImageRef as typeof ImageRef;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { requireNativeModule } from 'expo';
|
||||
|
||||
import { ImageManipulator } from './ImageManipulator.types';
|
||||
|
||||
export default requireNativeModule<ImageManipulator>('ExpoImageManipulator');
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { NativeModule } from 'expo';
|
||||
import { registerWebModule } from 'expo-modules-core';
|
||||
import { SharedRef } from 'expo-modules-core/types';
|
||||
|
||||
import ImageManipulatorContext from './web/ImageManipulatorContext.web';
|
||||
import ImageManipulatorImageRef from './web/ImageManipulatorImageRef.web';
|
||||
import { loadImageAsync } from './web/utils.web';
|
||||
|
||||
class ImageManipulator extends NativeModule {
|
||||
Context = ImageManipulatorContext;
|
||||
Image = ImageManipulatorImageRef;
|
||||
|
||||
manipulate(source: string | SharedRef<'image'>): ImageManipulatorContext {
|
||||
return new ImageManipulatorContext(() => {
|
||||
if (typeof source === 'string') {
|
||||
return loadImageAsync(source);
|
||||
}
|
||||
// Image refs should provide the `uri` property on Web. It could be either remote url, blob or data url.
|
||||
if (typeof source === 'object' && 'uri' in source && typeof source.uri === 'string') {
|
||||
return loadImageAsync(source.uri);
|
||||
}
|
||||
throw new Error(`Source not supported: ${source}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default registerWebModule(ImageManipulator, 'ImageManipulator');
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
export { ImageManipulator, manipulateAsync, useImageManipulator } from './ImageManipulator';
|
||||
|
||||
// SaveFormat and FlipType are enums
|
||||
export { SaveFormat, FlipType } from './ImageManipulator.types';
|
||||
|
||||
export type { SaveOptions, ImageResult } from './ImageManipulator.types';
|
||||
|
||||
// Export types that are deprecated as of SDK 52
|
||||
export type {
|
||||
ActionResize,
|
||||
ActionRotate,
|
||||
ActionFlip,
|
||||
ActionCrop,
|
||||
ActionExtent,
|
||||
Action,
|
||||
} from './ImageManipulator.types';
|
||||
|
||||
export type { ImageRef } from './ImageRef';
|
||||
export type { ImageManipulatorContext } from './ImageManipulatorContext';
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Action,
|
||||
ActionCrop,
|
||||
ActionExtent,
|
||||
ActionFlip,
|
||||
ActionResize,
|
||||
ActionRotate,
|
||||
FlipType,
|
||||
SaveFormat,
|
||||
SaveOptions,
|
||||
} from './ImageManipulator.types';
|
||||
|
||||
export function validateArguments(uri: string, actions: Action[], saveOptions: SaveOptions) {
|
||||
validateUri(uri);
|
||||
validateActions(actions);
|
||||
validateSaveOptions(saveOptions);
|
||||
}
|
||||
|
||||
export function validateUri(uri: string): void {
|
||||
if (!(typeof uri === 'string')) {
|
||||
throw new TypeError('The "uri" argument must be a string');
|
||||
}
|
||||
}
|
||||
|
||||
export function validateActions(actions: Action[]): void {
|
||||
if (!Array.isArray(actions)) {
|
||||
throw new TypeError('The "actions" argument must be an array');
|
||||
}
|
||||
for (const action of actions) {
|
||||
if (typeof action !== 'object' || action === null) {
|
||||
throw new TypeError('Action must be an object');
|
||||
}
|
||||
const supportedActionTypes = ['crop', 'extent', 'flip', 'rotate', 'resize'];
|
||||
const actionKeys = Object.keys(action);
|
||||
if (actionKeys.length !== 1) {
|
||||
throw new TypeError(
|
||||
`Single action must contain exactly one transformation: ${supportedActionTypes.join(', ')}`
|
||||
);
|
||||
}
|
||||
const actionType = actionKeys[0];
|
||||
if (!supportedActionTypes.includes(actionType)) {
|
||||
throw new TypeError(`Unsupported action type: ${actionType}`);
|
||||
}
|
||||
|
||||
if (actionType === 'crop') {
|
||||
validateCropAction(action as ActionCrop);
|
||||
} else if (actionType === 'extent') {
|
||||
validateExtentAction(action as ActionExtent);
|
||||
} else if (actionType === 'flip') {
|
||||
validateFlipAction(action as ActionFlip);
|
||||
} else if (actionType === 'rotate') {
|
||||
validateRotateAction(action as ActionRotate);
|
||||
} else if (actionType === 'resize') {
|
||||
validateResizeAction(action as ActionResize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateCropAction(action: ActionCrop): void {
|
||||
const isValid =
|
||||
typeof action.crop === 'object' &&
|
||||
action.crop !== null &&
|
||||
typeof action.crop.originX === 'number' &&
|
||||
typeof action.crop.originY === 'number' &&
|
||||
typeof action.crop.width === 'number' &&
|
||||
typeof action.crop.height === 'number';
|
||||
if (!isValid) {
|
||||
throw new TypeError(
|
||||
'Crop action must be an object of shape { originX: number; originY: number; width: number; height: number }'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateExtentAction(action: ActionExtent): void {
|
||||
const isValid =
|
||||
typeof action.extent === 'object' &&
|
||||
action.extent !== null &&
|
||||
(action.extent.backgroundColor == null || typeof action.extent.backgroundColor === 'string') &&
|
||||
(action.extent.originX == null || typeof action.extent.originX === 'number') &&
|
||||
(action.extent.originY == null || typeof action.extent.originY === 'number') &&
|
||||
typeof action.extent.width === 'number' &&
|
||||
typeof action.extent.height === 'number';
|
||||
if (!isValid) {
|
||||
throw new TypeError(
|
||||
'Extent action must be an object of shape { backgroundColor?: string; originX?: number; originY?: number; width: number; height: number }'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFlipAction(action: ActionFlip): void {
|
||||
if (
|
||||
typeof action.flip !== 'string' ||
|
||||
![FlipType.Horizontal, FlipType.Vertical].includes(action.flip)
|
||||
) {
|
||||
throw new TypeError(`Unsupported flip type: ${action.flip}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRotateAction(action: ActionRotate): void {
|
||||
if (typeof action.rotate !== 'number') {
|
||||
throw new TypeError('Rotation must be a number');
|
||||
}
|
||||
}
|
||||
|
||||
function validateResizeAction(action: ActionResize): void {
|
||||
const isValid =
|
||||
typeof action.resize === 'object' &&
|
||||
action.resize !== null &&
|
||||
(typeof action.resize.width === 'number' || typeof action.resize.width === 'undefined') &&
|
||||
(typeof action.resize.height === 'number' || typeof action.resize.height === 'undefined');
|
||||
if (!isValid) {
|
||||
throw new TypeError(
|
||||
'Resize action must be an object of shape { width?: number; height?: number }'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSaveOptions({ base64, compress, format }: SaveOptions): void {
|
||||
if (base64 !== undefined && typeof base64 !== 'boolean') {
|
||||
throw new TypeError('The "base64" argument must be a boolean');
|
||||
}
|
||||
if (compress !== undefined) {
|
||||
if (typeof compress !== 'number') {
|
||||
throw new TypeError('The "compress" argument must be a number');
|
||||
}
|
||||
if (compress < 0 || compress > 1) {
|
||||
throw new TypeError('The "compress" argument must be a number between 0 and 1');
|
||||
}
|
||||
}
|
||||
const allowedFormats = [SaveFormat.JPEG, SaveFormat.PNG, SaveFormat.WEBP];
|
||||
if (format !== undefined && !allowedFormats.includes(format)) {
|
||||
throw new TypeError(`The "format" argument must be one of: ${allowedFormats.join(', ')}`);
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { SharedObject } from 'expo';
|
||||
|
||||
import { ActionCrop, ActionExtent, FlipType } from '../ImageManipulator.types';
|
||||
import ImageManipulatorImageRef from './ImageManipulatorImageRef.web';
|
||||
import { crop, extent, flip, resize, rotate } from './actions/index.web';
|
||||
|
||||
type ContextLoader = () => HTMLCanvasElement | Promise<HTMLCanvasElement>;
|
||||
|
||||
export default class ImageManipulatorContext extends SharedObject {
|
||||
private loader: ContextLoader;
|
||||
|
||||
private _currentTask: Promise<HTMLCanvasElement> | undefined;
|
||||
get currentTask() {
|
||||
if (this._currentTask) {
|
||||
return this._currentTask;
|
||||
}
|
||||
this._currentTask = new Promise((resolve) => resolve(this.loader()));
|
||||
return this._currentTask;
|
||||
}
|
||||
set currentTask(task) {
|
||||
this._currentTask = task;
|
||||
}
|
||||
|
||||
constructor(loader?: ContextLoader) {
|
||||
super();
|
||||
this.loader = loader ?? (() => document.createElement('canvas'));
|
||||
}
|
||||
|
||||
resize(size: { width: number; height: number }): ImageManipulatorContext {
|
||||
return this.addTask((canvas) => resize(canvas, size));
|
||||
}
|
||||
|
||||
rotate(degrees: number): ImageManipulatorContext {
|
||||
return this.addTask((canvas) => rotate(canvas, degrees));
|
||||
}
|
||||
|
||||
flip(flipType: FlipType): ImageManipulatorContext {
|
||||
return this.addTask((canvas) => flip(canvas, flipType));
|
||||
}
|
||||
|
||||
crop(rect: ActionCrop['crop']): ImageManipulatorContext {
|
||||
return this.addTask((canvas) => crop(canvas, rect));
|
||||
}
|
||||
|
||||
extent(options: ActionExtent['extent']): ImageManipulatorContext {
|
||||
return this.addTask((canvas) => extent(canvas, options));
|
||||
}
|
||||
|
||||
reset(): ImageManipulatorContext {
|
||||
this.currentTask = new Promise((resolve) => resolve(this.loader()));
|
||||
return this;
|
||||
}
|
||||
|
||||
async renderAsync(): Promise<ImageManipulatorImageRef> {
|
||||
const canvas = await this.currentTask;
|
||||
|
||||
// We're copying the canvas so ref's `saveAsync` can safely use `toBlob` again with the desired format and quality.
|
||||
// The original canvas cannot be reused as the manipulator context may still draw on it.
|
||||
const clonedCanvas = document.createElement('canvas');
|
||||
const clonedCanvasCtx = clonedCanvas.getContext('2d');
|
||||
|
||||
clonedCanvas.width = canvas.width;
|
||||
clonedCanvas.height = canvas.height;
|
||||
clonedCanvasCtx?.drawImage(canvas, 0, 0);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Create a full-sized, full-quality blob from the original canvas.
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
const url = blob ? URL.createObjectURL(blob) : canvas.toDataURL();
|
||||
const image = new ImageManipulatorImageRef(url, clonedCanvas);
|
||||
|
||||
resolve(image);
|
||||
},
|
||||
// Use PNG format so the result is of the best quality.
|
||||
// If you need another format, see `saveAsync` function on the image ref.
|
||||
'image/png'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private addTask(
|
||||
task: (canvas: HTMLCanvasElement) => HTMLCanvasElement | Promise<HTMLCanvasElement>
|
||||
): ImageManipulatorContext {
|
||||
this.currentTask = this.currentTask.then((canvas) => {
|
||||
return task(canvas);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { SharedRef } from 'expo';
|
||||
|
||||
import { ImageResult, SaveFormat, SaveOptions } from '../ImageManipulator.types';
|
||||
import { blobToBase64String } from './utils.web';
|
||||
|
||||
export default class ImageManipulatorImageRef extends SharedRef<'image'> {
|
||||
readonly nativeRefType: string = 'image';
|
||||
|
||||
readonly uri: string;
|
||||
readonly canvas: HTMLCanvasElement;
|
||||
|
||||
constructor(uri: string, canvas: HTMLCanvasElement) {
|
||||
super();
|
||||
this.uri = uri;
|
||||
this.canvas = canvas;
|
||||
}
|
||||
|
||||
get width() {
|
||||
return this.canvas.width;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.canvas.height;
|
||||
}
|
||||
|
||||
async saveAsync(options: SaveOptions = { base64: false }): Promise<ImageResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.canvas.toBlob(
|
||||
async (blob) => {
|
||||
if (!blob) {
|
||||
return reject(new Error(`Unable to save image: ${this.uri}`));
|
||||
}
|
||||
const base64 = options.base64 ? await blobToBase64String(blob) : undefined;
|
||||
const uri = URL.createObjectURL(blob);
|
||||
|
||||
resolve({
|
||||
uri,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
base64,
|
||||
});
|
||||
},
|
||||
`image/${options.format ?? SaveFormat.JPEG}`,
|
||||
options.compress
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { CodedError } from 'expo-modules-core';
|
||||
|
||||
import { ActionCrop } from '../../ImageManipulator.types';
|
||||
import { getContext } from '../utils.web';
|
||||
|
||||
const clamp = (value: number, max: number): number => Math.max(0, Math.min(max, value));
|
||||
|
||||
export default (canvas: HTMLCanvasElement, options: ActionCrop['crop']) => {
|
||||
// ensure values are defined.
|
||||
let { originX = 0, originY = 0, width = 0, height = 0 } = options;
|
||||
// lock within bounds.
|
||||
width = clamp(width, canvas.width);
|
||||
height = clamp(height, canvas.height);
|
||||
originX = clamp(originX, canvas.width);
|
||||
originY = clamp(originY, canvas.height);
|
||||
|
||||
// lock sum of crop.
|
||||
width = Math.min(originX + width, canvas.width) - originX;
|
||||
height = Math.min(originY + height, canvas.height) - originY;
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
throw new CodedError(
|
||||
'ERR_IMAGE_MANIPULATOR_CROP',
|
||||
'Crop size must be greater than 0: ' + JSON.stringify(options, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
const result = document.createElement('canvas');
|
||||
result.width = width;
|
||||
result.height = height;
|
||||
|
||||
const context = getContext(result);
|
||||
context.drawImage(canvas, originX, originY, width, height, 0, 0, width, height);
|
||||
|
||||
return result;
|
||||
};
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { CodedError } from 'expo-modules-core';
|
||||
|
||||
import { ActionExtent } from '../../ImageManipulator.types';
|
||||
import { getContext } from '../utils.web';
|
||||
|
||||
export default (canvas: HTMLCanvasElement, options: ActionExtent['extent']) => {
|
||||
// ensure values are defined.
|
||||
const { backgroundColor = null, originX = 0, originY = 0, width = 0, height = 0 } = options;
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
throw new CodedError(
|
||||
'ERR_IMAGE_MANIPULATOR_EXTENT',
|
||||
'Extent size must be greater than 0: ' + JSON.stringify(options, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
const result = document.createElement('canvas');
|
||||
result.width = width;
|
||||
result.height = height;
|
||||
|
||||
const sx = originX < 0 ? 0 : originX;
|
||||
const sy = originY < 0 ? 0 : originY;
|
||||
const sw =
|
||||
originX < 0 ? Math.min(canvas.width, width + originX) : Math.min(canvas.width - originX, width);
|
||||
const sh =
|
||||
originY < 0
|
||||
? Math.min(canvas.height, height + originY)
|
||||
: Math.min(canvas.height - originY, height);
|
||||
|
||||
const dx = originX < 0 ? -originX : 0;
|
||||
const dy = originY < 0 ? -originY : 0;
|
||||
|
||||
const context = getContext(result);
|
||||
|
||||
if (backgroundColor != null) {
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
context.drawImage(canvas, sx, sy, sw, sh, dx, dy, sw, sh);
|
||||
|
||||
return result;
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { ActionFlip, FlipType } from '../../ImageManipulator.types';
|
||||
import { getContext } from '../utils.web';
|
||||
|
||||
export default (canvas: HTMLCanvasElement, flip: ActionFlip['flip']) => {
|
||||
const xFlip = flip === FlipType.Horizontal;
|
||||
const yFlip = flip === FlipType.Vertical;
|
||||
|
||||
const result = document.createElement('canvas');
|
||||
result.width = canvas.width;
|
||||
result.height = canvas.height;
|
||||
|
||||
const context = getContext(result);
|
||||
|
||||
// Set the origin to the center of the image
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
|
||||
// Flip/flop the canvas
|
||||
const xScale = xFlip ? -1 : 1;
|
||||
const yScale = yFlip ? -1 : 1;
|
||||
context.scale(xScale, yScale);
|
||||
|
||||
// Draw the image
|
||||
context.drawImage(canvas, -canvas.width / 2, -canvas.height / 2, canvas.width, canvas.height);
|
||||
|
||||
return result;
|
||||
};
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { ActionResize } from '../../ImageManipulator.types';
|
||||
import { getContext } from '../utils.web';
|
||||
|
||||
/**
|
||||
* Hermite resize - fast image resize/resample using Hermite filter. 1 cpu version!
|
||||
* https://stackoverflow.com/a/18320662/4047926
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {int} width
|
||||
* @param {int} height
|
||||
* @param {boolean} resizeCanvas if true, canvas will be resized. Optional.
|
||||
*/
|
||||
function resampleSingle(
|
||||
canvas: HTMLCanvasElement,
|
||||
width: number,
|
||||
height: number,
|
||||
resizeCanvas: boolean = false
|
||||
): HTMLCanvasElement {
|
||||
const result = document.createElement('canvas');
|
||||
result.width = canvas.width;
|
||||
result.height = canvas.height;
|
||||
|
||||
const widthSource = canvas.width;
|
||||
const heightSource = canvas.height;
|
||||
width = Math.round(width);
|
||||
height = Math.round(height);
|
||||
|
||||
const wRatio = widthSource / width;
|
||||
const hRatio = heightSource / height;
|
||||
const wRatioHalf = Math.ceil(wRatio / 2);
|
||||
const hRatioHalf = Math.ceil(hRatio / 2);
|
||||
|
||||
const ctx = getContext(canvas);
|
||||
|
||||
const img = ctx.getImageData(0, 0, widthSource, heightSource);
|
||||
const img2 = ctx.createImageData(width, height);
|
||||
const data = img.data;
|
||||
const data2 = img2.data;
|
||||
|
||||
for (let j = 0; j < height; j++) {
|
||||
for (let i = 0; i < width; i++) {
|
||||
const x2 = (i + j * width) * 4;
|
||||
let weight = 0;
|
||||
let weights = 0;
|
||||
let weightsAlpha = 0;
|
||||
let gx_r = 0;
|
||||
let gx_g = 0;
|
||||
let gx_b = 0;
|
||||
let gx_a = 0;
|
||||
const yCenter = (j + 0.5) * hRatio;
|
||||
const yy_start = Math.floor(j * hRatio);
|
||||
const yy_stop = Math.ceil((j + 1) * hRatio);
|
||||
for (let yy = yy_start; yy < yy_stop; yy++) {
|
||||
const dy = Math.abs(yCenter - (yy + 0.5)) / hRatioHalf;
|
||||
const center_x = (i + 0.5) * wRatio;
|
||||
const w0 = dy * dy; //pre-calc part of w
|
||||
const xx_start = Math.floor(i * wRatio);
|
||||
const xx_stop = Math.ceil((i + 1) * wRatio);
|
||||
for (let xx = xx_start; xx < xx_stop; xx++) {
|
||||
const dx = Math.abs(center_x - (xx + 0.5)) / wRatioHalf;
|
||||
const w = Math.sqrt(w0 + dx * dx);
|
||||
if (w >= 1) {
|
||||
//pixel too far
|
||||
continue;
|
||||
}
|
||||
//hermite filter
|
||||
weight = 2 * w * w * w - 3 * w * w + 1;
|
||||
const xPosition = 4 * (xx + yy * widthSource);
|
||||
//alpha
|
||||
gx_a += weight * data[xPosition + 3];
|
||||
weightsAlpha += weight;
|
||||
//colors
|
||||
if (data[xPosition + 3] < 255) {
|
||||
weight = (weight * data[xPosition + 3]) / 250;
|
||||
}
|
||||
gx_r += weight * data[xPosition];
|
||||
gx_g += weight * data[xPosition + 1];
|
||||
gx_b += weight * data[xPosition + 2];
|
||||
weights += weight;
|
||||
}
|
||||
}
|
||||
data2[x2] = gx_r / weights;
|
||||
data2[x2 + 1] = gx_g / weights;
|
||||
data2[x2 + 2] = gx_b / weights;
|
||||
data2[x2 + 3] = gx_a / weightsAlpha;
|
||||
}
|
||||
}
|
||||
|
||||
//resize canvas
|
||||
if (resizeCanvas) {
|
||||
result.width = width;
|
||||
result.height = height;
|
||||
}
|
||||
|
||||
//draw
|
||||
const context = getContext(result);
|
||||
context.putImageData(img2, 0, 0);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default (canvas: HTMLCanvasElement, { width, height }: ActionResize['resize']) => {
|
||||
const imageRatio = canvas.width / canvas.height;
|
||||
|
||||
let requestedWidth: number = 0;
|
||||
let requestedHeight: number = 0;
|
||||
if (width !== undefined) {
|
||||
requestedWidth = width;
|
||||
requestedHeight = requestedWidth / imageRatio;
|
||||
}
|
||||
if (height !== undefined) {
|
||||
requestedHeight = height;
|
||||
if (requestedWidth === 0) {
|
||||
requestedWidth = requestedHeight * imageRatio;
|
||||
}
|
||||
}
|
||||
|
||||
return resampleSingle(canvas, requestedWidth, requestedHeight, true);
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { ActionRotate } from '../../ImageManipulator.types';
|
||||
import { getContext } from '../utils.web';
|
||||
|
||||
function sizeFromAngle(
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number
|
||||
): { width: number; height: number } {
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
let c = Math.cos(radians);
|
||||
let s = Math.sin(radians);
|
||||
if (s < 0) {
|
||||
s = -s;
|
||||
}
|
||||
if (c < 0) {
|
||||
c = -c;
|
||||
}
|
||||
return { width: height * s + width * c, height: height * c + width * s };
|
||||
}
|
||||
|
||||
export default (canvas: HTMLCanvasElement, degrees: ActionRotate['rotate']) => {
|
||||
const { width, height } = sizeFromAngle(canvas.width, canvas.height, degrees);
|
||||
|
||||
const result = document.createElement('canvas');
|
||||
result.width = width;
|
||||
result.height = height;
|
||||
|
||||
const context = getContext(result);
|
||||
|
||||
// Set the origin to the center of the image
|
||||
context.translate(result.width / 2, result.height / 2);
|
||||
|
||||
// Rotate the canvas around the origin
|
||||
const radians = (degrees * Math.PI) / 180;
|
||||
context.rotate(radians);
|
||||
|
||||
// Draw the image
|
||||
context.drawImage(canvas, -canvas.width / 2, -canvas.height / 2, canvas.width, canvas.height);
|
||||
|
||||
return result;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export { default as crop } from './CropAction.web';
|
||||
export { default as extent } from './ExtentAction.web';
|
||||
export { default as flip } from './FlipAction.web';
|
||||
export { default as resize } from './ResizeAction.web';
|
||||
export { default as rotate } from './RotateAction.web';
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { CodedError } from 'expo-modules-core';
|
||||
|
||||
export function getContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new CodedError('ERR_IMAGE_MANIPULATOR', 'Failed to create canvas context');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function blobToBase64String(blob: Blob): Promise<string> {
|
||||
const dataURL = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = () =>
|
||||
reject(new Error(`Unable to convert blob to base64 string: ${reader.error}`));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
return dataURL.replace(/^data:image\/\w+;base64,/, '');
|
||||
}
|
||||
|
||||
export function loadImageAsync(uri: string): Promise<HTMLCanvasElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const imageSource = new Image();
|
||||
imageSource.crossOrigin = 'anonymous';
|
||||
const canvas = document.createElement('canvas');
|
||||
imageSource.onload = () => {
|
||||
canvas.width = imageSource.naturalWidth;
|
||||
canvas.height = imageSource.naturalHeight;
|
||||
|
||||
const context = getContext(canvas);
|
||||
context.drawImage(imageSource, 0, 0, imageSource.naturalWidth, imageSource.naturalHeight);
|
||||
|
||||
resolve(canvas);
|
||||
};
|
||||
imageSource.onerror = () => reject(canvas);
|
||||
imageSource.src = uri;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user