diff --git a/skills/motion-render/kit/player.js b/skills/motion-render/kit/player.js index 2176488..6533d34 100644 --- a/skills/motion-render/kit/player.js +++ b/skills/motion-render/kit/player.js @@ -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() { diff --git a/skills/motion-render/references/html-film.md b/skills/motion-render/references/html-film.md index 213c145..daf7601 100644 --- a/skills/motion-render/references/html-film.md +++ b/skills/motion-render/references/html-film.md @@ -56,13 +56,15 @@ Claude は `/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 の外に置いたものはずっと見える | ## 配置の決まり @@ -71,7 +73,8 @@ Claude は `/film/index.html` だけを書く。描画中はネットワ 1. **関係のあるものは、親子にしてレイアウトで並べる。** カードの中身(見出し、説明、チェック項目、 中に入るチップ)はカードの子要素にし、`display: flex` / `grid` と `gap` で並べる。 画面全体の座標(`position: absolute; left: …px`)で 1 つずつ置くのは、図の大きな部品(カード、 - 字幕、流れ図)だけにする + 字幕、流れ図)だけにする。子の箱(アイコン、下線)を持つ要素の文字は `` で包む。 + 文字を直に持つ要素は `check` が文字の範囲で測るので、文字の外にある子がはみ出しと出る 2. **印は、判定する相手の中に置く。** ✕・✓・バッジ・番号は、それが付く要素の子にする (`
OIDC トークン✕
`)。2 つの要素の間の 空いた所に浮かせない。拒否を見せるときは、チップを相手にぶつけて跳ね返し、チップに ✕ を出し、 @@ -123,6 +126,32 @@ motion-render check /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` を付けた要素の親に付ける。 + ## 文字 - `` と `kit/base.css` で、見出しは文節の切れ目で改行される。長いカタカナ語 diff --git a/skills/motion-render/scripts/layout.ts b/skills/motion-render/scripts/layout.ts index 9090353..565c395 100644 --- a/skills/motion-render/scripts/layout.ts +++ b/skills/motion-render/scripts/layout.ts @@ -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; diff --git a/tests/html-film.test.ts b/tests/html-film.test.ts index 8d38138..410185e 100644 --- a/tests/html-film.test.ts +++ b/tests/html-film.test.ts @@ -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 ? `
✕
OIDC トークン
` @@ -47,17 +55,22 @@ function page(wrong: boolean): string { `; } -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; } @@ -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 }); diff --git a/tests/layout.test.ts b/tests/layout.test.ts index 003a58f..0df99b5 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -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]),