Communitygithub.com

mvpdsigner/faceless-motion-reel

Code as Remotion でフェイスレス・モーショングラフィックスリール(キネティックタイポグラフィ、白いキャンバス、一つのアクセントカラー、アニメーションアイコン、ピル、カウンター、ホイップトランジション)を構築し、MP4 にレンダリングします。リール、ショート動画、TikTok、Instagram、LinkedIn の動画、フェイスレス動画、モーショングラフィックス、キネティックテキスト、アニメーション説明動画、またはスクリプト、ヒント、引用を短い縦型動画にしたい場合に使用します。

faceless-motion-reel とは?

このスキルは、Remotion と React を使用して、フェイスレス(顔出しなし)のモーショングラフィックスリールをコードで生成するためのテンプレートとツールキットです。15 〜 30 秒の縦型動画(1080x1920)を「最小限のビジュアルで最大のインパクト」スタイルで作成します。白い背景、黒いテキスト、一つのアクセントカラー、各アイデアに一つの小さいアニメーションオブジェクトを使用するため、映像素材、顔出し、ナレーションは一切不要です。 スキルは、動画の骨子となるビートシート(場面構成表)を先に計画し、それに基づいてコードを生成するワークフローを提供します。各シーンは一つのアイデアを表現し、テキストは単語ごとに出現し、シーン遷移はカメラの動き(プッシュアップ、フェードなど)を模倣します。また、キネティックタイポグラフィ用の `Words` コンポーネント、アニメーション遷移を管理する `Scene` コンポーネント、背景のゆっくりとした動きを追加する `Rings` コンポーネントなど、再利用可能なビルディングブロック(motion kit)が含まれています。 このスタイルの特徴は、コントラストの効いた3色のみのカラーパレット、2種類のタイポグラフィ(サンセリフとイタリックセリフ)、テキストの出現とセトリング(グレーから黒へ)、シーン全体にわたるスローなズームと浮遊するリングによる常時アニメーション、そしてカメラの動きを模倣したトランジションです。これにより、スライドショーのような静止感を排除し、プロフェッショナルな動画に仕上げます。

対応~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 コードとして記述されるため、すべてのフレームが正確で、修正も容易です。

見た目(このスタイルを機能させるルール)

これらのルールは、参考リールをフレームごとに研究して導き出されました。特定のエフェクトよりも重要なので、新しいシーンを考案する際もこれらを守ってください。

  • 1シーン=1アイデア。 各シーンは短い行(2〜6単語)と、その意味を伝える一つのオブジェクトで構成されます。音声がなくても視聴者が理解できるようにします。
  • 3色のみ。 白い背景、ほぼ黒のインク、一つのアクセント。グレーは「まだ確定していない」テキストにのみ使用します。2つ目のアクセントがあるとテンプレートのように見えます。
  • 2つのタイプの声。 ほとんどの単語にはタイトなサンセリフ(Inter、ウェイト500、トラッキング約 -0.04em)を使用し、重要なフレーズにはアクセントカラーのイタリックセリフを使用します。セリフは各シーンで最大1回のみ使用します。
  • テキストは「出現」するのではなく「到着」する。 新しい単語はグレーでややぼやけた状態で現れ、その後黒に落ち着きます。目は最も新しい単語を追います。
  • 何も静止しない。 各シーンはスローなズームを維持し、背景のリングは漂い続けます。フレームが止まるとスライドショーのように見えます。
  • トランジションはカメラの動き。 シーンはモーションブラーを伴って上下または横にプッシュするか、ホワイトにディップします。ワイプ、スピン、ズームバーストは使用しません。
  • 小さなタイプ、たっぷりの余白。 1080幅のフレームで見出しは約84〜100pxに設定します。空きスペース自体がスタイルです。
  • オブジェクトがアイデア。 時間を表すカウントダウンリング、問題リスト用のラベル付きバッジ、特性リスト用の積み重ねピル、決めゼリフの周りのループ描画。スクリプトに強いイメージ(時計、グラフ、カーソル)がある場合は、キットの部品を再利用する代わりに、そのオブジェクトを SVG で描画します。

ワークフロー

1. 依頼内容の把握

必要なもの:メッセージまたはスクリプト、アクセントカラー、ラストシーン用のロゴまたは名前、音楽ファイルの有無。ユーザーがトピックのみを提示した場合は、自分でスクリプトを作成し、ビートシートで表示します。質問で立ち止まらないでください。適切なデフォルト値:約20秒、1080x1920、30fps、アクセント #1FB81A、オーディオなし。

2. 最初にビートシートを作成する

コードの前に、計画を小さなテーブルとして作成し、ビルドを開始する同じメッセージでユーザーに表示します。ここが動画の成否を分けるポイントです。弱いビートはアニメーションで修正できません。

#画面に表示される行アクセントフレーズオブジェクトIn / Outフレーム数

ガイドライン:

  • ビート1はフック:2〜5単語で、緊張感または大胆な主張として提示します。
  • 15〜30秒で6〜9ビート。ほとんどのビートは60〜110フレーム(2〜3.5秒)で実行します。リストビート(バッジ、ピル)にはより多くの時間を割り当てます:アイテムあたり約20フレーム + 読み取りに30フレーム。
  • 終盤の一つの決めゼリフに向けて構築し、その周りにループ(Circled)を配置します。
  • 最後のビートはロゴまたはハンドルで、45〜60フレーム。
  • ビートごとにオブジェクトを変化させます。テキストのみのビートが2回連続するのは問題ありませんが、バッジビートが2回連続するのは避けてください。
  • トランジションを変化させます:主に 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

…

[翻訳のため一部省略]

Individual skills in this repo

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

関連スキル