diff --git a/docs/asset-factory.md b/docs/asset-factory.md index 0bb568d..3f5da1e 100644 --- a/docs/asset-factory.md +++ b/docs/asset-factory.md @@ -550,3 +550,32 @@ pnpm --filter @kaede/client assets:pack # 任意・アトラス派生物の確 (正解)。「どれがどれか」をチャット本文の説明に依存させない — 日本語フォントは `wqy-microhei.ttc`(DroidSansFallback はラテン 文字が豆腐化する実測) +35. **God Mode AI の運転実測(§7 残タスク 2 ベンチ 2026-08-16)**: + ①**Spine ジェネレーター(変形レーンの本命)は API 非公開** — Web の + `/api/spine-pipeline/*` はブラウザセッション(Supabase)専用で、 + gmd_ API トークンは全ルート 401(`spike_godmode.py probe-spine` が + 証跡を記録)。公開 REST 面(generation-api/v1)は sprite・ + bg-removal・files・actions/catalog・models のみ(405 走査で確定)、 + 公式 MCP にも spine ツールなし。②**残高 API が存在しない** — + ダッシュボードはセッション経由の Supabase 読みで、PixelLab/fal 流の + 残高前後照合(運転知見 30)が構造的に不可能。ベンチはローカル計測 + (sprite 1cr・bg-removal 1cr の公表単価)+オーナーのダッシュボード + 目視照合で代替する。③スプライト出力は**「一次再描画+テンプレ変形」**: + パレットは保持(較正済みドリフト lint 0 件)するが線の太さ・髪の + つやはハウス寄りに再レンダリングされる。動きはテンプレロックで + **positive_prompt のポーズ指示は効かない**(両手前保持を指示しても + 通常歩行 — carry 系統は作れない。カタログ側にも両手前保持歩行は + ない)。コマ間の輪郭ブレはゼロ(変形系の強み)。④bg-removal の + `bounding_boxes` は**repack された座標で clean シートと一致しない** + — clean シートは元の 4 列グリッドのままなのでグリッド切りが正 +36. **シルエットループの半サイクル罠は 2D 出力にも出る — RGB 全解像度の + wrap 照合で歩行周期を補正する**(God Mode ベンチ 2026-08-16 — + 運転知見 22 の 2D 版): 3/4 チビの鏡像ステップはシルエット IoU では + 区別できず、God Mode walk の周期 6 は半サイクルだった(判定材料を + その周期で標本化すると**存在しない片脚スキップを捏造**し videoReview + まで汚染する — 実測)。全解像度 RGB の平均絶対差は前脚/奥脚の陰影を + 区別でき、2P の wrap 差 < P の 0.8 倍なら歩行周期 = 2P と機械判定 + (`spike_godmode.py` `refine_gait_period`。walk_t1 実測 P=8.96 vs + 2P=5.63)。おまけ 2 件: ffmpeg drawtext はクォート内でも `:` の + エスケープが必要(未エスケープはラベル全体が豆腐化)・比較動画の + 列ラベルは短文に限る(長文は隣列に衝突 — 詳細は判定シート側に書く) diff --git a/docs/factory-v2-plan.md b/docs/factory-v2-plan.md index 3024f65..2e39448 100644 --- a/docs/factory-v2-plan.md +++ b/docs/factory-v2-plan.md @@ -451,6 +451,17 @@ avatar-carry/order.json = 旧 b4102f1f…)が台帳と一時的に乖離する マスターよりかわいいか**(変形は新しい絵を生まない系統 = v1 回転 リグ・Blender ベイク・3D 素レンダーで 3 回負けた弱点が、専業の自動化で 覆るか)。手順 5(PixelLab)と同型の即断ベンチ・数ドル規模。 + **実施済(2026-08-16 — オーナー判定待ち)**: ①**Spine レーン + (リグ化→Spine zip = ゲート本命の変形出力)は API 非公開で停止** — + `/api/spine-pipeline/*` は Web セッション専用・gmd_ トークン 401 + (運転知見 35。評価継続はオーナーのブラウザ実行が前提)②スプライト + レーンは walk 2 テイク+carry プローブを 6cr で完走: パレット保持・ + 輪郭ブレゼロ(変形系の強み)だが様式は一次再描画(太輪郭・つや髪)、 + ループ機械値 0.889〜0.94 はマスター 0.988 に未達、**carry 相当は + カタログ不在+positive_prompt 無視で不成立**。videoReview(実物 + identity 添付)はかわいさで God Mode > マスターと採点したが最終判定は + オーナー目視(運転知見 14)。実測・R2 ハッシュは factory-yield.md の + 2026-08-16 行、再現は `spike_godmode.py`。 3. **4b(fal Compute 経由 Wan-Animate-2)**: オーナーのインスタンス作成 (1x H100・SSH 鍵・Secrets 登録)待ち。SCAIL-2 hosted の pose/ replacement 追試(各 ~$0.4)が安い先行選択肢。 diff --git a/docs/factory-yield.md b/docs/factory-yield.md index 8b6cb71..10bb919 100644 --- a/docs/factory-yield.md +++ b/docs/factory-yield.md @@ -51,6 +51,7 @@ | 2026-08-15 | ファクトリー v2 手順 5: PixelLab ベンチ(ワイルドカード — 「kaede の柔らかいチビ絵柄に寄るか」の一点判定。手順 4⇄5 入れ替え = 4 は fal ホスト待ち) | 0 | 28(pixelart 2・bitforge 2・style 4・8 方向回転 8・walk 6 コマ×2 方向) | **PixelLab トライアル 30/40 世代・USD $0**(fal $0・AI Gateway $0・Meshy 0cr — 残高 API 照合 40→10)| ~2h | 撤退(オーナー判定 2026-08-15 — 記録のみ) | 段取りどおり安い順 4 段(各段の再現は `spike_pixellab.py` — state 永続化・再実行無課金・世代/USD 予算停止・401/402 即停止)。**① image-to-pixelart**(1 世代/回): committed stand セル・A ポーズ(R2 9c9a69…)とも構図・配色・顔が保持され一次判定は通過(ただし変換ツールなので新規絵は作れない)**② Bitforge**(style_image つき・1 世代/回): **strength 100 でもドット絵ハウススタイルへ引き戻す**(ディザ髪・褐色肌・頭身低下 — 2 テイクで系統的・不合格)**③ generate-with-style-v2**(Pro・20 世代/回 — 96px 参照で 4 サンプル): **4/4 とも kaede の柔らかいチビ絵柄にかなり寄る**(滑らかグラデ・大きな瞳・ディザなし。服装・向きの指示追従は弱くテイストのみ従う)**④ create-character-v3**(参照モード・3 世代): A ポーズ 111×192 入力から **112×192 の 8 方向回転を一発生成・全方向で同一性と柔らかい画風を保持** — 計画時の懸念「キャラ系キャンバス上限 128〜168px」は v3 では非該当と実測(出荷 4x=192px がそのまま通る)。**歩行(テンプレ walking-6-frames・~1.5 世代/方向)は不合格水準**: SE はストライド消失(ほぼ待機に見える)・E は片脚スキップ(真の交互なし)+腕振りゼロ+輪郭の毎コマ煮え(videoReview — 画風は「baseline art style matches remarkably well」だが動きは committed に大きく劣る。Tripo/Meshy raw と同型の症状)。**計測の注意**: トライアルの usage 表示は世代/USD が混在(アニメ job は usd 0.072 表示で実際はトライアル世代 3 消費)— fal 同様、残高 API(GET /balance)照合が正(運転知見 30)。生成物 10 点 R2 保管(pixelart 3736c2…/413ae6…・bitforge baa3cc…/adad3e…・style 4 本 d8a630…/0f7415…/d73f21…/9a9646…・character zip(8 回転+walk12 コマ)a46eb4…・並列ループ動画 6905a1…)。判定材料(4 段モンタージュ・8 方向・歩行並列比較・ループ動画)は本スパイクの PR に添付。**裁定: 記録のみ残して撤退**(唯一有望な多方向静止回転も現行製品 = 右向き 3/4+反転に消費者なし・動きは不合格で現行レーン担当のまま。再開条件は定めず必要時の再調査が正 — 本記録・再現ツール・R2 生成物が判定材料として残る) | | 2026-08-15 | ファクトリー v2 手順 4a′: 同一性再戦(運転ミス訂正後の正しい入力 — R2 原本 identity+外見記述なしプロンプト) | 5 | 0 | **fal BYOK ~$4.9+edit 1 本**(オーナーチャージ後残高 9.93→5.99 時点+seedance carry/edit 分ポスト待ち。内訳: v1 walk $0.25・seedance 480p walk $1.09・**seedance 720p walk $2.45**(960²)・seedance 480p carry $1.09・edit walk 再戦 ~$0.2 は投入済みキュー滞留)・AI Gateway $0・Meshy 0cr | ~1.5h(edit 除く) | 実施済・オーナー判定待ち | **運転知見 32 の機械強制を実装してから再走**(`spike_r2v_bench.py`: identity は R2 原本必須 300px ゲート・プロンプトから外見記述を全廃・identity 実物を勝敗表第 1 行へ自動同梱・identityPaletteDrift 機械計測(較正済み `check_palette_drift` 流用)・プロンプト全文+identity ズームのプリフライト成果物)。videoReview のルーブリックも実物 identity 画像添付に変更(言語記述を渡さない)。**結果: 正しい入力でも seedance 2.5 が勝つ** — walk は服・髪・顔とも高忠実(v1 は服は保持するが**顔が歪む**・髪膨らみ — videoReview「facial distortion」)、**carry は v1 recast(fb6d4e…)が服崩壊+手元の緑黄斑で不合格級**なのに対し seedance はクリーン成立。**720p 追試は 480p の輪郭ギザを解消し総合最良**(オーナー質問「720p にできるか」への実測回答 — seedance 2.5 のネイティブ上限は 720p・それ以上は SeedVR2 後段)。機械値: 全レーン識別パレットドリフト 0 件・walk 周期 1.042s 完全継承(loop 0.963〜0.972/0.940〜0.984)・carry のみ +8%(1.083s)。歩留まり全テイク 1 発。生成物 R2 保管(v1 walk b267d7…・seedance 480p walk 4ceec0…・720p 91e771…・carry 7d8b37…)。判定材料は identity 実物を第 1 行に置いた並列モンタージュ+実物ルーブリック videoReview。**identity 解像度 A/B(オーナー承認の相乗り +$2.45)**: 720p 同条件で stand 原本(右 3/4・400px)vs 高解像度 A ポーズ(正面・784px・R2 9c9a69…)— **解像度はディテールに明確に効く**(A ポーズ側が顔・襟/ボタン/ベルトで上・ループ 0.980/0.994)が **identity のポーズが視点を上書きし正面歩き化**(正準右 3/4 崩壊 — 運転知見 33。replace に無い r2v 特有の結合)。両取りレバー①「stand 原本の SeedVR2 画像 4x($0.003)→ 720p テイク($2.45)」を同日検証・**勝ち確定**: 右 3/4 完全保持のまま顔・服・輪郭安定が「meaningfully better」(videoReview 全項目優位)・ループ 0.989/0.991 は全テイク中最良 = **walk マスター候補**。identity 標準前処理を「R2 原本 → SeedVR2 4x → 白地正方形化」に確定(運転知見 33)。生成物 R2 保管(apose テイク 3cb6c8…・upscaled identity c7b3f9…・upid テイク 6149715f…)。**edit walk 再戦も受信**(キュー滞留 ~3h 後・R2 0da36c…): 外見記述なしプロンプトで**服が実物どおりに復帰** — 初回の「短パン化」は誤プロンプト起因と確定。画風は柔らかく最良級だが目の色が明るめにドリフト・ループ 0.888/0.920 は seedance 系より弱い。**正面化の修正ラウンド(オーナー実見「A ポーズ版の品質が圧倒的に上」— 残高追加チャージ後・+$7.36)**: A ポーズ優位の正体は解像度でなく**大判原画の情報量**。①**視点ロック文言のみで正面化ほぼ解消**(720p $2.45・ループ 0.978/0.988・videoReview「rock-solid edges・master 最有力」— 残差は正解よりやや正面寄りの角度・手の簡略化)②2 枚条件付け(A ポーズ+立ちセル・$2.45)は**角度が正解と完全一致**(ループ 0.993/0.994 全テイク最高)だが奥脚の変形が出る ③同形式の carry(視点ロック・720p・$2.45)もクリーン成立(0.980/0.989)。生成物 R2 保管(apose-side walk 4c422f…/carry 953453…・dual 3d3151…)。**判定材料の必須記載を規則化**(運転知見 34 — 各行にモデル・解像度・入力・料金を焼き込み・第 1 行は元画像)。**オーナー裁定: 口消えはそのままで良い(walk/carry は頭部貼り替えで出荷に乗らない — ダンス等の動画ネイティブ頭鋳造時に再対処)・walk マスターは立ちセル 4 倍化版(6149715f…)を採用**。**carry 頭起こし補正版(オーナー指示)**: Texting_Walk の下向き頭(スマホ視線)を `bpy_pose_offset.py` 新設(carry v3 の fcurve 手術の恒久ツール化)で Head X−20° 補正 → 骨署名ゲート再合格(閉包 0.214・歩容 2 歩・antiphase 1.0)→ 緑参照再作成(シルエットループ 0.999/1.0・$0)→ seedance 480p × 立ちセル 4 倍化 identity で清書 $1.09 — **顔が起きて読める+両手前保持は維持**(ループ 0.986/0.986・ドリフト 0 件)。両手持ち代替はカタログ全 680 本を再照合し既知 4 本のみ(新顔なし — Texting 継続が正)。R2 保管: 補正 GLB e9c3623e…・緑参照 155a7af8…・テイク 4e678586…。**オーナー判定「carry【2】は合格で OK」(2026-08-16)→ 台帳登録で 4a 締結**: モーション台帳 boy/walk-carry = 補正 GLB へ差し替え(参照再レンダー sha 一致 = 決定論確認)・マスターテイク台帳 walk/boy = トリム 71f・0.988/0.990(ea621ba6…)/walk-carry/boy = 68f・0.977/0.986(6a8bd7bc…)。既存 committed シートは現状維持 — 新マスターからの再鋳造は次ステップ | | 2026-08-15 | ファクトリー v2 手順 4a: 第 2 層総当たりベンチ(fal ホスト済み r2v 世代 — walk 7 レーン+carry 対照+SeedVR2 後段) | 9 | 0 | **fal BYOK $4.38**(残高 4.31→−0.07 で照合 — **宣言上限 ~$10 に対し実残高 $4.31 が実効上限**・最終テイクで僅少マイナスを許容された。残高差分の近似内訳: v1 基準 walk $0.25・wan2.7 r2v $0.76(入力+出力秒)・H3 $0.57(**名目 $0.40 — 入力動画秒が乗る実測**)・Kling o3 $0.33・**seedance2.5 $1.09**(名目 $0.57 の ~2 倍)・wan2.7 edit walk $0.21/carry ~$0.60(walk は出力秒のみ — carry 側差分は帰属不確か)・SCAIL-2 hosted $0.41・SeedVR2 $0.16。Kling 422 検証エラー ×2 は無課金)・AI Gateway $0・Meshy 0cr | ~8h(うち wan2.7 edit のキュー滞留 ~2h/本 ×2 — qpos 60+ を実測) | 実施済・オーナー判定待ち | **全レーン同一入力**(台帳緑参照 68f554…/f090cc… の骨署名 2 周期トリム × committed boy stand セル 105×192 の白地正方形化 identity — 新規 3D・Meshy 0)。**着手時再確認: fal に Wan-Animate-2 専用エンドポイントは依然なし**(v1 系 + Wan-Animate v1 ベースの wan-motion $0.06/秒のみ)、**SCAIL-2 は fal-ai/scail-2 としてホスト済みに変化**(§7 ②の「ComfyUI 唯一」が塗り替わり)→ 4a に追加投入。**⚠ 同一性列はベンチ運転ミス 2 件で汚染 — 同日オーナー指摘で訂正**(運転知見 32。①プロンプトが identity の服装を実物と違う「白 T・短パン」と誤記述 — 実物は白の襟付き半袖 Y シャツ+茶ベルト+紺長ズボン+茶靴。初版の「seedance 2.5 が同一性で v1 に勝利」は誤プロンプト追従の誤読で、**v1 こそ画像に忠実**だった ②identity に出荷用 192px セルを使用 — R2 原本 sheet-original 6cceff… の stand セル 380×418 が正)。**walk 勝敗(訂正後・モーション系判定は有効)**: ①seedance 2.5 r2v 480p = **モーション最良**(厳密交互・ループ 0.982/0.984・参照リズム継承 1.00s vs 1.042s・1 テイク成立)だが同一性は判定不能 — **誤プロンプトの服装を identity 画像より優先して描いた = テキストが画像を上書きできることの実測**(汚染リスクにも着せ替えレバーにもなる)②wan2.7 edit-video = 画風最良・モーション完全継承・最安級(服の短パン化は誤プロンプト混入の疑い — 再戦対象)③v1 基準 = 動き完全継承+**服も画像に忠実**(Y シャツ・ベルト・長ズボン保持。輪郭煮えは残存)④Kling o3 pro = 服はむしろ実物寄りだが周期 1.46s に間延び+足滑り+画風硬化で負け(服と無関係に確定)⑤H3 768p = キャラがフレーム内極小+黒ピラーボックスで判定不能(2K 追試見送り)⑥**wan2.7 r2v = モーション転写せず静止**(1 テイク即打ち切り)⑦**SCAIL-2 hosted = マゼンタノイズ崩壊**(チビ非互換が明白・即打ち切り)。**同一性の勝敗は未決着 — 再戦 4a′(原本 identity+服装をプロンプトに書かない・v1/seedance/edit の 3 レーン ~$1.6〜4)は fal 残高チャージ待ち**。**carry 対照**: v1 = 手順 3 recast テイク fb6d4e…($0 再利用)vs wan2.7 edit — **edit が絵のクリーンさで明確優位**(v1 は手元の緑斑・手の融解・目元にじみ)、ループ機械値は v1 優位(0.968/0.978 vs 0.872/0.890)。**SeedVR2 2x 後段**(seedance walk 640²→1280²・$0.16): 192px 出荷スケールでも髪・目・靴のディテール増分が残存(一部セルで髪縁の赤フリンジ)— 独立レバー成立。**構造知見**: r2v 世代はコマ非同期(fps も 30/16 に変わる)で recast の決定論対応(知見 26)を失う — 運転知見 31(初版に書いた「長ズボン化 = 緑参照レンダーへの引き寄せ」は撤回 — そもそも元画像が長ズボンで、ドリフト自体が誤判定だった)。生成物 10 点 R2 保管(v1 walk 8cd504…・wan27 r2v 63f5e1…・H3 d7391d…・Kling b2bf8e…・seedance 851be7…・edit walk c03341…/carry bd7148…・SCAIL-2 16055f…・SeedVR2 bf6e9a…・v1 carry は既存 fb6d4e…)。再現ツール `spike_r2v_bench.py`(fal_client 流用・state 永続化・残高フロア停止・2 周期トリム・PR #98 形式判定材料) | +| 2026-08-16 | ファクトリー v2 §7 残タスク 2: God Mode AI ベンチ(スプライトレーン実施・Spine レーンは認証壁で停止 — オーナー判定待ち) | 3 | 0 | **God Mode 6 クレジット(ローカル計測 — スプライト生成 3×1cr+bg-removal 3×1cr・上限 20cr の 30%。**残高 API が存在しない**ためダッシュボード照合をオーナーに依頼: 期待残 14cr)・fal/AI Gateway/Meshy $0 | ~4h | 実施済・オーナー判定待ち | **① Spine ジェネレーターレーン(ゲート本命 = 元絵ピクセル変形・再描画なし)は API 非公開で停止**: Web アプリの `/api/spine-pipeline/*` は**ブラウザセッション(Supabase)専用**で gmd_ API トークンは全ルート 401(health/jobs/retarget を実測記録 — spike_godmode.py `probe-spine`)。公開 REST 面は 405 走査で sprite/bg-removal/files/actions/models のみ・公式 MCP にも spine ツールなし → 規律(401/402 即停止・運転知見 30)どおり停止。リグ化 4〜5cr の本命評価は**オーナーのブラウザ実行**か God Mode 側の API 公開待ち。**② スプライトレーン(walk 2 テイク+carry プローブ・全て一発完走)**: 入力 = 立ちセル SeedVR2 4x の透過化 1024px(c7b3f904… に原本セルの α を写像 — 追加課金ゼロ)。出力は 768×448・16fps・4 列シート同梱。**同一性はパレット保持・様式は一次再描画**: 較正済みドリフト lint 0 件・服/体型/シルエット維持だが、輪郭が太く・髪がつや寄りの別レンダリング(videoReview 実物 identity 添付で確認)。**コマ間の輪郭ブレはゼロ**(テンプレ変形系の安定 — seedance マスターの毎コマ煮えと対照的)。ループ機械値: walking_looping = 0.889/0.862(シルエット周期 6 は**半サイクル罠の再発** — RGB 全解像度 wrap 照合 8.96 vs 5.63 で真の歩行周期 12 = 0.75s と機械確定・運転知見 36)/ walk_ffg = 0.94/0.924(周期 10 = 0.625s)— **マスター 0.988/0.990 に未達**。videoReview: t1 = 深いストライドだが足滑り・行進調、t2 = 浅いが自然、**かわいさ順位は God Mode 2 本 > 採用マスター**(安定性・チビ比率保持を評価 — ただしレビュー AI 単独裁定禁止・運転知見 14。最終判定材料 = 判定シート hantei bbc9100c…・並列ループ 7e7a8f0b…)。**③ carry 相当は不成立**: side-scrolling カタログ 115 本に両手前保持歩行なし・walking テンプレ+positive_prompt のポーズ指示は**無視され通常歩行**(ポーズはテンプレロック — kaede の carry 系統は God Mode で作れない)。**④ bg-removal(1cr)は clean シート良質・bounding_boxes は repack 座標で clean シートと不一致**(グリッド切りが正 — 運転知見 35)。生成物 11 点 R2 保管(walk_t1 6ef20831…/シート f0f85301…/clean 9e809003…・walk_t2 2a2aea26…/シート 33854952…/clean d33b9fda…・carry_t1 e464c237…/シート 29524b35…/clean 9e19bb24…・判定シート bbc9100c…・並列ループ 7e7a8f0b…)。再現 `spike_godmode.py`(state 永続化・再実行無課金・クレジット予算停止・401/402 即停止) | | 2026-08-14 | ファクトリー v2 手順 2: 3D 正本の台帳化(master_models.json 新設・boy/girl 登録・walk/carry 参照 4 本) | 0 | 0 | **Meshy 8cr**(girl 再リグ 5+Texting リターゲット 3 — 残高 952→944 で照合一致。宣言上限 ~50cr の 16%。**girl 骨署名ゲート合格で新規作成 43cr を回避**)・fal $0(CDN ホストのみ・課金なし)・AI Gateway $0 | ~3h | 実施済・オーナー判定待ち | **台帳 `master_models.json`+`model_ledger.py` 新設**(master_takes 前例の第 1 層版 — 登録条件 = R2 保管の sha256 照合・オーナー承認の出所・骨署名ゲート・緑参照のシルエットゲートを register が機械強制)。**骨署名ゲート(bone_signature.py)の較正**: クリーン(boy/girl walking 0.000・girl running 0.000・boy Texting 0.206/ループ整合 0.493)と既知不良(girl gangnam 最良 0.411・boy Carry_Heavy 0.453 — どちらも「閉包可能なサイクルなし」で機械棄却)を相対閉包 ≤0.35・整合 ≤1.0 が明確分離。歩容ゲート(L−R 足先行の交代 2 回・両振幅 ≥0.5・半周期反相 ≥0.8)が #110 の carry v2 半サイクル窓(f75-116・42 コマ)を反相 0.74<0.8 で機械棄却 — **運転知見 22「金型段階の 2 歩確認」の機械化が完了**。**登録 4 本**: boy walk(周期 24.8f・閉包 0.000)/ boy walk-carry=Texting(24.0f・0.206 — v4 の窓 s14/p24 を独立再発見)/ girl walk(24.8f・0.000 — **再利用可否ゲート合格・新規作成不要が機械確定**)/ girl walk-carry=Texting(24.25f・0.297・整合 0.907)。緑参照はサイクル正確なサブフレーム標本化(bpy_render_loop — 30fps 素材の 24fps 整数標本化が作るサブコマのシームを構造から消す)でタイル×3・シルエットゲート 0.998〜1.0・全 4 本 R2 保管(68f554…/f090cc…/284302…/4c828d…)。**新知見 3 件**: ① **Meshy はタスクを ~3 日で削除**(2026-08-10/11 の全タスク 404 実測 — boy 019febfd…も girl 019fee46…系も消滅)。リターゲット 3cr の前提だった rig タスクは恒久資産でなく、**rigging の `model_url` 入力へ R2 保管 remesh GLB を渡す再リグ(5cr)が恒久経路**(fal CDN ホスト経由・一発成功・再リグ産 walking も閉包 0.000 でリグ再現性を確認)② **girl の Texting リターゲットは前傾 +20.7°**(hips→head。boy +1.8°・girl 自身の walking +12.2° — リターゲット姿勢ガチャの定量。videoReview も同点を指摘)— 骨署名ゲートは合格のため登録し、台帳 note に代替 2 経路(boy 参照の crosschar / 背骨 fcurve 補正)を記録してオーナー判定材料へ ③ Blender 落とし穴 2 件(`--python-exit-code 1` がないとスクリプト例外でも exit 0・Cycles addon は `--` 以降を自前 argparse し `--cycle` が `--cycles-*` と曖昧衝突して登録失敗 = CYCLES エンジン消失に見える)。videoReview 補助検収: 4 参照とも「シームレス・脚の厳密な交互・boy carry は直立で両手を腹前」(girl carry の前傾のみ指摘 = 知見②)。生成物: girl Texting GLB(4e7acf…)+再リグ副産物 3 本(rigged e28149…/walking cfc085…/running 25f46f…)も R2 保管 | ## 通し運転のまとめ(①b 完了条件、2026-08-09。オーナー差し戻し 2 件の対応込み) diff --git a/scripts/factory/spike_godmode.py b/scripts/factory/spike_godmode.py new file mode 100644 index 0000000..fbf6535 --- /dev/null +++ b/scripts/factory/spike_godmode.py @@ -0,0 +1,723 @@ +#!/usr/bin/env python3 +"""Factory v2 §7 残タスク 2 spike: God Mode AI bench (docs/factory-v2-plan.md +— owner-approved 2026-08-16. The one-point gate: does a deformation-based +walk beat the adopted video-refinement master on cuteness?). + +Two lanes were ordered; only one is API-reachable (measured 2026-08-16): + +- Sprite-generator lane (THIS TOOL): REST at + https://www.godmodeai.co/api/generation-api/v1 with the gmd_ token + (Secrets GODMODE_API_TOKEN). Flow per the official skill: upload file → + POST /sprite (202 + request_id) → poll status → fetch result + (generation_url video + sprite_sheet_url). Background removal rides the + same 3-step flow under /bg-removal. 1 credit = 1 sprite generation, + bg-removal flat 1 credit (plans page + skill doc). +- Spine-generator lane (BLOCKED — `probe-spine` records the evidence): + the web app drives it through the Next.js proxy /api/spine-pipeline/*, + which authenticates the *browser session* (Supabase cookie / session + JWT). The gmd_ API token gets 401 on every spine route, the public REST + surface has no spine paths (405-scan: only /sprite, /bg-removal, + /files/file/local, /actions/catalog, /models), and the official MCP + exposes no spine tools. Per the task discipline (401/402 → immediate + stop + owner report), that lane stops here. + +Money mechanics follow the spike_pixellab.py precedent: every paid call is +persisted in /state.json under its key the moment the submit +returns, so re-runs poll the same request instead of re-billing; a +--budget-credits stop guards every submission. One deviation is forced by +the provider: God Mode has NO balance API (the dashboard reads credits via +the browser-session Supabase client), so the PixelLab-style balance +reconciliation is impossible — credits are metered locally from the +documented per-call prices and the owner's dashboard is the only +authoritative counter. 401/402/403 and FAILED("Insufficient credits") are +hard stops: report to the owner, never route around. + +Usage: + export GODMODE_API_TOKEN=gmd_... CLOUDFLARE_API_TOKEN=... + python3 scripts/factory/spike_godmode.py prepare + python3 scripts/factory/spike_godmode.py sprite \ + --key walk_t1 --image identity_godmode.png \ + --action sidescrolling_walking_looping [--seed 1234] \ + [--positive-prompt ...] [--auto-repose] + python3 scripts/factory/spike_godmode.py bgremove \ + --key bg_walk_t1 --source walk_t1 + python3 scripts/factory/spike_godmode.py probe-spine + python3 scripts/factory/spike_godmode.py analyze + python3 scripts/factory/spike_godmode.py material + python3 scripts/factory/spike_godmode.py upload-r2 +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import time +from pathlib import Path + +import numpy as np +import requests +from PIL import Image + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) + +from factory.art_lint import check_palette_drift # noqa: E402 +from factory.compose_sheet import chroma_key_greenwear, content_bbox # noqa: E402 +from factory.loop_scan import best_loop_start, mask_iou # noqa: E402 +from factory.spike_r2v_bench import judgment_sheet # noqa: E402 +from factory.verdict_material import loop_video, montage_rows, scaled # noqa: E402 +from factory.video import extract_frames, probe, run_quiet # noqa: E402 +from r2_originals import get_object, put_object # noqa: E402 + +BASE = "https://www.godmodeai.co/api/generation-api/v1" +SPINE_PROXY = "https://www.godmodeai.co/api/spine-pipeline" +POLL_SECONDS = 5 +POLL_TIMEOUT_SECONDS = 20 * 60 +# Tier-1 rate limit is 6 RPM — a 429 waits out the window instead of dying. +RATE_LIMIT_WAIT_SECONDS = 15 +RATE_LIMIT_MAX_RETRIES = 10 + +# Documented per-call prices (plans page 2026-08-16: "1 credit = 1 sprite +# generation"; the skill doc: bg-removal "Flat 1 credit"). There is no +# balance API to reconcile against — see the module doc. +SPRITE_CREDITS = 1.0 +BGREMOVAL_CREDITS = 1.0 +DEFAULT_BUDGET_CREDITS = 6.0 +# The owner bought the minimum 20-credit pack — an absolute ceiling no +# --budget-credits value may exceed. +CREDIT_PACK_CEILING = 20.0 + +# Identity inputs are pinned by content address (運転知見 32: the R2 +# original stand cell is the identity authority; the SeedVR2 4x upscale is +# the adopted walk master's identity preprocessing — 運転知見 33). +IDENTITY_SHEET_SHA256 = ( + "6cceff101abf66034f89ad98facf725300417347f623aad0bf084f89bd123f12" +) +IDENTITY_SHEET_COLS = 5 +UPSCALED_IDENTITY_SHA256 = ( + "c7b3f9049161e4acbfb09bf3d43c92b295d7ee7148922b25c39f0b66b179ae0b" +) +# God Mode's own input guidance: full body, transparent PNG, ~700-1024px. +GODMODE_INPUT_MAX_SIDE = 1024 + +MASTER_TAKES = ROOT / "scripts" / "factory" / "master_takes.json" +COMMITTED_AVATAR = ROOT / "packages" / "client" / "src" / "game.package" / "avatar" +JUDGMENT_FONT = "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc" +PHASE_SAMPLES = 8 + + +def token() -> str: + key = os.environ.get("GODMODE_API_TOKEN") + if not key: + raise SystemExit("GODMODE_API_TOKEN is not set") + return key + + +def api(method: str, path: str, *, payload: dict | None = None, + files: dict | None = None) -> dict: + for attempt in range(RATE_LIMIT_MAX_RETRIES): + response = requests.request( + method, + f"{BASE}{path}", + headers={"Authorization": f"Bearer {token()}"}, + json=payload, + files=files, + timeout=180, + ) + if response.status_code == 429: + print(f" 429 rate-limited — waiting {RATE_LIMIT_WAIT_SECONDS}s " + f"({attempt + 1}/{RATE_LIMIT_MAX_RETRIES})", flush=True) + time.sleep(RATE_LIMIT_WAIT_SECONDS) + continue + if response.status_code in (401, 402, 403): + # Task rule: auth/payment rejections stop the bench — the charge + # decision goes back to the owner, no workaround is attempted. + raise SystemExit( + f"God Mode {method} {path} -> HTTP {response.status_code} " + f"{response.text[:600]}\n" + "STOP: report to the owner (401/402 discipline — 運転知見 30)." + ) + if response.status_code not in (200, 201, 202): + raise SystemExit( + f"God Mode {method} {path} failed: HTTP {response.status_code} " + f"{response.text[:800]}" + ) + return response.json() + raise SystemExit(f"God Mode {method} {path}: still 429 after " + f"{RATE_LIMIT_MAX_RETRIES} waits — stopping") + + +def digest_of(*parts: bytes | str) -> str: + h = hashlib.sha256() + for part in parts: + h.update(part if isinstance(part, bytes) else part.encode()) + return h.hexdigest()[:12] + + +class Spike: + def __init__(self, work: Path, budget_credits: float) -> None: + self.work = work + self.budget_credits = min(budget_credits, CREDIT_PACK_CEILING) + self.state_path = work / "state.json" + self.state: dict = ( + json.loads(self.state_path.read_text()) + if self.state_path.exists() + else {"spent_credits": 0.0} + ) + + def save(self) -> None: + self.state_path.write_text(json.dumps(self.state, indent=1)) + + def charge(self, credits: float) -> None: + if self.state["spent_credits"] + credits > self.budget_credits: + raise SystemExit( + f"credit budget stop: {self.state['spent_credits']:g} spent " + f"+ {credits:g} would exceed --budget-credits " + f"{self.budget_credits:g} — not submitting" + ) + + def upload(self, path: Path) -> str: + """Free file hosting — cached by content digest.""" + uploads = self.state.setdefault("uploads", {}) + digest = digest_of(path.read_bytes()) + if digest not in uploads: + result = api( + "POST", "/files/file/local", + files={"file_upload": (path.name, path.read_bytes(), "image/png")}, + ) + uploads[digest] = result["url"] + self.save() + print(f" uploaded {path.name} -> {uploads[digest]}") + return uploads[digest] + + def submit(self, key: str, path: str, payload: dict, credits: float) -> dict: + """Persist the paid POST the moment it returns (crash-safe: a re-run + polls the recorded request instead of paying again).""" + submits = self.state.setdefault("submits", {}) + if key not in submits: + self.charge(credits) + submits[key] = api("POST", path, payload=payload) + # The billed recipe (minus the throwaway upload URL) rides the + # state file so the judgment sheet can burn it in later. + recipe = {k: v for k, v in payload.items() if k != "image_url"} + self.state.setdefault("recipes", {})[key] = recipe + self.state["spent_credits"] += credits + self.save() + print(f"[{key}] submitted — spent (metered) " + f"{self.state['spent_credits']:g}/{self.budget_credits:g} cr") + return submits[key] + + def poll(self, key: str, kind: str) -> dict: + """kind: 'sprite' | 'bg-removal'.""" + results = self.state.setdefault("results", {}) + if key in results: + return results[key] + request_id = self.state["submits"][key]["request_id"] + deadline = time.time() + POLL_TIMEOUT_SECONDS + while True: + status = api("GET", f"/{kind}/requests/{request_id}/status") + state = status.get("status") + if state == "COMPLETED": + result = api("GET", f"/{kind}/requests/{request_id}") + results[key] = result + self.save() + return result + if state == "FAILED": + message = status.get("error_message") or json.dumps(status)[:400] + if "insufficient credits" in message.lower(): + raise SystemExit( + f"[{key}] FAILED: {message}\n" + "STOP: credits exhausted — report to the owner " + "(402 discipline)." + ) + raise SystemExit(f"[{key}] FAILED: {message}") + if time.time() > deadline: + raise SystemExit( + f"[{key}] still {state} after {POLL_TIMEOUT_SECONDS}s — " + "re-run the same command to resume polling (no re-bill)" + ) + print(f" [{key}] {state}", flush=True) + time.sleep(POLL_SECONDS) + + def download(self, url: str, dest: Path) -> Path: + if not dest.exists(): + response = requests.get(url, timeout=300) + response.raise_for_status() + dest.write_bytes(response.content) + print(f" saved {dest} ({len(response.content)} bytes)") + return dest + + +# ---------------------------------------------------------------- prepare + + +def cmd_prepare(spike: Spike, _args: argparse.Namespace) -> None: + work = spike.work + # Ground-truth identity cell: the stand cell cut from the R2 ORIGINAL + # sheet (the spike_r2v_bench prepare precedent) — the palette anchor and + # judgment-sheet row 1. + sheet_path = work / "identity_sheet_original.png" + if not sheet_path.exists(): + sheet_path.write_bytes(get_object(IDENTITY_SHEET_SHA256)) + sheet = Image.open(sheet_path).convert("RGBA") + stand_canvas = sheet.crop((0, 0, sheet.width // IDENTITY_SHEET_COLS, sheet.height)) + cell = chroma_key_greenwear(stand_canvas) + cell = cell.crop(content_bbox(cell)) + cell.save(work / "identity_cell.png") + + # God Mode input: the SeedVR2 4x identity (the adopted walk master's + # identity — high resolution favors a tool that cuts pieces from the + # artwork), made transparent by mapping the original cell's alpha onto + # the upscaled square (the upscale is geometry-preserving; the square + # was built by pasting the cell at a known offset, so the alpha maps + # back deterministically). + up_path = work / "identity_upscaled_square.png" + if not up_path.exists(): + up_path.write_bytes(get_object(UPSCALED_IDENTITY_SHA256)) + upscaled = Image.open(up_path).convert("RGB") + side = max(cell.size) # the 400px square the upscale was built from + scale = upscaled.width // side + alpha_canvas = Image.new("L", (side * scale, side * scale), 0) + cell_alpha = cell.getchannel("A").resize( + (cell.width * scale, cell.height * scale), Image.LANCZOS + ) + offset = ((side - cell.width) // 2 * scale, (side - cell.height) * scale) + alpha_canvas.paste(cell_alpha, offset) + transparent = upscaled.convert("RGBA") + transparent.putalpha(alpha_canvas) + transparent = transparent.crop(content_bbox(transparent)) + shrink = min(1.0, GODMODE_INPUT_MAX_SIDE / max(transparent.size)) + if shrink < 1.0: + transparent = scaled(transparent, shrink) + input_path = work / "identity_godmode.png" + transparent.save(input_path) + + manifest = { + "identityCell": {"file": "identity_cell.png", "size": cell.size, + "sheetSha256": IDENTITY_SHEET_SHA256}, + "godmodeInput": {"file": input_path.name, "size": transparent.size, + "upscaledSha256": UPSCALED_IDENTITY_SHA256}, + } + (work / "inputs.json").write_text(json.dumps(manifest, indent=1) + "\n") + zoom = cell.resize((cell.width * 2, cell.height * 2), Image.NEAREST) + montage_rows([[cell, zoom], [transparent]], work / "identity_preflight.png") + print(f"prepared: cell {cell.size}, God Mode input {transparent.size}") + + +# ------------------------------------------------------------------ lanes + + +def cmd_sprite(spike: Spike, args: argparse.Namespace) -> None: + image_path = spike.work / args.image + payload: dict = { + "image_url": spike.upload(image_path), + "action_id": args.action, + "view_type": args.view, + } + if args.seed is not None: + payload["seed"] = args.seed + if args.positive_prompt: + payload["positive_prompt"] = args.positive_prompt + if args.auto_repose: + payload["auto_repose"] = True + spike.submit(args.key, "/sprite", payload, SPRITE_CREDITS) + result = spike.poll(args.key, "sprite") + generation_url = result["generation_url"] + suffix = Path(generation_url.split("?")[0]).suffix or ".mp4" + spike.download(generation_url, spike.work / f"{args.key}{suffix}") + if result.get("sprite_sheet_url"): + spike.download(result["sprite_sheet_url"], spike.work / f"{args.key}_sheet.png") + if result.get("auto_repose_url"): + spike.download(result["auto_repose_url"], spike.work / f"{args.key}_repose.png") + print(f"[{args.key}] COMPLETED") + + +def cmd_bgremove(spike: Spike, args: argparse.Namespace) -> None: + source = spike.state.get("results", {}).get(args.source) + if not source or not source.get("sprite_sheet_url"): + raise SystemExit(f"no sprite result for --source {args.source!r}") + payload: dict = {"image_url": source["sprite_sheet_url"]} + if args.model: + payload["model"] = args.model + spike.submit(args.key, "/bg-removal", payload, BGREMOVAL_CREDITS) + result = spike.poll(args.key, "bg-removal") + spike.download(result["clean_sprite_sheet_url"], spike.work / f"{args.key}_clean.png") + if result.get("bbox_overlay_sprite_sheet_url"): + spike.download(result["bbox_overlay_sprite_sheet_url"], + spike.work / f"{args.key}_bbox.png") + if result.get("preview_url"): + preview = result["preview_url"] + suffix = Path(preview.split("?")[0]).suffix or ".mp4" + spike.download(preview, spike.work / f"{args.key}_preview{suffix}") + (spike.work / f"{args.key}_boxes.json").write_text( + json.dumps(result.get("bounding_boxes", []), indent=1) + "\n" + ) + print(f"[{args.key}] COMPLETED — {result.get('total_frames')} frames") + + +def cmd_probe_spine(spike: Spike, _args: argparse.Namespace) -> None: + """Free: record the spine lane's auth wall as reproducible evidence.""" + evidence = {} + for path in ("health", "jobs/probe", "retarget/animations"): + response = requests.get( + f"{SPINE_PROXY}/{path}", + headers={"Authorization": f"Bearer {token()}"}, + timeout=60, + ) + evidence[path] = {"status": response.status_code, + "body": response.text[:300]} + print(f"GET /api/spine-pipeline/{path} -> {response.status_code} " + f"{response.text[:120]}") + (spike.work / "spine_probe.json").write_text(json.dumps(evidence, indent=1) + "\n") + + +# ---------------------------------------------------------------- analyze + + +def background_silhouettes(frame_paths: list[Path]) -> list[np.ndarray]: + """Silhouette masks for solid-background clips: the background color is + the median of the border pixels, the silhouette is everything farther + than a fixed distance (God Mode videos are not chroma-keyed green, so + the loop_scan alpha silhouette does not apply).""" + masks = [] + for path in frame_paths: + rgb = np.asarray(Image.open(path).convert("RGB")).astype(int) + border = np.concatenate([rgb[0], rgb[-1], rgb[:, 0], rgb[:, -1]]) + background = np.median(border, axis=0) + distance = np.abs(rgb - background).sum(axis=2) + image = Image.fromarray((distance > 60).astype(np.uint8) * 255) + height = max(1, round(image.height * 160 / image.width)) + small = image.resize((160, height), Image.BILINEAR) + masks.append(np.asarray(small) >= 128) + return masks + + +def rgb_wrap_difference(frame_paths: list[Path], start: int, period: int) -> float: + a = np.asarray(Image.open(frame_paths[start]).convert("RGB")).astype(int) + b = np.asarray(Image.open(frame_paths[start + period]).convert("RGB")).astype(int) + return float(np.abs(a - b).mean()) + + +def refine_gait_period(frame_paths: list[Path], loop: dict) -> dict: + """Half-cycle correction (運転知見 22's 2D analogue): a 3/4-view chibi's + mirrored steps are silhouette-identical, so the IoU scan can lock onto + the HALF gait cycle. Full-resolution RGB tells the steps apart (front + vs back leg shading): when the wrap at 2x the silhouette period matches + markedly better in RGB, the gait period is the double (measured on + walk_t1: P=6 diff 8.96 vs 2P=12 diff 5.63).""" + start, period = loop["start"], loop["period"] + gait = period + # best_loop_start legitimately returns start == len - 2 * period, which + # puts the 2P wrap one frame past the clip; anchor the comparison earlier + # so the check always fits (measure_loop guarantees len >= 2P + 1). + anchor = min(start, len(frame_paths) - 1 - 2 * period) + if anchor >= 0: + at_p = rgb_wrap_difference(frame_paths, anchor, period) + at_2p = rgb_wrap_difference(frame_paths, anchor, 2 * period) + if at_2p < at_p * 0.8: + gait = 2 * period + return {**loop, "gaitPeriod": gait} + + +def measure_loop(masks: list[np.ndarray]) -> dict | None: + """Best (start, period) over the full plausible range — God Mode clips + carry no reference period, so the search is unconstrained.""" + hi = (len(masks) - 1) // 2 + if hi < 6: + return None + best: tuple[float, int, int] | None = None + for period in range(6, hi + 1): + start, score = best_loop_start(masks, period) + if best is None or score > best[0]: + best = (score, period, start) + score, period, start = best + return { + "start": start, + "period": period, + "loopMean": round(score, 3), + "closure": round(mask_iou(masks[start], masks[start + period]), 3), + } + + +def sheet_cells(spike: Spike, bg_key: str) -> list[Image.Image]: + """Transparent per-frame cells from a bg-removal result. + + Sliced on the 4-column video-frame grid, NOT on the returned + bounding_boxes: the boxes describe a repacked tight layout whose x/y do + not land on the clean sheet's actual cells (measured 2026-08-16 — cell + (260,0,244x448) is 61% opaque but the box for frame 1 points at empty + canvas). The grid is authoritative because the sheet is column-major + 4-wide by construction. + """ + boxes = json.loads((spike.work / f"{bg_key}_boxes.json").read_text()) + sheet = Image.open(spike.work / f"{bg_key}_clean.png").convert("RGBA") + columns = 4 + rows = -(-len(boxes) // columns) + cell_w, cell_h = sheet.width // columns, sheet.height // rows + cells = [] + for index in range(len(boxes)): + row, col = divmod(index, columns) + cell = sheet.crop((col * cell_w, row * cell_h, + (col + 1) * cell_w, (row + 1) * cell_h)) + cells.append(cell.crop(content_bbox(cell))) + return cells + + +def sprite_takes(spike: Spike) -> list[str]: + results = spike.state.get("results", {}) + return [key for key in results if not key.startswith("bg_")] + + +def cmd_analyze(spike: Spike, _args: argparse.Namespace) -> None: + work = spike.work + report: dict = {"spentCreditsMetered": spike.state["spent_credits"], + "balanceApi": "none (dashboard-only — see module doc)", + "takes": {}} + identity_cell = Image.open(work / "identity_cell.png").convert("RGBA") + for key in sprite_takes(spike): + videos = sorted(work.glob(f"{key}.*")) + video = next((v for v in videos if v.suffix in (".mp4", ".webm", ".webp")), None) + if video is None: + continue + info = probe(video) + frame_paths = extract_frames(video, work / f"frames_{key}") + masks = background_silhouettes(frame_paths) + loop = measure_loop(masks) + if loop: + loop = refine_gait_period(frame_paths, loop) + entry: dict = { + "file": video.name, + "frames": info.frames, + "fps": round(info.fps, 2), + "seconds": round(info.duration, 2), + "loop": loop, + } + if loop: + entry["cycleSeconds"] = round(loop["gaitPeriod"] / info.fps, 3) + bg_key = f"bg_{key}" + if (work / f"{bg_key}_boxes.json").exists(): + cells = sheet_cells(spike, bg_key) + mid = cells[len(cells) // 2] + entry["identityPaletteDrift"] = check_palette_drift( + identity_cell, mid.crop(content_bbox(mid)) + ) + entry["sheetFrames"] = len(cells) + report["takes"][key] = entry + print(f"{key}: {json.dumps(entry)}") + (work / "bench-report.json").write_text(json.dumps(report, indent=1) + "\n") + + +# --------------------------------------------------------------- material + + +def master_cells(work: Path, n: int = PHASE_SAMPLES) -> list[Image.Image]: + """Phase cells of the adopted walk master (ledger walk/boy) — the + comparison baseline the gate names.""" + ledger = json.loads(MASTER_TAKES.read_text())["masters"]["walk/boy"] + path = work / "master_walk.mp4" + if not path.exists(): + path.write_bytes(get_object(ledger["masterSha256"])) + frame_paths = extract_frames(path, work / "frames_master_walk") + start, period = ledger["loop"]["start"], ledger["loop"]["period"] + cells = [] + for i in range(n): + index = min(start + round(i * period / n), len(frame_paths) - 1) + keyed = chroma_key_greenwear(Image.open(frame_paths[index])) + cells.append(keyed.crop(content_bbox(keyed))) + return cells + + +def take_loop(spike: Spike, key: str) -> dict: + entry = json.loads( + (spike.work / "bench-report.json").read_text() + )["takes"][key] + loop = entry.get("loop") + if not loop: + return {"start": 0, "gaitPeriod": entry["frames"] - 1} + return loop + + +def take_cells(spike: Spike, key: str, n: int = PHASE_SAMPLES) -> list[Image.Image]: + """Phase cells over the measured GAIT cycle (not the silhouette period — + sampling the half cycle would fabricate a one-leg skip), preferring + bg-removed sheet cells (true transparency; sheet frames map 1:1 onto + video frames) over background-thresholded video frames.""" + work = spike.work + loop = take_loop(spike, key) + start, period = loop["start"], loop["gaitPeriod"] + bg_key = f"bg_{key}" + if (work / f"{bg_key}_boxes.json").exists(): + cells = sheet_cells(spike, bg_key) + return [cells[min(start + round(i * period / n), len(cells) - 1)] + for i in range(n)] + video = next((v for v in sorted(work.glob(f"{key}.*")) + if v.suffix in (".mp4", ".webm", ".webp")), None) + frame_paths = extract_frames(video, work / f"frames_{key}") + cells = [] + for i in range(n): + index = min(start + round(i * period / n), len(frame_paths) - 1) + rgb = np.asarray(Image.open(frame_paths[index]).convert("RGB")).astype(int) + border = np.concatenate([rgb[0], rgb[-1], rgb[:, 0], rgb[:, -1]]) + background = np.median(border, axis=0) + alpha = (np.abs(rgb - background).sum(axis=2) > 60).astype(np.uint8) * 255 + cell = Image.open(frame_paths[index]).convert("RGBA") + cell.putalpha(Image.fromarray(alpha)) + cells.append(cell.crop(content_bbox(cell))) + return cells + + +def committed_cells() -> list[Image.Image]: + names = ["walk-a.png", "walk-b.png", "walk-c.png", "walk-d.png"] + return [Image.open(COMMITTED_AVATAR / name).convert("RGBA") for name in names] + + +def stack_loop_videos(videos: list[Path], labels: list[str], out: Path) -> None: + """Side-by-side loop video with Japanese labels burned in (運転知見 34 + — wqy-microhei; DroidSansFallback tofus Latin).""" + existing = [(v, l) for v, l in zip(videos, labels) if v.exists()] + if len(existing) < 2: + return + cmd = ["ffmpeg", "-y", "-loglevel", "error"] + for video, _ in existing: + cmd += ["-stream_loop", "-1", "-t", "6", "-i", str(video)] + + def escape(label: str) -> str: + # drawtext re-parses its text value, so option separators inside the + # quoted label still need escaping (an unescaped ':' tofu'd the whole + # label — measured 2026-08-16). + return label.replace("\\", "\\\\").replace(":", "\\:").replace("'", "") + + parts = [ + f"[{i}:v]scale=-2:420,drawtext=fontfile={JUDGMENT_FONT}:" + f"text='{escape(label)}':x=8:y=8:fontsize=22:fontcolor=black:box=1:" + f"boxcolor=white@0.7[v{i}]" + for i, (_, label) in enumerate(existing) + ] + chain = "".join(f"[v{i}]" for i in range(len(existing))) + filtergraph = ";".join(parts) + f";{chain}hstack=inputs={len(existing)}[out]" + cmd += ["-filter_complex", filtergraph, "-map", "[out]", + "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p", str(out)] + run_quiet(cmd) + print(f"wrote {out.name}") + + +def cmd_material(spike: Spike, args: argparse.Namespace) -> None: + work = spike.work + identity = Image.open(work / "identity_cell.png").convert("RGBA") + report = json.loads((work / "bench-report.json").read_text()) + rows: list[tuple[str, str, list[Image.Image]]] = [ + ("1. 元画像(正解) — boy stand セル", "R2 原本 sheet-original の第 1 セル " + "218×400(6cceff…)。God Mode 入力はこの SeedVR2 4x 透過版 1024px", + [identity]), + ("2. committed walk セル(現行出荷)", "packages/client avatar walk-a..d " + "192px — 承認済み出荷セル", committed_cells()), + ("3. 採用マスター walk/boy(比較基準)", "seedance 2.5 720p × 立ちセル " + "SeedVR2 4x identity(ea621ba6…・loop 0.988/0.990・$2.45)— " + "2026-08-16 オーナー採用", master_cells(work)), + ] + index = 4 + for key, entry in report.get("takes", {}).items(): + loop = entry.get("loop") or {} + drift = entry.get("identityPaletteDrift") + drift_note = (f"・パレットドリフト {len(drift)} 件" + if isinstance(drift, list) else "") + submitted = spike.state.get("recipes", {}).get(key, {}) + prompt_note = ("・positive_prompt あり(両手前保持を指示)" + if submitted.get("positive_prompt") else "") + bg_note = ("+bg-removal 1cr" + if (work / f"bg_{key}_boxes.json").exists() else "") + recipe = ( + f"God Mode sprite API・action={submitted.get('action_id')}・" + f"seed={submitted.get('seed')}{prompt_note}・" + f"{entry['file']} {entry['frames']}f/{entry['fps']}fps・" + f"loop {loop.get('loopMean')}/{loop.get('closure')}・" + f"歩行周期 {entry.get('cycleSeconds')}s・実測 {SPRITE_CREDITS:g}cr{bg_note}" + f"{drift_note}" + ) + rows.append((f"{index}. God Mode {key}", recipe, take_cells(spike, key))) + index += 1 + judgment_sheet(rows, work / "hantei_godmode_walk.png") + + # Side-by-side loop: adopted master vs each God Mode take (100ms/cell, + # the PixelLab-precedent judging speed). + master = master_cells(work) + loop_video(master, work / "loop_master.mp4") + videos = [work / "loop_master.mp4"] + # Short labels only — long text overflows a 100ms-loop column and + # collides with its neighbor (運転知見 34: no cut-off text; the full + # recipes live on the judgment sheet). + labels = ["基準: 採用マスター"] + for key in report.get("takes", {}): + cells = take_cells(spike, key) + loop_video(cells, work / f"loop_{key}.mp4") + videos.append(work / f"loop_{key}.mp4") + labels.append(f"God Mode {key}") + stack_loop_videos(videos, labels, work / "loops_side_by_side_walk.mp4") + + +# ----------------------------------------------------------------- upload + + +def cmd_upload_r2(spike: Spike, _args: argparse.Namespace) -> None: + work = spike.work + hashes = {} + patterns = ["*_t*.mp4", "*_t*.webm", "*_t*.webp", "*_sheet.png", + "*_clean.png", "hantei_godmode_walk.png", + "loops_side_by_side_walk.mp4"] + seen = set() + for pattern in patterns: + for path in sorted(work.glob(pattern)): + if (path.name in seen + or path.stem.startswith(("loop_", "frames_")) + or path.stem.endswith("_preview")): + continue + seen.add(path.name) + hashes[path.name] = put_object(path.read_bytes()) + print(f"{path.name}: {hashes[path.name]}") + (work / "r2-hashes.json").write_text(json.dumps(hashes, indent=1) + "\n") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("workdir", type=Path) + parser.add_argument("--budget-credits", type=float, + default=DEFAULT_BUDGET_CREDITS) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("prepare") + sprite = sub.add_parser("sprite") + sprite.add_argument("--key", required=True) + sprite.add_argument("--image", required=True) + sprite.add_argument("--action", required=True) + sprite.add_argument("--view", default="side-scrolling") + sprite.add_argument("--seed", type=int, default=None) + sprite.add_argument("--positive-prompt", default=None) + sprite.add_argument("--auto-repose", action="store_true") + bgremove = sub.add_parser("bgremove") + bgremove.add_argument("--key", required=True) + bgremove.add_argument("--source", required=True) + bgremove.add_argument("--model", default=None) + sub.add_parser("probe-spine") + sub.add_parser("analyze") + sub.add_parser("material") + sub.add_parser("upload-r2") + + args = parser.parse_args() + args.workdir.mkdir(parents=True, exist_ok=True) + spike = Spike(args.workdir, args.budget_credits) + commands = { + "prepare": cmd_prepare, + "sprite": cmd_sprite, + "bgremove": cmd_bgremove, + "probe-spine": cmd_probe_spine, + "analyze": cmd_analyze, + "material": cmd_material, + "upload-r2": cmd_upload_r2, + } + commands[args.command](spike, args) + + +if __name__ == "__main__": + main()