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
42 changes: 42 additions & 0 deletions skills/motion-render/kit/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,48 @@
y: b.y + b.h / 2 - (a.y + a.h / 2) + (dy || 0),
};
},
// 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,
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.
tl.to(
what,
{
y: function (i, e) {
return (Number(gsap.getProperty(e, "y")) + to.y) / 2 - lift;
},
duration: dur / 2,
ease: "sine.out",
},
at
);
tl.to(what, { y: to.y, duration: dur / 2, ease: "sine.in" }, at + dur / 2);
return tl;
},
// Keeps `what` gently scaling by `amount` (0.01 = 1%) from `at` until scene `id` ends, so a held
// frame is never fully still. Do not tween the element's scale in the same span.
breathe: function (tl, what, id, at, amount) {
// An even number of halves, so the element is back at its own size when the scene ends.
var half = 1.5,
n = Math.floor((window.film.end(id) - at) / half / 2) * 2;
if (n < 2) return tl;
return tl.to(
what,
{
scale: 1 + (amount || 0.01),
duration: half,
ease: "sine.inOut",
repeat: n - 1,
yoyo: true,
},
at
);
},
};

function sections() {
Expand Down
45 changes: 37 additions & 8 deletions skills/motion-render/references/html-film.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,15 @@ Claude は `<work>/film/index.html` だけを書く。描画中はネットワ

プレーヤーが用意するもの:

| 名前 | 意味 |
| ------------------------------- | ---------------------------------------------------------------------------------- |
| `film.build(fn)` | フォントの読み込みが終わってから `fn(tl)` を呼ぶ。アニメーションは全部この中に書く |
| `film.at(id, 秒)` | 場面 `id` の頭から何秒目か(動画の先頭からの秒に直す) |
| `film.end(id, 秒)` | 場面 `id` の終わりの何秒前か |
| `film.spot(要素, 相手, dx, dy)` | `要素` の中心を `相手` の中心へ(`dx`・`dy` だけずらして)動かす GSAP の `x`・`y` |
| `section.scene` | その場面の間だけ見える。`#stage` のように section の外に置いたものはずっと見える |
| 名前 | 意味 |
| ------------------------------------ | ----------------------------------------------------------------------------------------- |
| `film.build(fn)` | フォントの読み込みが終わってから `fn(tl)` を呼ぶ。アニメーションは全部この中に書く |
| `film.at(id, 秒)` | 場面 `id` の頭から何秒目か(動画の先頭からの秒に直す) |
| `film.end(id, 秒)` | 場面 `id` の終わりの何秒前か |
| `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)だけゆっくり拡大縮小する |
| `section.scene` | その場面の間だけ見える。`#stage` のように section の外に置いたものはずっと見える |

## 配置の決まり

Expand All @@ -71,7 +73,8 @@ Claude は `<work>/film/index.html` だけを書く。描画中はネットワ
1. **関係のあるものは、親子にしてレイアウトで並べる。** カードの中身(見出し、説明、チェック項目、
中に入るチップ)はカードの子要素にし、`display: flex` / `grid` と `gap` で並べる。
画面全体の座標(`position: absolute; left: …px`)で 1 つずつ置くのは、図の大きな部品(カード、
字幕、流れ図)だけにする
字幕、流れ図)だけにする。子の箱(アイコン、下線)を持つ要素の文字は `<span>` で包む。
文字を直に持つ要素は `check` が文字の範囲で測るので、文字の外にある子がはみ出しと出る
2. **印は、判定する相手の中に置く。** ✕・✓・バッジ・番号は、それが付く要素の子にする
(`<div class="chip">OIDC トークン<span class="mark">✕</span></div>`)。2 つの要素の間の
空いた所に浮かせない。拒否を見せるときは、チップを相手にぶつけて跳ね返し、チップに ✕ を出し、
Expand Down Expand Up @@ -123,6 +126,32 @@ motion-render check <work>/film
- 場面の最後の動きのあと、1.5 秒以上止めて読ませる
- 秒数は `film.at` / `film.end` だけで書く。ナレーションを作り直しても `build` が秒数を読み直す

