akari-video 0.1.96 → 0.1.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +19 -0
  2. package/package.json +1 -1
  3. package/src/doctor-command.mjs +3 -0
  4. package/src/runtime-diagnostics.mjs +35 -5
  5. package/src/store-command.mjs +1 -1
  6. package/src/store-device-connect.mjs +6 -6
  7. package/vendor/docs/contract-2026-07-22-render-basics.md +1 -1
  8. package/vendor/docs/contract-2026-07-23-analysis-person-matte.md +1 -1
  9. package/vendor/docs/contract-2026-08-02-preview-parity.md +1 -1
  10. package/vendor/docs/contract-2026-08-03-caption-display-encoding-qc-v1.md +8 -4
  11. package/vendor/docs/contract-2026-08-28-osr-export-v0.md +5 -3
  12. package/vendor/docs/contract-2026-08-28-v2-approximation-ledger.md +1 -1
  13. package/vendor/docs/contract-2026-08-28-v2-audio-roles-v0.md +31 -0
  14. package/vendor/docs/contract-2026-08-29-capture-v0.md +2 -0
  15. package/vendor/packages/akari-launcher/README.md +19 -0
  16. package/vendor/packages/akari-launcher/package.json +1 -1
  17. package/vendor/packages/asset-resolver/browse/app.js +1 -1
  18. package/vendor/packages/asset-resolver/src/resolve.mjs +2 -2
  19. package/vendor/packages/edit-lint/README.md +7 -0
  20. package/vendor/packages/edit-lint/src/edit-lint.mjs +56 -10
  21. package/vendor/packages/edit-store/lib/internal-model.js +42 -6
  22. package/vendor/packages/edit-store/lib/webview-kernel.js +28 -4
  23. package/vendor/packages/gpu-export/README.md +4 -0
  24. package/vendor/packages/media-bin/src/preview-audio-sidecar.mjs +4 -1
  25. package/vendor/packages/media-bin/test/preview-audio-pcm-sidecar.test.mjs +19 -0
  26. package/vendor/packages/osr-export/README.md +2 -0
  27. package/vendor/packages/overlay-runtime/runtimes.mjs +108 -13
  28. package/vendor/packages/overlay-runtime/src/overlay-runtime.js +287 -58
  29. package/vendor/packages/overlay-runtime/src/three-runtime.js +59 -9
  30. package/vendor/packages/project-scaffold/src/index.mjs +1 -1
  31. package/vendor/packages/project-scaffold/test/create-project.test.mjs +2 -2
  32. package/vendor/packages/render-cut/README.ja.md +1 -1
  33. package/vendor/packages/render-cut/README.md +1 -1
  34. package/vendor/packages/render-cut/src/html-scan.mjs +175 -7
  35. package/vendor/packages/schemas/ai-models.json +24 -0
  36. package/vendor/packages/schemas/ai-models.schema.json +17 -2
  37. package/vendor/packages/schemas/test/ai-models-actual-card.test.mjs +21 -0
  38. package/vendor/skills/analyze-footage/SKILL.md +1 -0
  39. package/vendor/skills/beat-sync-edit/pitfalls.md +1 -1
  40. package/vendor/skills/edit-lint/SKILL.md +6 -2
  41. package/vendor/skills/generate-narration/SKILL.md +6 -6
  42. package/vendor/skills/generate-narration/engines.md +20 -1
  43. package/vendor/skills/generate-narration/reading-text.md +3 -1
  44. package/vendor/skills/overlay-authoring/3d.md +3 -3
  45. package/vendor/skills/render-cut/SKILL.md +6 -2
package/README.md CHANGED
@@ -123,6 +123,25 @@ npm i -g akari-video && akari
123
123
  npx akari-video
