화면 녹화 도구 없이 웹사이트·웹앱의 제품 데모 영상(SNS용 1:1, 9:16, 16:9)을 만든다. 실제 사이트를 iframe에 띄워 진짜 마우스 이벤트로 조작하며 프레임을 캡처하고 ffmpeg로 합성한다.
npx skills add https://github.com/bam-bam-2/solo-skills --skill web-demo-video
사용자의 웹 프로젝트(모자이크, 커뮤니티앱, 카공메이트 등) 소개 영상을 만들 때 쓰는 절차.
목업 이미지를 이어붙이는 게 아니라 실제 사이트를 실제로 조작한 화면을 찍는 게 핵심이다.
영상은 어디 쓰느냐로 비율·길이가 갈린다. 만들기 전에 반드시 확인한다.
| 용도 | 비율 | 길이 |
|---|---|---|
| 스레드·릴스·쇼츠 | 9:16 (1080x1920) | 15~25초 |
| 블로그·유튜브 임베드·랜딩 | 16:9 (1920x1080) | 30~40초 |
| 인스타 피드·카톡 공유 | 1:1 (1080x1080) | ~20초 |
사용자는 시각 산출물의 형식에 구체적인 기대가 있다. 레퍼런스가 있으면 먼저 달라고 한다.
cd <프로젝트> && python3 -m http.server 8899
무대(stage) 페이지와 대상 사이트는 반드시 같은 오리진이어야 한다.
포트가 다르면 iframe.contentDocument 접근이 막혀서 조작이 불가능하다.
→ 무대 파일을 프로젝트 폴더 안에 임시로 두고, 작업이 끝나면 반드시 빼낸다(안 그러면 다음 배포에 딸려 올라간다).
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) — 프레임 번호 하나를 받아 그 시점의 화면 상태를 만드는 결정론적 함수__tick(frame) 안에서 t = frame / FPS로 시간을 계산하고, 모든 상태를 t로부터 다시 계산한다.
실시간 setTimeout을 쓰면 캡처 속도와 어긋나서 망가진다.
once(key, fn) 으로 중복 실행을 막는다drag(t, 시작초, 끝초, ptFn) — 시작 프레임에 mousedown, 매 프레임 mousemove, 끝나면 mouseupW.MouseEvent로 만들어 clientX/clientY(iframe 기준 좌표)로 dispatch한다.mousedown은 대상 요소에, mousemove/mouseup은 보통 window에 붙어 있으니 확인하고 각각 맞는 타깃에 쏜다
getBoundingClientRect() 또는 그려넣은 목업의 rect 비율로 계산한다. 창 크기를 바꿔도 안 깨진다iframe load 시점에 <style> 주입으로 숨긴다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 이미지로 나온다__tick(0) 후 이미지 로드가 끝날 시간을 준다ffmpeg는 PATH에 없다. export PATH="$PATH:/opt/homebrew/bin" 를 붙인다.
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 호환 필수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=3x3lsof -ti:8899 | xargs kill -9SNS 영상과 목적이 다르다. 블로그 독자는 "이 도구가 뭘 하는지"를 3초 안에 봐야 하므로,
맥 브라우저 창 목업·엔딩 카드 없이 작업 대상만 크게 보여주는 편이 낫다.
무대(stage) 페이지도 로컬 서버도 필요 없다. 배포된 사이트를 직접 조작하고 캔버스를 그대로 뜬다.
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);
adInCanvas 같은 광고 영역).찍혀서 세 번 다시 찍었다. 캔버스 픽셀을 직접 훑어 대상 글자의 bbox를 구한다.
const bbox = (x0,x1,y0,y1) => { /* 어두운 픽셀(r,g,b<110)의 min/max */ };
노란 말풍선 같은 색 블록은 해당 색 범위로 같은 방식으로 찾는다.
page.screenshot({clip})은 스크롤/DPR 때문에좌표가 어긋나기 쉽다. 자막·커서·선택 박스·브러시 원도 이 합성 단계에서 그려 넣는다.
넣어야 눈이 따라간다.
차이가 안 보여서 뺐다. 블러는 디테일이 있는 대상에만 쓴다.
살짝 비치면 여백을 더 줘서 한 번 더 덮고 뒤쪽 정지 프레임만 다시 찍으면 된다.
사진 버튼 → 파일 선택으로 올린다 (GIF도 그대로 애니메이션된다)첨부 → 사진. 업로드하면 [##_Image|...|_##] 문법이 본문에 들어가므로,그걸 읽어서 전체 HTML을 조립한 뒤 setContent() 하면 이미지 위치를 원하는 곳에 둘 수 있다
<figure> + loading="lazy" + 캡션에 "예시 데이터는 전부 가짜"를 명시Take bam-bam-2/web-demo-video from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.