3P by GN⁺ | ★ favorite | 댓글 1개
  • 화상회의 중 reMarkable 2 스케치를 공유하던 도구가 노트북 로컬 서비스 없이 열리도록 바뀌어, 발표자가 브라우저만으로 즉석 스트리밍을 시작할 수 있게 됨
  • 새 구조는 reMarkable 내부 HTTP 서버와 브라우저의 JavaScript 클라이언트가 원시 이미지를 받아 canvas에 그리는 방식으로 단순화됨
  • WebSocket 대체안은 동작했지만 iOS 문제와 서버 오버헤드가 남아, 고정 해상도 이미지를 http.ResponseWriter로 계속 쓰고 fetch 스트림으로 읽는 원시 스트림 방식으로 정리됨
  • 1872x1404 원시 프레임은 약 2.5MB라서, 펌웨어 3.3 이후 16색 값을 uint4로 패킹해 50% 줄이고 RLE로 평균 약 200KB 전송량까지 낮춤
  • /dev/input/event* 감시로 입력이 없을 때는 새 프레임 전송을 멈추며, 연결된 클라이언트가 있어도 CPU 사용량은 0으로 떨어지고 필기 중에는 약 10% CPU로 동작함

기존 도구가 불편했던 이유

  • 2021년에 만든 reMarkable 스트리밍 도구는 화상회의 중 스케치를 공유하는 데 쓰였고, 브라우저 탭만 공유하면 되어 발표 내용에 집중하기 쉬웠음
  • 기존 구현은 세 구성 요소로 나뉨
    • 서버: reMarkable 장치에서 실행되며 현재 화면의 원시 이미지를 노출함
    • 클라이언트: 노트북에서 서버의 원시 이미지를 가져와 브라우저가 볼 수 있는 형식으로 처리함
    • 렌더러: 브라우저나 VLC처럼 HTTP MJPEG 스트림을 읽어 화면에 표시함
  • 장치 CPU 사용을 줄이기 위해 서버는 클라이언트가 연결됐을 때만 이미지를 추출했고, 통신에는 gRPC가 사용됨
  • 노트북 클라이언트는 이미지를 반복해서 가져오고 JPEG로 인코딩한 뒤 MJPEG 스트림을 HTTP 서비스로 제공함
  • 발표 환경에서는 reMarkable 주소, 클라이언트 실행 권한, 렌더러가 알아야 하는 클라이언트 IP 같은 네트워크 구성이 부담이 됨

브라우저만으로 여는 새 구조

  • 새 목표는 어떤 브라우저에서든 reMarkable 주소만 입력하면 스트림에 접근하게 만드는 것임
  • 별도 노트북 클라이언트를 없애고, reMarkable의 서버 컴포넌트 안에 HTTP 서버를 넣는 구조로 바뀜
  • 브라우저에서 실행할 클라이언트는 JavaScript 또는 WASM 형태가 필요했음
    • 처음에는 Go 경험을 활용하기 위해 WASM 컴파일을 검토했지만, 상당한 수정이 필요한 제한 때문에 포기함
    • 두 번째 버전의 클라이언트는 최종적으로 JavaScript로 작성됨
  • JavaScript 코드 조각과 설명을 얻는 과정에서 ChatGPT를 사용했지만, 원하는 해법의 방향은 직접 정함

canvas 렌더링 방식

  • MJPEG 스트림에서 벗어나기 위해 브라우저의 이미지 조작 기본 요소인 canvas 를 사용함
  • reMarkable에서 받은 원시 이미지를 Uint8Array로 읽고, ImageData의 RGBA 픽셀 데이터에 같은 값을 R/G/B로 넣은 뒤 알파 값을 255로 설정해 표시함
  • 반응형 표시, 회전, 색상화 가능성을 위해 고정 크기의 fixedCanvas를 숨긴 상태로 유지함
  • 표시용 캔버스에는 drawImage로 숨겨진 캔버스 내용을 복사함
  • 브라우저 창 크기가 바뀌면 컨테이너 크기와 1872/1404 비율을 기준으로 표시 캔버스의 폭과 높이를 조정함

