配膳ロボットの導入前シミュレーションをLPにする ― 記事と同じ構成をやめた

配膳ロボット(ファミレスなどで料理を席まで運ぶロボット)を入れるか迷っている飲食店と、それを店に提案するメーカー・販売会社に向けて、導入前に自分の店舗で何が起きるかを試せるページを1枚作りました。住所は pixelstomotors.com/serving-robot-one-less-staff です。

材料は、このサイトで続けているサービスロボット導入シミュレーションの記録11本です。店舗の混む2時間を計算で再現する試算と、同じ店舗を3D(ゲームエンジンの Unity)で建ててスタッフ・客・ロボットを走らせた記録が入っています。たとえば標準的なファミレスでは、ロボットを2台入れても店舗が回る最少の人数は3人のまま減りませんでした。会計の列の上をロボットが通る店舗では、レジのまわりで112回止まりました。こうした結果は、ロボットを入れる前に分かります。

最初に作った版は、記事の順番をほぼそのままページにしたものでした。3つの数字で結論、人は減らせるか、お金は合うか、店の形で変わる、3Dの店で確かめた、という並びです。これを見て、次の指摘がありました。

構成がサービスロボット導入シミュレーションと全く同じだね。デザインルールは同じでもいいけど、表現すべきことや伝えることは違うはずだからもう一度考え直してください。

記録は作った順に並べればよいが、このページを読む人が知りたいのは、自分の店舗で導入前に何が分かるか、それをどう頼めるか、でした。 そこで構成を作り直し、困りごと → 導入前に分かる3つのこと → なぜ自分の店舗でやるのか → まず近い店舗で試す → 頼むときの流れ → 立場ごとの使い方 → この試算で言えないこと → 相談、の順にしました。

そのあと、画面写真に一言ずつ指示をもらっては直して公開する、を朝から昼すぎまで続けました。ページの公開は17回です。

段階何をしたか結果
要件定義
読む人を飲食店とメーカー・販売会社の2つに、同じ重さで置いた
最初の画面は、3Dの店舗が動く動画を主役にする案を選んだ
構成
記事の順番を並べた案1から、読む人の問いの順の案2へ
結論の帯・チェックリスト・Unity と試算の比べを外し、頼み方と立場ごとの使い方を足した
最初の画面
1本の動画 → 暗い膜をかけた全面の動画 → 分割と全面を切り替える4場面
平面図・3Dの店舗・走るロボット・試算の結果・Unity の編集画面・実際に動くコードを順に見せる
画質
元の大きい画像と動画から切り出し直した
走る様子の動画は、同じ大きさのまま情報量(ビットレート)が約2.3倍
細部
番号を外す・中央揃え・幅・言葉・絵・角丸・色
店→店舗、入れる前→導入前。黒をサイト共通の #242424 に
最後の相談
3案 → 送るもの3つとボタン1つ → 背景を店舗の図面とロボットの道に
ボタンのオレンジだけが明るく見える

この記事では、どう構成を考え直したか、最初の画面をどう組んだか、細部で何を直したかを、指示と画面写真の順に残します。数字はすべて試算のページから読み込んでいて、このページのために新しく計算した数字はありません。

ここから下は作業ログです。

作業ログ ― シリーズの記録から、導入前の相談のページへ

効果について先に断っておきます。下の「見込める効果」は、測った値(画像の大きさ・動画のビットレート・ページの重さ)以外は見込みです。押されたかどうかは、サイトの使われ方の記録(/api/ev-stats)で、最初の画面のボタン(FV:相談)と最後のボタン(最後:相談)の数で確かめます。

依頼:研究室のLPと同じように、シリーズの記事からLPを作る

最初の依頼は次のとおりでした。

サービスロボット導入シミュレーションの記事の内容を使って、フィジカルAI研究所で作ったLPと同じようなものを作成したいので要件定義(誰に?どんな目的でどんな成果を目指す?など)、ワイヤーフレーム、デザイン、細部詰めの順番で進めてみてください。動画や画像をうまく使ってみてわかりやすく、伝わるLPを目指してください。

ここでいう研究室のLPは、学習用のGPUを買うか借りるかを決めるためのページ(pixelstomotors.com/gpu-buy-or-rent )です。作った経緯は LOG368 に、デザインの詰め方は LOG369 にあります。

要件定義:飲食店とメーカー・販売会社を、同じ重さで読む人に置いた

要件定義では、誰に・何のために・何を成果にするかを決めました。読む人は2つの候補から選んでもらい、両方を同じ重さで扱うことになりました。

項目決めたこと
読む人
配膳ロボットの導入を考える飲食店の経営者・事業責任者と、店に提案するロボットのメーカー・販売会社。同じ重さ
読む人の段階
入れるかどうか、何台か、どう使うかを、まだ決めていない
ページの目的
導入前に、自分の店舗(提案先の店舗)で何が起きるかが分かること。その試算と3Dの店舗での確かめを頼めること
成果
相談のフォーム(/message?topic=physical-ai)に、自店・提案先の条件で試算の相談が来ること
扱い
記事ではなく LP。LOG 番号を付けず、記事一覧にも並べない。数字は試算が変われば、次のビルドで自動で変わる

最初の画面は、3つの方向で案を出しました。結果の数字を2枚の票に並べる案A、3Dの店舗が動く動画を主役にする案B、店舗の図面を主役にする案Cです。案Bが選ばれました。

最初の画面の3つの方向。左から、結果の数字を2枚の票に並べる案A、3Dの店舗の動画を主役にする案B、手描きの見取り図と図面を主役にする案C

構成案1:研究室のLPの型に、記事の流れを当てはめていた

最初のワイヤーフレーム(ページの骨組みの図)は、次の並びでした。

  1. 最初の画面
  2. 3つの数字で、先に結論
  3. 人は減らせるか
  4. お金は合うか
  5. 人数そのままで変わること
  6. 店の形で答えが変わる
  7. 自分の店で試算する
  8. 予想と逆だったこと
  9. 3Dの店で確かめた
  10. 入れる前に確かめる7つ
  11. 次は、あなたの店で
構成案1のワイヤーフレームの上半分。左に節の名前と伝えること、右に灰色の箱で中身の置き方を描いている

この案で一度ページを作って公開し、最初の画面を、左のメニューと8つの場面が切り替わる動画に組み替えるところまで進めました。そこで、構成が記事と同じだという指摘を受けました(冒頭の引用)。

並びを見直すと、研究室のLPの型(結論の数字 → 章ごとの確かめ → チェックリスト)に、記事の流れ(見取り図 → 図面 → 試算 → Unity)をそのまま当てはめていました。研究室のLPは、読む人が自分で買うか借りるかを判断するページです。このページは、読む人が自分の店舗で確かめたくなり、頼めるページです。目的が違うので、並びも変わるはずでした。

構成案2:困りごとから始めて、頼み方と立場ごとの使い方で終える

作り直した骨組みは次のとおりです。

順節伝えること(1つ)
最初の画面
配膳ロボットを、導入前に自分の店舗でシミュレーション
店舗の図面と人数から、何人で回るか・どこで止まるか・月いくらかかるかを出す
1導入後のつまずき
つまずきの多くは、導入前に分かる
2導入前に分かる3つのこと
何人で回るか・どこで止まるか・月いくらかかるか
3なぜ自分の店舗でやるのか
同じ2台でも、入店待ちの縮み方は店舗の形で違う
4まず、近い店舗で試す試算をその場で動かせる
5頼むときの流れ
送るのは、図面と人数と混む時間だけ
6立場ごとの使い方
店舗は入れるかを、提案する側はどう入れるかを決める
7この試算で言えないこと
数字は仮の前提。店舗で測って合わせていく
最後相談
図面と人数を送れば、同じ試算でお返しする

外したものは、3つの数字で先に結論を言う帯、Unity と試算を比べる節、チェックリスト7つです。研究の過程の話は、このページでは主役にしません。気になった人のために、ページの最後に記録11本へのリンクを置きました。

構成案2のワイヤーフレーム。上に、研究の記事・研究室のLP・このLPで伝えることの違いを書き、その下に節ごとの伝えることと置き方を並べた
  • ねらい:読む人が、自分の店舗のことだと思える順番で読み進められるようにする
  • 変わったこと:記事を作った順(見取り図 → 図面 → 試算 → Unity)から、読む人の問いの順(困りごと → 分かること → なぜ自分の店舗か → 試す → 頼む)に。頼むときの流れと受け取るもの4つの節を新しく足した
  • 見込める効果:相談のボタンを押す前に、何を送れば何が返ってくるかが分かるので、相談をためらう理由が1つ減る(見込み。最後のボタンの押された数で確かめる)

