何本も並行する動画づくりを見える化する ― 段階の自動判定と、PCは一覧・iPhoneはハコブくんという役わけ
YouTube の解説動画を、何本も並行して作るようになりました。この記事を書いている時点で、作業フォルダのある動画は21本あります。そのうち12本が公開予約済み、6本が公開済み、3本がアップロード待ちです。まだ作業フォルダのない台本の候補も8本あります。
1本の動画は、台本 → 検証 → 声 → 書き出し → YouTube に出す素材 → ショート → 投稿文 → 公開、の8つの段階を通ります。段階ごとに、Claude が進める所と、人が確かめたり YouTube に上げたりする所が入れ替わります。21本がそれぞれ違う段階にいると、「いま、どの動画が、誰の手で止まっているか」が分からなくなりました。
そこで、全部の動画の段階と「誰の番か」を1枚で見られるページを作りました。手で書く表は使わず、フォルダの中のファイルと YouTube の公開一覧から、スクリプトが自動で判定します。人の番になった仕事は、案内役のキャラクターのハコブくんが知らせます。途中から、PC では詳しい一覧を見て、iPhone ではハコブくんのカードを1枚ずつ片付ける、という役わけに作り直しました。
この記事は、そのページを作った記録です。作ったスクリプト・ページのソース・プラグイン・予定のタスクの指示文は、記事の後半にすべて全文で載せています。
作業は、Claude のクラウドの作業環境と、Mac 連携(Claude が Mac のフォルダを読み書きできるつなぎ方)で進めました。YouTube の許可の鍵と ElevenLabs の API キーは Mac の中だけに置き、Claude には渡していません。

記事を書いている時点の記録を、PC の画面で表示したところです。
並行している解説動画21本と、それぞれの段階
ページが読み取った、記事を書いている時点(2026-10-16 08:05)の全21本です。「誰の番」が「—」の動画は、公開予約が済んで待つだけのものか、全部終わったものです。
| 動画(作業フォルダの名前) | 段階 | 誰の番 | 次にやること | ショート | 公開日 |
|---|---|---|---|---|---|
| 3Dの世界を作るAI | 予約済み | — | 2026-10-25 に公開予定(待つだけ)。公開されたらショートを投稿 | 3本(公開 0) | 2026-10-25 予約 |
| AIエージェントの取り違え | 完了 | — | 完了(X・LinkedIn の投稿だけ手で確かめる) | 3本(公開 2・予約 1) | 2026-10-15 |
| AIバブルとフィジカルAI | 公開済み | エイイチ | ショートを投稿する(0/3本 公開済み・予約 0) | 3本(公開 0) | 2026-10-16 |
| ジャングリアの赤字 | 完了 | — | 完了(X・LinkedIn の投稿だけ手で確かめる) | 3本(公開 3) | 2026-10-14 |
| セキュリティの人手不足 | 予約済み | — | 2026-10-16 に公開予定(待つだけ)。公開されたらショートを投稿 | 3本(公開 0) | 2026-10-16 予約 |
| フィジカルAIのお金の流れ | 完了 | — | 完了(X・LinkedIn の投稿だけ手で確かめる) | 3本(公開 3) | 2026-10-14 |
| フィジカルAIの現在地 | 公開済み | エイイチ | ショートを投稿する(0/3本 公開済み・予約 0) | 3本(公開 0) | 2026-10-12 |
| ロボットが怖い | 予約済み | — | 2026-10-26 に公開予定(待つだけ)。公開されたらショートを投稿 | 3本(公開 0) | 2026-10-26 予約 |
| ロボットの乗っ取り | 予約済み | — | 2026-10-18 に公開予定(待つだけ)。公開されたらショートを投稿 | 3本(公開 0) | 2026-10-18 予約 |
| 丸ごと任せるロボット | 予約済み | — | 2026-10-17 に公開予定(待つだけ)。公開されたらショートを投稿 | 3本(公開 0) | 2026-10-17 予約 |
| 人型に足は要るのか | 予約済み | — | 2026-10-20 に公開予定(待つだけ)。公開されたらショートを投稿 | 3本(公開 0) | 2026-10-20 予約 |
| 冷凍庫のサイバー攻撃 | 公開待ち | エイイチ | YouTube にアップロードする(予約済みなら待つだけ) | 3本(公開 0) | |
| 協働ロボットはなぜ遅い | 予約済み | — | 2026-10-24 に公開予定(待つだけ)。公開されたらショートを投稿 | 3本(公開 0) | 2026-10-24 予約 |
| 夜のエレベーター | 予約済み | — | 2026-10-22 に公開予定(待つだけ)。公開されたらショートを投稿 | 3本(公開 0) | 2026-10-22 予約 |
| 夜の警報 | 予約済み | — | 2026-10-21 に公開予定(待つだけ)。公開されたらショートを投稿 | 3本(公開 0) | 2026-10-21 予約 |
| 導入はいくら | 公開待ち | エイイチ | YouTube にアップロードする(予約済みなら待つだけ) | 3本(公開 0) | |
| 日本で広がらない理由 | 予約済み | — | 2026-10-19 に公開予定(待つだけ)。公開されたらショートを投稿 | 3本(公開 0) | 2026-10-19 予約 |
| 最速の定義 | 予約済み | — | 2026-10-16 に公開予定(待つだけ)。公開されたらショートを投稿 | 3本(公開 0) | 2026-10-16 予約 |
| 洗濯機を分解するロボット | 公開待ち | エイイチ | YouTube にアップロードする(予約済みなら待つだけ) | 3本(公開 0) | |
| 自動運転の暴走 | 完了 | — | 完了(X・LinkedIn の投稿だけ手で確かめる) | 3本(公開 3) | 2026-10-13 |
| 誰が動かすか | 予約済み | — | 2026-10-23 に公開予定(待つだけ)。公開されたらショートを投稿 | 3本(公開 0) | 2026-10-23 予約 |
公開予約は 10/16 から 10/26 まで、ほぼ毎日1本ずつ入っています。本編が公開されるたびに、ショート3本の投稿が人の番として生まれます。この表を手で書き直していたら、追いつきません。
始まりは、この依頼でした。
今いくつも並行して動画を作ることが多くなってきました。一連の流れをモニタリングできるようなものを作ることはできますか?もっと簡単に作業を進めるようにしたいです。どのように行えばより簡単にできるかを考えて提案してください。
段階の判定は、フォルダの中のファイルから status_scan.py が出す
1本の動画のファイルは、2か所に分かれています。作り方のファイル(台本の narration.py、声の mp3、ショートの作り方)は iCloud の 動画/{動画名}/ に、書き出した動画(確認用の mp4、YouTube に上げる mp4)は外付けの SSD(KIOXIA)の videos/{動画名}/ にあります。_制作ベース/status_scan.py は、この2か所を見比べて、8つの段階それぞれが「済・途中・未」のどれかを決めます。
| 段階 | 何があれば「済」か |
|---|---|
| 台本 | narration.py がある |
| 検証 | codex(別の AI)の検証結果があるか、声を作り始めている |
| 声 | 台本の文の数だけ声の mp3 がある(確認用の動画があれば済とみなす) |
| 書き出し | YouTube に上げる mp4 がある(確認用だけなら「途中」) |
| YouTube素材 | タイトル・概要欄・サムネイルがそろっている。公開済みなら済 |
| ショート | ショートを3本作った |
| 投稿文 | X・LinkedIn の投稿文 投稿文_X_LinkedIn.txt がある |
| 公開 | YouTube で公開されている(予約中なら「予約」) |
判定の決まりは3つです。
- 後の段階のファイルがあれば、前の段階は済んでいる。 最初は、台本の文の数と声の mp3 の数が1つずれる動画(台本に声のない区切りの行が入っている)で、「声」が「途中」に見えていました。確認用の動画を書き出しているなら声は済んでいる、と決めて直しました。
- YouTube の動画との結び付けは、タイトルで探してから、見つからないときだけ動画の ID で探す。 最初は、フォルダの中の文章に書いてある YouTube の ID で探していました。すると「AIバブルとフィジカルAI」が、投稿文の中で紹介していた別の動画の ID に結び付き、その動画として「公開済み」になりました。投稿文や概要欄には、ほかの動画の URL を書くことがあるためです。
- 「_」で始まるフォルダは動画として数えない。
_制作ベースや_旧版のような、作業用のフォルダを外すためです。
公開の記録は、YouTube の RSS(新しい15本しか出ない)と、サイト用に別に作っている動画一覧から読み、status_published.json にためます。RSS からこぼれた古い動画も、一度見つけたものは忘れません。
出力は JSON です。Claude はこれを動画の作業の始め・区切り・終わりに動かし、ページの記録(status/latest)に書き込みます。
下の図で、フォルダの中のファイルや YouTube の状態を切り替えると、8つの段階と「誰の番か」がどう変わるかを試せます。人の番になると、iPhone に出るカードも変わります。
YouTube の公開予約は RSS に出ないので、YouTube Data API で読んだ
最初のページでは、公開予約した動画が「アップロード待ち」に出ていました。YouTube Studio の画面を見せて、こう聞かれました。
動画のアップロード待ちってどこから情報持ってきてんの?
フォルダの中身と RSS だけで判定していたためです。YouTube の予約投稿は、公開されるまで RSS にも、チャンネルの公開ページにも出ません。最初は、ページに「YouTube で公開予約した日」を手で記録する欄を作りました。
投稿予約の情報を取得する方法はないの?
YouTube の公式の仕組み(YouTube Data API)なら、チャンネルの持ち主として許可を出せば、非公開の動画と予約の日時まで読めます。_制作ベース/youtube_schedule.py を作りました。Python の標準の道具だけで動き、pip で入れるものはありません。
- 許可は OAuth(Google のログイン画面で「このアプリに読ませてよいか」を許可する仕組み)で取ります。許可の範囲は「YouTube を読むだけ」です。動画には何も書き込みません。
- ブラウザで許可すると、Mac の中で待っている小さなサーバーが許可を受け取ります。許可の鍵は Mac の
~/.config/pixelstomotors/にだけ保存し、Claude は読みません。 - 書き出すのは、鍵の入らない一覧
youtube_videos.jsonです(動画ごとに ID・タイトル・公開の状態・予約の日時・長さ・ショートかどうか・説明文にある本編の ID)。 --installを付けると、Mac の launchd(決まった間隔でプログラムを動かす Mac の仕組み)に登録し、1時間ごとに書き出します。
許可が通るまでに、何度かつまずきました。ターミナルの表示は会話に貼ってもらいましたが、会話の記録が途中でまとめられたため、全文は残っていません。残っている表示と、何が起きていたかを並べます。
| 出た表示・起きたこと | 原因 | 直し方 |
|---|---|---|
AttributeError(http.server を使う所で止まった) | 自分で書いた関数の名前を http にしていて、Python の http という部品を上書きしていた | 関数の名前を call に変えた |
403(API が使えない) | Google Cloud のプロジェクトで、YouTube Data API v3 を有効にしていなかった | Google Cloud の画面で有効にしてもらった |
4 本を書き出しました(うち公開予約 0 本) | 許可したアカウントが、別のチャンネル(ADAPT DESIGN)の持ち主だった | チャンネルの持ち主のアカウントで許可し直した。違うチャンネルなら書き出さずに止まるようにした |
| アカウントを選ぶ画面で、Chrome が「プロフィールを分けますか」と聞いてきた | 会社のアカウント(Google Workspace)でのログインのため | その画面は取り消して、シークレットウィンドウで許可してもらった |
invalid_grant・code_verifier not needed | 許可の画面を開くアドレスを作る行に注釈を足したとき、code_challenge(許可の受け渡しを確かめる値)の指定まで消していた | その行を元に戻した |
許可したアカウント: … チャンネル: Pixels to Motors 31 本(うち公開予約 15 本) | (通った) |
チャンネル「Pixels to Motors」には、持ち主と管理者が別のアカウントで登録されています。予約まで読めたのは、持ち主のアカウントで許可したときでした。
これで、予約済みの15本が「予約済み」に変わりました。本当にアップロードを待っているのは2本だけでした。
予約の時刻を過ぎても「予約済み」のまま残った
記事を書く直前に、「AIバブルとフィジカルAI」(YouTube のタイトルは「日経平均3318円安|AIへの失望は、ロボットにも届くのか」)が予約どおり 7時30分 に公開されました。ところがページは「予約済み」のままで、ショートの投稿を知らせませんでした。
例えば今なら日経平均3318円安|AIへの失望は、ロボットにも届くのかの動画が投稿されたので、ショートを投稿する作業を促してほしい
調べると、Mac が書き出した youtube_videos.json は 6時51分 のものでした。その時点では、この動画はまだ「非公開・予約 2026-10-15T22:30:00Z」です。1時間ごとの書き出しの次の回(7時51分)が、まだ届いていませんでした。Mac がスリープしていると、この書き出しは動きません。さらに、status_scan.py は YouTube の一覧の「予約中」で、RSS の「公開済み」を上書きする作りになっていました。
直したのは2か所です。
- status_scan.py:予約の時刻を過ぎた動画は、YouTube が公開しているので「公開済み」として数える。RSS で公開を見つけている動画は、古い「予約中」で上書きしない。あわせて、予約の時刻(
公開予約の時刻)と予約した動画の URL(予約のURL)も書き出す。 - ページ:
公開予約の時刻を過ぎたら、スクリプトの読み直しを待たずに、その場で「公開済み → ショートの投稿は人の番」に切り替える。ページは1分ごとに描き直すので、予約の時刻から1分以内に切り替わります。
直したあとの判定はこうなりました。
AIバブルとフィジカルAI 公開済み エイイチ ショートを投稿する(0/3本 公開済み・予約 0) https://www.youtube.com/watch?v=WXujjOEwUG8
フィジカルAIの現在地 公開済み エイイチ ショートを投稿する(0/3本 公開済み・予約 0) https://www.youtube.com/watch?v=wGlD_LJrlPo
冷凍庫のサイバー攻撃 公開待ち エイイチ YouTube にアップロードする(予約済みなら待つだけ)
導入はいくら 公開待ち エイイチ YouTube にアップロードする(予約済みなら待つだけ)
洗濯機を分解するロボット 公開待ち エイイチ YouTube にアップロードする(予約済みなら待つだけ)
予約の時刻と URL を書き出した12本です。
3Dの世界を作るAI 2026-10-25T09:30:00Z https://www.youtube.com/watch?v=6xgBA2RClKk
セキュリティの人手不足 2026-10-16T09:30:00Z https://www.youtube.com/watch?v=mEOQJbxiP4w
ロボットが怖い 2026-10-26T09:30:00Z https://www.youtube.com/watch?v=-2u1_ccDYm0
ロボットの乗っ取り 2026-10-18T09:30:00Z https://www.youtube.com/watch?v=ZS9sN7F6a7w
丸ごと任せるロボット 2026-10-17T09:30:00Z https://www.youtube.com/watch?v=9l9lMPmMlLI
人型に足は要るのか 2026-10-20T09:30:00Z https://www.youtube.com/watch?v=DtyyGmMoUlU
協働ロボットはなぜ遅い 2026-10-24T09:30:00Z https://www.youtube.com/watch?v=tVlo6NsCpeY
夜のエレベーター 2026-10-22T09:30:00Z https://www.youtube.com/watch?v=gtUX2wuooxM
夜の警報 2026-10-21T09:30:00Z https://www.youtube.com/watch?v=jD6UXGYKxfc
日本で広がらない理由 2026-10-19T09:30:00Z https://www.youtube.com/watch?v=Tt_uWzJGzlw
最速の定義 2026-10-16T03:15:00Z https://www.youtube.com/watch?v=L4AN-ifKdno
誰が動かすか 2026-10-23T09:30:00Z https://www.youtube.com/watch?v=M4SYp8u48rg
時刻は世界標準時です。09:30Z は日本時間の18時30分、03:15Z は12時15分です。
「冷凍庫のサイバー攻撃」は予約投稿済みですが、6時51分の一覧に入っていないため、まだ「公開待ち」に出ています。次に Mac の書き出しが動けば「予約済み」に変わります。
進み具合のページは、データベースつきの手作りのページにした
ページは、claude.ai で公開できる手作りのページ(HTML)で、持ち主だけが開ける設定です。ページには小さなデータベースが付けられます。ページの中の JavaScript がそれを読み書きし、Claude も ArtifactData という道具で同じデータベースを読み書きできます。
最初は、表やグラフを作る既成のダッシュボードの型で作りました。ただ、メモを書き込む、ハコブくんの声を鳴らす、会話ごとの動きを受け取る、といったことが型ではできませんでした。そのため、手作りのページに作り直しました。
データベースに入れている物です。
| 名前 | 中身 | 誰が書くか |
|---|---|---|
status/latest | status_scan.py の出力(全部の動画の段階と、台本の候補) | Claude |
sessions/s-{会話の ID} | 動画を作っている会話ごとの1件。{動画, 作業, 待ち, 番, 声キー, 会話, 更新, 終了, 返事の候補} | Claude とプラグイン、ページの「返事した」 |
memos | 直す所のメモ。{video, text, at, done} | ページ(人)が書き、Claude が直したら done にする |
marks | iPhone のカードで押した「済んだ」「あとで」の印 | ページ |
scheduled/all | 手で記録した公開予約の日(YouTube の一覧に日がないときだけ使う) | ページ |
requests/r{番号} | 台本の候補から「作り始める」を押した記録 | ページ |
voice/lines | ハコブくんの声の差し替え(今は使っていない) |
文字はサイト(adaptdesign.jp/pixelstomotors)と同じ Inter と Noto Sans JP にし、背景は黒にしました。
フォントは現行のうちのサイトのデザインに合わせてください。背景は今のブラックベースでいいです。
PC で開いたときの中身は、上から順にこうなっています。
- 「次は」の帯:人の番の仕事を、止まっている順に1件ずつ。‹ › で次の件へ送れる
- 本数のタイル6つ(人の番・Claude の番・アップロード待ち・公開予約・公開済み・台本の候補)。押すと、下の表をその動画だけにする
- 「いま動いている作業」:動画を作っている会話ごとのカード
- 「エイイチの番」「Claude の番」の一覧
- 選んだ動画の詳しいこと(まだの段階・次にやること・メモ欄・公開予約の日の記録)
- 全部の動画と段階の表(済は緑の点、途中は半分の点、完了は赤い札)
- まだ作業フォルダのない台本の候補

