← 과제 목록

[과제] 3주차) ThorVG 활용사례 조사 이주연

@juyonLee00
  • #과제
목차

어떤 사례를 조사할지 고민해보다가 FlowMux Terminal이라는 Rust 기반 터미널, 에이전트 작업 관리 애플리케이션에도 적용된다는 것을 알았습니다. 그래서 해당 프로그램에 어떤 구조로 붙어있는지 알아보고 싶어서 해당 레포를 선택해 보았습니다.

목차


1. FlowMux Terminal?

FlowMux Terminal은 여러 AI 코딩 에이전트의 터미널 세션, 브라우저, 파일과 작업 상태를 하나의 데스크롭 작업 공간에서 관리하는 애니메이션입니다. 주로 CLI 기반 코딩 에이전트를 중심으로 하며 주 언어는 Rust, GUI는 GTK4 기반입니다. 해당 프로그램은 여러 AI 코딩 에이전트를 하나의 작업 공간에서 관리하기 용이하게 하기 위해 만들어졌습니다.

https://github.com/flowmux-ai/flowmux-terminal



2. ThorVG를 적용한 방법

요약하면 FlowMux 내 GTK4가 표시할 이미지 데이터를 만들기 위해 추가한 것입니다. GTK4는 이미지 로딩 오류나 특수 포맷을 처리하기 위해서는 별도의 이미지 로딩 프레임워크로 이미지를 읽어온 뒤 그 결과를 GTK가 표시 가능한 픽셀 데이터로 변환해야 하기 때문에 ThorVG를 추가한 것입니다. 또한 ThorVG를 통해 여러 포맷을 하나의 출력 경로로 통일 가능해서 GTK 출력 코드는 입력 파일 형식과 무관하게 GdkMemoryTexture를 표시할 수 있는 것도 또다른 이유입니다. 그러면 FlowMux 내 ThorVG의 적합성과 어떻게 적용되었는지를 살펴보도록 하겠습니다.


첫 번째는 Lottie Animation을 통해 Lottie를 처리하기 위함입니다.

먼저 Lottie 데이터를 ThorVG에 넘기는 방법입니다. FlowMux는 Lottie 파일을 열 때 ThorVG Animation을 생성하고, Animation이 관리하는 Picture을 가져옵니다. 이 때 Lottie를 ThorVG의 Animation 객체로 관리합니다. 이후에 Lottie 데이터를 읽고 MIME type을 lottie+json으로 지정하고, tvg_picture_load_data()를 호출해 Lottie JSON을 ThorVG Animation의 Picture에 전달합니다.


if let Err(err) = check(
        unsafe { tvg::tvg_canvas_add(canvas.raw, picture) },
        "add image",
    ) {
        unsafe {
            tvg::tvg_paint_rel(picture);
        }
        return Err(err);
    }

//중략

let animation = unsafe { tvg::tvg_animation_new() };
let picture = unsafe { tvg::tvg_animation_get_picture(animation) };

//중략

let data = read_lottie_data(path)?; let mimetype = CString::new("lottie+json").expect("static string has no NUL");

let load = unsafe {
            tvg::tvg_picture_load_data(
                picture,
                data.as_ptr().cast(),
                data.len() as u32,
                mimetype.as_ptr(),
                std::ptr::null(),
                true,
            )
        };


FlowMux는 프레임 타이밍을 만들기 위해 ThorVG에서 전체 프레임 수와 애니메이션 시간을 조회합니다. 이 때 ThorVG는 Lottie의 전체 프레임 정보, Lottie 전체 재생 시간을 기반으로 현재 프레임의 픽셀을 생성합니다. 초기 렌더링에서는 0번 프레임을 지정하고, 재생 중에는 current_frame을 증가시킨 뒤 다시 ThorVG에 전달합니다.

let total_frames = animation_float(animation, tvg::tvg_animation_get_total_frame)

let duration = animation_float(animation, tvg::tvg_animation_get_duration)

//생략

unsafe { tvg::tvg_animation_set_frame(animation, 0.0) }

let set = unsafe { tvg::tvg_animation_set_frame(self.animation, self.current_frame) };

즉, FlowMux는 현재 프레임 번호를 결정하고 ThorVG는 FlowMux에게서 전달받은 프레임 번호에서 해당 프레임 데이터를 기반으로 픽셀을 만들어냅니다.


다음 프레임을 요청할 때 FlowMux는 GTK의 Glib Timer를 사용해 다음 프레임을 요청합니다. Timer callback에서는 advance()를 호출하고, Animation에 현재의 프레임을 반영하고 SwCanvas에 픽셀을 생성합니다. 이렇게 렌더링이 성공하면 FlowMux와 GDK가 픽셀을 Texture로 변환하고, 이것을 GtkPicture에 설정합니다.

glib::timeout_add_local()

ThorvgAnimationRenderer::advance()

tvg_animation_set_frame()

ThorvgCanvas::render()

texture_from_frame()

GtkPicture::set_paintable()


두 번째는 SVG와 정적 이미지를 렌더링하는 과정입니다.