### 動きを豊かにする

文字のチップが出て止まるだけの映像は、内容が正しくても単調に見える。仕組みを説明する映像では、
画面のどこかが動いている時間を全体の 3 割以上にする。次をどの場面でも使う。

1. **名詞は絵にする。** 鍵・人・サーバー・書類のように説明の主役になるものは、インラインの SVG で
描く(線の太さをそろえた単純な形、色は `currentColor`)。文字だけのチップは補助に回す
2. **移動は弧を描かせ、1 対多はずらして動かす。** `film.arc(tl, 要素, 相手, 秒, { lift })` で山なりに
飛ばす。1 つのものが複数へ配られる・漏れる場面は、コピーを 0.2〜0.3 秒ずつずらして飛ばす
3. **出るときは少し跳ねる。** アイコン・数字・印は `scale` 0.6→1 を `back.out(1.7)` で出す。
文字の行や大きなカードは跳ねさせず、`expo.out` で下から出す
4. **止まっている間も完全には止めない。** 場面の部品をまとめた要素を、場面の頭から終わりまで
`scale` 1→1.02〜1.03 にゆっくり寄せる(`ease: "none"`)。いま注目させる要素には
`film.breathe(tl, 要素, 場面, 秒, 0.01)` で微かな呼吸を付ける。決定的な瞬間(漏れる・拒否される)は、
0.3 秒ほど小刻みに揺らすか、枠を一度光らせる
5. **届いたら相手が変わる。** チップが着いた瞬間に、相手の枠の色・アイコンの明るさを変える。
受け取ったことが色で分かるようにする
6. **線は引く。** 矢印・下線・取り消し線は、`scaleX` 0→1(`transform-origin` を始点に置く)で描く。
ふわっと出さない
7. **行って帰るまで見せ、結果は両方見せる。** 何かを受け取る流れは、受け取ったものを頼んだ側へ
届けるところまで動かす(その場に出して終わりにしない)。条件で通す・止める場面は、通るものを
先に通してから、止まるものを止める。移動は画面の端から端まで使ってよい

`film.breathe` で揺らす要素の `scale` は、同じ時間帯に別の動きで変えない。寄せる動きは、
`film.breathe` を付けた要素の親に付ける。

## 文字

- `<html lang="ja">` と `kit/base.css` で、見出しは文節の切れ目で改行される。長いカタカナ語
Expand Down
4 changes: 3 additions & 1 deletion skills/motion-render/scripts/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export interface LayoutIssue {
}

const SLACK = 2; // px of overlap that reads as touching, not overlapping
const MOVED = 1; // px a box may shift between samples and still count as still
// px a box may shift between samples and still count as still. A slow push-in or a breathing card
// moves 1-2 px per sample and is still to the eye; a chip on its way moves tens of px.
const MOVED = 3;

type Rect = Pick<LayoutBox, "x" | "y" | "w" | "h">;

Expand Down
51 changes: 46 additions & 5 deletions tests/html-film.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,17 @@ const TIMING = {
],
};

// The same with a deny scene long enough to breathe in.
const LONG = {
total: 5,
scenes: [TIMING.scenes[0]!, { ...TIMING.scenes[1]!, dur: 4 }],
};