上の画像は、6時45分に PC で撮ってもらったページ全体です。iPhone の画面を作る前の形です。
「いま動いている作業」のカードでは、小さなハコブくんが済んだ区間を走る
動画を作っている会話は、始めにページの sessions に自分の1件を書き、区切りごとに書き換えます。カードには8つの段階を道のりとして描き、済んだ区間を緑の線にしています。
今動いてる作業がある時はハコブくんが左右に動いているアニメーションを入れたりできる?
進み終わった部分を走ってるがいいかな
ずっと同じ動きじゃなくて、途中で止まったり、ゆっくり動いたり、早く動いたり変化があるといいね
Claude が作業しているあいだは、小さなハコブくんが済んだ区間の中を行き来します。1つの動きを終えるたびに、次の動きをくじで決めます。22% で立ち止まり(0.6〜2.8秒)、動くときは 30% でゆっくり・50% でふつう・20% で急ぎます。端まで行くか、途中で止まるかもくじです。顔は100ミリ秒ごとに描き替え、2〜4.5秒に1回まばたきし、作業中は口ずさむように口が動きます。
作業が区切りまで進んだら、吹き出しが出て、作業が止まってるとか次の作業を出すとかにしてほしい
人の番になると、ハコブくんは止まって小さく跳ね、頭の上に「作業が止まっています。次は」と、待っている中身の吹き出しを出します。
会話の動きを、プラグイン p2m-progress-sync がページへ自動で書く
最初は、会話の始め・区切り・終わりに、Claude が手でページへ書き込む決まりにしていました。ところが、人が返事をして作業が再開しても、ページは人の番のままでした。
新しい作業が始まったけど、パネルは更新されてないよ?
いや作業再開したらパネル側も同期させたいんだけど。
ページの側から会話の動きを直接見る方法がない これを見るようにできるには?何か方法はない?
ページから会話の中を見ることはできません。逆に、会話の側で動く小さな仕組み(Claude Code のプラグインの「フック」。決まった出来事のたびに動く短いプログラム)を入れて、会話からページへ書くようにしました。
| 出来事 | プラグインがすること |
|---|---|
人が返事を送って、Claude が動き出した(turn.start) | その会話の1件を「Claude の番」にし、待ちを空にする |
Claude が返事を書き終えて止まった(turn.complete) | 「エイイチの番」にする。待ちの文はそのまま残す |
作業中に道具を使った(tool.call) | 3分に1回だけ、更新の時刻を書く(止まっていないことの印) |
ページに自分の1件がない会話(動画づくりではない会話)では、何もしません。書き込みに許可の確認が要る場面でも、作業を止めないように書きません。
プラグインは、作業中の会話にその場で読み込ませる確認の画面が一度閉じてしまい、同じ会話では出し直せませんでした。そのため、プラグインのファイル(.plugin)にまとめて渡し、claude.ai に追加してもらいました。
台本の候補を押すと、新しい会話が台本まで作り始める
まだ作業フォルダのない台本の候補 パネルをクリックして選択すると動画作成が始まるとかできる?
claude.ai の「予定のタスク」(決まった時刻や、呼ばれたときに、新しい会話を始める仕組み)を1つ作りました。時刻では動かず、呼ばれたときだけ動きます。ページの「この台本で作り始める」→「始める」を押すと、ページがこのタスクを呼びます(ページの mcp という許可で、fire_trigger という道具を使います)。呼ぶときに「台本_15本_20261012 の {ファイル名} から、解説動画を作り始めてください。」という文を渡します。
始まった会話は、台本(narration.py)・台本の検査・出典との突き合わせ・読み物の台本と検証の依頼文まで作って止まり、「台本を確かめて」と知らせます。押した記録はページに残り、同じ台本は24時間押せません。押し間違えても動かないよう、ページの中で一度「始めますか?」と確かめます。
ページからの呼び出しは、まだ本番で一度も動かしていません。
ハコブくんの声は決まり文句8つを Mac で作り、ページに埋め込んだ
次の作業をハコブくんがお知らせしてくれるなんてことはできますか?(うるさくならない程度の通知をすることが可能か?あと音声お知らせの停止も可能にしてほしい。仕事のMTG中に喋られたら困るため)
声は、動画のナレーションと同じ ElevenLabs の声で、決まった8つの言葉だけを Mac で作りました(動画/_ハコブくんのお知らせ/)。
| 声キー | 言葉 |
|---|---|
| kakunin | 確認用の動画ができました。見てもらえますか。 |
| daihon | 台本ができました。確かめてもらえますか。 |
| koe | 声を作る番です。Macで声を作ってください。 |
| title | タイトルとサムネイルを選んでください。 |
| short | ショートの投稿が残っています。 |
| upload | アップロードを待っている動画があります。 |
| session | 会話で、お返事を待っています。 |
| owari | いまは、待っている作業はありません。 |
- ページは、開くたびに「声:オフ」から始まります。会議中に急に話さないためです。
- オンのときだけ、人の番の一番上の件が入れ替わったときに1回話します。1分以内には続けて話しません。
- mp3 はページの資料として上げられなかったため、ページの HTML の中に埋め込みました。
- 話しているあいだは、音の大きさに合わせてハコブくんの口が4段階で動きます。動画と同じく、2コマごとに1段ずつしか変えません。
iPhone では、消音のスイッチを入れていると声が出ませんでした。
消音にしている(iPhoneの横のボタン)のがマズい?
iPhone は、ページで鳴らす種類の音(Web Audio)を消音スイッチで止めます。音楽や動画と同じ「再生」の扱いになる <audio> で鳴らすように変えました。口の動きは、同じ声を一度ほどいて33ミリ秒ごとの大きさを前もって測っておき、再生している位置に合わせて読みます。消音でも鳴るということは会議中でも鳴るということなので、勝手に話すのは声がオンのときだけにしています。
PC は詳しい一覧、iPhone はハコブくんと1件ずつ進める画面に分けた
一覧ができたところで、役わけの希望が出ました。
PC側は詳細なダッシュボードパネル、iPhone側でハコブくんとやり取りして仕事進めるという役割にしたいです(iPhone側で詳細な情報なるべくみたくない。ただし見たい時には見れる融通性は必要)
ページは1枚のままにして、開いた画面の幅で見せ方を変えました。ページのデータベースはそのページの中でしか読めないので、2枚に分けると記録が二重になります。幅が700以下ならハコブくんの画面、それより広ければ一覧です。iPhone で「全部見る」を押すと一覧になり、その選び方は iPhone に覚えさせます。
進めてください。ちなみに詳細なタスク情報はスワイプで見れるのがUI的にはいいね。
人の番の仕事は、横にめくるカードにしました。参考に見せてもらったのは、別に作っている「ロボティクスの見直し」のページの、横にめくる単語カードです。
| カードの種類 | 一番大きいボタン | ほかのボタン |
|---|---|---|
| 会話で返事待ち | 返事の候補(Claude が書いたとき)・会話を開いて返事する | 返事した・あとで・くわしく |
| 確認・台本・声・ショート・アップロード | 見終わった・確かめた・声を作った・ショートを投稿した・アップロードした | その会話を開く・あとで・くわしく |
- くわしく:そのカードの中に、まだの段階・次にやること・メモ・依頼文のコピーを開きます。詳しいことは、見たいときだけ見ます。
- あとで:「1時間」「3時間」「明日の朝」から選びます。印はページの
marksに残り、時間が来るとカードが戻ります。 - 済んだ:その件を人の番から外します。段階が変わると印は効かなくなり、次の仕事としてまた出ます。
- 返事の候補:Claude が人の番にするとき、選んで答えられる問いなら、短い返事を2〜4個書く決まりにしました。押すと返事がコピーされて会話が開くので、貼って送るだけで済みます。

上は、記事を書いている時点の本物の記録を、iPhone と同じ大きさの画面で表示したものです。
並べ方は、iPhone で触りながら何度も直しました。
iPhoneでは下にハコブくんを配置したいね。操作しやすいので。
claudeがすすめてるは一番上かな。
ハコブくんもっと大きくしてください。
台本候補からとか全部見るは上の方に配置
エイイチの番のパネルが少し狭いので広く
ハコブくんは、親指の届く画面の下にくっつけました。横幅は70から104に大きくし、押すと、いまめくっているカードの件を声で読みます。「台本の候補」と「全部見る」は一番上の小さなボタンに、「Claude が進めている」は細い1段にして、カードの上に置きました。カードは、次のカードの端が3ミリほど見える幅まで広げました。

上は、ハコブくんが画面の上にいたころに iPhone で撮ってもらった画面です。
ハコブくんに、表情・緑の輪の明かり・押したことへの反応を足した
ハコブくんにもう少し動きが欲しいところ。何か良いアイデアはない?親しみを持てるようにしたい。
案を3つの群に分けて出し、次の3つで進めました。
- 緑の輪が息をする:体の横の緑の輪が、4.2秒かけて明るくなったり暗くなったりします。
- 押したことに反応する:「済んだ」を押すと、ぴょんと跳ねて
> <の笑顔になり、「ありがとうございます!」と言います。「あとで」を押すと、うなずいて「了解です。10時51分ごろにまた出しますね。」のように時刻を言います。 - 件数で表情が変わる:人の番が0件ならにっこりし、0件になった瞬間にくるっと一回りします。4件以上たまると困り眉になり、吹き出しも「◯件たまっています…」に変わります。
顔は、動画のハコブくんと同じ描き方(hakobu.py。目・眉・口をコードで描き、体のディスプレイの傾きに合わせて変形する)で、13枚作りました。困り眉が顔の範囲の上で切れたので、顔を切り出す範囲を上に9広げています。




困り顔・喜び・うなずきの3枚は、iPhone と同じ大きさの画面に、試しのデータを入れて表示したものです。
ショートは本編の公開後に投稿し、関連動画で本編へつなぐ
ちなみにショート動画は投稿時に、投稿済みの動画と連携させたい(ショートから通常動画に遷移してほしい)ので通常動画投稿されたら、ショートの動画を投稿する流れです。
ショートの編集画面の「関連動画」で本編を選ぶと、ショートから本編へ移ってもらえます。本編が公開されてからでないと選べません。判定は、もともとこの順番でした。本編が公開予約のあいだは、ショートを作る所までが Claude の番です。本編が公開されたら、ショートの投稿が人の番になります。
そのうえで、ショートのカードを直しました。
- 言葉を「本編が公開されました。ショートを投稿して、関連動画に本編をつないでください。」にしました。
- 「本編の URL をコピー」のボタンを足しました。
- 3本を1本ずつ出すので、「ショートを投稿した」の印は、ショートの公開本数が変わったら効かなくなるようにしました。1本出すとカードは消え、ページが読み直して本数が増えていると、残りの分のカードがまた出ます。
動画の作り方の決まり(video_rules.md の 9-4)にも、「ショートは本編が公開されてから投稿する」と書きました。
本編が公開されると、ハコブくんが画面の下を走り回って知らせる
動画が投稿されたらハコブくんが左右に動き回って(一時的にでいいけど)通知するようなことってできるの?
本編が公開されると、ハコブくんが画面の下を左右に2往復、7.5秒ほど走ります。跳ねながら走り、> < の笑顔と大きく開けた口を交互に見せます。走っているあいだは、画面の真ん中に「「◯◯」が公開されました! ショートを投稿しましょう」と出ます。走り終わるといつもの位置に戻り、もう一度跳ねて同じことを言います。
- 知らせた動画は、端末ごとに覚えます。同じ動画で2回は走りません。iPhone と PC では、それぞれで1回ずつ走ります。
- 初めて開いた端末では、その日に公開された分だけ知らせます。
- ページが画面に見えていないとき(別のタブの裏など)は走らず、見えたときに走ります。最初は、裏にあるあいだに走る時間が過ぎてしまい、戻ったときには一瞬で終わる作りになっていました。
- ページを閉じているあいだは、走ることも知らせることもできません。

