Communitygithub.com

mvpdsigner/faceless-motion-reel

얼굴 없는 모션 그래픽 릴(운동 타이포그래피, 흰 캔버스, 단일 강조 색상, 애니메이션 아이콘, 약자, 카운터, 휩 전환)을 Remotion으로 코드로 제작하고 MP4로 렌더링합니다. 릴, 쇼트, 틱톡, 인스타그램, 링크드인 동영상, 얼굴 없는 동영상, 모션 그래픽, 운동 텍스트, 애니메이션 설명 요청, 또는 스크립트, 팁, 인용구가 짧은 세로 동영상이 되어야 할 때 사용하세요.

faceless-motion-reel란 무엇인가요?

이 스킬은 **Face Less Motion Reel** 제작을 위한 것입니다. 사용자가 제공한 주제나 스크립트를 바탕으로, Remotion 프레임워크를 사용해 15~30초 분량의 세로형 모션 그래픽 동영상을 코드로 생성합니다. '미니멀 비주얼, 최대 임팩트' 스타일을 따르며 흰색 배경, 검은색 텍스트, 하나의 강조 색상만 사용하여 깔끔하고 집중된 메시지를 전달합니다. 실제 영상이나 목소리 없이 순수하게 코드와 SVG만으로 제작되며, 명확한 비트 시트(beat sheet)를 먼저 작성하여 동영상의 흐름을 설계하고, 각 장면은 하나의 아이디어에 집중합니다. 이 스킬은 Claude Code, Cursor, Codex와 같은 AI 비서가 직접 실행할 수 있도록 설계되었으며, 사용자 상호작용 없이 독립적으로 동영상을 완성합니다. 결과물은 MP4 파일로 출력되어 소셜 미디어 콘텐츠 제작에 즉시 활용할 수 있습니다.

지원 대상~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/mvpdsigner/claude-skills/tree/HEAD/skills/faceless-motion-reel

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

얼굴 없는 모션 릴

짧은 스크립트를 15~30초 분량의 세로 동영상으로 변환합니다. '미니멀 비주얼, 최대 임팩트' 스타일: 깨끗한 흰색 캔버스, 검은색 타입, 하나의 강조 색상, 각 아이디어당 하나의 작은 애니메이션 오브젝트. 영상, 얼굴, 보이스오버가 필요 없습니다. 동영상은 Remotion과 함께 React 코드로 작성되므로 모든 프레임이 정확하고 수정이 쉽습니다.

룩 (이 스타일이 효과적인 이유)

이 규칙들은 참고 릴을 프레임별로 분석한 결과입니다. 어떤 단일 효과보다 중요하므로, 새로운 장면을 만들 때도 꼭 지켜주세요.

  • 장면당 하나의 아이디어. 각 장면은 하나의 짧은 라인(2~6단어)과 의미를 전달하는 하나의 오브젝트를 보여줍니다. 시청자는 소리를 끄고도 이해할 수 있어야 합니다.
  • 세 가지 색상만 사용. 흰색 배경, 거의 검은색 잉크, 하나의 강조 색상. 회색은 아직 '자리 잡지 않은' 텍스트에만 사용합니다. 두 번째 강조 색상은 템플릿처럼 보이게 만듭니다.
  • 두 가지 타입 보이스. 대부분의 단어에는 타이트한 산세리프(Inter, 두께 500, 자간 약 -0.04em)를 사용하고, 중요한 구문에는 강조 색상의 기울임꼴 세리프를 사용합니다. 세리프는 장면당 최대 한 번만 사용하세요.
  • 텍스트는 도착합니다. 그냥 나타나지 않습니다. 새로운 단어는 회색과 약간 흐릿하게 들어와서 검은색으로 자리잡습니다. 눈은 가장 최신 단어를 따라갑니다.
  • 아무것도 가만히 있지 않습니다. 모든 장면은 느린 줌을 유지하고 배경 링은 계속 움직입니다. 정지된 프레임은 슬라이드쇼처럼 보입니다.
  • 전환은 카메라 움직임입니다. 장면은 모션 블러와 함께 위나 옆으로 밀리거나 흰색으로 사라집니다. 와이프, 회전, 줌 버스트는 없습니다.
  • 작은 타입, 많은 여백. 헤드라인은 1080 너비 프레임에서 약 84~100px로 설정합니다. 빈 공간이 스타일입니다.
  • 오브젝트가 아이디어입니다. 시간을 위한 카운트다운 링, 문제 목록을 위한 레이블 배지, 특성 목록을 위한 쌓이는 알약, 핵심 구문 주변의 루프. 스크립트에 강력한 이미지(시계, 차트, 커서)가 있을 때는 킷 부품을 재사용하는 대신 SVG로 그 오브젝트를 그립니다.

