From 1ac8b91ba5c5a99757f796a7b01e7a5e10b345d6 Mon Sep 17 00:00:00 2001 From: umiyosh Date: Mon, 26 Oct 2026 02:36:06 +0900 Subject: [PATCH 1/2] feat(kit): add film.typeIn and guidance for a style plan, restraint, object transitions and real mechanisms --- skills/motion-render/SKILL.md | 1 + skills/motion-render/kit/base.css | 10 ++++ skills/motion-render/kit/player.js | 43 +++++++++++++++ skills/motion-render/references/html-film.md | 57 ++++++++++++++++++++ tests/html-film.test.ts | 49 +++++++++++++++++ 5 files changed, 160 insertions(+) diff --git a/skills/motion-render/SKILL.md b/skills/motion-render/SKILL.md index d5cae38..d0395aa 100644 --- a/skills/motion-render/SKILL.md +++ b/skills/motion-render/SKILL.md @@ -132,6 +132,7 @@ motion-render narrate 1. `motion-render kit ` で `/film/` にプレーヤー・フォント・GSAP・場面の秒数を置く 2. [references/html-film.md](references/html-film.md) を読み、その決まりに従って `/film/index.html` を書く。 + 先に「作る前に決めること」を依頼文とテーマから決め、ページの先頭に書く。 場面は `
`、動きは `film.build` の中で `film.at(場面id, 秒)` に置く 3. 並行して BGM を作ってよい: `motion-render bgm ` 4. 効果音を付けるときは、`script.json` の `sfx` の `at` に、その動きに渡した `film.at` の秒を書き、 diff --git a/skills/motion-render/kit/base.css b/skills/motion-render/kit/base.css index 31155d5..9d333db 100644 --- a/skills/motion-render/kit/base.css +++ b/skills/motion-render/kit/base.css @@ -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. */ diff --git a/skills/motion-render/kit/player.js b/skills/motion-render/kit/player.js index 6533d34..ac03d6f 100644 --- a/skills/motion-render/kit/player.js +++ b/skills/motion-render/kit/player.js @@ -29,6 +29,14 @@ return { x: x, y: y, w: e.offsetWidth, h: e.offsetHeight }; } + // 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, total: T.total, @@ -98,6 +106,41 @@ 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() { diff --git a/skills/motion-render/references/html-film.md b/skills/motion-render/references/html-film.md index 95f4d66..3a22e05 100644 --- a/skills/motion-render/references/html-film.md +++ b/skills/motion-render/references/html-film.md @@ -64,8 +64,22 @@ Claude は `/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` はこれを破った所を見つけて止める。最初から守って書く。 @@ -155,6 +169,49 @@ motion-render check /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 まで計算し直してよい(下の「決まった絵になるように」と同じ理由) +- 数字は実際の値を使い、原稿の数字と合わせる。推定の値には「約」を付ける +- 機械や構造は分解図で見せる: 部品が名前のラベルを付けて離れ、また組み上がる +- 大きさが桁で違うもの(地球から原子まで)は、目盛りを付けた連続のズームで見せる + ## 文字 - `` と `kit/base.css` で、見出しは文節の切れ目で改行される。長いカタカナ語 diff --git a/tests/html-film.test.ts b/tests/html-film.test.ts index e51ed13..4357018 100644 --- a/tests/html-film.test.ts +++ b/tests/html-film.test.ts @@ -264,3 +264,52 @@ describe("several pages", () => { } }); }); + +// A title that rises one character at a time with film.typeIn. +const TYPE_PAGE = ` + + + + +
+

ループ engineering

