Communitygithub.com

mvpdsigner/faceless-motion-reel

将短脚本文案转化为 15-30 秒的竖屏动效视频,采用“极简视觉、最大冲击”的风格:白画布、黑字体、一个强调色、每个创意搭配一个小型动画对象。无需视频素材、人物出镜或配音。代码基于 Remotion 编写,帧级精确、便于修改。

faceless-motion-reel 是什么?

该技能(SKILL)能够将一条简短的文案(如 Tip、语录或要点)自动生成为一个 15-30 秒的“无脸”动效短片,风格为“极简视觉、最大冲击”:纯白背景、黑色字体、一个强调色、每个创意搭配一个动画图标。整个视频以 React + Remotion 代码定义,确保每一帧都精确可控、易于迭代。它适用于 Claude Code、Cursor、Codex 等支持 SKILL.md 和 Remotion 的 AI 编码工具。工作原理是:先通过 6-9 个 Beat(节奏点)的结构化表格规划故事板,然后调用 Remotion 的 Sequence、Spring、插值等 API,实现字词逐字浮现、推拉转场、微缩放与漂移环等动画效果。内置主题系统(白、黑、灰、一个强调色)、字体本地化加载,渲染时不依赖网络。默认输出 1080×1920、30fps、约 20 秒的 MP4,也可搭配音乐文件。该技能特别适合 TikTok、Instagram Reel、LinkedIn 视频或任何需要快速、可复用的动感文字短视频场景。

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

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

无脸动效短片 (Faceless Motion Reel)

将一条短小的脚本转化为 15-30 秒的竖屏视频,采用“极简视觉、最大冲击”的风格:干净的白色画布、黑色字体、一个强调色、每个创意搭配一个小型动画对象。无需视频素材、人物出镜或配音。视频使用 React + Remotion 编写,每一帧都精确且易于修改。

视觉风格 (此风格为何有效)

这些规则来自逐帧拆解参考视频。它们比任何单个特效都重要,即使在创作新场景时也要遵守。

  • 每个场景一个创意。 每个场景展示一行短文字(2-6 个词)和一个承载含义的物体。观众无需声音即可理解。
  • 仅用三种颜色。 白色背景、接近黑色的墨色、一个强调色。灰色仅用于“尚未落定”的文字。第二个强调色会让视频看起来像模板。
  • 两种字体声音。 紧实无衬线体(Inter, weight 500, tracking 约 -0.04em)用于大多数文字;斜体衬线体以强调色显示关键短语。每个场景最多使用一次衬线体。
  • 文字是“抵达”的,而非直接出现。 新词先以灰色和轻微模糊呈现,然后稳定为黑色。眼睛会跟随最新的词。
  • 任何东西都不会静止。 每个场景保持缓慢缩放,背景光环持续漂移。静止的画面会显得像幻灯片。
  • 转场是摄像机的移动。 场景向上或向侧边推动,带有运动模糊,或淡出为白色。没有划像、旋转或缩放爆发。
  • 小字号,大留白。 标题字号大约 84-100px(在 1080 宽的画面上)。留白本身就是风格。
  • 物体就是创意。 用倒计时环表示时间、标签徽章列出问题、堆叠药丸表示特性、在“要点”行周围画圈。当脚本有强烈的图像(时钟、图表、光标)时,用 SVG 绘制该物体,而不是重复使用工具包中的构件。

工作流程

1. 获取需求

你需要:信息或脚本、强调色、最后一幕的 Logo 或名称、以及是否有音乐文件。如果用户只提供了主题但没有脚本,请自行编写脚本并在 Beat 表中展示。不要因为提问而停滞不前。合理的默认值:约 20 秒、1080×1920、30fps、强调色 #1FB81A、无音频。

2. 先编写 Beat 表

在任何代码之前,将计划写成一个小的表格,并在开始构建的同一消息中展示给用户。这是决定视频成败的关键,因为薄弱的 Beat 无法用动画弥补。

#屏幕上的文字强调短语物体入/出帧数

指导原则:

  • Beat 1 是“钩子”:2-5 个词,以张力或大胆陈述的形式呈现。
  • 6-9 个 Beat 对应 15-30 秒。大多数 Beat 运行 60-110 帧(2-3.5 秒)。给列表型 Beat(如徽章、药丸)更多时间:每个项目约 20 帧 + 阅读时间 30 帧。
  • 在接近结尾处构建一个“要点”行,并为其添加圆圈动画(Circled)。
  • 最后一个 Beat 是 Logo 或用户名,45-60 帧。
  • 每个 Beat 使用的物体要有所变化。连续两个纯文字 Beat 是可以的,但连续两个徽章 Beat 不行。
  • 变化转场:大部分用 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 弹簧动画。默认平滑;传递配置以添加弹跳。 */
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.

相关技能