124
124
  ```
125
125
 
126
+ ### akari doctor の cli.node
127
+
128
+ `akari doctor --json` の `cli.node` は、この CLI を動かしている実行体と、スクリプトを Node として呼ぶ方法を返す。
129
+
130
+ | フィールド | 意味 |
131
+ |---|---|
132
+ | `exec_path` | 診断を実行した実行体のパス。Electron アプリの EXE になることもある |
133
+ | `electron_run_as_node` | 診断を実行したプロセスの環境変数が `ELECTRON_RUN_AS_NODE=1` だったか |
134
+ | `version` | 実行体の Node のバージョン(Electron では内蔵の Node) |
135
+ | `runtime` | 実行体が `electron` か `node` か |
136
+ | `electron_version` | Electron のバージョン。Node 実行体では `null` |
137
+ | `run_as_node_supported` | Electron 実行体を Node として呼べるか |
138
+ | `required_env` | Node として呼ぶために必要な環境変数。Electron では `{ "ELECTRON_RUN_AS_NODE": "1" }`、Node では `{}` |
139
+ | `invocation` | `shell` は既定シェル、`example` はそのシェル用の呼び方。`cmd` と `sh` は両方常に出る。`<script>` を実際のスクリプトパスに置き換える |
140
+
141
+ `electron_run_as_node` は診断時の環境だけを表す。Node として使えるかは `runtime`、`run_as_node_supported`、`required_env` で判断する。`runtime` が `electron` なら `required_env` を付けて呼ぶ。Windows の `cmd` では `set "ELECTRON_RUN_AS_NODE=1"` と引用符で囲む。`set ELECTRON_RUN_AS_NODE=1 &&` と書くと末尾の空白が値に入る。
142
+
143
+ Electron を Node として呼んだプロセスから GUI の Electron を子プロセスとして起動するときは、子へ渡す環境から `ELECTRON_RUN_AS_NODE` を外す。
144
+
126
145
  ## 既知の制約
127
146
 
128
147
  - npm tarball は追跡済み skills・雛形・schemas と capability source set を `vendor/` に同梱する。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.96",
3
+ "version": "0.1.98",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,6 +29,9 @@ export async function runDoctorCommand(argv, options = {}) {
29
29
  export function formatDoctorReport(report) {
30
30
  const rows = [
31
31
  ['cli', 'ok', `v${report.cli.version} — ${report.cli.entry_path}`],
32
+ ['node', report.cli.node?.runtime ?? 'unknown', report.cli.node
33
+ ? `v${report.cli.node.version} — ${report.cli.node.exec_path}${report.cli.node.runtime === 'electron' ? ' (Node として使うには ELECTRON_RUN_AS_NODE=1 を付けて呼ぶ)' : ''}`
34
+ : '診断情報がありません'],
32
35
  ['app_managed', report.app_managed.status, detail(report.app_managed.path, report.app_managed.version)],
33
36
  ['app_bundle', report.app_bundle.found ? 'found' : 'missing', detail(report.app_bundle.path, report.app_bundle.version)],
34
37
  ['render_cut', report.render_cut.origin, report.render_cut.path ?? '見つかりません'],
@@ -128,6 +128,36 @@ export function resolveRuntimePaths(options = {}) {
128
128
  };
129
129
  }
130
130
 
131
+ export function describeNodeRuntime(options = {}) {
132
+ const execPath = options.execPath ?? process.execPath;
133
+ const platform = options.platform ?? process.platform;
134
+ const env = options.env ?? process.env;
135
+ const versions = options.versions ?? process.versions;
136
+ const runtime = versions.electron ? 'electron' : 'node';
137
+ const quotedExecPath = `"${execPath}"`;
138
+ const cmd = runtime === 'electron'
139
+ ? `set "ELECTRON_RUN_AS_NODE=1" && ${quotedExecPath} <script>`
140
+ : `${quotedExecPath} <script>`;
141
+ const sh = runtime === 'electron'
142
+ ? `ELECTRON_RUN_AS_NODE=1 ${quotedExecPath} <script>`
143
+ : `${quotedExecPath} <script>`;
144
+ return {
145
+ exec_path: execPath,
146
+ electron_run_as_node: env.ELECTRON_RUN_AS_NODE === '1',
147
+ version: options.nodeVersion ?? versions.node,
148
+ runtime,
149
+ electron_version: versions.electron ?? null,
150
+ run_as_node_supported: runtime === 'electron',
151
+ required_env: runtime === 'electron' ? { ELECTRON_RUN_AS_NODE: '1' } : {},
152
+ invocation: {
153
+ shell: platform === 'win32' ? 'cmd' : 'sh',
154
+ example: platform === 'win32' ? cmd : sh,
155
+ cmd,
156
+ sh,
157
+ },
158
+ };
159
+ }
160
+
131
161
  export async function resolveDoctorReport(options = {}) {
132
162
  const env = options.env ?? process.env;
133
163
  const platform = options.platform ?? process.platform;
@@ -144,11 +174,11 @@ export async function resolveDoctorReport(options = {}) {
144
174
  cli: {
145
175
  version: options.cliVersion ?? readOwnVersion(),
146
176
  entry_path: options.entryPath ?? process.argv[1] ?? null,
147
- node: {
148
- exec_path: execPath,
149
- electron_run_as_node: env.ELECTRON_RUN_AS_NODE === '1',
150
- version: options.nodeVersion ?? process.versions.node,
151
- },
177
+ node: describeNodeRuntime({
178
+ execPath, platform, env,
179
+ versions: options.versions ?? process.versions,
180
+ nodeVersion: options.nodeVersion,
181
+ }),
152
182
  },
153
183
  ...runtime,
154
184
  ffmpeg: mediaBin.ffmpeg,
@@ -265,7 +265,7 @@ export async function runStoreCommand(args, options = {}) {
265
265
  return { exitCode: 1 };
266
266
  }
267
267
 
268
- log('ブラウザで AKARI Store を開いて接続を承認してください。');
268
+ log('ブラウザで AKARI Video Lab を開いて、AKARI アカウントの接続を承認してください。');
269
269
  log(` 確認コード: ${start.userCode}`);
270
270
  log(` URL: ${start.verificationUrl}`);
271
271
  if (!args.includes('--no-open')) {
@@ -62,10 +62,10 @@ export async function fetchStoreEntitlements(fetchImpl, baseUrl, token) {
62
62
  headers: { authorization: `Bearer ${token}` }
63
63
  });
64
64
  } catch (error) {
65
- return { error: `ストアに接続できませんでした: ${error instanceof Error ? error.message : String(error)}` };
65
+ return { error: `AKARI Video Lab に接続できませんでした: ${error instanceof Error ? error.message : String(error)}` };
66
66
  }
67
67
  if (response.status === 401) return { error: 'トークンが無効です。マイページで発行し直してください。' };
68
- if (!response.ok) return { error: `ストアがエラーを返しました(${response.status})` };
68
+ if (!response.ok) return { error: `AKARI Video Lab がエラーを返しました(${response.status})` };
69
69
  return { data: await response.json() };
70
70
  }
71
71
 
@@ -125,16 +125,16 @@ export async function startDeviceConnection({
125
125
  } catch (error) {
126
126
  return {
127
127
  status: 'network-error',
128
- error: `ストアに接続できませんでした: ${error instanceof Error ? error.message : String(error)}`
128
+ error: `AKARI Video Lab に接続できませんでした: ${error instanceof Error ? error.message : String(error)}`
129
129
  };
130
130
  }
131
131
  if (!response.ok) {
132
- return { status: 'error', error: `ストアに接続できませんでした(${response.status})` };
132
+ return { status: 'error', error: `AKARI Video Lab に接続できませんでした(${response.status})` };
133
133
  }
134
134
  const body = await response.json().catch(() => ({}));
135
135
  if (typeof body.device_code !== 'string' || typeof body.user_code !== 'string'
136
136
  || typeof body.verification_url !== 'string') {
137
- return { status: 'error', error: 'ストアから接続に必要な情報を受け取れませんでした。' };
137
+ return { status: 'error', error: 'AKARI Video Lab から接続に必要な情報を受け取れませんでした。' };
138
138
  }
139
139
  const result = {
140
140
  status: 'started',
@@ -168,7 +168,7 @@ export async function pollDeviceConnection({
168
168
  } catch (error) {
169
169
  return {
170
170
  status: 'network-error',
171
- error: `ストアに接続できませんでした: ${error instanceof Error ? error.message : String(error)}`
171
+ error: `AKARI Video Lab に接続できませんでした: ${error instanceof Error ? error.message : String(error)}`
172
172
  };
173
173
  }
174
174
  if (response.status === 410) {
@@ -178,5 +178,5 @@ x264、その他の環境で x264 の順に解決する。ハードウェア対
178
178
  OSR / GPU の v2 書き出しでは、映像エンジンが `edit.sources` から直接描画するため、cut 段と
179
179
  tail-pad 段は音声専用中間物だけを生成する。通常は `cut-audio.mp4`、最終尺までの音声 padding が
180
180
  必要な場合は `cut-audio-tail-padded.mp4` を使用し、どちらも `-vn` で映像を処理しない。
181
- legacy 書き出しは従来どおり `cut.mp4` と必要時の `cut-tail-padded.mp4` を使用する。
181
+ 旧 legacy 書き出しは `cut.mp4` と必要時の `cut-tail-padded.mp4` を使用していた(現在は廃止)。
182
182
  音声入力は cut ごとに入力側シーク(`-ss` / `-t`)し、cut 頭 0.5 s の先読みガード(AAC の overlap-add 用)を設け、cut 段の費用を素材長に依存させない。
@@ -279,7 +279,7 @@ frame-engine を使う消費側は、VP9 alpha WebM または alpha pixel format
279
279
  明示 `mask` がある場合も color / mask の取り込みは行い、frame-engine のマスク入力には明示参照を優先する。
280
280
 
281
281
  変換失敗は非致命警告とし、該当 layer だけを frame-engine から外す。旧 `<video>` プレビューは
282
- 元素材の `src` を維持し、legacy 書き出しの alpha filtergraph もこの取り込み変換を使わない。
282
+ 元素材の `src` を維持する。旧 legacy 書き出しの alpha filtergraph はこの取り込み変換を使わなかった(現在は廃止)。
283
283
 
284
284
  ## 9. 既知の追随事項(本契約が作る宿題)
285
285
 
@@ -258,7 +258,7 @@ legacy 合成経路は、移行中の既存利用者と Windows を支えるた
258
258
  エンジン意味論や完成画の仕様の正本ではない。
259
259
 
260
260
  - `<video>` ベースの二つの互換プレビューは、各器で frame-engine が既定になった後 **2 リリース**保持する。
261
- - legacy 書き出しは明示的な互換選択で到達可能なまま残し、コードとテストを削除・無効化しない。
261
+ - 当時は legacy 書き出しを明示的な互換選択で残す計画だった。現在は廃止済み。
262
262
  - legacy 削除(#100b)は、#14 の Windows 実機で OSR が PASS するか、Windows でも OSR を既定にする
263
263
  オーナー裁定がある場合にだけ開始する。
264
264
  - 退役前も新しい意味論を互換経路へ追加しない。差は清算表へ記録し、v2 の合否は §4.1 と §4.2 で決める。
@@ -163,15 +163,18 @@ The `filter_report.normalized.output_tp` loudnorm reports for the PCM stage is n
163
163
  real true peak: the AAC re-encode that follows can measurably overshoot it (a real render measured
164
164
  `filter_report` at -1.00 dBTP against a decoded artifact at +0.23 dBFS — about +1.2 dB of
165
165
  codec-introduced overshoot; `planning/notes-2026-08-17-mac-fresh-install-bug-reports.md` #05). Two
166
- additive mitigations apply only when `true_peak_dbtp` is **explicit** in `audio.master` — the -1.5
166
+ additive mitigations apply to explicit `true_peak_dbtp` in `audio.master` — the -1.5
167
167
  dBTP default is unchanged and unmargined:
168
168
 
169
- - **Applied margin.** `packages/render-cut/src/plan.mjs` hands loudnorm `configured -
169
+ - **Applied margin.** Only when the output audio codec is AAC (h264 / hevc),
170
+ `packages/render-cut/src/plan.mjs` hands loudnorm `configured -
170
171
  AAC_TRUE_PEAK_OVERSHOOT_MARGIN_DBTP` (1.5 dB, `packages/render-cut/src/audio-qc.mjs`) instead of