+
+
+ +`; + +describe("film.typeIn", () => { + it("raises a title one character at a time, keeping Latin words whole, with no layout issues", async () => { + const work = tempDir("mr-html-"); + writeFileSync(join(work, "timing.json"), JSON.stringify(TIMING)); + const dir = writeKit(work); + writeFileSync(join(dir, "index.html"), TYPE_PAGE); + const film = await openFilm(dir); + try { + const chars = async (t: number) => + ((await film.inspect?.(t)) ?? []).filter((b) => b.label.startsWith("span.type-char")); + expect(await chars(0.1)).toEqual([]); + const done = await chars(0.95); + expect(done.map((b) => b.label.match(/"(.*)"/)?.[1])).toEqual([ + "ル", + "ー", + "プ", + "engineering", + ]); + // Settled on one baseline, left to right. + expect(new Set(done.map((b) => Math.round(b.y))).size).toBe(1); + expect(done.map((b) => b.x)).toEqual([...done.map((b) => b.x)].sort((a, b) => a - b)); + expect(await checkLayout(film)).toEqual([]); + expect(await film.warnings()).toEqual([]); + } finally { + await film.close(); + } + }); +}); From afbc51fb8b5eb5446031d112554064527062e87f Mon Sep 17 00:00:00 2001 From: umiyosh Date: Mon, 26 Oct 2026 10:59:11 +0900 Subject: [PATCH 2/2] fix(kit): let film.spot measure SVG elements and stop a film page that uses an id twice --- skills/motion-render/kit/player.js | 65 +++++++++++++-- skills/motion-render/references/html-film.md | 5 +- tests/html-film.test.ts | 87 +++++++++++++++++++- 3 files changed, 145 insertions(+), 12 deletions(-) diff --git a/skills/motion-render/kit/player.js b/skills/motion-render/kit/player.js index ac03d6f..a95a11e 100644 --- a/skills/motion-render/kit/player.js +++ b/skills/motion-render/kit/player.js @@ -28,6 +28,39 @@ } 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 , 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. @@ -55,13 +88,16 @@ 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 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 @@ -69,7 +105,7 @@ 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. @@ -146,6 +182,16 @@ 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) { @@ -189,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); diff --git a/skills/motion-render/references/html-film.md b/skills/motion-render/references/html-film.md index 3a22e05..43cbaf4 100644 --- a/skills/motion-render/references/html-film.md +++ b/skills/motion-render/references/html-film.md @@ -37,7 +37,7 @@ Claude は `/film/index.html` だけを書く。描画中はネットワ
…
- +
…
…
@@ -106,7 +106,8 @@ Claude は `/film/index.html` だけを書く。描画中はネットワ | `layer-caption` | 字幕 | 4. **チップの行き先は `film.spot` で決める。** 止まる位置は、カードの中に収まるか、どのカードとも - 重ならない所にする。カードの枠線をまたいで止めない + 重ならない所にする。カードの枠線をまたいで止めない。SVG の要素(アイコンの ``、図の中の図形)も + `film.spot`・`film.arc` で動かせる 5. **箱は親の中に収める。** 親より大きくなりそうな中身は、文字を短くするか親を広げる。 はみ出しを `overflow: hidden` で隠さない 6. **前の場面の残りを片付ける。** 次の場面で使わない部品は、場面の頭で消すか薄くし、新しい部品と diff --git a/tests/html-film.test.ts b/tests/html-film.test.ts index 4357018..5a19d7d 100644 --- a/tests/html-film.test.ts +++ b/tests/html-film.test.ts @@ -30,7 +30,7 @@ const LONG = { // references/html-film.md asks for. function page(wrong: boolean, lively = false): string { const mark = wrong - ? `
✕
+ ? `
✕
OIDC トークン
` : `
OIDC トークン✕
`; return ` @@ -95,6 +95,58 @@ const TEXT_PAGE = ` `; +// An icon flies to a card, and inside a diagram drawn at twice its viewBox a dot moves onto a +// slot. #probe shows where the dot and the slot are on screen once the dot has arrived. +// `extra` goes into the root after the scenes. +function svgPage(extra = ""): string { + return ` + + + + +
+

図

+
+ +
+ + + + +

+
+ ${extra} +
+ +`; +} + +function filmOf(html: string): string { + const work = tempDir("mr-html-"); + writeFileSync(join(work, "timing.json"), JSON.stringify(TIMING)); + const film = writeKit(work); + writeFileSync(join(film, "index.html"), html); + return film; +} + function htmlFilm(wrong: boolean, lively = false): string { const work = tempDir("mr-html-"); writeFileSync(join(work, "timing.json"), JSON.stringify(lively ? LONG : TIMING)); @@ -135,7 +187,7 @@ describe("html film", () => { const wrong = await openFilm(htmlFilm(true)); try { expect((await checkLayout(wrong))?.map(formatIssue)).toEqual([ - `1.1-1.9s: #cond "assertion.repository == " and #deny "✕" partly overlap`, + `1.1-1.9s: #cond "assertion.repository == " and #cross "✕" partly overlap`, ]); } finally { await wrong.close(); @@ -170,7 +222,7 @@ describe("html film", () => { 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); + expect(issues.some((i) => /#cond .* and #cross "✕" partly overlap/.test(i))).toBe(true); } finally { await film.close(); } @@ -213,7 +265,9 @@ describe("html film", () => { }); expect(existsSync(join(work, "film", "kit", "gsap.min.js"))).toBe(true); writeFileSync(join(work, "film", "index.html"), page(true)); - expect(() => run("check", join(work, "film"))).toThrow(/#cond .* and #deny "✕" partly overlap/); + expect(() => run("check", join(work, "film"))).toThrow( + /#cond .* and #cross "✕" partly overlap/ + ); writeFileSync(join(work, "film", "index.html"), page(false)); expect(run("check", join(work, "film"))).toContain("layout ok"); expect(run("stills", join(work, "film"), join(work, "stills"), "0.5,1.5")).toContain( @@ -223,6 +277,31 @@ describe("html film", () => { }); }); +describe("film.spot and ids", () => { + it("sends an icon and a shape inside a scaled diagram to their targets", async () => { + const film = await openFilm(filmOf(svgPage())); + try { + const boxes = (await film.inspect?.(1.9)) ?? []; + const box = (l: string) => boxes.find((x) => x.label.startsWith(l)); + const center = (l: string) => { + const b = box(l); + return b && [Math.round(b.x + b.w / 2), Math.round(b.y + b.h / 2)]; + }; + expect(center("#ico")).toEqual(center("#dock")); + // The slot's center is at (220, 115) in the viewBox, drawn at twice that from (300, 500). + expect(box("#probe")?.label).toBe('#probe "740,730 740,730"'); + } finally { + await film.close(); + } + }); + + it("fails to load when an id is used twice, as a chip named like its scene", async () => { + await expect(openFilm(filmOf(svgPage('
チップ
')))).rejects.toThrow( + /ids used more than once: deny\b/ + ); + }); +}); + describe("a stuck film page", () => { it("fails a frame whose seek never returns, naming the call and its time", async () => { const dir = htmlFilm(false);