よくtwitterで流れてくるアプリの紹介動画ってすごい見やすいですよね。どうやって作ってるんだろうとうっすら思っていたのですが、よく見る方がRemotionを使っていると言っていました。今回は自分が過去に作ったツールの紹介動画を作成することで、Remotionに触れていこうと思います!


先に完成品を置いておきます!

今回の仕様

  • 16:9(1920x1080、30fps)
  • 音声もBGMもなし
  • 今どきのアプリ紹介動画みたいに洗練されていてテンポがいいやつ
  • モックデータを用意してわかりやすくする
  • 以下のOGP画像に合わせた配色にする

参考にしたOGP画像。淡いピンクベージュと、くすんだサーモンピンクの配色はここから

この記事は動きを作っている関数と演出の話が中心です。
構成は次の9シーンにしました。↓
共有先のアプリはイメージカラーとテキストで表現しています。

  1. オープニング。ロゴとキャッチコピー
  2. 課題提示。通常のプレイリスト共有では作成者のチャンネルが見えてしまう
  3. リンクを貼る。動画URLとプレイリストURLを貼って追加する
  4. コピー。共有URLの生成とコピー、共有先ごとのゲージ
  5. 共有シーン。コピーしたURLをLINE、Discord、X、Slackへ
  6. 各アプリへの共有可否判定。たくさん足すとLINEとDiscordのゲージが✕に変わる
  7. 受け取った側の閲覧画面例
  8. 特徴の3点まとめ
  9. エンディング

完成した動画の9シーンを並べた絵コンテ

縦横比と尺を決める!

RemotionはCompositionに幅と高さとフレーム数を渡すだけです。16:9はここで1920x1080を指定するだけ。

export const Root = () => (
  <Composition
    id="Promo"
    component={Promo}
    durationInFrames={PROMO_DURATION}
    fps={fps}
    width={1920}
    height={1080}
  />
)

尺のPROMO_DURATIONは、シーンごとの長さから計算しています。シーンの間には15フレームのフェードを挟んでいて、その間は前後のシーンが重なるので、合計から重なりぶんを引いています。

export const SCENES = {
  intro: 100,
  problem: 205,
  demo: 345,
  share: 245,
  targets: 165,
  limits: 345,
  viewer: 185,
  features: 155,
  outro: 125,
}

export const PROMO_DURATION =
  Object.values(SCENES).reduce((sum, d) => sum + d, 0) - TRANSITION * (Object.keys(SCENES).length - 1)

テンポを変えたいときはこの数値を触るだけで済むので、後から2シーン足したときも楽でした。あ、仕様にも書きましたが今回は音声無しです!

素材はスクショではなくReactで作り直す

アプリの画面はスクショを貼るのではなく、Remotion上のReact部品として作り直しました。理由はやりたかった演出にあります。カーソルが入力欄まで動いてクリックし、文字が1文字ずつ打たれ、ボタンが押されると色が変わり、一覧が1件ずつポンポン出てくる。スクショだとせいぜいスクロールとズームまでで、こういう動きは作れません(頑張ればいけるのかな?)。

配色はアプリ本体のstyle.cssをそのまま写し、寸法はおおむね1.3倍にして1080pでも読めるサイズにしています。フォントは@remotion/google-fontsでZen Maru Gothicを読み込みました。丸ゴシックにすると一気に柔らかい印象になるので好きです。サムネイルは本物のYouTube画像を使わず、カフェ、朝、夜、森をイメージした4種類のパステルなイラストをSVGで描いています。SVG描くのはAIの十八番なので任せました!

再現したUIとイラスト風のサムネイル

動きの部品

シーンを書く前によく使う動きをanim.tsに関数としてまとめました。Remotionのspringとinterpolateを薄く包んだだけですが、シーン側がすごく読みやすくなります。

popとease

export function pop(frame: number, delay = 0): number {
  return spring({ frame: frame - delay, fps, config: { damping: 12, stiffness: 150, mass: 0.7 } })
}

export function ease(frame: number, delay = 0): number {
  return spring({ frame: frame - delay, fps, config: { damping: 200, stiffness: 110 } })
}

popはdampingを低くして少し行き過ぎてから戻るぽよんとしたばねのような動きです。登場する要素はほぼ全部これです。easeはdampingを200まで上げて行き過ぎをなくしたすっと収まる動きで、スクロールやゲージなど行き過ぎると変になる場所に使います。

ポイントはframe - delayの引き算です。springは負のフレームでは0を返すので、開始タイミングをずらすのは引き算だけで済みます。

値をスタイルに変えるrise

springが返す0から1の値を、そのままopacityとtranslateYに変換します。

