이미지 크롭 기능 9종 — 업로드 없이 브라우저에서
이미지 크롭 기능은 올리기 전 사진에서 남길 자리를 골라 잘라내는 편집 UI입니다. 여기 아홉 가지는 그 일을 업로드 없이 브라우저 안에서 끝내도록 캔버스 2D 와 포인터 이벤트만으로 짠 것이라, 자르기부터 회전·필터·워터마크·내보내기까지 파일을 열면 그대로 돕니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 자유 크롭 박스
- 02 회전과 수평 보정
- 03 필터 프리셋 여섯 개
- 04 밝기 대비 채도 슬라이더
- 05 좌우와 상하 뒤집기
- 06 정사각 여백 채우기
- 07 텍스트 워터마크
- 08 RGB 히스토그램
- 09 내보내기 패널
아홉 개의 순서는 기능의 무게가 아니라 사진 한 장이 편집기를 지나는 길입니다. 먼저 모양을 정하는 셋 — 남길 자리를 손잡이로 잡고(01), 삐뚤어진 수평을 각도로 펴고(02), 톤을 프리셋으로 한 번에 바꿉니다(03). 다음은 값을 다듬는 셋 — 밝기·대비·채도를 따로 밀고(04), 축을 뒤집고(05), 규격에 맞게 여백을 채웁니다(06). 마지막 셋은 내보내기 직전의 일 — 글자를 얹고(07), 픽셀 분포로 과했는지 확인하고(08), 형식과 품질을 골라 파일을 만듭니다(09). 사진은 외부 파일을 하나도 쓰지 않고 캔버스에 그라디언트와 도형으로 합성했습니다. 손으로 긋는 쪽 이야기는 서명 패드와 캔버스 드로잉 UI 9종에, 색을 고르는 쪽은 컬러 피커 직접 만들기 CSS+JS 9종에 있습니다.
01자유 크롭 박스
모서리 넷과 변 넷, 손잡이 여덟 개로 남길 범위를 잡는 기본 판입니다. 바깥을 덮는 그늘은 겉 사각형을 돌고 안쪽 구멍을 거꾸로 도는 열 점짜리 clip-path 다각형 하나로 그렸고, 결과 칸에는 아홉 인자 drawImage 가 오려낸 사각형만 다시 그립니다. 오른쪽 아래 손잡이를 끌면 162 × 112 였던 범위가 111 × 72 로 줄어드는 것을 Playwright 로 확인했고, 미리보기에서 누적면적 18.917%·움직인 프레임 19/23 을 기록했습니다.
// 바깥을 어둡게 덮는 그늘. 겉 사각형을 한 바퀴 돌고 안쪽 구멍을 거꾸로 도는 열 점짜리 다각형이다
shade.style.clipPath = 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, ' +
x + '% ' + y + '%, ' + x + '% ' + (y + h) + '%, ' + (x + w) + '% ' + (y + h) + '%, ' +
(x + w) + '% ' + y + '%, ' + x + '% ' + y + '%)';
var sx = photo.width * x / 100, sy = photo.height * y / 100;
var sw = photo.width * w / 100, sh = photo.height * h / 100;
var pw = Math.round(sw), ph = Math.round(sh);
// 결과 칸은 잘린 크기 그대로 만든다 — 고정 정사각에 그리면 비율이 눌린다
out.width = pw; out.height = ph;
// 아홉 인자 drawImage — 원본에서 오려낼 사각형과 그려 넣을 사각형을 따로 준다
octx.drawImage(photo, sx, sy, sw, sh, 0, 0, pw, ph);
02회전과 수평 보정
각도를 밀면 사진이 기울고, 모서리에 빈 자리가 생기지 않도록 필요한 배율을 삼각함수로 같이 계산합니다. 12도에서 배율은 1.31배로 나오는데 이 값은 감이 아니라 기운 사각형의 가로변과 세로변이 얼마나 길어지는지에서 곧바로 나옵니다. 격자 미리보기는 기울어진 상태에서 시작해 수평으로 폈다가 다시 기울며 누적면적 22.342%·강도 65.5 를 기록했습니다.
// 기운 사각형이 원래 칸을 끝까지 덮으려면 얼마나 키워야 하는가.
// 가로변은 w·|cos| + h·|sin| 로 늘어나고 세로변은 w·|sin| + h·|cos| 로 늘어난다 — 둘 중 큰 쪽이 배율이다
function zoomFor(rad, w, h) {
var c = Math.abs(Math.cos(rad)), s = Math.abs(Math.sin(rad));
return Math.max((w * c + h * s) / w, (w * s + h * c) / h);
}
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
vctx.translate(w / 2, h / 2);
vctx.rotate(rad);
vctx.scale(k, k);
vctx.drawImage(src, -w / 2, -h / 2, w, h);
03필터 프리셋 여섯 개
원본·흑백·세피아·선명·차분·빈티지 여섯 개가 각자 filter 문자열 하나로만 정의돼 있고, 누르면 큰 사진과 아래 글자가 동시에 바뀝니다. 격자 미리보기에서만 여섯 상태를 이어 돌리는데, 이때는 함수 목록을 여섯 개로 통일해 적습니다. 값 계산은 프리셋과 같지만 목록을 맞추지 않으면 보간이 아예 일어나지 않아 톤이 툭툭 끊깁니다(누적면적 19.97%·움직인 프레임 10/23).
// 함수 목록이 서로 다르면 브라우저가 보간을 포기한다. 없는 함수는 항등값으로 채워 길이를 맞춘다
@keyframes fpChain {
0%, 10% { filter: grayscale(0) sepia(0) saturate(1) contrast(1) brightness(1) hue-rotate(0deg); }
14%, 24% { filter: grayscale(1) sepia(0) saturate(1) contrast(1.08) brightness(1) hue-rotate(0deg); }
28%, 38% { filter: grayscale(0) sepia(0.75) saturate(1.2) contrast(1) brightness(1) hue-rotate(0deg); }
42%, 52% { filter: grayscale(0) sepia(0) saturate(1.35) contrast(1.3) brightness(1) hue-rotate(0deg); }
56%, 66% { filter: grayscale(0) sepia(0) saturate(0.55) contrast(1) brightness(1.08) hue-rotate(0deg); }
70%, 85% { filter: grayscale(0) sepia(0.4) saturate(1) contrast(0.9) brightness(1) hue-rotate(-14deg); }
89%, 100% { filter: grayscale(0) sepia(0) saturate(1) contrast(1) brightness(1) hue-rotate(0deg); }
}
04밝기 대비 채도 슬라이더
세 값이 따로 놀지 않고 filter 문자열 하나로 합쳐지므로 적는 순서가 곧 적용 순서입니다. 비교 버튼은 그 문자열을 none 으로 갈아 끼워 같은 자리에서 원본을 보여주고, 꼬리표 글자도 함께 바뀝니다. 밝기 트랙을 오른쪽으로 끌었을 때 brightness(1.35) 와 낭독 문구 「1.35배」가 같이 갱신되는 것을 실측했습니다(누적면적 15.922%·강도 56.2).
// 슬라이더 셋이 filter 문자열 하나로 합쳐진다 — 순서가 곧 적용 순서다
var chain = 'brightness(' + value.b.toFixed(2) + ') contrast(' + value.c.toFixed(2) + ') saturate(' + value.s.toFixed(2) + ')';
view.style.filter = showOriginal ? 'none' : chain;
code.textContent = chain;
tag.textContent = showOriginal ? TAG_ORIG : TAG_EDIT;
for (var i = 0; i < tracks.length; i++) {
var key = tracks[i].getAttribute('data-k'), v = value[key];
thumbs[key].style.left = ((v - 0.5) * 100) + '%';
tracks[i].setAttribute('aria-valuenow', v.toFixed(2));
tracks[i].setAttribute('aria-valuetext', VOICE.replace('%v', v.toFixed(2)));
}
05좌우와 상하 뒤집기
축 하나에 음수 배율을 주는 일인데, 그냥 뒤집으면 그림이 캔버스 밖으로 나가므로 그 변의 길이만큼 먼저 밀어 놓아야 합니다. 미리보기는 네 상태를 steps(1, end) 로 끊는데, 1 에서 -1 로 이어서 보간하면 도중에 폭이 0 이 되는 장면이 생겨 대표 이미지가 선 한 줄로 남기 때문입니다. 컷만으로는 프레임이 모자라 빛줄기 하나를 따로 흘려 20/23 프레임을 채웠습니다(누적면적 29.441%).
var w = view.width, h = view.height;
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
// 축을 음수로 뒤집으면 그림이 화면 밖으로 나가므로 그만큼 먼저 밀어 놓는다
vctx.translate(flipX ? w : 0, flipY ? h : 0);
vctx.scale(flipX ? -1 : 1, flipY ? -1 : 1);
vctx.drawImage(src, 0, 0, w, h);
06정사각 여백 채우기
가로로 긴 사진을 정사각 규격에 넣을 때 잘라내는 대신 남는 자리를 칠하는 방식입니다. 배율은 안쪽 크기를 사진의 가로·세로로 각각 나눈 값 중 작은 쪽이라 긴 변이 먼저 닿고 잘리지 않습니다. 바탕색 넷을 한 프레임에 갈아 끼우고 여백만 계속 움직여 22/23 프레임·강도 99.5 를 기록했습니다.
var s = out.width;
// 먼저 정사각 전체를 고른 색으로 칠하고, 그 위에 사진을 가운데로 앉힌다
octx.fillStyle = bg;
octx.fillRect(0, 0, s, s);
var inner = s * (1 - pad * 2);
var k = Math.min(inner / src.width, inner / src.height);
var dw = src.width * k, dh = src.height * k;
octx.drawImage(src, (s - dw) / 2, (s - dh) / 2, dw, dh);
07텍스트 워터마크
글자를 끌어 자리를 정하고 크기와 그림자를 조절해 캔버스에 굽습니다. 밝은 하늘 위에서는 흰 글자가 묻히므로 shadowBlur 와 세로 오프셋을 함께 걸어야 읽히고, 토글을 끄면 둘 다 0 으로 내려갑니다. 미리보기 동안에는 캔버스가 사진만 그리고 글자는 DOM 유령 판이 대신 움직이는데, 한 번 구운 캔버스 글자는 CSS 로 옮길 수 없기 때문입니다(누적면적 6.148%·강도 77.7).
vctx.textAlign = 'center';
vctx.textBaseline = 'middle';
vctx.fillStyle = 'rgba(255, 247, 230, 0.94)';
vctx.shadowColor = 'rgba(23, 20, 26, 0.62)';
// 그림자는 글자 아래 번지는 정도만 — 0 이면 밝은 하늘 위에서 글자가 묻힌다
vctx.shadowBlur = shadowOn ? 9 : 0;
vctx.shadowOffsetY = shadowOn ? 2 : 0;
vctx.fillText(mark, spot.x * w, spot.y * h);
08RGB 히스토그램
보정을 입힌 사진을 작은 판에 다시 그린 다음 getImageData 로 픽셀을 통째로 읽어 채널마다 열여섯 칸으로 셉니다. 바이트를 네 비트 오른쪽으로 밀면 그대로 칸 번호가 되고, 256 단계를 다 세면 막대가 잘아 모양이 안 읽힙니다. 밝기를 1.55배로 올렸을 때 막대가 칠한 넓이가 16.44%에서 8.65%로 줄어드는 것을 실측했습니다(누적면적 16.857%·움직인 프레임 23/23).
// ctx.filter 는 Safari 에 아직 없다(caniuse 81.08%). 없으면 읽은 값에 직접 곱해 같은 결과를 만든다
var CAN_FILTER = typeof wctx.filter === 'string';
function countBins() {
if (CAN_FILTER) { wctx.filter = 'brightness(' + level.toFixed(2) + ')'; }
wctx.clearRect(0, 0, work.width, work.height);
wctx.drawImage(view, 0, 0, work.width, work.height);
var data = wctx.getImageData(0, 0, work.width, work.height).data;
var bins = [], i;
for (i = 0; i < BINS * 3; i++) { bins.push(0); }
for (var p = 0; p < data.length; p += 4) {
for (var ch = 0; ch < 3; ch++) {
var v = CAN_FILTER ? data[p + ch] : Math.min(255, Math.round(data[p + ch] * level));
bins[Math.min(BINS - 1, v >> 4) * 3 + ch] += 1;
}
}
return bins;
}
09내보내기 패널
용량을 공식으로 짐작하지 않고 toBlob 으로 파일을 실제로 만들어 길이를 읽습니다. 같은 사진 320 × 200 을 기준으로 PNG 는 12.4 KB, JPG 는 품질 0.8 에서 5.8 KB, WEBP 는 품질 0.8 에서 3.3 KB, 품질을 0.5 로 내리면 2.4 KB 가 나왔습니다. PNG 는 무손실이라 품질 인자를 아예 무시하므로 그때는 슬라이더를 흐리게 두고 aria-disabled 를 켭니다(누적면적 2.169%·강도 108.0).
// 고른 형식을 브라우저가 모르면 규격상 조용히 PNG 가 나온다 — image/webp 는 Safari 에 아직 없다
// (caniuse 81.11%). blob.type 을 견줘 다르면 실제로 나온 형식을 같이 적는다
function label(blob) {
if (!blob) { return '— KB'; }
return (blob.size / 1024).toFixed(1) + ' KB' + (blob.type === mime ? '' : ' · ' + blob.type);
}
function estimate() {
view.toBlob(function (blob) { sizeEl.textContent = label(blob); }, mime, quality);
}
어디서 깨지는가 — 함정
이 편에서 가장 조용히 깨지는 것은 캔버스 쪽 두 기능이 Safari 에만 없다는 점입니다. CanvasRenderingContext2D.filter 는 전역 81.08%, toBlob 의 image/webp 는 81.11% 인데 둘 다 Safari 에서 빠져 있고, 하필 둘 다 오류를 던지지 않습니다. 08 은 필터가 안 먹은 원본을 세어 막대가 안 움직이고, 09 는 WEBP 를 골랐는데 규격대로 PNG 가 나와 용량만 두 배 넘게 찍힙니다. 그래서 08 은 typeof wctx.filter === 'string' 으로 지원 여부를 보고 없으면 읽은 바이트에 직접 곱하도록, 09 는 blob.type 을 고른 형식과 견줘 다르면 실제로 나온 형식을 같이 적도록 고쳤습니다. 반면 CSS 쪽 filter 는 97.02% 라 03·04 는 그대로 써도 됩니다.
| 기능 | 전역 지원 | 빠지는 곳 | 이 편의 대응 |
|---|---|---|---|
CSS filter |
97.02% | 없음 | 03·04 가 그대로 사용 |
ctx.filter |
81.08% | Safari (18부터도 기본 꺼짐) | 08 이 픽셀에 직접 곱함 |
toBlob 의 image/webp |
81.11% | Safari 전 버전 | 09 가 blob.type 을 표시 |
두 번째 함정은 미리보기와 화면 글자가 어긋나는 것이었습니다. 03 에서 선택 테두리만 부드럽게 밀었더니 테두리가 먼저 도착한 프레임이 대표 이미지로 뽑혀 「선명 칸에 테두리, 아래 글자는 세피아 값」이 됐고, 테두리와 글자를 같은 순간에 끊고 나서야 둘이 맞아떨어졌습니다. 세 번째는 대비입니다. 06 의 오렌지 무대 위 크림 글자가 #ff4d1f 배경에서 3.11:1 로 AA 미달이라 잉크로 바꿔 5.50:1 이 됐고, 05·07 의 눌린 알약도 #2f6df6 위 크림이 4.25:1 이라 흰색으로 올려 4.53:1 을 만들었습니다(근거: run/295/_수리전실측.json·run/295/_probe_contrast/_대비실측.json). 편집기 아홉 벌을 한 묶음으로 받는 zip 의 잠금은 z3y6mq2c 이며, 열면 항목 번호마다 바닐라 판과 React 판이 같은 이름으로 나란히 놓여 있습니다. 출처는 MDN drawImage 와 caniuse 입니다.
접근성
캔버스는 화면 낭독기에 그림일 뿐이라 아홉 개 전부 role="img" 와 aria-label 을 붙였고, 01·03·05 는 값이 바뀔 때마다 그 라벨을 다시 씁니다(01 은 「잘라낸 범위 111 × 72 픽셀」). 슬라이더 여덟 개는 role="slider" 에 aria-labelledby(02 만 aria-label)로 이름을 잇고 aria-valuenow·aria-valuetext 를 갱신하며, 토글은 진짜 버튼에 aria-pressed 를 씁니다. 키보드만으로도 같은 조작이 됩니다 — 01 의 손잡이에 포커스를 두고 오른쪽 화살표를 열 번 누르면 범위가 162 × 112 에서 152 × 112 로 정확히 10px 줄고, Shift 와 함께 왼쪽 화살표를 한 번 누르면 162 × 112 로 돌아옵니다. prefers-reduced-motion 에서는 아홉 개의 미리보기 루프를 !important 로 끄고 편집이 끝난 상태를 세워 두므로, 02 는 기울어진 사진과 -12.0도 값이, 06 은 먹색 바탕에 여백을 넓힌 정사각이 그대로 남습니다.
FAQ
사진 파일을 서버로 올리지 않고도 자를 수 있나요?
가능합니다. 아홉 개 전부 브라우저 안에서만 돌아가고 네트워크 요청이 하나도 없습니다. 사용자가 고른 파일을 URL.createObjectURL 로 <img> 에 물린 뒤 그 이미지를 drawImage 의 첫 인자로 넘기면 되고, 이 편의 데모는 그 자리에 캔버스로 합성한 가짜 사진을 넣어 파일 없이도 열리게 했습니다.
잘라낸 결과를 원본 해상도로 저장하려면 어떻게 하나요?
화면에 보이는 크기가 아니라 원본 픽셀 기준으로 계산하면 됩니다. 01 은 크롭 범위를 퍼센트로만 들고 있다가 그릴 때 photo.width 에 곱하므로, 원본이 4000px짜리라도 같은 퍼센트를 곱해 4000px 기준 사각형을 오려낼 수 있습니다. 결과 캔버스의 width·height 를 그 픽셀 크기로 잡아 주는 것이 요령입니다.
편집 전후를 나란히 비교하는 화면은 어떻게 만드나요?
04 는 같은 자리에서 원본과 보정본을 번갈아 보여주는 A/B 토글입니다. 두 장을 좌우로 문질러 비교하는 방식이 필요하면 이미지 비교 슬라이더 UI 9종이 그쪽을 다루고, 두 방식은 목적이 달라 함께 써도 부딪히지 않습니다.