p5.js / music video

上下の帯は、
絵のあとで描く。

p5.jsでMVを作るとき、曲名と歌詞は映像のピクセルです。この工房では 1920×1080 の設計座標で上帯と下帯を組み、高さ・濃さ・型を動かしながら、書き出しに残る情報オーバーレイの作り方を追います。

上帯
曲名・区間・時刻
中央
絵のために空ける
下帯
歌詞・次行・クレジット

Preview / 16:9

スタジオ

設計 1920×1080帯はキャンバス内
書体を読み込んでいます
00:00.0
00:36.0

画面内 TC 00:00:00 / 30fps

PNG には上帯も下帯も入ります。キャンバスの上に HTML を重ねた文字は、この保存には残りません。

01

帯は、識別と進行を分ける

MVの情報オーバーレイは、画面の上下に仕事を分けます。上は識別です。誰の、何という曲で、いまどの区間にいるか。下は進行です。いま歌っている行、次の行、最後に小さくクレジット。

中央は絵のために空けます。粒子、映像、タイポグラフィの主役は、上下の帯が終わったあとの領域に置きます。帯を先にデザインすると、絵が「余白を埋めるもの」にならず、帯が絵を後から支える形になります。

タイトルセーフ 10%
アクションセーフ 5%
曲名00:12:08
いまの一行次の一行
上帯下帯
1080p での目安。上帯はフレームの約 10%、下帯は約 16%。文字はタイトルセーフの内側、絵の中心は空けたままにします。
要素1080p役割
上帯の高さ96–128pxフレームの 9–12%
下帯の高さ150–200pxフレームの 14–18%
左右インセット96pxアクションセーフ 5% の内側端
曲名36–44px明朝。上帯の主情報
歌詞40–52px下帯の主情報
次行20–24px主行の約半分のコントラスト
タイムコード20–24px等幅。右上

この工房の初期値は上 112px、下 176px、左右 96px です。スタジオのスライダーで高さを変えると、文字のベースラインは帯の中に収まる範囲で維持されます。帯を文字より低くすると、そのときだけ位置を押し込みます。

02

draw の最後に、層を積む

draw() の順番が、そのまま画面の前後になります。絵を描いたあとで帯を塗る。塗ったあとで文字を置く。ガイドは一番上で、書き出すときは呼びません。

絵。 曲名も歌詞もまだ描かない。中央は、あとで空ける前提で作る。

draw の順番
function draw() {
  // 1. 絵。粒子でも映像でも、ここまでが「中身」。
  drawPicture();
  // 2. 端を少し落とす。帯の黒がなじむ。
  drawVignette();
  // 3. 上の塗り → 上の文字。文字は塗りのあと。
  drawTopOverlay();
  // 4. 下の塗り → 下の文字。
  drawBottomOverlay();
  // 5. ガイドは確認用。書き出す前に消す。
  if (showGuides) drawSafeGuides();
}

上の文字を塗りの前に描くと、グラデーションが文字を食べます。下の歌詞を絵の前に描くと、街の棒が行を横切ります。関数を drawPicture、drawTopOverlay、drawBottomOverlay に分けると、この順番を崩しにくくなります。

03

1920×1080 で考えてから、縮小する

帯の寸法は出力解像度で決めます。プレビュー用にキャンバスを 960×540 にしても、頭の中の定規は 1920×1080 のままです。上帯 112px は、1080p でフレームの約 10% です。

p5 の scale() は見た目だけを変えます。width と height はキャンバスの実寸のままです。縮小した座標系の中で rect(0, 0, width, height) と書くと、帯が設計フレームの半分で止まります。帯の計算には設計寸法 DW / DH を使います。

設計座標への変換
const DW = 1920;
const DH = 1080;

function setup() {
  createCanvas(960, 540);
  pixelDensity(1);
}