export function rise(s: number, distance = 28): CSSProperties {
  const visible = Math.max(0, Math.min(1, s))
  return { opacity: visible, transform: `translateY(${(1 - s) * distance}px)` }
}

popは1を超えることがあるので、opacityだけは0から1に丸めます。translateYはそのまま渡しているので、行き過ぎたぶんは少し上に飛び出してから戻ります。キャプションは行ごとに5フレームずらしてrise(pop(frame, start + i * 5))で出していて、これだけで文章が順番に浮かび上がります。

タイプ入力

タイプ入力は、文字列をフレーム数で切るだけです。

export function typed(text: string, frame: number, start: number, perFrame = 2.5): string {
  const count = Math.max(0, Math.floor((frame - start) * perFrame))
  return text.slice(0, count)
}

export function typedChars(text: string, frame: number, start: number, framesPerChar = 4): string {
  const chars = Array.from(text)
  const count = Math.max(0, Math.min(chars.length, Math.floor((frame - start) / framesPerChar) + 1))
  return frame < start ? '' : chars.slice(0, count).join('')
}

2つあるのは速さの単位が違うからです。URLは長いので1フレームに2〜4文字の勢いで流し、リスト名の作業用BGMは4フレームに1文字ずつゆっくり打ちます。日本語のほうはサロゲートペアの文字を途中で切らないよう、Array.fromで1文字ずつに分けています。キャレットの点滅はframe % 18が半分より小さいときだけ表示する、というだけの関数です。

moveAlong

カーソルの移動はキーフレーム配列で表します。

export interface Key {
  f: number
  x: number
  y: number
}

export function moveAlong(frame: number, keys: Key[]): { x: number; y: number } {
  if (frame <= keys[0].f) return keys[0]
  for (let i = 0; i < keys.length - 1; i += 1) {
    const a = keys[i]
    const b = keys[i + 1]
    if (frame <= b.f) {
      const t = interpolate(frame, [a.f, b.f], [0, 1], {
        ...clamp,
        easing: Easing.inOut(Easing.cubic),
      })
      return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }
    }
  }
  return keys[keys.length - 1]
}

現在のフレームを挟む2点を探しその間をinOutのcubicで補間します。人の手の動きに近づけたかったので出だしと止まりを緩やかにしました!

その他のアニメーション

カーソルの動き

シーン側では、カーソルに渡すキーフレームをこう書いています。リンクを貼るシーンのものです。

const CX = BROWSER.x + BROWSER.padX
const CY = BROWSER.y + BROWSER.bar + BROWSER.padTop

const keys: Key[] = [
  { f: 0, x: 1500, y: 1000 },
  { f: 10, x: 1500, y: 1000 },
  { f: T.nameClick, x: CX + 200, y: CY + 134 },
  { f: 58, x: CX + 200, y: CY + 134 },
  { f: T.textClick, x: CX + 300, y: CY + 277 },
  { f: 80, x: CX + 300, y: CY + 277 },
  { f: 96, x: 1440, y: 590 },
  { f: 145, x: 1440, y: 590 },
  { f: T.addClick, x: CX + 47, y: CY + 385 },
  { f: 182, x: CX + 47, y: CY + 385 },
  { f: 215, x: 1540, y: 900 },
]

行き先の座標は、ブラウザ枠の位置にUIの寸法(ヘッダーの高さ、ラベルの高さ、入力欄の高さ)を足して出しています。同じ座標を続けて2回並べると、その間カーソルは止まります。テキストエリアをクリックした後は右へ逃がして、打っている文字に重ならないようにしました。

動画URLとプレイリストURLをまとめてタイプしている途中。カーソルは右に逃げている

クリックの表現はCursor部品の中にあります。クリックのフレームから8フレームかけて0.8倍まで縮んで戻り、同時にアクセント色の輪が18フレームかけて広がりながら消えます。

let scale = 1
for (const c of clicks) {
  const d = frame - c
  if (d >= 0 && d < 8) scale *= interpolate(d, [0, 3, 8], [1, 0.8, 1])
}

{clicks.map((c) => {
  const d = frame - c
  if (d < 0 || d > 18) return null
  const r = interpolate(d, [0, 18], [10, 56])
  const o = interpolate(d, [0, 18], [0.5, 0])
  return <div key={c} style={{ position: 'absolute', left: x - r, top: y - r, width: r * 2, height: r * 2, borderRadius: '50%', border: `3px solid ${color.accent}`, opacity: o }} />
})}

細かいところですが、矢印のSVGは先端が座標にぴったり来るように、パスの先端ぶん(viewBox24のうち6と3)だけ左上にずらし、縮むときの中心のtransformOriginも先端に合わせています。

