Communitygithub.com

mvpdsigner/faceless-motion-reel

使用 Remotion 將腳本轉換為「無臉動態圖形短片」:白色畫布、黑色文字、一種強調色、一個小動畫物件。可用於 TikTok、Instagram、LinkedIn 短影音、無臉影片、動態圖形、動態文字或動畫解說。

faceless-motion-reel 是什麼?

此技能可將簡短腳本製作成 15 到 30 秒的直式影片,風格為「極簡視覺,最大衝擊」:乾淨白色背景、黑色字體、一種強調色,以及每個想法搭配一個小動畫物件。無需影片素材、人物或配音。整個影片以 React 元件搭配 Remotion 框架撰寫,因此每一幀都精確且易於修改。 此技能主要用於 terminal-based AI agent(如 Claude Code)或任何支援 Remotion 的開發環境。它會先撰寫「節奏表」(beat sheet)來規劃腳本段落、強調短語、動畫物件與轉場,然後自動建立專案、安裝依賴、複製字型檔,並提供完整「動態工具箱」(motion kit),包含「Words」、「Scene」、「PunchIn」、「Caption」、「Rings」、「Glow」、「Counters」、「Pills」、「Badges」、「WhipTransition」等自訂元件,讓使用者只需組合這些模組即可快速產出高品質動態圖形影片,最後渲染為 MP4。

相容平台~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/mvpdsigner/claude-skills/tree/HEAD/skills/faceless-motion-reel

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

無臉動態短片

將簡短腳本轉換為 15 到 30 秒的直式影片,風格為「極簡視覺,最大衝擊」:乾淨的白色背景、黑色字體、一種強調色,以及每個想法搭配一個小動畫物件。無需影片素材、人物或配音。影片以 React 程式碼搭配 Remotion 撰寫,因此每一幀都精確且易於修改。

風格(此風格的關鍵要素)

這些規則來自逐幀研究參考短片,它們比任何單一特效都更重要,即使在設計新場景時也要遵守。

  • 一個想法一個場景。 每個場景顯示一行簡短文字(2 到 6 個字)加上一個能傳達意義的物件。觀眾即使關掉聲音也能理解。
  • 僅三種顏色。 白色背景、近乎黑色的墨水色、一種強調色。灰色僅用於「尚未定位」的文字。第二種強調色會讓它看起來像套版。
  • 兩種字體聲音。 緊湊無襯線字體(Inter,字重 500,字距約 -0.04em)用於大部分文字,強調色斜體襯線字體用於重點短語。每個場景最多使用一次襯線字體。
  • 文字是浮現的,不是突然出現。 新文字以灰色且略微模糊的狀態出現,然後穩定為黑色。眼睛會跟隨最新的文字。
  • 沒有任何東西靜止不動。 每個場景都保持緩慢的縮放,背景環持續漂移。凍結的畫面看起來像幻燈片。
  • 轉場是攝影機移動。 場景向上或向側邊推動並帶有動態模糊,或淡出至白色。沒有擦拭、旋轉或縮放爆發。
  • 小字體,大量留白。 標題在 1080 寬的畫面上約為 84 到 100px。空白空間就是風格。
  • 物件就是想法。 用倒數環表示時間,用標籤顯示問題列表,用堆疊膠囊顯示特質列表,用迴圈包圍重點句子。當腳本中有強烈的圖像(時鐘、圖表、游標)時,使用 SVG 繪製該物件,而不是重複使用套件元件。

工作流程

1. 獲取簡報

你需要:訊息或腳本、強調色、最後場景的標誌或名稱,以及是否有音樂檔案。如果使用者只提供主題但沒有腳本,請自行撰寫腳本並在節奏表中呈現。不要因為問題而停滯。合理的預設值:約 20 秒、1080x1920、30fps、強調色 #1FB81A、無音訊。

2. 先撰寫節奏表

在任何程式碼之前,先將計畫撰寫為一個小表格,並在開始建構的同一則訊息中顯示給使用者。這是影片成敗的關鍵,因為薄弱節拍無法透過動畫修復。

#螢幕上的文字強調短語物件進 / 出幀數

指南:

  • 節拍 1 是鉤子:2 到 5 個字,以緊張或大膽的主張呈現。
  • 15 到 30 秒大約需要 6 到 9 個節拍。大多數節拍持續 60 到 110 幀(2 到 3.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';

// 字型為本地檔案(設定時從 @fontsource 複製),因此渲染永遠不需要網路。
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';

// ---- 主題:一種背景、一種墨水、一種灰色、一種強調色。依品牌交換強調色(6位十六進位)。 ----
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); // 慢、快、慢:所謂的「攝影機甩動」

/** 從 `delay` 開始的 0 到 1 彈簧動畫。預設為平滑;傳入 config 可產生彈跳效果。 */
export const useEnter = (delay = 0, config: object = {damping: 200}) => {
  const frame = useCurrentFrame();
  const {fps} = useVideoConfig();
  return spring({frame: frame - delay, fps, config});
};

// ---- 時間軸:場景重疊 OVERLAP 幀,使退出和進入同時發生 ----
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>
  );
};

// ---- 場景:包裝一個想法。處理推動/淡入淡出、動態模糊、緩慢縮放。 ----
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"` = 內容向上移動到位。對於推動,給予場景 A exit="up" 和場景 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(); // = 此 Sequence 的長度
  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; // 模糊跟隨速度,如同真實快門
  const by = Math.abs(y - py) * 0.35;
  const fIn = enter === 'fade' ? interpolate(frame, [t * 0.5, t], [0, 1], CL) : 1; // 舊場景先清除
  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>
    </>
  );
};

// ---- 文字 ----
/** 逐字(或逐字母)顯示文字。新文字以灰色出現,然後穩定為墨水色。
 *  使用 *星星* 包圍關鍵短語以使其成為強調色。使用 \n 進行換行。 */
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>
  );
};

/** 開場重擊:從巨大且模糊開始,在大約 8 幀內縮小到位。 */
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>
  );
};

/** 標題下的小型輔助文字行。 */
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>
  );
};

// ---- 氛圍 ----
/** 關鍵字後方的柔和強調光。使用 CSS 值設定 x / y 位置。 */
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)`}} />
  );
};

/** 邊緣附近的漂移圓圈,使畫面永不靜止。thin = 輪廓,soft = 粗模糊環。 */
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.

相關技能