function draw() {
  background(8);
  // scale() しても width / height はキャンバス実寸のまま。
  // 帯の計算は DW / DH で行う。
  const s = min(width / DW, height / DH);
  const ox = (width - DW * s) / 2;
  const oy = (height - DH * s) / 2;
  push();
  translate(ox, oy);
  scale(s);
  drawPicture();
  drawTopOverlay();
  drawBottomOverlay();
  pop();
}

p5 エディタに貼る最小スケッチは、キャンバス自体を 1920×1080 にして CSS で縮めています。その場合 width が 1920 なので、帯の関数は width を使って大丈夫です。このサイトのプレイヤーは窓の大きさに合わせてキャンバスを作り、中で設計座標へ変換しています。重い 1080p バッファを常時持たないためです。数字の意味は同じです。

04

上の帯 — 名前と時刻

上帯に載せるものは少なくします。左にキッカー(シリーズ名や番号)、その下に曲名。右に区間名とタイムコード。帯の下端に、曲全体のプログレスを 3px だけ。説明文や歌詞は置きません。上は「これは何か」を一瞬で読む場所です。

塗りは、下端を透明にする

ソリッドな矩形は情報の地として安定します。同時に、絵を水平な線で切ります。MVでは多くの場合、上端を rgba(6,6,8,0.75) 前後、帯の下端を透明にした線形グラデーションにします。境界線の代わりに、絵の明度が少しずつ戻ってきます。

ソリッド。 境界がフレームに対して水平に切れる。
グラデーション。 帯の内側が透明になり、絵へ溶ける。

グラデーションは p5 の fill() では作れません。drawingContext から Canvas の createLinearGradient を使います。座標は、すでに scale() した設計空間です。

上帯
// キャンバスは 1920×1080、pixelDensity(1) を想定。
// このとき width = 1920, height = 1080。
const TOP_H = 112;
const INSET = 96;

function drawTopOverlay() {
  const ctx = drawingContext;
  // 上端は濃く、帯の下端で透明。境界線を作らない。
  const g = ctx.createLinearGradient(0, 0, 0, TOP_H);
  g.addColorStop(0, "rgba(6, 6, 8, 0.75)");
  g.addColorStop(0.55, "rgba(6, 6, 8, 0.34)");
  g.addColorStop(1, "rgba(6, 6, 8, 0)");
  ctx.save();
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, width, TOP_H);
  ctx.restore();

  // 収録ランプ。ビートでは透明度だけを動かす。
  noStroke();
  fill(255, 90, 31);
  circle(INSET, 36, 8);

  textFont("IBM Plex Sans JP");
  textAlign(LEFT, BASELINE);
  textSize(15);
  fill(244, 239, 230, 190);
  ctx.letterSpacing = "0.18em";
  text("MV STUDY  /  01", INSET + 18, 42);
  ctx.letterSpacing = "0px";

  textFont("Shippori Mincho");
  textSize(42);
  fill(246, 242, 234);
  text("夜の枠線", INSET, 84);

  // 右上は「いまどの区間か」とタイムコード。
  textFont("IBM Plex Sans JP");
  textAlign(RIGHT, BASELINE);
  textSize(13);
  fill(244, 239, 230, 170);
  text("Aメロ", width - INSET, 40);

  textFont("IBM Plex Mono");
  textSize(22);
  fill(246, 242, 234);
  text(timecode(t), width - INSET, 70);

  // プログレスは帯の下端。絵の中央には置かない。
  const span = width - INSET * 2;
  noStroke();
  fill(255, 255, 255, 36);
  rect(INSET, TOP_H - 8, span, 3);
  fill(255, 90, 31);
  rect(INSET, TOP_H - 8, span * (t / duration), 3);
}

function timecode(seconds) {
  const fps = 30;
  const frames = Math.floor(seconds * fps);
  const ff = frames % fps;
  const total = Math.floor(frames / fps);
  const mm = Math.floor(total / 60);
  const ss = total % 60;
  const pad = (n) => String(n).padStart(2, "0");
  return pad(mm) + ":" + pad(ss) + ":" + pad(ff);
}

