본문 바로가기
C.W.K.
Stream
Lesson 04 of 07 · published

Screenshot과 clipping — captureVisibleTab + canvas crop

~13 min · screenshot, captureVisibleTab, canvas, clipping

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"chrome.tabs.captureVisibleTab이 tab 전체를 dataURL로 넘겨주고, 선택 영역의 좌표가 그중 어느 픽셀이 중요한지 알려 줘. 이 lesson에서 그 둘을 이어서, ClipDeck의 clip 마다 자기가 어디서 왔는지를 딱 맞게 오려낸 그림으로 지니고 다니게 만들 거야."

chrome.tabs.captureVisibleTab

API가 작아. 두 인자:

  • windowId — optional. 주어진 window의 active tab capture. 생략하면 현재 window capture.
  • options — 안 줘도 돼. { format: 'png' | 'jpeg', quality: 0..100 }를 받고 기본은 PNG 야. 글자가 많은 페이지를 찍을 땐 quality 80 짜리 JPEG가 보통 5 배쯤 작아.

data: URL을 돌려줘. 찍히는 건 지금 화면에 보이는 부분, 그러니까 user가 실제로 보고 있는 만큼이야. 화면 밖에 있는 건 안 찍혀.

권한은 셋 중 하나면 돼. user가 뭘 누른 직후의 activeTab 이든, 그 주소에 대한 host_permissions 든, tabs 든. ClipDeck은 이 시점에 셋 다 갖고 있고.

Crop workflow

찍힌 그림은 tab 전체야. ClipDeck이 원하는 건 user가 고른 영역만이고. 계산은 이렇게 해:

  1. Content script에서 selection의 getBoundingClientRect() 얻기 — viewport-relative 좌표.
  2. Device pixel ratio (window.devicePixelRatio) 얻기. Captured 이미지가 native 해상도. rect는 CSS pixel.
  3. SW가 dataURL 이랑 좌표, 화면 배율을 받아. 화면 밖 canvas에 그림을 올리고, 계산해 둔 픽셀 좌표대로 잘라서 다시 그려.
  4. Cropped canvas를 또 다른 dataURL로 export. Clip과 저장.

Canvas 위한 offscreen document

service worker는 DOM을 못 쓰니까 평범한 canvas를 만들 수가 없어. 그래서 MV3가 chrome.offscreen을 들여왔지. SW가 DOM API가 필요할 때 — canvas 든, Blob을 다루는 fetch 든, 오디오든 — 몰래 띄우는 문서야. 모양은 이래:

await chrome.offscreen.createDocument({
  url: 'offscreen.html',
  reasons: ['BLOBS'],
  justification: 'Crop captured tab screenshots for ClipDeck clips',
});

offscreen.html이 SW에서 메시지 받고, canvas work 하고, 결과 다시 post 하는 offscreen.js 가진 작은 page. 사용 후 chrome.offscreen.closeDocument() 호출 — 열어 두는 게 약간 메모리 낭비.

Crop 코드

Offscreen document 안:

const img = new Image();
img.src = dataURL;
await new Promise((r) => (img.onload = r));
const canvas = new OffscreenCanvas(
  Math.round(rect.width * dpr),
  Math.round(rect.height * dpr),
);
const ctx = canvas.getContext('2d');
ctx.drawImage(
  img,
  rect.left * dpr, rect.top * dpr,
  rect.width * dpr, rect.height * dpr,
  0, 0,
  rect.width * dpr, rect.height * dpr,
);
const blob = await canvas.convertToBlob({ type: 'image/png' });
const reader = new FileReader();
reader.readAsDataURL(blob);
await new Promise((r) => (reader.onload = r));
return reader.result; // cropped dataURL

OffscreenCanvas가 offscreen document에서 지원되고 Worker-friendly canvas 제공. 9-인자 drawImage form: sx, sy, sw, sh, dx, dy, dw, dh. Source 좌표가 rect에서. destination이 0,0에서 시작.

Size 예산

Typical 텍스트 selection의 각 PNG screenshot이 ~10–50 KB. PNG이 텍스트엔 fine. JPEG quality 80이 또 30–50% cut. dataURL을 chrome.storage.local에 직접 저장 — quota가 약 10 MB, cropped 유지하면 몇 백 screenshot에 plenty. Clip 카운트 자라면 공격적 압축, JPEG 전환, 또는 user가 clip 별 screenshot opt-out 고려.

Clip에 들어가는 것

Clip schema 확장:

{ id, text, url, title, savedAt, screenshot?: string /* dataURL */ }

Side panel이 있으면 screenshot inline 렌더 (작은 thumbnail. click으로 확대). Export flow가 포함. clipboard copy는 여전히 text만.