最初の画面:手描きの見取り図を外し、メニューと動画の形にした

最初の画面は、構成案1のときに、研究室のLPのメニューの作り方を参考に、左に節のメニュー、右に場面が切り替わる動画を置く形にしました。参考にしたいという指示は次のとおりです。

TOPの動画は切り替わってイメージを伝わりやすくしたい。メニューの作り方も含めて参考にしてください。

最初に組んだ動画には、記憶から描いたファミレスの見取り図(LOG360 で使った手描きの絵)の場面が入っていました。

手描きの図面はいらないかな

だから手描きは嫌だって。

1回目の指示で見取り図の場面だけを外したものの、4の節の中にも同じ絵が残っていて、2回目の指示を受けました。以後、ページのどこにも手描きの絵は使っていません。

最初の画面の初めの形。左にメニュー、右に切り替わる動画。右の画像は、手描きの見取り図の場面が入っていたときのもの

最初の画面:暗い膜をかけた全面の動画は、灰色で汚く見えた

構成を作り直したときに、最初の画面を、全面の動画の上に暗い膜をかけて文字を載せる形にしました。

グレーっしゅでFV画面が汚く見えるね。デザイン見直し

常に全面画像、動画じゃなくて、分割とか使ってデザイン性を上げてください。

作り直した直後の最初の画面。全面の動画に暗い膜がかかり、全体が灰色に沈んでいる

最初の画面:分割と全面を切り替え、Unity とコードも見せる

右側を4つに分けました。店舗の平面図、同じ店舗を3Dで建てたところ、スタッフとロボットが走るところ、試算の結果の4枚です。3Dの店舗の斜めの動画と、真上・正面・横・斜めの4つの視点の動画は、このとき新しくもらったものです。

4つに分けた最初の画面。平面図・3Dの店舗・走るスタッフとロボット・試算の結果(店舗が回る人数4人→3人、入店待ち3.2分→1.0分、ひと月の差し引き−0.3万円)

続けて、同じ4枚がずっと出ているのではなく、全面と分割を何度か切り替えてほしいという指示がありました。

ずっと同じ動画じゃなくて、全面になったり、分割になったりと何度か切り替えてください。

Unityの操作画面とか、ターミナルでコードが動いているとか、色々材料はあるはずです。

場面は4つにしました。

場面形出しているもの長さ
14分割
平面図・3Dの店舗・走らせる・試算の結果
6.0秒
2全面
Unity の編集画面で店舗を建てて回しているところ
4.5秒
33分割
試算のコード(SimPy)が動くターミナル・ロボットの道を引いた図面・費用と浮く人件費
6.5秒
4全面
試算のページで人数・台数を変えて結果を見比べるところ
4.5秒

全面の場面には、元の画像が画面より大きいもの(1920×1741 の Unity の画面と、2000×1840 の試算の画面)だけを使いました。小さい画像を全面に引き伸ばすと、ぼやけるからです。全面の場面ではゆっくり寄っていく動き(Ken Burns と呼ばれる、静止画に少しずつ寄る見せ方)を付け、下に4本の進み具合の線を置いています。

ターミナルの場面は、作った絵ではありません。LOG359 の試算のコード(haizen_sim.py)を実際に動かし、出てきた文字と時刻をそのまま記録して、記録した時刻どおりにブラウザの中のターミナル(xterm.js)に書き出し、1コマずつ撮って動画にしました。打つ速さだけは1文字0.045秒にしています。

最初の画面の4つの場面を並べたところ。ターミナル・4分割・試算の全面などが入れ替わる
  • ねらい:このページで何ができるか(図面から店舗を建て、走らせ、数字を出す)を、文字を読む前に見せる
  • 変わったこと:1本の動画(暗い膜付き)から、4分割 → Unity の全面 → コード・図面・費用の3分割 → 試算の全面、の4場面21.5秒の切り替えに。実際に動いた Unity の画面とコードの出力を入れた
  • 見込める効果:3Dで建てたのも、計算したのも作り物ではないと分かり、試算の数字を信じてもらいやすくなる(見込み)

最初の画面の言葉:シミュレーション・導入前・店舗

見出しの言葉にも指示がありました。

FVテキストどこにもシミュレーションという言葉がなくてなんのことか伝わりづらい 入れる前という言葉より、導入前の方が良いのでは?

見出しを「配膳ロボット、入れる前に自分の店で走らせる」から「配膳ロボットを、導入前に自分の店舗でシミュレーション」に変え、ページの中の「入れる前」を「導入前」にそろえました。あとで、「店」も「店舗」にそろえています。試算のデータの中の店の種類の名前(小型店・大型店・洗い場が遠い店)は、試算のページと同じ名前のままです。

いまの最初の画面のうち2つの場面。左が4分割、右がコード・図面・費用の3分割
いまの最初の画面の、試算のページを全面で見せる場面

スマホでは、上に見出しとボタン、その下に切り替わる場面、メニューの順に縦に並びます。

スマホで見た最初の画面

画質:元の大きい画像と動画から切り出し直した

画像、動画で画質が悪いのは避けてください。

画質が悪い

2回指摘を受けました。原因は、記事に載せるために小さく書き出した画像を、さらに切り抜いて大きく見せていたことです。元の画面収録(2884×1741 など)から切り出し直し、動画は画質の指定を上げて書き出し直しました。

ファイル前後
top-busy.mp4(スタッフとロボットが走る)
976×652・174 kbps・419KB
976×652・406 kbps・940KB
fv-oblique.mp4(3Dの店舗の斜め)
930×716・573 kbps(暗い膜あり)
820×716・556 kbps・1.4MB(膜なし・編集画面の印を外して切り直し)
fv-unity.webp(Unity の編集画面)
なし
1920×1741・167KB
trouble-staff.webp(入れたのに人が減らない)
小さく書き出した画像から切り抜き
1400×930・93KB
  • ねらい:画面を見た人が、作りの粗さで中身まで疑わないようにする
  • 変わったこと:走る様子の動画は、同じ大きさのままビットレートが 174 kbps から 406 kbps(約2.3倍)に。全面に出す画像は元が 1920px 以上のものだけにした
  • 見込める効果:動画の中のロボットとスタッフの区別がつきやすくなる。一方で、動画のファイルは 419KB から 940KB に増えた(測った値)

見出しと説明文:番号を外し、中央にそろえ、1行に収めた

構成案2の初めの形では、節の頭に 01・02 の大きな番号を、中にも①②③の小さな番号を付けていました。

セクションの数字を削除。タイトル、説明文はセンター合わせ(全セクション)

① 小さい①とか削除(他の数字もノイズに見える)

説明文テキストは折り返さない。

番号をすべて外し、どの節も見出しと説明文を中央にそろえました。説明文は、幅が 1100px 以上の画面では折り返さず1行に収めています(それより狭い画面では折り返します)。

導入後のつまずきの節の前と後。左は番号付きで左寄せ、右は番号を外して中央にそろえた形

つまずきの3枚の写真の下にあった札(試算で・3Dの店舗で)は、黒い四角の背景に白い文字だったため、見出しより目立っていました。

試算で 四角背景にテキストがあるのが強すぎてタイトルに目が行かない。もっと軽く作ってください。

札は、背景のない小さな文字だけにしました。

導入前に分かる3つのこと:左の文を図の上端にそろえた

この節は、左に文、右に図を置き、3つの段で左右を入れ替えています。何人で回るかの段は、店舗と混み具合を選ぶと、ロボット0〜3台ごとに店舗が回る最少の人数が人とロボットの絵で並ぶ図です。

左のテキストは画像の上とツライチに変更

人とロボットとわかりやすいようにイラスト修正

左の文は、図の上端と同じ高さから始めました。人の絵は頭と体の形、ロボットの絵は四角い頭に目が2つ、台形の胴と台の形にし、ロボットだけをオレンジにしています。最後に、ロボットの胴に入れていた横線2本を外しました。

ロボット胴体のシマシマいらない削除

どこで止まるかの段の数字(79 → 0回、112 → 31回)は、文字の大きさを 2rem から、画面の幅に合わせて 2.6〜3.6rem に上げました。