171
172
  the raw configured value, so the *decoded* artifact — not just the PCM stage — has a better chance
172
173
  of landing under what the caller asked for. The receipt records both under an additive
173
- `audio_qc.true_peak_margin: { overshoot_margin_dbtp, applied_true_peak_dbtp }` field;
174
+ `audio_qc.true_peak_margin: { overshoot_margin_dbtp, applied_true_peak_dbtp, reason: "aac_reencode_overshoot", audio_codec: "aac" }` field;
174
175
  `audio_qc.configured.true_peak_dbtp` is unchanged and still reports the caller's original value.
176
+ PCM output (prores422 / png → `pcm_s16le`) passes the configured target directly to loudnorm
177
+ and has no `audio_qc.true_peak_margin` field (#122, 2026-09-28).
175
178
  The margin is a fixed mitigation, not a guarantee — real-render testing found synthetic
176
179
  high-transient material where even the margined target still decodes above 0 dBFS (this is what
177
180
  the next mitigation exists to catch).
@@ -183,7 +186,8 @@ dBTP default is unchanged and unmargined:
183
186
  configured target, decoded measurement, and tool version; a mismatch is a structural error.
184
187
 
185
188
  These two fields are additive to the existing `configured` / `filter_report` /
186
- `decoded_measurement` fields. No margin is recorded when `true_peak_dbtp` is omitted, and no
189
+ `decoded_measurement` fields. No margin is recorded when `true_peak_dbtp` is omitted or the output
190
+ audio codec is PCM, and no
187
191
  overshoot warning is recorded when the measured peak does not exceed the threshold.
188
192
 
189
193
  ## 4. Recipe boundary and evidence grade
@@ -55,7 +55,7 @@ A = 255
55
55
 
56
56
  BGRA bitmapでは `[0x55, G, R, 255]` となる。左端、中央、右端の3画素を復号し、期待番号との全点一致を要求する。確認後、ffmpegへ渡す前に `buffer.subarray(0, W * H * 4)` で最下行を除く。
57
57
 
58
- `--verify stamp|hash|off` を持ち、既定は `stamp` とする。`hash` は直前の映像領域と同じSHA-256ならsettle後に再取得し、静止画で上限へ達した場合は曖昧件数を記録して受理する。`off` は比較計測用である。通常書き出しではverifyを無効にしない。
58
+ `--verify stamp|hash|off` を持ち、既定は `stamp` とする。`hash` は直前の映像領域と同じSHA-256ならsettle後に再取得し、上限は8回かつ`OSR_STAMP_RETRY_BUDGET_MS`、待ちは伸ばさない。静止画で上限へ達した場合は曖昧件数を記録して受理する。`off` は比較計測用である。通常書き出しではverifyを無効にしない。
59
59
 
60
60
  ## 4. 駆動プロトコル
61
61
 
@@ -71,6 +71,8 @@ seek → ready → invalidate → paint → verify → write
71
71
 
72
72
  `run.json` はseek、paint、toBitmap、verify、writeのp50/p95、1000コマ区切りmedian、先頭と末尾のdriftRatio、paint timeout、verify retry、verify前delta histogram、backpressure、メモリ、ffprobe結果を記録する。
73
73
 
74
+ **2026-09-28 追記(#113 / #88)**: stampの再試行は時間予算3000 msと回数上限32の先に達した方で失敗する。2回目からsettleの前に16、32、64、…最大500 msを追加で待つ。並走実測では再試行1回が約33〜40 ms、2〜3本並走でも最大2回、1コマ平均が212〜312 msだった。従来の固定8回は約0.3秒で諦めていたため、3秒は並走中の平均1コマの約10倍の猶予とする。失敗文は従来の先頭、読めたstamp値と分類、活性オーバーレイとCSS機能、予算、再実行の案内を順に含む。`run.json` の `verify.budget` / `verify.retryElapsedMs` は完了・失敗の両方に、`verify.failure` はstamp失敗時だけに記録する。
75
+
74
76
  ## 5. LUT
75
77
 
76
78
  `output.look` のLUTはframe-engine canvas内のsampler3Dで適用する。ページ全体へCSS filterを掛けない。したがって字幕、自由HTML、3DはLUTの外にあり、映像canvasだけが色変換の対象となる。
@@ -85,7 +87,7 @@ seek → ready → invalidate → paint → verify → write
85
87
 
86
88
  第1段・第2段とも、実プロセスのコマンドラインに`--force-device-scale-factor=1`、`--force-color-profile=srgb`、background throttling無効化スイッチを渡す。npm Electronではスクリプトパスを`argv[1]`に保ち、その後へChromiumスイッチを置く。ソフト描画時は加えてGPU無効化とSwiftShaderスイッチを渡す。
87
89
 
88
- 第1段・第2段とも`--user-data-dir=<run 一時ディレクトリ>/electron-user-data`を渡し、本体アプリの単一インスタンスロック(userData単位)と分離する。これによりアプリ起動中でも書き出せる。子がexit 0で終了して出力を作らなかった場合は、launcherが失敗として扱う。
90
+ 第1段・第2段とも`--user-data-dir`を渡し、本体アプリの単一インスタンスロック(userData単位)と分離する。既定は`launchElectronExport`が書き出しごとに`os.tmpdir()`直下へ`mkdtemp("akari-osr-")`で作る短い一意なディレクトリで、子のclose後に`finally`で削除する。呼び出し側が`userDataDir`を明示した場合はそれを使い、作らず消さない。これによりアプリ起動中でも書き出せる。子がexit 0で終了して出力を作らなかった場合は、launcherが失敗として扱う。**2026-09-28 改訂(#114)**: 出力の隣に置くとプロジェクトのパス長とChromiumのキャッシュ階層がWindowsのMAX_PATHを超え、キャッシュ作成エラーがstderrに出ていた。
89
91
 
90
92
  **Windows のアプリ別 GPU 設定の一時上書き(2026-09-01 追記・§11.7)**: `platform === "win32"` かつソフト描画でないとき、第1段・第2段とも launcher(`launchElectronExport`・gpu / osr 共通の spawn 点)は、`auto` では GPU 出口(`options.exit === "gpu"`・gpu-export の electron-main)のときだけ、`force` では OSR 出口でも、spawn の直前に `HKCU\Software\Microsoft\DirectX\UserGpuPreferences` へ「値名 = 実行体のフルパス(`path.win32.resolve` で正規化)・REG_SZ `GpuPreference=2;`」を書き、子の `close` 後(exit code に関わらず・spawn エラーでも)に `finally` で必ず 1 回復元する(無かったなら削除・あったなら元の値へ)。方針は呼び出し側の `gpuPreference` → env `AKARI_EXPORT_GPU_PREFERENCE` → `auto` の順に解決し、`auto` は利用者が明示した値(`GpuPreference=1;` 等)を黙って上書きしない(`force` だけが上書き + 復元する)。書く前に sidecar `<AKARI_HOME ?? ~/.akari>/gpu-preference-override.json` を置き、復元後に削除する。`launchElectronExport` は毎回冒頭で sidecar があれば先に復元する。記録は戻り値の `gpuPreference`(`exit` 込み)と receipt の `provenance.gpu_preference`。OSR 出口を `auto` で外す根拠は §11.7(2026-09-02 改訂)。他 OS はバイト同一の no-op(記録に `reason: platform` だけ残す)。開発用に `AKARI_EXPORT_ALLOW_DESKTOP=0` で第1段(インストール済みアプリ)を候補から外せる(明示引数 `allowDesktop` が env より優先)。
91
93
 
@@ -195,7 +197,7 @@ fieldtest / 検収に receipt が 1 件も無く、一度も動いていない
195
197
 
196
198
  v0.1.27 からの挙動: `resolveOsrLauncher`(製品入口)はインストール済みアプリを既定で候補から外す
197
199
  (`allowInstalledDesktop: false`)。`AKARI_OSR_ELECTRON` の明示指定は従来どおり tier 1。npm Electron(tier 2)が無い
198
- パッケージ版では tier 3 = legacy へ警告付きで落ち、provenance に `engine_fallback` と理由が残る(`auto` / `osr` とも)。
200
+ 当時のパッケージ版では tier 3 = legacy へ警告付きで落ちていた。現在は legacy へフォールバックせず、OSR の Electron が見つからない場合は書き出しを拒否する。
199
201
  根本修正 = `--render` を Theia より前に捕捉する書き出し専用の Electron 入口(別票)。入口が入ったら既定を戻す。
200
202
 
201
203
  **2026-08-29 追記(根治)**: 書き出し専用の入口 `apps/shell/electron-entry.js` が合流した(§6 / §11.4)。`resolveOsrLauncher` の既定を戻し、インストール済みアプリを再び tier 1 の候補にする(v0.1.28〜)。`allowInstalledDesktop: false` は明示の opt-out として残す。
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 1. 目的と判定規則
6
6
 
7
- 本表は、プレビュー、legacy 書き出し、エンジン v2 の間に蓄積した恒久近似を一件ずつ清算する正本である。
7
+ 本表は、プレビュー、当時の legacy 書き出し、エンジン v2 の間に蓄積した恒久近似を一件ずつ清算した履歴である。legacy 書き出しは現在廃止済み。
8
8
  状態は次の三つに限る。
9
9
 
10
10
  - **解消**: `packages/frame-engine/test/golden` の点群、`test:seek`、または契約に記録された実測値がある。
@@ -176,3 +176,34 @@ frame-engine は映像の ready を先に成立させ、その直後から予定
176
176
  他の圧縮音声は符号化サイズの 16 倍で行う。再生開始は先読み済みの項目を再利用し、未着の項目だけを
177
177
  待つため、先読み処理は初期フレームの描画を止めない。decode に失敗した項目は 5 秒空けて再試行し、
178
178
  それまで `debug().prefetch.failed` に載る。
179
+
180
+ ## 9. BGM の重ね(クロスフェード)
181
+
182
+ 複数の `role:"bgm"` item は別の audio トラックに置けば時間で重ねてよい。
183
+ 重なり区間は両方が鳴る。render-cut は各 BGM にフェードと ducking 包絡を掛けてから
184
+ `amix normalize=0` で足す。クロスフェードは前の item の `fade_out` と後の item の
185
+ `fade_in` に秒数を指定する。
186
+
187
+ edit-lint は「前に `fade_out` が無く、かつ後に `fade_in` が無い」重なりだけを
188
+ `v2.audio-bgm-multiple` の warning にする(2026-09-28 に error から緩和。#120)。
189
+ 同じトラック内の重なりは従来どおり `v2.track-no-overlap` の error となる。
190
+
191
+ 実書き出しの測定は `packages/render-cut/test/bgm-crossfade-overlap.test.mjs` に固定した。
192
+ 2026-09-28 に Windows・ffmpeg 9.0 で測定。帯域レベルは各時刻の 0.2 秒窓に
193
+ `bandpass`(Q=15)と `volumedetect` を適用し、ピークは `astats` で測った(単位は dB / dBFS)。
194
+
195
+ | 測定項目 | フェードあり | フェードなし |
196
+ |---|---:|---:|
197
+ | 440 Hz 帯域、1.0 / 1.55 / 1.75 / 1.95 / 2.5 秒 | -9.4 / -10.7 / -15.5 / -26.5 / -36.1 | -9.4 / -9.4 / -9.4 / -11.5 / -36.1 |
198
+ | 880 Hz 帯域、同時刻 | -36.1 / -23.7 / -14.6 / -10.0 / -9.3 | -36.1 / -10.3 / -9.2 / -9.3 / -9.3 |
199
+ | 素材 `a.wav` のピーク | -3.06 | -3.06 |
200
+ | 出力全体のピーク | -5.82 | -0.96 |
201
+ | 重なり区間(1.5〜2.0 秒)のピーク | -6.05 | -0.96 |
202
+ | 単独区間・前(0.5〜1.3 秒)のピーク | -5.89 | -5.89 |
203
+ | 単独区間・後のピーク | -5.82 | — |
204
+
205
+ フェードありは重なり区間で両周波数が鳴りつつ、ピークは単独区間を超えない
206
+ (-6.05 対 -5.89 dBFS)。フェードなしは重なり区間のピークが約 4.9 dB 上がる
207
+ (-0.96 dBFS。素材がもっと大きければ 0 dBFS に届き得る)。このためフェードなしの重なりを warning にする。
208
+
209
+ プレビュー(Web Audio)側の実測は未実施。
@@ -118,6 +118,8 @@ akari capture [-p <project>] (-t <time…> | --auto) [--separate] [--full] [--pe
118
118
 
119
119
  ## 9. v1 改訂(2026-08-30)— v2 経路への載せ替え
120
120
 
121
+ > **現況(2026-09-27):** 以下の legacy 分岐と非 macOS フォールバックは当時の設計記録。現在の書き出しは全 OS で `auto` が GPU 適格性により GPU / OSR を選び、`--engine legacy` は拒否する。
122
+
121
123
  ### 9.1 なぜ改訂するか(確定事実)
122
124
 
123
125
  - v0 の実装は `packages/akari-tools/src/capture/run.mjs` が `renderProject(…, { engine: "legacy" })` を**固定**で呼び、
@@ -123,6 +123,25 @@ npm i -g akari-video && akari
123
123
  npx akari-video
124
124
  ```
125
125
 
126
+ ### akari doctor の cli.node
127
+
128
+ `akari doctor --json` の `cli.node` は、この CLI を動かしている実行体と、スクリプトを Node として呼ぶ方法を返す。
129
+
130
+ | フィールド | 意味 |
131
+ |---|---|
132
+ | `exec_path` | 診断を実行した実行体のパス。Electron アプリの EXE になることもある |
133
+ | `electron_run_as_node` | 診断を実行したプロセスの環境変数が `ELECTRON_RUN_AS_NODE=1` だったか |
134
+ | `version` | 実行体の Node のバージョン(Electron では内蔵の Node) |
135
+ | `runtime` | 実行体が `electron` か `node` か |
136
+ | `electron_version` | Electron のバージョン。Node 実行体では `null` |
137
+ | `run_as_node_supported` | Electron 実行体を Node として呼べるか |
138
+ | `required_env` | Node として呼ぶために必要な環境変数。Electron では `{ "ELECTRON_RUN_AS_NODE": "1" }`、Node では `{}` |
139
+ | `invocation` | `shell` は既定シェル、`example` はそのシェル用の呼び方。`cmd` と `sh` は両方常に出る。`<script>` を実際のスクリプトパスに置き換える |
140
+
141
+ `electron_run_as_node` は診断時の環境だけを表す。Node として使えるかは `runtime`、`run_as_node_supported`、`required_env` で判断する。`runtime` が `electron` なら `required_env` を付けて呼ぶ。Windows の `cmd` では `set "ELECTRON_RUN_AS_NODE=1"` と引用符で囲む。`set ELECTRON_RUN_AS_NODE=1 &&` と書くと末尾の空白が値に入る。
142
+
143
+ Electron を Node として呼んだプロセスから GUI の Electron を子プロセスとして起動するときは、子へ渡す環境から `ELECTRON_RUN_AS_NODE` を外す。
144
+
126
145
  ## 既知の制約
127
146
 
128
147
  - npm tarball は追跡済み skills・雛形・schemas と capability source set を `vendor/` に同梱する。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.96",
3
+ "version": "0.1.98",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。 [akari-video npm vendor: bin/akari.mjs is reference-only. These CLI entrypoints are not included in the akari-video npm package. Use `akari doctor --json` and run the path reported in `render_cut.path`. Full installations provide it in a monorepo checkout, ~/.akari/app, /Applications/AKARI Video.app/Contents/Resources/packages, or %LOCALAPPDATA%\\Programs\\@akari-videoshell\\resources\\packages.]",
5
5
  "type": "module",
6
6
  "files": [
@@ -89,7 +89,7 @@
89
89
  const say = (ok, text) => { msg.className = `msg ${ok ? 'ok' : 'err'}`; msg.textContent = text; };
90
90
 
91
91
  if (item.state === 'locked') {
92
- actions.append(el(`<button class="btn" disabled>未購入(¥${(item.price ?? 0).toLocaleString()}) — ストアで購入してください</button>`));
92
+ actions.append(el(`<button class="btn" disabled>未購入(¥${(item.price ?? 0).toLocaleString()}) — AKARI Video Lab で購入してください</button>`));
93
93
  } else if (item.state === 'cached') {
94
94
  actions.append(el('<p class="note">✓ ライブラリに取得済みです。</p>'));
95
95
  } else {
@@ -118,7 +118,7 @@ export async function resolve(
118
118
  const { ids: entitlements } = await fetchEntitlements({ env, fetchImpl });
119
119
  if (!entitlements.has(item.id) && !entitlements.has(item.product_id)) {
120
120
  throw new AssetResolverError(
121
- `未購入の素材です(¥${price.toLocaleString()})。ストアで購入してから再度お試しください: ${item.id}`,
121
+ `未購入の素材です(¥${price.toLocaleString()})。AKARI Video Lab で購入してから再度お試しください: ${item.id}`,
122
122
  'locked',
123
123
  );
124
124
  }
@@ -200,7 +200,7 @@ async function resolvePaidZip(item, { env, fetchImpl, project, reference, home,
200
200
  if (!credentials) {
201
201
  // entitled 判定(fetchEntitlements)が通った直後にここへ来るので通常は発生しないが、
202
202
  // その間にトークンが失効した場合も黙って劣化させず拒否する(fail-closed)。
203
- throw new AssetResolverError(`ストア接続情報がありません(トークン失効の可能性): ${item.id}`, 'locked');
203
+ throw new AssetResolverError(`AKARI アカウントの接続情報がありません(トークン失効の可能性): ${item.id}`, 'locked');
204
204
  }
205
205
 
206
206
  await mkdir(home, { recursive: true });
@@ -78,6 +78,13 @@ non-decreasing `at` order. It does not change the render meaning or fail lint.
78
78
 
79
79
  `timeline.duration-derived` is an info finding when a project without visual media derives its duration from overlays, captions, narration, or SFX; BGM never extends that fallback duration.
80
80
 
81
+ For v2, `at` and `duration` are frames; `source.in`, `source.out`, and fade values are seconds.
82
+ `v2.item-duration-short` warns when a non-audio item is shorter than half a second (excluding
83
+ caption items). `v2.item-duration-source-mismatch` warns when a media item's source interval,
84
+ after speed adjustment, differs from its timeline duration by at least fivefold. The latter
85
+ also applies to audio items but excludes still images and frozen sources. Both are warnings
86
+ and leave the pass/fail verdict unchanged.
87
+
81
88
  ## Engine compatibility (`--engine`)
82
89
 
83
90
  Before export, check v2 projects against the selected renderer. Use `auto` when engine selection is
@@ -204,7 +204,8 @@ export async function lintProject(input, options = {}) {
204
204
  if (findings.some(finding => finding.severity === "error")) {
205
205
  return writeResult(findings, skipped, inputs, paths, options);
206
206
  }
207
- const rawEdit = edit;
207
+ const rawEdit = edit.version === 2 && edit.sources === undefined
208
+ ? { ...edit, sources: [] } : edit;
208
209
  const internalEdit = readEditWithProviderGuidance(rawEdit, { allowCutAudioSplit: true });
209
210
  const legacyEdit = projectLegacyEdit(internalEdit);
210
211
  if (engineCapabilities !== null && rawEdit.version === 2) {
@@ -1029,7 +1030,7 @@ function validateEditStructure(edit, findings, paths) {
1029
1030
  validateLook(edit.output.look, findings, "edit.json#output.look");
1030
1031
  }
1031
1032
  const sourceIds = new Set();
1032
- if (!Array.isArray(edit.sources)) {
1033
+ if (!Array.isArray(edit.sources) && !(edit.version === 2 && edit.sources === undefined)) {
1033
1034
  structureFinding(findings, editRelative, "sources must be an array");
1034
1035
  } else {
1035
1036
  for (const [index, source] of edit.sources.entries()) {
@@ -1154,6 +1155,8 @@ function validateEditV2(edit, findings) {
1154
1155
  }
1155
1156
  }
1156
1157
 
1158
+ const fps = edit.output?.fps;
1159
+
1157
1160
  for (const [trackIndex, track] of edit.tracks.entries()) {
1158
1161
  if (!isRecord(track) || !Array.isArray(track.items)) continue;
1159
1162
  const visit = (items, parent, parentPath) => {
@@ -1168,6 +1171,34 @@ function validateEditV2(edit, findings) {
1168
1171
  if (track.lane === "audio" && Object.hasOwn(item, "link")) {
1169
1172
  linkedAudioItems.push({ item, path: `${itemPath}.link` });
1170
1173
  }
1174
+ if (isPositiveNumber(fps) && Number.isInteger(item.duration) && item.duration > 0) {
1175
+ const duration = item.duration;
1176
+ const seconds = (duration / fps).toFixed(2);
1177
+ if (track.lane !== "audio" && item.source?.kind !== "caption"
1178
+ && duration < Math.round(0.5 * fps)) {
1179
+ addFinding(findings, {
1180
+ severity: "warning", check: "v2.item-duration-short",
1181
+ message: `item ${String(item.id)} の duration は ${duration} フレーム(${seconds} 秒)です。at / duration の単位はフレームです。${duration} 秒のつもりなら ${formatNumber(duration * fps)} フレームにしてください(×fps = ${formatNumber(fps)})。`,
1182
+ path: `${itemPath}.duration`,
1183
+ });
1184
+ }
1185
+ const source = item.source;
1186
+ if (source?.kind === "media" && isFiniteNumber(source.out)
1187
+ && !isStillImageSourcePath(sourcePaths.get(source.src)) && !isRecord(source.freeze)) {
1188
+ const sourceIn = source.in ?? 0;
1189
+ const sourceSeconds = (effectiveSourceOut(item, fps) - sourceIn)
1190
+ / (isPositiveNumber(source.speed) ? source.speed : 1);
1191
+ const timelineSeconds = duration / fps;
1192
+ const ratio = Math.max(sourceSeconds / timelineSeconds, timelineSeconds / sourceSeconds);
1193
+ if (sourceSeconds > 0 && ratio >= 5) {
1194
+ addFinding(findings, {
1195
+ severity: "warning", check: "v2.item-duration-source-mismatch",
1196
+ message: `item ${String(item.id)} の duration ${duration} フレーム(${seconds} 秒)と source の区間 in ${formatNumber(sourceIn)} 秒 〜 out ${formatNumber(source.out)} 秒(${sourceSeconds.toFixed(2)} 秒${isPositiveNumber(source.speed) && source.speed !== 1 ? `・speed ${formatNumber(source.speed)}` : ""})が ${ratio.toFixed(1)} 倍食い違っています。at / duration はフレーム、source.in / out は秒です。取り違えていないか確かめてください(duration を秒で書いたなら ×fps = ${formatNumber(fps)})。`,
1197
+ path: `${itemPath}.duration`,
1198
+ });
1199
+ }
1200
+ }
1201
+ }
1171
1202
  if (track.lane === "visual" && item.source?.kind === "media" && item.audio === false
1172
1203
  && (Object.hasOwn(item.source, "gain_db") || Object.hasOwn(item.source, "mute"))) {
1173
1204
  addFinding(findings, {
@@ -1258,6 +1289,7 @@ function validateEditV2(edit, findings) {
1258
1289
  ? track.items.flatMap((item, itemIndex) =>
1259
1290
  isRecord(item) && item.role === "bgm" ? [{
1260
1291
  id: item.id, trackIndex, itemIndex, start: item.at, end: item.at + item.duration,
1292
+ fadeIn: item.fade_in, fadeOut: item.fade_out,
1261
1293
  }] : [])
1262
1294
  : []
1263
1295
  );
@@ -1269,13 +1301,19 @@ function validateEditV2(edit, findings) {
1269
1301
  const start = Math.max(bgmItems[i].start, bgmItems[j].start);
1270
1302
  const end = Math.min(bgmItems[i].end, bgmItems[j].end);
1271
1303
  if (Number.isFinite(start) && Number.isFinite(end) && start < end) {
1272
- overlaps.push(`${String(bgmItems[i].id)} / ${String(bgmItems[j].id)} [${start}, ${end})`);
1304
+ const [before, after] = bgmItems[i].start <= bgmItems[j].start
1305
+ ? [bgmItems[i], bgmItems[j]] : [bgmItems[j], bgmItems[i]];
1306
+ if (!(before.fadeOut > 0) && !(after.fadeIn > 0)) {
1307
+ overlaps.push({ label: `${String(before.id)} / ${String(after.id)} [${start}, ${end})`, frames: end - start });
1308
+ }
1273
1309
  }
1274
1310
  }
1275
1311
  }
1312
+ const exampleSeconds = overlaps.length && isPositiveNumber(fps)
1313
+ ? formatNumber(Number((overlaps[0].frames / fps).toFixed(2))) : null;
1276
1314
  if (overlaps.length) addFinding(findings, {
1277
- severity: "error", check: "v2.audio-bgm-multiple",
1278
- message: `BGM ${bgmItems.map(describe).join('、')}。重なり: ${overlaps.length ? overlaps.join('、') : 'なし'}。BGM の時間が重ならないように配置してください。`,
1315
+ severity: "warning", check: "v2.audio-bgm-multiple",
1316
+ message: `BGM ${bgmItems.map(describe).join('、')}。重なり: ${overlaps.map(overlap => overlap.label).join('、')}。重なった区間は両方の BGM が鳴ります。クロスフェードにするなら、前の item に fade_out(秒)、後の item に fade_in(秒)を付けてください${exampleSeconds !== null ? `(例: 重なり ${formatNumber(overlaps[0].frames)} フレーム = ${exampleSeconds} 秒なら "fade_out": ${exampleSeconds} / "fade_in": ${exampleSeconds})` : ""}。意図しない重なりなら時間が重ならないように配置してください。(同じトラックの中では重ねられません。別の audio トラックに置いてください)`,
1279
1317
  path: "edit.json#tracks",
1280
1318
  });
1281
1319
  }
@@ -2198,19 +2236,27 @@ function validateSfxTracks(sfx, findings) {
2198
2236
  const hasValidTrack = Object.hasOwn(item, "track") && Number.isInteger(item.track) && item.track >= 0;
2199
2237
  const track = hasValidTrack ? item.track : 0;
2200
2238
  if (!pointsByTrack.has(track)) pointsByTrack.set(track, []);
2201
- pointsByTrack.get(track).push({ index, t: item.t });
2239
+ pointsByTrack.get(track).push({ index, t: item.t, path: isNonEmptyString(item.path) ? item.path : undefined, track });
2202
2240
  });
2203
2241
  for (const list of pointsByTrack.values()) {
2204
2242
  list.sort((a, b) => a.t - b.t);
2243
+ let groupStart = 0;
2205
2244
  for (let i = 1; i < list.length; i += 1) {
2206
2245
  if (Math.abs(list[i].t - list[i - 1].t) <= EPSILON) {
2246
+ const current = list[i];
2247
+ const samePath = current.path !== undefined
2248
+ && list.slice(groupStart, i).some(previous => previous.path !== undefined && previous.path === current.path);
2207
2249
  addFinding(findings, {
2208
- severity: "warning",
2250
+ severity: samePath ? "warning" : "info",
2209
2251
  check: "audio.sfx.track-overlap",
2210
- message: "sfx item shares the same track and t as another sfx item",
2211
- path: `edit.json#audio.sfx[${list[i].index}]`,
2212
- range: { start: list[i].t, end: list[i].t },
2252
+ message: samePath
2253
+ ? `効果音 audio.sfx[${current.index}] が同じ track ${current.track}・同じ時刻 ${formatNumber(current.t)} 秒に同じ素材 ${current.path} で置かれています(二重置きの可能性)。意図した重ねなら track を分けてください(書き出しのミックスは track を見ずに全部鳴らしますが、NLE 書き出しは track を NLE のトラック単位にするため同じトラックに重なります)。`
2254
+ : `効果音 audio.sfx[${current.index}] が同じ track ${current.track}・同じ時刻 ${formatNumber(current.t)} 秒に別素材と重なっています。書き出しのミックスは track を見ずに両方鳴らします。重ねるつもりなら track を分けてください(NLE 書き出しは track を NLE のトラック単位にするため、同じトラックに重なります)。`,
2255
+ path: `edit.json#audio.sfx[${current.index}]`,
2256
+ range: { start: current.t, end: current.t },
2213
2257
  });
2258
+ } else {
2259
+ groupStart = i;
2214
2260
  }
2215
2261
  }
2216
2262
  }
