# Web Demo Video

> 화면 녹화 도구 없이 웹사이트·웹앱의 제품 데모 영상(SNS용 1:1, 9:16, 16:9)을 만든다. 실제 사이트를 iframe에 띄워 진짜 마우스 이벤트로 조작하며 프레임을 캡처하고 ffmpeg로 합성한다.

- Skill: `bam-bam-2/web-demo-video` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add bam-bam-2/web-demo-video`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bam-bam-2/web-demo-video/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: bam-bam-2 (https://skillmd.com/u/bam-bam-2)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/bam-bam-2/web-demo-video

---


# 웹 데모 영상 만들기

사용자의 웹 프로젝트(모자이크, 커뮤니티앱, 카공메이트 등) 소개 영상을 만들 때 쓰는 절차.
목업 이미지를 이어붙이는 게 아니라 **실제 사이트를 실제로 조작한 화면**을 찍는 게 핵심이다.

## 먼저 물어볼 것

영상은 어디 쓰느냐로 비율·길이가 갈린다. **만들기 전에 반드시 확인한다.**

| 용도 | 비율 | 길이 |
|---|---|---|
| 스레드·릴스·쇼츠 | 9:16 (1080x1920) | 15~25초 |
| 블로그·유튜브 임베드·랜딩 | 16:9 (1920x1080) | 30~40초 |
| 인스타 피드·카톡 공유 | 1:1 (1080x1080) | ~20초 |

사용자는 시각 산출물의 **형식**에 구체적인 기대가 있다. 레퍼런스가 있으면 먼저 달라고 한다.

## 절차

### 1. 로컬 서버로 대상 사이트를 띄운다

```bash
cd <프로젝트> && python3 -m http.server 8899
```

무대(stage) 페이지와 대상 사이트는 **반드시 같은 오리진**이어야 한다.
포트가 다르면 `iframe.contentDocument` 접근이 막혀서 조작이 불가능하다.
→ 무대 파일을 프로젝트 폴더 안에 임시로 두고, **작업이 끝나면 반드시 빼낸다**(안 그러면 다음 배포에 딸려 올라간다).

### 2. 무대 페이지를 만든다

`assets/stage-template.html`를 복사해서 고친다. 구조:

- `#stage` — 출력 비율 그대로의 고정 크기 div (1:1이면 540x540, dpr 2로 캡처하면 1080x1080)
- `#cap` / `#foot` — 큰 자막(상단) / 보조 문구(하단)
- `#win` — 맥 브라우저 창 목업(신호등 3개 + URL) 안에 `<iframe>`을 `transform: scale()`로 축소해 넣음
- `#cursor` — 가짜 마우스 커서 SVG. **이게 있어야 조작 영상처럼 보인다**
- `#ring` — 클릭·브러시 크기를 보여주는 하이라이트 원
- `#end` — 엔딩 카드(제품명 / 한줄 설명 / 셀링포인트 3개 / URL)
- `window.__tick(frameIndex)` — 프레임 번호 하나를 받아 그 시점의 화면 상태를 만드는 **결정론적** 함수

### 3. 타임라인을 결정론적으로 짠다

`__tick(frame)` 안에서 `t = frame / FPS`로 시간을 계산하고, 모든 상태를 t로부터 다시 계산한다.
실시간 `setTimeout`을 쓰면 캡처 속도와 어긋나서 망가진다.

- 1회성 동작(이미지 로드, 모드 전환)은 `once(key, fn)` 으로 중복 실행을 막는다
- 드래그는 `drag(t, 시작초, 끝초, ptFn)` — 시작 프레임에 `mousedown`, 매 프레임 `mousemove`, 끝나면 `mouseup`
- 이벤트는 iframe의 `W.MouseEvent`로 만들어 `clientX/clientY`(iframe 기준 좌표)로 dispatch한다.
  `mousedown`은 대상 요소에, `mousemove`/`mouseup`은 보통 `window`에 붙어 있으니 확인하고 각각 맞는 타깃에 쏜다
- 좌표는 하드코딩하지 말고 **요소의 `getBoundingClientRect()` 또는 그려넣은 목업의 rect 비율**로 계산한다. 창 크기를 바꿔도 안 깨진다
- iframe 안에서 영상에 불필요한 요소(헤더, SEO 본문, 푸터, 광고 배너, 상태줄)는 `iframe` load 시점에 `<style>` 주입으로 숨긴다

### 4. 프레임을 캡처한다

```js
for (let i = from; i <= to; i++) {
  await page.evaluate(f => __tick(f), i);
  const buf = await page.screenshot({ clip: { x:0, y:0, width:540, height:540 } });
  await fs.writeFile(`${DIR}/f${String(i).padStart(4,'0')}.png`, buf);
}
```

- **`clip`은 CSS 픽셀 단위다.** devicePixelRatio를 곱하면 안 된다. dpr 2면 540 클립이 1080px 이미지로 나온다
- 프레임 한 장에 약 0.09초. 400장이면 40초 → REPL 120초 제한 때문에 **100~150장씩 나눠 호출**한다
- 캡처 시작 전에 페이지를 새로 로드해 상태를 초기화하고, `__tick(0)` 후 이미지 로드가 끝날 시간을 준다

### 5. ffmpeg로 합성한다

`ffmpeg`는 PATH에 없다. `export PATH="$PATH:/opt/homebrew/bin"` 를 붙인다.