WebSocket을 버리고 원시 스트림으로 전환

  • gRPC는 웹 개발에서 일반적인 선택이 아니어서, 첫 번째 대체 구현은 WebSocket을 통신과 캡슐화 수단으로 사용함
  • WebSocket 메시지는 원시 이미지를 담고, 브라우저 클라이언트는 메시지를 받을 때마다 캔버스를 업데이트해 스트리밍처럼 보이게 함
  • 이 방식은 서버 측 메시지 전송 빈도를 조절해 reMarkable의 메모리와 CPU 부하를 관리할 수 있었음
  • 하지만 iOS에서 문제가 있었고, 서버 측 WebSocket 구현의 오버헤드도 제어하기 어려웠음
  • 최종 구조는 캡슐화를 없애고, 고정된 이미지 크기를 이용해 원시 이미지를 네트워크로 직접 전송함
    • Go 서버는 http.ResponseWriter에 이미지를 반복해서 Write
    • 브라우저 클라이언트는 fetch('/stream')ReadableStream을 읽고, 들어온 청크를 캔버스 데이터로 반영함

전송량 최적화

  • reMarkable 2의 원시 이미지는 해상도 1872x1404 기준 약 2.5MB이며, 이 데이터가 프레임마다 전송돼야 함
  • 펌웨어 3.3 이후 reMarkable의 16가지 색상 값은 uint8 대신 uint4 배열로 표현할 수 있음
    • Go와 JavaScript에는 네이티브 uint4 타입이 없음
    • 두 픽셀 값을 하나의 uint8 바이트에 저장하는 방식으로 우회함
    • Go에서는 두 uint4 값을 상위 4비트와 하위 4비트로 패킹하고, JavaScript에서는 이를 다시 언패킹함
    • 이 표현은 데이터량을 50% 줄일 수 있음
  • 추가 압축에는 Run Length Encoding (RLE)을 사용함
    • RLE는 같은 픽셀 값이 연속되는 횟수와 값을 함께 전송하는 단순한 알고리듬임
    • 예시 0 0 0 0 0 0 1 1 1 0 0 0 06 0 3 1 4 0으로 표현됨
  • 카운트 값은 최대 1872*1404까지 커질 수 있어 uint64 같은 타입이 필요할 수 있고, 경우에 따라 압축 결과가 원본보다 커질 위험이 있음
  • 이를 피하기 위해 카운트 길이를 15로 제한하고, 카운트와 픽셀 값을 한 바이트에 담는 균형점을 택함
  • RLE 구현은 Go의 io.Writer처럼 동작해 재사용 가능하며, 필요한 경우 RLE를 두 번 적용할 수도 있지만 현재는 필요하지 않았음
  • 패킹과 RLE 이후 평균 전송량은 약 200KB 수준임

변경이 있을 때만 프레임 전송

  • 마지막 최적화는 화면이 바뀔 때만 새 프레임을 보내는 방식임
  • 변경 여부를 체크섬으로 계산하면 CPU 부담이 커질 수 있음
  • reMarkable은 Linux 기반이라 펜이나 터치 입력이 /dev/input/event*로 전달됨
  • goroutine이 이 입력 이벤트를 감시하고, 필요한 경우에만 이미지를 전송함
  • 이벤트가 없으면 클라이언트가 연결된 상태에서도 CPU 사용량은 0으로 떨어짐
  • 필기 중 CPU 사용량은 약 10% 수준임

펌웨어 변경과 유지보수 부담

  • 이 애플리케이션은 해킹에 기반하며, 핵심 과제는 이미지를 가져오는 인터페이스와 클라이언트/렌더러를 효과적으로 분리하는 것임
  • 이전 구현은 protobuf 정의를 통해 클라이언트와 서버가 완전히 분리돼 있었음
  • reMarkable 3.3 펌웨어는 도구를 깨뜨렸고, 관련 내용은 GitHub issue 36에 있음
    • 당시 수정은 클라이언트 컴포넌트에만 영향을 줌
  • 펌웨어 3.6도 GitHub issue 58에 따르면 깨지는 변경을 가져올 수 있음
    • 이 경우 더 넓은 수정이 필요할 수 있음
    • 다만 클라이언트가 서버에 통합된 자체 포함 구조라 장치 업데이트는 더 단순해질 수 있음
  • 애플리케이션과 소스 코드는 github.com/owulveryck/goMarkableStream에서 제공됨

댓글과 토론

