YouTube 숏폼 URL을 넣거나 비디오 파일을 끌어다 놓으면, 카메라 앞에서 따라 춘 내 동작을 영상 속 댄서와 비교해 점수로 보여주는 무가입 정적 웹앱. 영상·포즈 데이터 업로드 없음 — 모든 영상/포즈 처리가 브라우저 안에서 끝난다. 사이트 이용 통계는 Google Analytics로 전송된다.
[YouTube iframe] --(탭 화면 캡처 + 플레이어 영역 크롭)--\
>-- 포즈 추정 --> RefTrack
[로컬 파일 <video>] --(시크하며 캔버스로 직접 읽기)-----/ |
|
[웹캠] --------------------------------------------> 포즈 추정 --> 특징 벡터 |
|
시간 창 안에서 정렬 비교 --> 점수 (0~100)
입력 경로는 둘이고, 그 뒤 파이프라인은 같다(js/source.js가 둘을 같은 인터페이스로 감싼다).
핵심 제약: YouTube iframe은 cross-origin이라 픽셀을 읽을 수 없다.
canvas.drawImage(iframe)도, captureStream()도 불가능하다. 그래서 영상 속
댄서의 포즈를 얻는 유일한 무서버 경로가 getDisplayMedia({preferCurrentTab:true})로
자기 탭을 캡처해 플레이어 영역만 잘라내는 것이다. (Plan A)
이 캡처는 첫 1회 재생(학습 랩) 동안만 돌리고, 추출한 안무 시퀀스를 IndexedDB에 캐시한다. 2회차부터는 화면 공유 없이 캐시만으로 채점하므로 연산량이 절반으로 떨어지고 UX도 깔끔해진다.
파일은 blob: URL이라 same-origin이다. <video>를 캔버스에 그대로 그릴 수 있어
경로 A의 복잡도가 통째로 사라진다.
| YouTube | 파일 | |
|---|---|---|
| 화면 공유 | 필수 | 불필요 |
| 레터박스 자동 감지 | 필요 | 불필요 (videoWidth/Height가 곧 원본 좌표계) |
| 좌우 반전 되펴기 | 필요 | 불필요 (CSS 반전은 원본 픽셀에 영향 없음) |
| 학습 속도 | 실시간 1배속 1회전 | 시크 기반 선추출 — 재생 없이 미리 |
| iOS Safari | ❌ |
넣는 즉시 프리스캔이 돈다: 영상 길이를 설정 FPS(기본 24fps)로 나눈 시각마다 시크해서 프레임을
읽고 포즈를 뽑는다(js/prescan.js). 배속 재생 + requestVideoFrameCallback 대신
시크를 고른 이유는, 추론이 디코딩보다 느리면 재생 방식은 프레임을 조용히 흘려
커버리지에 구멍이 생기기 때문이다. 시크는 표본 시각이 결정적이라 구멍이 없고
진행률·취소가 정확하다.
캐시 키는 파일 내용의 해시다(SHA-256(크기 ‖ 앞 4MB ‖ 뒤 4MB), js/filekey.js).
이름이나 수정시각으로 잡으면 복사하거나 이름만 바꿔도 깨져 처음부터 다시 학습한다.
같은 파일을 다시 떨구면 프리스캔을 통째로 건너뛰고 즉시 시작한다.
프리스캔이 부적절한 경우엔 재생하며 학습 + 채점 동시 모드로 넘어간다:
3분 초과 파일(선추출이 재생보다 오래 걸린다), 사용자가 "건너뛰고 바로 시작"을 누른 경우,
그리고 일시정지 상태의 프레임을 못 읽는 브라우저(앞 1초가 전부 빈 프레임이면 감지).
파일 경로는 오버레이가 픽셀 추출을 오염시키지 않아 첫 바퀴부터 채점이 성립한다 —
채점은 추정된 지연 t−τ 시각을 보므로 방금 학습한 구간으로 이미 맞춰진다.
비디오 파일로 (권장 — 화면 공유가 필요 없다)
- 📁 비디오 파일 버튼, 또는 창 아무 데나 파일을 끌어다 놓기
- 넣는 즉시 안무 포즈 추출이 시작된다 (진행률·남은 시간 표시, 건너뛰기 가능)
- 카메라 허용 — 추출과 동시에 진행하면 기다리는 시간이 겹쳐 사라진다
- 상단 속도 슬라이더에서 재생 속도를
0.5×~1.2×로 조절한다 - 추출이 끝나면 영상이 무한 반복되며 바로 채점이 시작된다
업로드한 영상은 무대에 포인터를 올렸을 때 나타나는 하단 타임바를 드래그하거나 키보드 방향키로 움직여 원하는 위치부터 다시 연습할 수 있다. 탐색해도 중지 상태는 그대로 유지된다. 타임바가 필요 없으면 설정의 업로드 영상 타임바 표시를 끈다. 내 카메라 창이 안무를 가리면 설정의 내 화면 PiP 표시를 끌 수 있다. 선택은 브라우저에 저장되며, PiP를 끈 상태에서 영상과 같이 녹화하면 결과에도 카메라 창이 들어가지 않는다.
같은 파일을 다시 넣으면 학습을 건너뛴다. 파일은 브라우저 밖으로 나가지 않는다.
YouTube 숏폼으로
- 숏폼 URL 붙여넣기 → 시작
- 카메라 허용
- 이 탭 공유 → 공유 대화상자에서 반드시 “이 탭” 선택
- 영상이 한 바퀴 도는 동안 그대로 두면 학습 완료 → 이후 따라 추면 점수가 뜬다
카메라와 안무 학습이 준비되면 상단 ● 녹화 버튼이 활성화된다.
- 녹화를 누르고 팝업에서 녹화 화면과 음원 포함 여부를 고른다. 점수·콤보·세부 수치와 화면에 표시 중인 스켈레톤을 결과에 넣을 수도 있다.
- 기본값인 나만 녹화 + 음원 제거는 화면의 PiP를 유지하면서 카메라 원본의 가로·세로 비율 그대로 내 모습만 저장한다.
- 영상과 같이 녹화를 고르면 기존처럼 안무 영상과 오른쪽 아래 셀프 카메라 PiP를 한 화면에 합쳐 9:16으로 저장한다. 설정에서 내 화면 PiP 표시를 끄면 안무 영상만 저장한다. PiP는 드래그로 옮기고 왼쪽 상단·왼쪽 하단·오른쪽 하단 모서리로 크기를 조절하거나 ⛶ 버튼으로 전체 화면에 맞출 수 있으며, 화면에서 정한 배치가 녹화 결과에도 그대로 적용된다.
- 3초 카운트다운 뒤 영상이 0초부터 재생된다. 한 바퀴와 마지막 동작 채점이 끝나면 최종 점수를 잠시 담은 뒤 자동으로 멈춘다.
- 프리뷰에서 재촬영, 기기 공유, 파일 저장 중 하나를 고른다. 공유 직전에는 영상·음원의 저작권 및 초상권을 확인하라는 경고가 표시된다.
파일 영상은 원본 프레임과 오디오를 브라우저 안에서 직접 합성한다. YouTube는 iframe 픽셀을 직접 읽을 수 없어 영상과 같이 녹화할 때 현재 탭을 한 번 더 공유해야 한다. YouTube 음원을 포함하는 경우에도 현재 탭을 공유하고 탭 오디오 공유를 켜야 한다. Safari처럼 H.264 MediaRecorder를 제공하는 브라우저는 MP4, Chromium 계열은 지원 코덱에 따라 MP4 또는 WebM으로 저장한다. 두 형식 모두 YouTube Shorts 업로드가 가능하다.
index.html#<videoId 또는 URL> 로 바로 열 수도 있다.
마지막으로 입력한 유효한 URL은 localStorage에 저장되어 같은 브라우저에서 다시 방문하면
입력칸에 복원된다(도메인이 다른 localhost와 배포 주소의 저장값은 서로 공유되지 않는다).
상단 ⏸ 버튼이나 스페이스바로 언제든 멈췄다 이어서 할 수 있다. 멈추면 화면에 가림막이 덮이고, 아무 데나 누르면 계속된다.
중지는 영상·채점·안무 학습·플레이 시간을 한꺼번에 멈춘다. 영상만 멈추면 정지 화면을 상대로 계속 채점해 점수가 바닥나고, 채점만 멈추면 안무가 그냥 지나가 버린다. 중지된 동안은 포즈 추론도 돌지 않아 노트북 팬이 잠잠해진다.
- 중지 중에 흐른 시간은 총 플레이 시간에 들어가지 않는다(
js/clock.js: Stopwatch). - 영상을 직접 클릭해서 멈춘 경우도 같은 상태로 다룬다 — 플레이어의
onStateChange를 받아 버튼과 실제 상태가 어긋나지 않게 한다. - 학습 랩 중에 멈춰도 안전하다. 안무 데이터는 영상 시각을 키로 쌓이므로 이어서 학습된다.
영상이 한 바퀴 돌 때마다 한 판으로 치고, 그 판의 평균 점수를 남긴다. 판이 끝나면 무대 아래에 결과 카드가 잠깐 뜨고(평균 점수·플레이 시간·최고 콤보· 지난 판 대비 증감), 옆 패널에 지금까지의 추세가 가로 스크롤 막대 그래프로 쌓인다. 총 플레이 시간은 판별 시간의 합이며 진행 중인 판은 1초마다 갱신된다.
- 판 시작은 "첫 점수가 나온 순간"이다. 카메라 앞에 서기까지 기다린 시간은 안 센다.
- 30샘플·5초를 못 채운 구간은 판으로 치지 않는다. 영상 끝자락에 잠깐 잡힌 정도로 기록이 쌓이면 그래프가 쓸모없어진다.
- 기록은 판당 수십 바이트라 localStorage에 넣는다(포즈 트랙만 IndexedDB). 최근 200판까지.
설정에서 자동 반복을 켜면 한 판의 시간대별 점수를 0.5초 단위로 모아 가장 낮은 2초를 찾는다. 평균이 반복 기준 점수(기본 30점)보다 낮으면 준비 동작 1.5초 전부터 해당 구간을 반복한다. 취약 구간 평균이 기준점을 넘으면 전체 안무를 0초부터 다시 시작한다. 반복 중에는 집중 연습을 판 기록과 총 플레이 시간에 섞지 않으며, 무대 아래의 전체 안무로 버튼으로 언제든 건너뛸 수 있다.
- 임베드 루프:
loop=1은 단독으로 동작하지 않고playlist=<같은 id>가 필요하다. 그래도 안 도는 케이스가 있어onStateChange의 ENDED에서 직접 되감는다. - 자동재생엔
mute=1이 필수. 소리는 사용자 제스처(🔇 버튼) 이후에만 켜진다. - 필러박스: 세로 숏폼을 16:9 임베드에 넣으면 좌우에 검은 띠가 생겨 좌표가
어긋난다. 무대 자체를 9:16으로 잡고, 그래도 남는 띠는 밝기 기반으로
자동 감지해 크롭한다(
capture.js: detectContentBox). - 학습 중에는 영상 위에 스켈레톤을 그리지 않는다. 탭 캡처에 그 스켈레톤이 같이 찍혀 영상 속 사람 인식을 방해하기 때문. 학습 랩 동안엔 옆 패널의 셀프뷰에 그린다.
- 가운데 한 명 선택: 다중 검출 후 골반·어깨 중점 x가 화면 중앙에 가장 가까운
사람을 고르되, 프레임마다 독립 선택하면 두 사람 사이를 깜빡이므로
히스테리시스를 건다(
pose.js: makeCenterPicker). - “내 스켈레톤 표시”를 켜면 댄서 크기로 맞춰 가운데에 그린다(
align.js). 사용자는 카메라와의 거리도 서 있는 위치도 제각각이라, 정규화 좌표를 그대로 무대에 얹으면 댄서보다 훨씬 크거나 작고 한쪽에 치우쳐서 겹쳐 보는 의미가 없다. 몸통(어깨중점~골반중점) 기준으로 균등 스케일 + 평행이동만 걸어 관절 각도는 보존한다. 다만 매 프레임 정확히 못 박으면 좌우 스텝처럼 이동 자체가 안무인 동작이 사라지므로, 중심·크기를 EMA로 천천히 따라가게 해서 짧은 이동은 살리고 오래 치우친 것만 되돌린다. 채점은 어차피 이동·스케일 불변이라 점수에는 영향이 없다(순수 표시용). - 채점은 원본 좌표 거리를 그대로 비교하지 않는다. 뼈대 단위벡터와 몸통 길이로 정규화한 관절 관계를 쓰고, x는 종횡비로 보정한다(카메라 4:3 vs 영상 9:16).
- 점수는 세 축으로 분해된다: 자세 0.7 + 움직임 0.3, 타이밍은 0.85~1.0 배수.
자세만 보면 정지 화면도 통과한다 — 서 있는 사람끼리는 몸통·다리가 항상 닮아서
어떤 안무와 견줘도 일정한 바닥 점수가 나오기 때문이다. 그래서 각 축을 따로 잰다.
- 자세: 뼈별 각도 오차 → 62°에서 0점(그 뒤로는 감점). 코사인 평균이 아니다 — 코사인은 90° 틀려도 절반을 주지만 춤에서 90°는 완전히 다른 자세다. 집계는 못 맞춘 하위 1/3에 3배 가중(한 팔만 틀려도 티가 나야 한다). 뼈 방향 75%에 손↔머리·골반, 양손·양발 간격, 팔꿈치·무릎 굽힘 등 관절 관계 25%를 섞어 여러 관절이 만드는 전신 배치도 함께 본다. 그리고 대조 정규화: "이 영상의 아무 순간과 비교했을 때보다 얼마나 더 맞는가." 영상마다 다른 공짜 점수 바닥이 자동으로 빠진다.
- 움직임: 뼈별 각속도의 부호·크기 일치. 안무가 안 움직이는 뼈는 가중치를 깎는다 (그런 뼈는 "따라 췄는가"에 대한 정보가 없다). 역재생·정지가 여기서 걸린다.
- 타이밍: 지연이 일정한가(jitter) × 그 지연이 데이터를 실제로 설명하는가(margin).
- 시간 정렬이 가장 큰 함정: 사람은 반응 지연 + 카메라/추론 지연으로 항상 늦다.
그런데 그 지연을 넓은 창으로 흡수하면 안 된다. 120BPM에서 850ms 창은 1.7박이라
창 안에 안무 한 주기가 통째로 들어가고, 그러면 어떤 자세를 취하든 창 안 어딘가와
맞아떨어진다(가만히 서 있어도 만점이 나왔다). 대신 지연 τ를 최근 2초의 상관으로
하나의 값으로 추정하고(
lag.js), 채점은t−τ의 ±70ms에서만 한다. 설정의 "최대 지연 탐색"은 창 폭이 아니라 τ 후보의 범위라서 넓혀도 점수가 후해지지 않는다. 기준 스켈레톤도 같은t−τ를 그린다 — 채점 대상과 화면에 보이는 대상이 어긋나면 "화면과 안 맞는데 PERFECT"가 구조적으로 가능해진다. - 레퍼런스는 자세도 각속도도 시간 보간해서 쓴다. 학습 트랙은 설정 FPS로 만들어지며,
기본 24fps도 약 42ms 간격이다. 빠른 동작에서 가장 가까운 프레임을 그대로 쓰면
프레임 양자화 오차가 생긴다. 사용자 탓이 아닌 오차다.
각속도(
velocityAt)도 같은 보간을 쓴다 — 자세만 보간하고 각속도는 프레임에 스냅하면 채점하는 시각과 움직임을 잰 시각이 최대 반 프레임 어긋나고, 그 어긋남이 학습 FPS와VEL_DT의 정수배 관계에 따라 점수를 들쭉날쭉하게 만든다(실측: 스냅 시절엔 20fps가 24fps보다 높았다). - 등급·콤보는 안무의 키포즈에서만 갱신한다. 키포즈 = 레퍼런스 각속도의 국소 최소점. 900ms 타이머로 뿌리면 안무와 아무 관계 없는 순간의 점수가 등급으로 굳고, 콤보는 프레임 카운터가 되어 10초만 춰도 COMBO 600이 된다. 평균도 시간 가중이라 프레임레이트에 흔들리지 않는다.
- 좌우 반전과 거울 모드는 다른 축이다. 좌우 반전은 보이는 화면을 뒤집고
(
.frame.flip #player { transform: scaleX(-1) }), 거울 모드는 채점할 때 좌우를 스왑해서 비교한다. 둘은 서로 상쇄되므로 실제 스왑 여부는 XOR이다 (features.js: needsSwap). 기본값은 둘 다 ON = 화면이 뒤집혀 있고 보이는 그대로 같은 쪽 손발로 따라하면 된다. 뒷모습으로 찍힌 안무 영상은 거울 모드를 끈다. - 뒤집힌 화면은 탭 캡처에도 그대로 찍힌다. 그래서 학습용 프레임은
capture.grab(maxH, flipX)에서 도로 펴서 넣는다. RefTrack과 IndexedDB 캐시는 항상 원본 영상 좌표계라, 반전 설정을 바꿔도 다시 학습할 필요가 없다. - 뒤집는 건
#player(iframe)와#fileVideo뿐이다..frame전체를 뒤집으면 HUD의 점수 글자까지 거꾸로 나온다. 다만 파일에서는 되펴기가 필요 없다 — CSStransform은 표시만 바꿀 뿐drawImage가 읽는 원본 픽셀에는 영향이 없다. - 파일 재생에
loop속성을 쓰지 않는다.loop가 붙으면ended가 발화하지 않아 판(라운드) 경계를 잡을 수 없다.ended에서 직접 되감고onLoop을 쏘면 YouTube 경로의onVideoLoop을 그대로 재사용할 수 있다. - 루프·시크 직후의
pause/play이벤트는 삼킨다. 그대로 올려보내면 앱이 "사용자가 멈췄다"로 오해해 중지 가림막이 깜빡인다(filevideo.js의 quiet 창). - 일부 webm은
duration이Infinity로 온다. 끝으로 한 번 시크하면 브라우저가 실제 길이를 채운다. 안 하면 진행률과 프리스캔 표본 시각이 통째로 무너진다. - 영상 분석 FPS는 8~24, 기본값 24다. 위쪽 한계는 RefTrack이 0.04초(=25fps) 이내
표본을 중복으로 보고 버려서다 — 그보다 촘촘히 뽑아도 시크만 낭비된다. 아래쪽
한계(
MIN_FPS)는 채점이 성립하는 경계다: 프레임 간격이MAX_INTERP_SPAN(150ms)을 넘으면 각속도를 아예 못 내서 움직임 축이 사라지고 키포즈가 0개가 되며 지연 추정까지 어긋난다(실측: 6fps에서 100ms 지연을 132ms로 읽는다). 긴 영상은 표본 상한(1800장)에 걸리면 앞부분을 자르는 대신 전 구간을 고르게 솎는다 — 잘라내면 뒤쪽 안무가 통째로 빈다.
| 항목 | 상태 |
|---|---|
| 데스크톱 Chrome / Edge | ✅ 전 기능 |
| Firefox | preferCurrentTab 미지원 — 공유 목록에서 탭을 직접 골라야 함 |
| iOS Safari | getDisplayMedia 없음). 파일 경로는 동작한다 — 일시정지 프레임을 못 읽는 기기에서는 자동으로 "재생하며 학습"으로 넘어간다 |
| 큰 파일 (>500MB) / 긴 영상 (>3분) | |
| HEVC / AV1 등 브라우저가 못 여는 코덱 | ❌ MP4(H.264)나 WebM으로 변환 필요 (문구로 안내) |
| 임베드 차단 영상 | ❌ 업로더 설정(오류 101/150) — 다른 영상을 써야 함 |
| 턴/회전이 많은 안무 |
플레이어 위 오버레이와 화면 캡처는 YouTube 약관상 회색지대다. 데모/개인용 전제. 파일 경로에는 해당하지 않는다 — 임베드도 캡처도 없이 내 파일을 내 브라우저에서 읽을 뿐이다.
빌드 없음. 정적 파일이 전부다.
python3 -m http.server 8080
# http://localhost:8080 (getUserMedia는 https 또는 localhost에서만 동작)index.html
css/style.css
js/app.js 오케스트레이션, 렌더 루프
js/source.js 재생 소스 추상화 (app.js가 소스 종류를 모르게 한다) + YouTube 어댑터
js/youtube.js URL 파싱 + IFrame API 루프 재생
js/capture.js 탭 캡처 + 플레이어 영역 크롭 + 레터박스 감지
js/filevideo.js 로컬 파일 소스 (blob 재생 / 루프 / 프레임 추출)
js/filekey.js 비디오 파일 판별 + 콘텐츠 해시 캐시 키
js/prescan.js 시크 기반 안무 선추출 (진행률 / 취소 / 표본 시각)
js/pose.js MediaPipe Pose Landmarker 로더
js/center.js 가운데 한 명 선택 (히스테리시스)
js/align.js 내 스켈레톤을 댄서 크기·화면 중앙으로 정렬 (표시용 닮음변환)
js/features.js 뼈대·관절 관계 특징 추출 / 거울 변환 / 자세 점수 / 양자화
js/lag.js 따라 추는 지연 τ 추정 (상관 피크 + 신뢰도)
js/scoring.js RefTrack(영상 시각→포즈, 보간·각속도·키포즈) + 세 축 채점
js/store.js IndexedDB 캐시 (영상별 안무 트랙)
js/history.js 판 기록 (평균 점수·플레이 시간) localStorage 저장 + 집계
js/clock.js 판 시간 스톱워치 (중지된 시간 제외)
js/preferences.js 마지막으로 입력한 숏폼 URL 저장
js/draw.js 스켈레톤 렌더링
js/recording.js 9:16 영상+카메라 PiP 합성 / MediaRecorder / 파일 저장
의존성은 CDN에서 로드하는 MediaPipe Tasks Vision(WASM) 하나뿐이다.
npm test # 순수 로직 (URL 파싱, 자세 점수, 지연 추정, 채점 판별력, 중앙 선택, 판 기록)
npm run test:e2e # 실제 브라우저에서 추론→채점→캐시 통합 검증 (playwright 필요)e2e는 두 부분이다. 1부는 실제 댄스 사진 2장을 MediaPipe에 통과시켜 스케일 불변성,
거울 구분, 시간 정렬 보정, IndexedDB 왕복을 확인한다. 2부는 페이지를 실제로 조작해
파일 선택 → 프리스캔 → 캐시 → 재투입 시 스킵 → 드래그 앤 드롭 → 잘못된 파일 거부까지
훑는다. 서버가 떠 있어야 한다(BASE_URL로 주소 변경 가능, 기본 http://localhost:8080).
영상 픽스처(test/e2e/fixtures/dance.webm, dance.mp4)는 위 사진 2장을 이어붙인
2초짜리로 저장소에 들어 있다 — 네트워크 없이 재현되는 게 핵심이다. 다시 만들려면:
cd test/e2e/fixtures
ffmpeg -y -loop 1 -t 1 -i a.jpg -loop 1 -t 1 -i b.jpg \
-filter_complex "[0:v]scale=-2:640,setsar=1,fps=15[v0];[1:v]scale=-2:640,setsar=1,fps=15[v1];[v0][v1]concat=n=2:v=1:a=0[v]" \
-map "[v]" -c:v libx264 -pix_fmt yuv420p -profile:v baseline -crf 30 -movflags +faststart dance.mp4
# Playwright 크로미움엔 H.264가 없어 webm도 같이 둔다 (테스트가 재생 가능한 쪽을 고른다)
ffmpeg -y -loop 1 -t 1 -i a.jpg -loop 1 -t 1 -i b.jpg \
-filter_complex "[0:v]scale=-2:640,setsar=1,fps=15[v0];[1:v]scale=-2:640,setsar=1,fps=15[v1];[v0][v1]concat=n=2:v=1:a=0[v]" \
-map "[v]" -c:v libvpx -pix_fmt yuv420p -b:v 400k dance.webm캐시 용량은 프레임당 132바이트 — 30초 숏폼을 24fps로 학습해도 100KB 미만이다.
캐시는 features.FEATURE_VERSION으로 무효화한다. 특징 추출 방식을 바꾸면 낡은
포맷으로 학습된 안무가 계속 재사용되면서 원인 없이 점수가 이상해지기 때문이다.
레터박스 크롭 보정값(capture.inset)도 소스를 바꾸거나 캐시를 지울 때 함께 버린다 —
남겨 두면 이전 영상의 크롭이 다음 영상의 좌표계를 어긋나게 만든다.
절대 점수는 합성 데이터로 맞출 수 없다. 설정의 채점 로그 기록을 켜면 매 프레임의 지연·세 축 점수·뼈별 각도 오차를 JSONL로 내려받을 수 있고, 그 로그로 각도 허용치와 대조 곡선 상수를 다시 잡는다.