中のグリッド

  • 収録ランプは左インセット上。ビートに反応させるなら、半径ではなく透明度だけ。
  • キッカーは 15px、字間を広げ、曲名より薄い。
  • 曲名のベースラインは上端から 84px。帯が 112px なら、下のプログレスとぶつかりません。
  • タイムコードは MM:SS:FF。FF はミリ秒ではなく、30fps なら 0 から 29 のフレーム番号です。
  • プログレスの幅は (曲の経過 / 曲の長さ)。行の進みとは別です。行の進みは下帯に置きます。

曲名の登場は、横方向のクリップで 0.85 秒ほどかけています。帯の高さはアニメーションしません。高さが変わると、その下の絵が毎フレームずれて見えます。

05

下の帯 — いまの行

下帯の主情報は、いま歌っている一行です。次の行は同じ左端(または同じ中心)に、小さく、薄く置きます。クレジットはさらに下、13px 程度で、帯が 150px より低いときは消します。狭い帯に三行を詰めると、主行が読めなくなります。

全面の帯と、ローワーサード

歌詞のように行が長いときは、下端いっぱいの帯にします。左右インセットは上帯と同じ 96px です。視線が上下で揃います。

人名、役名、ロケの地名は、ローワーサードにします。これは画面の下から三分の一に置くカードで、幅はフレームのおよそ半分です。左辺に 4px のアクセントを一本入れると、帯ではなく「名札」に見えます。カードの後ろには、ごく薄い下端グラデーションだけ残します。カードの角と絵のコントラストが足りないときのためです。

下帯(歌詞)
const BOTTOM_H = 176;
const INSET = 96;

function drawBottomOverlay() {
  const y = height - BOTTOM_H;
  const ctx = drawingContext;
  const g = ctx.createLinearGradient(0, y, 0, height);
  g.addColorStop(0, "rgba(6, 6, 8, 0)");
  g.addColorStop(0.42, "rgba(6, 6, 8, 0.38)");
  g.addColorStop(1, "rgba(6, 6, 8, 0.72)");
  ctx.save();
  ctx.fillStyle = g;
  ctx.fillRect(0, y, width, BOTTOM_H);
  ctx.restore();

  // 主行。ベースラインは下端から 86px。帯の高さを変えてもこの値は維持する。
  textFont("Shippori Mincho");
  textAlign(LEFT, BASELINE);
  textSize(46);
  fill(246, 242, 234);
  text(line.text, INSET, height - 86);

  // 行の進み。文字幅に対する割合なので、帯の幅とは独立。
  const w = textWidth(line.text) * line.progress;
  noStroke();
  fill(255, 90, 31);
  rect(INSET, height - 76, w, 2);

  // 次行は同じ x、小さい、薄い。
  textFont("IBM Plex Sans JP");
  textSize(22);
  fill(244, 239, 230, 120);
  text(line.next, INSET, height - 46);

  textSize(13);
  fill(244, 239, 230, 140);
  text("枠線バンド    設計図    1920×1080", INSET, height - 22);
}
ローワーサード
// ローワーサードは全面の帯ではない。
// 下から三分の一に、幅 40–55% のカードを置く。
function drawLowerThird() {
  const cardW = 980;
  const cardH = 156;
  const x = 96;
  const y = height - 48 - cardH;

  noStroke();
  fill(8, 8, 10, 210);
  rect(x, y, cardW, cardH);
  fill(255, 90, 31);
  rect(x, y, 4, cardH);

  textFont("IBM Plex Sans JP");
  textAlign(LEFT, BASELINE);
  textSize(13);
  fill(255, 90, 31);
  drawingContext.letterSpacing = "0.16em";
  text("Aメロ", x + 28, y + 32);
  drawingContext.letterSpacing = "0px";

  textFont("Shippori Mincho");
  textSize(36);
  fill(246, 242, 234);
  text(line.text, x + 28, y + cardH * 0.62);

  textFont("IBM Plex Sans JP");
  textSize(16);
  fill(244, 239, 230, 140);
  text(line.next, x + 28, y + cardH * 0.84);
}

