Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions skills/motion-render/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ motion-render narrate <work>

1. `motion-render kit <work>` で `<work>/film/` にプレーヤー・フォント・GSAP・場面の秒数を置く
2. [references/html-film.md](references/html-film.md) を読み、その決まりに従って `<work>/film/index.html` を書く。
先に「作る前に決めること」を依頼文とテーマから決め、ページの先頭に書く。
場面は `<section class="scene" id="<場面id>">`、動きは `film.build` の中で `film.at(場面id, 秒)` に置く
3. 並行して BGM を作ってよい: `motion-render bgm <work>`
4. 効果音を付けるときは、`script.json` の `sfx` の `at` に、その動きに渡した `film.at` の秒を書き、
Expand Down
10 changes: 10 additions & 0 deletions skills/motion-render/kit/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ section.scene {
.num {
font-variant-numeric: tabular-nums;
}
/* film.typeIn: characters rise from below the line, clipped by it. */
.type-mask {
display: inline-block;
overflow: hidden;
vertical-align: top;
padding-bottom: 0.08em;
}
.type-char {
display: inline-block;
}

/* Stacking by role, lowest first. Give every positioned element one of these instead of a
z-index of its own, so what is drawn on top never depends on the order in the HTML. */
Expand Down
108 changes: 102 additions & 6 deletions skills/motion-render/kit/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,47 @@
}
return { x: x, y: y, w: e.offsetWidth, h: e.offsetHeight };
}
function htmlParent(e) {
var p = e.parentNode;
while (!(p instanceof HTMLElement)) p = p.parentNode;
return p;
}
// How many times the transforms above `h` scale it on screen.
function scaleOf(h) {
return h.offsetWidth ? h.getBoundingClientRect().width / h.offsetWidth : 1;
}
// Frame px per unit of GSAP's x/y on `e`. Inside an <svg>, GSAP moves a shape in the svg's own
// units, which a viewBox can make larger or smaller than px.
function unit(e) {
if (!(e instanceof SVGElement) || !e.ownerSVGElement) return 1;
return e.parentNode.getScreenCTM().a / scaleOf(htmlParent(e));
}
// The center of `e` in frame px, leaving out its own GSAP x/y. SVG elements have no layout box,
// so they are measured on screen against their nearest HTML parent.
function center(e) {
if (e instanceof HTMLElement) {
var b = layoutBox(e);
return { x: b.x + b.w / 2, y: b.y + b.h / 2 };
}
var h = htmlParent(e),
hb = layoutBox(h),
s = scaleOf(h),
u = unit(e),
r = e.getBoundingClientRect(),
hr = h.getBoundingClientRect();
return {
x: hb.x + (r.x + r.width / 2 - hr.x) / s - Number(gsap.getProperty(e, "x")) * u,
y: hb.y + (r.y + r.height / 2 - hr.y) / s - Number(gsap.getProperty(e, "y")) * u,
};
}

// Latin words and numbers stay whole so they do not break apart; everything else goes one
// character at a time. Spaces become no-break spaces so an inline-block keeps their width.
function glyphs(text) {
return (text.match(/[A-Za-z0-9.,'’%+\-]+|\s+|[\s\S]/gu) || []).map(function (s) {
return /^\s+$/.test(s) ? "\u00a0" : s;
});
}

window.film = {
scenes: scenes,
Expand All @@ -47,21 +88,24 @@
builders.push(fn);
},
// GSAP x/y that put the center of `what` on the center of `target`, moved by (dx, dy).
// Use it to send a chip to a card instead of working out pixel offsets by hand.
// Use it to send a chip to a card instead of working out pixel offsets by hand. Either may be an
// SVG element, such as an <svg> icon or a shape inside a diagram.
spot: function (what, target, dx, dy) {
var a = layoutBox(el(what)),
b = layoutBox(el(target));
var w = el(what),
a = center(w),
b = center(el(target)),
u = unit(w);
return {
x: b.x + b.w / 2 - (a.x + a.w / 2) + (dx || 0),
y: b.y + b.h / 2 - (a.y + a.h / 2) + (dy || 0),
x: (b.x - a.x + (dx || 0)) / u,
y: (b.y - a.y + (dy || 0)) / u,
};
},
// Sends `what` to `target` along an arc that rises `lift` px above the straight line, starting
// `at` seconds into the film. o: { dur = 0.9, lift = 120, dx, dy }.
arc: function (tl, what, target, at, o) {
o = o || {};
var dur = o.dur || 0.9,
lift = o.lift === undefined ? 120 : o.lift,
lift = (o.lift === undefined ? 120 : o.lift) / unit(el(what)),
to = window.film.spot(what, target, o.dx, o.dy);
tl.to(what, { x: to.x, duration: dur, ease: "power2.inOut" }, at);
// The start y is read when the tween first renders, so earlier moves of `what` count.
Expand Down Expand Up @@ -98,11 +142,56 @@
at
);
},
// Raises the text of `what` one character at a time from below a mask line, starting `at`.
// The text is split into spans the first time, so call it on an element that holds only text.
// o: { dur = 0.5, stagger = 0.04 }.
typeIn: function (tl, what, at, o) {
o = o || {};
var e = el(what);
if (!e.querySelector(".type-mask")) {
if (e.children.length)
throw new Error("typeIn needs an element that holds only text: " + what);
var mask = document.createElement("span");
mask.className = "type-mask";
glyphs(e.textContent.trim()).forEach(function (g) {
var c = document.createElement("span");
c.className = "type-char";
c.textContent = g;
mask.appendChild(c);
});
e.textContent = "";
e.appendChild(mask);
}
// Hidden until its turn, so a character waiting below the mask line is not laid out as a
// box sticking out of the line.
return tl.fromTo(
e.querySelectorAll(".type-char"),
{ yPercent: 110, autoAlpha: 0 },
{
yPercent: 0,
autoAlpha: 1,
duration: o.dur || 0.5,
ease: "expo.out",
stagger: o.stagger === undefined ? 0.04 : o.stagger,
},
at
);
},
};