```bash
ffmpeg -y -framerate 20 -i frames/f%04d.png \
  -vf "scale=1080:1080:flags=lanczos,fps=30" \
  -c:v libx264 -profile:v high -pix_fmt yuv420p -crf 20 -movflags +faststart out.mp4
```

- `-pix_fmt yuv420p`는 SNS 호환 필수
- 채팅에 붙여 보여줄 미리보기 GIF는 palettegen/paletteuse 2패스로:
  `fps=10,scale=420:-1:flags=lanczos,palettegen=stats_mode=diff` → `paletteuse=dither=bayer:bayer_scale=3`
- 콘티 확인용 컨택트시트: `select='eq(n\,20)+...',scale=340:340,tile=3x3`

### 6. 뒷정리

- 무대 파일을 프로젝트 폴더 밖으로 옮긴다 (배포에 섞이면 안 됨)
- 로컬 서버 종료: `lsof -ti:8899 | xargs kill -9`
- 캡처 탭 닫기

## 주의

- 영상에 쓰는 예시 데이터(대화 내용, 이름, 계좌번호, 전화번호, 주소)는 **전부 지어낸 가짜**로 만들고, 보고할 때 가짜라고 명시한다. 사용자의 실제 개인정보(전세금 등)나 제3자 정보는 절대 넣지 않는다
- 무음이 기본. 음악이 필요하면 사용자에게 확인받는다
- 완성본은 세션 artifacts에 두되, 다시 뽑을 수 있도록 **무대 파일을 영구 경로에 보관**한다

---

## 부록 · 블로그 본문용 GIF (브라우저 목업 없이)

SNS 영상과 목적이 다르다. 블로그 독자는 **"이 도구가 뭘 하는지"**를 3초 안에 봐야 하므로,
맥 브라우저 창 목업·엔딩 카드 없이 **작업 대상만 크게** 보여주는 편이 낫다.
무대(stage) 페이지도 로컬 서버도 필요 없다. 배포된 사이트를 직접 조작하고 캔버스를 그대로 뜬다.

### 절차

1. 배포 사이트를 열고 예시 이미지를 붙여넣기 이벤트로 주입한다(파일 선택 UI를 거치지 않아도 된다).
   ```js
   await page.evaluate(async (b64) => {
     const blob = await (await fetch('data:image/png;base64,'+b64)).blob();
     const dt = new DataTransfer(); dt.items.add(new File([blob],'s.png',{type:'image/png'}));
     document.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true}));
   }, b64);
   ```
2. 영상에 불필요한 요소는 캡처 전에 숨긴다 (`adInCanvas` 같은 광고 영역).
3. **좌표는 반드시 픽셀로 탐지한다. 눈대중 금지.** 이번에 눈대중으로 잡았다가 펜·채우기가 엉뚱한 데
   찍혀서 세 번 다시 찍었다. 캔버스 픽셀을 직접 훑어 대상 글자의 bbox를 구한다.
   ```js
   const bbox = (x0,x1,y0,y1) => { /* 어두운 픽셀(r,g,b<110)의 min/max */ };
   ```
   노란 말풍선 같은 색 블록은 해당 색 범위로 같은 방식으로 찾는다.
4. 프레임은 **캔버스를 새 캔버스에 다시 그려서** 만든다. `page.screenshot({clip})`은 스크롤/DPR 때문에
   좌표가 어긋나기 쉽다. 자막·커서·선택 박스·브러시 원도 이 합성 단계에서 그려 넣는다.
   - 상단에 라벨 띠(무슨 모드를 쓰는지)를 넣으면 GIF 하나로 기능 여러 개를 설명할 수 있다
   - 드래그 중에는 파란 점선 선택 박스를, 펜 모드에서는 브러시 크기 원을 그린다
5. 실제 마우스 이벤트로 조작하면서 매 스텝 프레임을 저장한다. 동작 전후로 **정지 프레임 6~8장**을
   넣어야 눈이 따라간다.
6. ffmpeg 2패스로 GIF 합성 (본문용은 폭 620~640px, 12fps, 70KB 안팎이면 충분).

### 효과 선택 주의

- **단색 영역에 블러를 걸면 화면상 아무 변화가 없다.** 평면 아바타를 블러 처리하는 컷을 넣었다가
  차이가 안 보여서 뺐다. 블러는 디테일이 있는 대상에만 쓴다.
- **펜은 모자이크보다 블러가 깔끔하다.** 펜+모자이크는 블록이 흩뿌려진 것처럼 지저분하게 나온다.
- 가리기 결과를 보여줄 땐 **가린 영역 위로 원본이 삐져나오지 않았는지** 마지막 프레임에서 확인한다.
  살짝 비치면 여백을 더 줘서 한 번 더 덮고 뒤쪽 정지 프레임만 다시 찍으면 된다.

### 어디에 쓰나

- 네이버 블로그: 사진 업로드와 동일하게 `사진` 버튼 → 파일 선택으로 올린다 (GIF도 그대로 애니메이션된다)
- 티스토리: `첨부 → 사진`. 업로드하면 `[##_Image|...|_##]` 문법이 본문에 들어가므로,
  그걸 읽어서 전체 HTML을 조립한 뒤 `setContent()` 하면 이미지 위치를 원하는 곳에 둘 수 있다
- 사이트 자체: `<figure>` + `loading="lazy"` + 캡션에 "예시 데이터는 전부 가짜"를 명시