主行の下には、その行の進みを文字幅いっぱいの 2px で引きます。上のプログレスが曲全体、下のバーがいまの行、と尺度が分かれます。バーの幅は帯の幅ではなく textWidth(行) * 行の進捗 です。

06

文字は、ベースラインで組む

帯の中の文字は textAlign(LEFT, BASELINE) か CENTER, BASELINE で置きます。上端揃えにすると、書体を変えた瞬間に行が飛びます。ベースラインを固定しておけば、明朝の曲名とゴシックの次行を同じ定規で比べられます。

このサイトでは曲名と歌詞に Shippori Mincho、ラベルに IBM Plex Sans JP、タイムコードに IBM Plex Mono を使っています。Canvas は CSS の継承では書体を拾いません。document.fonts.load() で必要な字を読んでから、最初のフレームを描きます。p5 エディタでは、同じ書体の link を HTML に足すか、読めない環境では serif / sans-serif に落ちます。

字間は p5 の API にはありません。キッカーのような欧文ラベルだけ、drawingContext.letterSpacing = "0.18em" を使い、描いたら "0px" に戻します。戻し忘れると、歌詞まで間延びします。対応していないブラウザでは、このプロパティを無視して詰めのまま描きます。

行がインセットの内側に収まらないときは、帯を広げる前にサイズを 2px ずつ落とします。下限は歌詞 24px、曲名 22px です。それより小さい日本語は、MVの視聴距離では読めません。

07

動かすのは位置と透明度だけ

行が替わる 0.45 秒だけ、前の行を上へ 26px 送って消し、新しい行を 18px 下からベースラインへ置きます。イージングは ease-out cubic です。帯の高さ、文字サイズ、インセットはこの間も固定です。

行の入れ替え
const INSET = 96;

// since = 行が始まってからの秒。0.45秒で着地。
const e = easeOutCubic(min(1, since / 0.45));

textFont("Shippori Mincho");
textAlign(LEFT, BASELINE);
textSize(46);

// 出ていく行は上へ、消える。
if (prev && e < 1) {
  fill(246, 242, 234, 255 * (1 - e));
  text(prev, INSET, (height - 86) - 26 * e);
}
// 入ってくる行は 18px 下から、ベースラインへ。
fill(246, 242, 234, 255 * e);
text(current, INSET, (height - 86) + (1 - e) * 18);

function easeOutCubic(t) {
  return 1 - Math.pow(1 - t, 3);
}

ビートに反応させるなら、収録ランプの透明度と、絵の中の棒の高さだけにします。帯全体を拡大すると、文字が毎拍ジャンプして読めなくなります。ビートの値は拍の位置から指数的に減衰させ、レイアウトの計算には入れません。

08

セーフエリアと、短い帯は両立しない

アクションセーフは端から 5%。1920×1080 なら左右 96px、上下 54px です。この工房の文字の左端は、この線に揃えています。タイトルセーフは端から 10%、左右 192px、上下 108px です。放送で端が切られても残したい文字は、こちらの内側に置きます。

上帯を 112px にすると、帯の下端は y=112 です。タイトルセーフの上端は y=108 です。つまり、短い上帯の中にタイトルセーフを収める余地はほとんどありません。配信の MV として端まで絵を使うなら短い帯を選び、放送セーフを優先するなら文字のベースラインを 108px より下に下げ、帯もそれに合わせて高くします。ガイドを出して、オレンジの枠と文字の位置を見比べてください。

09

書き出しに残るものだけを、帯にする

saveCanvas()、キャンバスの captureStream()、CCapture が記録するのは、キャンバスのピクセルです。DOM をキャンバスの上に重ねた HTML の曲名は、どれにも入りません。ブラウザ上では見えていても、書き出した動画では消えます。