Hacker News 의견들
  • reMarkable 태블릿 화면을 노트북으로 스트리밍하던 2021년 도구를 다시 다듬어 공개했고, 새 글에서는 아키텍처, 구성 요소, 사용자 경험을 개선한 과정을 깊게 다뤘음
    제품 관리자 관점에서 사용자가 어떻게 느끼는지 보면서 활성화 과정을 단순화했고, 로컬 서비스 없이 동작하게 만들고 네트워크 사용량도 최적화함

    • 멋진 프로젝트처럼 보임. Ctrl-C./goMarkableStream으로 재시작하니 어느 정도 동작했지만, 여전히 waiting for reMarkable screen이 자주 뜨며 서비스가 불안정함
      reMarkable2를 3.5.2.1807로 올린 뒤 설치했는데, 노트북·시트·책 위에 그려도 반응이 없었고 read /dev/input/event2: file already closed, read /dev/input/event1: file already closed 같은 로그가 가끔 보임
      https://192.168.8.143:2001/https://10.11.99.1:2001/ 모두 HTML과 캔버스를 제공하고, Chrome·Firefox·Brave에서도 시도했음
      스트림당 브라우저 하나, IP 하나 제한처럼 보이지만, 어떤 주소와 브라우저를 써도 waiting for reMarkable screen이 뜰 때가 있음. nohup ./goMarkableStream & 후 PuTTY를 닫고 클라이언트를 재시작하자 모든 브라우저가 같은 상태가 됐고, https://10.11.99.1:2001/stream을 보면 too many requests가 반환됨. 스트림을 어떻게 재시작해야 할지 궁금함
    • USB 연결로도 구현할 수 있을지 궁금함
  • 대안으로 SuperNote를 매우 만족하며 쓰고 있음. 화면 미러링이 가능해서 회의 중 다이어그램을 빠르게 그리기에 아주 좋음
    단점은 SuperNote가 작은 웹 서버를 띄우고 Firefox로 접속하는 방식이라, 노트북과 SuperNote가 같은 네트워크에 있어야 한다는 것임. 홈오피스에서는 문제가 없지만 회사 정책상 막힐 수 있음
    RM2든 SuperNote든 펜과 종이로 아이디어를 쓰는 걸 좋아하는 사람에게는 훌륭한 도구이고, 앱이나 텍스트 문서와는 느낌이 꽤 다르며 노트에 낙서도 할 수 있음
    [0]: https://supernote.com/

    • Onyx Boox Note도 잘 동작하고, 5년 넘게 지난 뒤에도 업데이트를 내고 있음
      다만 구매할 때는 GPL 위반을 감수해야 함. 완전히 Android 기반인데도 운영체제 소스를 공개하지 않음
    • 전자책 리더 겸 노트 필기용 전자잉크 태블릿을 찾고 있었는데 추천이 도움이 됨. Remarkable 2와 Boox 사이에서 고민 중이었고, SuperNote의 소프트웨어 업데이트 경험이 어떤지 궁금함
      앞으로 3~5년 동안 기능 업데이트나 최소한 보안 업데이트라도 지원받지 못할 기기를 사게 될까 봐 걱정됨
    • 기억으로는 SuperNote가 함께 배포하는 소프트웨어의 GPL을 준수하지 않았는데, 입장이 바뀌었는지 궁금함
    • 아직 “같은 네트워크에 있어야 하는” 문제는 겪지 않았지만, 네이티브 Ngrok 기능을 넣는 건 몇 분이면 되는 간단한 작업으로 보임. 그러면 인터넷을 통해 스트리밍할 수 있음
    • SuperNote의 필기감이 RM2와 비교해 어떤지 궁금함
  • HTML 캔버스 렌더링은 여기서 설명한 것처럼 형식화 배열을 쓰면 더 빨라질 수 있음: https://hacks.mozilla.org/2011/12/faster-canvas-pixel-manipu...

    • 고마움, 한번 살펴보겠음
  • 이런 글이야말로 여기서 보고 싶은 콘텐츠임. ChatGPT가 잘 모르는 영역의 문제를 배우고 해결하는 데 어떻게 도움을 줬는지가 좋았고, “내가 개발자이고 ChatGPT가 코더였다”는 표현에 공감함
    단순함은 실제로 복잡하다는 말도 맞음

  • JPEG를 고른 건 MJPEG로 바꾸기 쉽고, 지원하는 쪽에 넘기면 디코딩을 공짜처럼 처리할 수 있어서였을 것 같음. 다만 그게 reMarkable CPU에 큰 부담을 주는 요인일 수 있음
    JPEG는 사진에 더 적합하지만 reMarkable 화면은 일러스트에 가깝고, 게다가 흑백 계열임. PNG 같은 다른 일반 이미지 형식이나 단순한 RLE 압축만 써도 CPU 부담이 더 낮을 것 같음

    • 엄밀히 말하면 reMarkable은 단색이 아니라 회색조이고, 기억상 16단계 회색을 지원함. 동반 앱에서는 펜은 파랑·빨강, 형광펜은 노랑·초록처럼 보이는 컬러 잉크도 있음
      그리고 독자 파일 형식은 비트맵이 아니라 필기 입력 기반임
    • 처음에 JPEG를 고른 이유는 맞음. 다만 그래서 클라이언트/서버 구조를 택했고, 인코딩은 태블릿이 아니라 클라이언트인 노트북에서 수행했음
      프로파일링해 보니 CPU 대부분은 선로를 통한 데이터 전송에 쓰였고, 그래서 압축을 넣었음. 지금은 CPU 사용량이 낮음
  • 프레임버퍼의 변경된 영역만 전송하는 방식은 검토해 봤는지 궁금함. 그러면 데이터 전송률을 크게 줄일 수 있음: https://github.com/pl-semiotics/mxc_epdc_fb_damage
    rM VNC 프로젝트도 그렇게 하지만, 클라이언트 쪽 소프트웨어가 필요 없는 이 앱의 사용자 경험이 더 마음에 듦

    • 그 방식은 기기에서 어느 정도 분석이 필요하다는 게 문제이고, 코드는 최대한 덜 침습적으로 두고 싶음. 저렴하게 처리할 방법이 있는지 살펴보겠음
  • 정말 멋지고 ReMarkable 2를 좋아하고 싶지만, 안전하지 않은 기기라는 입장 때문에 쉽지 않음: https://support.remarkable.com/s/article/Does-reMarkable-off...

    • 링크 내용은 이 기기가 대체하려는 종이와 같은 수준의 물리적 보안만 갖는다는 뜻임. 즉 누군가 기기에 접근하면 읽을 수 있음
      네트워크에 연결된 불안전한 기기라고 할 때 흔히 떠올리는, 알려진 소프트웨어 취약점 얘기는 아님
    • 비공식적으로 gocryptfs 기반 홈 디렉터리 암호화가 가능함: https://github.com/RedTeamPentesting/remarkable-encryption
    • 소프트웨어도 현재로서는 매우 제한적임. 공식적으로 마켓플레이스나 확장 기능을 기기에서 쓸 수 있게 해주지 않는 점이 아쉬움
    • 전체 디스크 암호화를 제공하는 전자책 리더가 있나?
  • “처음에는 클라이언트를 WASM으로 컴파일하려 했다. Go 개발 경험을 활용할 수 있어 유망해 보였지만, 상당한 수정이 필요한 여러 한계에 부딪혔다”는 부분을 더 읽어보고 싶음

    • 핵심 문제는 gRPC 라이브러리였고, 현재 지원이 매우 제한적이었음. 또 Go에서 JPEG 압축은 느리고 CPU를 많이 씀
      설령 MJPEG 스트림을 생성하더라도 어떻게 표시할지도 문제였음. 캔버스 방식을 생각했지만, WASM과 JS 사이에서 큰 복사를 하지 않고는 캔버스 백엔드에 접근하기 어려웠고, 크기도 2.5MB였음
      결국 WASM에 의존하면 이미지 회전처럼 JS에서 기본으로 접근 가능한 이미지 기본 연산을 많이 직접 구현해야 할 것 같았음
  • 이 도구가 내장 스트리밍, 즉 화면 공유 기능과 어떻게 다른지 궁금함

    • 내장 기능을 쓰려면 데스크톱 앱을 설치해야 하고, 알기로는 Linux 버전이 없음
      https://support.remarkable.com/s/article/Screen-Share
      글의 해결책은 충분한 기능을 갖춘 브라우저만 있으면 Linux에서도 동작하는 것으로 보임
    • 가장 큰 차이는 이제 클라이언트 설치가 필요 없다는 점임. 브라우저에 reMarkable 주소만 입력하면 내용을 볼 수 있음
    • 이미 이 기능이 된다고 생각했음. 화면 공유는 충분히 괜찮고, 라이브 스트리밍에도 쓰고 있음
  • reMarkable은 좋아하지만, 앞으로도 돈 낼 생각이 없는 구독보다 이런 스트리밍 기능에 집중해 줬으면 함