소리가 0.15초 앞서 있었다 · v1.0.0.1
첫 판의 시험은 전부 통과했지만 결함이 남아 있었다. 시험은 결과 파일의 길이만 확인하였고 소리와 영상이 맞는지는 확인하지 않았기 때문이다. 이 판에서는 코드를 처음부터 다시 읽어 결함 셋과 보완 넷을 찾아 고친다. Windows 실행 파일도 이 판에서 처음 만든다.
1판 코드를 처음부터 다시 읽어 시험이 잡지 못한 결함을 찾아 고친다. exe 를 클라우드에서 만들어 판마다 함께 낸다.
- 값이 어디서 와서 어디로 가는지 따라가며 코드 점검
- 결함은 수치로 재현한 뒤 고치고, 그 수치를 재는 시험을 붙임
- 보완(단축키·영역·출력 크기·커서)은 고친 뒤 시험
- wine 에 Windows Python 을 올려 PyInstaller 빌드
- 빌드된 exe 로 실제 녹화
- ☑결함 1: 소리가 영상보다 0.15초 앞섬 → atrim·adelay, 섬광·삐 소리 시험으로 1.000초 일치 확인
- ☑결함 2: 버전표시 시각이 파일 시각과 다름 → 첫 프레임 기준으로 보정
- ☑결함 3: 합치는 중 재시작하면 세션을 잃음 → finalizing 상태로 차단
- ☑단축키 Ctrl+Alt 로 변경, settings.json 에서 수정 가능
- ☑영역 화면 밖 잘라내기 + 미리보기
- ☑출력 크기 50·75%, 마우스 커서 덧그리기
- ☑exe 189MB 클라우드 빌드(icuuc.dll 대역, numpy 2.0.2, ffmpeg 한 벌)
- ☑Windows Python 으로 enginetest·realtest 전부 통과, exe 로 5.93초 녹화
- ☐실제 Windows 에서 loopback 장치·배율 화면 확인
1. 점검 방법
1판의 시험은 결과 파일이 나오는지와 길이가 맞는지만 확인하였다. 이번에는 코드를 처음부터 다시 읽으면서 각 값이 어디서 와서 어디로 가는지를 따라갔다. 찾은 것은 결함 셋과 보완 넷이다. 결함은 수치로 재현한 뒤 고쳤고, 보완은 고친 뒤 시험을 붙였다.
| # | 구분 | 내용 | 재현·확인 |
|---|---|---|---|
| 1 | 결함 | 소리가 영상보다 0.15초 앞서 있음 | enginetest [4] 섬광·삐 소리 시각 비교 |
| 2 | 결함 | 버전표시 시각이 파일 시각과 다름 | enginetest [6] |
| 3 | 결함 | 합치는 중 단축키로 시작하면 새 세션이 화면에서 사라짐 | realtest「합치는 중엔 새로 시작 안 됨」 |
| 4 | 보완 | Ctrl+Shift+R 이 브라우저 강력 새로고침과 겹침 | Ctrl+Alt+R/P/M, 설정으로 변경 |
| 5 | 보완 | 영역이 화면 밖으로 나가면 mss 예외. 배율 어긋남을 알 길 없음 | 잘라내기 + 미리보기, realtest |
| 6 | 보완 | 4K 화면을 그대로 넣으면 인코더가 못 따라감 | 출력 크기 50·75%, enginetest [5] |
| 7 | 보완 | 커서가 안 찍힘 | 화살표 덧그리기, enginetest [5] |
2. 결함 1. 소리가 0.15초 앞서 있었다
1판의 _audio_parts 는 소리 시작이 영상보다 늦은 경우(양수)만 -itsoffset 으로 처리하고, 이른 경우(음수)는 0으로 버렸다. 세션은 소리 장치를 먼저 열고 그 다음에 ffmpeg 를 띄운다. 따라서 소리가 항상 먼저 시작한다. 측정값은 다음과 같다.
t_start->video 0.175
t_start->mic 0.025
mic - video -0.150 (음수 = 소리가 먼저. 1판은 이 값을 버렸다)
결과 파일에서 소리는 영상보다 0.15초 앞서 재생된다. 1판의 시험이 이것을 잡지 못한 이유는 길이만 쟀기 때문이다. 길이는 -shortest 가 맞추므로 항상 통과한다.
고침
세 가지를 바꿨다. 첫째, 음수면 소리 앞머리를 그만큼 잘라낸다(atrim). 둘째, 양수면 -itsoffset 대신 adelay 를 쓴다. amix 는 늦게 시작하는 입력 앞을 무음으로 채워 주지 않으므로 -itsoffset 은 섞을 때 어긋난다. 셋째, 결과 길이는 영상 길이로 확정한다. 영상 프레임 수 ÷ FPS 를 알고 있으니 apad=whole_dur 로 채우고 atrim=end 로 자른다.
def _audio_chain(idx, off, tag):
chain = f"[{idx}:a]aresample=48000"
if off > 0.0005: chain += f",adelay={int(round(off * 1000))}:all=1"
elif off < -0.0005: chain += f",atrim=start={-off:.3f},asetpts=PTS-STARTPTS"
return chain + f"[{tag}]"
fit = f",apad=whole_dur={duration:.3f},atrim=end={duration:.3f}"
고치는 중에 ffmpeg 가 끝나지 않던 문제
처음에는 apad(무한)와 -shortest 를 함께 썼다. 영상을 다시 인코딩할 때는 동작하는 조합이지만 -c:v copy 와 함께 쓰면 ffmpeg 가 끝나지 않았다. 2분을 기다린 뒤 프로세스를 죽였다. 영상 길이를 숫자로 알고 있으므로 whole_dur 로 끊는 쪽이 맞다.
시험
길이가 아니라 소리와 영상의 맞춤을 재는 시험을 넣었다. 1.0초에 흰 프레임 3장이 있는 영상과, 소리 시계로 1.15초에 삐 소리가 있는 WAV(0.15초 일찍 켜진 상황)를 만들어 합친 뒤, 결과에서 삐 소리의 시작과 섬광의 시작을 따로 잰다.
[4] 영상·소리 맞춤 (소리가 0.15초 먼저 시작)
삐 시작 1.000s / 섬광 시작 1.00s / 길이 2.00s
ok 삐 소리가 1.00초에
ok 섬광이 1.00초에
ok 길이 = 영상 길이 2.0
3. 결함 2. 버전표시 시각
버전표시는 세션 시계(_t_start)로 쟀다. 파일의 0초는 첫 프레임을 보낸 순간이라 그보다 0.02~0.5초 뒤다. 플레이어에서 버전표시 시각으로 가면 그만큼 앞이다. 마무리할 때 media_start() − _t_start 만큼 빼서 적는다. 녹음 모드는 가장 먼저 블록이 온 시각이 기준이다. 두 소리 원천의 시작 차이도 같은 기준으로 adelay 한다.
4. 결함 3. 합치는 중에 다시 시작
정지를 누르면 화면의 단추는 잠기지만 단축키는 살아 있다. 그 사이에 Ctrl+Shift+R 을 누르면 session.running 이 이미 False 라 새 세션이 만들어지고, 조금 뒤 앞 세션의 마무리 신호가 self.session = None 을 해 버린다. 새 세션은 돌고 있는데 화면에는 그 세션이 없으므로 정지할 수 없다.
finalizing 을 따로 두고, 그것이 남아 있으면 시작을 거부하고 상태줄에 이유를 쓴다. 마무리 신호는 자기 세션이 아닌 것을 지우지 않는다. realtest 에서 정지 직후 start() 를 다시 불러 확인한다.
5. 보완
5-1. 단축키
Ctrl+Shift+R 은 Chrome·Edge 의 강력 새로고침이다. 전역 단축키는 다른 프로그램에 등록된 같은 조합보다 우선하므로, 브라우저에서 새로고침을 누를 때마다 녹화가 켜진다. Ctrl+Alt+R/P/M 으로 바꾸고 settings.json 에서 고칠 수 있게 했다. "F9", "Ctrl+Shift+F12" 같은 글을 읽어 RegisterHotKey 의 modifier·vk 로 바꾸는 parse() 를 넣었다.
5-2. 영역: 잘라내기와 미리보기
영역을 화면 밖까지 끌면 mss 가 예외를 낸다. 가상 데스크톱 사각형으로 잘라낸다. 그리고 고른 직후 그 자리를 한 번 찍어 창 안에 작은 그림으로 표시한다. Windows 배율 125%·150% 에서 좌표가 어긋나면 이 그림이 어긋난 자리를 보여 줄 것이다. 배율 문제를 클라우드에서는 재현할 수 없으니, 사용자가 눈으로 확인할 자리를 만든 것이다.