スタジオの「このフレームを PNG」は、いまの上帯と下帯を含んだ画像を保存します。帯を消してから保存すると、絵だけが残ります。書き出し用のスケッチではセーフガイドの呼び出しを外し、pixelDensity(1) で 1920×1080 をそのまま出します。プレビュー用の devicePixelRatio を掛けたバッファをマスターにしないためです。

マスターでは設計座標への scale() は不要です。キャンバスがすでに 1920×1080 だからです。プレビューとマスターで帯の関数を共有するなら、関数の中では width ではなく設計幅を使い、変換は draw() の入口だけに置きます。

10

p5 エディタに貼る最小スケッチ

下のスケッチは、絵を仮の月と地平線にし、上帯と下帯だけを本物の手順で描きます。再生位置 t は deltaTime で進め、曲の長さを超えたら 0 に戻します。行は開始秒の配列から探します。

skeleton.js
let t = 0;
const duration = 36;
const lines = [
  { t: 0, section: "Aメロ", text: "画面の上で、名前が灯る" },
  { t: 4, section: "Aメロ", text: "下のほうで、言葉が息をする" },
  { t: 8, section: "Aメロ", text: "真ん中は、月のために空けて" },
  { t: 12, section: "Bメロ", text: "帯は、絵が終わってから" },
];

function setup() {
  const c = createCanvas(1920, 1080);
  pixelDensity(1);
  c.elt.style.width = "100%";
  c.elt.style.height = "auto";
  textFont("serif");
}

function draw() {
  t += deltaTime / 1000;
  if (t > duration) t = 0;
  drawPicture(t);
  drawTop(t);
  drawBottom(t);
}

function drawPicture(time) {
  background(18, 22, 38);
  noStroke();
  fill(244, 236, 224);
  circle(1400, 280, 140);
  fill(255, 90, 31, 40);
  rect(0, 790, width, 4);
}

function drawTop(time) {
  const h = 112;
  const ctx = drawingContext;
  const g = ctx.createLinearGradient(0, 0, 0, h);
  g.addColorStop(0, "rgba(6,6,8,0.75)");
  g.addColorStop(1, "rgba(6,6,8,0)");
  ctx.save();
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, width, h);
  ctx.restore();

  const line = lineAt(time);
  fill(246, 242, 234);
  textAlign(LEFT, BASELINE);
  textSize(40);
  text("夜の枠線", 96, 84);
  textAlign(RIGHT, BASELINE);
  textSize(22);
  text(line.section, width - 96, 70);

  noStroke();
  fill(255, 90, 31);
  rect(96, h - 8, (width - 192) * (time / duration), 3);
}

function drawBottom(time) {
  const h = 176;
  const y = height - h;
  const ctx = drawingContext;
  const g = ctx.createLinearGradient(0, y, 0, height);
  g.addColorStop(0, "rgba(6,6,8,0)");
  g.addColorStop(1, "rgba(6,6,8,0.72)");
  ctx.save();
  ctx.fillStyle = g;
  ctx.fillRect(0, y, width, h);
  ctx.restore();

  const line = lineAt(time);
  fill(246, 242, 234);
  textAlign(LEFT, BASELINE);
  textSize(46);
  text(line.text, 96, height - 86);
  fill(244, 239, 230, 120);
  textSize(22);
  text(line.next, 96, height - 46);
}

function lineAt(time) {
  let i = 0;
  for (let k = 0; k < lines.length; k++) {
    if (lines[k].t <= time) i = k;
  }
  const next = lines[i + 1] ? lines[i + 1].text : lines[0].text;
  return { text: lines[i].text, next, section: lines[i].section };
}

ここから先は、絵の関数だけを差し替えます。帯の高さとインセットは定数のまま残してください。絵が変わっても、歌詞のベースラインは下端から 86px のままです。