走っている途中の1コマです。これも試しのデータで表示しています。
つまずいた所と直し方
| 何が起きたか | 原因 | 直し方 |
|---|---|---|
| 声の数が1つ足りず、「声」が「途中」に見えた | 台本に声のない区切りの行が入る | 確認用の動画があれば声は済とみなす |
| 「AIバブルとフィジカルAI」が、別の動画として「公開済み」になった | 投稿文の中の、紹介した別の動画の ID で結び付けた | タイトルで探してから、見つからないときだけ ID で探す |
| 公開予約した動画が「アップロード待ち」に出た | 予約は RSS に出ない | YouTube Data API で予約まで読む |
| 予約の時刻を過ぎても「予約済み」のまま | 一覧の書き出しが1時間ごとで、書き出しの後に公開された | 予約の時刻を過ぎたら公開済みとして数える。ページもその場で切り替える |
| 既成のダッシュボードの型で、メモ・声・会話の動きが扱えない | 型にない機能だった | 手作りのページに作り直した |
| mp3 をページの資料に上げられない | 上げられる種類に入っていない | ページの中に埋め込んだ |
| ページを作り直すと、直したはずの所が戻った | 出来上がった HTML を直し、元のソースを直していなかった | 直すのは必ず元のソース(hub.src.html)にし、組み立て直す |
| ハコブくんの車輪のまわりが白く汚れた | 切り抜きの縁に白い背景が残った | 縁をきれいにする処理を足した |
| 縁の処理で、顔のまわりの白い枠まで削れた | 処理を体全体にかけていた | 下から2割(車輪のあたり)だけにかけた |
| カードの小さなハコブくんの顔がはみ出た | 体の画像に当てた大きさの指定が、顔の画像にも当たった | 顔の画像は外して指定した |
| 吹き出しの角がハコブくんを指していなかった | 位置の基準にした値を、吹き出しの外で決めていた | 基準の値をカード全体で決めた |
| 作業を再開しても、ページは人の番のまま | 会話の動きをページが知る手段がなかった | プラグインで会話から書き込む |
| iPhone で、消音のときに声が出ない | Web Audio は消音スイッチで止まる | <audio> で鳴らし、口の動きは前もって測った大きさで動かす |
| 困り顔の眉が切れた | 顔を切り出す範囲が狭かった | 範囲を上に9広げて13枚作り直した |
| 隠したはずの欄の見出しが残った | 欄の表示の指定が、隠す指定より強かった | 隠す指定を一番強くした |
| 走って知らせる動きが、裏のタブだと一瞬で終わった | 動きの時間を、見えていない間も数えていた | 見えたときに走り始め、最初に描けたときから数える |
まだ決まっていないこと・確かめていないこと
- PC 側の作り直し:iPhone に操作を任せたので、PC 側は詳しく見ることに寄せる案を出しています。公開予定を2週間の横並びで見せる、表を状態ごとに分ける、上を1行の要約にする、同じ動画が「エイイチの番」と「Claude の番」の両方に出る食い違いを直す、の4つです。進めるかはまだ決めていません。
- 台本の候補から作り始めるボタン:ページから予定のタスクを呼ぶ所は、本番でまだ動かしていません。
- プラグイン:新しい動画の会話で、返事を送ると Claude の番に、返事が終わると人の番に変わるかは、次の会話で確かめます。
- ページを閉じているときの知らせ:公開の時刻にプッシュ通知で知らせる仕組みはありません。予定のタスクで1時間ごとに状況を読み直して知らせることはできます。
- YouTube の一覧の書き出し:Mac がスリープしていると止まります。止まっていても、予約の時刻を過ぎた動画は公開済みとして数えるので、ショートの知らせは遅れません。ただ、新しく予約した動画は、次に書き出されるまで「アップロード待ち」に出ます。
- iPhone の画面で、本物の記録の「済んだ」「あとで」:押した印は、実際に2件残っていました(「フィジカルAIの現在地」を翌朝8時まで「あとで」、「AIバブルとフィジカルAI」を「ショートを投稿した」)。ショートの印の形を途中で変えたので、前に付けた「あとで」の1件は、新しい形に書き換えました。
この記事で作った・直したファイルの全文
ファイルの置き場所です。
| ファイル | 置き場所 | 中身 |
|---|---|---|
status_scan.py | iCloud 動画/_制作ベース/ | フォルダと YouTube から段階と誰の番かを判定する |
youtube_schedule.py | iCloud 動画/_制作ベース/ | YouTube Data API で、予約まで含めた動画の一覧を書き出す |
narration.py・決まり文句.txt | iCloud 動画/_ハコブくんのお知らせ/ | ハコブくんの声の決まり文句を作るためのフォルダ |
hub.src.html・build.py | Claude の作業環境 | 進み具合のページのソースと、顔の画像と声を埋め込んで組み立てる手順 |
p2m-progress-sync | Claude Code のプラグイン | 会話の動きをページへ書くフック |
| 予定のタスク「動画を作り始める(進み具合のページから)」 | claude.ai の予定のタスク | 台本の候補から動画を作り始める会話の指示文 |
video_rules.md の「進み具合の一覧」 | iCloud 動画/_制作ベース/ | 動画の会話がページをどう書き換えるかの決まり |
hub.src.html の中の __HK_BODY__・__HK_FACES__・__HK_FACES3__・__HK_RING__・__VOICES__ は、build.py が組み立てるときに、ハコブくんの画像(webp)と声(mp3)を文字にしたものへ置き換えます。置き換えた後のページは約600KBになるので、ここには組み立てる前のソースを載せます。
動画/_制作ベース/status_scan.py
#!/usr/bin/env python3
"""status_scan.py — 動画ごとの進み具合を、フォルダの中身から読み取る.
手で書く表は使わない. 進み具合は、すでにファイルとして残っている
(narration.py・voice_eleven の mp3・確認用 mp4・アップロード用・ショート・投稿文).
公開したかは、YouTube の RSS と、サイト用の一覧 (pixelstomotors-videos) で見る.
使い方:
python3 status_scan.py # JSON を画面に出す
python3 status_scan.py --out 出力先.json
読む場所 (見つかったものを使う):
iCloud の 動画/ … このファイルの1つ上のフォルダ
KIOXIA の videos/ … /Volumes/KIOXIA/r2r2r-data/videos ほか
公開の記録は、RSS が新しい15件しか出さないため、
このファイルと同じフォルダの status_published.json にためていく.
"""
import argparse, ast, json, os, re, sys, unicodedata, urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone, timedelta
JST = timezone(timedelta(hours=9))
HERE = os.path.dirname(os.path.abspath(__file__))
CHANNEL_ID = "UC3vr_5p9kJch9zCMb44_rsQ"
RSS = f"https://www.youtube.com/feeds/videos.xml?channel_id={CHANNEL_ID}"
SITE_JSON = "https://raw.githubusercontent.com/PixelstoMotors/pixelstomotors-videos/main/site/youtube/videos.json"
SKIP = {"_ネタ帳", "_制作ベース", "_旧版", "_試してみた回の型", "台本_15本_20261012", "hakobu_試作"}
STEPS = ["台本", "検証", "声", "書き出し", "YouTube素材", "ショート", "投稿文", "公開"]
def nfc(s):
return unicodedata.normalize("NFC", s)
def norm_title(s):
return re.sub(r"[\s ]", "", nfc(s or ""))
def find_dir(cands):
for c in cands:
if c and os.path.isdir(c):
return c
return None
def listdir_nfc(path):
"""{NFC の名前: 実際の名前}"""
try:
return {nfc(n): n for n in os.listdir(path) if not n.startswith(".")}
except OSError:
return {}
def read_text(path):
try:
with open(path, encoding="utf-8") as f:
return f.read()
except OSError:
return ""
def first_line(path):
for line in read_text(path).splitlines():
if line.strip():
return line.strip()
return ""
def count_segs(path):
"""narration.py の SEGS の文の数 (実行せずに数える)."""
try:
tree = ast.parse(read_text(path))
except SyntaxError:
return None
for node in tree.body:
if isinstance(node, ast.Assign) and any(getattr(t, "id", "") == "SEGS" for t in node.targets):
if isinstance(node.value, (ast.List, ast.Tuple)):
return len(node.value.elts)
return None
def voice_count(vdir):
"""voice_eleven/ の中で、試しではないフォルダの mp3 の数の最大."""
best = 0
for name in listdir_nfc(vdir).values():
p = os.path.join(vdir, name)
if not os.path.isdir(p) or "試し" in nfc(name):
continue
n = sum(1 for f in os.listdir(p) if f.endswith(".mp3"))
best = max(best, n)
return best
def latest_mtime(*dirs):
t = 0
for d in dirs:
if not d or not os.path.isdir(d):
continue
for root, subdirs, files in os.walk(d):
if root.count(os.sep) - d.count(os.sep) > 2:
subdirs[:] = []
continue
subdirs[:] = [s for s in subdirs if s not in ("__pycache__", "_取り込み", "voice_eleven")]
for f in files:
if f.startswith("."):
continue
try:
t = max(t, os.path.getmtime(os.path.join(root, f)))
except OSError:
pass
return t
def short_titles(txt):
"""ショート_タイトルと説明文.txt から、ショートのタイトルを順に取り出す."""
# 書き方が動画ごとに少し違う(「タイトル:本文」「タイトル:」の次の行「▼タイトル」の次の行)
out, lines = [], txt.splitlines()
for i, line in enumerate(lines):
t = line.strip().lstrip("▼")
if not t.startswith("タイトル") or t.startswith("タイトルと"):
continue
rest = t[len("タイトル"):].lstrip("::").strip()
if rest:
out.append(rest)
continue
for nxt in lines[i + 1:]:
if nxt.strip():
out.append(nxt.strip())
break
return out
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=20) as r:
return r.read().decode("utf-8", errors="replace")
def load_published():
"""公開の記録を、ためておいた分 + RSS + サイト用の一覧 から作る."""
cache_path = os.path.join(HERE, "status_published.json")
try:
seen = json.loads(read_text(cache_path) or "{}")
except json.JSONDecodeError:
seen = {}
notes = []
try:
ns = {"a": "http://www.w3.org/2005/Atom", "yt": "http://www.youtube.com/xml/schemas/2015",
"media": "http://search.yahoo.com/mrss/"}
root = ET.fromstring(fetch(RSS))
for e in root.findall("a:entry", ns):
vid = e.find("yt:videoId", ns).text
link = e.find("a:link", ns).get("href")
desc = e.findtext("media:group/media:description", "", ns)
seen[vid] = {
"parent": parent_id(desc, vid),
"title": e.find("a:title", ns).text,
"published": e.find("a:published", ns).text,
"is_short": "/shorts/" in link,
"url": link,
}
except Exception as ex: # 取れないときは、ためた分だけで続ける
notes.append(f"YouTube の RSS が読めなかった: {ex}")
try:
for v in json.loads(fetch(SITE_JSON)).get("videos", []):
seen.setdefault(v["id"], {
"title": v["title"], "published": v["published"],
"is_short": str(v.get("is_short")) == "True", "url": v["url"],
})
except Exception as ex:
notes.append(f"サイト用の一覧が読めなかった: {ex}")
# 公開予約まで入った一覧(youtube_schedule.py が Mac で1時間ごとに書き出す。鍵は入っていない)
yv = os.path.join(HERE, "youtube_videos.json")
if os.path.isfile(yv):
try:
yd = json.loads(read_text(yv))
for v in yd.get("動画", []):
e = {"title": v["title"], "is_short": bool(v.get("is_short")), "url": v["url"],
"parent": v.get("parent", "")}
pa = v.get("publishAt")
# 予約の時刻を過ぎていれば、YouTube が公開している(一覧は1時間ごとなので、書き出しの後に公開されたものもある)
due = False
if pa:
try:
due = datetime.fromisoformat(pa.replace("Z", "+00:00")) <= datetime.now(timezone.utc)
except ValueError:
pass
if v.get("privacy") == "public" and v.get("published"):
e["published"] = v["published"]
elif pa and due:
e["published"] = pa
elif pa:
e["scheduled"] = pa
else:
continue # 予約のない非公開・限定公開は数えない
if seen.get(v["id"], {}).get("published") and not e.get("published"):
continue # RSS で公開を見つけているなら、古い「予約中」で上書きしない
seen[v["id"]] = e
notes_ok = yd.get("作成", "")
if notes_ok:
notes.append(f"YouTube の一覧(公開予約を含む): {notes_ok} 時点")
except Exception as ex:
notes.append(f"youtube_videos.json が読めなかった: {ex}")
try:
with open(cache_path, "w", encoding="utf-8") as f:
json.dump({k: v for k, v in seen.items() if v.get("published")}, f, ensure_ascii=False, indent=1)
except OSError as ex:
notes.append(f"公開の記録を保存できなかった: {ex}")
return seen, notes
ID_RE = re.compile(r"(?:youtu\.be/|watch\?v=|shorts/)([A-Za-z0-9_-]{11})")
def parent_id(text, self_id=""):
"""説明文などに書かれた、ほかの動画の ID (ショートなら本編の ID)."""
for m in ID_RE.finditer(text or ""):
if m.group(1) != self_id:
return m.group(1)
return ""
def match(local_titles, folder, pub_title):
p = norm_title(pub_title)
for t in local_titles:
t = norm_title(t)
if t and (t == p or p.startswith(t) or t.startswith(p)):
return True
if norm_title(folder) in p:
return True
# フォルダ名を「の・は・が」などで区切り、2文字以上の語がすべてタイトルに入っていれば同じ動画とみなす
parts = [x for x in re.split(r"[のはがとをに]", nfc(folder)) if len(x) >= 2]
return len(parts) >= 2 and all(x in p for x in parts)
def scan_one(name, wdir, kdir, published):
w = lambda *a: os.path.join(wdir, *a) if wdir else ""
k = lambda *a: os.path.join(kdir, *a) if kdir else ""
up = k("アップロード用")
upfiles = {nfc(n) for n in listdir_nfc(up)} if kdir else set()
shortdir = k("ショート")
shortfiles = sorted(nfc(n) for n in listdir_nfc(shortdir)) if kdir else []
has_narr = os.path.isfile(w("narration.py"))
segs = count_segs(w("narration.py")) if has_narr else None
wfiles = {nfc(n) for n in listdir_nfc(wdir)} if wdir else set()
codex_req = any(f.startswith("codex_敵対的検証依頼") for f in wfiles)
codex_n = sum(1 for f in wfiles if f.startswith("codex_検証結果"))
voice = voice_count(w("voice_eleven")) if wdir else 0
voice_ok = bool(segs) and voice >= segs
check_mp4 = os.path.isfile(k(f"{name}_確認用.mp4")) or any(
f.endswith("_確認用.mp4") for f in listdir_nfc(kdir)) if kdir else False
up_mp4 = any(f.endswith(".mp4") for f in upfiles)
up_need = {"サムネイル.png": "サムネイル", "タイトル.txt": "タイトル", "概要欄.txt": "概要欄"}
up_missing = [v for f, v in up_need.items() if f not in upfiles]
shorts_made = sum(1 for f in shortfiles if f.endswith(".mp4"))
post_txt = "投稿文_X_LinkedIn.txt" in upfiles or "投稿文_X_LinkedIn.txt" in wfiles
title = first_line(os.path.join(up, "タイトル.txt")) or first_line(w("タイトル.txt"))
kind = "マンガ回" if wdir and any(
re.match(r"ep\d+_manga\.md", nfc(f)) for f in listdir_nfc(w("src"))) else "解説回"
local_text = "".join(read_text(p) for p in (
os.path.join(up, "投稿文_X_LinkedIn.txt"), w("投稿文_X_LinkedIn.txt"),
os.path.join(up, "概要欄.txt"), os.path.join(shortdir, "ショート_タイトルと説明文.txt")) if p)
local_ids = {m.group(1) for m in ID_RE.finditer(local_text)}
# タイトルで探し、見つからないときだけ投稿文などに書いた動画の ID で探す
# (投稿文や概要欄には、ほかの動画の URL も書くことがあるため、ID を先に使わない)
hits = [(vid, v) for vid, v in published.items() if not v["is_short"] and match([title], name, v["title"])]
if not hits:
hits = [(vid, v) for vid, v in published.items() if not v["is_short"] and vid in local_ids]
mains = [(vid, v) for vid, v in hits if v.get("published")]
scheds = [(vid, v) for vid, v in hits if v.get("scheduled")]
main = sorted(mains, key=lambda x: x[1]["published"])[0] if mains else None
s_titles = short_titles(read_text(os.path.join(shortdir, "ショート_タイトルと説明文.txt"))) if kdir else []
main_id = main[0] if main else (scheds[0][0] if scheds else "")
mine = [v for v in published.values() if v["is_short"] and (
(main_id and v.get("parent") == main_id)
or any(norm_title(v["title"]) == norm_title(t) for t in s_titles))]
s_pub = sum(1 for v in mine if v.get("published"))
s_sched = sum(1 for v in mine if v.get("scheduled"))
sched_date = (datetime.fromisoformat(scheds[0][1]["scheduled"].replace("Z", "+00:00")).astimezone(JST).strftime("%Y-%m-%d")
if scheds and not main else "")
# 後の段階のファイルがあれば、前の段階は済んでいる
# (narration.py の SEGS には声のない区切りが入ることがあり、mp3 の数が1つ少なく見える)
if check_mp4 or up_mp4:
voice_ok = True
# 段階ごとの状態 (済・途中・未)
st = {
"台本": "済" if has_narr else "未",
"検証": "済" if (codex_n or voice) else ("途中" if codex_req else "未"),
"声": "済" if voice_ok else ("途中" if voice else "未"),
"書き出し": "済" if up_mp4 else ("途中" if check_mp4 else "未"),
# 公開済みなら YouTube に上がっているので済(初期の動画はフォルダにサムネイル・タイトル・概要欄を残していない)
"YouTube素材": "済" if (main or (up_mp4 and not up_missing)) else ("途中" if upfiles else "未"),
"ショート": "済" if shorts_made >= 3 else ("途中" if shorts_made else "未"),
"投稿文": "済" if post_txt else "未",
"公開": "済" if main else ("予約" if sched_date else "未"),
}
# 次にやることと、誰の番か
if main:
stage = "公開済み"
if shorts_made and s_pub + s_sched < shorts_made:
nxt, who = f"ショートを投稿する({s_pub}/{shorts_made}本 公開済み・予約 {s_sched})", "エイイチ"
elif shorts_made < 3:
nxt, who = "ショートを3本作る", "Claude"
else:
nxt, who = "完了(X・LinkedIn の投稿だけ手で確かめる)", "—"
stage = "完了"
elif sched_date and shorts_made >= 3 and post_txt:
stage, who = "予約済み", "—"
nxt = f"{sched_date} に公開予定(待つだけ)" + ("" if s_pub + s_sched >= shorts_made else "。公開されたらショートを投稿")
elif sched_date and shorts_made < 3:
stage, nxt, who = "ショート", f"ショートを3本作る(本編は {sched_date} に公開予約済み)", "Claude"
elif sched_date and not post_txt:
stage, nxt, who = "投稿文", f"X・LinkedIn の投稿文を作る(本編は {sched_date} に公開予約済み)", "Claude"
elif not has_narr:
stage, nxt, who = "台本", "台本を書く", "Claude"
elif not (codex_n or voice):
stage = "台本"
nxt, who = (("codex にかけるか決める(依頼文はできている)", "エイイチ") if codex_req
else ("台本を点検し、codex の依頼文を作る", "Claude"))
elif not voice_ok:
stage = "声"
nxt = (f"声を作る({voice}/{segs}文)" if voice else
"台本を確定して、Mac で声を作る")
who = "エイイチ"
elif not check_mp4 and not up_mp4:
stage, nxt, who = "図・書き出し", "図を作って書き出す", "Claude"
elif not up_mp4:
stage, nxt, who = "確認", "確認用を通しで見て、直す所を伝える", "エイイチ"
elif up_missing:
stage, nxt, who = "YouTube素材", "・".join(up_missing) + "を作る", "Claude"
elif shorts_made < 3:
stage, nxt, who = "ショート", "ショートを3本作る", "Claude"
elif not post_txt:
stage, nxt, who = "投稿文", "X・LinkedIn の投稿文を作る", "Claude"
else:
stage, nxt, who = "公開待ち", "YouTube にアップロードする(予約済みなら待つだけ)", "エイイチ"
mt = latest_mtime(wdir, kdir)
row = {
"動画": name,
"タイトル": title or (main[1]["title"] if main else name),
"種類": kind,
"段階": stage,
"次にやること": nxt,
"誰の番": who,
"声の数": f"{voice}/{segs}" if segs else "",
"ショートの数": (f"{shorts_made}本(公開 {s_pub}" + (f"・予約 {s_sched}" if s_sched else "") + ")") if shorts_made else "",
"公開予約": sched_date,
# 予約の時刻と URL(ページが、時刻を過ぎたら読み直しを待たずに「公開済み・ショートの投稿」に切り替える)
"公開予約の時刻": scheds[0][1]["scheduled"] if (sched_date and not main) else "",
"予約のURL": scheds[0][1].get("url", "") if (sched_date and not main) else "",
"公開日": datetime.fromisoformat(main[1]["published"].replace("Z", "+00:00")).astimezone(JST).strftime("%Y-%m-%d") if main else "",
"URL": main[1]["url"] if main else "",
"最終更新": datetime.fromtimestamp(mt, JST).strftime("%Y-%m-%d %H:%M") if mt else "",
}
row.update({s: st[s] for s in STEPS})
return row
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--douga", help="iCloud の 動画/ フォルダ")
ap.add_argument("--kioxia", help="KIOXIA の videos/ フォルダ")
ap.add_argument("--out", help="JSON の書き出し先(なければ画面に出す)")
a = ap.parse_args()
home = os.path.expanduser("~")
douga = find_dir([a.douga, os.path.dirname(HERE), os.path.join(home, "mnt", "動画")])
kiox = find_dir([a.kioxia, "/Volumes/KIOXIA/r2r2r-data/videos",
os.path.join(home, "mnt", "videos"),
os.path.join(home, "mnt", "KIOXIA", "r2r2r-data", "videos")])
if not douga:
sys.exit("動画/ フォルダが見つからない")
published, notes = load_published()
if not kiox:
notes.append("KIOXIA の videos/ が見つからない(つながっていない?)。書き出し以降は空で出る")
# 「_」で始まるフォルダ(_ネタ帳・_ハコブくんのお知らせ など)は動画ではない
wmap = {k: v for k, v in listdir_nfc(douga).items()
if k not in SKIP and not k.startswith("_") and os.path.isdir(os.path.join(douga, v))}
kmap = {k: v for k, v in listdir_nfc(kiox).items()
if k not in SKIP and not k.startswith("_") and os.path.isdir(os.path.join(kiox, v))} if kiox else {}
rows = []
for name in sorted(set(wmap) | set(kmap)):
rows.append(scan_one(
name,
os.path.join(douga, wmap[name]) if name in wmap else None,
os.path.join(kiox, kmap[name]) if name in kmap else None,
published))
# まだ作業フォルダのない台本の候補
cands = []
sdir = os.path.join(douga, listdir_nfc(douga).get("台本_15本_20261012", "台本_15本_20261012"))
for f in sorted(listdir_nfc(sdir)):
m = re.match(r"(\d\d)_(.+)\.md$", f)
if m and m.group(1) != "00" and m.group(2) not in wmap:
head = first_line(os.path.join(sdir, listdir_nfc(sdir)[f])).lstrip("# ").strip()
cands.append({"番号": m.group(1), "名前": m.group(2), "見出し": head, "置き場所": "台本_15本_20261012"})
out = {
"作成": datetime.now(JST).strftime("%Y-%m-%d %H:%M"),
"注意": notes,
"動画": rows,
"台本の候補": cands,
}
text = json.dumps(out, ensure_ascii=False, indent=1)
if a.out:
with open(a.out, "w", encoding="utf-8") as f:
f.write(text)
else:
print(text)
if __name__ == "__main__":
main()
動画/_制作ベース/youtube_schedule.py
#!/usr/bin/env python3
"""youtube_schedule.py — 自分のチャンネルの動画を、公開予約中のものまで含めて書き出す.
YouTube の公式の仕組み(YouTube Data API v3)で読む。読むだけで、動画には何も書き込まない。
Python の標準の道具だけで動く(pip で入れるものはない)。
許可の鍵は Mac の中だけに置く(Claude には渡さない。ElevenLabs の鍵と同じ考え):
~/.config/pixelstomotors/client_secret.json Google Cloud で作った「デスクトップ アプリ」の OAuth クライアント
~/.config/pixelstomotors/youtube_token.json ログインの許可(このスクリプトが作る)
書き出すもの(鍵は入らない):
このファイルと同じフォルダの youtube_videos.json
→ status_scan.py が読み、進み具合のページの「予約済み」に使う
使い方(Mac のターミナルで):
python3 youtube_schedule.py --login 最初の1回。ブラウザで許可して、一覧を書き出す
python3 youtube_schedule.py 一覧を書き出し直す
python3 youtube_schedule.py --install 1時間ごとに自動で書き出すように登録する(launchd)
python3 youtube_schedule.py --uninstall 自動の登録を外す
"""
import argparse, base64, hashlib, http.server, json, os, re, secrets, subprocess, sys, threading
import urllib.parse, urllib.request, webbrowser
from datetime import datetime, timezone, timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "youtube_videos.json")
CONF = os.path.expanduser("~/.config/pixelstomotors")
SECRET = os.path.join(CONF, "client_secret.json")
TOKEN = os.path.join(CONF, "youtube_token.json")
LOG = os.path.join(CONF, "youtube_schedule.log")
SCOPE = "openid email https://www.googleapis.com/auth/youtube.readonly"
API = "https://www.googleapis.com/youtube/v3"
LABEL = "jp.adaptdesign.pixelstomotors.youtube-schedule"
PLIST = os.path.expanduser(f"~/Library/LaunchAgents/{LABEL}.plist")
JST = timezone(timedelta(hours=9))
CHANNEL = "Pixels to Motors"
CHANNEL_ID = "UC3vr_5p9kJch9zCMb44_rsQ" # @PixelstoMotors # このチャンネル以外を許可したときは書き出さない
def call(url, data=None, headers=None):
body = urllib.parse.urlencode(data).encode() if isinstance(data, dict) else data
req = urllib.request.Request(url, data=body, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
raise SystemExit(f"YouTube / Google から断られました({e.code}): {e.read().decode(errors='ignore')[:400]}")
def client():
if not os.path.isfile(SECRET):
raise SystemExit(f"{SECRET} がありません。Google Cloud でダウンロードした JSON を、この名前で置いてください")
c = json.load(open(SECRET))
c = c.get("installed") or c.get("web") or c
return c["client_id"], c["client_secret"]
def save_token(t):
os.makedirs(CONF, exist_ok=True)
with open(TOKEN, "w") as f:
json.dump(t, f)
os.chmod(TOKEN, 0o600)
def login():
"""ブラウザで許可をもらう(この Mac の中だけで受け取る)"""
cid, csec = client()
verifier = secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
got = {}
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
got.update({k: v[0] for k, v in q.items()})
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
msg = "許可を受け取りました。この画面は閉じてください。" if "code" in got else "許可されませんでした。"
self.wfile.write(f"<p style='font:16px sans-serif'>{msg}</p>".encode())
def log_message(self, *a):
pass
srv = http.server.HTTPServer(("127.0.0.1", 0), H)
redirect = f"http://127.0.0.1:{srv.server_port}/"
url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode({
"client_id": cid, "redirect_uri": redirect, "response_type": "code", "scope": SCOPE,
"access_type": "offline", "prompt": "select_account consent", # 毎回アカウントを選ばせる
"code_challenge": challenge, "code_challenge_method": "S256",
})
print("ブラウザで Google の許可の画面を開きます。チャンネル「Pixels to Motors」のアカウントを選んで許可してください。")
print("開かないときは、このアドレスをブラウザに貼ってください:\n" + url)
webbrowser.open(url)
th = threading.Thread(target=srv.handle_request)
th.start(); th.join(timeout=300)
srv.server_close()
if "code" not in got:
raise SystemExit("許可を受け取れませんでした(5分で止めました)。もう一度 --login から")
t = call("https://oauth2.googleapis.com/token", {
"code": got["code"], "client_id": cid, "client_secret": csec, "redirect_uri": redirect,
"grant_type": "authorization_code", "code_verifier": verifier,
})
if "refresh_token" not in t:
raise SystemExit("許可の鍵(refresh_token)が返りませんでした。--login をもう一度")
email = ""
try: # どのアカウントで許可したかを表示する(id_token の中のメールアドレス)
part = t.get("id_token", "").split(".")[1]
email = json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))).get("email", "")
except Exception:
pass
save_token({"refresh_token": t["refresh_token"], "email": email})
print("許可を保存しました:", TOKEN)
print("許可したアカウント:", email or "(分からない)")
def access_token():
if not os.path.isfile(TOKEN):
raise SystemExit("まだ許可がありません。python3 youtube_schedule.py --login を先に")
cid, csec = client()
rt = json.load(open(TOKEN))["refresh_token"]
t = call("https://oauth2.googleapis.com/token", {
"refresh_token": rt, "client_id": cid, "client_secret": csec, "grant_type": "refresh_token"})
return t["access_token"]
def seconds(iso):
m = re.match(r"P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso or "")
if not m:
return None
d, h, mi, s = (int(x or 0) for x in m.groups())
return ((d * 24 + h) * 60 + mi) * 60 + s
ID_RE = re.compile(r"(?:youtu\.be/|watch\?v=|shorts/)([A-Za-z0-9_-]{11})")
def fetch():
tok = access_token()
hd = {"Authorization": "Bearer " + tok}
# チャンネルは ID で決め打ちする(許可したアカウントの「自分のチャンネル」が別のこともあるため)
me = call(f"{API}/channels?part=snippet&mine=true", headers=hd)
me_names = [i["snippet"]["title"] for i in me.get("items", [])]
ch = call(f"{API}/channels?part=contentDetails,snippet&id={CHANNEL_ID}", headers=hd)
if not ch.get("items"):
raise SystemExit("チャンネル Pixels to Motors が見つかりませんでした")
owner = any(CHANNEL in n for n in me_names)
print("許可したアカウント:", json.load(open(TOKEN)).get("email") or "(前の許可。--login し直すと表示されます)")
print("許可したアカウントのチャンネル:", "・".join(me_names) or "(なし)")
uploads = ch["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]
ids, page = [], ""
while True:
r = call(f"{API}/playlistItems?part=contentDetails&maxResults=50&playlistId={uploads}" + (f"&pageToken={page}" if page else ""), headers=hd)
ids += [i["contentDetails"]["videoId"] for i in r.get("items", [])]
page = r.get("nextPageToken")
if not page or len(ids) >= 500:
break
out = []
for k in range(0, len(ids), 50):
r = call(f"{API}/videos?part=snippet,status,contentDetails&id={','.join(ids[k:k + 50])}", headers=hd)
for v in r.get("items", []):
st, sn = v["status"], v["snippet"]
sec = seconds(v["contentDetails"].get("duration"))
parent = next((m.group(1) for m in ID_RE.finditer(sn.get("description", "")) if m.group(1) != v["id"]), "")
out.append({
"id": v["id"],
"title": sn.get("title", ""),
"privacy": st.get("privacyStatus"), # public / private / unlisted
"publishAt": st.get("publishAt"), # 公開予約の日時(予約中だけ)
"published": sn.get("publishedAt") if st.get("privacyStatus") == "public" else None,
"seconds": sec,
"is_short": sec is not None and sec <= 180, # 3分以内をショートとみなす
"parent": parent, # 説明文にある本編の ID(ショート用)
"url": f"https://www.youtube.com/watch?v={v['id']}",
})
n_priv = sum(1 for v in out if v["privacy"] != "public")
if not owner and n_priv == 0:
print("注意: 許可したアカウントが Pixels to Motors の持ち主ではないので、公開予約中の動画は読めていません。--login で持ち主のアカウント(とチャンネル)を選び直してください")
data = {"作成": datetime.now(JST).strftime("%Y-%m-%d %H:%M"), "チャンネル": ch["items"][0]["snippet"]["title"],
"予約まで読めた": bool(owner or n_priv), "動画": out}
with open(OUT, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=1)
n_s = sum(1 for v in out if v["publishAt"] and v["privacy"] != "public")
print(f"{len(out)} 本を書き出しました(うち公開予約 {n_s} 本): {OUT}")
def install():
os.makedirs(os.path.dirname(PLIST), exist_ok=True)
os.makedirs(CONF, exist_ok=True)
plist = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>{LABEL}</string>
<key>ProgramArguments</key><array><string>{sys.executable}</string><string>{os.path.abspath(__file__)}</string></array>
<key>StartInterval</key><integer>3600</integer>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>{LOG}</string>
<key>StandardErrorPath</key><string>{LOG}</string>
</dict></plist>
"""
with open(PLIST, "w") as f:
f.write(plist)
subprocess.run(["launchctl", "unload", PLIST], capture_output=True)
subprocess.run(["launchctl", "load", PLIST], check=True)
print("1時間ごとに書き出すように登録しました。うまく動いているかは次のファイルで分かります:", LOG)
def uninstall():
subprocess.run(["launchctl", "unload", PLIST], capture_output=True)
if os.path.exists(PLIST):
os.remove(PLIST)
print("自動の登録を外しました")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--login", action="store_true")
ap.add_argument("--install", action="store_true")
ap.add_argument("--uninstall", action="store_true")
a = ap.parse_args()
if a.uninstall:
uninstall()
elif a.install:
install()
else:
if a.login:
login()
fetch()
動画/_ハコブくんのお知らせ/narration.py
# 進み具合のページ(動画の進み具合)で、ハコブくんが話す決まり文句の声を作るためのフォルダ。
# 台本はないので SEGS は空。声は tts_eleven.py の --try で作る(読み方の直しは使われない)。
SEGS = []
動画/_ハコブくんのお知らせ/決まり文句.txt
試し_01 kakunin 確認用の動画ができました。見てもらえますか。
試し_02 daihon 台本ができました。確かめてもらえますか。
試し_03 koe 声を作る番です。Macで声を作ってください。
試し_04 title タイトルとサムネイルを選んでください。
試し_05 short ショートの投稿が残っています。
試し_06 upload アップロードを待っている動画があります。
試し_07 session 会話で、お返事を待っています。
試し_08 owari いまは、待っている作業はありません。
進み具合のページのソース hub.src.html
<title>動画の進み具合</title>
<meta name="claude:viewport-fit-sides" content="cover">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Noto+Sans+JP:wght@400;500;600&display=swap">
<style>
/* 黒地の制作ボード。上にハコブくんの知らせ、その下に「誰の番か」、最後に全体の段階表。
文字はサイト(adaptdesign.jp/pixelstomotors)と同じ Inter + Noto Sans JP。黒地だけの1テーマ */
:root {
--bg: #0e0f0f;
--panel: #17191a;
--panel-2: #1f2223;
--line: #2b2f30;
--fg: #f1f1ec;
--muted: #8f938f;
--accent: #e0603a; /* 動画のサムネイルのオレンジ(#C4471F)を黒地で読める明るさに */
--ok: #6cc28f;
--warn: #e7b04a;
--fin: #ef5a50; /* 完了(公開もショートも済んだ)の赤 */
--chip-fg: #0e0f0f;
--font: "Inter", "Noto Sans JP", "Hiragino Sans", "Hiragino Kaku Gothic ProN", sans-serif;
--radius: 6px;
color-scheme: dark;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { background: var(--bg); color: var(--fg); }
body { font-family: var(--font); font-size: 14px; line-height: 1.6; -webkit-font-smoothing: antialiased; }
.wrap { max-width: 1180px; margin: 0 auto; padding-block: 28px 64px; padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: max(16px, env(safe-area-inset-right, 0px)); display: flex; flex-direction: column; gap: 28px; }
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: .01em; text-wrap: balance; }
h1 { font-size: 26px; line-height: 1.3; }
h2 { font-size: 17px; }
button { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.muted { color: var(--muted); }
.num { font-variant-numeric: tabular-nums; }
/* 見出しの行 */
.top { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px 24px; }
.top .when { font-size: 12px; color: var(--muted); margin-top: 4px; }
.voice { display: inline-flex; align-items: center; gap: 8px; padding: 7px 14px; border: 1px solid var(--line); border-radius: 4px; background: var(--panel); cursor: pointer; font-size: 13px; font-weight: 500; }
.voice svg { width: 16px; height: 16px; }
.voice[aria-pressed="true"] { background: var(--fg); color: var(--chip-fg); border-color: var(--fg); }
.voice:disabled { cursor: default; opacity: .55; }
/* ハコブくんの知らせ */
.next-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 12px 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
/* サイトの「NEW」ラベルと同じ、塗りつぶしの札で目立たせる */
.next-bar .nb-lab { font-size: 12px; font-weight: 600; color: var(--chip-fg); background: var(--accent); padding: 2px 9px; border-radius: 3px; letter-spacing: .06em; }
.next-bar .nb-say { font-size: 15px; font-weight: 600; min-width: 0; }
.next-bar .nb-rest { font-size: 12px; color: var(--muted); display: inline-flex; align-items: center; gap: 4px; }
.next-bar .pg { width: 26px; height: 26px; border-radius: 4px; border: 1px solid var(--line); background: var(--panel-2); color: var(--fg); cursor: pointer; font-size: 15px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; }
.next-bar .pg:hover { border-color: var(--muted); }
.next-bar .nb-rest .num { min-width: 3.2em; text-align: center; }
.next-bar .nb-actions { margin-top: 0; margin-left: auto; }
.next-bar .nb-actions .btn { font-size: 12px; padding: 4px 10px; }
.hk { display: grid; grid-template-columns: 172px minmax(0, 1fr); gap: 12px; align-items: end; }
.hk-stage { display: flex; justify-content: center; }
.hk-body { position: relative; width: 132px; aspect-ratio: 292 / 460; }
.hk-body .face, .mini .face { position: absolute; left: 53.082%; top: 11.087%; width: 29.110%; height: 16.739%; }
.hk-body img { display: block; width: 100%; height: auto; }
.bubble { position: relative; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; margin-bottom: 48px; min-width: 0; }
.bubble::before { content: ""; position: absolute; left: -9px; bottom: 26px; width: 16px; height: 16px; background: var(--panel); border-left: 1px solid var(--line); border-bottom: 1px solid var(--line); transform: rotate(45deg); }
.bubble .who { font-size: 12px; color: var(--muted); font-weight: 500; letter-spacing: .04em; }
.bubble .say { font-size: 19px; font-weight: 600; line-height: 1.5; margin: 4px 0 6px; text-wrap: pretty; }
.bubble .sub { font-size: 13px; color: var(--muted); }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 4px; border: 1px solid var(--line); background: var(--panel-2); cursor: pointer; font-size: 13px; font-weight: 500; text-decoration: none; color: var(--fg); }
.btn:hover { border-color: var(--muted); }
.btn.primary { background: var(--fg); color: var(--chip-fg); border-color: var(--fg); }
.btn svg { width: 14px; height: 14px; }
.toast { font-size: 12px; color: var(--ok); align-self: center; }
/* 本数 */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr)); gap: 10px; }
.tile { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 16px; }
.tile .lab { font-size: 12px; color: var(--muted); font-weight: 500; letter-spacing: .04em; }
.tile .val { font-size: 30px; font-weight: 600; line-height: 1.2; margin-top: 2px; font-variant-numeric: tabular-nums; }
.tile .val small { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 3px; }
.tile.me { border-color: var(--fg); }
button.tile { text-align: left; font: inherit; color: var(--fg); cursor: pointer; transition: background .15s, border-color .15s; }
button.tile:hover { background: var(--panel-2); border-color: var(--muted); }
button.tile[aria-pressed="true"] { background: var(--fg); color: var(--chip-fg); border-color: var(--fg); }
button.tile[aria-pressed="true"] .lab, button.tile[aria-pressed="true"] .val small { color: var(--chip-fg); opacity: .7; }
.filter-chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; padding: 2px 6px 2px 10px; border-radius: 3px; background: var(--fg); color: var(--chip-fg); border: 0; cursor: pointer; }
/* 区画 */
.sec { display: flex; flex-direction: column; gap: 12px; }
.sec-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.sec-head .note { font-size: 12px; color: var(--muted); }
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 28px; }
/* いま動いている作業 */
.live { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 10px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.card .name { font-weight: 600; }
.card .doing { font-size: 13px; }
.card .meta { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; font-size: 12px; color: var(--muted); }
.card.stale { opacity: .7; }
.empty { border: 1px dashed var(--line); border-radius: var(--radius); padding: 16px; font-size: 13px; color: var(--muted); }
/* 札 */
.chip { display: inline-block; font-size: 11px; font-weight: 600; letter-spacing: .03em; padding: 1px 7px; border-radius: 3px; border: 1px solid var(--line); white-space: nowrap; line-height: 1.6; }
.chip.e { background: var(--fg); color: var(--chip-fg); border-color: var(--fg); }
.chip.c { color: var(--fg); }
.chip.done { color: var(--ok); border-color: currentColor; }
.chip.fin { color: var(--fin); border-color: currentColor; }
.chip.warn { color: var(--warn); border-color: currentColor; }
.chip.memo { color: var(--accent); border-color: currentColor; }
/* 一覧 */
.list { display: flex; flex-direction: column; border-top: 1px solid var(--line); }
.row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; padding: 10px 4px; border-bottom: 1px solid var(--line); width: 100%; text-align: left; background: none; border-left: 0; border-right: 0; border-top: 0; cursor: pointer; }
.row:hover { background: var(--panel); }
.row[aria-current="true"] { background: var(--panel-2); }
.row .name { font-weight: 600; }
.row .next { font-size: 13px; color: var(--muted); grid-column: 1 / -1; }
.row .right { display: flex; gap: 6px; align-items: center; }
details.ups > summary { list-style: none; }
details.ups > summary::-webkit-details-marker { display: none; }
details.ups > summary .name::before { content: "▸"; display: inline-block; width: 1.1em; color: var(--muted); transition: transform .15s; }
details.ups[open] > summary .name::before { transform: rotate(90deg); }
.row.sub { padding-left: 22px; }
.row.sub .name { font-weight: 500; }
/* 選んだ動画 */
.detail { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px 20px; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 20px 28px; }
.detail h3 { font-size: 17px; }
.detail .title { font-size: 13px; color: var(--muted); margin-top: 2px; }
.todo { margin-top: 14px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.todo .lab { font-size: 12px; color: var(--muted); font-weight: 500; margin-right: 4px; }
.todo .step { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500; padding: 3px 9px; border: 1px solid var(--line); border-radius: 3px; background: var(--bg); }
.todo .step .st { color: var(--muted); font-weight: 400; }
.todo .all { font-size: 13px; color: var(--fin); font-weight: 600; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; font-size: 13px; margin-top: 12px; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; }
.sched { margin-top: 18px; display: flex; flex-direction: column; gap: 2px; }
.sched label { font-size: 12px; color: var(--muted); font-weight: 500; }
input[type="date"] { background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 4px; padding: 6px 10px; font: inherit; font-size: 13px; }
.memo-box { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.memo-box label { font-size: 12px; color: var(--muted); font-weight: 500; }
textarea { width: 100%; min-height: 84px; resize: vertical; background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 4px; padding: 10px 12px; font: inherit; font-size: 14px; }
.memos { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.memos li { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 10px; font-size: 13px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); }
.memos li.done { opacity: .5; }
.memos li.done .t { text-decoration: line-through; }
.memos .at { grid-column: 2; font-size: 11px; color: var(--muted); }
.memos input { margin-top: 4px; accent-color: var(--ok); }
/* 段階表 */
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
th { font-size: 12px; font-weight: 500; color: var(--muted); letter-spacing: .03em; position: sticky; top: 0; background: var(--panel); }
tbody tr { cursor: pointer; }
tbody tr:hover { background: var(--panel-2); }
td.st, th.st { text-align: center; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid var(--line); vertical-align: middle; }
.dot.done { background: var(--ok); border-color: var(--ok); }
.dot.part { background: linear-gradient(90deg, var(--warn) 50%, transparent 50%); border-color: var(--warn); }
.legend { display: flex; gap: 14px; font-size: 12px; color: var(--muted); align-items: center; flex-wrap: wrap; }
.legend span { display: inline-flex; align-items: center; gap: 5px; }
.cands { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 8px; }
.cand { font-size: 13px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 4px; color: var(--fg); display: flex; flex-direction: column; gap: 10px; }
.cand .go { font-size: 12px; color: var(--muted); }
.cand.requested { opacity: .6; }
.cand > span:first-child { font-size: 15px; font-weight: 600; line-height: 1.5; }
.cand .btn { font-size: 11.5px; padding: 3px 10px; font-weight: 500; }
.cand .go { font-size: 12px; margin-top: auto; } /* ボタンはカードの下にそろえる */
.cand .ask { font-size: 13px; color: var(--fg); align-self: center; }
.cand b { font-weight: 600; margin-right: 6px; font-variant-numeric: tabular-nums; color: var(--muted); }
/* Claude が作業しているあいだ、ハコブくんが左右に行き来する(向きも変える) */
@keyframes patrol {
0% { transform: translateX(calc(var(--roam) * -1)) scaleX(1); }
46% { transform: translateX(var(--roam)) scaleX(1); }
50% { transform: translateX(var(--roam)) scaleX(-1); }
96% { transform: translateX(calc(var(--roam) * -1)) scaleX(-1); }
100% { transform: translateX(calc(var(--roam) * -1)) scaleX(1); }
}
.hk-body { --roam: 18px; }
/* 「いま動いている作業」のカード: ハコブくんが動画を運ぶ道のり(8つの段階)。
済んだ区間は緑、いまの段階で行ったり来たりして作業している */
.card { --pad: 19px; } /* 道のりの端の余白(吹き出しの角の位置にも使う) */
.track { position: relative; height: 76px; margin-top: 6px; }
.track.has-bubble { margin-top: 2px; }
/* 区切りまで来て止まったときの、小さなハコブくんの吹き出し(下向きの角がハコブくんを指す) */
.mbubble { position: relative; margin-top: 10px; background: var(--fg); color: var(--chip-fg); font-size: 12.5px; line-height: 1.5; padding: 8px 12px; border-radius: 8px; display: flex; flex-direction: column; gap: 1px; transform-origin: var(--ax) 100%; }
.mbubble .t { font-size: 11px; opacity: .65; font-weight: 500; }
.mbubble b { font-weight: 600; }
.mbubble::after { content: ""; position: absolute; bottom: -5px; left: var(--ax); width: 10px; height: 10px; margin-left: -5px; background: var(--fg); transform: rotate(45deg); border-radius: 1px; }
@keyframes pop { 0% { transform: scale(.6); opacity: 0; } 70% { transform: scale(1.04); opacity: 1; } 100% { transform: scale(1); } }
@keyframes hop { 0%, 80%, 100% { transform: translateY(0); } 86% { transform: translateY(-5px); } 92% { transform: translateY(0); } 95% { transform: translateY(-2px); } }
@media (prefers-reduced-motion: no-preference) {
.mbubble { animation: pop .45s ease-out both; }
.card.waiting .track .mini { animation: hop 3.2s ease-in-out infinite; }
}
.track .rail { position: absolute; left: var(--pad); right: var(--pad); bottom: 7px; height: 2px; background: var(--line); border-radius: 1px; }
.track .rail i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--ok); border-radius: 1px; transition: width .6s ease; }
.track .stop { position: absolute; bottom: 3px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: var(--panel); border: 2px solid var(--line); }
.track .stop.done { background: var(--ok); border-color: var(--ok); }
.track .stop.now { border-color: var(--accent); background: var(--panel); }
.track .mini { position: absolute; bottom: 14px; width: 38px; margin-left: -19px; aspect-ratio: 292 / 460; }
.track .mini img:not(.face) { display: block; width: 100%; height: auto; } /* 顔の画像には当てない(顔は .face の位置と大きさ) */
.progress-label { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--muted); }
.progress-label b { color: var(--fg); font-weight: 600; }
.live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); margin-right: 2px; }
.doing .ell::after { content: ""; }
@keyframes shuttle {
0% { transform: translateX(-12px) scaleX(1); }
45% { transform: translateX(12px) scaleX(1); }
50% { transform: translateX(12px) scaleX(-1); }
95% { transform: translateX(-12px) scaleX(-1); }
100% { transform: translateX(-12px) scaleX(1); }
}
/* 済んだ区間(スタートからいまの段階まで)を往復する */
@keyframes run {
0% { left: var(--to); transform: scaleX(-1); }
46% { left: var(--from); transform: scaleX(-1); }
50% { left: var(--from); transform: scaleX(1); }
96% { left: var(--to); transform: scaleX(1); }
100% { left: var(--to); transform: scaleX(-1); }
}
@keyframes ping { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent); } 100% { box-shadow: 0 0 0 8px transparent; } }
@keyframes blinkdot { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
@keyframes ell { 0% { content: ""; } 25% { content: "."; } 50% { content: ".."; } 75% { content: "..."; } }
@media (prefers-reduced-motion: no-preference) {
.card.working .track .stop.now { animation: ping 1.6s ease-out infinite; }
.card.working .live-dot { animation: blinkdot 1.4s ease-in-out infinite; }
.card.working .doing .ell::after { animation: ell 1.6s steps(1) infinite; }
}
/* ---------- iPhone: ハコブくんの画面(幅が狭いときだけ。「全部見る」で一覧に切り替え) ---------- */
#phone, .back-phone { display: none; }
body.phone .wrap { display: none; }
body.phone #phone { display: flex; }
body.narrow-full .back-phone { display: inline-flex; }
/* 動画が公開されたときに、画面の下を走り回って知らせるハコブくん(数秒だけ) */
.parade { position: fixed; z-index: 30; left: 0; bottom: calc(8px + env(safe-area-inset-bottom, 0px)); width: 104px; pointer-events: none; will-change: transform; }
.parade .pb { position: relative; aspect-ratio: 292 / 460; }
.parade .pb img { display: block; width: 100%; height: auto; }
.parade .pb .face3 { position: absolute; left: 52.055%; top: 9.130%; width: 30.479%; height: 18.696%; }
.parade-say { position: fixed; z-index: 31; left: 0; right: 0; margin: 0 auto; bottom: calc(190px + env(safe-area-inset-bottom, 0px)); width: max-content; max-width: min(92vw, 520px);
background: var(--fg); color: var(--chip-fg); border-radius: 12px; padding: 10px 16px; font-size: 15px; font-weight: 600; line-height: 1.5; text-align: center; text-wrap: balance; box-shadow: 0 6px 24px rgba(0,0,0,.5); pointer-events: none; }
.parade-say small { display: block; font-size: 12.5px; font-weight: 500; opacity: .7; }
@media (prefers-reduced-motion: no-preference) { .parade-say { animation: pop .45s ease-out both; } }
body.parading .ph-hero { visibility: hidden; }
#phone { flex-direction: column; gap: 14px; max-width: 560px; margin: 0 auto; padding-block: 10px 48px; padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: max(16px, env(safe-area-inset-right, 0px)); }
.ph-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.ph-links { display: flex; gap: 8px; flex-wrap: wrap; }
.ph-chip { display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); color: var(--fg); font-size: 13px; font-weight: 600; cursor: pointer; }
.ph-chip .num { color: var(--muted); font-weight: 400; }
.ph-chip[aria-expanded="true"] { background: var(--fg); color: var(--chip-fg); border-color: var(--fg); }
.ph-chip[aria-expanded="true"] .num { color: var(--chip-fg); opacity: .7; }
.ph-voice { width: 36px; height: 36px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
.ph-voice svg { width: 18px; height: 18px; }
.ph-voice[aria-pressed="true"] { background: var(--fg); color: var(--chip-fg); border-color: var(--fg); }
.ph-voice:disabled { opacity: .5; }
/* ハコブくんは画面の下に置く(親指で押しやすい) */
.hk.ph-hero { position: fixed; z-index: 15; left: 0; right: 0; bottom: 0; max-width: 560px; margin: 0 auto; grid-template-columns: 108px minmax(0, 1fr); gap: 8px; align-items: end;
padding: 18px max(16px, env(safe-area-inset-right, 0px)) calc(10px + env(safe-area-inset-bottom, 0px)) max(16px, env(safe-area-inset-left, 0px));
background: linear-gradient(to top, var(--bg) 80%, color-mix(in srgb, var(--bg) 0%, transparent)); }
.ph-hero .hk-body { width: 104px; }
.ph-hero .bubble { margin-bottom: 40px; padding: 10px 14px; }
.ph-hero .bubble::before { bottom: 30px; }
.ph-hero .bubble .who { display: none; }
.ph-hero .bubble .say { font-size: 15px; margin: 0 0 2px; }
.ph-hero .bubble .sub { font-size: 12px; line-height: 1.5; }
body.phone #phone { padding-bottom: 220px; } /* 下のハコブくんに隠れないだけ空ける */
#ph-hk { cursor: pointer; -webkit-tap-highlight-color: transparent; }
#ph-hk:active { transform: scale(.97); }
/* 表情つきの顔(眉が入るよう、少し大きい範囲)と、体の横の緑の輪の光 */
#ph-hk .face3 { position: absolute; left: 52.055%; top: 9.130%; width: 30.479%; height: 18.696%; }
#ph-hk .ring { position: absolute; left: 2.740%; top: 34.783%; width: 27.740%; height: 29.348%; opacity: .35; pointer-events: none; }
@keyframes breathe { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
@keyframes hkhop { 0% { transform: translateY(0); } 30% { transform: translateY(-16px); } 55% { transform: translateY(0) scaleY(.95); } 70% { transform: translateY(-5px); } 100% { transform: none; } }
@keyframes hknod { 0%, 100% { transform: none; } 30% { transform: translateY(4px) rotate(2deg); } 60% { transform: none; } 75% { transform: translateY(2px) rotate(1deg); } }
@keyframes hkspin { 0% { transform: scaleX(1); } 25% { transform: scaleX(.05); } 50% { transform: scaleX(-1); } 75% { transform: scaleX(.05); } 100% { transform: scaleX(1); } }
@media (prefers-reduced-motion: no-preference) {
#ph-hk .ring { animation: breathe 4.2s ease-in-out infinite; }
#ph-hk.hop { animation: hkhop .7s cubic-bezier(.3, .7, .4, 1) both; }
#ph-hk.nod { animation: hknod 1s ease-in-out both; }
#ph-hk.spin { animation: hkspin 1.1s ease-in-out both; }
}
.ph-hero .bubble .say { text-wrap: balance; }
.ph-sec { display: flex; flex-direction: column; gap: 8px; }
.ph-sec h2 { font-size: 13px; color: var(--muted); font-weight: 500; letter-spacing: .04em; }
/* 横にめくるカード(次のカードの端が見える) */
.swipe { display: flex; gap: 10px; overflow-x: auto; scroll-snap-type: x mandatory; margin-inline: -16px; padding-inline: 16px; scroll-padding-inline: 16px; scrollbar-width: none; overscroll-behavior-x: contain; align-items: flex-start; }
.swipe::-webkit-scrollbar { display: none; }
.pcard { flex: 0 0 calc(100% - 26px); scroll-snap-align: start; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.pcard:only-child { flex-basis: 100%; }
.pcard .k { display: flex; gap: 10px; align-items: baseline; font-size: 12px; color: var(--muted); }
.pcard .k b { color: var(--accent); font-weight: 600; letter-spacing: .04em; }
.pcard h3 { font-size: 20px; line-height: 1.35; }
.pcard .say { margin: 0; font-size: 16px; line-height: 1.65; text-wrap: pretty; }
.pcard .sub, .pcard .hint { margin: 0; font-size: 13px; color: var(--muted); }
.pcard .acts { margin-top: auto; padding-top: 8px; display: flex; flex-direction: column; gap: 8px; }
.pbtn { min-height: 46px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel-2); color: var(--fg); font-size: 15px; font-weight: 600; display: flex; align-items: center; justify-content: center; text-align: center; text-decoration: none; padding: 8px 14px; cursor: pointer; }
.pbtn.primary { background: var(--fg); color: var(--chip-fg); border-color: var(--fg); }
.pbtn:disabled { opacity: .55; }
.prow { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 8px; }
.prow .pbtn { min-height: 40px; font-size: 13px; font-weight: 500; padding: 6px 8px; }
.pmore { border-top: 1px solid var(--line); margin-top: 6px; padding-top: 12px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; }
.pmore .todo { margin-top: 0; }
.pmore .kv { margin-top: 0; }
.pmore textarea { min-height: 72px; font-size: 16px; } /* 16px 未満だと iPhone が拡大してしまう */
.pcount { text-align: center; font-size: 13px; color: var(--muted); min-height: 1.6em; }
.ph-hid summary { font-size: 13px; color: var(--muted); cursor: pointer; text-align: center; list-style: none; }
.ph-hid summary::-webkit-details-marker { display: none; }
.ph-hid .hrow { display: flex; align-items: center; gap: 10px; padding: 10px 2px; border-bottom: 1px solid var(--line); font-size: 14px; }
.ph-hid .hrow .n { flex: 1; min-width: 0; }
.ph-hid .hrow .w { font-size: 12px; color: var(--muted); }
.wcard { flex: 0 0 74%; scroll-snap-align: start; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 9px 14px; display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.wcard:only-child { flex-basis: 100%; }
.wcard .name { font-weight: 600; font-size: 14px; display: flex; justify-content: space-between; gap: 8px; }
.wcard .name .num { font-weight: 400; font-size: 12px; color: var(--muted); white-space: nowrap; }
.wcard .doing { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wcard .bar { height: 3px; border-radius: 2px; background: var(--line); overflow: hidden; margin-top: 2px; }
.wcard .bar i { display: block; height: 100%; background: var(--ok); }
.wcard .meta { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--muted); }
.ph-new > summary { list-style: none; cursor: pointer; display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); font-size: 14px; font-weight: 600; }
.ph-new > summary::-webkit-details-marker { display: none; }
.ph-new > summary .muted { font-weight: 400; font-size: 13px; }
.ph-new[open] > summary { margin-bottom: 10px; }
.swipe .cand { flex: 0 0 82%; scroll-snap-align: start; background: var(--panel); border-radius: 12px; padding: 16px; }
.swipe .cand .btn { font-size: 13px; padding: 8px 12px; }
.back-phone { position: fixed; z-index: 20; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(16px + env(safe-area-inset-bottom, 0px)); align-items: center; gap: 6px; padding: 10px 16px; border-radius: 999px; border: 1px solid var(--fg); background: var(--fg); color: var(--chip-fg); font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: 0 4px 16px rgba(0,0,0,.5); }
@media (max-width: 560px) {
.hk { grid-template-columns: 104px minmax(0, 1fr); gap: 8px; }
.hk-body { --roam: 9px; }
.hk-body { width: 84px; }
.bubble { margin-bottom: 24px; padding: 14px 16px; }
.bubble .say { font-size: 16px; }
h1 { font-size: 22px; }
}
</style>
<div class="wrap">
<header class="top">
<div>
<h1>動画の進み具合</h1>
<div class="when" id="when">フォルダを読んだ結果を待っています</div>
</div>
<button class="voice" id="voice" type="button" aria-pressed="false" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4z"/><path id="voice-wave" d="m22 9-6 6m0-6 6 6"/></svg>
<span id="voice-label">ハコブくんの声:準備中</span>
</button>
</header>
<section class="next-bar" id="next-bar" aria-live="polite">
<span class="nb-lab">次は</span>
<span class="nb-say" id="say">いまの進み具合を読み込んでいます。</span>
<span class="nb-rest" id="say-sub"></span>
<span class="nb-actions actions" id="say-actions"></span>
</section>
<section class="tiles" aria-label="本数(押すと、その動画だけを表に出します)">
<button type="button" class="tile me" data-f="e"><div class="lab">エイイチの番</div><div class="val"><span id="t-e">–</span><small>本</small></div></button>
<button type="button" class="tile" data-f="c"><div class="lab">Claude の番</div><div class="val"><span id="t-c">–</span><small>本</small></div></button>
<button type="button" class="tile" data-f="u"><div class="lab">アップロード待ち</div><div class="val"><span id="t-u">–</span><small>本</small></div></button>
<button type="button" class="tile" data-f="s"><div class="lab">公開予約</div><div class="val"><span id="t-s">–</span><small>本</small></div></button>
<button type="button" class="tile" data-f="p"><div class="lab">公開済み</div><div class="val"><span id="t-p">–</span><small>本</small></div></button>
<button type="button" class="tile" data-f="k"><div class="lab">台本の候補</div><div class="val"><span id="t-k">–</span><small>本</small></div></button>
</section>
<section class="sec" id="live-sec">
<div class="sec-head"><h2>いま動いている作業</h2><span class="note">Claude の会話が、区切りごとにここへ書き込みます</span></div>
<div class="live" id="live"></div>
</section>
<section class="cols">
<div class="sec">
<div class="sec-head"><h2>エイイチの番</h2><span class="note">止まっている順</span></div>
<div class="list" id="list-e"></div>
</div>
<div class="sec">
<div class="sec-head"><h2>Claude の番</h2></div>
<div class="list" id="list-c"></div>
</div>
</section>
<section class="sec" id="detail-sec">
<div class="sec-head"><h2>選んだ動画</h2><span class="note">一覧や表の行を押すと切り替わります</span></div>
<div class="detail">
<div>
<h3 id="d-name">–</h3>
<div class="title" id="d-title"></div>
<div class="todo" id="d-todo"></div>
<dl class="kv" id="d-kv"></dl>
<div class="actions" id="d-actions"></div>
<div class="sched" id="d-sched" hidden>
<label for="sched-date">YouTube で公開予約した日</label>
<div class="actions" style="margin-top:4px">
<input type="date" id="sched-date" min="2026-01-01" max="2099-12-31">
<button class="btn" id="sched-save" type="button">予約した日を記録</button>
<button class="btn" id="sched-clear" type="button" hidden>取り消す</button>
<span class="toast" id="sched-toast"></span>
</div>
</div>
</div>
<div class="memo-box">
<label for="memo-text">直す所のメモ(例:3分16秒の読みがおかしい)</label>
<textarea id="memo-text" placeholder="確認用を見ながら、気づいた所を時間つきで"></textarea>
<div class="actions" style="margin-top:0"><button class="btn primary" id="memo-add" type="button">メモを残す</button><span class="toast" id="memo-toast"></span></div>
<ul class="memos" id="memos"></ul>
</div>
</div>
</section>
<section class="sec">
<div class="sec-head" id="table-sec"><h2 id="table-title">全部の動画と段階</h2><button type="button" class="filter-chip" id="filter-chip" hidden>絞り込みを外す ×</button>
<div class="legend"><span><i class="dot done"></i>済</span><span><i class="dot part"></i>途中</span><span><i class="dot"></i>まだ</span></div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>動画</th><th>誰の番</th><th class="st">台本</th><th class="st">検証</th><th class="st">声</th><th class="st">書き出し</th><th class="st">YouTube素材</th><th class="st">ショート</th><th class="st">投稿文</th><th class="st">公開</th><th>公開日</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</section>
<section class="sec">
<div class="sec-head"><h2>まだ作業フォルダのない台本の候補</h2><span class="note">動画/台本_15本_20261012/</span></div>
<div class="cands" id="cands"></div>
</section>
</div>
<!-- iPhone: ハコブくんの画面。エイイチの番を1件ずつ横にめくる。詳しいことは「くわしく」と「全部見る」で -->
<div id="phone">
<header class="ph-top">
<div class="ph-links">
<button class="ph-chip" id="ph-new-btn" type="button" aria-expanded="false" aria-controls="ph-new">台本の候補<span class="num" id="ph-k"></span></button>
<button class="ph-chip" id="ph-all" type="button">全部見る</button>
</div>
<button class="ph-voice" id="ph-voice" type="button" aria-pressed="false" aria-label="ハコブくんの声" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4z"/><path id="ph-wave" d="m22 9-6 6m0-6 6 6"/></svg>
</button>
</header>
<section class="ph-sec ph-new" id="ph-new" hidden>
<h2>台本の候補から作り始める</h2>
<div class="swipe" id="ph-cands"></div>
</section>
<section class="hk ph-hero" aria-live="polite">
<div class="hk-stage"><div class="hk-body" id="ph-hk"></div></div>
<div class="bubble"><div class="who">ハコブくん</div><div class="say" id="ph-say">読み込んでいます。</div><div class="sub" id="ph-sub"></div></div>
</section>
<section class="ph-sec" id="ph-work-sec" hidden>
<h2>Claude が進めている</h2>
<div class="swipe" id="ph-work"></div>
</section>
<section class="ph-sec" style="margin-top: 48px">
<h2>エイイチの番</h2>
<div class="swipe" id="ph-cards"></div>
<div class="pcount num" id="ph-count"></div>
<div id="ph-hidden"></div>
</section>
</div>
<button class="back-phone" id="back-phone" type="button">ハコブくんの画面に戻る</button>
<script>
(function () {
const STEPS = ['台本', '検証', '声', '書き出し', 'YouTube素材', 'ショート', '投稿文', '公開'];
const STALE_H = 6;
// 知らせの種類(声の決まり文句の名前と同じ)
const KIND = {
session: 0, kakunin: 1, daihon: 2, koe: 3, title: 4, short: 5, upload: 6,
};
// ハコブくんの決まり文句(動画/_ハコブくんのお知らせ/ で作った声。2026-10-16)
const BUILTIN_VOICE = __VOICES__;
const S = { nextIdx: 0, marks: {}, phOpen: new Set(), phDraft: {}, phSig: '', filter: null, sched: {}, status: null, sessions: [], memos: [], voice: Object.assign({}, BUILTIN_VOICE), selected: null, db: null, voiceOn: false, lastSpoken: null, lastSpokenAt: 0, loaded: false };
const $ = (id) => document.getElementById(id);
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
// YouTube の予約は RSS に出ないので、予約した日をページに記録して重ねる(scheduled/all)
const rawRows = () => (S.status && Array.isArray(S.status['動画'])) ? S.status['動画'] : [];
function rows() {
const sc = S.sched || {};
return rawRows().map(r => {
// 予約の時刻を過ぎたら、フォルダの読み直しを待たずに「公開済み → ショートの投稿」に切り替える
const at = r['公開予約の時刻'];
if (at && r['公開'] !== '済' && Date.parse(at) <= Date.now()) {
const sh = r['ショートの数'] || '', made = parseInt(sh, 10) || 0;
const m = /公開 (\d+)(?:・予約 (\d+))?/.exec(sh), pub = m ? +m[1] : 0, posted = pub + (m && m[2] ? +m[2] : 0);
const o = Object.assign({}, r, { '公開': '済', '書き出し': '済', 'YouTube素材': '済', '公開予約': '',
'公開日': new Date(at).toLocaleDateString('sv-SE', { timeZone: 'Asia/Tokyo' }), 'URL': r['予約のURL'] || r['URL'] || '' });
if (made && posted < made) Object.assign(o, { '段階': '公開済み', '誰の番': 'エイイチ', '次にやること': `ショートを投稿する(${pub}/${made}本 公開済み)` });
else if (made < 3) Object.assign(o, { '段階': 'ショート', '誰の番': 'Claude', '次にやること': 'ショートを3本作る' });
else Object.assign(o, { '段階': '完了', '誰の番': '—', '次にやること': '完了(X・LinkedIn の投稿だけ手で確かめる)' });
return o;
}
const d = r['公開予約'] || sc[r['動画']]; // YouTube の一覧(youtube_schedule.py)の日を先に。なければ手で記録した日
if (!d || r['公開'] === '済') return r;
const o = Object.assign({}, r, { '公開予約': d, '書き出し': '済', 'YouTube素材': '済' });
if (r['ショート'] !== '済') Object.assign(o, { '段階': 'ショート', '誰の番': 'Claude', '次にやること': `ショートを3本作る(本編は ${d} に公開予約済み)` });
else if (r['投稿文'] !== '済') Object.assign(o, { '段階': '投稿文', '誰の番': 'Claude', '次にやること': `X・LinkedIn の投稿文を作る(本編は ${d} に公開予約済み)` });
else Object.assign(o, { '段階': '予約済み', '誰の番': '—', '次にやること': `${d} に公開予定(待つだけ)` });
return o;
});
}
const memosFor = (v) => S.memos.filter(m => m.video === v);
const openMemos = (v) => memosFor(v).filter(m => !m.done);
function ago(iso) {
const t = Date.parse(iso); if (!t) return '';
const m = Math.round((Date.now() - t) / 60000);
if (m < 1) return 'いま'; if (m < 60) return m + '分前';
const h = Math.round(m / 60); if (h < 24) return h + '時間前';
return Math.round(h / 24) + '日前';
}
const stale = (s) => !s['終了'] && (Date.now() - Date.parse(s['更新'] || 0)) > STALE_H * 3600e3;
const liveSessions = () => S.sessions.filter(s => !s['終了']).sort((a, b) => String(b['更新']).localeCompare(String(a['更新'])));
// エイイチの番を、止まっている順に並べる(say は PC の帯用、phSay は iPhone のカード用で動画名を含まない)
function allItems() {
const out = [];
for (const s of liveSessions()) {
if (s['番'] === 'エイイチ') out.push({ video: s['動画'], kind: 'session', voiceKind: s['声キー'] || 'session', prio: KIND.session, session: s,
stage: s['待ち'] || '', phSay: s['待ち'] || '会話で返事を待っています',
say: `「${s['動画']}」:${s['待ち'] || '会話で返事を待っています'}`, sub: s['作業'] || '' });
}
const taken = new Set(out.map(i => i.video));
const rs = rows().filter(r => r['誰の番'] === 'エイイチ' && !taken.has(r['動画']));
for (const r of rs) {
const v = r['動画'], st = r['段階'];
let it;
if (st === '確認') it = { kind: 'kakunin', phSay: '確認用ができています。通しで見て、直す所を教えてください。' };
else if (st === '台本') it = { kind: 'daihon', phSay: '台本を確かめて、codex にかけるか決めてください。' };
else if (st === '声') it = { kind: 'koe', phSay: '声を作る番です。', sub: r['声の数'] ? `いま ${r['声の数']} 文` : '' };
else if (st === '公開済み') it = { kind: 'short', phSay: '本編が公開されました。ショートを投稿して、関連動画に本編をつないでください。', sub: r['ショートの数'] || '' };
else if (st === '公開待ち') it = { kind: 'upload', phSay: 'YouTube へのアップロード待ちです。', sub: '予約済みなら待つだけです' };
else it = { kind: 'other', phSay: r['次にやること'] || '' };
if (it.kind === 'short') it.say = `「${v}」の本編が公開されました。ショートを投稿してください。`;
else it.say = it.kind === 'other' ? `「${v}」:${it.phSay}` : `「${v}」${/^(声|YouTube)/.test(it.phSay) ? 'は' : 'の'}${it.phSay}`;
out.push(Object.assign({ video: v, prio: KIND[it.kind] ?? KIND.session, row: r, sub: '', stage: it.kind === 'short' ? st + '|' + (r['ショートの数'] || '') : st }, it));
}
for (const it of out) if (!it.voiceKind) it.voiceKind = it.kind === 'other' ? 'session' : it.kind;
out.sort((a, b) => a.prio - b.prio || (a.kind === 'upload'
? String(a.row?.['最終更新']).localeCompare(String(b.row?.['最終更新']))
: String(b.row?.['最終更新'] || b.session?.['更新']).localeCompare(String(a.row?.['最終更新'] || a.session?.['更新']))));
return out;
}
// 「あとで」「済んだ」の印(marks)。段階が変わったら印は効かなくなり、また出てくる
function markId(it) { let h = 7; for (const ch of it.video + '|' + it.kind) h = (h * 31 + ch.codePointAt(0)) >>> 0; return 'm' + h.toString(36); }
function markOf(it) {
const m = S.marks[markId(it)];
if (!m || m['段階'] !== it.stage) return null;
if (m['何'] === 'done') return m;
if (m['何'] === 'later' && Date.parse(m['まで']) > Date.now()) return m;
return null;
}
const queue = () => allItems().filter(it => !markOf(it));
const hiddenItems = () => allItems().filter(it => markOf(it));
async function setMark(it, what, until) {
if (!S.db) throw new Error('no db');
await S.db.doc('marks/' + markId(it)).set({ 動画: it.video, 種類: it.kind, 段階: it.stage, 何: what, まで: until || null, at: new Date().toISOString() });
}
const hhmm = (iso) => { const d = new Date(iso); return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`; };
function instruction(v) {
const r = rows().find(x => x['動画'] === v);
let t = `「${v}」の続きを進めてください。`;
if (r) t += `いまの段階は「${r['段階']}」、次は「${r['次にやること']}」です。`;
const n = openMemos(v).length;
if (n) t += `進み具合のページに、直す所のメモが ${n} 件あります。`;
return t;
}
async function copy(text, toastEl) {
try { await navigator.clipboard.writeText(text); toastEl.textContent = 'コピーしました'; }
catch (e) { toastEl.textContent = text; }
setTimeout(() => { if (toastEl.textContent === 'コピーしました') toastEl.textContent = ''; }, 2500);
}
function actionButtons(box, v, session) {
box.replaceChildren();
if (!v) return;
const toast = el('span', 'toast');
const c = el('button', 'btn primary', '依頼文をコピー'); c.type = 'button';
c.addEventListener('click', () => copy(instruction(v), toast));
box.append(c);
const s = session || liveSessions().find(x => x['動画'] === v);
if (s && /^https:\/\/claude\.ai\//.test(s['会話'] || '')) {
const a = el('a', 'btn', 'その会話を開く'); a.href = s['会話']; a.target = '_blank'; a.rel = 'noopener';
box.append(a);
}
box.append(toast);
}
function select(v) { S.selected = v; renderDetail(); renderLists(); renderTable(); }
// ---------- 描画 ----------
function renderHakobu() {
const q = queue();
const say = $('say'), sub = $('say-sub');
if (!S.loaded) return;
if (!q.length) {
say.textContent = 'いまはありません。';
sub.textContent = liveSessions().length ? 'Claude が作業を進めています' : '';
actionButtons($('say-actions'), null);
return;
}
// 何件目を見ているか(‹ › で切り替え)。データが変わって件数が減ったら収める
S.nextIdx = Math.min(S.nextIdx || 0, q.length - 1);
const it = q[S.nextIdx];
say.textContent = it.say;
sub.replaceChildren();
if (q.length > 1) {
const prev = el('button', 'pg', '‹'); prev.type = 'button'; prev.setAttribute('aria-label', '前の件');
const next = el('button', 'pg', '›'); next.type = 'button'; next.setAttribute('aria-label', '次の件');
prev.addEventListener('click', () => { S.nextIdx = (S.nextIdx - 1 + q.length) % q.length; renderHakobu(); });
next.addEventListener('click', () => { S.nextIdx = (S.nextIdx + 1) % q.length; renderHakobu(); });
sub.append(prev, el('span', 'num', `${S.nextIdx + 1} / ${q.length}`), next);
}
actionButtons($('say-actions'), it.video, it.session);
const more = el('button', 'btn', '詳しく見る'); more.type = 'button';
more.addEventListener('click', () => { select(it.video); $('detail-sec').scrollIntoView({ behavior: 'smooth', block: 'start' }); });
$('say-actions').insertBefore(more, $('say-actions').lastChild);
maybeSpeak(q[0]); // 声で知らせるのは、いちばん止まっている1件だけ
}
function renderTiles() {
const r = rows();
$('t-e').textContent = queue().length;
$('t-c').textContent = r.filter(x => x['誰の番'] === 'Claude').length;
$('t-u').textContent = r.filter(x => x['段階'] === '公開待ち').length;
$('t-p').textContent = r.filter(x => x['公開'] === '済').length;
$('t-s').textContent = r.filter(x => x['公開予約']).length;
$('t-k').textContent = (S.status?.['台本の候補'] || []).length;
$('when').textContent = S.status?.['作成'] ? `フォルダを読んだ時刻:${S.status['作成']}` : 'まだフォルダを読んでいません。Claude に「進み具合を更新して」と頼んでください';
}
function renderLive() {
const box = $('live'); box.replaceChildren();
MINIS.length = 0;
const ls = liveSessions();
if (!ls.length) { box.append(el('div', 'empty', 'いま動いている会話はありません。動画の作業を始めると、ここに会話ごとのカードが出ます。')); return; }
for (const s of ls) {
const working = s['番'] !== 'エイイチ' && !stale(s);
const waiting = s['番'] === 'エイイチ';
const c = el('div', 'card' + (stale(s) ? ' stale' : '') + (working ? ' working' : '') + (waiting ? ' waiting' : ''));
c.append(el('div', 'name', s['動画'] || '(動画名なし)'));
// Claude が作業中なら行き来し、エイイチの番なら左で待つ
// 道のり: この動画の段階のどこまで来ているか
const r = rows().find(x => x['動画'] === s['動画']);
const nowIdx = r ? Math.max(0, STEPS.findIndex(st => r[st] !== '済')) : 0;
const doneAll = r && STEPS.every(st => r[st] === '済');
const at = doneAll ? STEPS.length - 1 : nowIdx;
const pos = (i) => `calc(var(--pad) + (100% - var(--pad) * 2) * ${i / (STEPS.length - 1)})`;
const track = el('div', 'track');
const rail = el('div', 'rail'); const fill = el('i'); fill.style.width = `${at / (STEPS.length - 1) * 100}%`; rail.append(fill); track.append(rail);
STEPS.forEach((st, i) => { const d = el('span', 'stop' + (i < at || doneAll ? ' done' : i === at ? ' now' : '')); d.style.left = pos(i); d.title = st; track.append(d); });
const mini = el('div', 'mini'); mini.style.left = pos(at);
const mb = el('img'); mb.src = HK_BODY; mb.alt = '';
const mf = el('img', 'face'); mf.src = FACES.open_mid; mf.alt = '';
mini.append(mb, mf); track.append(mini);
// 区切りまで来て止まったら、ハコブくんの頭の上に吹き出しを出す(角がハコブくんを指す)
if (waiting) {
const b = el('div', 'mbubble');
b.style.setProperty('--ax', `max(14px, min(calc(100% - 14px), ${pos(at)}))`);
b.append(el('span', 't', '作業が止まっています。次は'), el('b', null, s['待ち'] || 'エイイチさんの返事を待っています'));
c.append(b); track.classList.add('has-bubble');
}
c.append(track);
if (r) {
const pl = el('div', 'progress-label');
const left = el('span'); left.append(document.createTextNode('いま:'), el('b', null, doneAll ? '全部済み' : STEPS[at]));
pl.append(left, el('span', 'num', `${at + (doneAll ? 1 : 0)} / ${STEPS.length} 済`));
c.append(pl);
}
MINIS.push({ img: mf, el: mini, track, id: s.id, video: s['動画'], at, working, mouth: 'mid', nextBlink: Date.now() + 800 + Math.random() * 2500, blinkUntil: 0, nextMouth: 0 });
const doing = el('div', 'doing', s['作業'] || ''); if (working) doing.append(el('span', 'ell')); c.append(doing);
const meta = el('div', 'meta');
const who = s['番'] === 'エイイチ' ? el('span', 'chip e', 'エイイチの番') : el('span', 'chip c');
if (s['番'] !== 'エイイチ') { who.append(el('span', 'live-dot'), document.createTextNode(' Claude が作業中')); }
meta.append(who);
if (s['待ち'] && !waiting) meta.append(el('span', null, '待っているもの:' + s['待ち']));
meta.append(el('span', 'num', ago(s['更新'])));
if (stale(s)) meta.append(el('span', 'chip warn', '止まっているかも'));
c.append(meta);
const acts = el('div', 'actions'); acts.style.marginTop = '6px';
actionButtons(acts, s['動画'], s);
// 会話で返事をして作業を再開したら、ページの側からも Claude の番に戻せる
if (waiting && S.db) {
const resume = el('button', 'btn', '返事した(再開)'); resume.type = 'button';
resume.addEventListener('click', async () => {
resume.disabled = true;
try { await S.db.doc('sessions/' + s.id).update({ 番: 'Claude', 待ち: '', 作業: 'エイイチの返事を受けて作業を再開', 更新: new Date().toISOString() }); }
catch (e) { resume.disabled = false; resume.textContent = '書き換えられませんでした'; }
});
acts.insertBefore(resume, acts.lastChild);
}
c.append(acts);
box.append(c);
}
}
function listRow(v, next, chips) {
const b = el('button', 'row'); b.type = 'button';
if (S.selected === v) b.setAttribute('aria-current', 'true');
b.append(el('span', 'name', v));
const right = el('span', 'right'); chips.forEach(c => right.append(c)); b.append(right);
b.append(el('span', 'next', next));
b.addEventListener('click', () => select(v));
return b;
}
function renderLists() {
const le = $('list-e'), lc = $('list-c');
le.replaceChildren(); lc.replaceChildren();
const q = queue();
if (!q.length) le.append(el('div', 'empty', S.loaded ? 'いまはありません' : '読み込み中'));
const ups = q.filter(it => it.kind === 'upload');
for (const it of q.filter(it => it.kind !== 'upload')) {
const chips = []; const n = openMemos(it.video).length;
if (n) chips.push(el('span', 'chip memo', 'メモ ' + n));
if (it.session) chips.push(el('span', 'chip e', '会話で待機中'));
le.append(listRow(it.video, it.row ? it.row['次にやること'] : (it.session['待ち'] || ''), chips));
}
// アップロード待ちは1行にまとめ、開くと名前が出る
if (ups.length) {
const d = el('details', 'ups'); if (ups.some(it => it.video === S.selected)) d.open = true;
const sm = el('summary', 'row');
sm.append(el('span', 'name', `YouTube へのアップロード待ち ${ups.length} 本`));
sm.append(el('span', 'right'));
sm.append(el('span', 'next', 'YouTube で公開予約したら、動画を選んで「予約した日」を記録してください'));
d.append(sm);
for (const it of ups) {
const chips = []; const n = openMemos(it.video).length;
if (n) chips.push(el('span', 'chip memo', 'メモ ' + n));
const r = listRow(it.video, '最終更新 ' + (it.row?.['最終更新'] || ''), chips); r.classList.add('sub');
d.append(r);
}
le.append(d);
}
for (const it of hiddenItems()) {
const m = markOf(it);
const r = listRow(it.video, it.phSay, [el('span', 'chip', m['何'] === 'done' ? '済んだと記録' : `あとで(${hhmm(m['まで'])}まで)`)]);
r.style.opacity = '.6'; le.append(r);
}
const cs = rows().filter(r => r['誰の番'] === 'Claude');
if (!cs.length) lc.append(el('div', 'empty', S.loaded ? 'いまはありません' : '読み込み中'));
for (const r of cs) {
const chips = []; const n = openMemos(r['動画']).length;
if (n) chips.push(el('span', 'chip memo', 'メモ ' + n));
lc.append(listRow(r['動画'], r['次にやること'], chips));
}
}
function renderDetail() {
const v = S.selected;
const r = rows().find(x => x['動画'] === v);
$('d-name').textContent = v || '動画を選んでください';
$('d-title').textContent = r && r['タイトル'] !== v ? r['タイトル'] : '';
// 終わっていない段階だけを、表と同じ印で並べる
const todo = $('d-todo'); todo.replaceChildren();
if (r) {
const left = STEPS.filter(st => r[st] !== '済');
if (!left.length) todo.append(el('span', 'all', '全部の段階が済んでいます'));
else {
todo.append(el('span', 'lab', 'まだの段階'));
for (const st of left) {
const v = r[st] || '未';
const chip = el('span', 'step');
chip.append(el('i', 'dot' + (v === '途中' ? ' part' : '')), document.createTextNode(st), el('span', 'st', v === '未' ? 'まだ' : v));
todo.append(chip);
}
}
}
const kv = $('d-kv'); kv.replaceChildren();
if (r) {
const add = (k, val) => { if (!val) return; kv.append(el('dt', null, k)); kv.append(el('dd', null, val)); };
add('段階', r['段階']); add('次にやること', r['次にやること']); add('誰の番', r['誰の番']);
add('声', r['声の数']); add('ショート', r['ショートの数']); add('公開日', r['公開日']); add('最終更新', r['最終更新']);
if (r['URL']) { kv.append(el('dt', null, 'YouTube')); const dd = el('dd'); const a = el('a', null, '動画を開く'); a.href = r['URL']; a.target = '_blank'; a.rel = 'noopener'; a.style.color = 'var(--fg)'; dd.append(a); kv.append(dd); }
}
actionButtons($('d-actions'), v);
const sf = $('d-sched'); sf.hidden = !(r && r['公開'] !== '済' && S.db);
if (!sf.hidden) { $('sched-date').value = (S.sched || {})[v] || ''; $('sched-clear').hidden = !(S.sched || {})[v]; }
$('memo-add').disabled = !v || !S.db;
const ul = $('memos'); ul.replaceChildren();
const ms = memosFor(v).sort((a, b) => String(b.at).localeCompare(String(a.at)));
if (!S.db) ul.append(el('li', null, 'メモは、このページを claude.ai で開いたときに使えます'));
for (const m of ms) {
const li = el('li', m.done ? 'done' : '');
const cb = el('input'); cb.type = 'checkbox'; cb.checked = !!m.done; cb.setAttribute('aria-label', '直した');
cb.addEventListener('change', async () => { try { await S.db.doc('memos/' + m.id).update({ done: cb.checked }); } catch (e) { cb.checked = !cb.checked; } });
li.append(cb, el('span', 't', m.text), el('span', 'at', (m.done ? '直した・' : '') + ago(m.at)));
ul.append(li);
}
}
function renderTable() {
const tb = $('tbody'); tb.replaceChildren();
const order = { 'エイイチ': 0, 'Claude': 1, '—': 2 };
const FL = { e: ['エイイチの番', r => qset.has(r['動画'])], c: ['Claude の番', r => r['誰の番'] === 'Claude'],
u: ['アップロード待ち', r => r['段階'] === '公開待ち'], s: ['公開予約', r => r['公開予約'] && r['公開'] !== '済'], p: ['公開済み', r => r['公開'] === '済'] };
const qset = new Set(queue().map(i => i.video));
const f = FL[S.filter];
$('table-title').textContent = f ? `${f[0]}の動画` : '全部の動画と段階';
$('filter-chip').hidden = !f;
document.querySelectorAll('button.tile').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.f === S.filter)));
const rs = rows().filter(r => !f || f[1](r)).sort((a, b) => (order[a['誰の番']] ?? 3) - (order[b['誰の番']] ?? 3) || String(b['最終更新']).localeCompare(String(a['最終更新'])));
for (const r of rs) {
const tr = el('tr'); if (S.selected === r['動画']) tr.style.background = 'var(--panel-2)';
tr.append(el('td', null, r['動画']));
const w = el('td'); const who = r['誰の番'];
const fin = who === '—' && r['段階'] !== '予約済み';
w.append(el('span', 'chip ' + (who === 'エイイチ' ? 'e' : who === 'Claude' ? 'c' : fin ? 'fin' : 'done'), who === '—' ? (fin ? '完了' : '予約済み') : who)); tr.append(w);
for (const st of STEPS) {
const td = el('td', 'st'); const v = r[st];
const d = el('i', 'dot' + (v === '済' ? ' done' : v === '途中' ? ' part' : '')); d.title = st + ':' + v; d.setAttribute('aria-label', st + ' ' + v);
td.append(d); tr.append(td);
}
tr.append(el('td', 'muted num', r['公開日'] || (r['公開予約'] ? r['公開予約'] + ' 予約' : '')));
tr.addEventListener('click', () => { select(r['動画']); $('detail-sec').scrollIntoView({ behavior: 'smooth', block: 'start' }); });
tb.append(tr);
}
}
function renderCands() {
const cs = S.status?.['台本の候補'] || [];
for (const id of ['cands', 'ph-cands']) {
const box = $(id); const keep = box.scrollLeft; box.replaceChildren();
for (const c of cs) box.append(candCard(c));
box.scrollLeft = keep;
}
$('ph-k').textContent = String(cs.length);
}
function candCard(c) {
const reqs = S.requests || {};
{
const d = el('div', 'cand');
const t = el('span'); t.append(el('b', null, c['番号']), document.createTextNode((c['見出し'] || c['名前']).replace(/^台本:/, '')));
const go = el('div', 'go');
d.append(t, go);
const req = reqs['r' + c['番号']];
const recent = req && (Date.now() - Date.parse(req.at || 0)) < 24 * 3600e3;
const file = `${c['番号']}_${c['名前']}.md`;
const text = `台本_15本_20261012 の ${file} から、解説動画を作り始めてください。`;
if (recent) {
d.classList.add('requested');
go.textContent = `${ago(req.at)}に作り始めを頼みました。「いま動いている作業」に出ます`;
} else {
const b1 = el('button', 'btn primary', 'この台本で作り始める'); b1.type = 'button';
const b2 = el('button', 'btn', '依頼文をコピー'); b2.type = 'button';
const msg = el('span', 'toast');
const acts = el('div', 'actions'); acts.style.marginTop = '0'; acts.append(b1, b2, msg);
go.append(acts);
b2.addEventListener('click', () => copy(text + 'p2m-explainer-video の手順で、台本(narration.py)まで進めて、確かめてほしい所で止まってください。', msg));
b1.addEventListener('click', () => {
// 押し間違えても動かないよう、ページの中で1回確かめる
acts.replaceChildren(el('span', 'ask', 'Claude が新しい会話で台本まで作ります。始めますか?'));
const yes = el('button', 'btn primary', '始める'); yes.type = 'button';
const no = el('button', 'btn', 'やめる'); no.type = 'button';
acts.append(yes, no, msg);
no.addEventListener('click', () => renderCands());
yes.addEventListener('click', () => startVideo(c, text, acts, msg));
});
}
return d;
}
}
function renderAll() {
if (!S.selected) { const q = queue(); S.selected = q[0]?.video || rows()[0]?.['動画'] || null; }
renderTiles(); renderHakobu(); renderLive(); renderLists(); renderDetail(); renderTable(); renderCands(); renderVoice(); renderPhone();
checkPublished();
}
// ---------- 動画が公開されたら、ハコブくんが走り回って知らせる ----------
// 知らせた動画はこの端末に覚えておく(同じ動画で2回走らない)。初めて開いたときは、今日公開された分だけ知らせる
const ANN_KEY = 'p2m-announced';
function loadAnn() { try { const v = localStorage.getItem(ANN_KEY); return v ? new Set(JSON.parse(v)) : null; } catch (e) { return null; } }
function saveAnn(set) { try { localStorage.setItem(ANN_KEY, JSON.stringify([...set].slice(-200))); } catch (e) {} }
function checkPublished() {
if (!S.status) return;
const pub = rows().filter(r => r['公開'] === '済');
if (!S.ann) {
S.ann = loadAnn();
if (!S.ann) { // 初めて: 今日より前に公開された分は知らせ済みにする
const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Tokyo' });
S.ann = new Set(pub.filter(r => r['公開日'] !== today).map(r => r['動画']));
}
}
const fresh = pub.filter(r => !S.ann.has(r['動画']));
// 見えていないとき(別のタブ・画面が消えている)は走らず、見えたときに走る
if (!fresh.length || S.parading || document.hidden) return;
fresh.forEach(r => S.ann.add(r['動画'])); saveAnn(S.ann);
const r = fresh[0];
const more = fresh.length > 1 ? `(ほかに ${fresh.length - 1} 本)` : '';
const next = r['誰の番'] === 'エイイチ' && r['段階'] === '公開済み' ? 'ショートを投稿しましょう' : 'おつかれさまでした';
parade(`「${r['動画']}」が公開されました!${more}`, next);
}
document.addEventListener('visibilitychange', () => { if (!document.hidden) checkPublished(); });
function parade(text, sub) {
S.parading = true; document.body.classList.add('parading');
const box = el('div', 'parade'); const pb = el('div', 'pb');
const b = el('img'); b.src = HK_BODY; b.alt = '';
const f = el('img', 'face3'); f.src = FACES3.joy; f.alt = '';
pb.append(b, f); box.append(pb);
const say = el('div', 'parade-say'); say.setAttribute('role', 'status'); say.append(document.createTextNode(text), el('small', null, sub));
document.body.append(box, say);
if (S.voiceOn) play('short', null);
const DUR = reduce ? 4000 : 7500; let t0 = null;
let faceT = 0;
const step = (t) => {
if (t0 === null) t0 = t; // 最初に描けたときから数える
const e = t - t0;
const w = box.offsetWidth, span = Math.max(0, window.innerWidth - w - 16);
if (!reduce) {
// 左右に2往復。走る速さに強弱をつけ、跳ねながら行く
const ph = (e / DUR) * 2, k = ph % 1, goingRight = Math.floor(ph) % 2 === 0;
const ease = k < .5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
const x = 8 + span * (goingRight ? ease : 1 - ease);
const y = -Math.abs(Math.sin(e / 140)) * 10;
box.style.transform = `translate(${x}px, ${y}px)`;
pb.style.transform = `scaleX(${goingRight ? 1 : -1})`;
if (e - faceT > 280) { faceT = e; f.src = f.src === FACES3.joy ? FACES3.open_large : FACES3.joy; }
} else box.style.transform = 'translate(16px, 0)';
if (e < DUR) requestAnimationFrame(step);
else {
box.remove(); say.remove(); S.parading = false; document.body.classList.remove('parading');
if (phoneView) react('joy', text.replace(/(ほかに.*?)/, ''), 'hop', 3000);
checkPublished(); // 続けて公開されたものがあれば、もう一度
}
};
requestAnimationFrame(step);
}
// ---------- ハコブくんの顔(動画の hakobu.py と同じ顔。体は動かさない) ----------
const FACES = __HK_FACES__;
const HK_BODY = '__HK_BODY__';
const FACES3 = __HK_FACES3__; // 表情つき(iPhone の大きなハコブくん用)
const HK_RING = '__HK_RING__';
// ---------- カードの中の小さなハコブくんの走り ----------
// 済んだ区間を、止まる・ゆっくり・ふつう・急ぐ を気まぐれに混ぜて行き来する
const RUN = {}; // 会話ごとの位置(カードを描き直しても続きから走る)
const PAD = 19;
function planNext(st, lo, hi) {
const r = Math.random();
if (r < 0.22) { st.pauseUntil = performance.now() + 600 + Math.random() * 2200; return; } // 立ち止まる
const span = Math.max(hi - lo, 1);
// 端まで行くことも、少しだけ動くこともある
st.target = Math.random() < 0.4 ? (st.x - lo > span / 2 ? lo : hi) : lo + Math.random() * span;
const kind = Math.random();
st.speed = kind < 0.3 ? 14 + Math.random() * 10 : kind < 0.8 ? 32 + Math.random() * 20 : 80 + Math.random() * 50; // px/秒
}
let lastT = performance.now();
function runLoop(t) {
const dt = Math.min(0.1, (t - lastT) / 1000); lastT = t;
for (const m of MINIS) {
if (!m.working || reduce) continue;
const w = m.track.clientWidth; if (!w) continue;
const step = (w - PAD * 2) / (STEPS.length - 1);
let lo = PAD, hi = PAD + step * m.at;
if (m.at === 0) { lo = PAD - 4; hi = PAD + 18; } // まだ1つ目の段階なら、スタートのあたりだけ
const st = RUN[m.id] || (RUN[m.id] = { x: hi, target: hi, speed: 30, pauseUntil: 0, dir: -1 });
st.x = Math.min(hi, Math.max(lo, st.x));
if (t < st.pauseUntil) { /* 止まっている */ }
else if (Math.abs(st.target - st.x) < 0.5) planNext(st, lo, hi);
else {
const d = st.target - st.x;
// 動き出しと止まり際はゆっくり
const ease = Math.min(1, Math.abs(d) / 14, 0.25 + Math.abs(st.x - (st.from ?? st.x)) / 14);
const v = st.speed * Math.max(0.2, ease) * dt;
st.x += Math.sign(d) * Math.min(Math.abs(d), v);
st.dir = Math.sign(d) || st.dir;
}
if (Math.abs(st.target - st.x) >= 0.5 && st.from === undefined) st.from = st.x;
if (Math.abs(st.target - st.x) < 0.5) st.from = undefined;
m.el.style.left = st.x + 'px';
m.el.style.transform = `scaleX(${st.dir < 0 ? -1 : 1})`;
}
requestAnimationFrame(runLoop);
}
requestAnimationFrame(runLoop);
// カードの中の小さなハコブくんの顔: まばたきと、作業中は口ずさむように口が少し動く
const MINIS = [];
const HEROES = []; // iPhone の画面の大きなハコブくん(声に合わせて口を動かす)
setInterval(() => {
if (reduce) return;
const now = Date.now();
for (const m of MINIS.concat(HEROES)) {
if (now > m.nextBlink) { m.blinkUntil = now + 130; m.nextBlink = now + 2000 + Math.random() * 2500; }
if (m.working && now > m.nextMouth) {
m.mouth = ['closed', 'small', 'mid', 'mid', 'small', 'large'][Math.floor(Math.random() * 6)];
m.nextMouth = now + 350 + Math.random() * 900;
} else if (!m.working) m.mouth = 'mid';
// 声で話しているあいだは、その動画のカードのハコブくん(なければ全部)の口を声に合わせる
const speaking = F.talking && (m.hero || m.video === F.video || !MINIS.some(x => x.video === F.video));
if (m.hero) {
const bl = now < m.blinkUntil, ex = heroExpr();
const hk = speaking ? (bl ? 'blink' : 'open') + '_' + F.mouth
: ex === 'joy' ? 'joy' : ex === 'smile' ? (bl ? 'smile_blink' : 'smile_open') : ex === 'trouble' ? (bl ? 'trouble_blink' : 'trouble_open') : (bl ? 'blink_mid' : 'open_mid');
if (hk !== m.shown) { m.shown = hk; m.img.src = FACES3[hk]; }
continue;
}
const k = (now < m.blinkUntil ? 'blink' : 'open') + '_' + (speaking ? F.mouth : m.mouth);
if (k !== m.shown) { m.shown = k; m.img.src = FACES[k]; }
}
}, 100);
const LADDER = ['closed', 'small', 'mid', 'large'];
const F = { eyes: 'open', mouth: 'mid', blinkUntil: 0 };
const reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
// 声で話すときは、カードの中の小さなハコブくんの口を動かす(話している動画のカード。なければ全部)
function setFace() { /* 口の形は F.mouth に入れ、カードの顔の更新(100ミリ秒ごと)が拾う */ }
// 口: 音量で4段階。2コマごとに1段ずつしか変えない(動画と同じ)
let talkTimer = null;
function talk(levelFn, until) {
clearInterval(talkTimer);
let step = 2;
F.talking = true;
talkTimer = setInterval(() => {
if (Date.now() > until()) { clearInterval(talkTimer); F.talking = false; F.mouth = 'mid'; setFace(); return; }
const target = levelFn();
step += Math.max(-1, Math.min(1, target - step));
F.mouth = LADDER[step]; setFace();
}, 66);
}
// 声を出さないときも、知らせが変わったら少しだけ口を動かす
function fakeTalk(ms) {
if (reduce) return;
const end = Date.now() + ms;
talk(() => [0, 1, 2, 3, 2, 1][Math.floor(Math.random() * 6)], () => end);
}
// ---------- ハコブくんの声 ----------
// 開くたびに「オフ」から始まる。オンのときだけ、知らせが変わった時に1回だけ話す
function hasVoice() { return Object.keys(S.voice || {}).some(k => typeof S.voice[k] === 'string' && S.voice[k]); }
function renderVoice() {
const b = $('voice'); const ready = hasVoice();
b.disabled = !ready;
b.setAttribute('aria-pressed', String(S.voiceOn));
$('voice-label').textContent = !ready ? 'ハコブくんの声:準備中' : (S.voiceOn ? 'ハコブくんの声:オン' : 'ハコブくんの声:オフ');
$('voice-wave').setAttribute('d', S.voiceOn ? 'M15.5 8.5a5 5 0 0 1 0 7M19 5a10 10 0 0 1 0 14' : 'm22 9-6 6m0-6 6 6');
const pb = $('ph-voice'); pb.disabled = !ready; pb.setAttribute('aria-pressed', String(S.voiceOn));
pb.setAttribute('aria-label', S.voiceOn ? 'ハコブくんの声:オン' : 'ハコブくんの声:オフ');
$('ph-wave').setAttribute('d', $('voice-wave').getAttribute('d'));
}
let actx = null;
const bufCache = {};
function b64ToBuf(uri) {
const bin = atob(uri.slice(uri.indexOf(',') + 1)); const u = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
return u.buffer;
}
// 音は <audio> で鳴らす。iPhone は、ページの音(Web Audio)を消音スイッチで止めるが、
// <audio> の再生は「メディア」として扱うので、消音にしていても鳴る。
// 口の動きは、同じ声を一度ほどいて、33ミリ秒ごとの大きさを前もって測っておき、再生の位置に合わせて読む
const AUD = new Audio(); AUD.preload = 'auto'; AUD.setAttribute('playsinline', '');
const envCache = {};
async function envelope(kind, src) {
if (envCache[kind]) return envCache[kind];
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
const buf = await actx.decodeAudioData(b64ToBuf(src));
const ch = buf.getChannelData(0), step = Math.max(1, Math.round(buf.sampleRate * 0.033));
const env = [];
for (let i = 0; i < ch.length; i += step) { let sum = 0; const e = Math.min(ch.length, i + step); for (let k = i; k < e; k++) sum += ch[k] * ch[k]; env.push(Math.sqrt(sum / (e - i))); }
const sorted = env.slice().sort((a, b) => a - b); const ref = sorted[Math.floor(sorted.length * 0.95)] || 1;
return (envCache[kind] = env.map(v => { const l = v / ref; return l < 0.15 ? 0 : l < 0.35 ? 1 : l < 0.65 ? 2 : 3; }));
}
async function play(kind, video) {
F.video = video;
const src = S.voice[kind] || S.voice.session;
if (!src || !src.startsWith('data:')) { fakeTalk(1500); return; }
try {
AUD.pause(); AUD.src = src; AUD.currentTime = 0;
const p = AUD.play(); // 押した瞬間に鳴らし始める(iPhone は押したときでないと鳴らせない)
let env = null; envelope(kind, src).then(e => { env = e; }).catch(() => {});
await p;
talk(() => env ? (env[Math.floor(AUD.currentTime / 0.033)] ?? 0) : [1, 2, 3, 2][Math.floor(Date.now() / 140) % 4],
() => (AUD.ended || AUD.paused) ? 0 : Infinity);
} catch (e) { fakeTalk(1500); }
}
function maybeSpeak(top) {
const sig = top.kind + '|' + top.video;
if (sig === S.lastSpoken) return;
const first = S.lastSpoken === null;
S.lastSpoken = sig;
if (first) return;
if (!S.voiceOn) { fakeTalk(1400); return; }
if (Date.now() - S.lastSpokenAt < 60e3) return; // 1分以内に続けては話さない
S.lastSpokenAt = Date.now();
play(top.voiceKind, top.video);
}
function toggleVoice() {
S.voiceOn = !S.voiceOn; renderVoice();
if (S.voiceOn) { const q = queue(); S.lastSpokenAt = Date.now(); play(q[0]?.voiceKind || 'owari', q[0]?.video); }
}
$('voice').addEventListener('click', toggleVoice);
$('ph-voice').addEventListener('click', toggleVoice);
// ---------- 台本の候補から作り始める(予定のタスク「動画を作り始める」を今すぐ動かす) ----------
const START_TASK = 'trig_013iuMmKsuvMU5wz7LrCs28Q';
const MCP_SERVER = 'Claude Code Remote';
async function startVideo(c, text, acts, msg) {
acts.querySelectorAll('button').forEach(b => b.disabled = true);
msg.textContent = '頼んでいます…';
const mcp = window.claude?.use ? await window.claude.use('mcp') : null;
if (!mcp) { msg.textContent = 'このページからは頼めませんでした。「依頼文をコピー」で新しい会話に貼ってください'; return; }
try {
await mcp.callTool(MCP_SERVER, 'fire_trigger', { trigger_id: START_TASK, text });
if (S.db) { try { await S.db.doc('requests/r' + c['番号']).set({ 台本: `${c['番号']}_${c['名前']}`, at: new Date().toISOString() }); } catch (e) {} }
msg.textContent = '頼みました。数分で「いま動いている作業」に出ます';
} catch (e) {
const code = e && e.code;
msg.textContent =
code === 'server_not_connected' ? 'Claude Code Remote のコネクタがつながっていません。claude.ai の設定→コネクタで追加してください' :
code === 'needs_reauth' ? 'Claude Code Remote のコネクタをつなぎ直してください(claude.ai の設定→コネクタ)' :
code === 'not_in_manifest' ? 'このページに、作り始める許可が出ていません。ページの許可の設定を見てください' :
code === 'tool_error' ? '頼めませんでした: ' + (e.message || '') :
(code === 'server_unavailable' || code === 'upstream_error') ? '返事がありませんでした。頼めたかどうか分からないので、少し待って「いま動いている作業」を見てください' :
'頼めませんでした(' + (code || '不明') + ')。「依頼文をコピー」で新しい会話に貼ってください';
}
}
// ---------- 数字のカード: 押すと表をその動画だけにする ----------
document.querySelectorAll('button.tile').forEach(b => b.addEventListener('click', () => {
if (b.dataset.f === 'k') { S.filter = null; renderTable(); $('cands').scrollIntoView({ behavior: 'smooth', block: 'center' }); return; }
S.filter = S.filter === b.dataset.f ? null : b.dataset.f;
renderTable();
if (S.filter) $('table-sec').scrollIntoView({ behavior: 'smooth', block: 'start' });
}));
$('filter-chip').addEventListener('click', () => { S.filter = null; renderTable(); });
// ---------- 公開予約の記録 ----------
async function saveSched(v, date) {
const next = Object.assign({}, S.sched || {});
if (date) next[v] = date; else delete next[v];
try { await S.db.doc('scheduled/all').set({ 予約: next, 更新: new Date().toISOString() }); $('sched-toast').textContent = date ? '記録しました' : '取り消しました'; }
catch (e) { $('sched-toast').textContent = '記録できませんでした。もう一度押してください'; }
setTimeout(() => { $('sched-toast').textContent = ''; }, 3000);
}
$('sched-save').addEventListener('click', () => { const d = $('sched-date').value; if (S.selected && d && $('sched-date').validity.valid) saveSched(S.selected, d); });
$('sched-clear').addEventListener('click', () => { if (S.selected) saveSched(S.selected, null); });
// ---------- メモ ----------
$('memo-add').addEventListener('click', async () => {
const t = $('memo-text').value.trim(); const v = S.selected;
if (!t || !v || !S.db) return;
$('memo-add').disabled = true;
try {
await S.db.collection('memos').add({ video: v, text: t, at: new Date().toISOString(), done: false });
$('memo-text').value = ''; $('memo-toast').textContent = '残しました。次に「続き」と言えば Claude が読みます';
setTimeout(() => { $('memo-toast').textContent = ''; }, 4000);
} catch (e) { $('memo-toast').textContent = '残せませんでした。もう一度押してください'; }
$('memo-add').disabled = false;
});
// ---------- iPhone: ハコブくんの画面 ----------
// 幅が狭いときはこの画面。「全部見る」を押すと一覧になり、その選び方は iPhone に覚えさせる
const NARROW = window.matchMedia('(max-width: 700px)');
const prefView = () => { try { return localStorage.getItem('p2m-view'); } catch (e) { return null; } };
const setPref = (v) => { try { localStorage.setItem('p2m-view', v); } catch (e) {} };
let phoneView = false;
function applyView() {
const narrow = NARROW.matches;
phoneView = narrow && prefView() !== 'full';
document.body.classList.toggle('phone', phoneView);
document.body.classList.toggle('narrow-full', narrow && !phoneView);
}
NARROW.addEventListener ? NARROW.addEventListener('change', () => { applyView(); renderAll(); }) : NARROW.addListener(applyView);
$('ph-new-btn').addEventListener('click', () => {
const sec = $('ph-new'); sec.hidden = !sec.hidden;
$('ph-new-btn').setAttribute('aria-expanded', String(!sec.hidden));
});
$('ph-all').addEventListener('click', () => { setPref('full'); applyView(); window.scrollTo(0, 0); });
$('back-phone').addEventListener('click', () => { setPref('phone'); applyView(); S.phSig = ''; renderAll(); window.scrollTo(0, 0); });
applyView();
{
const b = el('img'); b.src = HK_BODY; b.alt = 'ハコブくん';
const f = el('img', 'face3'); f.src = FACES3.open_mid; f.alt = '';
const rg = el('img', 'ring'); rg.src = HK_RING; rg.alt = '';
$('ph-hk').append(b, rg, f);
// ハコブくんを押すと、いま見ているカードの件を声で知らせる(声がオフでも、押したときは話す)
const hk = $('ph-hk'); hk.setAttribute('role', 'button'); hk.tabIndex = 0; hk.setAttribute('aria-label', 'ハコブくんに、いまのカードを読んでもらう');
const speakNow = () => { anim('hop'); const q = queue(); const it = q[S.phIdx || 0]; play(it ? it.voiceKind : 'owari', it?.video); };
hk.addEventListener('click', speakNow);
hk.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); speakNow(); } });
HEROES.push({ img: f, hero: true, working: false, mouth: 'mid', nextBlink: Date.now() + 1200, blinkUntil: 0, nextMouth: 0 });
}
// ---------- ハコブくんの反応(押したことに応える・件数で顔が変わる) ----------
const heroExpr = () => (S.react && Date.now() < S.react.until) ? S.react.expr : (S.mood || 'base');
function anim(name) {
const h = $('ph-hk'); h.classList.remove('hop', 'nod', 'spin'); void h.offsetWidth; h.classList.add(name);
clearTimeout(anim.t); anim.t = setTimeout(() => h.classList.remove(name), 1200);
}
function react(expr, text, motion, ms) {
ms = ms || 4000;
S.react = { expr, text, until: Date.now() + ms };
if (motion) anim(motion);
renderPhone();
clearTimeout(react.t); react.t = setTimeout(() => { S.react = null; renderPhone(); }, ms + 50);
}
const KLAB = { session: '会話で返事待ち', kakunin: '確認', daihon: '台本', koe: '声', short: 'ショート', upload: 'アップロード', other: '次' };
const DONE_LAB = { kakunin: '見終わった', daihon: '確かめた', koe: '声を作った', short: 'ショートを投稿した', upload: 'アップロードした', other: '済んだ' };
const linkFor = (it) => { const u = (it.session || liveSessions().find(x => x['動画'] === it.video))?.['会話']; return /^https:\/\/claude\.ai\//.test(u || '') ? u : ''; };
const openLink = (cls, text, href) => { const a = el('a', cls, text); a.href = href; a.target = '_blank'; a.rel = 'noopener'; return a; };
function tellToast(t, msg, ms) { t.textContent = msg; setTimeout(() => { if (t.textContent === msg) t.textContent = ''; }, ms || 3500); }
function phoneCard(it) {
const key = markId(it);
const c = el('article', 'pcard');
const k = el('div', 'k'); k.append(el('b', null, KLAB[it.kind] || '次'));
if (it.session) k.append(el('span', 'num', ago(it.session['更新'])));
c.append(k, el('h3', null, it.video), el('p', 'say', it.phSay));
if (it.sub) c.append(el('p', 'sub', it.sub));
const acts = el('div', 'acts'); const toast = el('span', 'toast');
const link = linkFor(it);
if (it.kind === 'session') {
// 返事の候補(Claude が待ちと一緒に書いたもの)。押すと返事をコピーして会話を開く
const rc = it.session['返事の候補'];
const opts = rc && rc['待ち'] === it.session['待ち'] && Array.isArray(rc['候補']) ? rc['候補'].slice(0, 4) : [];
for (const o of opts) {
const b = link ? openLink('pbtn', o, link) : el('button', 'pbtn', o);
if (!link) b.type = 'button';
b.addEventListener('click', () => {
const p = navigator.clipboard?.writeText(o);
if (p) p.then(() => tellToast(toast, '返事をコピーしました。会話に貼って送ってください', 6000)).catch(() => tellToast(toast, o, 8000));
else tellToast(toast, o, 8000);
});
acts.append(b);
}
if (opts.length && link) acts.append(el('p', 'hint', '押すと返事がコピーされて会話が開きます。貼って送れば、Claude の番に戻ります。'));
if (link) acts.append(openLink('pbtn' + (opts.length ? '' : ' primary'), opts.length ? '自分で書いて返事する' : '会話を開いて返事する', link));
} else {
const b = el('button', 'pbtn primary', DONE_LAB[it.kind] || '済んだ'); b.type = 'button';
b.disabled = !S.db;
b.addEventListener('click', async () => {
b.disabled = true;
try { await setMark(it, 'done'); react('joy', 'ありがとうございます!', 'hop'); }
catch (e) { b.disabled = false; tellToast(toast, '記録できませんでした。もう一度押してください'); }
});
acts.append(b);
if (it.kind === 'short' && it.row?.['URL']) {
const u = el('button', 'pbtn', '本編の URL をコピー'); u.type = 'button';
u.addEventListener('click', () => copy(it.row['URL'], toast));
acts.append(u);
}
if (link) acts.append(openLink('pbtn', 'その会話を開く', link));
}
const row = el('div', 'prow');
const later = el('button', 'pbtn', 'あとで'); later.type = 'button'; later.disabled = !S.db;
later.addEventListener('click', () => {
const keep = [...row.childNodes];
const choose = (label, ms) => { const b = el('button', 'pbtn', label); b.type = 'button';
b.addEventListener('click', async () => {
row.querySelectorAll('button').forEach(x => x.disabled = true);
let until;
if (ms) until = new Date(Date.now() + ms);
else { until = new Date(); until.setDate(until.getDate() + 1); until.setHours(8, 0, 0, 0); }
try {
await setMark(it, 'later', until.toISOString());
const when = ms ? `${until.getHours()}時${until.getMinutes() ? until.getMinutes() + '分' : ''}ごろ` : '明日の朝';
react('smile', `了解です。${when}にまた出しますね。`, 'nod');
}
catch (e) { row.replaceChildren(...keep); tellToast(toast, '記録できませんでした。もう一度押してください'); }
});
return b; };
const no = el('button', 'pbtn', 'やめる'); no.type = 'button';
no.addEventListener('click', () => row.replaceChildren(...keep));
row.replaceChildren(choose('1時間', 3600e3), choose('3時間', 3 * 3600e3), choose('明日の朝', 0), no);
});
if (it.kind === 'session' && S.db) {
const resume = el('button', 'pbtn', '返事した'); resume.type = 'button';
resume.addEventListener('click', async () => {
resume.disabled = true;
try { await S.db.doc('sessions/' + it.session.id).update({ 番: 'Claude', 待ち: '', 作業: 'エイイチの返事を受けて作業を再開', 更新: new Date().toISOString() }); }
catch (e) { resume.disabled = false; tellToast(toast, '書き換えられませんでした'); }
});
row.append(resume);
}
const open = S.phOpen.has(key);
const more = el('button', 'pbtn', open ? 'とじる' : 'くわしく'); more.type = 'button';
more.setAttribute('aria-expanded', String(open));
more.addEventListener('click', () => { open ? S.phOpen.delete(key) : S.phOpen.add(key); renderPhone(); });
row.append(later, more);
acts.append(row, toast);
c.append(acts);
if (open) c.append(phoneMore(it, key));
return c;
}
// くわしく: まだの段階・次にやること・メモ・依頼文。見たいときだけ開く
function phoneMore(it, key) {
const box = el('div', 'pmore');
const r = it.row || rows().find(x => x['動画'] === it.video);
if (r) {
const left = STEPS.filter(st => r[st] !== '済');
if (left.length) {
const todo = el('div', 'todo'); todo.append(el('span', 'lab', 'まだの段階'));
for (const st of left) { const v = r[st] || '未'; const chip = el('span', 'step'); chip.append(el('i', 'dot' + (v === '途中' ? ' part' : '')), document.createTextNode(st)); todo.append(chip); }
box.append(todo);
}
}
const kv = el('dl', 'kv');
const add = (k2, v) => { if (!v) return; kv.append(el('dt', null, k2), el('dd', null, v)); };
if (r) { add('タイトル', r['タイトル'] !== it.video ? r['タイトル'] : ''); add('次にやること', r['次にやること']); add('声', r['声の数']); add('ショート', r['ショートの数']); add('最終更新', r['最終更新']); }
if (it.session) { add('Claude の作業', it.session['作業']); add('書き込み', ago(it.session['更新'])); }
if (kv.childNodes.length) box.append(kv);
const ul = el('ul', 'memos');
for (const m of openMemos(it.video)) {
const li = el('li');
const cb = el('input'); cb.type = 'checkbox'; cb.setAttribute('aria-label', '直した');
cb.addEventListener('change', async () => { try { await S.db.doc('memos/' + m.id).update({ done: cb.checked }); } catch (e) { cb.checked = !cb.checked; } });
li.append(cb, el('span', 't', m.text), el('span', 'at', ago(m.at)));
ul.append(li);
}
if (ul.childNodes.length) box.append(ul);
const ta = el('textarea'); ta.placeholder = '直す所のメモ(例:3分16秒の読みがおかしい)'; ta.value = S.phDraft[key] || '';
ta.addEventListener('input', () => { S.phDraft[key] = ta.value; });
const toast = el('span', 'toast');
const save = el('button', 'pbtn', 'メモを残す'); save.type = 'button'; save.disabled = !S.db;
save.addEventListener('click', async () => {
const t = ta.value.trim(); if (!t) return;
save.disabled = true;
try { await S.db.collection('memos').add({ video: it.video, text: t, at: new Date().toISOString(), done: false }); S.phDraft[key] = ''; ta.value = ''; tellToast(toast, '残しました'); }
catch (e) { tellToast(toast, '残せませんでした。もう一度押してください'); }
save.disabled = false;
});
const cp = el('button', 'pbtn', '依頼文をコピー'); cp.type = 'button';
cp.addEventListener('click', () => copy(instruction(it.video), toast));
const prow = el('div', 'prow'); prow.append(save, cp);
box.append(ta, prow, toast);
return box;
}
function updateCount() {
const box = $('ph-cards'); const n = box.querySelectorAll('.pcard').length;
if (n < 2) { S.phIdx = 0; $('ph-count').textContent = ''; return; }
const w = box.firstElementChild.getBoundingClientRect().width + 10;
const i = Math.max(0, Math.min(n - 1, Math.round(box.scrollLeft / w)));
S.phIdx = i;
$('ph-count').textContent = `${i + 1} / ${n}`;
}
$('ph-cards').addEventListener('scroll', () => requestAnimationFrame(updateCount), { passive: true });
function renderPhone() {
if (!phoneView) return;
const q = queue(), hid = hiddenItems();
const ls = liveSessions(), working = ls.filter(s => s['番'] !== 'エイイチ');
// 件数で表情を変える: 0件はにっこり、4件以上は困り顔。0件になった瞬間はくるっと回る
const prevMood = S.mood;
S.mood = !S.loaded ? 'base' : q.length === 0 ? 'smile' : q.length >= 4 ? 'trouble' : 'base';
if (S.loaded && S.mood === 'smile' && prevMood && prevMood !== 'smile') anim('spin');
$('ph-say').textContent = !S.loaded ? '読み込んでいます。' : q.length === 0 ? 'いま、エイイチさんの番はありません。'
: q.length >= 4 ? `エイイチさんの番が ${q.length} 件たまっています…` : `エイイチさんの番が ${q.length} 件あります。`;
$('ph-sub').textContent = !S.loaded ? '' : working.length ? `Claude は ${working.length} 本を進めています。` : (q.length ? '' : '台本の候補から、新しく作り始められます。');
if (S.loaded && q.length) $('ph-sub').textContent += (working.length ? ' ' : '') + 'ぼくを押すと読みます。';
if (S.react && Date.now() < S.react.until) { $('ph-say').textContent = S.react.text; $('ph-sub').textContent = ''; }
// カードは中身が変わったときだけ描き直す(めくった位置や書きかけのメモを保つ)
const sig = JSON.stringify([!!S.db, q.map(it => [it.video, it.kind, it.phSay, it.sub, it.stage, it.session?.['返事の候補'], it.session?.['作業'], linkFor(it)]),
S.memos.filter(m => !m.done).map(m => [m.id, m.video, m.text]), [...S.phOpen]]);
if (sig !== S.phSig) {
S.phSig = sig;
const box = $('ph-cards'); const keep = box.scrollLeft;
box.replaceChildren(...q.map(phoneCard));
box.scrollLeft = keep;
}
updateCount();
// あとで・済んだにした件(戻せる)
const hb = $('ph-hidden'); const wasOpen = !!hb.querySelector('details[open]'); hb.replaceChildren();
if (hid.length) {
const d = el('details', 'ph-hid'); d.open = wasOpen;
d.append(el('summary', null, `あとで・済んだにした ${hid.length} 件 ▾`));
for (const it of hid) {
const m = markOf(it);
const r = el('div', 'hrow');
const back = el('button', 'btn', '戻す'); back.type = 'button';
back.addEventListener('click', async () => { back.disabled = true; try { await S.db.doc('marks/' + markId(it)).delete(); } catch (e) { back.disabled = false; } });
r.append(el('span', 'n', it.video), el('span', 'w', m['何'] === 'done' ? '済んだ' : `${hhmm(m['まで'])} まで`), back);
d.append(r);
}
hb.append(d);
}
// Claude が進めている会話(見るだけ)
const wb = $('ph-work'); const wk = wb.scrollLeft; wb.replaceChildren();
$('ph-work-sec').hidden = !working.length;
for (const s of working) {
const r = rows().find(x => x['動画'] === s['動画']);
const at = r ? (STEPS.every(st => r[st] === '済') ? STEPS.length : Math.max(0, STEPS.findIndex(st => r[st] !== '済'))) : 0;
const c = el('div', 'wcard');
const bar = el('div', 'bar'); const fill = el('i'); fill.style.width = `${at / STEPS.length * 100}%`; bar.append(fill);
const nm = el('div', 'name'); nm.append(el('span', null, s['動画'] || '(動画名なし)'), el('span', 'num', `${at}/${STEPS.length} 済・${ago(s['更新'])}`));
c.append(nm, el('div', 'doing', s['作業'] || ''), bar);
if (stale(s)) { const meta = el('div', 'meta'); meta.append(el('span', 'chip warn', '止まっているかも')); c.append(meta); }
wb.append(c);
}
wb.scrollLeft = wk;
}
renderAll();
// ---------- データ ----------
(async () => {
const db = window.claude?.use ? await window.claude.use('db') : null;
if (!db) { S.loaded = true; $('say').textContent = 'このページは claude.ai で開くと、進み具合が出ます。'; renderAll(); return; }
S.db = db;
db.doc('status/latest').onSnapshot(s => { S.status = s.exists ? s.data() : null; S.loaded = true; renderAll(); }, () => { S.loaded = true; renderAll(); });
db.collection('sessions').onSnapshot(q => { S.sessions = q.docs.map(d => Object.assign({ id: d.id }, d.data())); renderAll(); });
db.collection('memos').onSnapshot(q => { S.memos = q.docs.map(d => Object.assign({ id: d.id }, d.data())); renderLists(); renderDetail(); });
db.collection('requests').onSnapshot(q => { S.requests = {}; q.docs.forEach(d => { S.requests[d.id] = d.data(); }); renderCands(); });
db.doc('scheduled/all').onSnapshot(s => { S.sched = (s.exists && s.data()['予約']) || {}; renderAll(); });
db.collection('marks').onSnapshot(q => { S.marks = {}; q.docs.forEach(d => { S.marks[d.id] = d.data(); }); renderAll(); });
db.doc('voice/lines').onSnapshot(s => { S.voice = Object.assign({}, BUILTIN_VOICE, s.exists ? s.data() : {}); renderVoice(); });
setInterval(() => { renderAll(); }, 60e3); // 「◯分前」を進め、「あとで」の時間が来た件を戻す
})();
})();
</script>
進み具合のページを組み立てる build.py
import json, base64, os, re, sys
H = os.path.dirname(os.path.abspath(__file__)); R = os.path.dirname(H)
src = open(os.path.join(H, 'hub.src.html')).read()
sp = json.load(open(os.path.join(R, 'hk/hakobu_sprites.json')))
voices = {}
for k in ['session','kakunin','daihon','koe','title','short','upload','owari']:
p = os.path.join(R, f'voice/hakobu_{k}.mp3')
voices[k] = 'data:audio/mpeg;base64,' + base64.b64encode(open(p,'rb').read()).decode()
out = src.replace('__HK_FACES__', json.dumps(sp['face'])).replace('__HK_BODY__', sp['body']).replace('__VOICES__', json.dumps(voices)).replace('__HK_FACES3__', json.dumps(sp['face3'])).replace('__HK_RING__', sp['ring']['src'])
assert '__HK' not in out and '__VOICES__' not in out
open(os.path.join(H, 'video-hub.html'), 'w').write(out)
m = re.search(r'<script>(.*)</script>', out, re.S)
open(os.path.join(H, 'check.js'), 'w').write(m.group(1))
print(len(out))
ページ用の顔と緑の輪を作る make_page_faces.py
"""進み具合のページの大きなハコブくん用に、表情つきの顔13枚と、体の横の緑の輪の光を作る.
hakobu.py(動画と同じ描き方)で全身を描き、顔の範囲と輪の範囲だけを切り出して webp にし、
hakobu_sprites.json の face3・box3・ring に入れる(build.py がページに埋め込む)。
"""
import json, base64, io, numpy as np, cv2
from PIL import Image
from hakobu import Hakobu
d = json.load(open('hakobu_sprites.json'))
hk = Hakobu(mode="full")
def webp(a, q=90):
b = io.BytesIO()
Image.fromarray(a).save(b, 'WEBP', quality=q, method=6)
return 'data:image/webp;base64,' + base64.b64encode(b.getvalue()).decode()
# ---------- 顔: ふつうの目とまばたき × 口4段階、にっこり・喜び・困り ----------
V = {}
for e in ['open', 'blink']:
for m in ['closed', 'small', 'mid', 'large']:
V[f'{e}_{m}'] = ((e, e), None, m)
V.update({'smile_open': (("open", "open"), None, "closed"), 'smile_blink': (("blink", "blink"), None, "closed"),
'joy': (("chev", "chev"), None, "tri"), 'trouble_open': (("open", "open"), "trouble", "frown"),
'trouble_blink': (("blink", "blink"), "trouble", "frown")})
base = np.array(hk.frame((("open", "open"), None, "mid", 0.3, 0.0))).astype(int)
x0, y0, x1, y1 = d['box'] # 前に作った顔の範囲(眉が入らない)
X0, Y0, X1, Y1 = x0, y0, x1, y1
imgs = {}
for k, (e, b, m) in V.items():
a = np.array(hk.frame((e, b, m, 0.3, 0.0)))
imgs[k] = a
diff = np.abs(a.astype(int) - base).sum(2) > 8 # ふつうの顔と違う所が全部入るまで範囲を広げる
ys, xs = np.where(diff)
if len(xs):
X0 = min(X0, xs.min() - 3); Y0 = min(Y0, ys.min() - 3); X1 = max(X1, xs.max() + 4); Y1 = max(Y1, ys.max() + 4)
print('old', d['box'], 'new', [X0, Y0, X1, Y1], base.shape)
faces = {k: webp(a[Y0:Y1, X0:X1]) for k, a in imgs.items()}
# 確かめる用に横に並べる
sheet = Image.new('RGBA', ((X1 - X0) * len(imgs), Y1 - Y0), (14, 15, 15, 255))
for i, a in enumerate(imgs.values()):
sheet.alpha_composite(Image.fromarray(a[Y0:Y1, X0:X1]), (i * (X1 - X0), 0))
sheet.resize((sheet.width * 2, sheet.height * 2)).save('expr_check2.png')
# ---------- 緑の輪の光: 明るいときと暗いときの差がある所だけを残した重ね絵 ----------
a0 = np.array(hk.frame((("open", "open"), None, "mid", 0.15, 0.0))).astype(np.float32)
a1 = np.array(hk.frame((("open", "open"), None, "mid", 1.0, 0.0)))
diff = np.abs(a1.astype(np.float32) - a0)[..., :3].sum(2)
mask = np.clip(diff / 40, 0, 1)
mask = cv2.GaussianBlur(cv2.dilate(mask, np.ones((5, 5), np.uint8)), (0, 0), 2)
ys, xs = np.where(mask > 0.02)
rx0, ry0, rx1, ry1 = xs.min(), ys.min(), xs.max() + 1, ys.max() + 1
ov = a1.copy()
ov[..., 3] = (a1[..., 3].astype(np.float32) / 255 * np.clip(mask, 0, 1) * 255).astype(np.uint8)
ring = Image.fromarray(ov[ry0:ry1, rx0:rx1])
b = io.BytesIO(); ring.save(b, 'WEBP', quality=92, method=6)
d['face3'] = faces
d['box3'] = [int(X0), int(Y0), int(X1), int(Y1)]
d['ring'] = {'src': 'data:image/webp;base64,' + base64.b64encode(b.getvalue()).decode(), 'box': [int(rx0), int(ry0), int(rx1), int(ry1)]}
json.dump(d, open('hakobu_sprites.json', 'w'))
print(d['box3'], d['ring']['box'], sum(len(v) for v in faces.values()))
体の切り抜きの縁をきれいにする defringe.py
import numpy as np, cv2
from PIL import Image
def defringe(img, band_px=5, shrink_px=2, from_y=0.0):
"""切り抜きの縁に残った白い背景の色を、内側の色で塗り替え、縁を少し内側へ寄せる"""
a = np.array(img).copy()
al = a[..., 3]
solid = (al >= 250).astype(np.uint8)
inner = cv2.erode(solid, np.ones((3, 3), np.uint8), iterations=band_px)
band = ((al > 0) & (inner == 0)).astype(np.uint8)
ys = np.where(al.max(1) > 0)[0]
y_lim = int(ys.min() + (ys.max() - ys.min()) * from_y)
band[:y_lim] = 0 # 直すのは下の方(タイヤのまわり)だけ。頭の白い枠は触らない
# 縁の帯のうち、まわりより明るく灰色っぽい(白背景が混ざった)所だけを塗り替える
rgb = a[..., :3].copy()
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
filled = cv2.inpaint(bgr, band, 5, cv2.INPAINT_TELEA)
filled = cv2.cvtColor(filled, cv2.COLOR_BGR2RGB).astype(np.int32)
lum_now = rgb.astype(np.int32).mean(2); lum_in = filled.mean(2)
fix = (band > 0) & (lum_now > lum_in + 12)
rgb[fix] = filled[fix]
a[..., :3] = rgb
# 縁を内側へ寄せてなめらかに
al2 = cv2.erode(al, np.ones((3, 3), np.uint8), iterations=shrink_px)
al2[:y_lim] = al[:y_lim]
al2 = cv2.GaussianBlur(al2.astype(np.float32), (0, 0), 0.8)
a[..., 3] = np.minimum(al, np.clip(al2, 0, 255)).astype(np.uint8)
return Image.fromarray(a)
プラグイン p2m-progress-sync/.claude-plugin/plugin.json
{
"name": "p2m-progress-sync",
"version": "0.1.0",
"description": "動画の会話の動き(再開・待ち・作業中)を「動画の進み具合」のページへ自動で書き込む",
"author": {
"name": "ADAPT DESIGN"
}
}
プラグイン p2m-progress-sync/hooks/hooks.json
{ "modules": ["./register.ts"] }
プラグイン p2m-progress-sync/hooks/register.ts
import type { Register } from 'claude-code'
// 「動画の進み具合」のページ(Pixels to Motors の動画づくり)
const PAGE = 'https://claude.ai/artifact/TkUVA2J51gMFKxUjH4hdhz'
const HEARTBEAT_MS = 3 * 60 * 1000 // 作業中の「生きている」印(更新の時刻)は3分に1回まで
type Doc = { version: number; 番?: string; 待ち?: string; 終了?: boolean }
let docId: string | undefined
let lastBeat = 0
// この会話の、ページの上での名前(sessions/s-{会話の ID})
async function sessionDocId($: any): Promise<string | undefined> {
if (docId) return docId
const raw = (await $.env.get('CLAUDE_CODE_REMOTE_SESSION_ID')) as string | undefined
if (!raw) return undefined
docId = 's-' + raw.replace(/^cse_/, '').replace(/^session_/, '')
return docId
}
// 許可を聞く場面になるなら何もしない(作業のじゃまをしない)
async function allowed($: any, input: any): Promise<boolean> {
try {
const v = await $.tool.check({ tool: 'ArtifactData', ...input })
return !!v && v.decision === 'allow'
} catch { return false }
}
function pick(text: string, key: string): string | undefined {
const m = text.match(new RegExp('"' + key + '"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"'))
if (!m) return undefined
try { return JSON.parse('"' + m[1] + '"') } catch { return m[1] }
}
async function readDoc($: any): Promise<Doc | undefined> {
const d = await sessionDocId($); if (!d) return undefined
const input = { action: 'get', url: PAGE, collection: 'sessions', doc_id: d }
if (!(await allowed($, input))) return undefined
try {
const r = await $.tool.call({ tool: 'ArtifactData', ...input })
const text: string = (r && r.text) || ''
const ver = text.match(/"version"\s*:\s*(\d+)/)
if (!ver || /not found|does not exist/i.test(text)) return undefined // ページに載っていない会話(動画の会話ではない)
return { version: Number(ver[1]), 番: pick(text, '番'), 待ち: pick(text, '待ち'), 終了: /"終了"\s*:\s*true/.test(text) }
} catch { return undefined }
}
async function writeDoc($: any, doc: Doc, data: Record<string, unknown>): Promise<void> {
const d = await sessionDocId($); if (!d) return
const input = { action: 'update', url: PAGE, collection: 'sessions', doc_id: d, if_version: doc.version, data: { ...data, 更新: new Date().toISOString() } }
if (!(await allowed($, input))) return
try { await $.tool.call({ tool: 'ArtifactData', ...input }) } catch { /* 書けなくても会話は止めない */ }
}
export const register: Register = on => {
// エイイチさんが返事を送って、Claude が動き出した → Claude の番
on('turn.start', async ($, e, next) => {
const res = await next(e)
if (e.agentId === undefined) {
const doc = await readDoc($)
if (doc && !doc.終了 && doc.番 !== 'Claude') await writeDoc($, doc, { 番: 'Claude', 待ち: '', 作業: 'エイイチの返事を受けて作業中' })
lastBeat = Date.now()
}
return res
})
// Claude が返事を書き終えて止まった → エイイチの番
on('turn.complete', async ($, e, next) => {
const res = await next(e)
if (e.agentId === undefined && e.reason === 'answer') {
const doc = await readDoc($)
if (doc && !doc.終了 && doc.番 !== 'エイイチ') await writeDoc($, doc, { 番: 'エイイチ', 待ち: doc.待ち || '会話で返事を待っています' })
}
return res
})
// 作業中は3分に1回、「生きている」印(更新の時刻)だけ書く
on('tool.call', async ($, e, next) => {
const res = await next(e)
if ((e as any).tool !== 'ArtifactData' && Date.now() - lastBeat > HEARTBEAT_MS) {
lastBeat = Date.now()
const doc = await readDoc($)
if (doc && !doc.終了 && doc.番 === 'Claude') await writeDoc($, doc, {})
}
return res
}).catch(($, e, next) => next(e)) // この仕組みが失敗しても、道具の呼び出しはふつうに通す
}
プラグイン p2m-progress-sync/README.md
# p2m-progress-sync
Pixels to Motors の動画づくりの会話で、会話の動きを「動画の進み具合」のページ(https://claude.ai/artifact/TkUVA2J51gMFKxUjH4hdhz)へ自動で書き込む。
- エイイチが返事を送って Claude が動き出したら、その会話のカードを「Claude が作業中」にする
- Claude が返事を書き終えて止まったら、「エイイチの番」にする
- 作業中は3分に1回、更新の時刻だけ書く
- ページに載っていない会話(sessions に自分の1件がない会話)では何もしない
- 書き込みに許可の確認が要る場面では書かない(作業を止めない)
プラグイン p2m-progress-sync/tsconfig.json
{
"extends": "./.claude-plugin/types/tsconfig.json"
}
予定のタスク「動画を作り始める(進み具合のページから)」の指示文
時刻では動かず、ページから呼ばれたときだけ動く予定のタスクです。Mac が必要で、承認なしで動く設定にしています。
あなたは、エイイチ(島津栄一)の YouTube「フィジカルAI解説|Pixels to Motors」の解説動画を作り始める担当です。この会話は、エイイチが「動画の進み具合」のページ(https://claude.ai/artifact/TkUVA2J51gMFKxUjH4hdhz)で台本の候補を押したときに始まります。どの台本から作るかは、このあとに続くメッセージに書いてあります。
やること:
1. p2m-explainer-video スキルを読み込み、その手順と、iCloud の `動画/_制作ベース/video_rules.md`(0節「1本を作る流れ」と「進み具合の一覧」)に従う。
2. はじめに、進み具合のページのデータベース(ArtifactData・上の URL)の `sessions` に、この会話の1件を set する(doc の id は `s-` +会話の ID の先頭の英数字。`{動画, 作業, 待ち, 番: "Claude", 声キー, 会話: この会話の URL, 更新: いまの ISO 時刻, 終了: false}`)。
3. 指定の台本(Mac の iCloud `動画/台本_15本_20261012/` の中)と `00_各回の指示.md`・`_事実確認/` を読み、作業フォルダ `動画/{動画名}/` を作って、narration.py まで書く。script_check.py を通し、書いたのとは別のエージェントに出典と1文ずつ突き合わせてもらい、直し、make_docs.py で読み物の台本と codex の依頼文を作る。
4. ここで止める(声を作る前。台本の確定はエイイチが決める)。`sessions` のこの会話の1件を `{番: "エイイチ", 声キー: "daihon", 待ち: "台本を確かめて、codex にかけるか決めること", 作業: いまの状態}` に update し、PushNotification で「ハコブくん:『{動画名}』の台本ができました。確かめてください」と1回だけ知らせる。
5. 最後に、この会話でやったことと、エイイチに確かめてほしい所を短くまとめる。
守ること: 声(ElevenLabs)はエイイチの Mac で作るので、ここでは作らない。API キーを求めない。台本の文体は language_rules.md に従う。Mac につながらないときは、その旨を sessions に書いて(番: "エイイチ", 待ち: "Mac とデスクトップアプリを開くこと")止める。
video_rules.md の「進み具合の一覧」の節
動画を作る会話が、ページをいつ・どう書き換えるかの決まりです。動画の作り方の決まり(video_rules.md)の0節の中にあります。
### 進み具合の一覧(2026-10-15)
何本も並行して作るので、どの動画がどの段階で、誰の番かを1枚で見られるようにした。手で書く表は使わず、フォルダの中身から自動で判定する。ハコブくんが次の作業を知らせる。
- 一覧のページ: https://claude.ai/artifact/TkUVA2J51gMFKxUjH4hdhz (「動画の進み具合」)。中身はページのデータベースに入っていて、Claude は ArtifactData で読み書きする
- 判定するスクリプト: `_制作ベース/status_scan.py`。iCloud の `動画/` と KIOXIA の `videos/` の中身(narration.py・声の mp3・確認用・アップロード用・ショート・投稿文)と、YouTube の公開一覧(RSS とサイト用の一覧)から、段階・次にやること・誰の番かを出す。「_」で始まるフォルダは動画として数えない。公開の記録は RSS が新しい15件しか出さないため `_制作ベース/status_published.json` にためる
- 判定のしかた: 後の段階のファイルがあれば前の段階は済み(narration.py の文の数と mp3 の数は1つずれることがある)。YouTube の予約投稿は RSS に出ないので、フォルダだけでは「アップロード待ち」に見える。公開予約は、YouTube の公式の仕組み(YouTube Data API)で読む: `_制作ベース/youtube_schedule.py` がエイイチの Mac で1時間ごとに(launchd)`youtube_videos.json` を書き出し、status_scan.py がそれを読んで「予約済み」にする。許可の鍵は Mac の `~/.config/pixelstomotors/` だけに置き、Claude は読まない。許可はチャンネルの所有者 eiichi_shimazu@adaptdesign.jp で出す(studio@ で許可すると ADAPT DESIGN のチャンネルになり、予約が読めない)。うまく動いていないときは `~/.config/pixelstomotors/youtube_schedule.log` を見てもらう。手で記録した日(ページの `scheduled/all`)は、YouTube の一覧に日がないときだけ使う。段階の判定を変えたら、このスクリプトも直す
**C 動画の作業を始めるとき**
1. Mac 連携の shell で `cd` して `python3 status_scan.py --out status_latest.json` を動かし、`status_latest.json` を取り込んで、ページの `status/latest` に set する
2. ページの `memos` を読み、この動画の `done: false` のメモがあれば、直す所として扱う。直したら、そのメモを `done: true` に update する
3. ページの `sessions` に、この会話の1件を set する。doc の id は `s-` +会話の ID の先頭(英数字だけ)。中身は `{動画, 作業(いまやっていること), 待ち(何を待っているか・なければ空), 番("Claude" か "エイイチ"), 声キー, 会話(この会話の URL), 更新(ISO 時刻), 終了: false}`
- 2026-10-16: プラグイン p2m-progress-sync を入れた。ページに自分のカードがある会話では、エイイチが返事を送ると「Claude の番」、Claude の返事が終わると「エイイチの番」に自動で書き換え、作業中は3分ごとに更新の時刻を書く。手で書く決まり(下)は、待ちの中身(何を待っているか)を正しく書くためにそのまま続ける
**C 区切りごと**(エイイチの番になったとき・**エイイチの返事を受けて作業を再開したとき**・書き出しを始めたときなど)
- 再開したときは、ほかの作業より先に `sessions` のこの会話の1件を `{番: "Claude", 待ち: "", 作業: これからやること, 更新}` に update する(しないと、ページはエイイチの番のまま止まって見える)
- `sessions` のこの会話の1件を update する(作業・待ち・番・声キー・更新)
- あわせて status_scan.py を動かして `status/latest` も書き換える(ページの道のり「いま:◯◯ ◯/8 済」は `status/latest` から描くので、これをしないとハコブくんが前の段階に残る)
- **エイイチの番になった瞬間だけ**、通知を1回送る(PushNotification。「ハコブくん:◯◯の確認用ができました」のように、何をすればいいかを先に書く)。途中の経過では送らない
- 声キーは、知らせの種類: kakunin(確認用を見る)・daihon(台本を確かめる)・koe(声を作る)・title(タイトルとサムネイルを選ぶ)・short(ショートを投稿する)・upload(アップロード)・session(その他、会話で返事を待つ)
- エイイチの番にするとき、選んで答えられる問いなら、`返事の候補` も一緒に書く: `{返事の候補: {待ち: 待ちと同じ文, 候補: ["このまま codex にかけて", "先に冒頭を直したい"]}}`(2〜4個。そのまま会話に貼って通じる短い文)。iPhone の画面では候補がボタンになり、押すと返事がコピーされて会話が開く。`待ち` が変わると古い候補は出なくなるので、待ちを書き換えたら候補も書き直す。選べない問い(直す所を書いてもらうなど)は候補を書かない
- `待ち` は、iPhone のカードにそのまま大きく出る。何をすればいいかが一文で分かるように書く(例:「確認用を通しで見て、直す所を時間つきで伝える」)
**C 動画の作業を終えるとき**
- もう一度 status_scan.py を動かして `status/latest` を書き換え、`sessions` のこの会話の1件を `終了: true` にする
**台本の候補から作り始める**
- ページの台本の候補で「この台本で作り始める」→「始める」を押すと、予定のタスク「動画を作り始める(進み具合のページから)」(trig_013iuMmKsuvMU5wz7LrCs28Q・この Mac が必要・承認なしで動く)が今すぐ動き、新しい会話が台本(narration.py・script_check・出典の突き合わせ・make_docs)まで作って止まる。押した記録はページの `requests/r{番号}` に残り、24時間は同じ台本を押せない。手順を変えるときは、この予定のタスクの指示文も直す
**iPhone の画面**(2026-10-16)
- 同じページを幅の狭い画面で開くと、ハコブくんの画面になる。PC は詳しい一覧、iPhone はハコブくんと1件ずつ進める役わけ(エイイチの希望)。エイイチの番がカードになり、横にめくって1件ずつ見る。詳しいことは、カードの「くわしく」(まだの段階・次にやること・メモ・依頼文)と、一番下の「全部見る」(PC と同じ一覧)で見る
- カードのボタン: 返事の候補・会話を開く・済んだ(「アップロードした」など)・あとで(1時間/3時間/明日の朝)・返事した。「済んだ」「あとで」はページの `marks` に `{動画, 種類, 段階, 何: "done" か "later", まで}` で残り、その件をエイイチの番から外す(PC の一覧には札つきで薄く出る)。段階が変わると印は効かなくなる。Claude は再開したとき、`marks` に「済んだ」があれば、エイイチがその作業を終えた合図として読む
**ハコブくんの声**
- ページを開くたびに「声:オフ」から始まる(会議中に急に話さないため)。オンにしたときだけ、エイイチの番の一番上が変わった時に1回話す(1分以内には続けて話さない)
- 決まり文句の声は `動画/_ハコブくんのお知らせ/` で作った(`決まり文句.txt` に、試しの番号と声キーの対応がある。2026-10-16)。mp3 はページの資料に上げられないので、ページの中に埋め込んでいる。言い方を変えるときは、そこで声を作り直してページを作り直す