ボタン側も同じフレームから状態を導いていて、クリックの3フレーム前からホバー色になり、押している5フレームだけ1px下がります。

const hover = frame >= T.addClick - 3 && frame < T.addClick + 5
const pressed = frame >= T.addClick && frame < T.addClick + 5

コピーボタンをクリックした瞬間。ボタンが押された色に変わり、文言が変わる

共有先ごとのゲージが埋まっていく

このアプリは共有URLの長さから、LINE、Discord、X、Slackそれぞれに送れるかを判定します。動画ではそれをゲージで見せました。
この演出が一番やってみたかったやつで、むしろこれやってみたくてRemotionを触ってみたと言っても過言ではないです。

const shown = Math.round(count)
const over = shown > t.max
const ratio = Math.max(1.5, Math.min(100, (shown / t.max) * 100))
const fill = animateFrom === undefined ? 1 : ease(frame, animateFrom + i * 6)
const appear = animateFrom === undefined ? 1 : Math.min(1.15, pop(frame, animateFrom + 14 + i * 6))

バーの幅はratio * fillパーセントです。fillがeaseなので行き過ぎずにすっと伸び、i * 6で1行ずつ6フレームずらしています。伸び終わるころ14フレーム遅れて左のチェックマークがpopで弾みます。本数が少なくても1.5%は残して空のバーに見えないようにしています。

4つのゲージが順に伸び、チェックが付いた状態

判定のシーンでは、このゲージに本数がどんどん増えていく動きを乗せます。本数はフレームから直接導き、上限を超えるフレームは先に走査して求めておきます。

function countAt(frame: number): number {
  return Math.round(DEMO_COUNT + (LIMIT_COUNT - DEMO_COUNT) * progress(frame, T.race, T.raceEnd))
}

const flipFrames = shareTargets.map((t) => {
  for (let f = T.race; f <= T.raceEnd; f += 1) if (countAt(f) > t.max) return f
  return null
})

23本から203本まで150フレームで駆け上がり、途中でLINEが52本、Discordが158本を超えます。超えた瞬間の演出はflipFramesを起点にします。

const since = flip === null ? -1 : frame - flip
const shake = since >= 0 && since < 14 ? Math.sin(since * 1.6) * 6 * Math.exp(-since / 5) : 0
const iconScale = over ? (flip === null ? 1 : Math.min(1.25, pop(frame, flip))) : appear
const tone = over ? color.warn : color.ok

shakeはsinにexp(-since / 5)を掛けた減衰振動で行が横に小さく震えて止まります。同時にチェックが✕にpopで入れ替わり色が緑から赤になりアイコンの位置から赤い星が散ります。

LINEが✕に変わった直後。カウンターは58本

Discordも✕に変わったところ

最後まで超えなかったXとSlackは、締めの一言に合わせて緑に光ります。

const glowing = !over && okGlowFrom !== undefined && frame >= okGlowFrom
const glow = glowing ? ease(frame, okGlowFrom) * (0.6 + 0.4 * (Math.sin((frame - okGlowFrom) / 6) + 1) / 2) : 0

box-shadowの広がりと濃さにglowを掛けていてeaseで立ち上がったあとsinでゆっくり脈打ちます。

最後はXとSlackの行が緑に光る

このシーンで一番考えたのは、本数が増えると共有URLの箱が伸びることでした。203本ぶんのURLは2300文字近くあり、そのままだと箱が伸びるたびに下のゲージが押し下げられてフレームごとに位置が動いてしまいます。そこでAIに相談するとURLを86文字ごとに切って1行ずつdivで描き行数から箱の高さを計算できるようにすることで、増えた高さぶんをスクロール量に足せば箱が上に伸びていくだけでゲージは同じ位置に留まるという助言をもらい、そうしました。。

export const urlLines = (url: string, perLine = 86) => Math.max(1, Math.ceil(url.length / perLine))

const scroll = SCROLL0 + (CAMERA_TO - SCROLL0) * ease(frame, T.addClick + 8) + (urlLines(url) - baseLines) * 27

一覧が1件ずつ弾んで流れ込む

追加ボタンを押したあと23件の一覧が1件ずつ出てきます。やっていることはmapの中で遅延をずらすだけです。

videos.map((v, i) => (
  <VideoItem key={v.id} video={v} style={rise(pop(frame, T.items + i * 5), 30)} />
))