@@ -225,7 +225,7 @@ function readV2Internal(raw) {
225
225
  // 同時表示も表現できない。先に全 visual media アイテムを横断して区間交差を求め、
226
226
  // 参加したアイテムを各トラックの build へ同じ集合として渡す。これにより preview / render
227
227
  // の両方が、トラックごとに独立した要素を合成できる layers 経路を選ぶ。
228
- const overlappingItemIds = computeOverlappingItemIds(edit.tracks.flatMap(track => 'items' in track && track.lane === 'visual' ? [track.items] : []), pathOf);
228
+ const overlappingItemIds = computeOverlappingItemIds(edit.tracks.flatMap(track => 'items' in track && track.lane === 'visual' ? [track.items] : []), pathOf, chromaKeyOf);
229
229
  const contentDurationFrames = edit.tracks.reduce((maximum, track) => 'items' in track && track.lane === 'visual' ? track.items.reduce((trackMaximum, item) => Math.max(trackMaximum, item.at + item.duration), maximum) : maximum, 0);
230
230
  const tracks = edit.tracks.map(track => {
231
231
  // P0 2026-08-21 render-path-unification (実測で発覚): 'cuts' 経路(concat チェーン)は
@@ -457,7 +457,7 @@ function needsLayersEngine(item, chromaKeyOf, hasOverlappingSibling = false) {
457
457
  return true;
458
458
  return false;
459
459
  }
460
- function analyzeOverlappingItems(itemGroups, pathOf) {
460
+ function analyzeOverlappingItems(itemGroups, pathOf, chromaKeyOf) {
461
461
  const overlapping = new Set();
462
462
  const crossTrackEvacuations = [];
463
463
  const entries = itemGroups.flatMap((group, trackIndex) => group.items.map(item => ({ item, trackIndex, trackId: group.trackId })));
@@ -497,13 +497,47 @@ function analyzeOverlappingItems(itemGroups, pathOf) {
497
497
  }
498
498
  }
499
499
  }
500
+ // 各パスの開始時点の集合だけを原因判定に使う。同じ upper に複数の lower が
501
+ // 重なれば全て記録し、新しく退避した upper は次のパスから原因にする。
502
+ for (;;) {
503
+ const newlyEvacuated = new Set();
504
+ for (let i = 0; i < entries.length; i++) {
505
+ const { item: lower, trackIndex: lowerTrackIndex, trackId: lowerTrackId } = entries[i];
506
+ const lowerIsLayer = lower.source.kind === 'media'
507
+ ? needsLayersEngine(lower, chromaKeyOf, overlapping.has(lower.id))
508
+ : lower.source.kind === 'telop' || lower.source.kind === 'filter';
509
+ if (!lowerIsLayer)
510
+ continue;
511
+ for (let j = 0; j < entries.length; j++) {
512
+ const { item: upper, trackIndex: upperTrackIndex, trackId: upperTrackId } = entries[j];
513
+ if (upperTrackIndex <= lowerTrackIndex || upper.source.kind !== 'media'
514
+ || overlapping.has(upper.id) || needsLayersEngine(upper, chromaKeyOf, false))
515
+ continue;
516
+ if (!(lower.at < upper.at + upper.duration && upper.at < lower.at + lower.duration))
517
+ continue;
518
+ newlyEvacuated.add(upper.id);
519
+ crossTrackEvacuations.push({
520
+ itemId: upper.id,
521
+ trackId: upperTrackId,
522
+ causeItemId: lower.id,
523
+ causeTrackId: lowerTrackId,
524
+ overlapStartFrames: Math.max(lower.at, upper.at),
525
+ overlapEndFrames: Math.min(lower.at + lower.duration, upper.at + upper.duration)
526
+ });
527
+ }
528
+ }
529
+ if (newlyEvacuated.size === 0)
530
+ break;
531
+ for (const id of newlyEvacuated)
532
+ overlapping.add(id);
533
+ }
500
534
  return { itemIds: overlapping, crossTrackEvacuations };
501
535
  }
502
- function computeOverlappingItemIds(itemGroups, pathOf) {
536
+ function computeOverlappingItemIds(itemGroups, pathOf, chromaKeyOf) {
503
537
  return analyzeOverlappingItems(itemGroups.map((items, index) => ({
504
538
  items,
505
539
  trackId: String(index)
506
- })), pathOf).itemIds;
540
+ })), pathOf, chromaKeyOf).itemIds;
507
541
  }