// The scene of the WIF film that had the mistake: a token turned away at a condition box. With
// `wrong`, the ✕ is placed on its own and half of it ends up under the box.
function page(wrong: boolean): string {
// With `lively`, the chip flies to the dock on an arc and the condition box breathes, as
// references/html-film.md asks for.
function page(wrong: boolean, lively = false): string {
const mark = wrong
? `<div id="deny" style="position:absolute; left:868px; top:300px; font-size:64px">✕</div>
<div class="chip">OIDC トークン</div>`
Expand All @@ -47,17 +55,22 @@ function page(wrong: boolean): string {
<script>
film.build(function (tl) {
tl.fromTo("#title", { opacity: 0 }, { opacity: 1, duration: 0.3 }, film.at("intro", 0.1));
tl.to(".chip", { ...film.spot(".chip", "#dock"), duration: 0.3 }, film.at("deny", 0.1));
${
lively
? `film.arc(tl, ".chip", "#dock", film.at("deny", 0.1), { dur: 0.6, lift: 150 });
film.breathe(tl, "#cond", "deny", film.at("deny", 0), 0.01);`
: `tl.to(".chip", { ...film.spot(".chip", "#dock"), duration: 0.3 }, film.at("deny", 0.1));`
}
});
</script>
</body></html>`;
}

function htmlFilm(wrong: boolean): string {
function htmlFilm(wrong: boolean, lively = false): string {
const work = tempDir("mr-html-");
writeFileSync(join(work, "timing.json"), JSON.stringify(TIMING));
writeFileSync(join(work, "timing.json"), JSON.stringify(lively ? LONG : TIMING));
const film = writeKit(work);
writeFileSync(join(film, "index.html"), page(wrong));
writeFileSync(join(film, "index.html"), page(wrong, lively));
return film;
}

Expand Down Expand Up @@ -106,6 +119,34 @@ describe("html film", () => {
}
});

it("moves a chip on an arc, and still checks a box that breathes", async () => {
const film = await openFilm(htmlFilm(true, true));
try {
// inspect seeks the page, so call it one time at a time.
const box = async (t: number, l: string) =>
((await film.inspect?.(t)) ?? []).find((x) => x.label.startsWith(l));
const center = async (t: number, l: string) => {
const b = await box(t, l);
return b ? [b.x + b.w / 2, b.y + b.h / 2] : [0, 0];
};
const [x0 = 0, y0 = 0] = await center(1, "div.chip");
const [xm = 0, ym = 0] = await center(1.4, "div.chip");
const [x1 = 0, y1 = 0] = await center(1.7, "div.chip");
// Halfway along x, the chip is well above the straight line from start to dock.
expect(xm).toBeGreaterThan(x0);
expect(xm).toBeLessThan(x1);
expect((y0 + y1) / 2 - ym).toBeGreaterThan(100);
expect([x1, y1].map(Math.round)).toEqual((await center(1.7, "#dock")).map(Math.round));
// #cond keeps breathing, yet the ✕ half under it is still reported.
const w1 = (await box(1, "#cond"))?.w ?? 0;
expect((await box(2.5, "#cond"))?.w ?? 0).toBeGreaterThan(w1 + 2);
const issues = (await checkLayout(film))?.map(formatIssue) ?? [];
expect(issues.some((i) => /#cond .* and #deny "✕" partly overlap/.test(i))).toBe(true);
} finally {
await film.close();
}
});

it("fails to load with a pointer to the kit when the font is missing", async () => {
const dir = htmlFilm(false);
rmSync(join(dir, "kit", "fonts"), { recursive: true });
Expand Down
7 changes: 7 additions & 0 deletions tests/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ describe("findLayoutIssues", () => {
expect(findLayoutIssues(samples)).toHaveLength(1);
});

it("counts a box that drifts a couple of px per sample (push-in, breathing) as still", () => {
const card = box(1, [0, 0, 100, 50]);
const drift = (i: number) => box(2, [80 + 2 * i, 10, 100 + 2 * i, 50]);
const samples = [0, 1, 2].map((i) => ({ t: i / 5, boxes: [card, drift(i)] }));
expect(findLayoutIssues(samples).map((i) => i.kind)).toEqual(["overlap"]);
});

it("reports a box sticking out of its parent, out of the frame, or hidden under another", () => {
const boxes = [
box(1, [100, 100, 300, 200]),
Expand Down
Loading