From 3cb4fa13394481f74e69d2be43efa348bd536c9e Mon Sep 17 00:00:00 2001 From: umiyosh Date: Mon, 26 Oct 2026 17:49:03 +0900 Subject: [PATCH 1/2] feat(kit): add drawing styles for html films: pencil on paper, coloured pencil, blueprint, collage and whiteboard --- README.ja.md | 25 +- README.md | 25 +- package-lock.json | 10 + package.json | 1 + skills/motion-render/SKILL.md | 2 +- skills/motion-render/kit/player.js | 84 ++++++- skills/motion-render/kit/styles.css | 235 +++++++++++++++++++ skills/motion-render/package-lock.json | 10 + skills/motion-render/package.json | 1 + skills/motion-render/references/html-film.md | 42 +++- skills/motion-render/scripts/kit.ts | 9 +- tests/html-film.test.ts | 50 ++++ 12 files changed, 454 insertions(+), 40 deletions(-) create mode 100644 skills/motion-render/kit/styles.css diff --git a/README.ja.md b/README.ja.md index 9b733d3..686ecda 100644 --- a/README.ja.md +++ b/README.ja.md @@ -24,6 +24,7 @@ https://github.com/user-attachments/assets/eb1bf7e6-a880-4bbe-b575-7f4ec5c21672 ![motion-render の仕組み](docs/how-it-works.svg) - 見た目・長さ・声・BGM の雰囲気はテーマから自動で決まります(指定すればそれに従います) +- HTML の映像は、テーマに合う作風で描かれます: 暗い平面、紙と鉛筆、色鉛筆、設計図、紙コラージュ、ホワイトボード - ElevenLabs の API キーがあれば、カードが現れる・流れ図が光るといった動きに合わせて効果音も付きます - 既存の Motion の動画の URL を渡して、ナレーションと BGM 付きの MP4 にすることもできます - 描画用のブラウザ(Chromium)と ffmpeg は自動で用意されるので、環境ごとの差が出ません @@ -32,13 +33,13 @@ https://github.com/user-attachments/assets/eb1bf7e6-a880-4bbe-b575-7f4ec5c21672 ナレーション・BGM・効果音・MP4 への書き出しは、どちらでも同じです。違うのはアニメーションの作り方だけで、Claude が自動で選びます。Motion が使えるアカウントなら Motion、使えなければ HTML です。 -| | Claude Motion | HTML | -| ---------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| 使えるプラン | Team・Enterprise(ベータ版) | Free・Pro・Max を含むすべてのプラン | -| 作り方 | Claude が Motion で作り、motion-render がそれを取得する | Claude が 1 枚の HTML を書く。土台(アニメーションのプレーヤー(GSAP)・基本のスタイル・日本語フォント)は motion-render に入っている | -| 書き出す前の確認 | Claude が静止画を見る | 配置の自動チェック(重なり、はみ出し、隠れ、小さすぎる文字や薄すぎる文字)のあと、静止画を見る | -| 見られるもの | claude.ai 上の Motion の動画と MP4 | 手元の静止画と MP4(MP4 ができる前に共有できるリンクはない) | -| 追加の準備 | なし | なし(土台は motion-render と一緒に入る) | +| | Claude Motion | HTML | +| ---------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| 使えるプラン | Team・Enterprise(ベータ版) | Free・Pro・Max を含むすべてのプラン | +| 作り方 | Claude が Motion で作り、motion-render がそれを取得する | Claude が 1 枚の HTML を書く。土台(アニメーションのプレーヤー(GSAP)・スタイル・日本語フォント)は motion-render に入っている | +| 書き出す前の確認 | Claude が静止画を見る | 配置の自動チェック(重なり、はみ出し、隠れ、小さすぎる文字や薄すぎる文字)のあと、静止画を見る | +| 見られるもの | claude.ai 上の Motion の動画と MP4 | 手元の静止画と MP4(MP4 ができる前に共有できるリンクはない) | +| 追加の準備 | なし | なし(土台は motion-render と一緒に入る) | ## 必要なもの @@ -145,7 +146,7 @@ auto mode を使っていて、それでも実行が拒否される場合は、` | `motion-render bgm ` | `script.json` の `bgm` の指示から BGM を作る | | `motion-render sfx ` | `script.json` の `sfx` から効果音を作り、全部を順に聞ける `preview.m4a` を書く | | `motion-render prepare /film` | Motion: Motion から取得した映像データを、描画できる形に展開する | -| `motion-render kit ` | HTML: `/film` にプレーヤー・基本のスタイル・GSAP・日本語フォント・場面の秒数を置く。Claude はそのあと `film/index.html` を書く | +| `motion-render kit ` | HTML: `/film` にプレーヤー・スタイル・GSAP・日本語フォント・場面の秒数を置く。Claude はそのあと `film/index.html` を書く | | `motion-render check /film` | HTML: 0.1 秒ごとに配置を調べ、重なり、箱や画面からのはみ出し、ほかのものの下に隠れたもの、小さすぎる文字や薄すぎる文字を報告する | | `motion-render stills 0,5,12` | 確認用の静止画を書き出す(HTML の映像は先に配置を調べる) | | `motion-render build [out.mp4]` | ナレーション・BGM・効果音を合成し、映像と合わせて MP4 にする。どれかが足りないとき、HTML の映像が配置のチェックに通らないときは止まる | @@ -174,8 +175,8 @@ auto mode を使っていて、それでも実行が拒否される場合は、` | 初回の Chromium のダウンロードが失敗する | `CHROMIUM_PATH` に手元の Chrome か Chromium を指定してください | | `motion-render` の実行で `Unknown file extension ".ts"` が出る | Node が 22.18 より古いです。Node を更新してください | | `stills` や `build` が `1.1-1.9s: #a and #b partly overlap` のような行を出して止まる | HTML の映像の配置に問題があります。Claude が `film/index.html` を直して調べ直します。自分で直すときは、`motion-render check /film` が `layout ok` を出すまで直してください。各メッセージの意味は [html-film.md](skills/motion-render/references/html-film.md) にあります | -| `font "Noto Sans JP" … did not load. Run "motion-render kit" again.` が出る | HTML の映像のフォントのファイルがありません。`motion-render kit ` をもう一度実行してください(`index.html` はそのまま残ります) | -| 日本語の字形や改行位置が Motion のページと少し違う | Motion の映像は OS のフォントで描かれます。HTML の映像は、同梱の Noto Sans JP で描かれます | +| `font … "Noto Sans JP" did not load. Run "motion-render kit" again.` が出る | HTML の映像のフォントのファイルがありません。`motion-render kit ` をもう一度実行してください(`index.html` はそのまま残ります) | +| 日本語の字形や改行位置が Motion のページと少し違う | Motion の映像は OS のフォントで描かれます。HTML の映像は、同梱の Noto Sans JP(手描きの作風では Klee One)で描かれます | ## 更新 @@ -233,9 +234,9 @@ package-lock.json tsconfig.json # TypeScript の設定(型の検査用。実行時は Node が .ts をそのまま動かす) skills/motion-render/ # スキル本体(skills CLI とプラグインの両方がここを読む) SKILL.md # Claude が読む手順(準備、絵コンテ・原稿・BGM の決め方を含む) - package.json # motion-render コマンドと、依存の版(playwright-core, ffmpeg-static, gsap, @fontsource/noto-sans-jp) + package.json # motion-render コマンドと、依存の版(playwright-core, ffmpeg-static, gsap, @fontsource/noto-sans-jp, @fontsource/klee-one) references/html-film.md # Claude が HTML の映像を書くときの決まり(配置、動き、配置のチェックの読み方) - kit/ # kit が HTML の映像に写すプレーヤーと基本のスタイル + kit/ # kit が HTML の映像に写すプレーヤーと基本のスタイル、作風のスタイル scripts/cli.ts # motion-render コマンド scripts/audio.ts # ナレーションと BGM の生成、音声の合成 scripts/sfx.ts # 効果音の生成(ElevenLabs)、音量のそろえ方、試聴用ファイル diff --git a/README.md b/README.md index d954543..adbf045 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ More videos, including one with sound effects, are in [Examples](https://github. ![How motion-render works](docs/how-it-works.en.svg) - Look, length, voice and the mood of the music are chosen from the theme (anything you specify wins) +- An HTML film is drawn in the look that fits the theme: flat and dark, pencil on paper, coloured pencil, blueprint, paper collage or whiteboard - With an ElevenLabs API key, sound effects follow the animation too: a card appearing, a step of a flow lighting up - You can also pass the URL of an existing Motion film to get an MP4 with narration and BGM - The rendering browser (Chromium) and ffmpeg are set up automatically, so results don't depend on what is installed on your machine @@ -32,13 +33,13 @@ More videos, including one with sound effects, are in [Examples](https://github. Narration, BGM, sound effects and the MP4 are the same either way. Only the animation is made differently, and Claude picks the way by itself: Motion if your account offers it, HTML otherwise. -| | Claude Motion | HTML | -| ---------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| Plans | Team and Enterprise (beta) | All plans, including Free, Pro and Max | -| How it is made | Claude builds the film in Motion, and motion-render fetches it | Claude writes one HTML page on a kit that comes with motion-render: an animation player (GSAP), base styles and a Japanese font | -| Checking before render | Claude looks at still frames | An automatic layout check (overlaps, things sticking out or hidden, text that is too small or too faint), then still frames | -| What you can look at | The Motion film on claude.ai, and the MP4 | Still frames and the MP4 on your machine (there is no link to share before the MP4) | -| Extra setup | None | None: the kit is installed with motion-render | +| | Claude Motion | HTML | +| ---------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Plans | Team and Enterprise (beta) | All plans, including Free, Pro and Max | +| How it is made | Claude builds the film in Motion, and motion-render fetches it | Claude writes one HTML page on a kit that comes with motion-render: an animation player (GSAP), styles and Japanese fonts | +| Checking before render | Claude looks at still frames | An automatic layout check (overlaps, things sticking out or hidden, text that is too small or too faint), then still frames | +| What you can look at | The Motion film on claude.ai, and the MP4 | Still frames and the MP4 on your machine (there is no link to share before the MP4) | +| Extra setup | None | None: the kit is installed with motion-render | ## Requirements @@ -145,7 +146,7 @@ Each video uses one work directory (``). Its layout and the `script.json` | `motion-render bgm ` | Generates the BGM from the `bgm` prompt in `script.json` | | `motion-render sfx ` | Generates the sound effects in `script.json` and a `preview.m4a` of them in order | | `motion-render prepare /film` | Motion: expands a Motion film's data into a directory it can render | -| `motion-render kit ` | HTML: sets up `/film` with the player, base styles, GSAP, the Japanese font and the scene times. Claude then writes `film/index.html` | +| `motion-render kit ` | HTML: sets up `/film` with the player, styles, GSAP, the Japanese fonts and the scene times. Claude then writes `film/index.html` | | `motion-render check /film` | HTML: checks the layout every 0.1 s for overlaps, things sticking out of their box or the frame, things hidden under others, and text that is too small or too faint | | `motion-render stills 0,5,12` | Writes still frames for checking (an HTML film is checked first) | | `motion-render build [out.mp4]` | Mixes narration, BGM and sound effects and renders the MP4. Stops if any is missing, or if an HTML film fails the layout check | @@ -174,8 +175,8 @@ Each video uses one work directory (``). Its layout and the `script.json` | Chromium fails to download on first run | Point `CHROMIUM_PATH` at your own Chrome or Chromium | | `Unknown file extension ".ts"` when running `motion-render` | Node is older than 22.18. Update Node | | `stills` or `build` stops with lines like `1.1-1.9s: #a and #b partly overlap` | The layout check found a problem in an HTML film. Claude fixes `film/index.html` and checks again. If you edit it yourself, run `motion-render check /film` until it prints `layout ok`; [html-film.md](skills/motion-render/references/html-film.md) explains each message | -| `font "Noto Sans JP" … did not load. Run "motion-render kit" again.` | The font files of an HTML film are missing. Run `motion-render kit ` again (it keeps `index.html`) | -| Japanese glyphs or line breaks differ slightly from the Motion page | A Motion film is drawn with your OS fonts. An HTML film always uses the bundled Noto Sans JP | +| `font … "Noto Sans JP" did not load. Run "motion-render kit" again.` | The font files of an HTML film are missing. Run `motion-render kit ` again (it keeps `index.html`) | +| Japanese glyphs or line breaks differ slightly from the Motion page | A Motion film is drawn with your OS fonts. An HTML film uses the bundled Noto Sans JP (and Klee One in the hand-drawn looks) | ## Update @@ -233,9 +234,9 @@ package-lock.json tsconfig.json # TypeScript settings (type check only; Node runs the .ts files directly) skills/motion-render/ # The skill (read by both the skills CLI and the plugin) SKILL.md # Instructions for Claude (setup, storyboard, script and BGM choices) - package.json # The motion-render command and pinned dependencies (playwright-core, ffmpeg-static, gsap, @fontsource/noto-sans-jp) + package.json # The motion-render command and pinned dependencies (playwright-core, ffmpeg-static, gsap, @fontsource/noto-sans-jp, @fontsource/klee-one) references/html-film.md # How Claude writes an HTML film: layout, motion, and reading the layout check - kit/ # The player and base styles that `kit` copies into an HTML film + kit/ # The player, base styles and drawing styles that `kit` copies into an HTML film scripts/cli.ts # The motion-render command scripts/audio.ts # Narration and BGM generation, audio mixing scripts/sfx.ts # Sound effect generation (ElevenLabs), leveling and the listening file diff --git a/package-lock.json b/package-lock.json index 5291298..1dbe6f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "motion-render-plugin", "version": "0.5.0", "dependencies": { + "@fontsource/klee-one": "5.3.1", "@fontsource/noto-sans-jp": "5.2.8", "ffmpeg-static": "5.2.0", "gsap": "3.13.0", @@ -748,6 +749,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fontsource/klee-one": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@fontsource/klee-one/-/klee-one-5.3.1.tgz", + "integrity": "sha512-TWW1hZylfNSpsKAIxKlCUY02gSiRhulJgfVIOlpDMZhBbssXvKBnbUREFkZuB0Hefe2xiso07JWKDxOENKwu1w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/noto-sans-jp": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource/noto-sans-jp/-/noto-sans-jp-5.2.8.tgz", diff --git a/package.json b/package.json index efd8031..d7111da 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "node": ">=22.18" }, "dependencies": { + "@fontsource/klee-one": "5.3.1", "@fontsource/noto-sans-jp": "5.2.8", "ffmpeg-static": "5.2.0", "gsap": "3.13.0", diff --git a/skills/motion-render/SKILL.md b/skills/motion-render/SKILL.md index d0395aa..86e4908 100644 --- a/skills/motion-render/SKILL.md +++ b/skills/motion-render/SKILL.md @@ -230,7 +230,7 @@ URL を渡された場合も音を付ける。映像データを取得し(手 - `scripts/sfx.ts`: ElevenLabs による効果音の生成(`sfx`)、音量の測定とそろえ方、試聴用ファイル - `scripts/work.ts`: `script.json` の検証、`timing.json` の計算、ナレーションを動画の場面の開始時刻に置く処理 - `scripts/render.ts`: プレーヤーを開いてコマを返す。描画中は外部への通信を遮断し、Chromium の sandbox を有効にする -- `scripts/kit.ts`・`kit/`: html の映像のプレーヤー(`player.js`)と土台の CSS(`base.css`)を、フォントと GSAP と一緒に置く(`kit`) +- `scripts/kit.ts`・`kit/`: html の映像のプレーヤー(`player.js`)と土台の CSS(`base.css`)、作風の CSS(`styles.css`)を、フォントと GSAP と一緒に置く(`kit`) - `scripts/layout.ts`・`scripts/snapshot.js`: html の映像の配置の確認(`check`。`stills`・`build` も実行する) - `references/html-film.md`: html の映像の書き方と配置の決まり - `scripts/json.ts`: ファイル・API・プレーヤーから来る JSON を、形を確かめながら読む diff --git a/skills/motion-render/kit/player.js b/skills/motion-render/kit/player.js index a95a11e..6629215 100644 --- a/skills/motion-render/kit/player.js +++ b/skills/motion-render/kit/player.js @@ -5,7 +5,40 @@ var T = window.__timing; if (!T || !window.gsap) throw new Error("load kit/gsap.min.js and kit/timing.js before kit/player.js"); - var FAMILY = '"Noto Sans JP"'; + // Each family with the weights kit/fonts.css has for it. Klee One is the hand-written face of the + // paper, crayon and whiteboard styles, so it is loaded only when the page uses it. + var FONTS = [ + { family: '"Noto Sans JP"', weights: ["400", "700"] }, + { family: '"Klee One"', weights: ["400", "600"], optional: true }, + ]; + // The filters kit/styles.css draws with. The noise of the line filters (.mr-boil) is re-seeded + // 8 times a second so drawn lines shimmer like hand-drawn animation; the seed comes from the time, + // so a frame always looks the same. + var FILTERS = + '"; var scenes = {}; T.scenes.forEach(function (s) { scenes[s.id] = s; @@ -201,26 +234,55 @@ } // Fonts load per unicode-range subset on first use, so a scene's first frame could fall back. // Load every glyph the film shows before the first frame, and fail if any is missing. + function usesFamily(family) { + var name = family.replace(/"/g, ""); + return Array.prototype.some.call(document.querySelectorAll("body, body *"), function (e) { + return getComputedStyle(e).fontFamily.indexOf(name) >= 0; + }); + } async function loadFonts() { var text = document.body.textContent.replace(/\s+/g, "") + "0123456789"; - var weights = ["400", "700"]; + var faces = []; + FONTS.forEach(function (f) { + if (f.optional && !usesFamily(f.family)) return; + f.weights.forEach(function (w) { + faces.push({ family: f.family, spec: w + " 48px " + f.family }); + }); + }); try { await Promise.all( - weights.map(function (w) { - return document.fonts.load(w + " 48px " + FAMILY, text); + faces.map(function (f) { + return document.fonts.load(f.spec, text); }) ); } catch (e) { throw new Error( - "font " + FAMILY + " failed to load (" + e.message + '). Run "motion-render kit" again.' + "font " + + faces + .map(function (f) { + return f.spec; + }) + .join(", ") + + " failed to load (" + + e.message + + '). Run "motion-render kit" again.' ); } await document.fonts.ready; - weights.forEach(function (w) { - if (!document.fonts.check(w + " 48px " + FAMILY, text)) - throw new Error( - "font " + FAMILY + " " + w + ' did not load. Run "motion-render kit" again.' - ); + faces.forEach(function (f) { + if (!document.fonts.check(f.spec, text)) + throw new Error("font " + f.spec + ' did not load. Run "motion-render kit" again.'); + }); + } + function addFilters() { + var holder = document.createElement("div"); + holder.innerHTML = FILTERS; + document.body.appendChild(holder.firstChild); + } + function boil(t) { + var seed = String(1 + (Math.floor(t * 8) % 4)); + document.querySelectorAll(".mr-boil").forEach(function (n) { + n.setAttribute("seed", seed); }); } @@ -242,6 +304,7 @@ twice.join(", ") + ". A selector #id reaches all of them, so hiding or moving one also hides or moves the others" ); + addFilters(); await loadFonts(); builders.forEach(function (fn) { fn(timeline); @@ -256,6 +319,7 @@ }, seek: function (t) { show(t); + boil(t); // suppressEvents=false so onUpdate callbacks (count-ups) run on every seek. timeline.seek(t, false); }, diff --git a/skills/motion-render/kit/styles.css b/skills/motion-render/kit/styles.css new file mode 100644 index 0000000..e41901e --- /dev/null +++ b/skills/motion-render/kit/styles.css @@ -0,0 +1,235 @@ +/* Drawing styles of an html film. picks one; without data-style the film + keeps the flat dark look the page sets itself. Each style sets the colour variables, the ground + (drawn on #root::before, which the layout check does not see, so text over it is still measured + for contrast against --ground) and how .sketch, .drawn, .hatch and .cutout look. The SVG filters + these use (#mr-pencil, #mr-crayon, #mr-marker, #mr-torn, #mr-tooth) are added to the page by kit/player.js. */ + +[data-style] { + background: var(--ground); + color: var(--ink); +} +[data-style] #root::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; +} + +/* A box outlined (and, with --fill, coloured in) by hand. Both are on a pseudo-element behind the + box's content, so the filter that makes them wobble does not reach the text inside, and the layout + check still measures the text against the box's own background. */ +/* :where() keeps these at no specificity, so a page's own position: absolute still wins. */ +:where([data-style] .drawn, [data-style="collage"] .cutout) { + position: relative; +} +[data-style] .drawn { + isolation: isolate; +} +[data-style] .drawn::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background-color: var(--fill, transparent); + border: var(--stroke) solid var(--line); + border-radius: inherit; + filter: var(--line-filter); + pointer-events: none; +} +[data-style] .sketch { + filter: var(--line-filter); +} +/* Shading: diagonal strokes in the current colour. Put text next to it, not on it. */ +.hatch { + background-image: repeating-linear-gradient( + -45deg, + currentColor 0 var(--hatch-w, 2px), + transparent var(--hatch-w, 2px) var(--hatch-gap, 10px) + ); +} +[data-style] .hatch { + filter: var(--fill-filter); +} + +/* Every style has --tint-1 to --tint-4: light colours to fill a .drawn box or a .cutout with + (--fill), light enough that --ink on them stays readable. */ + +/* 紙と鉛筆: cream paper with grain and a faint blue grid, graphite lines that wobble. */ +[data-style="paper"] { + --ground: #ede6d5; + --panel: #f4efe2; + --line: #34343a; + --ink: #26262b; + --muted: #5e5a52; + --accent: #2447c4; + --danger: #bf3328; + --tint-1: #f3d9a9; + --tint-2: #cad7f3; + --tint-3: #d2e4c3; + --tint-4: #f1cdc4; + --stroke: 3.5px; + --line-filter: url(#mr-pencil); + --fill-filter: url(#mr-pencil); + --hatch-w: 2px; + --hatch-gap: 9px; + font-family: "Klee One", "Noto Sans JP", sans-serif; +} +[data-style="paper"] #root::before { + background-image: + url("data:image/svg+xml;utf8,"), + linear-gradient(rgba(36, 71, 196, 0.17) 1px, transparent 1px), + linear-gradient(90deg, rgba(36, 71, 196, 0.17) 1px, transparent 1px), + linear-gradient(rgba(36, 71, 196, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(36, 71, 196, 0.08) 1px, transparent 1px); + background-size: + 480px 480px, + 300px 300px, + 300px 300px, + 60px 60px, + 60px 60px; +} + +/* 色鉛筆: warm paper with more tooth; thick soft lines, and fills of coloured pencil that let the + paper show through (.tooth on a box filled with one of --tint-1 to --tint-4). */ +[data-style="crayon"] { + --ground: #f6efe0; + --panel: #fbf6ea; + --line: #5a4334; + --ink: #3a302a; + --muted: #675a50; + --accent: #b9471a; + --danger: #b0302a; + --tint-1: #f6bf8e; + --tint-2: #f5dc84; + --tint-3: #add7a2; + --tint-4: #a6c8ef; + --stroke: 6px; + --line-filter: url(#mr-crayon); + --fill-filter: url(#mr-crayon); + --hatch-w: 5px; + --hatch-gap: 9px; + font-family: "Klee One", "Noto Sans JP", sans-serif; +} +[data-style="crayon"] #root::before { + background-image: url("data:image/svg+xml;utf8,"); + background-size: 480px 480px; +} +/* A solid fill in coloured pencil. */ +[data-style="crayon"] .tooth { + filter: url(#mr-tooth); +} + +/* 設計図: flat blue with a white grid, clean white lines. */ +[data-style="blueprint"] { + --ground: #1d3fae; + --panel: #2149bd; + --line: #f2f5ff; + --ink: #f6f8ff; + --muted: #c3cff5; + --accent: #ffd84a; + --danger: #ff9a8f; + --tint-1: rgba(255, 255, 255, 0.1); + --tint-2: rgba(255, 216, 74, 0.16); + --tint-3: rgba(140, 230, 200, 0.14); + --tint-4: rgba(255, 154, 143, 0.16); + --stroke: 3px; + --line-filter: none; + --fill-filter: none; + --hatch-w: 1.5px; + --hatch-gap: 8px; + font-family: "Noto Sans JP", sans-serif; +} +[data-style="blueprint"] #root::before { + background-image: + linear-gradient(rgba(255, 255, 255, 0.26) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.26) 1px, transparent 1px), + linear-gradient(rgba(255, 255, 255, 0.12) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.12) 1px, transparent 1px); + background-size: + 300px 300px, + 300px 300px, + 60px 60px, + 60px 60px; +} + +/* 紙コラージュ: kraft paper; pieces of coloured paper with torn edges and a hard shadow. */ +[data-style="collage"] { + --ground: #cfb994; + --panel: #f3ead6; + --line: #2e2722; + --ink: #2a231e; + --muted: #4f443a; + --accent: #1f4c9e; + --danger: #a3261b; + --tint-1: #f3ead6; + --tint-2: #f0c24f; + --tint-3: #a9cfa4; + --tint-4: #9dc0e3; + --stroke: 3px; + --line-filter: url(#mr-marker); + --fill-filter: url(#mr-tooth); + --hatch-w: 3px; + --hatch-gap: 10px; + font-family: "Noto Sans JP", sans-serif; +} +[data-style="collage"] #root::before { + background-image: url("data:image/svg+xml;utf8,"); + background-size: 480px 480px; +} +/* The torn edge and the shadow are on a pseudo-element a little larger than the piece, so the text + on the piece is not torn with it and the layout check still sees the piece's own colour. */ +[data-style="collage"] .cutout { + isolation: isolate; + background-color: var(--fill, var(--tint-1)); +} +[data-style="collage"] .cutout::before { + content: ""; + position: absolute; + inset: -10px; + z-index: -1; + background-color: inherit; + box-shadow: 5px 7px 0 rgba(40, 28, 16, 0.28); + filter: url(#mr-torn); + pointer-events: none; +} +/* A strip of tape over the top edge of a .cutout. */ +[data-style="collage"] .cutout.taped::after { + content: ""; + position: absolute; + top: -18px; + left: 50%; + width: 120px; + height: 36px; + margin-left: -60px; + background: rgba(250, 244, 222, 0.7); + transform: rotate(-3deg); + pointer-events: none; +} + +/* ホワイトボード: a white board, marker lines with round ends. */ +[data-style="whiteboard"] { + --ground: #f6f7f4; + --panel: #ffffff; + --line: #22262b; + --ink: #1d2126; + --muted: #4d545c; + --accent: #1f5bbf; + --danger: #cc3326; + --tint-1: #fdeeb3; + --tint-2: #dbe7fb; + --tint-3: #d3eedc; + --tint-4: #f9d6d2; + --stroke: 4px; + --line-filter: url(#mr-marker); + --fill-filter: url(#mr-marker); + --hatch-w: 3px; + --hatch-gap: 12px; + font-family: "Klee One", "Noto Sans JP", sans-serif; +} +[data-style="whiteboard"] #root::before { + background-image: + radial-gradient(ellipse 900px 500px at 70% 20%, rgba(255, 255, 255, 0.9), transparent 70%), + radial-gradient(ellipse 420px 160px at 22% 78%, rgba(120, 130, 140, 0.06), transparent 70%), + radial-gradient(ellipse 300px 120px at 82% 70%, rgba(120, 130, 140, 0.05), transparent 70%); +} diff --git a/skills/motion-render/package-lock.json b/skills/motion-render/package-lock.json index 6292d74..d88c972 100644 --- a/skills/motion-render/package-lock.json +++ b/skills/motion-render/package-lock.json @@ -8,6 +8,7 @@ "name": "motion-render", "version": "0.5.0", "dependencies": { + "@fontsource/klee-one": "5.3.1", "@fontsource/noto-sans-jp": "5.2.8", "ffmpeg-static": "5.2.0", "gsap": "3.13.0", @@ -35,6 +36,15 @@ "node": ">=6.0.0" } }, + "node_modules/@fontsource/klee-one": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@fontsource/klee-one/-/klee-one-5.3.1.tgz", + "integrity": "sha512-TWW1hZylfNSpsKAIxKlCUY02gSiRhulJgfVIOlpDMZhBbssXvKBnbUREFkZuB0Hefe2xiso07JWKDxOENKwu1w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/noto-sans-jp": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource/noto-sans-jp/-/noto-sans-jp-5.2.8.tgz", diff --git a/skills/motion-render/package.json b/skills/motion-render/package.json index 24015f5..aeb4261 100644 --- a/skills/motion-render/package.json +++ b/skills/motion-render/package.json @@ -17,6 +17,7 @@ "node": ">=22.18" }, "dependencies": { + "@fontsource/klee-one": "5.3.1", "@fontsource/noto-sans-jp": "5.2.8", "ffmpeg-static": "5.2.0", "gsap": "3.13.0", diff --git a/skills/motion-render/references/html-film.md b/skills/motion-render/references/html-film.md index 43cbaf4..0521f49 100644 --- a/skills/motion-render/references/html-film.md +++ b/skills/motion-render/references/html-film.md @@ -1,7 +1,7 @@ # html の映像の作り方 Claude Motion が使えないときに、映像を 1 枚の HTML として書く。`motion-render kit ` が -`/film/kit/` にプレーヤー・フォント(Noto Sans JP)・GSAP・場面の秒数を置くので、 +`/film/kit/` にプレーヤー・スタイル・フォント(Noto Sans JP と、手描きの作風用の Klee One)・GSAP・場面の秒数を置くので、 Claude は `/film/index.html` だけを書く。描画中はネットワークが遮断されるので、CDN や Google Fonts は使えない。 ## ページの骨組み @@ -13,10 +13,12 @@ Claude は `/film/index.html` だけを書く。描画中はネットワ + +
+

