# data_contract.yaml — AppPromoVideo の名詞台帳 (コードより先に凍結する) # # 規律: ここに無い名詞はコードに現れない。フィールドを撤去したら、その名前で # CLAUDE.md / specs/*.md / doc comment を grep して追従漏れを回収してから完了とする。 # `history.md` は追記専用の記録なので回収の対象外 (過去のエントリはその時点の事実として残す)。 # 移植元: Kataribe (D:/Github/Kataribe) の image_gen.rs / specs 24-27。移植した契約には (Kataribe) と記す。 meta: project: AppPromoVideo frozen: 2026-09-07 rev: "rev2 (2026-09-07 ユーザー査読 11 点を反映。specs/01 の『査読の反映』節が対応表)" gui: "Tauri 2 + Vue 3 (2026-09-07 ユーザー決定。構想文の Flutter + flutter_rust_bridge は不採用 — Kataribe の image_gen / 設定 UI がそのまま移植でき、この機体に Flutter が無い)" rust: "edition 2024 / rust-version 1.85 (構想文の Edition 2024 / 1.78 は矛盾。2024 edition は 1.85+)" scope_out: - "動画ファイルのレンダリング・動画生成サービス (Veo / Sora / Runway) との直接連携。成果物は動画生成用プロンプトと参照画像のみ" - "LLM の HTTP API 直接呼び出し (禁止。ローカル CLI のサブプロセスのみ)。画像生成の HTTP (OpenAI Images / Gemini / ComfyUI) は許可 — LLM ではない" # --------------------------------------------------------------------------- # 入力 # --------------------------------------------------------------------------- PromoProject: # 1 案件 = 1 リポジトリ。GUI の入力ペインと export の manifest が同じ形を持つ project_path: PathBuf # 解析対象リポジトリの root (存在必須・ディレクトリ必須) snapshot_paths: Vec # UI スナップショット。png/jpg/jpeg/webp のみ (拡張子は大文字小文字を区別しない)。0 枚可 video_concept: String # 動画イメージ / 世界観の指示 (ユーザー編集のシステムプロンプト素材) language: "ja | en" # コピー・ナレーションの言語。video_prompt は常に英語 (動画生成モデル向け) cli: CliSpec image: ImageGenConfig # (Kataribe) 非秘密のみ。API キーは app_data/.env export_dir: PathBuf # --------------------------------------------------------------------------- # LLM CLI (サブプロセス) # --------------------------------------------------------------------------- CliSpec: kind: "claude | aider | agy | custom" # agy = 2026-09-12 追加 (別系統の CLI。封筒も argv も claude と別。IsolationGuarantee を見ること) executable: String # 絶対パス or PATH 上の名前。起動前に存在検査 (which 同等) — 無ければ CliError::NotFound extra_args: Vec # ユーザー追加引数 (例 --dangerously-skip-permissions)。kind 既定引数の後ろに付く timeout_secs: u64 # 既定 600。最小 30 (それ未満は 30 に切り上げ) model: Option # claude: --model へ。aider: --model へ。custom: 無視 CliInvocation: # build_invocation の戻り。純粋 (プロセスを起動しない) — PoC で固定 program: String args: Vec prompt: String # 指示本文 (RepoBrief.render() + 指示)。全 kind で引数に載せない (OS の引数長制限とログ漏れ。rev2 査読 1 で aider の例外を撤去) transport: "stdin | message_file" # stdin = claude / custom。message_file = aider (runner が一時ファイルに書き `--message-file ` を argv 末尾に足す。aider 公式 -f) cwd: PathBuf # app 所有の作業ディレクトリ (app_data/work)。**project_path にしない** — `-p` は cwd の .claude/settings.json の hook と .mcp.json を信頼ダイアログなしで実行する (公式 headless 文書)。解析対象は他人のリポジトリになり得る structured: "json_schema | fenced_json | none" kind: CliKind # runner が畳み方 (claude = stream-json / 他 = 生 stdout) を選ぶために持つ env_scrub: # 2026-09-08 GUI 実測 (failures #7) 起点。cli_runner::env_scrub always_removed: "CLAUDE_CODE_* / CLAUDE_PREVIEW_* / CLAUDECODE / CLAUDE_PID / CLAUDE_EFFORT / CLAUDE_AGENT_SDK_VERSION — Claude Code がホストとして子に注入する変数。本アプリの子 CLI は独立プロセスなので渡さない (ホストのメッセージングソケットに認証更新を頼む経路を断つ)。ANTHROPIC_* は残す" oauth_only: "CliSpec.oauth_only (GUI 設定『OAuth ログインを使う』) が true なら ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN も外し、claude auth login の OAuth を使わせる。端末の鍵が古い・無効なときの回避 (RunOptions.env_remove)。**スイッチが効くのは claude だけ** — agy は設定に関係なく常に外し (rev52)、aider / custom は設定に関係なく外さない (rev53)。`pipeline::task::env_remove_for`" agy_keys: "rev52 (ユーザー決定 2026-09-14)。agy に Anthropic の鍵を持たせる理由が無く、隔離が弱い (IsolationGuarantee.detect_only)。それまでは種類を問わずスイッチだけで決まり、切ると agy も鍵を環境変数として引き継いでいた。画面は agy の間スイッチを出さず (代わりの文言も置かない — 初版の『agy には Anthropic の鍵を渡しません』はユーザー判断で撤去)、診断の ANTHROPIC_* / claude auth status 行も出さない (スイッチがあると『切れば渡す』と読める)。進捗ログの認証の行も agy では 1 行も出さない (初版の『agy には … を渡しません』も撤去 — agy なのに Anthropic の話を出すのは不自然、ユーザー判断)。promo CLI も同じ関数を通す" other_kinds: "rev53 (ユーザー決定 2026-09-14)。aider / custom も画面のスイッチ・ANTHROPIC_* / claude auth status の診断・進捗ログの認証の行を出さない (claude だけが持つ)。ただし鍵は**外さず普通に引き継ぐ** — aider は ANTHROPIC_API_KEY を環境変数から読む (aider 公式文書 docs/llms/anthropic)。常に外すと aider で Claude 系を使うには extra_args に --anthropic-api-key を書くしかなく、鍵が CliRawLog の .invocation.json に残る。スイッチを出さない種類では保存値を効かせない (見えない設定で挙動を変えない)" diagnostics: "check_cli が AuthView {api_key_present, api_key_len, api_key_fingerprint (FNV 12 桁、値は出さない), auth_token_present, base_url, oauth_logged_in, oauth_method, scrubbed[]} を返し、設定 → LLM タブに表示。run 開始時にも同じ 1 行を進捗ログへ" poc: "tests/runner.rs host_coupling_env_is_scrubbed_but_user_config_passes (fake_cli env モード: CLAUDE_CODE_MESSAGING_SOCKET / CLAUDECODE が absent、ANTHROPIC_* 相当が present、env_remove 指定で absent)" spawn: "**子プロセスの起動は必ず `cli_runner::no_window` の `std_command` / `tokio_command` を通す** (rev56)。Windows で `CREATE_NO_WINDOW` を付け、配布ビルド (windows_subsystem = windows、コンソールを持たない) からコンソール用プログラムを起動しても窓を出さない。対象は CLI 本体・`--version`・`auth status`・`git ls-files`・フォルダを開く処理のすべて。付け忘れは `no_window::tests::no_direct_command_new` が落とす (除外は no_window.rs 自身と fake_cli)。窓が出ないこと自体は配布ビルドでしか観測できない" invariants: # 純関数のテストで固定 (rev2 査読 3) - "allowedTools に Read が入る時は必ず --add-dir が付く (claude のみ。agy に --allowedTools は存在しない)" - "cwd != project_path (全 kind)" - "`--add-dir` は project + `TaskSpec.extra_read_dirs` (スナップショットの置き場)。フラグごと繰り返す — claude は可変長、agy は repeatable で、どちらも『フラグ + 値』の繰り返しを受ける (rev42)" - "本文は argv に現れない (全 kind × schema 有無)" - "--dangerously-skip-permissions / --bare は既定で付けない (--bare は OAuth を使わず ANTHROPIC_API_KEY が要る = 『LLM のキー管理不要』と衝突。ユーザーが extra_args に書いた時だけ)" # kind 別の既定引数 (凍結): # claude : -p --output-format stream-json --verbose --max-turns N [--model M] # [--json-schema ] (構造化タスクのみ) --add-dir # --allowedTools Read Glob Grep (リポジトリ走査は CLI に委任 — 2026-09-07 ユーザー決定。Write/Edit/Bash は許可しない) # aider : --no-git --yes-always --no-auto-commits [--model M] + (runner が) --message-file 。`--yes` は存在しない (公式は --yes-always)。未導入のため実機未確認 # custom : 既定引数なし。stdin に本文 # 構造化出力: claude = json_schema (result.structured_output — 公式文書で確認済み、stream-json での実データは fixture 採取待ち) + fenced_json フォールバック / aider・custom = fenced_json # fenced_json の規則 (rev2 査読 4): ①最後の ```json フェンスの中身 ②無ければ括弧バランスの取れた最上位 {…} の最後のもの (文字列内の括弧は数えない) # ③「最初の { から最後の }」は採らない (2 オブジェクトを跨いで拾う — Red で再現済み)。Kataribe failures #29 の no-tools と同型 # 走査の深さの差 (rev2 査読 3): RepoBrief は全 kind で同一バイト列 = 最低保証。claude の Read/Glob/Grep による深掘りは**上乗せ**であって別経路ではない。aider は --read で追加ファイルを渡す口を持つが v1 では使わない (RepoBrief が本文に入っている) # 隔離 (rev2): cwd を app_data/work にすることで対象リポジトリの hook / MCP / CLAUDE.md を拾わない。Read の到達範囲は cwd + --add-dir (Claude Code の permission 境界)。この境界の実効性は Phase B live で「--add-dir 外の既知ファイルを読ませて拒否されるか」で確認する CliKindCheck: # 2026-09-12 (rev40)。設定の「種類」と実行ファイルの実体が食い違っていないか why: "種類が claude のまま実行ファイルだけ agy にでき、**claude の argv が別系統の CLI に飛んでいた**。症状は『-p が次のフラグを本文として飲む』という読み解きにくい形で出て、真因の特定に 2 時間かかった。設定画面のチップも『claude 1.2.2』— ラベルは種類から、版は実体から取った嘘を表示していた" rule: "`cli_runner::kind_mismatches_version(kind, version)` (純関数)。claude = 名乗りに `claude` を含まなければ食い違い / agy = 含んだら食い違い / aider・custom は**判定しない** (名乗りの形を知らない)" grounding: "実測の名乗り — claude `2.1.263 (Claude Code)` / agy `1.2.2`" silence: "**名乗りが空なら何も言わない。** 検査が失敗しただけで、食い違いの証拠は無い (無いものを断定しない)" surface: "①設定画面 = `check_cli` が `CliCheck.kind_mismatch` を返し、警告バナーとチップの色で出す (rev40) ②**run の開始時 = 走らせる前に止める** (rev41)。認証の行の直後・brief を読む前に `--version` を引き、食い違えば進捗ログに 1 行出して `Err` で返す" why_blocking: "rev40 は設定画面にだけ警告を置いたが、**実行はメイン画面からするので誰も見なかった** — 同じ事故が 3 回続いた。食い違ったまま走らせると必ず数秒で失敗するので、止めても失うものは無い (ユーザー決定 2026-09-12)。**警告は人が見に行く場所ではなく、必ず通る場所に置く**" message: "1 行で完結させる — 種類 / 実行ファイル / 名乗り / **どう直すか** (「設定の『CLI の種類』を実体に合わせてください」)。ログには 1 行しか出ないため" poc: "invocation.rs kind_mismatch_is_judged_from_the_measured_version_strings / app src/kindMismatch.test.ts" IsolationGuarantee: # **kind ごとに強さが違う**。2026-09-12 実測で確定 (旧契約は claude だけを見て全 kind を同じ言葉で括っていた) why: "解析対象は**他人のリポジトリになり得る**。書き換え・実行を渡さないことが製品の前提だが、その前提を**どう担保しているか**は kind で違う。同じ不変条件で括ると、agy を選んだ時に対象リポジトリが壊れる" structural: "claude。`--allowedTools Read Glob Grep` で Write / Edit / Bash を**そもそも渡さない**。原理的に起きない" detect_only: "agy。**予防できない。起きた後に殺すだけ。**" agy_measured: # 2026-09-12 実測 (agy 1.2.2、headless = --input-format stream-json) read: "list_dir / view_file は**無条件に通り、cwd の外も読む** (実測: ホームディレクトリ配下の、この run と無関係なファイルを読んだ)。--add-dir が読み取りを縛るかは未確認" write: "write_to_file は**無条件に通る**。スクラッチパッドに probe.txt (3 バイト) が実際に作られた。**これがこの契約の根拠**であり、仕様に残す" command: "run_command だけは headless で自動拒否される (`permission check failed` → step state=ERROR)。理由は「headless では確認を出せないので auto-denied」" flags: "--mode plan / --sandbox のどちらもツールの面 (57 個) と permission_mode (request-review) を変えない。argv で絞る手は存在しない" not_taken: "agy 自身が案内する settings.json の permissions.allow は**触らない**。①置き場が表に出ていない (まだ存在しない) ②--settings 相当のフラグが無い ③ユーザーの global 設定を勝手に書くのは掟 (ユーザーの変更を上書きしない) に反する。将来やるならパスを確定し、**ユーザーに明示的に作らせる**流れで" watchdog: # detect_only の実装 (cli_runner::agy::tool_verdict) policy: "**allow リスト (既定で止める)**。deny リストにしない — 57 個のうち sed_file / multi_replace_file_content / notebook_edit / browser 系 / generate_image 等を列挙し漏らすと穴になる" allowed: "view_file / list_dir / find_by_name / grep_search (読み取り 4 種) + finish (副作用の無い完了合図。**tool event としては未観測**で、外すと正常な run を殺す恐れがあるため入れてある — live で要確認)" action: "許可外の tool event を見た瞬間に early_abort → tree_kill → CliError::ToolNotAllowed" limit: "**`state:ACTIVE` を見た時点で、その書き込みはもう始まっている。** 検出であって予防ではない。止められるのは 2 手目以降" escape_visible: "**止めた時、直前のツールの失敗を進捗に出しておく** (rev48)。agy が許可外のツールへ行くのは多くの場合『許可されたツールが失敗したから』で、失敗の理由 (hook の故障・パス不在) は agy の環境側にある。ToolNotAllowed の文言は変えない — 読み取りコマンドでも『書き換えが起きた可能性』と言うのは、見張りがコマンドの中身を判定しない (できない) ため" measured: "2026-09-12 初 live で実際に働いた — 構成タスクが `run_command (Get-ChildItem …snapshots)` に手を伸ばした瞬間に停止 (生ログの最終行が `ACTIVE run_command`。何も実行されていない)" escape_reason: "**なぜシェルに逃げたか**: スナップショットは app_data にあり `--add-dir` の外。`find_by_name` / `grep_search` は `--add-dir` のフォルダしか探さないので空振りし、シェルに落ちた。**逃げ道を塞ぐのではなく逃げる理由を消す** — rev42 で置き場を `--add-dir` に足した。見張りは厳格なまま (緩めると『確率の保証』に戻る)" read_scope: "**agy の `--add-dir` は読み取りを縛らない** (2026-09-12 実測で確定)。同じ run の解析タスクは `--add-dir` の外にあるスナップショットを `view_file` で問題なく読めた。縛るのは探索ツールの既定の探索範囲だけ" ui: "設定で agy を選んでいる間、画面に常時警告を出す (既定は claude のまま)。文言はユーザー起草 (2026-09-12) — 『agy は CLI 側で書き込み系ツールを無効化できません。解析対象のリポジトリが書き換わる可能性があります。信頼できない / 他人のリポジトリには向けないでください。実行は検出後に強制停止しますが、予防ではありません。』" CliEvent: # 実行中に Rust から GUI へ push するストリーム (Tauri event "cli-progress")。runner::run の on_event Started { pid: u32 } Stdout { text: String } # 人が読む断片。claude なら assistant text、他は生の行 Stderr { text: String } Progress { text: String } # 結果に影響しない進捗 (api_retry の retry_notice 等) Structured { json: Value } # 構造化出力が取れたとき 1 回 # Finished は runner::run の戻り値 (Result) で表し、Tauri 層が event に写す runner: # Phase A (2026-09-08 実装・PoC 9 本 + 判定検算 1 本) api: "runner::run(&CliInvocation, RunOptions{timeout, cancel: watch::Receiver}, on_event) -> Result" transport: "Stdin = 本文を書き切って shutdown (EOF を届ける) / MessageFile = cwd に prompt__.txt を書き `--message-file ` を argv 末尾に足し、終了後に削除 (Drop)" streams: "stdout / stderr を行単位で select! 読み。stderr は末尾 4 KB を保持 (RunFailed.stderr_tail)。claude は parse_line で Text → Stdout / ApiRetry → Progress、他 kind は生行 → Stdout" stop: "timeout / cancel / early_abort (終端 assistant.error) の 3 経路すべて tree_kill で木ごと止めてから wait" fold: "claude = stream::fold_lines (structured は result.structured_output → 無ければ result 文字列の fenced) / aider・custom = 終了コード検査 → 生 stdout 連結 → fenced_json なら救済" not_found: "spawn の io::ErrorKind::NotFound を CliError::NotFound へ (事前の PATH 検索はしない — spawn が唯一の真実)" CliRawLog: # 2026-09-12 起点。live の analyze が result 行なしで落ちた時、手掛かりが 300 字しか残っていなかった why: "落ちた run の stream-json をディスクに残す。それまで raw_lines はメモリにしか無く、畳むのに失敗すると CliError::Shape.raw (1 行の先頭 300 字) だけが残った。run ディレクトリは export 時に作られるので、解析で落ちた run は痕跡がゼロだった" where: "/cli-logs/-.jsonl — cwd は app 所有の作業ディレクトリ。**解析対象のリポジトリには書かない**" content: "**stdout の行だけ**を受信順にそのまま。stderr は混ぜない — NDJSON のままで fixtures/*.jsonl に流用できることを優先する (stderr は RunFailed.stderr_tail に末尾 4 KB)" when: "成功・失敗を問わず常に書く (成功した run の封筒も fixture の原料)。**行が届くたびに書く** — timeout / cancel / kill でも届いたところまで残る" invocation: "兄弟ファイル `<同名>.invocation.json` に `{program, args, cwd}` を書く。**何を起動したか**は失敗の半分の情報 (2026-09-12: argv が分からず再現できなかった)。`.jsonl` を claude の封筒だけに保つため別ファイルにする。**本文は argv に載らない契約**なのでここにも現れない。あわせて `runner::invocation_summary` が同じ内容を 1 行にして進捗ログへ出す (`--json-schema` の値は長さだけ)" retention: "run の開始時に古い順で刈り、最新 CLI_LOG_KEEP (=20) 本だけ残す。**兄弟の `.invocation.json` も道連れ**にする (孤児にしない)" fail_open: "ディレクトリが作れない・書けない時は log_path=None のまま実行を続ける。ログのために run を落とさない" poc: "tests/runner.rs truncated_stream_keeps_the_raw_log_and_reports_exit_code / successful_run_also_leaves_the_raw_log / old_raw_logs_are_pruned / the_invocation_is_recorded_next_to_the_raw_log / invocation_summary_abbreviates_the_schema" CliOutcome: # 列挙の各 variant を「名前: フィールド」で書く (Rust の擬似記法は引用符の中) Ok: "{ text: String, structured: Option, cost_usd: Option, duration_ms: u64, model: Option }" # model = init 行の実名 (rev25)。aider / custom は None Failed: "{ error: CliError, stderr_tail: String, log_path: Option }" # log_path = CliRawLog (書けなかった時は None)。Display は error の後ろに " — 生ログ: " を足す CliError: # 契約 errors。UI はこの列挙で文言を出す (沈黙させない) NotFound: "{ executable: String }" # spawn 前の存在検査 or ENOENT Timeout: "{ secs: u64 }" # kill 済み Cancelled: "(フィールドなし)" # ユーザー中断 (kill 済み) kill_semantics: # rev2 査読 8: tokio の child.kill() は Windows で TerminateProcess = 子孫が孤児化する。実装 = tree_kill.rs windows: "Job Object (CreateJobObjectW + KILL_ON_JOB_CLOSE、spawn 直後に AssignProcessToJobObject、止める時は TerminateJobObject)。**2026-09-08 実機 Green** (孫 = fake_cli hang 120 が timeout 後に消える)。限界: spawn〜アタッチの数 µs に生まれた孫は Job 外 (tokio は CREATE_SUSPENDED を出さない)" unix: "process_group(0) で spawn → killpg(SIGTERM) (claude は SIGTERM で実行中 Bash のプロセス木を止めて exit 143 する — 公式) → 猶予 5s → killpg(SIGKILL)。**この機体 (Windows) では未コンパイル・未検証**" poc: "tests/runner.rs timeout_kills_the_whole_tree_including_grandchild (fake_cli grandchild が孫 pid を stdout に出す → timeout 2s → process_alive(pid) == false)。process_alive 自体は自分=true / 終了済み=false で検算 (常に false の実装で通ってしまうのを防ぐ)" ExitStatus: "{ code: Option, stderr_tail: String }" Auth: "{ message: String }" # claude stream-json: assistant.error == "authentication_failed" or result.is_error + 本文 (fixtures/claude_auth_failed.jsonl = 2026-09-07 実測) ToolNotAllowed: "{ tool: String, target: String }" # agy の見張り (IsolationGuarantee.watchdog)。許可外の tool event を見て木ごと止めた。**検出であって予防ではない** EmptySuccess: "{ note: String }" # agy が status=SUCCESS を返したが response も structured_output も空。理由は JSON でない行 (`jetski: …`) にしか書かれないので note に運ぶ Shape: "{ detail: String, raw: String }" # stream-json / JSON が読めない。raw 保持 (再生成の燃料)。claude で result 行が無い時は detail に子の**終了コード**も載せる — それまで status を見るのは aider / custom だけで、claude では取得しておきながら捨てていた AgyStreamLine: # agy --input-format stream-json --output-format stream-json の 1 行 (NDJSON)。2026-09-12 実測 (fixtures/agy_json_schema_ok.jsonl) tag: "**`event`** (claude の `type` ではない)。既知の event だけ読み、未知は Other で素通し (捨てない)" input: '{ event: "user", message: { content: String } } を **stdin に 1 行**。`-p` / `--print` は使わない — あれは値 (prompt) を取るので argv に本文が載り、契約 (本文は argv に載せない) を破る。2026-09-12 の障害の正体もこれで、`-p --output-format …` の並びに対し agy が「-p took "--output-format" as its prompt」と言って終了コード 2 で死んでいた' init: '{ event: "init", conversation_id, init: { cwd, tools: [57 個], permission_mode: "request-review" } }' step_update: '{ event: "step_update", step_update: { conversation_id, step_index, state: "ACTIVE | DONE | ERROR", step_type: "user_input | agent_response | tool | finish", text_delta?, tool_name?, tool_info?, duration_seconds?, usage? } }' # 人が読む本文は agent_response の text_delta を順に連結する (claude の assistant.content[].text に相当)。 # tool_info = { name, parameters: {...}, output? , error? }。見張り (IsolationGuarantee.watchdog) はここの tool_name を見る。 tool_error: "**state=ERROR の tool 行は `tool_info.error.message` の 1 行目を運ぶ** (`AgyLine::Tool.error`、rev48)。ツールが失敗すると agy はシェル (run_command) へ逃げ、見張りはそこで止める — 逃げた理由はこの 1 行にしか無い。進捗には `ツール失敗: — <1 行目>` として出す (`agy::tool_notice`)。2026-09-13 実機: agy 側の PreToolUse hook (プラグイン) がパスの引用で壊れ、**全ツールが ERROR** → run_command → ToolNotAllowed。進捗には `ツール: view_file` しか無く、原因は生ログを開くまで分からなかった (fixtures/agy_tool_error_escape.jsonl)" result: '{ event: "result", result: { conversation_id, status: "SUCCESS | ERROR", response: String, error?: String, num_turns, duration_seconds: f64, structured_output?: Value, json_schema?: Value, usage: { input_tokens, output_tokens, thinking_tokens, cache_read_tokens, total_tokens } } }' cost: "**無い。** トークン数だけ。`RunOk.cost_usd` は None にする — トークンから費用を計算して埋めない (捏造)。**2026-09-12 の初 live で実際に `0.000 USD` と描いてしまった** ので、rev42 で `StageReport.cost_usd` / `RunStats.cost_usd` / `RunListItem.cost_usd` を `Option` にした。**片方の段でも不明なら合計は不明** (`pipeline::stages::add_cost`) — 分かっている分だけ足すと『一部しか数えていない総額』という別の嘘になる" duration: "`duration_seconds` は f64 秒 (claude の duration_ms とは単位が違う)" structured: "`--json-schema` の出力は **result.structured_output** (claude と同名)。無ければ result.response の fenced_json へ落ちる" empty_success: "**status=SUCCESS は成果の保証ではない。** ツールが拒否されて response が空でも SUCCESS を返す (実測)。response も structured_output も空なら CliError::EmptySuccess として扱う" plain_lines: "**JSON でない散文行が stdout に混ざる** (`jetski: no output produced — a tool required the \"command\" permission that headless mode cannot prompt for, so it was auto-denied. …`)。**捨てない** — EmptySuccess の理由はこの行にしか書かれていない。畳みでは終端として扱わず、Other として保持し失敗時の説明に使う (Kataribe #75 と同じ: 受信側の allowlist は既定で通す)" argv: "`--input-format stream-json --output-format stream-json --print-timeout s --disable-slash-commands [--model M] [--json-schema S] --add-dir `。**存在しないフラグ**: --max-turns / --verbose / --allowedTools。--print-timeout の既定は 5 分で、解析は実測 4 分 22 秒かかったことがあるので必ず CliSpec.timeout_secs を渡す" ClaudeStreamLine: # claude -p --output-format stream-json の 1 行 (NDJSON)。既知の type だけ読み、未知は Stdout へ流す (捨てない) system: '{ subtype: "init | api_retry | hook_started | hook_response | ...", session_id, model }' # model (init のみ) = **解決後の**実名。`--model sonnet` でも空欄でもここは実名 (rev25、fixture で確認) api_retry: '{ attempt, max_retries, retry_delay_ms, error_status: Option, error: "authentication_failed | oauth_org_not_allowed | billing_error | rate_limit | overloaded | invalid_request | model_not_found | server_error | max_output_tokens | unknown" }' # 2026-09-08 実測 (fixtures/claude_auth_retry_loop.jsonl、ユーザー端末): 401 は attempt 1..10 (遅延 0.5s→38s、合計約 2.5 分) を回してから assistant(error) + result(is_error) に落ちる early_abort: "runner は行を読むたびに stream::early_abort を評価するが、**api_retry では止めない**。初案『authentication_failed の初回再試行で打ち切る』は同日の成功 fixture (401 × 7 回の再試行の後に成功) で反証された。止めるのは終端の assistant.error (authentication_failed) だけ。再試行は stream::retry_notice で『再試行 n/max (種別, HTTP) — 待ち秒』として進捗ログに出し、待つか中断するかはユーザーが決める" assistant: '{ message: { content: [ {type: text, text} | ... ], model }, error: Option, is_api_error_message: Option }' result: '{ is_error: bool, result: String, total_cost_usd: f64, duration_ms: u64, num_turns, terminal_reason: "..." }' # --json-schema 使用時の構造化出力は **result.structured_output で確定** (2026-09-08 実測 fixtures/claude_json_schema_ok.jsonl)。`result` 文字列にも同じ JSON が入る。機序: assistant が `StructuredOutput` という tool_use を出し、user 行に tool_result が返り、result 行に載る。--allowedTools に StructuredOutput を書かなくても動いた。無ければ result 本文の fenced_json へ落ちる other_types: "user (tool_result の往復) / rate_limit_event / stream_event (--include-partial-messages 時) は未知 type として Other で素通し (捨てない)。成功 run にも api_retry が混じる (同 fixture: 401 authentication_failed × 7 の後に成功。OAuth 期限切れ + ANTHROPIC_API_KEY ありの環境)" fixture_procedure: "ユーザーの通常端末で `pwsh scripts/capture_claude_fixture.ps1` → scripts/redact_stream.py が cwd/tools/hook 行を伏せて crates/cli_runner/fixtures/claude_json_schema_ok.jsonl に書く → stream::tests に include_str! で焼く。Neo のセッション内では OAuth が継承されず採取不可 (live.claude_stream_20260907)" # --------------------------------------------------------------------------- # パイプライン (LLM が書く物。schemars で JSON Schema を機械生成し --json-schema に渡す = 規格と実装の単一真実源) # --------------------------------------------------------------------------- RepoBrief: # Rust が本文に載せる圧縮マニフェスト (CLI の深掘りの起点。走査本体は CLI が Read/Glob/Grep で行う) purity: "promo_core::brief = 刈り込みと整形だけ (純粋、BriefInputs -> RepoBrief -> render())。FS を読む収集は crates/pipeline (Phase B)。rev2 査読 6" root_name: String tree: String # 深さ 3。1 行 100 字超は … で切る。収集規則は tree_source を見よ tree_truncated: bool tree_source: # rev4 (Phase E)。Kataribe / Fuseforks の 2 本は生成物を tree に持たない綺麗な例で、 # 別リポジトリに当てた瞬間に tree が生成物で埋まった (2026-09-08 実測)。 primary: "git ls-files (対象が git リポジトリの時)。深さ 3 以下に絞り、親ディレクトリ行を補う" fallback: "git が無い / 失敗した時のみ従来の FS walk。除外語と拡張子はこちらにだけ効く" excluded_dirs: [".git", "node_modules", "target", "dist", "build", ".venv", "venv", "__pycache__", ".idea"] excluded_ext: ["lock"] secret_names: "*.env / .env* / *key*.txt / *.pem / *.p12 / *secret* / *_key / *credential* は名前ごと落とす (全経路)" machine_named_elision: "子の 50% 以上かつ 5 件以上が機械生成名 ([0-9a-f]{16,} または UUID 断片) のディレクトリは 1 行に畳む: `/ (N entries, elided)`" measured_20260908: | 深さ 3 以下の行数 (FS walk -> git ls-files): mxf-tool 314 -> 39 (doxy/html 227 行が gitignore 済み) / CaptionConverterSakura 400(切り捨て) -> 101 (vcpkg 251 行) Verificator 114 -> 22 (venv/Scripts。除外語は .venv しか知らず venv を取りこぼしていた) outcast 280 -> 231 (backend/.sqlx 49 は追跡されているので git では消えない -> machine_named_elision が畳む) Kataribe 188 / Fuseforks 141 は元からノイズ 0 = 契約を凍結した時の標本が偏っていた 件数による畳み込みは棄却: Fuseforks specs/ 52 (最重要) と outcast .sqlx/ 49 (純ノイズ) は 3 件差で分離不能。 機械生成名は 7 リポジトリで完全分離した (.sqlx 49/49、他の全ディレクトリ 0)。 readme: Option # README*.md 先頭 readme_truncated: bool manifests: Vec<{ path, head, truncated }> # Cargo.toml / package.json / pyproject.toml / pubspec.yaml / go.mod / *.csproj の順 snapshots: Vec<{ path, width, height }> # 画像メタ (image crate)。バイトは送らない (v1: CLI は画像を見ない) limits: # 全 kind 共通 = 最低保証サイズ (rev2 査読 3)。テストで固定 tree_max_lines: 400 tree_line_max_chars: 100 readme_max_chars: 8000 manifest_max_files: 5 manifest_max_chars: 2000 render_max_chars: 64000 # 最悪 ≈ 59.5k 字。UTF-8 で最大 192 KB、claude の stdin 上限 10 MB の 2 桁下 note: "初案 (総量 32 KB) は部分の和 (最悪 36 KB+) より小さく最悪ケースの PoC で落ちた (2026-09-07 Red)。上限は同じ単位 (文字数) で揃え、tree の 1 行にも上限を置いた" AnalyzedSummary: # タスク 1 の出力 (schemars 派生) app_name: String one_liner: String # 30 字以内 core_value: String target_audience: String differentiators: Vec # 3〜5 hook_copy: String # キャッチコピー 1 本 visual_identity: VisualIdentity # スナップショットの記述 (CLI は画像を見ないので v1 は README/テーマ設定からの推定 + ユーザー編集) VisualIdentity: # スタイルアンカー (画像プロンプトの接頭辞素材) palette: Vec # hex 3〜5。**既定はスナップショットの画素から Rust が算出** (image crate、k-means/ヒストグラム。LLM 不要・純関数) → ユーザー編集。LLM の推定は使わない (rev2 査読 11: README にテーマ色は書かれていない) mood: String # 例 "calm, minimal, warm light" (LLM 推定 + ユーザー編集) ui_traits: Vec # 例 "dark sidebar", "rounded cards" (LLM 推定 + ユーザー編集) ui: "設定ペインで palette は必須欄 (3 色以上)。スナップショット 0 枚なら手入力必須。自動算出は Phase C" ScenePlan: # タスク 2 の出力 (schemars 派生) total_seconds: u32 # 15 / 30 / 60 のいずれか (UI 選択) aspect: "16:9 | 9:16 | 1:1" scenes: Vec # 3〜8 Scene: # rev3 (2026-09-08、ユーザー FB「スクショに全く従わない」+ 参考: 画像ファースト → i2v): 画像が主役、動画は画像を動かすだけ cut_kind: "product | mood" # product = 実スクショの画素を Rust が合成 (モデルは背景だけ描く) / mood = 情景 (モデルが全部描く)。スナップショットがあるなら product が 1 つ以上必須 (validate) snapshot_index: Option # product が使うスナップショット (0 始まり、scene_prompt が一覧を示す)。範囲外は違反 motion_prompt: # 空・非英語は違反 type: "String。英語 2〜3 文。image-to-video (この静止画が最初の 1 コマ) 用" shape: "rev58 (MiniMax H3 の公式ガイド I2VA): 最初の 1 コマを 1 節で押さえる → **主なカメラの動き 1 つ**を公式の種類 + 振れ幅 + 速さで (`pushes in with small amplitude at slow speed`) → 終わり方。シーン間で動きを散らす。rev3 の「動きとカメラだけ」は公式と逆なので撤去" screen_lock: "product カットは検査通過後に `promo_core::plan::SCREEN_LOCK` を末尾へ 1 度だけ足す (`lock_product_screens`、既に含むなら足さない)。motion_prompt そのものに入るので画面・scenes.md・コピーが一致し、人は消せる。mood には足さない" scene_id: u32 # 1 始まり連番 duration_seconds: u32 # 4〜10 (rev59: MiniMax H3 の最短 4 秒に合わせた。以前は 3)。15 秒の動画は 3 シーン shot_type: String # Close-up / Medium / Wide / Screen recording 等 (自由文) video_prompt: String # 英語。text-to-video の保険 (rev58: schema の説明文から Veo / Sora を外した)。`--ar` を含まない (Midjourney 記法。Veo/Sora の文法ではない — rev2 査読 5)。aspect は ScenePlan が持つ # text-to-video の**保険** (rev3 で主役から降格)。Veo / Sora は指示に従わないのでコピー先から外した (ユーザー実測 2026-09-08) copy_text: String # テロップ / ナレーション (language) image_prompt: String # 静止画 1 枚のプロンプト。**product では背景のみ** (画面・端末・UI・文字の語は validate が弾く = 合成と二重にしない)。mood では絵の全部。参照そのものを指す語 (reference / screenshot / sheet) は書かない (Kataribe failures #85) plate_tilt: "Option<{ yaw_degrees: f32, pitch_degrees: f32 }>" # rev5。product のみ。背景のパースに合わせてスクショ面を傾ける指定。 # yaw 負 = 左が奥 / 正 = 右が奥。pitch 負 = 上が奥 (俯瞰) / 正 = 下が奥 (ローアングル)。 # 各 -35〜35 度。範囲外は違反 (PlateTiltOutOfRange)。省略 = 正面。frontal モードでは無視される reference_image: Option # 生成後に埋まる DesktopGuards: # 2026-09-12 (rev47)。配布ビルドでブラウザ由来の操作を隠す。ユーザー要望 why: "デスクトップアプリなのに、右クリックすると WebView の「最新の情報に更新」「名前を付けて保存」「印刷」が出て、F5 で実行中の表示が吹き飛ぶ。ラベルの文字も選べてしまい、アプリらしくない" dev_is_exempt: "**dev では締めない** — 右クリック → 検証と再読み込みは開発の道具。判別は `import.meta.env.DEV` (Vite)" devtools: "`Cargo.toml` の tauri は `features = []` なので、**release には開発ツールがそもそもコンパイルされない** (Tauri 2 は debug でだけ自動で入れる)。右クリックの抑止はその上の二重の蓋" context_menu: "抑止する。ただし**入力欄と選択中テキストの上ではネイティブメニューを残す** (コピー / 貼り付けの導線)" reload: "F5 と Ctrl+R も塞ぐ — 右クリックの「更新」を潰しても WebView2 のアクセラレータで通る" selection: # rev47 で追加 (Kataribe には無い) how: "main.ts が配布ビルドでだけ `document.documentElement.dataset.locked` を立て、CSS が `:root[data-locked]` で切る。**CSS は dev か配布かを知らない**ので印で伝える" locked: "既定は `user-select: none` (ラベル・見出し・ボタンの文字)" exempt: "`input` / `textarea` / `[contenteditable]` / `pre` (プロンプト本文) / `.warn` (エラー文) / `.selectable` (進捗ログ)。**ここを塞ぐと「ログを貼って報告する」導線ごと潰れる** — 実害があるので免除する" measured: "2026-09-12 実測 (印を手で立てて computed style): ラベル / 見出し / ボタンの文字 = none、入力欄 / テキストエリア / 進捗ログ / エラー文 = text" poc: "app src/desktopGuards.test.ts (締めが dev の外にあること / 右クリックの例外 / F5・Ctrl+R / 印 / 免除の一覧 / 網自身がソースを読めているか)" FirstRunTour: # 2026-09-12 (rev44)。初回起動のナビゲーション。ユーザー要望 why: "何をどの順でやるアプリなのかが、画面を見ただけでは分からない。**手順を教えるだけ** — 案内の中から設定や実行はさせない (ユーザー決定。移植元 Lorekeel と同じ流儀。開いた瞬間に案内は終わるので、案内の中に置く意味が薄い)" origin: "Lorekeel (Kataribe `app/src/tour.ts` + `FirstRunTour.vue`、2026-09-07) の移植。`spotlightBox` / `placeCard` は実機で詰めた判断なので**そのまま写す**。変えたのは ①歩数と対象 ②『初回』の見分け方 ③`spotlightUnion` (複数の要素をまとめて照らす)" steps: # 順序が案内の順序。値は `data-tour` 属性 input: "入力 — リポジトリ / スナップショット / 動画イメージ・世界観。**実行ボタン以外をまとめて**囲む" settings: "入力ペインの「設定」ボタン — LLM の CLI と画像生成の API キー" run: "「解析 → シーン構成」ボタン — 素材を生成する" result: "出力エリア — 「フォルダを開く」で参照画像を、「全シーンをコピー」でプロンプトを動画生成 AI へ" targets: "対象は `data-tour=\"…\"` を付けた**実物の要素**。案内の部品は `querySelectorAll` で測るだけで、対象側の部品は案内を知らない。**1 歩目は印が 2 つ** (先頭の欄と末尾の行) — 束ねて囲むため。包む div を足すと余白の出方が変わるので足さない (`spotlightUnion`)" first_only: "`shouldShowTour({done, hasProject, runCount})` — 印が無く、**使った痕跡も無い**ときだけ出す。更新で初めて機構が入った人 (リポジトリを選んでいる / run がある) には出さず、**印だけ立てる** — 次に空になっても出さないため" replay: "**設定画面に「使い方を見る」を置く** (rev45)。初回判定は既存ユーザーを弾くので、一度でも使った環境では**二度と見られない**穴があった (2026-09-12 実機で発覚 — 印を消して再読み込みしても runs が 7 件あって出なかった)。押すと**メイン画面へ戻してから**出す — 対象の要素が居ないと照らせない" done_key: "`apppromo.tourDone` (localStorage)。`apppromo.` 接頭辞なので設定ミラー (settings.json) の射程に入る" missing_target: "対象が 1 つも見つからない時は画面中央に小さく置いて案内は続ける (レイアウトが変わっても止めない)。**ただし静かに壊れる**ので、印の実在は機械の網 (`tourTargets.test.ts`) が見る" keys: "文言のキーは**字面で書く** (`t(`tour.${step}.title`)` と組み立てない) — 組み立てると型がキーを検めず、未使用キーの網からも見えなくなる" poc: "app src/tour.test.ts (初回の判定 / 矩形 / 束ね / カードの置き場 4 方向 / 三角の追従) / src/tourTargets.test.ts (印の実在・1 歩目は 2 か所・文言 3 言語)" SceneEdit: # 2026-09-12 (rev43)。シーンの追加 / 並び替え / 削除。ユーザー要望 why: "生成されたシーンの並びや数は人が直したい。ただし `scene_id` は**連番であることが検査で強制**されている (SceneIdNotSequential) ので、並べ替えと削除は必ず**振り直し**を伴う" ops: "move_up | move_down | duplicate | remove (どれも対象は 1 シーン)。追加は**複製** — 検査が空欄を弾く (EmptyField) ので白紙のシーンは作れない。隣を写してから直す" renumber: "操作のあと scene_id は**位置 + 1** に振り直す。振り直しは `SceneRemap` (旧 id → 新 id) を返し、**追従はこの表 1 つだけを見て行う**" follows: # 振り直しに追従するもの (**ここが全部。漏らすと静かに壊れる**) maps: "PromoJson の `caption_overrides` / `plate_overrides` / `original_copy` — いずれも scene_id が鍵。**人の手編集と『最初の文』の控え**なので、付け替えを誤ると別のシーンに付く" files: "`/scene_{NN}_ref_{MM}.png` と `/base/scene_{NN}_ref_{MM}.png`" derived: "`scenes.md` は plan から**作り直す**ので追従不要 (写しではなく導出)" rename_order: "**二段で rename する。** 1→2 と 2→1 のような入れ替えでは、直接 rename すると衝突して片方を潰す。①削除されたシーンのファイルを消す ②全部を一時名へ ③一時名から最終名へ" limits: "シーン数は 3..=8 (SCENES_MIN/MAX) — 3 で remove、8 で duplicate は拒む。**唯一の product カットは消せない** (NoProductCut になる。検査を待たずに拒む)。端での move_up / move_down も拒む" duplicate: "シーンの中身に加え、そのシーンの caption_override / plate_override / original_copy も**写す** (見た目ごと複製する)。**参照画像は写さない** — 複製直後は画像なしで、生成し直すまで出ない" duration: "**尺は触らない。合計のずれを画面に出すだけ** (ユーザー決定 2026-09-12)。そもそも `validate_scene_plan` は尺の合計を検査していない (`total_seconds` が 15/30/60 かだけ見る) ので、追加・削除で合計はずれる。**自動で数字をいじらない** — 人が決める" where: "`promo_core::scene_edit::apply_scene_edit(promo, op, scene_id) -> Result` が唯一の書き換え (純粋)。command `edit_scenes(run_dir, op, scene_id)` が呼び、ファイルを rename し、promo.json と scenes.md を書き直し、**書き換えた後の PromoJson を返す** (rev21 / rev37 と同じ作法 — frontend が手元で真似ると食い違う)" poc: "promo_core: 振り直しと 3 つの Map の追従 / 入れ替えでファイル名が衝突しない順序 / 限界 (3 で remove・8 で duplicate・唯一の product・端) / backend: rename と promo.json・scenes.md の書き直し" ScenePromptEdit: # rev37 (2026-09-12 ユーザー「motion や video prompt は書き換え可能に。鉛筆でシーンの編集モードにしてから」) why: "生成した文を貼る前に手で整えたい。ただし**常に書き換えられる欄**にすると、読むつもりの操作で壊れる — 編集モードを挟む" mode: "結果ペインのシーンカードの鉛筆で**そのシーンだけ**編集モードへ。モード中は motion_prompt / video_prompt が入力欄。保存で確定、破棄で入る前の値に戻す。実行中は入れない" where: "promo_core::plan::set_scene_prompts(plan, scene_id, motion, video) -> Result<(), PromptEditError> が唯一の書き換え。command update_scene_prompts(run_dir, scene_id, motion_prompt, video_prompt) が呼び、**書き換えた後の PromoJson を返す** (frontend が手元で真似ると食い違う。reburn_caption と同じ作法)" empty: "**空にはできない** (前後の空白を落として空なら拒む)。motion_prompt_empty / video_prompt は検査の対象で、空のまま保存すると次の検査と食い違う。拒んだ時は**片方だけ書き換えない**" files: "promo.json を書き直したら **scenes.md も書き直す** (パッケージの中で食い違わせない)。コピー先の文字列は plan から組むので、保存した時点でコピーにも反映される" no_original: "コピー文の original_copy のような『最初の文』は持たない。プロンプトは再生成でやり直せ、run は隔離されている (rev7)" PlateMode: # rev5 (2026-09-08 Phase E 実測: 背景のパースと貼った面のパースが噛み合わない)。設定で切り替え、**既定 = frontal** (rev22) perspective: "scene.plate_tilt に従い、スクショ面を射影変換で傾けてから合成する。背景のアングルと整合する。tilt が 0 / 省略なら frontal と同じ経路 (画素等価)" frontal: "**既定** (rev22)。正面固定。plate_tilt を無視し、さらに image_prompt のアングル語 (low angle / top-down / tilted / three-quarter / isometric / overhead 等) を検査で弾いて背景も正対に保つ。絵の変化は乏しくなるが不整合は原理的に出ない" note: | Phase E (AppPromoVideo 自身、2026-09-08) で表面化。scene 3 の image_prompt が `photographed at a low angle` で 背景も実際にローアングルなのに、貼られた UI は正対のままだった。product カットが 1 種類しか無かった Fuseforks の 1 本では見えず、複数の product カットを別アングルで作った瞬間に出る。 検査はモードに依存する = validate_scene_plan は PlateMode を受け取る。 default_frontal: | **既定を frontal にした根拠** (rev22、2026-09-09 ユーザー観測、rev5 から開いていた判断の決着): MiniMax の image-to-video に通したところ、**傾けると動きが過剰になった**。静止画としての整合 (背景のパースと面のパースが合う) と、そこから起こした動画の落ち着きは別の話で、後者が製品の目的。 perspective は残す — 捨てる根拠は無く、設定と `--plate perspective` で選べる。 **既存の run は影響を受けない** (rev10 で promo.json 自身が plate_mode を持つ)。 **保存済みの設定も上書きしない** — localStorage に perspective がある人はそのまま。 既定の変更が効くのは新規の環境・CLI・plateMode を持たない保存 (rev8 の precedent と同じ扱い)。 # --------------------------------------------------------------------------- # 画像生成 (Kataribe image_gen.rs を移植。差分は本節に記す) # --------------------------------------------------------------------------- ImageGenConfig: # (Kataribe) provider / base_url / model / shape / detail / style / user_prefix / negative / workflow_json / timeout_secs / lock_seed / seed provider: "openai | gemini | comfy" shape: "square | landscape | portrait" # 既定は ScenePlan.aspect から写す (16:9 は landscape) detail: "standard | high | highest" port_plan: "Kataribe の image_gen.rs は Tauri 非依存 (use は std / serde / serde_json のみ、tauri 語 0 件。#[tauri::command] は lib.rs 側) — 2026-09-07 grep 実測。ファイル単位で crates/image_gen/src/provider.rs へ写し、同梱の PoC (#[test] 10 + live 5 = ignored) を**そのまま**持ち込んで green を確認した (2026-09-08) = encode/decode 純関数の入出力同型の証明。**provider.rs は以後も触らない** (Kataribe との diff を追える状態を保つ)。契約差分は generator.rs (trait + ComfyUI の自前待機ループ) / refs.rs / comfy_wait.rs / palette.rs に載せた。provider.rs の image_file_name は未使用 (保存名は promo_core::reference_image_name)。Tauri command は app 側で新規に書く" modules: generator: "ImageGenerator trait { generate(cfg, api_key, prompt, seed, refs, progress) / probe }。HttpImageGenerator = OpenAI/Gemini は provider::generate に委譲、ComfyUI だけ /queue 併読の自前ループ。compose_reference_prompt(prefix, image_prompt, has_refs) = アンカー + 本文 (+ 参照ありなら SINGLE_FRAME_CLAUSE)" palette: "dominant_colors(pixels, n): 4 bit/ch 量子化 → ヒストグラム → 頻度順に RGB 距離 60 未満を統合 → hex。palette_from_file は Nearest で 96px に縮小 (thumbnail の triangle 補間は 2 色画像から第 3 の混色を生んだ — PoC で判明)" style_anchor: "promo_core::style::style_anchor(VisualIdentity) = 『Color palette: #…, #…. Mood: …. UI style: …; …』。palette は extract_hex で #RRGGBB だけ拾う (LLM が説明を混ぜる実例あり)。apply_palette は測った色が空でなければ LLM の値を置き換える" reference: "pipeline::reference::generate_references(generator, RefJob{cfg, api_key, out_dir, palette, ...}, plan, refs, progress) → Vec。シーン単位で失敗を返し他を続ける。保存名 scene_NN_ref_01.png、plan.scenes[i].reference_image を埋める。**product** = 参照を送らず背景だけ生成 (プロンプト末尾に BACKDROP_CLAUSE『no devices, no screens…』) → composite_product_cut で実スクショ (snapshot_index) を合成。背景生成が失敗したら palette 先頭色の単色背景で合成 (製品カットは provider の都合で落ちない)。**mood** = 従来どおり参照を送って生成" compose: "image_gen::compose::composite_product_cut(background, screenshot, Layout{width, height, screen_ratio 0.78, corner_radius, shadow, tilt}) → PNG。背景は cover、スクショは等比縮小 (拡大しない。rev57: `Layout.allow_upscale` が立つ時だけ引き伸ばす — PlateOverride.allow_upscale)・角丸・落ち影 (blur 10)・中央。**tilt が非ゼロなら射影変換で傾けてから貼る** (rev5): 面を 3D で yaw/pitch 回転 → ピンホール投影 → 4 点から homography を解いて逆写像 + バイリニア。tilt = 0 は従来の overlay 経路をそのまま通る (画素等価を PoC で固定)。canvas は **スクショを縮小せずに置ける大きさ** (rev6): canvas_for_snapshot(SizeMap::comfy_dims(shape), 最大のスクショ, screen_ratio, CANVAS_MAX_LONG_EDGE=3840) — 比率を保ったまま拡げ、長辺 3840px で頭打ち。実測 1282×842 の窓 → 1344×768 から 1890×1080 へ。上限に当たって縮小が残る時は進捗に警告 (黙って劣化させない)。fit_to_canvas で mood カットも同寸・PNG に揃える (プロバイダ出力は Gemini が JPEG 1376×768 で、product と食い違っていた)。PoC 10 本 (中央画素がスクショと一致 / 角丸 / 等倍維持 / 壊れ入力は Err / 傾き 0 は従来経路と画素等価 / yaw 正で右辺が縮む / pitch 負で上辺が縮む / 傾けても影が残る / canvas が窓に合わせて拡がる / fit_to_canvas が寸法と形式を揃える)。日本語見出しの焼き込みは v1 外 (フォント同梱が要る)。目視 1 枚: Gemini 背景 + Kataribe UI (failures #8)" differences_from_kataribe: - "参照画像 = UI スナップショット。送信順はユーザーの並び順" - "user_prefix の既定 = VisualIdentity から合成したスタイルアンカー (ユーザー編集可)" - "保存名 = scene_{NN}_ref_{MM}.png (export_dir 直下、MM = 同一シーン内の生成通番。rev2 査読 9: 1 シーン複数枚で衝突しない)。Kataribe の {stamp}_{slug}_T{turn} は採らない" reference_limits: # rev2 査読 9。上限 (provider::max_refs = Kataribe の凍結値) と既定送信枚数は別の列。実装 = image_gen::refs::select_refs openai: { api_max: 3, kataribe_live: 1, default_send: 1, note: "Kataribe の max_refs は 3。OpenAI 公式の『image[] 最大 16』は本 workspace で未検証なので採らない (rev2 の 16 は誤り — 2026-09-08 移植時のテストで判明)。2 枚以上は Phase C live まで既定にしない" } gemini: { api_max: 3, kataribe_live: 1, default_send: 3, note: "gemini-2.5-flash-image の公式上限 3。inlineData を前置" } comfy: { api_max: 3, kataribe_live: 1, default_send: 3, note: "%ref_1..3%。ワークフローに差し込み口が無ければ沈黙する (Kataribe #83) → 設定タブで %ref_1% 不在を警告" } rule: "送る枚数 = min(ユーザー選択 or default_send, api_max, 手持ち枚数)。先頭から (ユーザーの並び順)。切り詰めたら Truncated{total, sent, limit_by} を UI に『N 枚のうち先頭 M 枚』と出す (沈黙させない)" caption: # 見出し (copy_text) の焼き込み。**既定 ON** (rev8、2026-09-08 ユーザー判断で OFF → ON) spec: "CaptionSpec { font_path, font_index, size_ratio 0.055, position top|bottom, color '#RRGGBB' (rev9、省略で白), y_ratio (rev14) }" to_caption: | **`CaptionSpec::to_caption` が `Caption` を組み立てる唯一の場所** (rev17)。生成も焼き直しもここを通る。 rev14 で `y_ratio` を足したとき、同じ組み立てが 2 箇所にあり焼き直し側だけ落ちていた (縦位置スライダーが promo.json には残るのに絵が動かない、failures #18)。フィールドを足すならここだけ直す。 effective_position: | **面が避ける側は `y_ratio` から導く** (rev18)。上半分 (< 0.5) なら Top、下半分なら Bottom、 `y_ratio` が無ければ設定の `position`。UI から「位置」の選択を外した (縦位置スライダーが上位互換、 ユーザー判断 2026-09-09) ので、これが無いと見出しを上へ動かしても面が下の帯を避け続ける。 per_scene: # rev9 (2026-09-08 ユーザー要望「1 枚ごとに位置を決めたい」) why: | 見出しは**生成時に焼き込まれ、完成品の 1 枚しか残らなかった**。焼く行為そのものはアプリ内で無料だが、 **焼く対象を作り直せなかった** — 背景 (`generator.generate` の戻り値) はローカル変数で、 一度も書き出していなかったため。スクショは `snapshots/` にあるので、材料の半分だけが残っていた。 rev10_correction: | rev9 は「焼く前の**合成画像**」を base/ に置いたが、それでは足りなかった。合成の時点で `with_caption_band` が**帯の位置を焼き込む** (プレートを 0.78→0.68 に縮め、見出しと反対側へ 7.5% ずらす)。 つまり位置を下→上に変えると、上に空きが無いところへ焼くことになりプレートに重なる。 **base/ に置くのは背景**にする。スクショは `snapshots/` にあるので、アプリ内で**合成からやり直せる** — 帯も正しく取り直せ、`plate_tilt` も無料で変えられる。ディスクは同じ (どちらも canvas 同寸)。 overrides: "PromoJson.caption_overrides: Map。**LLM のスキーマ (Scene) には足さない** — 埋めるのは人であって LLM ではない" resolution: "scene の上書き → 無ければ設定 (既定) → それも無ければ焼かない。フィールド単位で落ちる (位置だけ上書き、他は既定 が成り立つ)" reburn: | command reburn_caption(run_dir, scene_id, spec) — **生成 API は呼ばない**。 product: base/ の背景 + snapshots/ の写し → `layout_for(canvas, 効いている position, tilt)` で合成 → 焼く。 mood: base/ をそのまま → 焼く。どちらも直下の完成品を置き換え、promo.json の overrides を更新して data URL を返す。 毎回 base から作り直すので、何度やっても劣化しない。 layout_for: "canvas / 見出しの有無と位置 / 傾き / はめ込みの上書き から Layout を作る**唯一の関数**。生成と焼き直しで共有する (帯の計算が二重にならないように)" default_font: | 既定 ON にすると「ON なのにフォント未選択で何も焼かれない」が無言で起きるので、実行直前に `pickCaptionFont` (frontend の純関数) が自動で選ぶ。規律は 4 つ: ①日本語グリフ必須 (copy_text は日本語) ②app_data/fonts に置いたもの (source=user) を最優先 — 意図して置いたものより機械の推測を上に置かない ③**太めのゴシックを優先**。画像に焼くので、細い明朝・教科書体は写真の上で読めない。 優先語には**日本語表記を必ず入れる** — 実測 (Windows 11、日本語あり 118 件) で 游ゴシック / メイリオ は日本語名で登録されており、ASCII の "yu gothic" / "meiryo" では 一生当たらない。半角カナ (HGPゴシックE) も見る ④同点は family 名で決める (実行のたびに変わらない) 選べなければ**焼かずに進み**、理由を進捗に出す (画像は出す)。fontPath は空のまま保存され、 toBackendCaption が null を返すので backend には渡らない。 fonts: "image_gen::fonts::list_fonts(app_data/fonts) = ユーザーが置いたフォント (User) + OS のフォントフォルダ (System: Windows %WINDIR%\\Fonts と %LOCALAPPDATA%\\Microsoft\\Windows\\Fonts / macOS /System/Library/Fonts, /Library/Fonts, ~/Library/Fonts / Linux /usr/share/fonts 等)。ttf/otf/ttc。TTC は face ごとに 1 件 (index)。ファミリー名は name テーブル (日本語 0x0411 → 英語 → 任意)。has_japanese = 『あ』のグリフ有無。並び = 日本語あり → ユーザー → 名前。実測: この機体で JP 118 / 他 368 面" burn: "image_gen::caption::burn_caption(png, Caption{text, font_data, font_index, size_ratio 0.055, color 白, position top|bottom, margin_ratio 0.06}) — 中央揃え・複数行・落ち影 (黒 190/255、~5% ずらし)・幅 90% に収まるまで縮小 (下限 2%)。ab_glyph でラスタライズ。純粋" wiring: "RefJob.caption: Option — 生成・合成の後に scene.copy_text を焼く。フォントが読めない / 焼けない時は焼かずに続行 (画像は残す、進捗に理由)。GUI: 設定 → 画像生成 → 『見出しの焼き込み — 任意』(チェック / フォント一覧 / 高さ比 / 位置 / プレビュー / フォントフォルダを開く)。command list_fonts / open_fonts_folder / caption_preview。CLI: --caption-font 等、`promo caption` / `promo fonts`" poc: "caption 3 本 (空文字は同一 / 壊れフォントは Err / 実フォントで下部帯に文字・上部無傷・200 字でも端を突き抜けない) + fonts 2 本 + settings.test toBackendCaption。目視 1 枚 (BIZ UDゴシック B、demo_product_cut_caption.png)" http: "全て backend (reqwest)。CSP connect-src は localhost のみ。表示は data URL" keys: "**2 層構造を UI で明示** (rev2 査読 2): LLM = CLI 認証委任 (キー欄なし) / 画像 = OpenAI・Gemini はキー必須、ComfyUI は無キー。設定ペインは『LLM (CLI)』と『画像生成 (API キー)』を別セクションにし、『完全無キー』と読める文言を置かない" comfy: "HTTP ポーリング (POST /prompt → GET /history/{id} → GET /view)。WebSocket は v1 外。%prompt% %negative% %seed% %width% %height% %ref_n% の型保持置換 + 未充足 %ref_n% ノードの剪定 (Kataribe #83)" comfy_wait: # rev2 査読 7: /history 404 の期間が『実行待ち』か『失敗』か区別できない poll: "1s → 2s → 4s → 上限 5s の指数バックオフ" queue_check: "history 未着の間は GET /queue を併読し、prompt_id が queue_running / queue_pending のどちらにも無く history にも無ければ『消失』として即失敗 (timeout を待たない)。どちらかに在れば『待機中 (前に N 件)』を進捗に出す" timeout: "600s は『queue に居る状態』にも適用 (無限待ちはしない)" PlateOverride: # rev11 (2026-09-09 ユーザー要望「傾きもはめ込み位置も変えたい」)。product カットのみ why: "rev10 で合成からやり直せるようになったので、見出しだけでなく**プレートの置き方**も無料で変えられる。より複雑な PR 動画を作るには 1 枚ごとの調整が要る" where: "PromoJson.plate_overrides: Map。caption_overrides と同じく**LLM の schema には足さない**" yaw_degrees: Option # ±35。省略で scene.plate_tilt (LLM が書いた値) pitch_degrees: Option # ±35。同上 screen_ratio: Option # 0.2〜0.95。プレートが canvas に占める最大比。省略で 0.78 (見出しありは 0.68)。**等倍で頭打ち** (allow_upscale が無い限り) x_offset_ratio: Option # ±0.4。中央からの横ずらし (canvas 幅比、正 = 右) y_offset_ratio: Option # ±0.4。縦ずらし (正 = 下)。**指定すると見出しの帯のずらしを置き換える** allow_upscale: # rev57 (2026-10-03、ユーザー報告「0.70 以上拡大できない」) type: "Option。省略 = 等倍で頭打ち (rev56 以前の run と LLM の既定。焼き直しても絵が変わらない)" when: "**人が大きさのつまみを動かした時だけ** UI が立てる。`screen_ratio` と組の時だけ効く (単独では無視 — `layout_for`)" effect: "合成の縮尺 `min(枠 / スクショ, 1.0)` の上限 1.0 を外し、等倍を越えた枠では**スクショを引き伸ばす** (文字の輪郭がぼやける)" native: "等倍になる枠 = `compose::native_screen_ratio(canvas, shot) = max(sw / cw, sh / ch)`。`plate_preview` が `native_ratio` として返す" resolution: "上書き → 無ければ帯と scene.plate_tilt から決まる既定。フィールド単位で落ちる" clamp: "範囲外は Rust 側で丸める (UI の入力を信用しない)" whether: # rev24 (2026-09-11、mood にも面を足せるように) where: "**`pipeline::reference::plate_snapshot_index(scene, plate) -> Option` が唯一の決定**。None = 貼らない (素材をそのまま出す)" product: "人の選び直し (PlateOverride.snapshot_index) が LLM の scene.snapshot_index に勝つ (rev13)。どちらも無ければ 0 枚目" mood: "**人が足した時だけ**貼る。既定は絵そのまま。LLM が mood に番号を書いていても無視する (契約では null。それを根拠に貼ると誰も足していないのに面が出る)" no_kind_rewrite: "面を足しても `cut_kind` は書き換えない。書き換えると image_prompt が背景の記述でなくなり (ProductBackdropDrawsScreen と食い違う)、参照画像を作り直したときに mood の絵を失う" callers: "`reburn_caption` (焼き直し) と `plate_preview` (予定位置の枠) が**両方ここを通る**。別々に同じ式を持つと枠が嘘をつく (#19 の型)" material: "mood の素材は背景ではなく**絵そのもの**。空きが無い場所では絵に重なるので、大きさと位置で逃がす前提 (黙って綺麗にはならない)" ui: | rev13: スライダーとドラッグは**値を変えるだけ**。焼き直しは「適用」だけ (実測 debug 0.64 s / release 0.25 s)。 rev14: 触っている間は**予定位置を枠で重ねる** — 座標は command `plate_preview` が `layout_for` + `compose::plate_quad` (合成と同じ関数) から出す。**射影の数式を TS に写さない**。 画像は作らないので速い (ヘッダから寸法を読むだけ)。枠を画像にぴったり重ねるため、 プレビューの要素は object-fit を使わず画像そのものの箱にする (contain だとレターボックス分ズレる)。 rev16: 編集は結果ペインの折り畳みではなく**ダイアログ** (左が絵・右がつまみ)。絵を大きく見るため。 rev21: **つまみは実効値を指す**。`plate_preview` が `tilt: [yaw, pitch]` (合成が使う `tilt_of` の値) を返し、 スライダーの基準にする。`0` に置くと絵が傾いているのにつまみが 0° を指す嘘になる (failures #19)。 表示は `plate.ts::tiltValue`/`tiltLabel` — `0° (正面)` / `18° (LLM)` / `18°` の 3 通りで**出どころまで出す**。 開いたときに `promo.json` の `caption_overrides` / `plate_overrides` をつまみへ戻す。 そのため `reburn_caption` は data URL だけでなく**書き換えた後の `promo`** も返す (上書きを書くのは backend。frontend が手元で真似ると食い違う)。 rev18: 枠に**見出しも含む** — `plate_preview` は `caption_lines: Vec<[x,y,w,h]>` (1 行 1 枠、canvas 座標) も返す。 出所は `image_gen::caption_layout` で、**焼き込み (`burn_caption`) がそのまま描くのと同じ結果**。 `quad` は Option (面が無いシーンでは None — 判定は `plate_snapshot_index`、rev24)。編集中のコピー文は引数 `copy` で渡す (promo.json はまだ古い)。 rev57: `plate_preview` は**実効の枠** `screen_ratio` と**等倍の枠** `native_ratio` も返す。大きさのつまみはそこを基準に実効の大きさを指し (`plate.ts::sizeValue` / `sizeLabel`)、等倍に目盛りを置き、越えた分は「拡大 (ぼやける)」と出す。0.78 の決め打ちは撤去。 rev24: はめ込みの節は**どのシーンにも出す**。mood の既定は「はめ込みなし (絵のまま)」で、選ぶと面が乗る。 つまみ (傾き / 大きさ / 位置) は**面がある時だけ**出す。編集ボタンはどのシーンでも押せる (以前は文も面も無いと押せず、mood に面を足す経路が塞がっていた)。 フォントは数 MB〜数十 MB あるので直前の 1 つだけ持ち回す (打鍵ごとに読み直さない)。 caption_y: "rev14: `CaptionSpec.y_ratio` / `CaptionOverride.y_ratio` (canvas 高さ比 = 文字ブロックの上端)。指定すると position + 余白より優先。canvas からはみ出す値は端で丸める" RunSnapshots: # run に写したスナップショット。**run は自己完結する** (元のパスは動きうる) naming: "**契約**: PromoJson.snapshot_paths[i] ↔ /snapshots/snapshot_{i+1:02}.*。拡張子は元のファイル次第なので読む側は前方一致" where: "promo_core::snapshot_prefix / snapshot_file_name が**唯一の場所**。書き出し (write_package) / 読み出し (read_run_snapshot) / 追加 (add_run_snapshot) が通る" why_one_place: "同じ式が 3 箇所にあると、片方だけ直したとき snapshot_index が別の画像を指す (failures #18 の型)" add: # rev19 (2026-09-09 ユーザー要望「コピーにあった画像が無いときに撮り直して貼りたい」) command: "add_run_snapshot(run_dir, path) → { index, snapshot_paths }。生成はやり直さない — 足した画像ははめ込みの材料になるだけ" numbering: "新しい番号は**一覧の長さ**で決める。ファイルを数えない (欠番や孤児があると番号がずれ、snapshot_index が別の画像を指す)" orphan: "同じ番号のファイルが既にあれば**拡張子を問わず先に消す**。残すと read_run_snapshot の前方一致がどちらを拾うか決まらない" validation: "入力ペインと同じ snapshot_meta のヘッダ検証を通す (壊れた画像を run に入れない)" no_dedup: "同じパスを 2 度足せる。**撮り直しは同じパスで中身が変わる**ので、パスでの重複排除は新しい画像を拒むことになる" intake: | **取り込み口は入力ペインの一覧 1 つだけ** (rev20、ユーザー判断)。ドロップ / 貼り付け / ファイル選択は そこに揃っているので、編集ダイアログには**取り込み口を持たせない** (持たせると同じ機能が 2 箇所になり、 貼り付けの所有権のような調停が要る)。ダイアログは選ぶだけで、run に無いものが選ばれた時に写す。 choices: | `snapshots.ts::snapshotChoices(runPaths, inputPaths, staleRunPaths)` (純関数) が一覧を組む。 run の番号順が先、入力ペインで後から足したぶんが下。**入力ペインから外されても run のぶんは消さない** (run は自己完結していて、入力の一覧はこの run の履歴ではない)。 `index: null` = まだ run に写していない → 選ばれた時に `add_run_snapshot` で写して番号を得る。 rev23: **重複排除の鍵はパスではなく中身**。`staleRunPaths` (撮り直し) は除外しないので、 同じパスが 2 行並ぶ — 上が run の写し (古いまま)、下が今のファイル。肩書きは「撮り直し」。 rev20 のここが `no_dedup` に違反していた (failures #20)。 stale: # rev23 (2026-09-10、撮り直しが一覧に出ない) command: "stale_snapshots(run_dir) → Vec。run は書き換えない (数えるだけ)" rule: "今の元ファイルのバイト列が、**同じパスで写したどの写しとも一致しない**なら撮り直し。パスは 2 度足せるので照合は index 単位ではなく同じパスの写し全部" silent: "元ファイルが読めない (消された / 移された) 時と、写しが無い時は**報せない** — run は自己完結していて焼き直せる (rev10) か、別のエラーで既に報せている" cost: "先に長さで弾き、同じ長さの時だけ読んで照合する" when: "**編集ダイアログを開く直前**に数える。撮り直しはアプリの外で起きるので、監視ではなく見る直前の計測にする" thumbnails: "撮り直しと分かったパスは `snapshotUrls` (パスをキーにしたキャッシュ) からも捨てて読み直す。古い絵のままだと「変わっていない」と読める" CaptionLayout: # rev18。見出しの版組み。**焼き込みとプレビューが共有する唯一の結果** why: "枠は「焼き上がりがどこに来るか」を見せるためのもの。数式が 2 つあると枠が嘘をつく (plate_quad と同じ作法)" where: "image_gen::caption_layout(canvas_w, canvas_h, &Caption) -> Option。**画素を触らない** (幅送りを測るだけ)" px: f32 # 自動縮小の**後**の文字の高さ。指定値をそのまま返すと枠が嘘になる line_h: f32 # px * 1.3 top: f32 # ブロック上端 (caption_top と同じ) block_h: f32 lines: "Vec。x は中央揃えの結果" fidelity: "枠は行送りの箱。**グリフの墨は枠よりわずかに内側**に入り、落ち影のぶんだけ右下にはみ出す (PoC で許容幅を固定)" # PromoJson.original_copy (rev12): scene_id → LLM が最初に書いたコピー文。生成時に 1 度だけ入れて以後触らない。 # コピー文の書き換えは `scene.copy_text` を**その場で**書き換える — scenes.md もクリップボードも plan を # 読むので自動で揃う。reburn は**毎回** scenes.md も書き直す (rev54。以前はコピー文を変えた時だけで、 # 見出し・はめ込みだけ触った run では scenes.md が作った時のまま残っていた)。 ImageGenError: # (Kataribe) Unauthorized / RateLimited{detail} / Blocked{reason} / Timeout{provider} / ComfyNodeError / Api / Network / Shape / Config ImageGenerator: # trait (依存性逆転)。実装 = OpenAiImages / GeminiImages / ComfyUi / テスト用 Fake generate: "(cfg, api_key, prompt, seed, refs) -> Result" probe: "(cfg, api_key) -> Result" # --------------------------------------------------------------------------- # 出力 # --------------------------------------------------------------------------- ExportPackage: # export_dir/_Promo_Package/runs// (rev7) layout: - "/runs//promo.json — { project: PromoProject(パスのみ), summary: AnalyzedSummary, plan: ScenePlan }" - "/runs//scenes.md — シーン構成表 (Markdown 表 + 各シーンの video_prompt 全文)。kind 列の snapshot 番号は**合成が実際に貼るもの** (`promo_core::export::plate_snapshot_index` — PlateOverride の選び直しが plan に勝ち、mood に足した面も書く。rev54、以前は plan の番号)。**番号は 1 始まり** (rev55 — ファイル名 snapshots/snapshot_01 と画面の「1 枚目」に揃える)。結果ペインの chip (`product · snap N`) も同じ規則 (`app/src/plate.ts` の `plateSnapshotIndex`。Rust と TS に同じ式があるので両方のテストに同じ 8 ケースを置く)。promo.json を書く経路はすべて `pipeline::export::write_run_files` を通り、scenes.md と揃えて書く (rev54、以前は焼き直しとスナップショットの追加が promo.json だけを書いていた)" - "/runs//scene_NN_ref_MM.png — 参照画像 (**見出しを焼いた完成品**。MiniMax へ渡すのはこれ)" - "/runs//base/scene_NN_ref_MM.png — **素材** (rev10): product は背景だけ / mood は絵そのもの。どちらも canvas 同寸の PNG" - "/runs//snapshots/ — 入力スナップショットの写し" run_isolation: # rev7 (2026-09-08 ユーザー指摘「再起動すると出力が揮発」→ 調べたらディスク上で破壊されていた) why: | rev6 まで package_dir_name は `_Promo_Package` だけで run の識別子を持たず、同じアプリに 2 回実行すると promo.json / scenes.md / scene_NN_ref_MM.png が**上書きされて過去の出力が消えていた**。 掃除もしないので、新しい run のシーン数が前回より少ないと前回の scene_07 等が残って混ざった。 画像・動画の生成は「新しいものを過去と比べて選ぶ」作業なので、過去が消えるのは機能の否定にあたる。 run_id: "YYYYMMDD-HHMMSS (ローカル時刻)。同一秒の衝突は `-2`, `-3` … を付す。ファイル名に使える文字だけ" dir: "/_Promo_Package/runs// — run はここで自己完結する (promo.json だけで復元できる)" stability: "`promo images /promo.json` は promo.json の親に書くので、入れ子でもそのまま動く" legacy: "rev6 以前の /promo.json は移動も削除もしない (放置。索引には載らない)" clipboard: # rev3: MiniMax (画像→動画) 限定 (ユーザー決定 2026-09-08「Veo とかだと指示に従わない」) target: "minimax | generic" # CopyTarget minimax: "motion_prompt のみ (空なら video_prompt)。画像 (scene_NN_ref_01.png) は MiniMax Design に別途アップロード" generic: "text-to-video の保険: video_prompt + 改行 + '[aspect 16:9 | 5s]'" images: "ファイルパス (クリップボードに画像バイトを載せるのは v1 外)" scenes_md: "先頭に『各カット = 静止画 1 枚 → image-to-video (MiniMax)』の作法。表に kind (product (snapshot N) | mood)、各シーンに Image / Motion prompt / Video prompt (fallback) / Backdrop / Copy" AppIdentity: # bundle identifier。**app_data の場所と WebView プロファイルを決める** identifier: "jp.outcasts.apppromovideo" # 兄弟アプリ (jp.outcasts.concordia / .fuseforks) と同じ「末尾 = 製品名」の流儀 product_name: "AppPromoVideo" app_data: "%APPDATA%// — .env (画像 API キー) / settings.json / runs.json / fonts/ / snapshots/" webview_profile: "%LOCALAPPDATA%//EBWebView/ — localStorage の実体" changing_it: | identifier を変えると app_data と WebView プロファイルが**別フォルダ扱いになり、既存の状態が孤児化する**。 配布後の変更は使う人の設定・キー・履歴を黙って消すので、**公開前にしか変えられない**。 2026-09-08 に jp.outcasts.apppromo → .apppromovideo へ変更 (public 化の直前、ユーザー判断)。移行は Roaming の フォルダ名変更で済ませた。WebView プロファイルは作り直させ、設定は settings.json ミラーから復元させる (ミラーはこの事故のために作った機構 — Kataribe 2026-08-28 実機)。 localstorage_prefix: "apppromo. — identifier とは**別物**。今回は揃えていない (揃えると既存キーの移行が要る)" RunIndex: # rev7。アプリが持つ run の索引 (app_data/runs.json)。**キャッシュであって正本ではない** why: "run は run_dir で自己完結するが、アプリは複数のリポジトリ・複数の出力先を横断して一覧する必要がある。走査だけでは出力先を変えた過去の run を見失う" authority: "正本は run_dir/promo.json。索引が指す run_dir が消えていたら missing として出し、消さない (ユーザーが動かしただけかもしれない)" version: u32 runs: "Vec。新しい順" RunRecord: id: String # run_id (YYYYMMDD-HHMMSS[-N]) created_at: i64 # unix ms app_name: String # AnalyzedSummary.app_name (表示用) project_path: String # 対象リポジトリ package_dir: String # /_Promo_Package run_dir: String # package_dir/runs/ seconds: u32 aspect: String # 表示用。**契約の表記**で入れる ("16:9")。Rust の識別子 ("Landscape") ではない language: String # 同上 ("ja") plate_mode: String # 同上 ("perspective")。GUI に "Perspective" と出ていたのを 2026-09-08 に修正 # PromoJson.plate_mode (rev10): run がどちらのモードで合成されたかを promo.json 自身が持つ。 # 焼き直しで合成をやり直すとき、傾きを適用するかどうかがこれで決まる (索引に頼らない)。 scene_count: usize cost_usd: f64 # analyze + plan の合計 image_provider: Option # 参照画像を作ったなら "gemini" 等 image_count: usize # 生成できた枚数 plan_attempts: usize # rev15。PromoJson.run_stats.plan_attempts の写し。**0 = 記録なし** (rev14 以前の行) violation_kinds: Vec # rev15。同上。種別のみ・重複なく整列 RunStats: # rev15。生成時の再生成ループの記録。**正本は run_dir/promo.json**、RunRecord はその写し why: | 2026-09-08 の実測で frontal の run だけ費用が約 1.5 倍 (1.422 USD 対 0.919 / 0.944) だったが、attempts も 違反種別も永続化していなかったため「ProductBackdropAngled で再生成が発火した」という推測を確認できなかった。 索引 (app_data/runs.json) はキャッシュで消えうるうえ、CLI (`promo run`) には索引が無いので、 正本である promo.json 自身に持たせる (plate_mode が rev10 で同じ判断をしている)。 2026-10-02: 溜まった記録 (13 本) で 1.5 倍は再現せず、frontal の費用の問いは閉じた。記録は再生成の原因を数えるために残す。 plan_attempts: usize # シーン構成が検査を通るまでの回数。1 = 一発。**analyze は常に 1 なので持たない** violation_kinds: "Vec。promo_core::violation_kind の値を重複なく整列。**この文字列は契約**" cost_usd: f64 # analyze + plan の合計。再生成が費用に効いたかを promo.json だけで見るため absent: "PromoJson.run_stats は Option。**None = 記録なし** であって「1 回で通った」ではない (rev14 以前の run)" models: # rev25 (2026-09-11)。実際に使われたモデル type: "Option>。analyze + plan の**全試行**で CLI が init 行に書いたモデル名を、重複なく整列" resolved: "**入力した文字列ではなく解決後の名前**。`sonnet` や空欄のまま走らせても、ここには `claude-sonnet-5` のような実名が入る" absent: "**None = 記録なし** (rev24 以前の run)。**Some([]) = CLI が名乗らなかった** (stream-json の init を出さない CLI)。空で埋めて区別を消さない" why: | エイリアス (`sonnet`) は系列の最新版に追従するので、新しい版が出ると黙って切り替わる。 開いている判断 1 (frontal の費用) は同じモデル同士の比較が前提なのに、それまで run はモデルを **どこにも残していなかった** (2026-09-11 実測: Fuseforks の promo.json に `model` の文字列 0 件)。 assembly: "**`pipeline::stages::run_stats(&analyze, &plan)` が唯一の組み立て**。CLI (`promo run`) と GUI が同じ関数を通る (#18: 同じ構造体を 2 箇所で手組みしない)" index: "索引 (RunRecord) にはまだ写さない。一覧に列を足す時に写す" violation_kind_values: | scene_count / total_seconds / scene_id_not_sequential / duration_out_of_range / empty_field / video_prompt_not_english / video_prompt_has_aspect_flag / image_prompt_mentions_input / no_product_cut / snapshot_index_invalid / product_backdrop_draws_screen / motion_prompt_empty / motion_prompt_not_english / plate_tilt_out_of_range / product_backdrop_angled (PlanViolation の variant と 1:1。**describe_violation とは用途が違う** — あちらは scene_id や語が埋まる 人と LLM 向けの文で、同じ種類でも文字列が変わるので数えられない) # --------------------------------------------------------------------------- # デスクトップ殻 (spec 01 Phase D、2026-09-08) # --------------------------------------------------------------------------- DesktopUi: # app/ = Tauri 2 + Vue 3 + Pinia。Tailwind は使わず CSS 変数 (data-theme dark/light) layout: "3 ペイン: 左 InputPane (リポジトリ / スナップショット / 世界観 / 尺・比率・言語 / 出力先 / 実行・中断) / 中 ScenePanel (要約・シーン表・参照画像・コピー・export。シーンごとの編集は rev16 から CaptionEditor ダイアログ) / 右 LogPanel (event promo-progress)。TitleBar は decorations:false のカスタム (Kataribe 縮約)" commands: # app/src-tauri/src/lib.rs。HTTP とプロセスは全部 backend load_ui_settings / save_ui_settings: "settings.json ミラー (接頭辞 apppromo.)" pick_directory / pick_images: "rfd (plugin 不使用、追加権限ゼロ)" brief_preview / validate_snapshot: "LLM ゼロ。RepoBrief の字数・行数、画像ヘッダ検証" check_cli: " --version を 20 s で。NotFound を実行前に見せる (P3)" run_pipeline / cancel_run: "RunRequest → RunResult{promo, package_dir, analyze, plan, brief_chars}。cancel は watch::Sender (同時 1 run)。cwd = app_data/work (決定 4)" generate_images: "ImagesRequest{promo, package_dir, image: ImageGenConfig, snapshot_paths, max_scenes, requested_refs} → ImagesResult{promo, results, palette, anchor, truncated}。user_prefix 空なら style_anchor を合成" image_data_url: "表示は data URL (CSP img-src data:)。ローカルファイルを WebView に直接見せない" get_image_api_keys / set_image_api_key / probe_image: "キーは app_data/.env、画面には有無 (bool) だけ" open_folder / copy_package: "export 補助" event: "promo-progress {stage: brief|analyze|plan|cli|cli-stderr|images|done|error, text}" event_listener: "rev61: `store.listenProgress` は登録中の印 (`listening`) を await の前に立てる — 終わる前に再び呼ばれても登録は 1 回。失敗したら印を下ろし、次の呼び出しでやり直す" typography: # rev27 (2026-09-11 ユーザー「文字を少しだけ大きく」) tokens: "main.css の :root に --fs-xs / sm / md / base / lg / xl = (11 / 12 / 13 / 14 / 15 / 19 px) × **--fs-scale** (項目、rev29 で 1.25)。**部品は font-size を px で直に書かずトークンを参照する**。例外は Lightbox の矢印 (文字ではなく記号、40px)" headings: "rev29: 見出しは --fs-h-md / h-lg / h-xl = (13 / 15 / 19 px) × **--fs-scale-heading** (1.5)。対象 = .panel h2 (入力・結果・進捗) / 結果のアプリ名 .app とキャッチ .hook / メッセージボックス .mb-title / 設定の小見出し .sub の 5 か所 (rev31 で履歴画面の見出し .title を足して 6 か所)。ユーザー「見出しはこれくらい、中の項目はちょっと小さく」で倍率を 2 本に分けた" chrome: "rev29: ウィンドウ枠 (タイトルバーのアプリ名 .brand) は文字の試行の対象外。--font-chrome / --fs-chrome / --fw-chrome を参照し、倍率は掛けない。ユーザー「ウィンドウタイトルバーのアプリタイトルのフォントと大きさは変えなくていい」。rev29 の値 (Segoe UI / 12px / 700) は、rev30 のデザイン修正 (ユーザー) で Inter Variable / 14px / 600。中の「実行中」の chip は .chip のまま (項目の倍率が掛かる)" font: "rev30: --font-ui = \"Inter Variable\", \"Noto Sans JP Variable\", \"Noto Sans JP\", \"BIZ UDPGothic\", \"Yu Gothic UI\", system-ui …。**同梱する** — @fontsource-variable/inter と @fontsource-variable/noto-sans-jp (5.3.0、OFL-1.1) を main.ts で import。**先頭はパッケージの宣言名** (Inter と書くと手元に入っていない限り当たらない)。**外部 (Google Fonts) から読まない** — tauri.conf.json の CSP は style-src に外部を含まず font-src 未指定 (= default-src 'self')。woff2 を data: にインライン化させない (同じ理由で読めない)。rev28 の IBM Plex Sans JP (未同梱) は rev30 のデザイン修正で置き換わった" weight: "rev28: --fw-base (本文) / --fw-semi (旧 600) / --fw-bold (旧 700) を全部 700。本文だけ Bold にすると見出し・ボタンの SemiBold が本文より細く見えて逆転するため。rev29: --fw-medium (500) を追加。rev30 のデザイン修正 (ユーザー): 本文 500 / semi 600 / bold 700 / --fw-regular 400 を追加" guard: "rev30: cssTokens.test.ts = フォールバックの無い var(--x) は src のどこかで定義されていること (定義を消すと宣言ごと無効になり黙って親の値に落ちる)。fonts.test.ts = index.html が外部の CSS・フォントを読まない / main.ts が同梱フォントを import / --font-ui と --font-chrome の先頭がパッケージの宣言名。vitest は既定で CSS を空にするので vite.config.ts の test.css.include (ID にクエリ ?raw が付くので /\\.css(?:\\?|$)/) で通す" secondary: "rev29: .muted (説明文と補足、47 か所) は Medium (--fw-medium) で小さめ (--fs-sm)。ユーザー「説明文は bold でなく medium で小さめ」。説明文以外の薄い文字 (ログの時刻 / 表の plate_mode 列 / コピー先のラベル / Lightbox の番号など) も同じになる。中の は bolder で Bold に戻る。設定ダイアログの .note も --fs-sm に揃えた" log: "rev29: 進捗ログは等幅 (Consolas、Medium が無い) をやめて UI の書体の Medium・本文の大きさ (--fs-base)。時刻の桁は font-variant-numeric: tabular-nums で揃える。ユーザー「進捗のログも medium で普通サイズ」" why: "旧値 10 / 11 / 12 / 13 / 14 / 18 px が 37 か所に直書きされていた。基準 (body) だけ上げてもラベルや補足 (11〜12px 固定) が変わらないので、トークンにまとめて全段 +1px。次の調整は main.css の 1 か所で済む" form_controls: # rev33 (2026-09-12 ユーザー「選択されるとチェックボックスを大きくする必要はないかな」) text_inputs: "main.css の input / select / textarea は文字の入力欄の寸法 (幅 100% / 最小の高さ --input-height 44px / 余白) を持つ (rev30 のデザイン修正)" checkbox_radio: "input[type=checkbox] / [type=radio] はその寸法を持たない — 16px 四方・余白なし・accent-color は --accent2。部品側で幅や高さを直書きしない (設定ダイアログの style=width:auto は rev33 で外した)" switch: "rev34: 入り切りは components/Switch.vue (設定の 4 か所)。中身は checkbox のまま (v-model / Tab の移動 / 読み上げ上の役割を変えない)、枠は input 自身を appearance: none で描き、**つまみは兄弟の span** — の疑似要素はブラウザ次第で、描かれたかを外から確かめられない。枠 38x22 / つまみ 14x14 / 余り 4px、ON は --accent。履歴の比較のチェックは表の複数選択なので checkbox のまま" screens: # rev36 (2026-09-12 ユーザー「開いているときにもう一度クリックすると戻ると同じ動きに」) toggle: "タイトルバーの履歴 / 設定のアイコンは**トグル** (views.ts の nextView)。開いている画面のアイコンをもう一度押すとメインへ戻り、別の画面を開いている時はメインを経由せず切り替える。設定から離れる時は store.persist()" active: "開いている画面のアイコンはアクセント色 (.tb-btn.on)。押せば戻ることを見せる" result_header: # rev36 source: "結果ペインの見出しの数値は**正本 (promo.json の run_stats)** から出す (runs.ts の runHeaderStats)。履歴から開いた run は実行時の stage を持たないので、そこを描くと存在しない記録 (0 回目で通過 / 0.000 USD) になる" no_record: "記録が無い run (rev14 以前) は 0 ではなく「—」で、費用と回数の chip は**出さない**。経過時間の tooltip は stage がある時だけ" settings_screen: # rev35 (2026-09-12 ユーザー「設定画面もダイアログではなく 1 画面に。スクロールなしで収まるように」) shape: "設定 (SettingsScreen.vue) は全画面。タイトルバーの下で 3 ペインと入れ替える (App.vue の view = main | runs | settings)。ダイアログではないので .panel も重なり順も使わない" columns: "3 列 = LLM CLI / 画像生成 / 面と見出し。列の間は区切り線。タブは廃止し、見出しに settings.title / cliTab / imageTab を使う (settings.imageTabLabel は撤去)" collapsed: "長い説明 (llmNote / toolsNote / imageNote / plateNote / captionNote)、認証の診断、ComfyUI の詳細 (ネガティブ / ワークフロー JSON / seed) は
で畳む。**文言は消さない** — 初期表示を 1 画面に収めるため" no_scroll: "画面自体はスクロールしない (.screen は overflow: hidden)。収まらない時は**列の中だけ**が動く。実測 1288x842 (ユーザーの窓) と 1600x1017 で画面・3 列とも溢れ 0。ComfyUI の詳細を開いた時だけ 2 列目が 253px スクロールする (JSON の入力欄なので許容)" rows: "横に並べる項目は .grid-row (grid + align-items: end) で**下端を揃える** — ラベルが 1〜3 行に折り返すので flex の中央揃えでは入力欄がずれる (ユーザー報告「段が崩れないように」)。label でないボタンは label.field と同じ下余白 4px を自分で持つ。項目の余白は既定 (14px / 6px) より詰めて 8px / 4px" i18n: # rev30 (2026-09-11、ユーザーが多言語化 → 残りを回収) langs: "ja (正本) / en / zh-CN。localStorage apppromo.ui_lang、既定 ja。**コピー言語 (project.lang = LLM に書かせる言語) とは別物**" dict: "app/src/i18n.ts。ja の as const からキー型 MessageKey を取り、en / zh-CN は Record — キーの欠け (TS2740) も余り (TS2353) も型検査で落ちる。キー集合の一致は i18n.test.ts でも見る" rule: "**画面に出る文言をコードに直書きしない**。網 noHardcodedJapanese.test.ts (コメント以外の日本語を落とす)。対象外 = i18n.ts / settings.ts (見出しフォントの照合に使う実機のフォント名) / console.*。backend (Rust) 由来の進捗ログ・エラー文言はこの契約の外 (日本語のまま)" params: "t(key, params) の {name} は 1 回の走査で差し込む。値を置換パターンとして解釈しない ($& がパスやエラー文言に入りうる) / 差し込んだ値の中の {…} を再び拾わない / params に無い名前はそのまま残す" rich: "強調と等幅は文言の中に … / … と書き、components/Rich.vue で出す (解析は rich.ts)。**v-html は使わない** — 認める印以外の < は文字として出る。閉じ忘れは文末まで、対の無い閉じ印は無視 (文字を落とさない)" ja_wording: "rev30 の移行で日本語の文言は変えていない (移行前の日本語断片 275 個を辞書と突き合わせた)。ソースの改行で画面に出ていた空白 1 つも残した" panes: # rev27 (ユーザー「入力、結果、進捗の枠をなくしてみたい」、試行) frameless: "3 つの列の直下の .panel は背景・枠線・角丸を持たない (App.vue の scoped + :deep)。**共通の .panel はダイアログ (シーン編集 / メッセージボックス) が使うので変えない** (履歴は rev31、設定は rev35 から全画面で .panel を使わない)" runs_screen: # rev31 (2026-09-11 ユーザー「履歴はダイアログではなく全画面に。開くを押すと画面を閉じ、開いた run でメイン画面に戻る」) shape: "履歴 (RunsScreen.vue) はダイアログではなく全画面。タイトルバーの下で 3 ペインと入れ替える (App.vue の view = main | runs)。メインは v-show で隠すだけ — 戻った時に入力・コピー先の選択・スクロール位置が残る" open: "「開く」は store.openRun(dir): Promise が true の時だけメイン画面へ戻る。false ならこの画面に残り、store.error をその場に出す (この画面ではログのペインも入力ペインのエラー表示も見えない)。読み込み中は他の「開く」を押させない。openRun は始めに store.error を空にする" back: "左上の「戻る」でメイン画面へ。Esc では戻らない (ダイアログ時代も閉じるボタンと背景クリックだけだった)" pin: "rev32: 比較中 (2 つ選択) は比較対象を選んだ順 A → B で表の先頭に出し (runs.ts pinCompared、残りは元の順)、行に A / B の印を付けて下の画像と揃える。組が変わったら画面と表のスクロールを先頭へ戻す。1 つ外すと比較が解けて全件の表に戻る" cells: "rev33: 表のセル (td) を display: flex にしない — 表のセルとして並ばなくなり、同じ行で上端と高さがずれる (比較対象の行の色付けが段になった)。並べたい中身は td の中の div を flex にする (チェックと A / B の印 .pick、操作ボタン .row)" compare: "rev32: 表の包み (.table-wrap、横にだけ流す) は縮ませない (flex-shrink: 0 — overflow を持つ箱は縦の flex の中で最小の高さが 0 扱いになり、rev31 では画像に場所を譲って 2 行まで潰れた)。比較中だけ表に max-height 30vh + 中でスクロール + 見出し行を固定。比較の区画が残りの高さを取り、画像はその中に object-fit: contain で収まる。画像の枠の最小 260px、それより低い窓は画面全体をスクロール" dialogs: # rev26 (2026-09-11 ユーザー報告、スクリーンショットつき) rule: "**ブラウザ標準の confirm / alert / prompt を使わない**。WebView の標準ダイアログは見出しに配信元の URL (`localhost:1421 の内容`) を出し、見た目もアプリから外れる" where: "`dialog.ts` の `ask(opts) -> Promise` が状態を持ち、`MessageBox.vue` (App.vue に 1 つ) が描く。**一度に 1 件** — 出ている間の次の ask は待たせ、答えた順に返す" options: "message / title (既定「確認」) / ok (既定「OK」) / cancel (既定「キャンセル」) / danger (削除など。肯定側を警告色にし、最初の焦点を否定側へ)" keys: "Esc と背景クリック = 否定側。keydown は capture 段階で受けて伝播を止める (下のダイアログの Esc が同じ打鍵で反応しないように)。Tab は 2 つのボタンの間に閉じ込める。閉じたら開く前の焦点へ戻す" layer: "z-index 70 = すべてのダイアログの上 (シーン編集 45 / トースト 50 / Lightbox 60)。履歴 (rev31) と設定 (rev35) は全画面なので重ならない (40 は空き)" guard: "`noBrowserDialogs.test.ts` が src の .vue / .ts を走査して標準ダイアログの呼び出しを落とす (機械の網)。網そのものの検出力もテストする (画面の文言「video prompt (…)」に初版が誤検出した)" snapshots_ux: # 2026-09-08 ユーザー FB: ドロップ / 貼り付け / 横並び + 拡大 drop: "tauri.conf dragDropEnabled: true → getCurrentWebview().onDragDropEvent の drop.paths (OS のパスが取れる。HTML5 drop はパスを持たないので使わない)。enter/over で帯を強調" listener_lifetime: "rev60: 登録は非同期 (動的 import + IPC 4 往復) なので `asyncListener.ts::disposableListener` で部品の寿命に結ぶ — 登録の途中で外されたら、終わった時点ですぐ外す。以前は外す関数が null のまま外し損ね、リスナーが残った (1 回のドロップが 4 枚)" idempotent: "rev60: `store.addSnapshotPaths` は validate_snapshot を待った**後**、push の直前にもう一度一覧を見る。同じパスが同時に届いても 1 枚 (以前は await の前だけで判定し、同時 4 回 → 4 枚)" paste: "window の paste event → clipboardData.items の image/* File → FileReader.readAsDataURL → base64 を command save_clipboard_image(base64, mime) へ → app_data/snapshots/clip_. に保存し snapshot_meta で検証 (壊れていれば消す) → 他のスナップショットと同じパスとして一覧へ" strip: "SnapshotStrip.vue: 横スクロールのサムネイル (data URL)、× で外す、クリックで Lightbox (← → Esc、『この画像を外す』)。参照画像も同じ Lightbox で拡大" pure: "snapshots.ts (mergePaths 重複と非画像の除外 / stripDataUrl / imageFilesFrom / baseName) は vitest 3 本" settings_two_sections: "決定 10 の実装。設定は『LLM CLI』(キー欄なし、CLI 認証委任の説明) と『画像生成』(OpenAI/Gemini はキー必須・ComfyUI は無キー) を**別の列**に置く (rev34 まではタブ)" frontend_settings: "localStorage apppromo.cli / apppromo.image (perProvider スロット = Kataribe spec 26 の写し、toBackendConfig で他プロバイダの値を送らない) / apppromo.project / apppromo.theme。vitest 6 本" clipboard: "navigator.clipboard.writeText (plugin 不使用)。CopyTarget veo|sora|generic は ScenePanel で選ぶ" send_bounds: "Tauri の async command は future 全体に Send を要求 → TaskRunner / ImageGenerator trait を Send + Sync、progress を dyn FnMut + Send、tree_kill unix の Cell を Mutex に (Phase D で判明)" verification: "vue-tsc + vite build green / vitest 6 / src-tauri cargo check・clippy clean・test 3 / GUI の目視はユーザー" # --------------------------------------------------------------------------- # 設定の置き場 # --------------------------------------------------------------------------- config_sources: localStorage: "非秘密 (CliSpec / ImageGenConfig のプロバイダ別スロット (Kataribe spec 26) / 直近の project)" env_file: "app_data/.env — IMAGE_API_KEY_OPENAI / IMAGE_API_KEY_GEMINI (Kataribe と同名)。LLM のキーは持たない (CLI 側の認証に委ねる)" settings_mirror: "app_data/settings.json — localStorage の耐久コピー (Kataribe settings_store と同型。identifier 変更で設定が消える事故の予防)" # --------------------------------------------------------------------------- # 実測ログ (live) # --------------------------------------------------------------------------- live: claude_stream_20260907: "claude 2.1.223 / -p --output-format stream-json --verbose --json-schema。Claude デスクトップの子セッション内では OAuth が継承されず authentication_failed (fixtures/claude_auth_failed.jsonl)。封筒 = system(init) → assistant → result の 3 段を確認。成功時の構造化出力の置き場は未確認" claude_stream_20260908: "ユーザー端末で scripts/capture_claude_fixture.ps1 を実行 → 401 authentication_failed を 10 回再試行 (約 2.5 分) して is_error (fixtures/claude_auth_retry_loop.jsonl)。親チェーンは WindowsTerminal → explorer で、デスクトップ内端末説は棄却。`claude auth status --text` = Login: Expired (OAuth 期限切れ、ユーザーも対話で承認要求を確認)" claude_stream_20260908_ok: "同日、ユーザーレベル環境変数 ANTHROPIC_API_KEY (Windows の User スコープに設定済み) をプロセスに適用して再実行 → 成功 (fixtures/claude_json_schema_ok.jsonl、13 行、haiku、cost 0.034 USD、78.5 s)。**ただし成功の前に 401 authentication_failed の api_retry が 7 回**ある (期限切れ OAuth で試行 → 途中から API キーに切り替わったと推定、機序は未確認)。-p は OAuth 期限切れでも ANTHROPIC_API_KEY があれば最終的に動くが、再試行の待ち時間 (約 70 s) を払う。ユーザーの端末で 401 のまま落ちた理由 (User スコープの変数が載っていない端末だった可能性) は未分離" pipeline_live_20260908: "promo run D:/Github/Kataribe (README + images/lorekeel_ui.png、--model sonnet --max-turns 12、User スコープ ANTHROPIC_API_KEY 適用、cwd = %TEMP%/apppromo_work、repo は --add-dir)。brief 12,993 字 / tree 188 行。analyze 0.390 USD / 62 s。plan **attempts 2** (初回に違反 1 件 → repair_suffix で再生成 → 通過。種別は当時の CLI が出力しておらず不明、以後は describe_violation を出す) 0.596 USD / 183 s。合計 0.99 USD / 4 分 22 秒。出力 = 7 シーン・尺合計 30 = total_seconds・video_prompt 全英語・copy 日本語・--ar なし。palette は『#15110E (ink, 焦げ茶の黒/背景)』のように hex + 説明の混在 (契約は hex 想定 — Phase C で画素算出に置き換わるので検査は緩いまま)。scene 3 の image_prompt が『upper half / lower half』の分割構図 (LLM の演出意図であり入力参照ではない。#85 の分割とは別物だが、参照画像生成時に分割が出やすい素地として記録)" images_live_20260908: "promo images --images gemini --image-scenes 2 --snapshot lorekeel_ui.png (GEMINI_API_KEY を User スコープから適用、モデル既定 gemini-3.1-flash-lite-image)。17 秒で 2/2 成功、各 450〜550 KB、16:9、1 場面 (分割なし)。palette from pixels = #181808 #483828 #987858 #989898 #F8F8F8 (UI スクショの暗色基調 + 白)。目視: 暖色の暗色基調はアンカーに寄った。**製品 UI そのものは 1 枚にも映らない** (7 シーン全部が情景 → scene_prompt に『UI が映るシーンを最低 1 つ』を追加)。**mood / ui_traits が日本語のまま画像モデルへ渡った** → analysis_prompt で visual_identity を英語に固定。Gemini 参照 1 枚 (max 3 のうちスナップショット 1 枚)。ComfyUI / OpenAI の live は未実施" auth_preflight: "claude auth status --json → { loggedIn: bool, authMethod: none|…, apiProvider } (2026-09-08 実測)。ただし OAuth の状態しか見ない (API キー経路で動く時も loggedIn=false)。runner の起動前検査には『loggedIn=false かつ ANTHROPIC_API_KEY 未設定』の組み合わせだけを『認証なし』として使う" toolchain_20260907: "Rust 1.95 / Node 24.15 / tauri-cli 2.11.3 / claude 2.1.223。aider・gemini・codex・Flutter 無し。ComfyUI 未起動"