数字をもっと強く!

直す前の、どこで止まるかの数字。79 → 0回、112 → 31回が、説明文に近い大きさで並んでいる
いまの導入前に分かる3つのことの節。何人で回るか(人とロボットの絵)、どこで止まるか(大きくした数字と4つの視点の動画)、月いくらかかるか(費用の棒)

コンテンツ幅:基本は 1040px、図面と試算の2つだけ広くした

コンテンツ幅調整。もっと狭くて良さそう

コンテンツ幅はセクションによっては広いのがあってもOKです。全部合わせなくても。広い場合は意図が合って広くしてください。

中身の幅を 1040px にそろえたうえで、2つの節だけを 1280px にしました。6つの平面図を並べる節(図面の中の卓や通路が見える大きさにするため)と、試算を埋め込んだ節(試算の画面は横に表とグラフが並ぶため)です。

6つの平面図を並べた節。同じ2台でも、入店待ちの縮み方が店舗ごとに違うことを棒と数字で見せる

角丸:埋め込んだ試算に合わせて3段にした

このシュミレーションが角丸を採用しているので、他のセクションも合わせた形に修正してください。

試算のページの角の丸みに合わせ、大きな面(試算の枠・図・受け取るものの枠・立場ごとのカード・最後の白い面)は 12px、カードと画像は 8px、ボタンと札は 4px にしました。

頼むときの流れ:5つの手順に絵を付けた

アイコン系イラスト追加

手順5つ(図面と条件を送る・図面に起こす・試算する・3Dの店舗で走らせる・結果と直し方を返す)に、それぞれ黒の小さな絵を付けました。

頼むときの流れの節。5つの手順と絵、その下に受け取るもの4つ

最後の相談:送るもの3つとボタン1つ、背景は店舗の図面とロボットの道

最後の相談の節は、もともと黒い地の右側に斜めのストライプを入れ、その上の白い面に見出しとボタンを置いた形でした。角丸を入れたあと、次の指摘がありました。

カードに角丸を使うとこのCTAのストライプとデザインが合わないような気がするので、違うデザインを提案してください。

3つの案を出しました。

案形押させるもの
A
左に見出し、右に送るもの3つ(店舗の図面・スタッフの人数と混む時間・ロボットの使い方)とボタン
送るものが分かったうえでの相談
B
黒い地に見出しとボタン、右に試算の結果の票の見本
返ってくるものを見てからの相談
C
左に3Dの店舗の写真、右に見出しとボタン
雰囲気からの相談
最後の相談の3つの案。左から、送るもの3つとボタン(案A)、黒い地に結果の票(案B)、3Dの店舗の写真と見出し(案C)

案Aが選ばれ、背景に写真を入れることになりました。

これもベースに背景写真を入れてみてください

最初は3Dの店舗を真上から撮った画面を背景にしました。そのあと、別の写真か、図のような絵に変えるよう指示がありました。

違う背景写真、もしくはグラフィカルな写真に変更

背景は、⑤ゆったり型・入口が正面の中央の店舗(46卓・200席)の図面の形のデータから、絵をコードで描き直しました。暗い地に白い細い線で卓と部屋を描き、ロボットの待機場所から46卓すべてへの道をオレンジで引いています。道は、図面の上で卓・部屋・壁を避けて、10cm の格子の上で一番短い道を探して引いたもので、試算や記事の数字とは関係ありません。線で描いているので、どの大きさの画面でもぼやけません。

初めの版では、何本もの道が重なる幹の部分が明るく出て、ボタンのオレンジと競って見えました。

ボタンのオレンジが少し競って見える 道の線をもう一段薄く

道を1本ずつ薄くすると、重なった所ほど濃くなります。道をまとめた1つのまとまりとして一度に薄くし(透明度 0.32)、重なっていても明るさが変わらないようにしました。

最後の相談の背景の前と後。左は3Dの店舗を真上から撮った画面、右は店舗の図面とロボットの道を暗い地に描いた絵
  • ねらい:最後の画面で押すものを1つにし、押す前に何を送ればよいかが分かるようにする
  • 変わったこと:ストライプの入った黒い地から、送るもの3つとオレンジのボタン1つに。背景を、ぼやけた画面写真(1560×1040・77KB)から、線で描いた図面の絵(15KB)に替えた
  • 見込める効果:背景の中で明るいオレンジはボタンだけになり、目が最後にボタンへ行く(見込み。最後:相談の押された数で確かめる)。背景のファイルは 77KB から 15KB に減った(測った値)

色:黒をサイト共通の #242424 にし、埋め込んだ試算もそろえた

黒のカラーは現行のサイトのデザインに合わせてください。配膳ロボット、何台入れたら店は変わる?が入っているので、色が統一されていない感じがします。

このページでは黒を #121418 という青みのある黒で作っていましたが、サイトのほかのページは #242424 です。ページの黒・灰色を、サイト共通の #242424(文字と黒い面)・#f7f7f7(薄い地)・#e6e6e6(線)にそろえました。

埋め込んだ試算(配膳ロボット、何台入れたら店は変わる?)の中にも、別の黒と白が使われていました。

揃えてみて。違和感があるか見ないとわからないね。

試算のページは単体でも開かれ、シリーズのページ(/series8)にも埋め込まれています。そちらの見た目は変えず、このページに埋め込んだときだけ(住所に ?lp=1 が付いたときだけ)、店舗の種類を選ぶ帯を #242424、選んだものを白、大きな平面図の地を #f7f7f7 にしました。

埋め込んだ試算の色。左がシリーズのページに埋め込んだときの元の色、右がこのページに埋め込んだときの色
まず、近い店舗で試す節。幅を 1280px にして、試算をそのまま動かせるように埋め込んでいる
  • ねらい:ページの中に入った試算が、別のサイトの部品に見えないようにする
  • 変わったこと:ページの黒を #121418 から #242424 に。埋め込んだ試算は、このページに入っているときだけ同じ黒と白に
  • 見込める効果:試算を使う人が、同じページの続きとして読み進められる(見込み。埋め込んだ試算の中で押された数は、data-ev-area=“sim” で数えている)

数字は試算のページから読み、このページでは計算しない

ページの数字(2台入れても回る人数が減らない条件が99のうち47、ひと月の差し引き、6つの店舗の入店待ちなど)は、試算のページ(public/sim/serving-robot.html)の中にあるデータを、サイトを作るとき(ビルドのとき)に読み出して入れています(src/lib/servingSim.ts)。試算の数字が変われば、次のビルドでこのページの数字も変わります。

3Dの店舗の数字(112回 → 31回、79回 → 0回、ロボットが運んだ料理が約3割 → 約7割)は、LOG364 と LOG367 の記録から書いています。

ページの重さ:最初の画面だけで約9.5MBを読み込んでいた

公開したページで、最初の画面を開いたときに読み込む量を測りました(1440×900 の画面・Chromium・本番の住所)。

種類量
動画5,898,395 バイト
文字の形(フォント)1,705,322 バイト
画像866,162 バイト
ページと埋め込んだ試算の HTML
665,404 バイト
CSS344,947 バイト
JS15,255 バイト
そのほか(使われ方の記録の送信など)
1,837 バイト
合計(126件)9,497,322 バイト

一番大きいのは、ターミナルの動画(terminal.mp4・3,539,481 バイト)でした。最初の画面の動画は、場面が来る前から読み込む設定(preload=“auto”)にしているため、3場面目のターミナルの動画も最初に読み込まれます。別の測り方(届いた返事に書かれた大きさを足す)で、下までスクロールしたあとの合計は約11.5MBでした。スマホの回線で開く人のために、ここを見直します。

コードと記録

この記事のコード・ログ・データは、1ファイルずつ下に置いています。全文はリンク先にあります。

ファイルの一覧(6本)

01_serving-robot-one-less-staff.astro.txt ― serving-robot-one-less-staff.astro(ページ本体)・47,150字

02_servingSim.ts.txt ― servingSim.ts(試算のページから数字を読む)・5,163字

03_cta_plan.py.txt ― cta_plan.py(最後の相談の背景を描く)・3,292字

04_cta-plan.svg.txt ― cta-plan.svg(描き出した背景)・15,062字

05_session.json ― session.json(記録した出力と時刻)・7,590字