워크플로

1. 브리프 받기

필요한 것: 메시지 또는 스크립트, 강조 색상, 마지막 장면을 위한 로고 또는 이름, 음악 파일 유무. 사용자가 주제만 주고 스크립트를 주지 않으면, 직접 스크립트를 작성하여 비트 시트에 표시합니다. 질문에 막히지 마세요. 합리적인 기본값: 약 20초, 1080x1920, 30fps, 강조 #1FB81A, 오디오 없음.

2. 먼저 비트 시트 작성

코드 작성 전에, 계획을 작은 표로 만들어 빌드를 시작하는 동일한 메시지에서 사용자에게 보여줍니다. 이 부분이 동영상의 성패를 좌우합니다. 약한 비트는 애니메이션으로 고칠 수 없기 때문입니다.

#화면에 표시될 라인강조 구문오브젝트인/아웃프레임

지침:

  • 비트 1은 훅: 2~5단어로 긴장감이나 대담한 주장으로 제시합니다.
  • 1530초에 69개의 비트. 대부분의 비트는 60110프레임(23.5초)으로 실행됩니다. 목록 비트(배지, 알약)에는 더 많은 시간을 할당합니다: 항목당 약 20프레임 + 읽는 시간 30프레임.
  • 결말을 향해 하나의 핵심 구문을 만들고 루프(Circled)를 제공합니다.
  • 마지막 비트는 로고 또는 핸들, 45~60프레임.
  • 비트마다 오브젝트를 다양하게 합니다. 두 개의 텍스트 전용 비트가 연속되는 것은 괜찮습니다. 두 개의 배지 비트가 연속되는 것은 괜찮지 않습니다.
  • 전환을 다양하게: 주로 up 푸시, 한 번의 사이드 푸시, 핵심 구문 전에 fade 한 번.

3. 프로젝트 설정

빈 폴더에서 한 번 실행합니다. 글꼴은 프로젝트에 복사되므로 렌더링이 네트워크에 의존하지 않습니다.

npm init -y
npm i remotion @remotion/cli @remotion/fonts react react-dom lucide-react @fontsource/inter @fontsource/instrument-serif
npm i -D typescript @types/react
mkdir -p src public/fonts out
cp node_modules/@fontsource/inter/files/inter-latin-400-normal.woff2 node_modules/@fontsource/inter/files/inter-latin-500-normal.woff2 node_modules/@fontsource/instrument-serif/files/instrument-serif-latin-400-italic.woff2 public/fonts/

tsconfig.json:

{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","jsx":"react-jsx","strict":true,"skipLibCheck":true,"noEmit":true,"esModuleInterop":true},"include":["src"]}

src/index.ts:

import {registerRoot} from 'remotion';
import {Root} from './Root';
registerRoot(Root);

src/Root.tsx:

import React from 'react';
import {Composition} from 'remotion';
import {totalFrames} from './kit';
import {Video, beats} from './Video';

export const Root: React.FC = () => (
  <Composition id="Reel" component={Video} width={1080} height={1920} fps={30} durationInFrames={totalFrames(beats)} />
);

4. 모션 킷 추가

정확히 이 코드로 src/kit.tsx를 생성합니다. 이미 렌더링되고 확인되었으므로 그대로 복사하고 테마 라인만 변경하세요. 같은 정신으로 그 옆에 새 부품을 만드세요.

import React from 'react';
import {
  AbsoluteFill, Easing, Sequence, interpolate, interpolateColors, random, spring,
  staticFile, useCurrentFrame, useVideoConfig,
} from 'remotion';
import {loadFont} from '@remotion/fonts';

// Fonts are local files (copied from @fontsource at setup), so renders never need the network.
const font = (family: string, file: string, weight: string, style = 'normal') =>
  loadFont({family, url: staticFile(`fonts/${file}.woff2`), weight, style});
font('Inter', 'inter-latin-400-normal', '400');
font('Inter', 'inter-latin-500-normal', '500');
font('Instrument Serif', 'instrument-serif-latin-400-italic', '400', 'italic');
const sans = 'Inter, sans-serif';
const serif = '"Instrument Serif", serif';

// ---- Theme: one background, one ink, one grey, ONE accent. Swap accent per brand (6-digit hex). ----
export const T = {bg: '#FFFFFF', ink: '#0B0B0C', muted: '#B4B6BB', accent: '#1FB81A', sans, serif};

const CL = {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'} as const;
const c01 = (n: number) => Math.min(1, Math.max(0, n));
const whip = Easing.bezier(0.76, 0, 0.18, 1); // slow, fast, slow: the "camera whip"

/** 0 to 1 spring that starts at `delay`. Smooth by default; pass a config for bounce. */
export const useEnter = (delay = 0, config: object = {damping: 200}) => {
  const frame = useCurrentFrame();
  const {fps} = useVideoConfig();
  return spring({frame: frame - delay, fps, config});
};

// ---- Timeline: scenes overlap by OVERLAP frames so the exit and the enter happen together ----
export const OVERLAP = 12;
export type Beat = {dur: number; el: React.ReactNode};
export const beatStarts = (beats: Beat[], overlap = OVERLAP) =>
  beats.map((_, i) => beats.slice(0, i).reduce((s, x) => s + x.dur - overlap, 0));
export const totalFrames = (beats: Beat[], overlap = OVERLAP) =>
  beats.reduce((s, b) => s + b.dur, 0) - overlap * (beats.length - 1);

export const Timeline: React.FC<{beats: Beat[]; overlap?: number}> = ({beats, overlap = OVERLAP}) => {
  const starts = beatStarts(beats, overlap);
  return (
    <AbsoluteFill style={{background: T.bg}}>
      {beats.map((b, i) => (
        <Sequence key={i} from={starts[i]} durationInFrames={b.dur}>
          {b.el}
        </Sequence>
      ))}
    </AbsoluteFill>
  );
};

// ---- Scene: wraps one idea. Handles the push/fade in and out, motion blur, slow zoom. ----
type Dir = 'up' | 'down' | 'left' | 'right' | 'fade';
const VEC: Record<Dir, [number, number]> = {up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0], fade: [0, 0]};

/** `enter="up"` = content travels up into place. For a push, give scene A exit="up" and scene B enter="up". */
export const Scene: React.FC<{
  enter?: Dir; exit?: Dir; t?: number; zoom?: number; children: React.ReactNode;
}> = ({enter = 'fade', exit = 'fade', t = OVERLAP, zoom = 0.05, children}) => {
  const frame = useCurrentFrame();
  const {width, height, durationInFrames: dur} = useVideoConfig(); // = this Sequence's length
  const id = React.useId().replace(/:/g, '');
  const pos = (f: number) => {
    const a = 1 - whip(c01(f / t));
    const b = whip(c01((f - (dur - t)) / t));
    const [ex, ey] = VEC[enter];
    const [lx, ly] = VEC[exit];
    return [(-ex * a + lx * b) * width, (-ey * a + ly * b) * height];
  };
  const [x, y] = pos(frame);
  const [px, py] = pos(frame - 1);
  const bx = Math.abs(x - px) * 0.35; // blur follows speed, like a real shutter
  const by = Math.abs(y - py) * 0.35;
  const fIn = enter === 'fade' ? interpolate(frame, [t * 0.5, t], [0, 1], CL) : 1; // old scene clears first
  const fOut = exit === 'fade' ? interpolate(frame, [dur - t, dur - t * 0.5], [1, 0], CL) : 1;
  const soft = (1 - fIn * fOut) * 14;
  const filter = bx + by > 0.4 ? `url(#${id})` : soft > 0.2 ? `blur(${soft}px)` : undefined;
  return (
    <>
      <svg width="0" height="0" style={{position: 'absolute'}}>
        <filter id={id} x="-40%" y="-40%" width="180%" height="180%">
          <feGaussianBlur stdDeviation={`${bx} ${by}`} />
        </filter>
      </svg>
      <AbsoluteFill
        style={{
          transform: `translate(${x}px, ${y}px) scale(${1 + (zoom * frame) / dur})`,
          opacity: fIn * fOut, filter,
          fontFamily: T.sans, color: T.ink, alignItems: 'center', justifyContent: 'center',
        }}
      >
        {children}
      </AbsoluteFill>
    </>
  );
};

// ---- Text ----
/** Reveals text word by word (or letter by letter). New text lands grey, then settles to ink.
 *  Wrap the key phrase in *stars* to make it the accent. Use \n for a line break. */
export const Words: React.FC<{
  text: string; delay?: number; stagger?: number; by?: 'word' | 'char';
  size?: number; accent?: 'serif' | 'sans'; style?: React.CSSProperties;
}> = ({text, delay = 0, stagger, by = 'word', size = 92, accent = 'serif', style}) => {
  const frame = useCurrentFrame();
  const step = stagger ?? (by === 'word' ? 5 : 1.2);
  let i = 0;
  const unit = (s: string, hot: boolean, key: string) => {
    const local = frame - delay - i++ * step;
    const p = interpolate(local, [0, 6], [0, 1], CL);
    const final = hot ? T.accent : T.ink;
    return (
      <span
        key={key}
        style={{
          display: 'inline-block', whiteSpace: 'pre', opacity: p,
          color: interpolateColors(c01((local - 3) / 9), [0, 1], [T.muted, final]),
          transform: by === 'word' ? `translateY(${(1 - p) * 0.2}em)` : undefined,
          filter: p < 1 ? `blur(${(1 - p) * 7}px)` : undefined,
        }}
      >
        {s}
      </span>
    );
  };
  return (
    <div style={{fontSize: size, fontWeight: 500, letterSpacing: '-0.04em', lineHeight: 1.08, textAlign: 'center', ...style}}>
      {text.split('\n').map((line, li) => (
        <div key={li}>
          {line.split('*').map((seg, si) => {
            const hot = si % 2 === 1;
            const hotStyle: React.CSSProperties = hot && accent === 'serif'
              ? {fontFamily: T.serif, fontStyle: 'italic', fontWeight: 400, fontSize: '1.14em', letterSpacing: '-0.02em'}
              : {};
            return (
              <span key={si} style={hotStyle}>
                {seg.split(/(\s+)/).filter(Boolean).map((w, wi) =>
                  /^\s+$/.test(w) ? <span key={wi} style={{whiteSpace: 'pre'}}>{w}</span>
                  : by === 'word' ? unit(w, hot, `${wi}`)
                  : <span key={wi} style={{display: 'inline-block', whiteSpace: 'nowrap'}}>{[...w].map((ch, ci) => unit(ch, hot, `${wi}-${ci}`))}</span>
                )}
              </span>
            );
          })}
        </div>
      ))}
    </div>
  );
};

/** The opening hit: starts huge and blurred, snaps to size in about 8 frames. */
export const PunchIn: React.FC<{delay?: number; from?: number; children: React.ReactNode}> = ({delay = 0, from = 3, children}) => {
  const s = useEnter(delay, {damping: 200, stiffness: 220});
  return (
    <div style={{transform: `scale(${interpolate(s, [0, 1], [from, 1])})`, opacity: c01(s * 5), filter: s < 0.98 ? `blur(${(1 - s) * 34}px)` : undefined}}>
      {children}
    </div>
  );
};

/** Small supporting line under the headline. */
export const Caption: React.FC<{children: React.ReactNode; delay?: number; width?: number}> = ({children, delay = 0, width = 620}) => {
  const s = useEnter(delay);
  return (
    <div style={{fontSize: 31, lineHeight: 1.28, letterSpacing: '-0.02em', color: '#2B2C2F', textAlign: 'center', width, marginTop: 44, opacity: s, filter: s < 0.98 ? `blur(${(1 - s) * 6}px)` : undefined}}>
      {children}
    </div>
  );
};

// ---- Atmosphere ----
/** Soft accent light behind the key word. Place it with x / y as CSS values. */
export const Glow: React.FC<{x?: string; y?: string; size?: number; delay?: number}> = ({x = '50%', y = '50%', size = 900, delay = 0}) => {
  const s = useEnter(delay);
  const frame = useCurrentFrame();
  return (
    <div style={{position: 'absolute', left: x, top: y, width: size, height: size, borderRadius: '50%', opacity: s,
      transform: `translate(-50%, -50%) scale(${0.6 + 0.4 * s + frame * 0.0012})`,
      background: `radial-gradient(closest-side, ${T.accent}3D, ${T.accent}00)`}} />
  );
};

/** Drifting circles near the edges so the frame never sits still. thin = outlines, soft = fat blurred rings. */
export const Rings: React.FC<{seed?: string; variant?: 'thin' | 'soft'; count?: number}> = ({seed = 'a', variant = 'thin', count = 3}) => {
  const frame = useCurrentFrame();
  const {width, height} = useVideoConfig();
  return (
    <AbsoluteFill>
      {Array.from({length: count}).map((_, i) => {
        const r = (k: string) => random(`${seed}-${i}-${k}`);
        const d = 300 + r('d') * 420;
        const cx = (i % 2 ? 0.86 + r('x') * 0.2 : -0.06 + r('x') * 0.2) * width;
        const cy = (r('y') * 1.1 - 0.05) * height;
        const ang = r('a') * Math.PI * 2;
        const s = spring({frame: frame - i * 4, fps: 30, config: {damping: 200}});
        return (
          <div key={i} style={{position: 'absolute', width: d, height: d, borderRadius: '50%', opacity: s,
            left: cx - d / 2 + Math.cos(ang) * frame * 0.9, top: cy - d / 2 + Math.sin(ang) * frame * 0.9,
            border: variant === 'thin' ? `2px solid ${T.accent}99` : `${d * 0.2}px solid ${T.accent}1F`,
            filter: variant === 'soft' ? `blur(${d * 0.1}px)` : undefined}} />
        );
      })}
    </AbsoluteFill>
  );
};

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

관련 스킬