紙

+
+

鉛筆で描く

薄い字

+ +
+
+ +`; + +describe("drawing styles", () => { + it("puts the stylesheet and the hand-written font in the kit", () => { + const dir = filmOf(STYLE_PAGE); + expect(readFileSync(join(dir, "kit", "styles.css"), "utf8")).toContain('[data-style="paper"]'); + expect(readFileSync(join(dir, "kit", "fonts.css"), "utf8")).toContain("Klee One"); + }); + + it("keeps a drawn card where the page puts it, measures its text against the card, and shimmers its lines the same way every time", async () => { + const film = await openFilm(filmOf(STYLE_PAGE)); + try { + const boxes = (await film.inspect?.(1.5)) ?? []; + const card = boxes.find((b) => b.label.startsWith("#card")); + expect(card && [card.x, card.y]).toEqual([300, 200]); + expect((await checkLayout(film))?.map(formatIssue)).toEqual([ + expect.stringMatching(/#faint "薄い字" has contrast 1\.\d:1, needs 4\.5:1/), + ]); + // Nothing moves in the scene; only the lines are re-drawn 8 times a second. + const a = await film.frame(1.5); + expect((await film.frame(1.5)).equals(a)).toBe(true); + expect((await film.frame(1.63)).equals(a)).toBe(false); + expect(await film.warnings()).toEqual([]); + } finally { + await film.close(); + } + }); +}); From b1405d41d0add096882b4e9877c1a21e64e7bb75 Mon Sep 17 00:00:00 2001 From: umiyosh Date: Mon, 26 Oct 2026 19:07:37 +0900 Subject: [PATCH 2/2] feat(kit): give pencil on paper a plain sheet, ochre ink, a highlighter and hatched SVG shapes --- skills/motion-render/kit/player.js | 6 ++ skills/motion-render/kit/styles.css | 80 ++++++++++++-------- skills/motion-render/references/html-film.md | 20 ++--- tests/html-film.test.ts | 41 ++++++++++ 4 files changed, 108 insertions(+), 39 deletions(-) diff --git a/skills/motion-render/kit/player.js b/skills/motion-render/kit/player.js index 6629215..f3d0c4d 100644 --- a/skills/motion-render/kit/player.js +++ b/skills/motion-render/kit/player.js @@ -38,6 +38,12 @@ '' + '' + '' + + // Hatching for SVG shapes (fill="url(#mr-hatch)"): strokes in --line, and crossed strokes in --accent. + '' + + '' + + '' + + '' + + '' + ""; var scenes = {}; T.scenes.forEach(function (s) { diff --git a/skills/motion-render/kit/styles.css b/skills/motion-render/kit/styles.css index e41901e..62d53eb 100644 --- a/skills/motion-render/kit/styles.css +++ b/skills/motion-render/kit/styles.css @@ -1,8 +1,9 @@ /* Drawing styles of an html film. picks one; without data-style the film keeps the flat dark look the page sets itself. Each style sets the colour variables, the ground (drawn on #root::before, which the layout check does not see, so text over it is still measured - for contrast against --ground) and how .sketch, .drawn, .hatch and .cutout look. The SVG filters - these use (#mr-pencil, #mr-crayon, #mr-marker, #mr-torn, #mr-tooth) are added to the page by kit/player.js. */ + for contrast against --ground) and how .sketch, .drawn, .hatch, .marker and .cutout look. The SVG + filters these use (#mr-pencil, #mr-crayon, #mr-marker, #mr-torn, #mr-tooth) and the hatch patterns + for SVG shapes (#mr-hatch, #mr-hatch-accent) are added to the page by kit/player.js. */ [data-style] { background: var(--ground); @@ -51,43 +52,58 @@ [data-style] .hatch { filter: var(--fill-filter); } +/* A highlighter stroke behind a word or a heading, drawn from left to right as --mark-x goes from + 0 to 1 (GSAP tweens it: { "--mark-x": 1 }). Keep the text on it in --ink. */ +:where([data-style] .marker) { + position: relative; +} +[data-style] .marker { + isolation: isolate; +} +[data-style] .marker::before { + content: ""; + position: absolute; + left: -0.35em; + right: -0.35em; + top: 0.38em; + bottom: 0.02em; + z-index: -1; + background: var(--mark); + border-radius: 0.5em; + transform: scaleX(var(--mark-x, 1)); + transform-origin: left center; + filter: var(--line-filter); + pointer-events: none; +} /* Every style has --tint-1 to --tint-4: light colours to fill a .drawn box or a .cutout with - (--fill), light enough that --ink on them stays readable. */ + (--fill), light enough that --ink on them stays readable; and --mark, the highlighter colour. */ -/* 紙と鉛筆: cream paper with grain and a faint blue grid, graphite lines that wobble. */ +/* 紙と鉛筆: off-white paper with a faint grain, graphite lines that wobble, ochre for what matters + and a highlighter stroke behind headings. */ [data-style="paper"] { - --ground: #ede6d5; - --panel: #f4efe2; - --line: #34343a; - --ink: #26262b; - --muted: #5e5a52; - --accent: #2447c4; - --danger: #bf3328; - --tint-1: #f3d9a9; - --tint-2: #cad7f3; - --tint-3: #d2e4c3; - --tint-4: #f1cdc4; - --stroke: 3.5px; + --ground: #f5f3ec; + --panel: #faf8f3; + --line: #3a3d45; + --ink: #2f3239; + --muted: #5d5f66; + --accent: #8a5a0e; + --danger: #b23a2c; + --mark: #e8c47c; + --tint-1: #f0dcae; + --tint-2: #d6e0f2; + --tint-3: #dbe7cf; + --tint-4: #f2d5cb; + --stroke: 3px; --line-filter: url(#mr-pencil); --fill-filter: url(#mr-pencil); - --hatch-w: 2px; - --hatch-gap: 9px; + --hatch-w: 2.4px; + --hatch-gap: 7px; font-family: "Klee One", "Noto Sans JP", sans-serif; } [data-style="paper"] #root::before { - background-image: - url("data:image/svg+xml;utf8,"), - linear-gradient(rgba(36, 71, 196, 0.17) 1px, transparent 1px), - linear-gradient(90deg, rgba(36, 71, 196, 0.17) 1px, transparent 1px), - linear-gradient(rgba(36, 71, 196, 0.08) 1px, transparent 1px), - linear-gradient(90deg, rgba(36, 71, 196, 0.08) 1px, transparent 1px); - background-size: - 480px 480px, - 300px 300px, - 300px 300px, - 60px 60px, - 60px 60px; + background-image: url("data:image/svg+xml;utf8,"); + background-size: 480px 480px; } /* 色鉛筆: warm paper with more tooth; thick soft lines, and fills of coloured pencil that let the @@ -100,6 +116,7 @@ --muted: #675a50; --accent: #b9471a; --danger: #b0302a; + --mark: #f5dc84; --tint-1: #f6bf8e; --tint-2: #f5dc84; --tint-3: #add7a2; @@ -129,6 +146,7 @@ --muted: #c3cff5; --accent: #ffd84a; --danger: #ff9a8f; + --mark: rgba(255, 216, 74, 0.38); --tint-1: rgba(255, 255, 255, 0.1); --tint-2: rgba(255, 216, 74, 0.16); --tint-3: rgba(140, 230, 200, 0.14); @@ -162,6 +180,7 @@ --muted: #4f443a; --accent: #1f4c9e; --danger: #a3261b; + --mark: #f0c24f; --tint-1: #f3ead6; --tint-2: #f0c24f; --tint-3: #a9cfa4; @@ -216,6 +235,7 @@ --muted: #4d545c; --accent: #1f5bbf; --danger: #cc3326; + --mark: #fde58a; --tint-1: #fdeeb3; --tint-2: #dbe7fb; --tint-3: #d3eedc; diff --git a/skills/motion-render/references/html-film.md b/skills/motion-render/references/html-film.md index 0521f49..bde6901 100644 --- a/skills/motion-render/references/html-film.md +++ b/skills/motion-render/references/html-film.md @@ -88,17 +88,17 @@ Claude は `/film/index.html` だけを書く。描画中はネットワ 依頼文に見た目の指定(「手描きで」「ホワイトボードで」「設計図っぽく」)があればそれに合う作風にする。 指定がなければ、テーマの中身で選ぶ。利用者には聞かない。迷ったら暗い平面にする。 -| 作風 | `data-style` | 合うテーマ | 見え方 | -| -------------- | ------------ | ---------------------------------------------------------- | ------------------------------------------------------------------ | -| 暗い平面 | (付けない) | ソフトウェアの仕組み、セキュリティ、インフラ、データの流れ | 暗い地に、色を絞った平らな図 | -| 紙と鉛筆 | `paper` | 考え方、アイデア、設計の発想、研究の要点 | 方眼のクリーム色の紙に、揺れる鉛筆の線と青鉛筆の斜線、手書きの文字 | -| 色鉛筆 | `crayon` | 初めての人や子ども向け、暮らし、健康、物語 | 暖かい紙に、太くやわらかい線と、紙の目が透ける塗り | -| 設計図 | `blueprint` | 機械、建物、部品の構造、システムの構成 | 青い地に白い方眼と、揺れないきれいな白い線 | -| 紙コラージュ | `collage` | 会社やチームや人の紹介、歴史、事例 | クラフト紙の上に、破った色紙をテープで貼る | -| ホワイトボード | `whiteboard` | 授業、数式、理論、アルゴリズム | 白い板に、マーカーの線と手書きの文字 | +| 作風 | `data-style` | 合うテーマ | 見え方 | +| -------------- | ------------ | ---------------------------------------------------------- | ------------------------------------------------------------------------ | +| 暗い平面 | (付けない) | ソフトウェアの仕組み、セキュリティ、インフラ、データの流れ | 暗い地に、色を絞った平らな図 | +| 紙と鉛筆 | `paper` | 考え方、アイデア、設計の発想、研究の要点、働き方の変化 | 白い紙に、揺れる鉛筆の線と斜線の陰、黄土色の強調と蛍光ペン、手書きの文字 | +| 色鉛筆 | `crayon` | 初めての人や子ども向け、暮らし、健康、物語 | 暖かい紙に、太くやわらかい線と、紙の目が透ける塗り | +| 設計図 | `blueprint` | 機械、建物、部品の構造、システムの構成 | 青い地に白い方眼と、揺れないきれいな白い線 | +| 紙コラージュ | `collage` | 会社やチームや人の紹介、歴史、事例 | クラフト紙の上に、破った色紙をテープで貼る | +| ホワイトボード | `whiteboard` | 授業、数式、理論、アルゴリズム | 白い板に、マーカーの線と手書きの文字 | `data-style` を付けると、地・面・線・文字の色(`--ground` `--panel` `--line` `--ink` `--muted` -`--accent` `--danger`)、紙の目や方眼、文字の書体が決まる。ページでは色をこの変数で書き、別の色を足さない。 +`--accent` `--danger`、蛍光ペンの `--mark`、塗りの `--tint-1`〜`--tint-4`)、紙の目や方眼、文字の書体が決まる。ページでは色をこの変数で書き、別の色を足さない。 紙の目と方眼は `#root` の後ろに描かれ、`check` は文字の色の差を `--ground` や面の色に対して測る。 作風の部品(どの作風でも同じ名前で使え、作風ごとに見え方が変わる): @@ -108,6 +108,8 @@ Claude は `/film/index.html` だけを書く。描画中はネットワ | `drawn` | カード・枠。手で引いた枠線を描く。`style="--fill: var(--tint-2)"` で中を塗る。中の文字は揺らさない | | `sketch` | 線の図(矢印・アイコン・グラフの線の ``)。線が手描きのように揺れる。文字を持つ要素には付けない | | `hatch` | 棒グラフの棒や図形の陰。`color` の色の斜線で塗る。上に文字を置かない | +| `url(#mr-hatch)` | `` の図形(人の胴、箱)の `fill`。`--line` の斜線で塗る。`url(#mr-hatch-accent)` は `--accent` の交差した斜線。図形には `sketch` も付ける | +| `marker` | 見出しや大事な語の後ろに蛍光ペンを引く。GSAP で `{ "--mark-x": 0 }` から `{ "--mark-x": 1 }` へ動かすと左から引かれる。上の文字は `--ink` のままにする | | `tooth`(`crayon`) | 文字のない図形を色鉛筆で塗ったように、紙の目を透かす | | `cutout`・`taped`(`collage`) | 紙片。破れた縁と影を付ける。`--fill` で紙の色(`--tint-1`〜`--tint-4`)を選び、`taped` で上にテープを貼る。縁が 20px ほど外へ出るので、紙片の間を 40px 以上あける | diff --git a/tests/html-film.test.ts b/tests/html-film.test.ts index e4f0943..3a7511e 100644 --- a/tests/html-film.test.ts +++ b/tests/html-film.test.ts @@ -416,6 +416,31 @@ const STYLE_PAGE = ` `; +// A heading with a highlighter stroke drawn under it between 0.14s and 0.2s, and a shape filled with +// the kit's hatching (or with nothing, to compare against). +function markPage(fill: string): string { + return ` + + + + + +
+
+

これから

+ +
+
+
+ +`; +} + describe("drawing styles", () => { it("puts the stylesheet and the hand-written font in the kit", () => { const dir = filmOf(STYLE_PAGE); @@ -441,4 +466,20 @@ describe("drawing styles", () => { await film.close(); } }); + + it("draws the highlighter as --mark-x grows and fills SVG shapes with the kit's hatching", async () => { + const hatched = await openFilm(filmOf(markPage("url(#mr-hatch)"))); + const empty = await openFilm(filmOf(markPage("none"))); + try { + // 0.13s and 0.24s fall in the same eighth of a second, so the lines shimmer the same way in both. + const before = await hatched.frame(0.13); + expect((await hatched.frame(0.24)).equals(before)).toBe(false); + expect((await empty.frame(0.13)).equals(before)).toBe(false); + expect((await checkLayout(hatched))?.map(formatIssue)).toEqual([]); + expect(await hatched.warnings()).toEqual([]); + } finally { + await hatched.close(); + await empty.close(); + } + }); });