06_haizen_sim.py.txt ― haizen_sim.py(ターミナルの場面で動かした試算のコード)・26,783字

ここから下は、このページを作るのに使ったコード・スクリプト・記録の全量です。

serving-robot-one-less-staff.astro(ページ本体)

LP を作り終えたとき(12:23 の公開・65aac70)の全文です。

---
// 導入前の試算ガイド:配膳ロボットを、導入前に自分の店でシミュレーション(LP。LOG の記事ではない)
// 伝えること:導入前に、自分の店(提案先の店)で何が起きるかが分かる。その試算と3Dの店での確かめを頼める
// 数字は試算(public/sim/serving-robot.html)からビルドのときに読む(src/lib/servingSim.ts)。3Dの店の数字は LOG364・LOG367 の記録から
// 動画と画像は public/site/srl/(Unity の記録動画から切り出した短いループと、試算と同じ描き方の平面図)
import Base from '../layouts/Base.astro';
import { SITE, AUTHOR, PUBLISHER } from '../lib/seo';
import { headline as H, twoRobots, staffTable, plans, buyLarge, robotMonthRent, savedPerStaff, COST, CONDITIONS, min1, man, minStaff, STORES } from '../lib/servingSim';
別のタブで開く保存

servingSim.ts(試算のページから数字を読む)

ページの数字はすべてここから出しています。

02_servingSim.ts.txt5,163字・104行・ts
// 配膳ロボットの試算(public/sim/serving-robot.html)の結果を、ビルドのときに読む。
// LP(/serving-robot-one-less-staff)の数字は、すべてここから出す。試算が正なので、試算を直せば LP の数字も次のビルドで変わる。
// 新しい計算はしない。試算のページと同じ決め方(find・minStaff・費用の既定値)で読むだけ。
import fs from 'node:fs';
import path from 'node:path';

type Row = Record<string, any>;
type Store = Record<string, any>;
別のタブで開く保存

serving-robot.html の変更(このページに埋め込んだときだけ色をそろえる)

試算のページの変更は、この commit の差分だけです。

commit 11113dc9cc18b0d630e953b3958a807bd295c14e
Author: Claude <noreply@anthropic.com>
Date:   Sat Oct 24 11:54:27 2026 +0900

    配膳ロボットのLP:埋め込んだ試算の黒と白を、LPに埋め込んだときだけ(?lp=1)サイト共通の色にそろえる。試算のページ単体と /series8 の見た目は変えない
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01UbPZJruW4MJ6W3afZEfKQy

