이미지 편집기를 만들 던 중, 인스타그램처럼 필터 기능을 제공하고 싶어졌다.
인스타그램에서 다양한 필터를 사진에 적용할 수 있다.
우선, 이미지 편집기의 기능을 모두 직접 만들어내자니 시간이 너무 오래걸릴 것 같아서, 기존에 제공하는 이미지 편집기를 개조해서 만들어내기로 했다.
여러 후보가 있었지만 내가 고른 곳은 tui-image-editor. github를 쭉 읽어보면서 왜 이렇게 익숙하지?? 했는데 nhn이었다ㅋㅋ
API 문서를 읽어보면 알겠지만, 여러 쓸만한 기능을 제공하고 있다. 뭔가 살짝씩 애매하긴 하지만 이만큼 친절하고 많은 유저들이 쓰는 라이브러리가 없어서 고르게 되었다.
우선, 나는 react를 기반으로 사용하고 있기 때문에, 적당히 코드를 맞춰서 써야한다. (react에서도 사용할 수 있게 기능을 제공 중이지만, 원하는대로 UI를 짜고 싶어서..)
import ToastImageEditor from 'tui-image-editor';
import 'tui-image-editor/dist/tui-image-editor.css';
const imageEditor = ({editingImage}) => {
const toastImageEditorRef = useRef(); // image-editor div의 ref
const editorInstanceRef = useRef(); // 밖에서도 image editor instance 쓰기 위함
useEffect(() => {
const options = {
cssMaxWidth: 400,
cssMaxHeight: 400,
selectionStyle: {
cornerSize: 20,
rotatingPointOffset: 70,
},
usageStatistics: false,
};
// instance 정의
let editorInstance;
const canvasDiv = toastImageEditorRef.current;
if (canvasDiv) {
editorInstance = new ToastImageEditor(
toastImageEditorRef.current,
options,
);
editorInstanceRef.current = editorInstance; // 밖에서도 쓸 수 있도록
}
// 이미지 editor에 불러오기
if (canvasDiv && editingImage) {
editorInstance
.loadImageFromFile(editingImage) // loadImageFromURL이 안되서 file 그자체를 가져와서
.then(sizeValue => {
//console.log(sizeValue); // 이미지 넣고 난 후, 이미지의 size
//console.log(canvasDiv.offsetWidth, canvasDiv.offsetHeight); // canvasDiv의 사이즈
let maxValue;
// 가로가 더 긴 사진은 가로 기준, 세로가 더 긴 사진은 세로 기준으로
if (sizeValue.newWidth > sizeValue.newHeight) {
maxValue = canvasDiv.offsetWidth;
} else {
maxValue = canvasDiv.offsetHeight;
}
editorInstance.resizeCanvasDimension({
width: maxValue,
height: maxValue,
});
})
.catch(err => console.log(err));
}
}, [editingImage]);
}
editorInstanceRef에 new ImageEditor
를 넣어주어서 컴포넌트 내에서 사용할 수 있게 해주었다.
ImageEdiotr
의 instance를 만들 때, option으로 includeUI
를 넣어주면 tui-image-editor
가 제공하는 UI가 같이 들어온다. 빼주자.
toastImageEditorRef
는 div
를 지정해주어야 한다 .(착각해서 canvas
를 넣었다가 엄청 헤맸다.)
이렇게 인스턴스를 만들어주고 사진을 editor에 load까지 했으니 바로 필터를 적용해보자. 공식문서를 읽어보면 다음과 같이 되있었다.
음 좋아... 그래서 어떤 필터가 있는거죠..? 여기서 1차 멘붕.
그래서 코드를 뒤져봤다. (덕분에 라이브러리 코드 뒤적뒤적하는 스킬은 엄청 늘었다)
ImageEditor
class 아래에 _graphics
라는 프로퍼티가 있는데, 여기 안에 중요한 것들이 다 모여있었다. 그 아래에 _canvas
가 있었는데, 이게 fabric.Canvas
의 인스턴스였다.
좀 더 살펴보다가 filter
컴포넌트에 들어가니 applyFilter
의 정체(?)를 찾을 수 있었다.
아... 그냥 fabric의 filter를 그대로 쓰는 거구나.. 이제 fabric js로 넘어가야 한다.
그 전에 잠시 인스타 필터는 어떻게 만드는지 알아보자.
다행스럽게도 이전에 이를 구현해서 깃허브에 올리셨던 분이 계셨다. filterous-2
이분의 경우 fabric
같은 라이브러리를 쓰지 않으시고 html5 canvas를 직접 조작하셨는데, 요약하면,
이제 이걸 fabric에 녹여내기만 하면 된다.
fabric js의 공식 문서를 살펴보자. 여기. 뭔가.. 굉장히 복잡해보인다. 다행히(?) filter class만 만드는 것이 복잡할 뿐. 필터를 적용하는 것은 간단해 보인다.
const applyCustomFilter = useCallback(e => {
const filterName = e.target.textContent;
const editorInstance = editorInstanceRef.current;
const canvas = editorInstance._graphics._canvas; //fabric Canvas
const canvasImage = editorInstance._graphics.canvasImage; // fabric Image
fabric.Image.filters.CustomFilter = createFilterClass(
'CustomFilter',
filterName,
);
const filter = new fabric.Image.filters.CustomFilter();
// fabric에서 filter 적용
canvasImage.filters = [filter];
canvasImage.applyFilters();
canvas.renderAll();
}, []);
custom Filter를 class로 만들어준 후에, 그 인스턴스를 fabric.Image의 filters 프로퍼티에 넣어주고 다시 랜더링시켜주기만 하면 된다.
저기 class를 만드는 boilterplate를 제공해주는 데 es5 문법으로 되어있는지 아주 알아먹기 싫게 생겼다.
정말 다행히도 굳이 webGL을 사용하지 않는다면 이정도만 있어도 충분하다.
import { fabric } from 'fabric';
import imageInstaFilters from 'utils/imageFilter/imageInstaFilters';
export default function createFilterClass(type, filterName) {
return fabric.util.createClass(fabric.Image.filters.BaseFilter, {
type: type,
applyTo2d: options => {
let imageData = options.imageData;
let data = imageData.data;
data = imageInstaFilters[filterName](imageData);
},
});
}
imageInstaFilters에는 pixel을 조정하는 함수들이 가득 담겨있다. 하나만 살펴보면
// Clarendon: adds light to lighter areas and dark to darker areas
clarendon: pixels => {
pixels = filters.brightness(pixels, 0.1);
pixels = filters.contrast(pixels, 0.1);
pixels = filters.saturation(pixels, 0.15);
return pixels;
},
// filters
brightness: (pixels, adj) => {
let d = pixels.data;
adj = adj > 1 ? 1 : adj;
adj = adj < -1 ? -1 : adj;
adj = ~~(255 * adj);
for (let i = 0; i < d.length; i += 4) {
d[i] += adj;
d[i + 1] += adj;
d[i + 2] += adj;
}
return pixels;
},
contrast: (pixels, adj) => {
adj *= 255;
let d = pixels.data;
let factor = (259 * (adj + 255)) / (255 * (259 - adj));
for (let i = 0; i < d.length; i += 4) {
d[i] = factor * (d[i] - 128) + 128;
d[i + 1] = factor * (d[i + 1] - 128) + 128;
d[i + 2] = factor * (d[i + 2] - 128) + 128;
}
return pixels;
},
saturation: (pixels, adj) => {
let d = pixels.data;
adj = adj < -1 ? -1 : adj;
for (let i = 0; i < d.length; i += 4) {
let r = d[i],
g = d[i + 1],
b = d[i + 2];
let gray = 0.2989 * r + 0.587 * g + 0.114 * b; //weights from CCIR 601 spec
d[i] = -gray * adj + d[i] * (1 + adj);
d[i + 1] = -gray * adj + d[i + 1] * (1 + adj);
d[i + 2] = -gray * adj + d[i + 2] * (1 + adj);
}
return pixels;
},
모두 image data를 직접 조작해주는 함수들이다. 조작한 data를 return해서 class로 만들고, 이를 fabric.Image
의 filter에 넣어주는 것이 전부다.
이번 기능을 구현하는 것이 상당히 어렵고 힘들었던 기억이 난다. 일단 라이브러리에서 제공하지 않는 기능을 직접 넣어야했고, fabric js의 공식 문서가 아주 불친절...해서... 레퍼런스도 별로 없어서 너무 힘들었다 ㅋㅋ;
덕분에 다른 사람이 쓴 코드를 분석하는 능력을 기를 수 있어서 좋긴했지만 어후~