508
542
  /**
509
543
  * 別 visual track との重なりが原因で upper item が layers へ退避される組を返す。
@@ -513,11 +547,13 @@ function findCrossTrackLayerEvacuations(edit) {
513
547
  const raw = toRecord(edit);
514
548
  const parsed = (0, edit_v2_1.readEditV2)(raw === undefined ? edit : extractV2MediaCaptionSwitches(raw).input);
515
549
  const pathOf = (id) => parsed.sources.find(entry => entry.id === id)?.path;
550
+ const chromaKeyOf = (id) => parsed.sources.find(entry => entry.id === id)?.chroma_key ?? undefined;
516
551
  return analyzeOverlappingItems(parsed.tracks.flatMap(track => track.lane === 'visual' && 'items' in track
517
552
  ? [{ items: track.items, trackId: track.id }]
518
- : []), pathOf).crossTrackEvacuations;
553
+ : []), pathOf, chromaKeyOf).crossTrackEvacuations;
519
554
  }
520
- // cuts の winner-take-all が下段を隠してよいのは、上段が全画面を不透明に覆う場合だけ。
555
+ // cuts の winner-take-all が下段を隠してよいのは、上段が全画面を不透明に覆い、下段が全部 cuts の場合だけ。
556
+ // 下段に layers があれば上段 cuts はその下に描かれるため、不動点で上段も退避する(#90)。
521
557
  // 全画面不透明なソースで transform の単位元を明示しただけなら従来経路を維持する。crop / 半透明 / keyframes は、
522
558
  // 現在または途中フレームで下段が見える可能性があるため宣言の存在だけで layers へ退避する。
523
559
  // 加えて、アルファを運べるコンテナ(webm / mov — 本製品のマット生成パイプラインの出力形式)は