diff --git a/public/sim/serving-robot.html b/public/sim/serving-robot.html
index 310b13e..c45f387 100644
--- a/public/sim/serving-robot.html
+++ b/public/sim/serving-robot.html
@@ -21,8 +21,14 @@ html.embed .stores label { border-color: #f7f7f7; }
 
 /* 表のセルを折り返さず、はみ出す分は .tablewrap の中で横にスクロールする(狭い画面で1行に1〜2文字になっていた。2026-10-20) */
 .ctable th, .ctable td { white-space: nowrap; }
+/* LP(/serving-robot-one-less-staff)に埋め込んだときだけ(?lp=1)、黒と白をサイト共通の色にそろえる。試算のページ単体と /series8 の見た目は変えない(2026-10-24) */
+html.lp .stores, html.lp .layouts { background: #242424; }
+html.lp .stores label, html.lp.embed .stores label, html.lp .layouts label { background: #3a3a3a; border-color: #3a3a3a; }
+html.lp .stores input:checked + label, html.lp.embed .stores input:checked + label, html.lp .layouts input:checked + label { background: #fff; border-color: #fff; color: #242424; }
+html.lp .plan-big { background: #f7f7f7; color: #242424; }
 </style>
 <script>if (/[?&]embed=1/.test(location.search)) document.documentElement.classList.add('embed');</script>
+<script>if (/[?&]lp=1/.test(location.search)) document.documentElement.classList.add('lp');</script>
 </head>
 <body>
 <style>

astro.config.mjs(LP を作っていたときの版)

LP を作っていたときの全文です。13行目の filter で、このページをサイトマップから外していました。

import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import remarkMarkers from './src/lib/remark-markers.mjs';

// URL は今の Studio と同じ「末尾の / なし」にそろえる(/series7 → series7.html)
export default defineConfig({
  site: 'https://pixelstomotors.com',
  trailingSlash: 'ignore',
  build: { format: 'file' },
  // 検索エンジン向けの全ページ一覧(/sitemap-index.xml と /sitemap-0.xml を作る)。404 ページは載せない
  integrations: [
    // 確認中の LP は、公開を決めるまで一覧に載せない(ページは noindex)
    sitemap({ filter: (page) => !page.endsWith('/404') && !page.endsWith('/404/') && !page.includes('/serving-robot-one-less-staff') && !page.includes('/thinking-') }),
  ],
  markdown: {
    // コードは今のサイトと同じく色を付けずに出す
    syntaxHighlight: false,
    remarkPlugins: [remarkMarkers],
  },
});

cta_plan.py(最後の相談の背景を描く)

⑤ゆったり型の店舗の形のデータ(p2m-site の public/sim/layouts/p3_relaxed_center.json と p3_relaxed_center_analysis.json)から、図面とロボットの道を SVG に描きます。動かし方は python3 cta_plan.py public/sim/layouts/p3_relaxed_center.json public/sim/layouts/p3_relaxed_center_analysis.json public/site/srl/cta-plan.svg です。

03_cta_plan.py.txt3,292字・60行・python
"""最後の節の背景:⑤の店舗の図面(暗い地に細い線)と、待機場所から全卓へのロボットの道(最短の道を格子で探す)"""
import json, sys
from collections import deque
L = json.load(open(sys.argv[1]))['layout']; A = json.load(open(sys.argv[2]))
W, D = L['outer']['w'], L['outer']['d']; S = 0.1; R = 0.3
nx, ny = int(W / S), int(D / S)
block = [[False] * ny for _ in range(nx)]
def rect_block(x0, y0, x1, y1, pad):
別のタブで開く保存

cta-plan.svg(描き出した背景)

04_cta-plan.svg.txt15,062字・198行・xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -1 28.0 19.0" preserveAspectRatio="xMidYMid slice">
<rect x="-50" y="-50" width="126.0" height="117.0" fill="#242424"/>
<g fill="none" stroke="#fff" stroke-opacity=".22" stroke-width="0.04">
<rect x="9.0" y="6.4" width="10.000" height="1.200" fill="#fff" fill-opacity=".04"/>
<rect x="9.0" y="3.0" width="10.000" height="3.400" fill="#fff" fill-opacity=".04"/>
<rect x="9.0" y="0.0" width="5.000" height="3.000" fill="#fff" fill-opacity=".04"/>
<rect x="14.0" y="0.0" width="5.000" height="3.000" fill="#fff" fill-opacity=".04"/>
<rect x="19.0" y="0.0" width="4.000" height="2.500" fill="#fff" fill-opacity=".04"/>
別のタブで開く保存

rec_session.py(試算のコードを実際に動かし、出力と時刻を記録する)

ターミナルの場面のもとです。haizen_sim.py と同じフォルダで動かしました。

# 実際にコマンドを動かし、出力と時刻をそのまま記録する(あとで同じ時間で再生して録画する)
import json, os, pty, subprocess, time, select
cmds = ["sed -n 469,503p haizen_sim.py", "python3 haizen_sim.py --one", "python3 haizen_sim.py --main"]
events = []; t0 = time.time()
for c in cmds:
    events.append([round(time.time()-t0,3), "cmd", c])
    m, s = pty.openpty()
    p = subprocess.Popen(["bash","-c", "stty cols 96; " + c], stdout=s, stderr=s, stdin=s, cwd=os.getcwd(), env={**os.environ, "PYTHONUNBUFFERED":"1", "COLUMNS":"96"})
    os.close(s); limit = 26 if "--main" in c else 60
    while True:
        r,_,_ = select.select([m],[],[],0.2)
        if r:
            try: d = os.read(m, 4096)
            except OSError: break
            if not d: break
            events.append([round(time.time()-t0,3), "out", d.decode("utf-8","replace")])
        if p.poll() is not None and not r: break
        if time.time()-t0 > limit + events[-1][0]*0 and "--main" in c and time.time()-t0 > 70: p.terminate(); break
    p.wait()
json.dump(events, open("session.json","w"), ensure_ascii=False)
print(len(events), events[-1][0])

session.json(記録した出力と時刻)

1つ目の数が記録を始めてからの秒、2つ目が cmd(打ったコマンド)か out(出てきた文字)です。

05_session.json7,590字・1行・json
[[0.0, "cmd", "sed -n 469,503p haizen_sim.py"], [0.009, "out", "def run_once(params, seed):\r\n    store = STORES[params[\"store\"]]\r\n    p = {**COMMON, **store, **params}\r\n    env = simpy.Environment()\r\n    s = Store(env, random.Random(seed), p)\r\n    env.process(s.arrivals())\r\n    if p[\"sustain\"]:\r\n        return _sustain_result(env, s, p)\r\n    env.run()\r\n    end = env.now\r\n    w = s.dish_waits\r\n    return {\r\n        \"dish_wait_mean\": statistics.mean(w),\r\n        \"dish_wait_p90\": sorted(w)[int(0.9 * (len(w) - 1))],\r\n        \"cold_share\": sum(x > p[\"cold_min\"] for x in w) / len(w),\r\n        \"seat_wait_mean\": statistics.mean(s.seat_waits),\r\n        \"turn_mean\": statistics.mean(s.turns) if s.turns else 0.0,\r\n        \"walk_km_per_staff\": s.staff_walk_m / p[\"staff\"] / 1000,\r\n        \"staff_util\": s.staff_busy_s / (p[\"staff\"] * end),\r\n        \"robot_util\": (s.robot_busy_s / (p[\"robots\"] * end)) if p[\"robots\"] else 0.0,\r\n        \"robot_share\": s.robot_trips / max(s.robot_trips + s.staff_trips, 1),\r\n        \"robot_bus_share\": s.robot_bus / max(s.robot_bus + s.staff_bus, 1),\r\n"], [0.009, "out", "        \"collects\": s.collects,\r\n        \"rescues\": s.rescues,\r\n        \"pauses\": s.pauses,\r\n        \"fragile_robot\": s.fragile_robot,\r\n        \"groups\": s.done,\r\n        # 入店待ちの内訳(v2.2)\r\n        \"entry_wait_mean\": statistics.mean(s.entry_waits),            # 来てから案内されて席に着くまで\r\n        \"blocked_wait_mean\": statistics.mean(s.blocked_waits),        # 席はあるのに片付けが終わらず待った\r\n        \"guide_wait_mean\": statistics.mean(s.guide_waits),            # 卓は決まったが案内の手が空かず待った\r\n        \"staff_wait_mean\": statistics.mean(b + g for b, g in zip(s.blocked_waits, s.guide_waits)),  # 席はあるのに待った(合計)\r\n        \"full_wait_mean\": statistics.mean(w - b for w, b in zip(s.seat_waits, s.blocked_waits)),    # 席が埋まっていて待った\r\n    }\r\n\r\n"], [0.009, "cmd", "python3 haizen_sim.py --one"], [0.844, "out", "serve 1 any {'dish_wait_mean': 0.61, 'dish_wait_p90': 1.05, 'cold_share': 0.0, 'seat_wait_mean': 2.59, 'turn_mean': 9.49, 'walk_km_per_staff': 2.7, 'staff_util': 0.75, 'robot_util': 0.49, 'robot_share': 0.76, 'robot_bus_share': 0.0, 'rescues': 1.07, 'pauses': 38.77, 'fragile_robot': 26.88, 'groups': 68.15, 'entry_wait_mean': 4.26, 'blocked_wait_mean': 2.32, 'guide_wait_mean': 0.45, 'staff_wait_mean': 2.77, 'full_wait_mean': 0.28}\r\n"], [1.571, "out", "serve 1 staff {'dish_wait_mean': 0.64, 'dish_wait_p90': 1.15, 'cold_share': 0.0, 'seat_wait_mean': 4.23, 'turn_mean': 11.29, 'walk_km_per_staff': 2.95, 'staff_util': 0.77, 'robot_util': 0.42, 'robot_share': 0.65, 'robot_bus_share': 0.0, 'rescues': 1.12, 'pauses': 35.08, 'groups': 69.9, 'entry_wait_mean': 5.93, 'blocked_wait_mean': 3.7, 'guide_wait_mean': 0.48, 'staff_wait_mean': 4.17, 'full_wait_mean': 0.54}\r\n"], [2.356, "out", "serve 3 any {'dish_wait_mean': 0.64, 'dish_wait_p90': 1.15, 'cold_share': 0.0, 'seat_wait_mean': 3.43, 'turn_mean': 9.29, 'walk_km_per_staff': 2.74, 'staff_util': 0.76, 'robot_util': 0.51, 'robot_share': 0.8, 'robot_bus_share': 0.0, 'rescues': 1.02, 'pauses': 37.98, 'fragile_robot': 30.23, 'groups': 70.5, 'entry_wait_mean': 5.11, 'blocked_wait_mean': 2.75, 'guide_wait_mean': 0.46, 'staff_wait_mean': 3.21, 'full_wait_mean': 0.68}\r\n"], [3.096, "out", "serve 3 staff {'dish_wait_mean': 0.64, 'dish_wait_p90': 1.18, 'cold_share': 0.0, 'seat_wait_mean': 4.37, 'turn_mean': 10.56, 'walk_km_per_staff': 2.86, 'staff_util': 0.76, 'robot_util': 0.42, 'robot_share': 0.66, 'robot_bus_share': 0.0, 'rescues': 0.93, 'pauses': 32.77, 'groups': 68.72, 'entry_wait_mean': 6.03, 'blocked_wait_mean': 3.79, 'guide_wait_mean': 0.45, 'staff_wait_mean': 4.24, 'full_wait_mean': 0.58}\r\n"], [3.901, "out", "bus 1 any {'dish_wait_mean': 0.66, 'dish_wait_p90': 1.19, 'cold_share': 0.0, 'seat_wait_mean': 4.27, 'turn_mean': 10.11, 'walk_km_per_staff': 2.54, 'staff_util': 0.75, 'robot_util': 0.6, 'robot_share': 0.72, 'robot_bus_share': 0.48, 'rescues': 1.5, 'fragile_robot': 26.2, 'groups': 70.5, 'entry_wait_mean': 5.95, 'blocked_wait_mean': 3.63, 'guide_wait_mean': 0.46, 'staff_wait_mean': 4.09, 'full_wait_mean': 0.65}\r\n"], [4.654, "out", "bus 1 staff {'dish_wait_mean': 0.67, 'dish_wait_p90': 1.23, 'cold_share': 0.0, 'seat_wait_mean': 3.75, 'turn_mean': 11.42, 'walk_km_per_staff': 2.6, 'staff_util': 0.75, 'robot_util': 0.55, 'robot_share': 0.61, 'robot_bus_share': 0.57, 'rescues': 1.43, 'pauses': 44.1, 'groups': 68.7, 'entry_wait_mean': 5.44, 'blocked_wait_mean': 3.56, 'guide_wait_mean': 0.47, 'staff_wait_mean': 4.04, 'full_wait_mean': 0.18}\r\n"], [5.42, "out", "bus 3 any {'dish_wait_mean': 0.66, 'dish_wait_p90': 1.22, 'cold_share': 0.0, 'seat_wait_mean': 3.39, 'turn_mean': 8.82, 'walk_km_per_staff': 2.41, 'staff_util': 0.73, 'robot_util': 0.6, 'robot_share': 0.76, 'robot_bus_share': 0.49, 'rescues': 1.48, 'pauses': 44.25, 'fragile_robot': 26.48, 'groups': 68.5, 'entry_wait_mean': 5.05, 'blocked_wait_mean': 2.98, 'guide_wait_mean': 0.44, 'staff_wait_mean': 3.41, 'full_wait_mean': 0.41}\r\n"], [6.184, "out", "bus 3 staff {'dish_wait_mean': 0.68, 'dish_wait_p90': 1.31, 'cold_share': 0.0, 'seat_wait_mean': 4.28, 'turn_mean': 10.72, 'walk_km_per_staff': 2.6, 'staff_util': 0.76, 'robot_util': 0.55, 'robot_share': 0.63, 'robot_bus_share': 0.58, 'rescues': 1.6, 'pauses': 41.88, 'groups': 69.75, 'entry_wait_mean': 5.97, 'blocked_wait_mean': 3.87, 'guide_wait_mean': 0.47, 'staff_wait_mean': 4.33, 'full_wait_mean': 0.41}\r\n"], [7.166, "out", "patrol 1 any {'dish_wait_mean': 0.62, 'dish_wait_p90': 1.11, 'cold_share': 0.0, 'seat_wait_mean': 2.19, 'turn_mean': 6.24, 'walk_km_per_staff': 2.56, 'staff_util': 0.7, 'robot_util': 0.76, 'robot_share': 0.47, 'robot_bus_share': 0.0, 'collects': 122.17, 'rescues': 2.05, 'pauses': 61.85, 'fragile_robot': 15.82, 'groups': 69.83, 'entry_wait_mean': 3.73, 'blocked_wait_mean': 1.83, 'guide_wait_mean': 0.33, 'staff_wait_mean': 2.16, 'full_wait_mean': 0.36}\r\n"], [8.139, "out", "patrol 1 staff "], [8.139, "out", "{'dish_wait_mean': 0.66, 'dish_wait_p90': 1.2, 'cold_share': 0.0, 'seat_wait_mean': 2.46, 'turn_mean': 7.11, 'walk_km_per_staff': 2.71, 'staff_util': 0.72, 'robot_util': 0.74, 'robot_share': 0.41, 'robot_bus_share': 0.0, 'collects': 128.12, 'rescues': 2.17, 'pauses': 60.58, 'groups': 70.53, 'entry_wait_mean': 4.07, 'blocked_wait_mean': 2.24, 'guide_wait_mean': 0.39, 'staff_wait_mean': 2.63, 'full_wait_mean': 0.22}\r\n"], [9.055, "out", "patrol 3 any {'dish_wait_mean': 0.64, 'dish_wait_p90': 1.23, 'cold_share': 0.0, 'seat_wait_mean': 0.95, 'turn_mean': 4.61, 'walk_km_per_staff': 2.38, 'staff_util': 0.68, 'robot_util': 0.75, 'robot_share': 0.51, 'robot_bus_share': 0.0, 'collects': 120.65, 'rescues': 1.98, 'pauses': 56.58, 'fragile_robot': 15.85, 'groups': 66.92, 'entry_wait_mean': 2.47, 'blocked_wait_mean': 0.8, 'guide_wait_mean': 0.3, 'staff_wait_mean': 1.11, 'full_wait_mean': 0.15}\r\n"], [10.124, "out", "patrol 3 staff {'dish_wait_mean': 0.66, 'dish_wait_p90': 1.24, 'cold_share': 0.0, 'seat_wait_mean': 2.1, 'turn_mean': 6.42, 'walk_km_per_staff': 2.61, 'staff_util': 0.7, 'robot_util': 0.74, 'robot_share': 0.44, 'robot_bus_share': 0.0, 'collects': 128.82, 'rescues': 1.98, 'pauses': 57.58, 'entry_wait_mean': 3.66, 'blocked_wait_mean': 1.7, 'guide_wait_mean': 0.34, 'staff_wait_mean': 2.04, 'full_wait_mean': 0.4}\r\n"], [10.157, "cmd", "python3 haizen_sim.py --main"], [10.319, "out", "1665 conditions, 0 reused, 1665 to run\r\n"], [34.511, "out", "  100/1665\r\n"], [57.975, "out", "  200/1665"], [57.979, "out", "\r\n"]]
別のタブで開く保存

render.py(記録した時刻どおりにターミナルへ書き、1コマずつ撮る)

15コマ/秒で撮りました。撮ったコマを mp4 にした ffmpeg のコマンドは残していません。

# 記録した実際の出力を、記録した時刻どおりにターミナルへ書き、1コマずつ撮る
import json, os, subprocess
from playwright.sync_api import sync_playwright
ev=json.load(open('session.json'))
PROMPT='\x1b[38;5;114m~/service_sim\x1b[0m $ '
# 1本目(コードを表示)は最初から出ている状態にし、2本目(--one)を打って走らせる所から録る
pre=[e for e in ev if e[0]<1 ]
one_start=[e for e in ev if e[1]=='cmd' and '--one' in e[2]][0][0]
main_start=[e for e in ev if e[1]=='cmd' and '--main' in e[2]][0][0]
seq=[]  # (時刻, 書く文字)
t=0.0
seq.append((0,PROMPT+'sed -n 469,503p haizen_sim.py\r\n'))
for e in ev:
    if e[0]<one_start and e[1]=='out': seq.append((0,e[2]))
seq.append((0.2,PROMPT))
cmd='python3 haizen_sim.py --one'
for i,ch in enumerate(cmd): seq.append((0.6+i*0.045,ch))
T0=0.6+len(cmd)*0.045+0.3
seq.append((T0,'\r\n'))
for e in ev:
    if one_start<=e[0]<main_start and e[1]=='out': seq.append((T0+e[0]-one_start,e[2]))
END=T0+(main_start-one_start)
seq.append((END+0.2,PROMPT))
cmd2='python3 haizen_sim.py --main'
for i,ch in enumerate(cmd2): seq.append((END+0.6+i*0.045,ch))
T1=END+0.6+len(cmd2)*0.045+0.3
seq.append((T1,'\r\n'))
for e in ev:
    if e[0]>=main_start and e[1]=='out' and e[0]-main_start<1: seq.append((T1+e[0]-main_start,e[2]))
DUR=T1+3.0
FPS=15
os.makedirs('frames',exist_ok=True)
for f in os.listdir('frames'): os.remove('frames/'+f)
with sync_playwright() as p:
    b=p.chromium.launch(); pg=b.new_page(viewport={"width":960,"height":880},device_scale_factor=2)
    pg.goto('file://'+os.path.abspath('term.html')); pg.wait_for_timeout(800)
    i=0; k=0; n=int(DUR*FPS)
    for fr in range(n):
        tt=fr/FPS; chunk=''
        while k<len(seq) and seq[k][0]<=tt: chunk+=seq[k][1]; k+=1
        if chunk: pg.evaluate("s=>window.write(s)",chunk)
        pg.screenshot(path=f'frames/{fr:05d}.png')
    b.close()
print(DUR, n)

term.html(ブラウザの中のターミナル)

<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css">
<style>html,body{margin:0;background:#0d0f12;height:100%}
#bar{height:34px;background:#1c1f25;display:flex;align-items:center;gap:8px;padding:0 14px;font:13px "DejaVu Sans Mono",monospace;color:#9da3ad}
#bar i{width:12px;height:12px;border-radius:50%;display:inline-block}
#t{position:absolute;top:34px;left:0;right:0;bottom:0;padding:14px 18px}</style></head>
<body><div id="bar"><i style="background:#ff5f57"></i><i style="background:#febc2e"></i><i style="background:#28c840"></i><span style="margin-left:10px">service_sim — python3 haizen_sim.py</span></div><div id="t"></div>
<script src="node_modules/@xterm/xterm/lib/xterm.js"></script>
<script>
const term=new Terminal({cols:92,rows:38,fontFamily:'"DejaVu Sans Mono","Noto Sans Mono CJK JP",monospace',fontSize:15,lineHeight:1.15,theme:{background:'#0d0f12',foreground:'#d8dce3',cursor:'#d8dce3'},cursorBlink:false});
term.open(document.getElementById('t'));
window.term=term;
window.write=(s)=>new Promise(r=>term.write(s,r));
</script></body></html>

package.json(xterm.js の版)

{
  "name": "xt",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@xterm/xterm": "^5.5.0"
  }
}

haizen_sim.py(ターミナルの場面で動かした試算のコード)

LOG359 の試算のコードを、ターミナルの場面のために動かしたときの全文です。

06_haizen_sim.py.txt26,783字・619行・python
"""
サービスロボット導入シミュレーション #1 配膳ロボット(試作 v2.2)

飲食店のピーク時間を SimPy で再現する。

入店待ちの内訳と「店が回らない」(v2.2)
  入店待ちは、店に来てから案内されて席に着くまで(entry_wait_mean)。
  そのうち、満席で待った時間(full_wait_mean)と、席は空いているのに片付け(下げ膳・卓拭き)や
別のタブで開く保存

build.py(構成案1のときの、7場面が切り替わる動画を作る)

手描きの見取り図の場面を外したあとの版です。構成を作り直したあとは使っていません。

import subprocess as sp
D=4.2; F=30; W,H=1200,900
def run(a): sp.run(a,check=True)
def still(src,out,crop=None,z0=1.0,z1=1.08):
    vf=(f"crop={crop}," if crop else "")+f"scale={W*2}:{H*2}:force_original_aspect_ratio=increase,crop={W*2}:{H*2},zoompan=z='{z0}+({z1}-{z0})*on/{int(D*F)}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d={int(D*F)}:s={W}x{H}:fps={F},format=yuv420p"
    run(["ffmpeg","-v","error","-y","-loop","1","-i",src,"-t",str(D),"-vf",vf,"-r",str(F),"-c:v","libx264","-preset","veryfast","-crf","18",out])
def clip(src,out,ss,crop=None):
    vf=(f"crop={crop}," if crop else "")+f"scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},fps={F},format=yuv420p"
    run(["ffmpeg","-v","error","-y","-ss",str(ss),"-t",str(D),"-i",src,"-an","-vf",vf,"-c:v","libx264","-preset","veryfast","-crf","18",out])
still("plan_p1_big.png","seq/t1.mp4",z0=1.0,z1=1.05)
still("LOG362_05_unity_store_3.png","seq/t2.mp4",crop="1440:1080:230:0",z0=1.05,z1=1.0)
clip("LOG364_v3_run3_people_and_robots.mp4","seq/t3.mp4",6.0,crop="870:652:180:38")
clip("LOG364_v3_run3_people_and_robots.mp4","seq/t4.mp4",53.0)
clip("LOG364_v4_run4_robots_back_and_forth.mp4","seq/t5.mp4",2.0,crop="870:652:180:38")
clip("rec/page@8aa4461e1a7b50a7ae89d5a768d2ebf9.webm","seq/t6.mp4",2.2,crop="1067:800:0:0")
still("LOG366_01_cost_cards.png","seq/t7.mp4",crop="1862:1397:0:0",z0=1.05,z1=1.0)
X=0.7; n=7
inp=[]; 
for i in range(1,n+1): inp+=["-i",f"seq/t{i}.mp4"]
fc=[]; prev="[0:v]"; off=0
for i in range(1,n):
    off+=D-X; lab=f"[v{i}]"
    fc.append(f"{prev}[{i}:v]xfade=transition=fade:duration={X}:offset={off:.2f}{lab}"); prev=lab
run(["ffmpeg","-v","error","-y",*inp,"-filter_complex",";".join(fc),"-map",prev,"-c:v","libx264","-preset","veryfast","-crf","27","-pix_fmt","yuv420p","-movflags","+faststart","seq/fv-scenes.mp4"])
print("ok")

shot.py(HTML や住所を画面写真にする)

"""HTML を画面写真にする。使い方: python3 shot.py <html|url> <出力.png> [幅] [高さ|full] [待つms]"""
import os, sys
from playwright.sync_api import sync_playwright

src, out = sys.argv[1], sys.argv[2]
w = int(sys.argv[3]) if len(sys.argv) > 3 else 1280
h = sys.argv[4] if len(sys.argv) > 4 else "full"
wait = int(sys.argv[5]) if len(sys.argv) > 5 else 2500
url = src if src.startswith("http") else "file://" + os.path.abspath(src)
proxy = os.environ.get("HTTPS_PROXY")
with sync_playwright() as p:
    b = p.chromium.launch(proxy={"server": proxy, "bypass": "localhost,127.0.0.1"} if proxy else None,
                          args=["--ignore-certificate-errors", "--autoplay-policy=no-user-gesture-required"])
    mobile = w < 600
    ctx = b.new_context(viewport={"width": w, "height": 900 if h == "full" else int(h)}, device_scale_factor=2 if mobile else 1, is_mobile=mobile, has_touch=mobile)
    pg = ctx.new_page()
    # ハコブくんの知らせを出さない(見た記録を入れておく)
    pg.add_init_script("try{localStorage.setItem('p2m-notrack','1')}catch(e){}")
    pg.goto(url, wait_until="networkidle", timeout=90000)
    if h == "full":
        H = pg.evaluate("document.body.scrollHeight")
        for y in range(0, H, 600):
            pg.evaluate(f"window.scrollTo(0,{y})"); pg.wait_for_timeout(120)
        pg.evaluate("window.scrollTo(0,0)")
        pg.evaluate("document.querySelectorAll('.hk-parade,.hk-say').forEach(e=>e.style.display='none')")
    pg.wait_for_timeout(wait)
    pg.screenshot(path=out, full_page=(h == "full"))
    b.close()
print(out)

snap.py(最初の画面とページ全体を、パソコンとスマホの幅で撮る)

import sys
from playwright.sync_api import sync_playwright
pre=sys.argv[1]
with sync_playwright() as p:
    b=p.chromium.launch(args=["--autoplay-policy=no-user-gesture-required"])
    for w,h,tag in [(1440,900,"pc"),(390,844,"sp")]:
        ctx=b.new_context(viewport={"width":w,"height":h},device_scale_factor=2 if w<600 else 1,is_mobile=w<600)
        pg=ctx.new_page()
        pg.add_init_script("document.addEventListener('DOMContentLoaded',()=>{const s=document.createElement('style');s.textContent='.hk-parade,.hk-say{display:none!important}';document.head.appendChild(s)})")
        pg.goto("http://localhost:8765/serving-robot-one-less-staff.html",wait_until="networkidle")
        for t in [1.5,8.5,22]:
            pg.evaluate(f"(()=>{{const v=document.querySelector('.fv-video');v.pause();v.currentTime={t};}})()")
            pg.wait_for_timeout(1000); pg.evaluate("document.querySelector('.fv-video').dispatchEvent(new Event('timeupdate'))"); pg.wait_for_timeout(600)
            if tag=="pc": pg.screenshot(path=f"{pre}_fv_{t}.png")
        H=pg.evaluate("document.body.scrollHeight")
        for y in range(0,H,700): pg.evaluate(f"scrollTo(0,{y})"); pg.wait_for_timeout(80)
        pg.evaluate("scrollTo(0,0)"); pg.wait_for_timeout(1500)
        pg.screenshot(path=f"{pre}_{tag}_full.png",full_page=True)
        ctx.close()
    b.close()

split.py(縦に長い画面写真を分ける)

import sys
from PIL import Image
src, pre = sys.argv[1], sys.argv[2]; step = int(sys.argv[3]) if len(sys.argv) > 3 else 1400
im = Image.open(src); w, h = im.size
n = 0
for y in range(0, h, step):
    c = im.crop((0, y, w, min(h, y + step)))
    if w > 1000: pass
    c.save(f"{pre}_{n}.png"); n += 1
print(w, h, n)

serve.sh(ビルドしたページを手元で配る)

#!/bin/bash
pkill -f "http-server -p 8765" 2>/dev/null; cd /home/claude/p2m-site/dist && setsid nohup npx --yes http-server -p 8765 -s . >/tmp/srv.log 2>&1 < /dev/null & 
for i in $(seq 1 20); do sleep 1; curl -s -o /dev/null http://localhost:8765/ && break; done; echo up

cap.py(本番のページを節ごとに撮り、読み込む量を足す)

from playwright.sync_api import sync_playwright
import os
U="https://pixelstomotors.com/serving-robot-one-less-staff?p2m-notrack=1"
proxy=os.environ.get("HTTPS_PROXY")
with sync_playwright() as p:
    b=p.chromium.launch(proxy={"server":proxy} if proxy else None,args=["--ignore-certificate-errors","--autoplay-policy=no-user-gesture-required"])
    ctx=b.new_context(viewport={"width":1440,"height":900},device_scale_factor=2)
    pg=ctx.new_page(); tot={'n':0,'b':0}
    def onres(r):
        try:
            l=r.headers.get('content-length'); tot['n']+=1; tot['b']+=int(l) if l else 0
        except Exception: pass
    pg.on('response',onres)
    pg.add_init_script("try{localStorage.setItem('p2m-notrack','1')}catch(e){}")
    pg.goto(U,wait_until="networkidle",timeout=90000)
    print('first view',tot)
    hide="document.querySelectorAll('.hk-parade,.hk-say').forEach(e=>e.style.display='none')"
    pg.evaluate(hide)
    t0=0
    for t in (1.5,8.5,15,22):
        pg.wait_for_timeout(int((t-t0)*1000)); t0=t; pg.evaluate(hide)
        pg.screenshot(path=f"shots/fv_{t}.png")
    H=pg.evaluate("document.body.scrollHeight")
    for y in range(0,H,500): pg.evaluate(f"scrollTo(0,{y})"); pg.wait_for_timeout(150)
    pg.wait_for_timeout(2500); pg.evaluate(hide)
    print('after scroll',tot, 'height',H)
    for s in ['#trouble','#know','#shape','#sim','#flow','#roles','#limits','#next']:
        el=pg.query_selector(s)
        if el: el.scroll_into_view_if_needed(); pg.wait_for_timeout(600); pg.evaluate(hide); el.screenshot(path=f"shots/sec_{s[1:]}.png")
    m=b.new_context(viewport={"width":390,"height":844},device_scale_factor=3,is_mobile=True,has_touch=True).new_page()
    m.add_init_script("try{localStorage.setItem('p2m-notrack','1')}catch(e){}")
    m.goto(U,wait_until="networkidle",timeout=90000); m.wait_for_timeout(2000); m.evaluate(hide)
    m.screenshot(path="shots/m_fv.png")
    b.close()

cap.py の出力

届いた返事に書かれた大きさ(content-length)を足した値です。

first view {'n': 126, 'b': 9376707}
after scroll {'n': 131, 'b': 11467131} height 10069

cap2.py(最初の画面を1秒ごとに見て、場面ごとに撮る)

from playwright.sync_api import sync_playwright
import os
U="https://pixelstomotors.com/serving-robot-one-less-staff?p2m-notrack=1"
proxy=os.environ.get("HTTPS_PROXY")
hide="""document.querySelectorAll('body *').forEach(e=>{const s=getComputedStyle(e);if(s.position==='fixed'&&!e.closest('header'))e.style.display='none'})"""
with sync_playwright() as p:
    b=p.chromium.launch(proxy={"server":proxy} if proxy else None,args=["--ignore-certificate-errors","--autoplay-policy=no-user-gesture-required"])
    pg=b.new_context(viewport={"width":1440,"height":900},device_scale_factor=2).new_page()
    pg.add_init_script("try{localStorage.setItem('p2m-notrack','1')}catch(e){}")
    pg.goto(U,wait_until="networkidle",timeout=90000)
    for t in range(1,27):
        pg.wait_for_timeout(1000); pg.evaluate(hide)
        if t in (11,12,13,24,25): pg.screenshot(path=f"shots/fvt_{t}.png")
    el=pg.query_selector('#next'); el.scroll_into_view_if_needed(); pg.wait_for_timeout(1500); pg.evaluate(hide)
    el.screenshot(path="shots/sec_next2.png")
    b.close()

weight.py(最初の画面で読み込むものを種類ごとに測る)

from playwright.sync_api import sync_playwright
import os,collections
U="https://pixelstomotors.com/serving-robot-one-less-staff?p2m-notrack=1"
proxy=os.environ.get("HTTPS_PROXY")
with sync_playwright() as p:
    b=p.chromium.launch(proxy={"server":proxy} if proxy else None,args=["--ignore-certificate-errors"])
    pg=b.new_context(viewport={"width":1440,"height":900}).new_page()
    rows=[]
    pg.on('requestfinished',lambda r: rows.append((r.url, r.sizes()['responseBodySize'], r.resource_type)))
    pg.add_init_script("try{localStorage.setItem('p2m-notrack','1')}catch(e){}")
    pg.goto(U,wait_until="networkidle",timeout=90000); pg.wait_for_timeout(3000)
    by=collections.Counter(); 
    for u,s,t in rows: by[t]+=s
    print('total',sum(s for _,s,_ in rows),len(rows)); print(dict(by))
    for u,s,t in sorted(rows,key=lambda x:-x[1])[:15]: print(s,t,u.split('?')[0][-70:])
    b.close()

weight.py の出力

本文の表はこの出力です。

total 9497322 126
{'document': 665404, 'stylesheet': 344947, 'script': 15255, 'image': 866162, 'media': 5898395, 'font': 1705322, 'fetch': 1837, 'xhr': 0, 'ping': 0}
3539481 media https://pixelstomotors.com/site/srl/terminal.mp4
1418753 media https://pixelstomotors.com/site/srl/fv-oblique.mp4
940161 media https://pixelstomotors.com/site/srl/top-busy.mp4
719812 font IeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZZLsYEpzC_1ver5Y0.woff2
651770 document https://pixelstomotors.com/sim/serving-robot
166542 image https://pixelstomotors.com/site/srl/fv-unity.webp
155306 image https://pixelstomotors.com/site/srl/terminal.webp
148842 image https://pixelstomotors.com/site/srl/fv-sim.webp
122261 stylesheet https://fonts.googleapis.com/css2
122107 stylesheet https://fonts.googleapis.com/css2
92750 image https://pixelstomotors.com/site/srl/trouble-staff.webp
91736 stylesheet https://fonts.googleapis.com/css2
91436 image https://pixelstomotors.com/site/srl/fv-cost.webp
79064 font /v57/-F62fjtqLzI2JPCgQBnw7HFow2oe2EcP5pp0erwTqsSWs9Jezazjcb4.119.woff2
53594 image https://pixelstomotors.com/site/srl/four-views.webp

このページの公開の記録(git log)

65aac70 2026-10-24 12:23 配膳ロボットLP:最後の節の背景の道を薄くし、ボタンのオレンジと競わないようにする
60c7f5a 2026-10-24 12:19 配膳ロボットLP:ロボットの絵の胴の線を消す・止まりの数字を大きく・最後の節の背景を店舗の図面とロボットの道に
11113dc 2026-10-24 11:54 配膳ロボットのLP:埋め込んだ試算の黒と白を、LPに埋め込んだときだけ(?lp=1)サイト共通の色にそろえる。試算のページ単体と /series8 の見た目は変えない
8705188 2026-10-24 11:50 配膳ロボットのLP:最後のCTAを「送るもの3つ+ボタン」に(3Dの店舗の写真を背景に)。黒とグレーをサイト共通の色(#242424・#f7f7f7 など)にそろえる
c30413c 2026-10-24 11:22 配膳ロボットのLP:角丸を埋め込んだ試算とそろえる(大きな面12px・カードと画像8px・ボタンと札4px)
31bda08 2026-10-24 11:19 配膳ロボットのLP:6つの平面図と埋め込んだ試算の2つだけ、コンテンツ幅を広くする(ほかは1040px)
f210238 2026-10-24 11:14 配膳ロボットのLP:人とロボットの絵、手順の絵を足す。コンテンツ幅を狭め、「店」を「店舗」にそろえ、文を読み直す
0151394 2026-10-24 11:09 配膳ロボットのLP:説明文を1行に(幅の広い画面)。つまずきの札を文字だけにして軽く。「人が減らない」の写真を大きい元画像から切り出し直す
5eec557 2026-10-24 11:06 配膳ロボットのLP:導入前に分かる3つのことで、左の文を図の上端にそろえる。ターミナルの見出しの位置とスマホの分割の並びを直す
efdb24d 2026-10-24 11:00 配膳ロボットのLP:最初の画面を分割と全面で切り替える。セクションの番号と小さい番号を外し、見出しと説明文を中央にそろえる
de4f40c 2026-10-24 10:40 配膳ロボットのLP:見出しを「導入前に自分の店でシミュレーション」に。「入れる前」を「導入前」にそろえ、斜めの動画から編集画面の印を外す
4d5fcfb 2026-10-24 10:36 配膳ロボットのLP:最初の画面の右を4枚の分割(平面図・3Dの店・走らせる・試算の結果)に。4視点の動画を足し、画質を上げる
b19736b 2026-10-24 10:13 配膳ロボットのLPを作り直す:研究の流れではなく「入れる前に自分の店で確かめられる・頼める」を伝える構成に
39fcbcb 2026-10-24 09:57 配膳ロボットのLP:04の節から手描きの見取り図を外す
0d83f2f 2026-10-24 09:48 配膳ロボットのLP:最初の画面の動画から手描きの見取り図を外す(7場面)
b6d6c2a 2026-10-24 09:44 配膳ロボットのLP:最初の画面を、左のメニューと8場面が切り替わる動画に組み替える
26d7a95 2026-10-24 09:05 配膳ロボットの導入前ガイド(LP)を確認用に足す(/serving-robot-one-less-staff・noindex・一覧と入口にはまだ出さない)