5フレームずらしているので23件で約4秒かけて流れ込みます。同時にブラウザの中身をスクロールさせていて、まず200pxをeaseで下げて一覧の頭を見せ続けて1200pxをinOutで流して一覧をなぞります。1080pの枠にフォームと23件は収まらないので、本当にスクロールしたかのように見せるのが自然そうだと思いました。いい感じにもなったしOK!

const scroll = 200 * ease(frame, T.scroll) + 1200 * progress(frame, T.scroll2, T.scroll2End)

23件追加され、一覧が1件ずつ弾んで出てくる

URLのチップが各アプリへ飛んでいく

共有シーンでは、中央のURLから小さなチップが4つのタイルへ順に飛びます。直線で動かすと味気ないので、縦方向だけsinで持ち上げて放物線っぽくしました。

const t = interpolate(frame, [flyStart, arrive], [0, 1], {
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
  easing: Easing.inOut(Easing.quad),
})
const fx = PILL.x + (tileX[i] - PILL.x) * t
const fy = startY + (landY - startY) * t - Math.sin(Math.PI * t) * 30

飛んでいる間はチップを1.1倍から0.6倍へ縮め左右交互に12度まで回して半透明にしながら消します。着地した瞬間、受け取ったタイルが震え、✓のバッジがpopで付いて星が散ります。このアニメーションはもっといい感じなのがありそうです。

const squash = since >= 0 && since < 16 ? 1 + Math.sin(since * 0.9) * 0.08 * Math.exp(-since / 5) : 1

URLのチップが2つ目のタイルへ飛んでいる途中

4つとも✓が付いた状態

星が散るBurst

コピー完了やロゴの登場に添えている4点の星が6つ散る装飾です。星ごとに飛ぶ方向とサイズと出だしの遅れを持たせています。

const local = frame - start - o.delay
if (local < 0 || local > 28) return null
const grow = interpolate(local, [0, 8, 28], [0, 1, 0.6], { extrapolateRight: 'clamp' })
const drift = interpolate(local, [0, 28], [0.6, 1.25])
const opacity = interpolate(local, [0, 6, 20, 28], [0, 1, 1, 0])

8フレームで大きくなって少し縮みながら外へ流れた後に回りながら消えます。色を渡せるようにしてゲージが✕になるときは赤でチップが着地したときは緑になるようになっています。

コピーボタンを押した直後。星が散る

背景はずっと漂い続ける

背景は大きな円にradial-gradientを敷いてsinとcosで位置をゆっくり揺らしています。filter: blurで大きなぼかしを描くと1フレームごとの計算が重くなる問題がありました。これもAIに相談してグラデーションで代用する方針になりました。

const dx = Math.sin(t * 0.25 * b.speed + b.phase) * 40
const dy = Math.cos(t * 0.2 * b.speed + b.phase) * 30

背景はシーンを切り替えるTransitionSeriesの外に置いているのでシーンがフェードで切り替わっても背景の円は途切れずに流れ続けます。

シーンのつなぎ方

シーンは@remotion/transitionsのTransitionSeriesで15フレームのフェードを挟んで並べています。

<TransitionSeries>
  <TransitionSeries.Sequence durationInFrames={SCENES.intro}>
    <Intro />
  </TransitionSeries.Sequence>
  <TransitionSeries.Transition presentation={fade({ shouldFadeOutExitingScene: true })} timing={timing} />
  <TransitionSeries.Sequence durationInFrames={SCENES.problem}>
    <Problem />
  </TransitionSeries.Sequence>
</TransitionSeries>

TransitionSeriesの中ではuseCurrentFrame()がシーン内0起点になります。おかげでシーンの順番を入れ替えても中のタイミングを書き直さなくて済むのですが、フェードで重なっている15フレームの間も次のシーンはフレーム0から動き始めます。なので前のシーンがまだ見えているうちに次のシーンの文字がぽんと出てきてしまいます。

そこで各シーンの先頭で、トランジション分だけフレームを引いています。

const frame = useCurrentFrame() - TRANSITION

springは負のフレームで0、fadeInはclampで0を返すので重なっている間は何も描かれず前のシーンが消えきった瞬間から登場アニメーションが始まります。

フェード中のフレーム。前のシーンだけが薄くなっていき、次のシーンの要素はまだ出ていない

感想

よく見るやつだ!を感じられてよかったです。宣伝するときは基本的に画像を作成していたのですが絶対に動画のほうがいいと思うのでこれからはRemotion使っていきたいです。特にショート風の動画も作って宣伝用のアカウントで投稿していきたいなと思いました。

あとアニメーションを勉強したことがないのでふんわりとしたイメージしか沸かないのが難しかったです。こういうデザインに特化したAIって何があるんだろうか。案出しだけでもお願いしたいな!