본문 바로가기
C.W.K.
Stream
Lesson 03 of 05 · published

네가 소유한 뷰에 렌더링해

~14 min · embed-engine, appkit, nsview, rendering

Level 0릴 입문자
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"libmpv 의 render/client API 로 AppKit NSView 안에 렌더링해."

영상을 보여주는 두 방식

embed 한 엔진은 어딘가엔 그려야 해. 게으른 방식은 엔진이 자기 창 열고 몰게 두는 거야. 시작은 빠른데 막다른 길이지. 그 창은 네 앱의 일부가 아니거든. 네 단축키가 거기 안 닿고, 전체화면 전환이 그거랑 싸우고, 온-비디오 오버레이는 얹힐 데가 없어. 결국 남의 TV 에 테이프로 붙인 리모컨 꼴이 돼.

맞는 방식은 libmpv 가 원래 그러라고 설계된 방식이야. render API 가 엔진을 네가 주는 표면에 그리게 해줘. Ashen Reel 은 AppKit NSView 를 내주고, 영상은 앱 자기 창 안에 그려져. responder chain 안에, 오버레이 아래에, 네가 통제하는 composition 의 일부로.

왜 '네 창 안' 이 모든 걸 바꾸나

영상이 네가 쥔 NSView 안에 사는 순간, 어려운 기능 한 무더기가 거의 공짜가 돼. seek 오버레이는 형제 뷰라서 위에 얹혀. 전체화면이랑 Spaces 는 그냥 평범한 창이라 잘 돌아가. 키보드 chord 는 responder chain 안이라 닿고. 더블클릭 전체화면이랑 스크롤 볼륨은 뷰가 이벤트를 받으니까 되고. 엔진이 자기 떨어진 창을 쥐고 있으면 그중 아무것도 안 돼.

엔진은 네가 쥔 표면에 embed 하고, 창을 소유하게는 절대 두지 마. 창은 네 앱의 정체성이 사는 데야. chrome 도, 입력도, 오버레이도. 디코더는 빌려도, 유리는 네가 쥐어.

render API 는 콜백으로 돌아. libmpv 가 자기 스레드에서 '새 프레임 준비됐어' 하고 신호하면, 넌 메인 스레드로 hop 해서 뷰 다시 그리라고 표시하고, AppKit 이 present 하라고 부르지. 단순하게 그리면 이런 모양이야.

libmpv 의 업데이트 콜백은 네 스레드가 아니라 libmpv 스레드에서 발화해. 메인 스레드 밖에서 AppKit 만지는 건 예약된 크래시고. 콜백이 할 일은 딱 하나, 메인 스레드로 hop 하는 거야. UI 는 전부 거기서 일어나고. 이 스레딩 이음매가 딱, 작고 잘 이해된 뷰 클래스에 격리해두고 싶은 종류야.

보상은 네이티브처럼 '느껴지는' 게 아니라 네이티브 '인' 플레이어야. 빌린 엔진이 Ashen Reel 자기 유리에 픽셀을 칠하고, 모든 창이랑 키랑 오버레이가 진짜 그런 AppKit 앱답게 돌아가거든.

Code

libmpv 렌더 출력을 NSView 에 호스팅 (단순화)·swift
import AppKit
import CLibMPV

/// The AppKit view libmpv renders into. Video lives INSIDE our window, so
/// overlays, full screen, and the responder chain all just work.
final class PlayerView: NSView {
    private var render: OpaquePointer?   // mpv_render_context (params elided)

    func attach(_ mpv: OpaquePointer) {
        mpv_render_context_create(&render, mpv, /* render params */ nil)

        // libmpv calls this from ITS thread when a frame is ready.
        // Our only job here: hop to main and ask AppKit to redraw.
        mpv_render_context_set_update_callback(render, { ctx in
            let view = Unmanaged<PlayerView>.fromOpaque(ctx!).takeUnretainedValue()
            DispatchQueue.main.async { view.needsDisplay = true }
        }, Unmanaged.passUnretained(self).toOpaque())
    }

    // On redraw, present the next frame into this view's backing surface.
    // (mpv_render_context_render call elided for brevity.)
}

External links

Exercise

자기 UI 표면을 달고 온 embed 컴포넌트를 떠올려봐. 웹뷰, 차트 위젯, 비디오 엘리먼트, 게임 엔진 캔버스 같은 거. 그게 네가 통제하는 컨테이너에 그렸어, 자기 표면을 쥐었어? 그게 네 컨테이너에 그리면 쉽고 안 그리면 어려워지는 기능 하나만 대봐. 보통 그 기능이, 네가 어떤 embedding 방식을 골랐어야 했는지 알려주는 신호야.
Hint
질문은 늘 하나야. '유리는 누가 쥐고 있나?' embed 한 게 자기 창이나 표면을 쥐면, 네 앱의 입력이랑 오버레이랑 레이아웃이 그거랑 싸워야 해. 네가 주는 표면에 그리면, 그 전부가 네 평범한 UI 프레임워크에서 공짜로 따라오고.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.