SVG, PNG, JPEG, WebP를 ThorVG의 native file loader로 처리합니다. 코드에서 Picture을 생성하고 파일 경로를 ThorVG에 전달하여 FlowMux가 여러 이미지 파일을 직접 파싱하지 않고 ThorVG Loader에 맡긴다는 것을 확인할 수 있습니다.

let picture = unsafe { tvg::tvg_picture_new() };

let load = unsafe { tvg::tvg_picture_load(picture, path.as_ptr()) };

Viewer에 표시하기 위해 이미지의 표시 크기를 설정해야 합니다. 그래서 Picture의 원본 크기를 가져온 뒤 Viewer의 최대 크기에 맞는 출력 크기를 계산합니다. 그리고 Picture을 Canvas에 추가해 이미지가 렌더링될 환경을 구성합니다.

tvg_picture_new()
tvg_picture_load()
tvg_picture_set_size()
tvg_canvas_add()
ThorvgCanvas::render()


FlowMux는 파일 확장자에 따라 이미지 형식을 결정하는데, 코드에서 구성된 enum은 다음과 같습니다. NativeRasterrenter_native(), Rasterrender_raster(), Svgrender_native(), LottieThorvgAnimationRenderer::new() 를 호출합니다. 여기서 PNG, JPEG, WebP는 ThorVG native loader을 먼저 이용합니다. ThorVG Loader이 거부하면 Rust의 image crate를 사용합니다. ThorVG가 지원하지 않는 래스터 형식은 Rust에서 RGBA로 deocde한 후 tvg_picture_load_raw()를 이용해 다시 ThorVG Picture에 전달합니다.

enum ImageKind {
	Raster,
	NativeRaster,
	Svg,
	Lottie,
}

match image_kind(path) {
    ImageKind::Raster => render_raster(path).map(ViewerContent::Static),
    ImageKind::Svg => render_native(path).map(ViewerContent::Static),
    ImageKind::Lottie => ThorvgAnimationRenderer::new(path)
        .map(|renderer| ViewerContent::Animated(Rc::new(RefCell::new(renderer)))),
    }


다음은 생성된 픽셀 데이터를 저장할 버퍼입니다. ThorvgCanvas는 Canvas handle과 함께 픽셀을 저장할 Vec<u32>를 보유합니다.

struct ThorvgCanvas {
	raw: tvg::Tvg_Canvas,
	width: u32,
	height: u32, 
	buffer: Vec<u32>,
}

버퍼를 ThorVG target으로 지정하기 위해 tvg_swcanvas_set_target()을 호출하고, Vec<u32>의 메모리 주소를 전달합니다. 이 과정을 통해 FlowMux가 일반 시스템 메모리를 target으로 제공한다는 사실을 확인할 수 있습니다.

fn new(width: u32, height: u32) -> Result<Self, String> {
    let raw =
        unsafe { tvg::tvg_swcanvas_create(tvg::Tvg_Engine_Option::TVG_ENGINE_OPTION_NONE) };
    //생략
    
unsafe {
	tvg::tvg_swcanvas_set_target(
        canvas.raw,
        canvas.buffer.as_mut_ptr(),
        width,
        width,
        height,
        tvg::Tvg_Colorspace::TVG_COLORSPACE_ABGR8888,
    )

위에서 설정한 Canvas 위에 이미지를 렌더링하기 위해 다음과 같은 순서를 거칩니다. 여기서 sync() 이후에는 ThorVG가 Vec<u32> 타겟에 렌더링을 완료한 상태가 됩니다.

tvg_canvas_update()
tvg_canvas_draw()
tvg_canvas_sync()


다음은 ThorVG 렌더링 결과를 GTK Texture로 변환합니다. 렌더링 결과는 RenderedFrame에 저장되고, 각 픽셀을 little-endian byte 배열로 변환됩니다. 다음은 glib::Bytes를 만들고 gdk::MemoryTexture::new()를 호출해 Texture를 생성합니다. 그리고 생성된 Texture를 GtkPicture에 전달합니다. 여기서 GtkPicture에 Paintable을 표시하고 GdkMemoryTextureGdkPaintable을 구현하기 때문에 이렇게 구현하였습니다.

bytes.extend_from_slice(&pixel.to_le_bytes());

let bytes = glib::Bytes::from_owned(bytes);
gdk::MemoryTexture::new(
    frame.width as i32,
    frame.height as i32,
    gdk::MemoryFormat::R8g8b8a8Premultiplied,
    &bytes,
    (frame.width * 4) as usize,
	    )

//생략
picture.set_paintable(Some(&texture_from_frame(&frame)));


3. 후기

다른 사람이 작성한 코드에서 왜 이 코드를 이렇게 사용했는지, 이렇게 함으로써 실행 과정에 이득은 뭔지 고민하면서 생각을 많이 해보게 되었습니다. 역시 코드는 다른 사람이 이해하기 좋게 짜는 것도 중요하기 때문에 코드를 보면서 나는 코드를 목적에 맞게 잘 짜고 있는지를 돌아보게 된 것 같기도 하네요. Cpp은 아니지만 Rust 언어를 오랜만에 보면서 재미있었습니다.

댓글

Discussion 원문