captureVisibleTab + Selection.rect + offscreen canvas = 모든 clip의 tightly-cropped, in-context screenshot. 저렴, optional, bare text가 잃는 원래 context 보존.
captureVisibleTab과 off-screen selection. User의 selection이 visible viewport 너머 걸치면 (긴 page의 긴 highlight), captureVisibleTab이 visible 한 것만 capture. Cropped 결과가 불완전. 두 option: scroll-and-stitch (selection을 view로 scroll 하면서 여러 frame capture, 합성) — heavy. 또는 rect 높이가 viewport 초과하면 screenshot 거부, text-only로 fallback — light. ClipDeck v1이 preview의 한 줄 note와 함께 light path.

Capture scale이 devicePixelRatio과 같다고 가정하지 마. Zoom과 display 설정 때문에 bitmap 크기가 달라질 수 있어. Selection rect와 CSS viewport width/height를 같이 보내고, image를 load한 뒤 scaleX = img.width / viewport.width, scaleY = img.height / viewport.height를 계산해. Rect를 visible viewport에 clamp한 다음 그 scale로 crop해.

Code

manifest.json — permission에 'offscreen' 추가·json
{
  "permissions": ["storage", "activeTab", "scripting", "sidePanel", "contextMenus", "alarms", "tabs", "offscreen"]
}
background.js — captureVisibleTab + crop을 offscreen에 위임·javascript
// background.js — capture, 다음 offscreen document 통해 crop
async function ensureOffscreen() {
  const existing = await chrome.offscreen.hasDocument();
  if (existing) return;
  await chrome.offscreen.createDocument({
    url: "offscreen.html",
    reasons: ["BLOBS"],
    justification: "Crop captured tab screenshots for ClipDeck clips",
  });
}

async function captureAndCrop(tab, rect, dpr) {
  if (rect.height > tab.height) return null; // off-screen — 경고 참조
  const dataURL = await chrome.tabs.captureVisibleTab(tab.windowId, {
    format: "png",
  });
  await ensureOffscreen();
  const response = await chrome.runtime.sendMessage({
    type: "clipdeck:crop",
    payload: { dataURL, rect, viewport },
  });
  return response?.cropped ?? null;
}
offscreen.html — cropping script의 minimal stub·html
<!-- offscreen.html -->
<!doctype html>
<html>
  <head><meta charset="utf-8" /></head>
  <body>
    <script src="offscreen.js"></script>
  </body>
</html>
offscreen.js — OffscreenCanvas가 captured 이미지 crop·javascript
// offscreen.js — crop request 받고 cropped dataURL 반환
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type !== "clipdeck:crop") return;
  (async () => {
    const { dataURL, rect, viewport } = message.payload;
    const img = new Image();
    img.src = dataURL;
    await new Promise((r) => (img.onload = r));
    const scaleX = img.naturalWidth / viewport.width;
    const scaleY = img.naturalHeight / viewport.height;
    const canvas = new OffscreenCanvas(
      Math.max(1, Math.round(rect.width * scaleX)),
      Math.max(1, Math.round(rect.height * scaleY)),
    );
    const ctx = canvas.getContext("2d");
    ctx.drawImage(
      img,
      rect.left * scaleX, rect.top * scaleY,
      rect.width * scaleX, rect.height * scaleY,
      0, 0,
      canvas.width, canvas.height,
    );
    const blob = await canvas.convertToBlob({ type: "image/png" });
    const reader = new FileReader();
    reader.readAsDataURL(blob);
    await new Promise((r) => (reader.onload = r));
    sendResponse({ cropped: reader.result });
  })();
  return true;
});

External links

Exercise

clipdeck/manifest.json permission에 "offscreen" 추가. 세 번째와 네 번째 code block 사용해 clipdeck/offscreen.html과 clipdeck/offscreen.js 생성. 두 번째 code block (captureAndCrop)을 clipdeck/background.js에 추가. Save-clip flow에 wire: content script에서 selection 텍스트 + rect capture 후, captureAndCrop(tab, rect, { width: innerWidth, height: innerHeight })도 호출하고 결과를 새 clip의 screenshot field로 attach. Side panel을 update 해서 각 clip text 옆 thumbnail 렌더. Reload, Wikipedia article에서 clip 저장, side panel이 highlight 된 영역의 작은 이미지 보이는지 확인.
Hint
chrome.tabs.captureVisibleTab이 Cannot access contents of the page error 면 activeTab grant가 current 아님 — fresh user gesture 후 호출 필수. Capture를 여러 await 통해 defer 안 하고 save-clip handler 자체로 옮기기 시도. Cropped 이미지가 offset 됐거나 size 잘못이면 dpr 곱셈 더블 체크 — captured 이미지가 physical pixel, rect가 CSS pixel. devicePixelRatio가 Mac에서 보통 1 이나 2, 대부분 Windows 화면에서 1. chrome.offscreen.createDocument가 Only a single offscreen document may be created throw 하면 hasDocument 확인 잊은 거 — ensureOffscreen을 idempotent 유지.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.