주성진·article·2026.09.24·8 min read·조회수389
"이 느낌으로 해 주세요"를 받았을 때, 레퍼런스 영상을 0.2초 단위로 쪼개 본 이야기
레퍼런스 영상 하나와 "이 느낌으로"라는 요청을 받았습니다. 감으로 맞추는 대신 영상을 0.2초 간격으로 쪼개서 순서와 타이밍을 숫자로 읽어 내고, Motion for React로 재현한 과정입니다.
사내 업무관리 시스템을 새로 만들면서 레퍼런스 영상을 하나 받았습니다. 화면이 열릴 때 요소들이 차례로 떠오르는 진입 모션이었고, 요청은 "이 느낌으로 해 주세요"였습니다.
이런 요청을 감으로 맞추기 시작하면 끝이 없습니다. "조금 더 빠르게", "아, 이번엔 너무 빠르다"를 몇 번이고 반복하게 되거든요. 저도 예전에 그렇게 하다가 하루를 날린 적이 있습니다. 그래서 이번엔 영상을 프레임 단위로 쪼개서 숫자로 읽어 보기로 했습니다. 이 글에서는 그 방법과, 읽어 낸 숫자를 코드로 옮긴 과정을 소개합니다.
1. 0.2초 간격으로 프레임 뽑기
FFmpeg의 fps 필터를 쓰면 초당 5장, 즉 0.2초 간격으로 프레임을 뽑을 수 있습니다.
mkdir -p frames
ffmpeg -i reference.mp4 -vf "fps=5,scale=480:-2" frames/%03d.png한 장씩 넘겨 보는 것보다 한 장에 모아서 보는 게 훨씬 빠릅니다. tile 필터를 쓰면 바로 콘택트 시트가 나옵니다.
ffmpeg -i reference.mp4 -vf "fps=5,scale=384:-2,tile=5x4" -frames:v 1 contact.png시간 표시를 같이 넣고 싶으면 파이썬으로 직접 붙여도 됩니다. 사내 레퍼런스 영상은 공개할 수 없어서, 이 블로그 캐릭터 영상을 같은 방법으로 0.2초 간격으로 뽑아 봤습니다.

from pathlib import Path
from PIL import Image, ImageDraw
frames = sorted(Path("frames").glob("*.png"))[:20]
w, h = Image.open(frames[0]).size
sheet = Image.new("RGB", (5 * w, 4 * h), "white")
for k, path in enumerate(frames):
im = Image.open(path).convert("RGB")
ImageDraw.Draw(im).text((8, 6), f"{k * 0.2:.1f}s", fill="red")
sheet.paste(im, ((k % 5) * w, (k // 5) * h))
sheet.save("contact.png")2. 시트를 보면서 숫자로 적기
콘택트 시트를 보면서 요소마다 "처음 보이기 시작한 시점"과 "자리를 잡은 시점"을 표로 적었습니다. 아래 수치는 정리 방식을 보여 드리려고 조금 단순화한 겁니다.
| 요소 | 시작 | 자리 잡음 | 메모 |
|---|---|---|---|
| 상단바 | 0.0s | 0.2s | 페이드만, 거의 안 움직임 |
| 페이지 타이틀 | 0.2s | 0.6s | 아래에서 위로, 블러가 걷힘 |
| KPI 카드 | 0.4s | 1.0s | 카드끼리 약 0.06초 간격 |
| 테이블 | 0.6s | 1.2s | 헤더가 먼저 |
| 테이블 행 | 0.8s | 1.6s | 행끼리 약 0.04초 간격, 살짝 튕김 |
이 표 하나로 대화가 달라졌습니다. "조금 더 빠르게" 대신 "카드 간격을 0.06초에서 0.05초로 줄여 볼게요"처럼 말할 수 있게 됐습니다. 디자이너와 같은 숫자를 보고 이야기하니 수정 횟수도 확실히 줄었습니다.
3. 하나의 타임라인으로 재현하기
요소마다 따로 delay를 주면 나중에 순서를 바꾸기가 어렵습니다. 하나를 바꾸면 뒤에 있는 걸 전부 다시 계산해야 하니까요. 그래서 부모가 순서와 간격만 정하는 하나의 타임라인으로 만들었습니다. Motion for React의 variants와 staggerChildren을 썼습니다.
import { motion, type Variants } from "motion/react";
const page: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.2 } }, // 상단바, 타이틀, 카드, 테이블 순서
};
const rise: Variants = {
hidden: { opacity: 0, y: 12, filter: "blur(6px)" },
show: {
opacity: 1, y: 0, filter: "blur(0px)",
transition: { type: "spring", stiffness: 260, damping: 26 },
},
};
const cards: Variants = { hidden: {}, show: { transition: { staggerChildren: 0.06 } } };
const rows: Variants = { hidden: {}, show: { transition: { staggerChildren: 0.04, delayChildren: 0.2 } } };
export function DashboardPage({ items }: { items: Task[] }) {
return (
<motion.main variants={page} initial="hidden" animate="show">
<motion.header variants={rise} data-motion="topbar">…</motion.header>
<motion.h1 variants={rise} data-motion="title">대시보드</motion.h1>
<motion.section variants={cards} data-motion="cards">
{kpis.map((k) => <motion.div key={k.id} variants={rise}><KpiCard {...k} /></motion.div>)}
</motion.section>
<motion.table variants={rows} data-motion="table">
<tbody>
{items.map((t) => <motion.tr key={t.id} variants={rise}>…</motion.tr>)}
</tbody>
</motion.table>
</motion.main>
);
}실제 프로젝트에서는 rise 같은 걸 Reveal 컴포넌트로, 숫자가 올라가는 효과를 RollingNumber로 빼서 motion 모듈로 묶었습니다. 화면을 만드는 쪽에서는 순서만 정하면 되게요.
4. 동작 줄이기도 챙겨야 합니다
모션이 불편한 사용자도 있습니다. OS의 "동작 줄이기" 설정을 따르도록 했는데, Motion은 MotionConfig 하나로 앱 전체에 적용할 수 있어서 편했습니다.
import { MotionConfig } from "motion/react";
export function Providers({ children }: { children: React.ReactNode }) {
return <MotionConfig reducedMotion="user">{children}</MotionConfig>;
}reducedMotion="user"로 두면 사용자가 설정을 켰을 때 이동이나 크기 변화 같은 transform 애니메이션이 꺼집니다. 블러처럼 남는 효과는 따로 확인해 보는 게 안전합니다.
5. 순서는 테스트로 고정했습니다
재현한 순서는 Playwright e2e로 고정해 뒀습니다. 요소의 투명도가 0.5를 넘는 순간을 기록해서 순서를 비교하는 방식입니다. 코드는 품질 게이트 글에 같이 정리해 뒀습니다.
마치며
"이 느낌으로"라는 말은 콘택트 시트 한 장이면 숫자가 됩니다. fps=5와 tile 필터면 충분합니다. 그리고 그 숫자를 표로 남겨 두면 디자이너, 기획자와 같은 언어로 이야기할 수 있습니다.
비슷한 요청을 받으셨다면, 코드부터 만지기 전에 영상을 한 번 쪼개 보시길 권합니다. 생각보다 많은 게 보입니다.
Comments (0)