function sections() {
return Array.prototype.slice.call(document.querySelectorAll("section.scene"));
}
function repeatedIds() {
var seen = {},
twice = [];
document.querySelectorAll("[id]").forEach(function (e) {
if (!e.id) return;
if (seen[e.id] && twice.indexOf(e.id) < 0) twice.push(e.id);
seen[e.id] = true;
});
return twice;
}
// A scene's section is shown over [start, start + dur); everything outside sections stays.
function show(t) {
sections().forEach(function (el) {
Expand Down Expand Up @@ -146,6 +235,13 @@
});
if (unknown.length)
throw new Error("section.scene ids not in timing.json: " + unknown.join(", "));
var twice = repeatedIds();
if (twice.length)
throw new Error(
"ids used more than once: " +
twice.join(", ") +
". A selector #id reaches all of them, so hiding or moving one also hides or moves the others"
);
await loadFonts();
builders.forEach(function (fn) {
fn(timeline);
Expand Down
62 changes: 60 additions & 2 deletions skills/motion-render/references/html-film.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Claude は `<work>/film/index.html` だけを書く。描画中はネットワ
<div id="root">
<!-- 場面をまたいで残るもの(図の全体) -->
<div id="stage">…</div>
<!-- 場面ごとのもの。id は timing.json の場面 id と同じにする -->
<!-- 場面ごとのもの。id は timing.json の場面 id と同じにする。ページの中で同じ id を 2 度使わない -->
<section class="scene" id="title">…</section>
<section class="scene" id="problem">…</section>
</div>
Expand All @@ -64,8 +64,22 @@ Claude は `<work>/film/index.html` だけを書く。描画中はネットワ
| `film.spot(要素, 相手, dx, dy)` | `要素` の中心を `相手` の中心へ(`dx`・`dy` だけずらして)動かす GSAP の `x`・`y` |
| `film.arc(tl, 要素, 相手, 秒, o)` | `要素` を `相手` へ山なりに動かす。o: `dur`(既定 0.9)・`lift`(既定 120px)・`dx`・`dy` |
| `film.breathe(tl, 要素, id, 秒, 量)` | `秒` から場面 `id` の終わりまで、`要素` を `量`(既定 0.01)だけゆっくり拡大縮小する |
| `film.typeIn(tl, 要素, 秒, o)` | `要素` の文字を 1 文字ずつ下から立ち上げる。o: `dur`(既定 0.5)・`stagger`(既定 0.04) |
| `section.scene` | その場面の間だけ見える。`#stage` のように section の外に置いたものはずっと見える |

## 作る前に決めること

`index.html` を書き始める前に、ページの先頭に HTML のコメントで次を 5〜8 行で書く。依頼文に書かれた指定
(雰囲気、対象の人、色)とテーマから決め、利用者には聞かない。場面を書くたびにこのコメントに照らして、
外れたものを直す。

- **1 本を通す発想を 1 つ**: 「1 枚の図が書き足されていく」「1 つのトークンが旅をする」のように、
全場面をつなぐ見せ方。場面ごとの見せ方も 1 つに絞る
- **配色**: 地・面・線・文字に加えて、強調 1 色と危険 1 色。強調色を増やさない
- **文字の役割**: 見出し・本文・字幕・数字(`num`)の大きさと太さ
- **動きの決まり**: 出る・移る・消えるの緩急と、止めて読ませる長さ。同じ役割の物には同じ動きを使う
- **この 1 本で使わないもの**: 下の「抑えるもの」から、テーマに合わせて足す

## 配置の決まり

`motion-render check` はこれを破った所を見つけて止める。最初から守って書く。
Expand All @@ -92,7 +106,8 @@ Claude は `<work>/film/index.html` だけを書く。描画中はネットワ
| `layer-caption` | 字幕 |

4. **チップの行き先は `film.spot` で決める。** 止まる位置は、カードの中に収まるか、どのカードとも
重ならない所にする。カードの枠線をまたいで止めない
重ならない所にする。カードの枠線をまたいで止めない。SVG の要素(アイコンの `<svg>`、図の中の図形)も
`film.spot`・`film.arc` で動かせる
5. **箱は親の中に収める。** 親より大きくなりそうな中身は、文字を短くするか親を広げる。
はみ出しを `overflow: hidden` で隠さない
6. **前の場面の残りを片付ける。** 次の場面で使わない部品は、場面の頭で消すか薄くし、新しい部品と
Expand Down Expand Up @@ -155,6 +170,49 @@ motion-render check <work>/film
`film.breathe` で揺らす要素の `scale` は、同じ時間帯に別の動きで変えない。寄せる動きは、
`film.breathe` を付けた要素の親に付ける。

### 抑えるもの

映像の格は、足す物より削る物で決まる。テーマがはっきり求めるとき以外は使わない。

- 粒子の飛び散り、光のにじみ、レンズの光、画面全体の揺れ、色ずれ
- 意味のない回転や立体の回転、理由のないズーム。寄せは場面ごとの 2〜3% だけにする
- 動き方の種類を増やすこと。出方は 2〜3 種類に絞る
- 印と数字以外の跳ねる動き。全部の物が同じ瞬間に動き出して同じ瞬間に止まること
- 説明の長い文を画面に置くこと。画面の文字は字幕と短いラベルにする
- 内容と関係のない飾り(漂う図形、意味のない背景の模様)

### 場面を物でつなぐ

境目で全部を消して新しく出し直すと、スライドをめくっているように見える。前の場面に残っている物を、
次の場面の部品に変えてつなぐ。

- 次の場面でも使う物は section の外(`#stage`)に置き、場面の頭で `film.spot` で新しい位置へ動かし、
大きさ・色・角の丸みを変える。例: カードが縮んで次の場面の流れ図の 1 段になる、グラフの線が伸びて
次の場面のつなぐ線になる、数字が次の場面の見出しの数字になる
- 最初から最後まで画面に残る主役の物(トークン、鍵、1 本の線)を 1 つ決めると、場面が 1 本につながる
- 全部を消して出し直すのは、話の章が変わる所だけにする
- `#stage` の物は場面が変わっても消えないので、使い終わったら自分で消す

### 文字を動かす

見出しと決めの言葉は、ふわっと出さずに 1 文字ずつ下から立ち上げる:
`film.typeIn(tl, "#t-title", film.at("title", 0.4))`。

- 文字だけを持つ要素に使う(中の子要素は文字に置き換わる)。1 行の見出し向けで、2 行なら行ごとに要素を分ける
- 言葉の意味と同じ向きに動かす: 「重い」は沈む、「支える」は下から持ち上げる、「詰まる」は字間を詰める
- 字幕と本文には使わない(読む速さが落ちる)。1 場面に 1 か所まで

### 仕組みを本物で見せる

説明の対象が過程(拡散、待ち行列、ランダムな歩み、増え方、ぶつかり合い)なら、それらしい絵を描く
代わりに、小さなシミュレーションを動かして見せる。

- 乱数はシード付きの関数(例: mulberry32)で作り、時刻 t だけで状態が決まるように書く。
毎回 0 から t まで計算し直してよい(下の「決まった絵になるように」と同じ理由)
- 数字は実際の値を使い、原稿の数字と合わせる。推定の値には「約」を付ける
- 機械や構造は分解図で見せる: 部品が名前のラベルを付けて離れ、また組み上がる
- 大きさが桁で違うもの(地球から原子まで)は、目盛りを付けた連続のズームで見せる

## 文字

- `<html lang="ja">` と `kit/base.css` で、見出しは文節の切れ目で改行される。長いカタカナ語
Expand Down
Loading
Loading