5-3. 출력 크기와 커서
4K 화면은 프레임 하나가 33MB 다. 30fps 면 초당 1GB 를 파이프로 넘기고 libx264 가 그것을 따라가야 한다. 출력 크기 50%·75% 설정을 두어 ffmpeg 의 scale 로 줄인다. 커서는 mss 가 찍지 않으므로 Windows GetCursorPos 위치에 화살표 하나를 numpy 로 덧그린다. 실제 커서 그림을 뽑는 대신 흰 테두리·검은 속의 화살표 하나로 통일했다.

6. 그 밖에 고친 것
- 일시정지 표시의 ⏸ 글리프가 Consolas 에 없어 네모로 나왔다. 글리프 대신 숫자 색을 회색으로 바꿔 표시한다.
- 품질 콤보가 잘려 「보통 (CRF」까지만 보였다. 화질 묶음을 두 줄로 나눴다.
- 녹화 모드와 녹음 모드가 장치 선택을 한 자리에 저장해 모드를 바꾸면 선택이 사라졌다. 모드마다 따로 저장한다.
- 창을 닫으면 트레이로 내려가는데 녹화가 계속되는지 알 수 없었다. 트레이 알림을 한 번 띄운다.
- 시험 스크립트가 사용자의 설정 파일(저장 폴더)을 덮어썼다. 시험은 임시 설정 파일을 쓴다.
- capture.py 의
processEvents()가 다음 단계 타이머를 먼저 돌려 한 단계 앞선 화면이 찍혔다. 그 호출을 제거했다.
7. 시험 결과
enginetest [1] 파일 이름 · [2] 녹화 · [3] 녹음 · [4] 맞춤 · [5] 커서·크기 · [6] 표식 전부 통과
realtest 화면 밖 영역 잘라냄 · 미리보기 · 시작 · 표식 · 일시정지 · 재개 · 합치는 중 재시작 차단
· 길이 = 경과 시간 · 정지 화면 서로 다름 · 세션 정리 전부 통과

8. exe 를 클라우드에서 만들다
1판은 exe 를 사용자 PC 의 run0.bat 에 맡겼다. 이번에는 클라우드에서 만들어 넣는다. wine 9.0 에 64비트 Windows Python 3.11.9(NuGet 배포본)를 올렸다. 그 위에 PySide6·mss·sounddevice·numpy·imageio-ffmpeg·PyInstaller 를 Windows 휠로 설치했다. 막힌 곳이 둘이었다.
8-1. icuuc.dll 이 없다
Qt6Core.dll 이 icuuc.dll 을 정적으로 끌어온다. Windows 10 부터 System32 에 들어 있는 시스템 ICU 라 실제 PC 에서는 문제가 없지만 wine 에는 없다. PyInstaller 의 PySide6 훅은 QtCore 를 실제로 import 해 경로를 묻기 때문에 이것이 안 뜨면 빌드가 깨진다. Qt6Core 가 쓰는 함수를 objdump 로 세어 보니 ucnv_* 스무 개다. 그 이름만 내보내고 전부 실패 값을 돌려주는 대역 DLL 을 mingw 로 만들어 wine 의 system32 에 넣었다. Qt 는 ICU 코덱이 없으면 자체 코덱으로 간다.
API void* ucnv_open(const char* n, UErrorCode* e){ if(e) *e = 4; return NULL; }
API int ucnv_countAvailable(void){ return 0; }
... /* 20개, 빌드 전용. 배포본에는 안 들어간다 */
8-2. numpy 2.4 가 wine 에서 안 뜬다
ucrtbase.dll.crealf 가 wine 9.0 에 구현돼 있지 않다. 빌드 환경만 numpy 2.0.2 로 내렸다. 프로그램의 요구는 1.26 이상이라 상관없다.
8-3. 두 벌 들어간 ffmpeg
첫 빌드가 273MB 였다. PyInstaller 훅이 imageio-ffmpeg 의 바이너리(84MB)를 넣고, build.bat 이 exe 옆에 또 복사했다. 스펙에서 훅이 넣는 것을 빼고 _internal/ffmpeg.exe 한 벌만 두었다. 안 쓰는 Qt 파일(소프트웨어 OpenGL 20MB, 번역 6.7MB)도 뺐다. 결과는 189MB 다.
8-4. 한글 파일 이름
Windows Python 으로 enginetest 를 돌리니 회의.mic.tmp.wav 를 못 열었다. wine 의 win32 파일계통이 한글 경로를 못 받는 것이라 실제 Windows 에서는 없는 일이지만, 덕분에 구조를 고쳤다. 임시 파일은 gr_시각_pid.* 같은 ASCII 이름으로 쓰고 마지막에 os.replace 로 최종 이름을 준다. 그것마저 안 되면 주석을 뺀 이름으로 물러선다. ffmpeg 와 wave 에 한글 경로를 직접 주는 자리가 없어졌다.
8-5. exe 로 실제 녹화
Windows Python 으로 enginetest 6묶음과 realtest 20항목이 전부 통과했다. 빌드된 exe 는 가상 화면에 띄워 xdotool 로 시작·커서 이동·버전표시·정지를 눌러 확인했다. 5.93초 MP4 와 버전표시 파일이 나왔고, 정지 화면에 덧그린 커서 화살표가 찍혀 있다.

9. 남은 일
- Windows 실기. exe 를 바로 실행해 WASAPI loopback 장치가 잡히는지와 배율 화면의 영역 미리보기가 맞는지 확인한다. 다시 빌드하려면 run1.bat 을 쓴다.
- 커서 위치는 Windows GetCursorPos 로 얻는다. wine 에서는 잡혔고, 실제 PC 의 배율 화면에서 자리가 맞는지 확인한다.