akari-video 0.1.37 → 0.1.39

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.
package/README.md CHANGED
@@ -45,9 +45,11 @@ DL・sha256 検証・適用まで実行。それ以外(npm グローバル / g
45
45
  `akari narration generate ...`(VOICEVOX / fal-qwen3 ナレーション生成)/
46
46
  `akari internal beat-sync-<beatmap|probe-frame|render-when-idle> ...`(beat-sync-edit 内部実行物)/
47
47
  `akari sounds [--variant wav] [--force]`(公式音源の一括ダウンロード。プロンプトなし・headless 可)/
48
- `akari store <connect|status|download|disconnect>`(AKARI Store 連携。マイページで発行した
48
+ `akari store <connect|status|install|download|disconnect>`(AKARI Store 連携。マイページで発行した
49
49
  接続トークンを `~/.akari/store-credentials.json`(0600)に保存し、購入済み一覧の確認と
50
- 配布物の取得ができる。`src/store-command.mjs`)/
50
+ 配布物の取得ができる。`install <productId> [--from <zip>]` は購入パックを展開し、`PACK.json` の
51
+ 収載素材を `~/.akari/assets/installed.json` へ登録する。`--from` は開発・オフライン導入時に手元の
52
+ zip を使う。`src/store-command.mjs`)/
51
53
  `akari assets <list|fetch|sync|...>`(素材カタログの一覧・取得・同期。
52
54
  `packages/asset-resolver` の CLI への薄い委譲で、カタログ合成・entitlements 判定・
53
55
  sha256 検証・fail-closed は resolver 側の責務のまま。`src/assets-command.mjs`)/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
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": {
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import {
3
3
  copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync,
4
- readdirSync, rmSync, writeFileSync
4
+ readFileSync, readdirSync, renameSync, rmSync, writeFileSync
5
5
  } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
7
  import path from 'node:path';
@@ -53,6 +53,123 @@ function findFile(dir, name) {
53
53
  return null;
54
54
  }
55
55
 
56
+ const INSTALLED_ASSETS_SCHEMA = 'akari-installed-assets/v0';
57
+
58
+ function readJsonFile(filePath) {
59
+ return JSON.parse(readFileSync(filePath, 'utf8'));
60
+ }
61
+
62
+ function titleFromMeta(assetRoot) {
63
+ try {
64
+ const meta = readJsonFile(path.join(assetRoot, 'meta.json'));
65
+ return typeof meta.title === 'string' && meta.title ? meta.title : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function isSafePathSegment(value) {
72
+ return typeof value === 'string' && value.length > 0
73
+ && value !== '.' && value !== '..'
74
+ && !value.includes('/') && !value.includes('\\');
75
+ }
76
+
77
+ function pathWithin(root, ...parts) {
78
+ const absoluteRoot = path.resolve(root);
79
+ const candidate = path.resolve(absoluteRoot, ...parts);
80
+ if (candidate !== absoluteRoot && !candidate.startsWith(`${absoluteRoot}${path.sep}`)) {
81
+ throw new Error(`PACK.json の path がパック外を指しています: ${parts.join('/')}`);
82
+ }
83
+ return candidate;
84
+ }
85
+
86
+ function flattenPackContents(pack, packRoot) {
87
+ if (!Array.isArray(pack?.contents)) {
88
+ throw new Error('PACK.json に contents[] がありません');
89
+ }
90
+
91
+ const items = [];
92
+ for (const entry of pack.contents) {
93
+ const candidates = Array.isArray(entry?.assets)
94
+ ? entry.assets.map((asset) => ({ asset, parentTitle: entry.title }))
95
+ : [{ asset: entry, parentTitle: null }];
96
+ if (candidates.length === 0) {
97
+ throw new Error('PACK.json の contents[] に空の assets[] があります');
98
+ }
99
+
100
+ for (const { asset, parentTitle } of candidates) {
101
+ if (!asset || !isSafePathSegment(asset.id)
102
+ || typeof asset.path !== 'string' || !asset.path) {
103
+ throw new Error('PACK.json の contents[] に不正な id / path があります');
104
+ }
105
+ const assetRoot = pathWithin(packRoot, asset.path);
106
+ if (!Array.isArray(asset.files) || asset.files.length === 0) {
107
+ throw new Error(`PACK.json の item に files[] がありません: ${asset.id}`);
108
+ }
109
+ const files = asset.files.map((file) => {
110
+ if (!file || typeof file.path !== 'string' || !file.path
111
+ || !Number.isInteger(file.bytes) || file.bytes < 0
112
+ || typeof file.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(file.sha256)) {
113
+ throw new Error(`PACK.json の files[] が不正です: ${asset.id}`);
114
+ }
115
+ pathWithin(assetRoot, file.path);
116
+ return {
117
+ path: file.path,
118
+ bytes: file.bytes,
119
+ sha256: file.sha256
120
+ };
121
+ });
122
+ const version = asset.version ?? pack.version;
123
+ if (version === undefined || version === null) {
124
+ throw new Error(`PACK.json の item に version がありません: ${asset.id}`);
125
+ }
126
+ items.push({
127
+ id: asset.id,
128
+ title: (typeof asset.title === 'string' && asset.title)
129
+ || titleFromMeta(assetRoot)
130
+ || (typeof parentTitle === 'string' && parentTitle)
131
+ || asset.id,
132
+ path: asset.path,
133
+ version,
134
+ files
135
+ });
136
+ }
137
+ }
138
+ return items;
139
+ }
140
+
141
+ function registerInstalledPack(env, productId, packPath) {
142
+ const home = resolveAkariHome(env);
143
+ const indexPath = path.join(home, 'assets', 'installed.json');
144
+ const packRoot = path.dirname(packPath);
145
+ const pack = readJsonFile(packPath);
146
+ if ((typeof pack?.version !== 'string' && typeof pack?.version !== 'number')) {
147
+ throw new Error('PACK.json に version がありません');
148
+ }
149
+ let index = { schema: INSTALLED_ASSETS_SCHEMA, packs: {} };
150
+
151
+ if (existsSync(indexPath)) {
152
+ index = readJsonFile(indexPath);
153
+ if (index?.schema !== INSTALLED_ASSETS_SCHEMA
154
+ || !index.packs || typeof index.packs !== 'object' || Array.isArray(index.packs)) {
155
+ throw new Error(`導入済み素材索引の形式が想定と違います: ${indexPath}`);
156
+ }
157
+ }
158
+
159
+ const items = flattenPackContents(pack, packRoot);
160
+ index.packs[productId] = {
161
+ version: pack.version,
162
+ installedAt: new Date().toISOString(),
163
+ root: packRoot,
164
+ items
165
+ };
166
+ mkdirSync(path.dirname(indexPath), { recursive: true });
167
+ const tempPath = `${indexPath}.tmp-${process.pid}`;
168
+ writeFileSync(tempPath, `${JSON.stringify(index, null, 2)}\n`, { mode: 0o600 });
169
+ renameSync(tempPath, indexPath);
170
+ return items;
171
+ }
172
+
56
173
  const KNOWN_BUNDLE_COMPONENTS = new Map([
57
174
  ['multi-device-combo', ['phone-pro-titanium', 'laptop-slim-aluminum', 'app-icon-squircle']]
58
175
  ]);
@@ -235,13 +352,22 @@ export async function runStoreCommand(args, options = {}) {
235
352
 
236
353
  if (sub === 'install') {
237
354
  const productId = args[1];
238
- if (!productId || productId.startsWith('--')) {
239
- log('使い方: akari store install <productId>');
355
+ if (!isSafePathSegment(productId) || productId.startsWith('--')) {
356
+ log('使い方: akari store install <productId> [--from <zip>]');
357
+ return { exitCode: 1 };
358
+ }
359
+ const hasFrom = args.includes('--from');
360
+ const fromZip = parseFlag(args, '--from');
361
+ if (hasFrom && !fromZip) {
362
+ log('使い方: akari store install <productId> [--from <zip>]');
240
363
  return { exitCode: 1 };
241
364
  }
242
365
  const stage = mkdtempSync(path.join(tmpdir(), 'akari-store-install-'));
243
366
  try {
244
- const dl = await runStoreCommand(['download', productId, '--dest', stage], options);
367
+ const dl = fromZip
368
+ ? { exitCode: existsSync(fromZip) ? 0 : 1, filePath: path.resolve(fromZip) }
369
+ : await runStoreCommand(['download', productId, '--dest', stage], options);
370
+ if (fromZip && dl.exitCode !== 0) log(`zip が見つかりません: ${fromZip}`);
245
371
  if (dl.exitCode !== 0) return { exitCode: 1 };
246
372
  const extractDir = path.join(stage, 'x');
247
373
  mkdirSync(extractDir, { recursive: true });
@@ -289,6 +415,12 @@ export async function runStoreCommand(args, options = {}) {
289
415
  const readme = findFile(destDir, 'README.md');
290
416
  log(`展開しました: ${destDir}`);
291
417
  if (readme) log(`導入手順: ${readme}`);
418
+ const packPath = findFile(destDir, 'PACK.json');
419
+ if (packPath) {
420
+ const items = registerInstalledPack(env, productId, packPath);
421
+ log(`akari assets list に ${items.length} 件を登録しました`);
422
+ if (items[0]) log(`次の一手: akari assets fetch ${items[0].id}`);
423
+ }
292
424
  return { exitCode: 0 };
293
425
  } finally {
294
426
  rmSync(stage, { recursive: true, force: true });
@@ -307,7 +439,7 @@ export async function runStoreCommand(args, options = {}) {
307
439
  log('使い方: akari store <connect|status|install|download|disconnect>');
308
440
  log(' connect ブラウザで承認して接続(既定。--token akst_... で手動 / --no-open でブラウザを開かない / --url <base>)');
309
441
  log(' status 接続状態と購入済み一覧');
310
- log(' install <productId> 購入済み商品のダウンロード + 導入まで一括');
442
+ log(' install <productId> [--from <zip>] 購入済み商品の導入(--from は手元 zip / PACK.json 素材を installed 索引へ登録)');
311
443
  log(' download <productId> [--dest <dir>] 購入済み配布物の取得のみ');
312
444
  log(' disconnect 接続解除');
313
445
  return { exitCode: sub ? 1 : 0 };
@@ -20,7 +20,7 @@
20
20
  | 分類 | 適格 | 意味 |
21
21
  |---|---|---|
22
22
  | `same` | はい | 静的 HTML は起動時、対応済み字幕は unit の初回活性時に 1 回だけスプライト化する |
23
- | `three` | はい | JSON の宣言型 3D scene と描画先 canvas を持つ overlay。毎コマ Three.js canvas を更新し、登場表現は `three-scene-entrance-curve` または `three-scene-entrance-sampled` で処理する |
23
+ | `three` | はい | JSON の宣言型 3D scene と描画先 canvas を持つ overlay。毎コマ Three.js canvas を更新し、登場表現は `three-scene-entrance-curve`、`three-scene-entrance-sampled`、または `three-scene-sampled-composite` で処理する |
24
24
  | `degraded` | いいえ | raster 自体は可能でも live DOM と同じ時間変化を保証できない |
25
25
  | `unsupported` | いいえ | v0 の表現範囲外であり、正しい完成画を生成できない |
26
26
 
@@ -123,11 +123,16 @@ cuts と layers が同時に空のフレームは、出力解像度の黒 1 枚
123
123
  スプライト合成は通常どおりこの黒い frame-engine canvas の上へ重ねる。
124
124
 
125
125
  3D は engine の時計から得た local seconds を `threeRuntime.render(container, t)` へ直接渡して駆動する。
126
- GPU 出口は overlay sheet の `__akariSeek` を使用しない。毎コマの DOM animation 同期、全 container の
127
- visibility 更新、video seek 待ちを 3D canvas の texture 更新へ持ち込まないためである。sheet の
126
+ GPU 出口は overlay sheet の `__akariSeek` を使用しない。毎コマの DOM animation 同期と全 container の
127
+ visibility 更新を 3D canvas の texture 更新へ持ち込まないためである。sheet の
128
128
  `__akariReady` は起動時に 1 回だけ待ち、各 scene が ready でない場合は overlay id と状態を示して
129
129
  fail-closed にする。active 区間は最終 compositor の draw へ積むかどうかで決める。
130
130
 
131
+ **2026-09-04 改訂(issue #53)**: video seek 待ちだけは例外とし、sheet が公開する `__akariSeekVideos(seconds)`
132
+ (`__akariSeek` から切り出した video 部分・OSR と同一実装)を毎コマ、3D 描画の前に呼ぶ。3D 断片の動画テクスチャは
133
+ `<video>` の提示フレームから上がるため、シーク → 提示確定 → 3D 描画 の順序が必要で(`3d.md`)、呼ばないと
134
+ GPU 経路の動画テクスチャは起動時の 0 秒の絵に固定される。シートに `<video>` が無ければ呼ばない。
135
+
131
136
  ## 4. 読み戻しゼロ
132
137
 
133
138
  製品実行経路は GPU frame surface を CPU へ読む API を使用しない。静的監査は
@@ -187,6 +192,26 @@ software MP4 SHA はエンコーダが決定論的な場合だけ必須とし、
187
192
  GPU と OSR の decode 比較は、engine-only 区間の per-frame MAD 1.0 以下、字幕 cue の代表 5 時刻の
188
193
  下半分 MAD 1.0 以下、3D 区間 MAD 1.0 以下を固定閾値とする。
189
194
 
195
+ **2026-09-04 追加(issue #53)— 2 経路で同じでなければならない 4 点**:
196
+
197
+ 1. **overlay へ渡す時刻は `frameNumber / fps`**。µs へ丸めてはならない。overlay の `start` は必ず
198
+ `atFrames / fps` なので、丸めると比較が 1 ulp で反転し、カット境界の 1 コマだけ絵が食い違う。
199
+ この `seconds` は時間窓判定・CSS アニメ位相・item keyframes のフレーム番号すべてに流れる。
200
+ 2. **時間窓の外の container も毎コマ pause して `currentTime` を書く**。飛ばすと窓の外の断片の CSS アニメが
201
+ 壁時計(書き出しは分単位)で走り切り、`animation-fill-mode: both/forwards` の最終姿勢に張り付いたまま
202
+ 窓へ入ってくる = 同じ時刻でも直前に何を撮ったかで絵が変わる。OSR の `__akariSyncAnimations` は
203
+ active 判定を持たない。
204
+ 3. **DOM ステージのルートに `data-no-timeline`**。断片の規約は
205
+ `[data-akari-active] .x, [data-no-timeline] .x { animation: … }` の 2 アームで、OSR のシートは `#stage` に
206
+ これを持つ。GPU 側に無いと no-timeline アームだけで宣言した断片が GPU でのみ動かない。
207
+ 4. **静的スプライトは overlay の `transform` を落とさない**。`.akari-sprite-root` に OSR の
208
+ `.akari-overlay-container` と同じ `translate/scale/rotate` + `transform-origin: center` を宣言する
209
+ (`role: "background"` は両経路とも恒等固定)。
210
+
211
+ あわせて、manifest 生成時に overlay の `start` / `duration` が有限数でなければ fail-closed とする。
212
+ 既定値(`?? 0` / `?? duration`)を置くと、欠けたときに「OSR は絶対に出さない・GPU は全尺出す」という
213
+ 最悪の非対称になる(OSR は `formatNumber(undefined)` が `"NaN"` を書き、`seconds >= NaN` が常に偽になる)。
214
+
190
215
  ## 7. receipt
191
216
 
192
217
  `.akari/render.json` は `provenance.engine = "gpu"` と GPU receipt を持つ。GPU receipt は少なくとも
@@ -305,6 +330,18 @@ CSS 3D は次の 3 群に分けて判定する。
305
330
  外部扱いしない(2026-08-31・issue #33。それまでは末尾に `;` の無いインライン style から後続 SVG の
306
331
  `fill="url(#id)"` まで走査が届いて誤検出していた)。
307
332
  - `drawElementImage` が利用できない実行環境、または device pixel ratio が 1 でない環境。
333
+ - 宣言型 3D が composite 経路の入口条件(`three-or-canvas-runtime` / `animation-timing` /
334
+ `css-3d-transform` / `advanced-css`)を満たさない場合は `three-sampled-condition:<条件名を , 連結>` とし、
335
+ curve 解析の失敗理由を流用しない。CSS 3D 幾何は composite 経路で断片全体を転写するため適格とする。
336
+ - root〜Three canvas の祖先チェーン上の `filter` / `clip-path` / `mask(-image)` / `backdrop-filter` /
337
+ `mix-blend-mode` に対する `three-sampled-chain-css:<プロパティ>` は、canvas だけを合成する方式 A(sampled)専用の
338
+ ガードとして走査側に保持する。方式 B(composite)はチェーン内外とも断片全体を転写するため `advanced-css` を通す。
339
+ - composite 候補に `@property` がある場合は `three-composite-property` とする。overlay sheet の WAAPI clone と
340
+ DOM 層の素の CSS animation でカスタムプロパティ補間が割れる可能性があるため、実測までは fail-closed とする。
341
+ - `transform-style: preserve-3d` を宣言する要素の要素の子に、Three canvas への祖先チェーン上の要素と
342
+ チェーン外の Z を持つ変形を宣言した要素が同居する場合は `three-composite-preserve-3d-siblings` とする。
343
+ 静的に判定できない場合も同じ理由で degraded へ倒す(2026-09-04 実測: 外接矩形 MAD 5.0082。
344
+ OSR は z 深度で、GPU は DOM 順で重ねるため)。
308
345
 
309
346
  settle は mount 時に一度だけ決める。`canvas.requestPaint` がある Chromium では rAF 2 回の後に
310
347
  `requestPaint()` と `paint` event(上限 250 ms)を待つ。API がない Chromium では computed style、
@@ -433,19 +470,37 @@ Three canvas までの各要素について計算済み opacity と transform
433
470
  累積行列が軸平行な translate / scale だけなら 3D canvas を従来の texture のまま使い、中心基準の
434
471
  sprite draw state へ変換する。回転またはせん断を含む一般 2D affine は、出力寸法の中間 canvas へ
435
472
  `setTransform(a,b,c,d,e,f)` で描いてから恒等 draw state で合成する。perspective、実 Z 成分、その他の
436
- 3D 行列は理由 `three-entrance-3d-matrix` `degraded` にする。
437
-
438
- sampled 方式 A の対象は、断片 root から Three canvas までの祖先チェーン(両端を含む)である。
439
- このチェーン上の任意の要素にある animation / transition は累積行列へ含める。Three canvas の CSS
440
- ボックスが出力全面と一致しない場合は、軸平行な行列でも中間 canvas 経路を使い、元の位置と寸法を保つ。
441
- canvas 以外の HTML(fallback や装飾)を DOM 層で別描画して合成順を保つ方式 B は本版では未実装である。
442
- 祖先チェーン外に animation / transition がある、または保守的な静的走査でチェーン内だけと証明できない
443
- 場合は `three-html-animated-descendants` `degraded` にする。filter / clip-path など他の既存 hard
444
- blocker も従来どおり fail-closed とする。
445
-
446
- manifest の各 3D sprite `entranceMode: "curve" | "sampled" | "none"` を持つ。run payload と receipt の
447
- `gpu.three.overlays[].entrance.mode` は登場表現について `curve` または `sampled` を記録し、
448
- `gpu.three.sampling` sampled フレームの `count`、`p50`、`p95` ミリ秒を記録する。
473
+ 3D 行列は方式 A では扱わず、方式 B の断片全体転写へ回す。
474
+
475
+ sampled 方式 A の入口条件は `three-or-canvas-runtime` / `animation-timing` 2 つで、対象は断片 root から
476
+ Three canvas までの祖先チェーン(両端を含む)である。このチェーン上の任意の要素にある
477
+ animation / transition は累積行列へ含める。Three canvas の CSS ボックスが出力全面と一致しない場合は、軸平行な
478
+ 行列でも中間 canvas 経路を使い、元の位置と寸法を保つ。
479
+
480
+ 方式 B(composite)は、方式 A で祖先チェーン内だけと証明できない断片、CSS 3D 幾何、または
481
+ `advanced-css` を持つ断片を理由 `three-scene-sampled-composite` で処理する。Three.js は overlay sheet で従来どおり
482
+ 描画し、その Three canvas を DOM 層コピー側の同じ canvas 要素へ毎コマ `drawImage` で中継する。その後、paused WAAPI を
483
+ 合成時刻へ seek して断片 root 全体を `drawElementImage` する。DOM 層コピー側の `[data-akari-3d-fallback]`
484
+ `hidden` `display:none !important` で隠す。断片内は canvas を含む DOM 順と z-index がそのまま効き、断片間は従来どおり
485
+ track z、宣言 index の順で合成する。これにより祖先チェーン上の 3D 行列と、チェーン内外の `advanced-css` も転写される。
486
+
487
+ CSS 3D の判定は DOM 層と同じ 3 群を使う。`backface-visibility:hidden` と深度 transform の組み合わせだけは
488
+ `css-3d-backface-hidden` で degraded を維持する。`transform-style:preserve-3d` の交差は composite として通し、
489
+ 検出した場合は stderr と receipt の `preserve3dOrderConflicts` へ警告を残す。それ以外の CSS 3D は通す。
490
+ ただし `preserve-3d` 空間で兄弟同士(3D canvas と兄弟要素など)が z 深度で並び替わる断片は、GPU が DOM 順で描くため
491
+ OSR と絵が変わる(2026-09-04 実測: 外接矩形 MAD 5.0082、OSR では z>0 の兄弟だけが canvas の前)。この型は
492
+ `three-composite-preserve-3d-siblings` で fail-closed にし、警告なしで絵が変わる断片を通さない。DOM 層の
493
+ `preserve3dOrderConflicts` 検出器を兄弟対まで広げれば再解禁できる(次ラウンド候補)。親子対は従来どおり、
494
+ 検出器の警告を receipt に残して通す(実測パリティ 0.6374)。
495
+ composite 候補の `@property` は、overlay sheet と DOM 層で補間結果が割れる可能性があるため
496
+ `three-composite-property` で fail-closed とする。方式 A の `three-sampled-chain-css:<プロパティ>` ガードは残すが、
497
+ 方式 B ではチェーン内外とも断片全体を描くため `advanced-css` を通す。その他の入口外条件は
498
+ `three-sampled-condition:<条件名>` とし、curve 解析の失敗理由を流用しない。
499
+
500
+ manifest の各 3D sprite は `entranceMode: "curve" | "sampled" | "composite" | "none"` を持つ。run payload と receipt の
501
+ `gpu.three.overlays[].entrance.mode` は `curve` / `sampled` / `composite` を記録し、`gpu.three.sampling` は sampled
502
+ フレームの `count`、`p50`、`p95` ミリ秒を記録する。composite がある場合は `gpu.three.composite` に overlay 数、
503
+ DOM 要素数、canvas 中継費用と DOM 層費用の p50/p95 を記録する。
449
504
 
450
505
  ## 11. v2 の cut 音声中間物(2026-08-29 追記)
451
506
 
@@ -117,6 +117,17 @@ ffprobe timeoutは `max(120000, frames × 100)` msとする。尺、フレーム
117
117
  - `AKARI_OSR_MEMORY_WARN_MIB` / `AKARI_OSR_MEMORY_HARD_STOP_MIB`で正の整数MiBへ上書きでき(絶対値・スケールも下限も上限も受けない)、
118
118
  適用値はwarning < hard stopを必須とする。hard stop だけを上書きし既定 warning がそれ以上になるときは warning を hard stop の 75% に追従させる。
119
119
  同じ変数を GPU 直結出口(gpu-export)も読む。
120
+ - 書き出しは厳密に前方順で過去フレームを読み直さないため、**評価 plan から外れたカットのデコーダセッションは解放する**
121
+ (`StreamReaper`。frame-engine が `plan.base` / `plan.layers` の `streamId` を集め、最後に使ったフレームから 1 秒ぶんの
122
+ 猶予を過ぎたものを `LookaheadFrameSource.releaseStream` で落とす)。解放しないとカット本数ぶんのセッションが最後まで
123
+ 積み上がり、RSS が単調に伸びて長尺ほど後ろで hard stop に当たる(2026-09-04 追加・issue #52。
124
+ 244 秒 / 7,320 コマの実機報告で 98% 地点・RSS 4.01 GB)。トランジション中の送出カットは plan に載るので残る。
125
+ - receipt / run.json の `memory.decoderSessions` に生存セッション数(`live`)と累計解放数(`released`)を記録する。
126
+ RSS はセッション数に比例するため、ランプの原因を後から突き合わせられるようにする(同・issue #52。
127
+ #28 の時点で比例は分かっていたが記録が無く、再発時にまた手探りになった)。
128
+ - hard stop に当たった GPU 直結出口の失敗は reasonCode `memory-hard-stop` とし、`--engine auto` のときは OSR で
129
+ 走り直して完走させる(`FALLBACK_REASONS`。同・issue #52。それまでは成果物ゼロで終わり、前版で出せていたものが
130
+ 出せない退行になっていた)。`--engine gpu` 明示は従来どおり fail-closed。
120
131
  - 並列予算1 worker = 1 GiBはGPU前提の値である。v0のworker数は1。
121
132
  - 10秒ごとにRSSを記録し、ウィンドウ破棄後も採る。
122
133
  - 固定Nコマごとのページ再生成は行わない。再生成を許すのはページ境界、renderer crash、watchdog回復時だけである。
@@ -45,9 +45,11 @@ DL・sha256 検証・適用まで実行。それ以外(npm グローバル / g
45
45
  `akari narration generate ...`(VOICEVOX / fal-qwen3 ナレーション生成)/
46
46
  `akari internal beat-sync-<beatmap|probe-frame|render-when-idle> ...`(beat-sync-edit 内部実行物)/
47
47
  `akari sounds [--variant wav] [--force]`(公式音源の一括ダウンロード。プロンプトなし・headless 可)/
48
- `akari store <connect|status|download|disconnect>`(AKARI Store 連携。マイページで発行した
48
+ `akari store <connect|status|install|download|disconnect>`(AKARI Store 連携。マイページで発行した
49
49
  接続トークンを `~/.akari/store-credentials.json`(0600)に保存し、購入済み一覧の確認と
50
- 配布物の取得ができる。`src/store-command.mjs`)/
50
+ 配布物の取得ができる。`install <productId> [--from <zip>]` は購入パックを展開し、`PACK.json` の
51
+ 収載素材を `~/.akari/assets/installed.json` へ登録する。`--from` は開発・オフライン導入時に手元の
52
+ zip を使う。`src/store-command.mjs`)/
51
53
  `akari assets <list|fetch|sync|...>`(素材カタログの一覧・取得・同期。
52
54
  `packages/asset-resolver` の CLI への薄い委譲で、カタログ合成・entitlements 判定・
53
55
  sha256 検証・fail-closed は resolver 側の責務のまま。`src/assets-command.mjs`)/
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
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": [
@@ -21,7 +21,14 @@ akari-assets sync # カタログを取得
21
21
  akari-assets browse [--port <n>] # ローカル HTTP サーバでカタログを閲覧・投入
22
22
  ```
23
23
 
24
- `list` の状態バッジ: `☁` 未取得 / `✓` 取得済み(ローカルにキャッシュ済み) / `¥<price>` 未購入。
24
+ `list` の状態バッジ: `☁` 未取得 / `✓` 取得済み(ローカルにキャッシュ済み) / `¥<price>` 未購入 /
25
+ `[installed]` `akari store install` で導入済み。
26
+
27
+ `akari store install <productId> [--from <zip>]` が `PACK.json` を持つ購入パックを展開すると、
28
+ 収載素材は `~/.akari/assets/installed.json` に登録される。resolver はこの索引をリモートカタログへ
29
+ マージし、同じ id があれば導入済みのローカル実体を優先する。`fetch` はネットワークや entitlement
30
+ 照会を使わずパックからコピーし、`PACK.json` 記載の sha256 と照合してから通常の素材ライブラリへ
31
+ 原子的に登録する。
25
32
 
26
33
  `fetch` はキャッシュヒットなら即座にそのパスを返す。未取得なら、カタログの `files[]` を全部
27
34
  一時ディレクトリへ実体化 → sha256 検証 → (`meta.json` を含む素材は)`validate-asset.mjs` で
@@ -108,8 +115,9 @@ checksums 不一致は、いずれも `AssetResolverError`(`code: 'download_fa
108
115
  `AKARI_ASSETS_CATALOG` がリモート URL のとき、`loadCatalog` は取得成功のたびに
109
116
  `~/.akari/catalog-cache.json` へ自動キャッシュする。オフライン時(fetch 失敗)はこのキャッシュへ
110
117
  フォールバックする。キャッシュも無い場合は「取得できていない」ことを明示するエラーで止まる
111
- (黙って空のカタログを返したりしない)。`akari-assets sync` はオンライン環境で明示的にキャッシュを
112
- 温めておくためのコマンド。
118
+ (黙って空のカタログを返したりしない)。ただし `installed.json` に導入済み素材がある場合は、
119
+ キャッシュが無くてもその素材だけを `list` / `fetch` できる。`akari-assets sync` はオンライン環境で
120
+ 明示的にキャッシュを温めておくためのコマンド。
113
121
 
114
122
  ## テスト
115
123
 
@@ -21,6 +21,7 @@ function flagValue(args, name) {
21
21
  const STATE_BADGE = { cached: '✓', locked: '¥', available: '☁' };
22
22
 
23
23
  function badgeOf(item) {
24
+ if (item.source === 'installed') return '[installed]';
24
25
  if (item.state === 'locked') return `¥${(item.price ?? 0).toLocaleString()}`;
25
26
  return STATE_BADGE[item.state] ?? '?';
26
27
  }
@@ -106,7 +107,7 @@ async function cmdBundle(args, env) {
106
107
  }
107
108
 
108
109
  async function cmdSync(_args, env) {
109
- const catalog = await loadCatalog({ env });
110
+ const catalog = await loadCatalog({ env, includeInstalled: false });
110
111
  await cacheCatalog(env, catalog);
111
112
  console.log(`カタログを同期しました: ${catalog.items.length} 件(version ${catalog.version ?? '不明'})`);
112
113
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { readFile, mkdir, writeFile } from 'node:fs/promises';
6
6
  import { catalogCachePath, resolveAkariHome, resolveCatalogSource } from './env.mjs';
7
+ import { loadInstalledItems, mergeInstalledItems } from './installed.mjs';
7
8
 
8
9
  function normalizeCatalog(catalog) {
9
10
  if (!catalog || !Array.isArray(catalog.items)) {
@@ -30,29 +31,37 @@ export async function cacheCatalog(env = process.env, catalog) {
30
31
  * カタログを読む。リモート取得が失敗した場合(オフライン等)はローカルキャッシュへ
31
32
  * フォールバックする(黙って劣化させるのではなく、キャッシュが無ければ明示的に失敗する)。
32
33
  */
33
- export async function loadCatalog({ env = process.env, fetchImpl = fetch } = {}) {
34
+ export async function loadCatalog({ env = process.env, fetchImpl = fetch, includeInstalled = true } = {}) {
34
35
  const source = resolveCatalogSource(env);
36
+ const installedItems = includeInstalled ? await loadInstalledItems(env) : [];
37
+ let catalog;
35
38
 
36
39
  if (source.kind === 'file') {
37
40
  const raw = await readFile(source.value, 'utf8');
38
- return normalizeCatalog(JSON.parse(raw));
41
+ catalog = normalizeCatalog(JSON.parse(raw));
42
+ } else {
43
+ try {
44
+ const res = await fetchImpl(source.value);
45
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
46
+ catalog = normalizeCatalog(await res.json());
47
+ await cacheCatalog(env, catalog);
48
+ } catch (error) {
49
+ const cached = await readCatalogCache(env);
50
+ if (cached) {
51
+ catalog = cached;
52
+ } else if (installedItems.length > 0) {
53
+ catalog = { schema: 'akari-assets-catalog/v0', version: null, base: null, items: [] };
54
+ } else {
55
+ throw new Error(
56
+ `カタログを取得できず、キャッシュもありません(${source.value}): ${
57
+ error instanceof Error ? error.message : String(error)
58
+ }。オンライン環境で先に \`akari-assets sync\` を実行してください`,
59
+ );
60
+ }
61
+ }
39
62
  }
40
63
 
41
- try {
42
- const res = await fetchImpl(source.value);
43
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
44
- const catalog = normalizeCatalog(await res.json());
45
- await cacheCatalog(env, catalog);
46
- return catalog;
47
- } catch (error) {
48
- const cached = await readCatalogCache(env);
49
- if (cached) return cached;
50
- throw new Error(
51
- `カタログを取得できず、キャッシュもありません(${source.value}): ${
52
- error instanceof Error ? error.message : String(error)
53
- }。オンライン環境で先に \`akari-assets sync\` を実行してください`,
54
- );
55
- }
64
+ return includeInstalled ? mergeInstalledItems(catalog, installedItems) : catalog;
56
65
  }
57
66
 
58
67
  export { resolveEffectiveBase } from './env.mjs';
@@ -19,6 +19,9 @@ function joinRemote(base, key) {
19
19
 
20
20
  /** files[] エントリ 1 件を { location, remote } に解決する */
21
21
  export function resolveFileLocation(base, fileEntry) {
22
+ if (fileEntry.local_path) {
23
+ return { location: fileEntry.local_path, remote: false };
24
+ }
22
25
  if (fileEntry.url) {
23
26
  if (!isRemoteLocation(fileEntry.url)) {
24
27
  throw new Error(`files[].url は絶対 URL である必要があります: ${fileEntry.url}`);
@@ -31,7 +34,7 @@ export function resolveFileLocation(base, fileEntry) {
31
34
  }
32
35
  return { location: path.join(base, fileEntry.key), remote: false };
33
36
  }
34
- throw new Error('files[] エントリに url key のどちらかが必要です');
37
+ throw new Error('files[] エントリに local_path / url / key のいずれかが必要です');
35
38
  }
36
39
 
37
40
  /**
@@ -0,0 +1,116 @@
1
+ // `akari store install` が書くローカル導入索引を、カタログ item の形へ変換する。
2
+
3
+ import { readFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { resolveAkariHome } from './env.mjs';
6
+
7
+ export const INSTALLED_ASSETS_SCHEMA = 'akari-installed-assets/v0';
8
+
9
+ export function installedAssetsPath(env = process.env) {
10
+ return path.join(resolveAkariHome(env), 'assets', 'installed.json');
11
+ }
12
+
13
+ function localPathWithin(root, ...parts) {
14
+ const absoluteRoot = path.resolve(root);
15
+ const candidate = path.resolve(absoluteRoot, ...parts);
16
+ if (candidate !== absoluteRoot && !candidate.startsWith(`${absoluteRoot}${path.sep}`)) {
17
+ throw new Error(`導入済み素材のパスがパック外を指しています: ${parts.join('/')}`);
18
+ }
19
+ return candidate;
20
+ }
21
+
22
+ function isSafePathSegment(value) {
23
+ return typeof value === 'string' && value.length > 0
24
+ && value !== '.' && value !== '..'
25
+ && !value.includes('/') && !value.includes('\\');
26
+ }
27
+
28
+ function categoryFromItemPath(itemPath) {
29
+ const segments = itemPath.replaceAll('\\', '/').split('/').filter(Boolean);
30
+ return segments[0] === 'assets' && isSafePathSegment(segments[1]) ? segments[1] : 'pack';
31
+ }
32
+
33
+ function catalogItem(packId, pack, item) {
34
+ if (!item || !isSafePathSegment(item.id)
35
+ || typeof item.title !== 'string' || !item.title
36
+ || typeof item.path !== 'string' || !item.path
37
+ || item.version === undefined || item.version === null) {
38
+ throw new Error(`導入済み素材索引に不正な item があります: ${packId}`);
39
+ }
40
+ if (!Array.isArray(item.files) || item.files.length === 0) {
41
+ throw new Error(`導入済み素材索引の item に files[] がありません: ${item.id}`);
42
+ }
43
+ const itemRoot = localPathWithin(pack.root, item.path);
44
+
45
+ return {
46
+ id: item.id,
47
+ title: item.title,
48
+ category: categoryFromItemPath(item.path),
49
+ version: item.version,
50
+ price: 0,
51
+ source: 'installed',
52
+ files: item.files.map((file) => {
53
+ if (!file || typeof file.path !== 'string' || !file.path
54
+ || !Number.isInteger(file.bytes) || file.bytes < 0
55
+ || typeof file.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(file.sha256)) {
56
+ throw new Error(`導入済み素材索引の files[] が不正です: ${item.id}`);
57
+ }
58
+ return {
59
+ name: file.path,
60
+ local_path: localPathWithin(itemRoot, file.path),
61
+ sha256: file.sha256,
62
+ bytes: file.bytes,
63
+ };
64
+ }),
65
+ };
66
+ }
67
+
68
+ /** installed.json が無ければ空配列、壊れていれば明示エラーを返す。 */
69
+ export async function loadInstalledItems(env = process.env) {
70
+ const indexPath = installedAssetsPath(env);
71
+ let index;
72
+ try {
73
+ index = JSON.parse(await readFile(indexPath, 'utf8'));
74
+ } catch (error) {
75
+ if (error?.code === 'ENOENT') return [];
76
+ throw new Error(`導入済み素材索引を読めません: ${indexPath}: ${error instanceof Error ? error.message : String(error)}`);
77
+ }
78
+
79
+ if (index?.schema !== INSTALLED_ASSETS_SCHEMA
80
+ || !index.packs || typeof index.packs !== 'object' || Array.isArray(index.packs)) {
81
+ throw new Error(`導入済み素材索引の形式が想定と違います: ${indexPath}`);
82
+ }
83
+
84
+ const byId = new Map();
85
+ for (const [packId, pack] of Object.entries(index.packs)) {
86
+ if (!isSafePathSegment(packId)
87
+ || !pack || typeof pack.root !== 'string' || !path.isAbsolute(pack.root)
88
+ || (typeof pack.version !== 'string' && typeof pack.version !== 'number')
89
+ || typeof pack.installedAt !== 'string' || !pack.installedAt
90
+ || !Array.isArray(pack.items)) {
91
+ throw new Error(`導入済み素材索引に不正な pack があります: ${packId}`);
92
+ }
93
+ localPathWithin(path.join(resolveAkariHome(env), 'assets', 'store', packId), pack.root);
94
+ for (const item of pack.items) {
95
+ const normalized = catalogItem(packId, pack, item);
96
+ byId.set(normalized.id, normalized);
97
+ }
98
+ }
99
+ return [...byId.values()];
100
+ }
101
+
102
+ /** 同じ id は installed item で置換し、ローカルだけの item は末尾へ足す。 */
103
+ export function mergeInstalledItems(catalog, installedItems) {
104
+ const items = [...catalog.items];
105
+ const positions = new Map(items.map((item, index) => [item.id, index]));
106
+ for (const item of installedItems) {
107
+ const position = positions.get(item.id);
108
+ if (position === undefined) {
109
+ positions.set(item.id, items.length);
110
+ items.push(item);
111
+ } else {
112
+ items[position] = item;
113
+ }
114
+ }
115
+ return { ...catalog, items };
116
+ }
@@ -130,7 +130,7 @@ export async function resolve(
130
130
  throw new AssetResolverError(`カタログに files[] がありません: ${item.id}`, 'invalid_catalog_item');
131
131
  }
132
132
 
133
- const base = resolveEffectiveBase(env, catalog);
133
+ const base = item.source === 'installed' ? null : resolveEffectiveBase(env, catalog);
134
134
  await mkdir(home, { recursive: true });
135
135
  const tempRoot = await mkdtemp(path.join(home, '.tmp-resolve-'));
136
136
  // validate-asset はディレクトリ名(basename)= id・親ディレクトリ名 = category を要求するので、
@@ -13,7 +13,8 @@ import { scanLocalLibrary } from './library.mjs';
13
13
  export async function composeState({ env = process.env, fetchImpl = fetch } = {}) {
14
14
  const home = resolveAkariHome(env);
15
15
  const catalog = await loadCatalog({ env, fetchImpl });
16
- const base = resolveEffectiveBase(env, catalog);
16
+ const hasCatalogItems = catalog.items.some((item) => item.source !== 'installed');
17
+ const base = hasCatalogItems ? resolveEffectiveBase(env, catalog) : null;
17
18
  const installed = scanLocalLibrary(home);
18
19
 
19
20
  // entitlements API は有料商品が無ければ叩く必要がない(無駄な認証リクエストを避ける)
@@ -0,0 +1,184 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import test from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { loadCatalog } from '../src/catalog.mjs';
9
+ import { AssetResolverError, resolve as resolveAsset } from '../src/resolve.mjs';
10
+ import { composeState } from '../src/state.mjs';
11
+ import { setupFixtureEnv } from './helpers.mjs';
12
+
13
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ const bin = path.join(packageRoot, 'bin', 'akari-assets.mjs');
15
+
16
+ function sha256(value) {
17
+ return createHash('sha256').update(value).digest('hex');
18
+ }
19
+
20
+ function writeInstalled({ home, id = 'installed-one', title = 'Installed One', category = 'pack', payload = 'local payload' }) {
21
+ const packRoot = path.join(home, 'assets', 'store', 'fixture-pack', 'fixture-pack-v1');
22
+ const itemPath = category === 'pack' ? `custom/${id}` : `assets/${category}/${id}`;
23
+ const assetRoot = path.join(packRoot, itemPath);
24
+ mkdirSync(assetRoot, { recursive: true });
25
+ writeFileSync(path.join(assetRoot, 'payload.txt'), payload);
26
+ const indexPath = path.join(home, 'assets', 'installed.json');
27
+ mkdirSync(path.dirname(indexPath), { recursive: true });
28
+ writeFileSync(indexPath, `${JSON.stringify({
29
+ schema: 'akari-installed-assets/v0',
30
+ packs: {
31
+ 'fixture-pack': {
32
+ version: 1,
33
+ installedAt: '2026-01-01T00:00:00.000Z',
34
+ root: packRoot,
35
+ items: [{
36
+ id,
37
+ title,
38
+ path: itemPath,
39
+ version: 1,
40
+ files: [{ path: 'payload.txt', bytes: Buffer.byteLength(payload), sha256: sha256(payload) }]
41
+ }]
42
+ }
43
+ }
44
+ }, null, 2)}\n`);
45
+ return { packRoot, assetRoot, indexPath };
46
+ }
47
+
48
+ function runCli(args, env) {
49
+ return spawnSync(process.execPath, [bin, ...args], {
50
+ encoding: 'utf8',
51
+ env: { ...process.env, ...env },
52
+ });
53
+ }
54
+
55
+ test('installed item をカタログへマージし、CLI は [installed] と source を表示する', async () => {
56
+ const { env, home } = setupFixtureEnv();
57
+ writeInstalled({ home, category: 'scene3d' });
58
+
59
+ const catalog = await loadCatalog({ env });
60
+ const item = catalog.items.find((entry) => entry.id === 'installed-one');
61
+ assert.equal(item.source, 'installed');
62
+ assert.equal(item.category, 'scene3d');
63
+ assert.equal(item.price, 0);
64
+ assert.ok(path.isAbsolute(item.files[0].local_path));
65
+ assert.deepEqual(Object.keys(item.files[0]).sort(), ['bytes', 'local_path', 'name', 'sha256']);
66
+
67
+ const text = runCli(['list'], env);
68
+ assert.equal(text.status, 0, text.stderr);
69
+ assert.match(text.stdout, /\[installed\]\s+installed-one/);
70
+ const json = runCli(['list', '--json'], env);
71
+ assert.equal(json.status, 0, json.stderr);
72
+ assert.equal(JSON.parse(json.stdout).find((entry) => entry.id === 'installed-one').source, 'installed');
73
+ });
74
+
75
+ test('同じ id はリモート catalog より installed item を優先する', async () => {
76
+ const { env, home } = setupFixtureEnv();
77
+ writeInstalled({ home, id: 'mini-still', title: '購入済みローカル版', category: 'scene3d' });
78
+
79
+ const { items } = await composeState({ env });
80
+ const item = items.find((entry) => entry.id === 'mini-still');
81
+ assert.equal(item.title, '購入済みローカル版');
82
+ assert.equal(item.category, 'scene3d');
83
+ assert.equal(item.source, 'installed');
84
+ assert.equal(item.state, 'available');
85
+ });
86
+
87
+ test('installed item の fetch はローカル実体をコピーし sha256 一致時だけ登録する', async () => {
88
+ const { env, home, root } = setupFixtureEnv();
89
+ const payload = 'verified local payload';
90
+ writeInstalled({ home, category: 'pack', payload });
91
+ const project = path.join(root, 'project');
92
+ mkdirSync(project, { recursive: true });
93
+
94
+ const result = await resolveAsset('installed-one', { env, project });
95
+ assert.equal(result.cached, false);
96
+ assert.equal(result.category, 'pack');
97
+ assert.equal(readFileSync(path.join(result.dir, 'payload.txt'), 'utf8'), payload);
98
+ assert.equal(readFileSync(path.join(result.projectDir, 'payload.txt'), 'utf8'), payload);
99
+ });
100
+
101
+ test('installed item の実体改竄は integrity エラーで fail-closed にする', async () => {
102
+ const { env, home } = setupFixtureEnv();
103
+ const { assetRoot } = writeInstalled({ home, category: 'still' });
104
+ writeFileSync(path.join(assetRoot, 'payload.txt'), 'tampered');
105
+
106
+ await assert.rejects(
107
+ () => resolveAsset('installed-one', { env }),
108
+ (error) => error instanceof AssetResolverError && error.code === 'integrity',
109
+ );
110
+ assert.equal(existsSync(path.join(home, 'assets', 'still', 'installed-one')), false);
111
+ });
112
+
113
+ for (const indexState of ['missing', 'empty']) {
114
+ test(`installed.json が ${indexState} のとき既存 list / fetch 出力を変えない`, () => {
115
+ const { env, home } = setupFixtureEnv();
116
+ if (indexState === 'empty') {
117
+ mkdirSync(path.join(home, 'assets'), { recursive: true });
118
+ writeFileSync(path.join(home, 'assets', 'installed.json'), '{"schema":"akari-installed-assets/v0","packs":{}}\n');
119
+ }
120
+ const list = runCli(['list'], env);
121
+ assert.equal(list.status, 0, list.stderr);
122
+ assert.equal(list.stdout,
123
+ `使える素材 2 件(ライブラリ: ${home})\n`
124
+ + ' ☁ mini-still\t[still]\tフィクスチャ素材 mini-still\n'
125
+ + ' ¥500 mini-paid\t[still]\tフィクスチャ素材 mini-paid(有料)\n');
126
+
127
+ const fetchResult = runCli(['fetch', 'mini-still'], env);
128
+ assert.equal(fetchResult.status, 0, fetchResult.stderr);
129
+ assert.equal(fetchResult.stdout, `取得しました: ${path.join(home, 'assets', 'still', 'mini-still')}\n`);
130
+ });
131
+ }
132
+
133
+ test('カタログ到達不能・キャッシュ無しでも installed item だけで一覧化できる', async () => {
134
+ const { env, home } = setupFixtureEnv({ AKARI_ASSETS_CATALOG: 'https://catalog.invalid/catalog.json' });
135
+ writeInstalled({ home, category: 'scene3d' });
136
+ const { base, items } = await composeState({
137
+ env,
138
+ fetchImpl: async () => { throw new Error('offline'); },
139
+ });
140
+ assert.equal(base, null);
141
+ assert.deepEqual(items.map((item) => item.id), ['installed-one']);
142
+ assert.equal(items[0].source, 'installed');
143
+ });
144
+
145
+ test('カタログ到達不能・キャッシュ無しで installed item も無ければ従来どおり失敗する', async () => {
146
+ const { env } = setupFixtureEnv({ AKARI_ASSETS_CATALOG: 'https://catalog.invalid/catalog.json' });
147
+ await assert.rejects(
148
+ () => loadCatalog({ env, fetchImpl: async () => { throw new Error('offline'); } }),
149
+ /カタログを取得できず、キャッシュもありません/,
150
+ );
151
+ });
152
+
153
+ test('壊れた installed.json は欠損を黙って無視せず明示エラーにする', async () => {
154
+ const { env, home } = setupFixtureEnv();
155
+ writeInstalled({ home });
156
+ const indexPath = path.join(home, 'assets', 'installed.json');
157
+ const index = JSON.parse(readFileSync(indexPath, 'utf8'));
158
+ delete index.packs['fixture-pack'].items[0].files[0].sha256;
159
+ writeFileSync(indexPath, `${JSON.stringify(index)}\n`);
160
+
161
+ await assert.rejects(() => loadCatalog({ env }), /files\[\] が不正/);
162
+ });
163
+
164
+ test('installed item の path が素材ディレクトリ外を指す索引は拒否する', async () => {
165
+ const { env, home } = setupFixtureEnv();
166
+ writeInstalled({ home });
167
+ const indexPath = path.join(home, 'assets', 'installed.json');
168
+ const index = JSON.parse(readFileSync(indexPath, 'utf8'));
169
+ index.packs['fixture-pack'].items[0].files[0].path = '../../outside.txt';
170
+ writeFileSync(indexPath, `${JSON.stringify(index)}\n`);
171
+
172
+ await assert.rejects(() => loadCatalog({ env }), /パック外を指しています/);
173
+ });
174
+
175
+ test('sync のキャッシュへ installed item を混ぜない', () => {
176
+ const { env, home, catalog } = setupFixtureEnv();
177
+ writeInstalled({ home });
178
+
179
+ const result = runCli(['sync'], env);
180
+ assert.equal(result.status, 0, result.stderr);
181
+ const cached = JSON.parse(readFileSync(path.join(home, 'catalog-cache.json'), 'utf8'));
182
+ assert.deepEqual(cached, catalog);
183
+ assert.equal(cached.items.some((item) => item.source === 'installed'), false);
184
+ });
@@ -296,7 +296,10 @@ function updateCaptionStylePresetInSource(source, captionIds, presetId) {
296
296
  changed++;
297
297
  continue;
298
298
  }
299
- if (hasPreset && record.style_preset === presetId)
299
+ const shadowed = shadowedPresetStyleKeys(presetId, record.text_style);
300
+ // 同じテンプレの再適用でも、そのテンプレを覆い隠している字幕個別の指定が残っていれば
301
+ // 掃除する仕事が残っている(「変更はありません」で終わらせない)。
302
+ if (hasPreset && record.style_preset === presetId && shadowed.length === 0)
300
303
  continue;
301
304
  let nextElement;
302
305
  if (hasPreset) {
@@ -317,11 +320,46 @@ function updateCaptionStylePresetInSource(source, captionIds, presetId) {
317
320
  + element.text.slice(textStyle.start);
318
321
  }
319
322
  }
323
+ nextElement = pruneShadowedTextStyle(nextElement, shadowed, captionId);
320
324
  output = replaceElement(output, array.openIndex + 1, element, nextElement);
321
325
  changed++;
322
326
  }
323
327
  return { source: output, changed };
324
328
  }
329
+ /**
330
+ * そのテンプレが決めるツマミのうち、字幕個別の text_style が上書きしてしまっているキーを挙げる。
331
+ *
332
+ * 合成規則は `{ ...presetStyle, ...text_style }`(caption-style-preset.ts)で **字幕側が強い**。
333
+ * そのため text_style に既定値が丸ごと書かれていると、テンプレを当てても見た目が変わらない
334
+ * (オーナー報告 2026-09-04:「ニュース帯だけ効く」= ニュース風の background だけが text_style に
335
+ * 無いツマミだった)。テンプレを選ぶ操作は「このツマミはテンプレに任せる」という意思表示なので、
336
+ * 適用時に該当キーを落としてテンプレを表に出す。テンプレが決めないツマミ(ドラッグした position /
337
+ * zone / max_characters など)は字幕個別の指定として残す。
338
+ */
339
+ function shadowedPresetStyleKeys(presetId, textStyle) {
340
+ const preset = Object.prototype.hasOwnProperty.call(textstyle_catalog_1.TEXTSTYLE_CATALOG, presetId)
341
+ ? textstyle_catalog_1.TEXTSTYLE_CATALOG[presetId] : undefined;
342
+ if (!preset || textStyle === null || typeof textStyle !== 'object' || Array.isArray(textStyle)) {
343
+ return [];
344
+ }
345
+ const style = textStyle;
346
+ return Object.keys(preset.style)
347
+ .filter(key => Object.prototype.hasOwnProperty.call(style, key));
348
+ }
349
+ /** text_style から指定キーを取り除く。空になったら text_style ごと落とす。 */
350
+ function pruneShadowedTextStyle(element, keys, captionId) {
351
+ if (keys.length === 0) {
352
+ return element;
353
+ }
354
+ const located = locateTopLevelObjectProperty(element, 'text_style', `字幕 ${captionId}`);
355
+ let textStyle = located.text;
356
+ for (const key of keys) {
357
+ textStyle = removeObjectProperty(textStyle, key);
358
+ }
359
+ return Object.keys(JSON.parse(textStyle)).length === 0
360
+ ? removeObjectProperty(element, 'text_style')
361
+ : element.slice(0, located.start) + textStyle + element.slice(located.end);
362
+ }
325
363
  function insertCaptionLine(source, caption) {
326
364
  const parsed = parseCaptions(source);
327
365
  if (!normalizeCaption(caption)) {
@@ -60,11 +60,26 @@ sprite draw state、回転・せん断・非全画面 canvas は中間 2D canvas
60
60
  clone では GPU / OSR ともプロパティが初期値のままです。直接宣言した opacity / transform は補間され、
61
61
  両エンジンのパリティは保たれます。カスタムプロパティ補間は書き出し用 sheet 側の別課題です。
62
62
 
63
- 3D 行列は `three-entrance-3d-matrix` fail-closed になります。root→canvas の祖先チェーン外に
64
- animation / transition がある場合は、fallback・装飾を別の DOM 描画で合成する方式が本版では未実装の
65
- ため、`three-html-animated-descendants` fail-closed になります。既存の filter / clip-path blocker は不変です。
66
- manifest `entranceMode`、receipt `curve` / `sampled` mode と sample 数・sampling 費用 p50/p95 を
67
- 記録します。CSS animation のない scene は従来どおり `three-scene-canvas-direct` です。
63
+ sampled 経路の入口条件は `three-or-canvas-runtime` / `animation-timing` 2 つです。方式 A が
64
+ root→canvas の祖先チェーンだけでは断片を説明できない場合は、方式 B
65
+ `three-scene-sampled-composite` に分類します。Three.js は従来どおり overlay sheet で描画し、毎コマその
66
+ canvas DOM 層コピー内の対応 canvas へ中継し、`[data-akari-3d-fallback]` を隠してから断片全体を
67
+ `drawElementImage` で転写します。これにより canvas 前後の DOM 順と z-index を保ち、断片間は track z と
68
+ 宣言 index の順を維持します。
69
+
70
+ composite は canvas チェーン内外の CSS 3D 幾何と `advanced-css` を扱います。CSS 3D は DOM 層と同じ判定を
71
+ 再利用し、深度 transform は通し、preserve-3d の順序競合は警告付きで通し、深度を伴う
72
+ `backface-visibility:hidden` だけ `css-3d-backface-hidden` で degraded のままにします。`@property` は
73
+ sheet / DOM 間のカスタムプロパティ補間パリティを実測するまで `three-composite-property` で fail-closed です。
74
+ `preserve-3d` 要素に Three canvas チェーン上の子と、深度 transform を持つチェーン外の子が同居する composite は
75
+ `three-composite-preserve-3d-siblings` で fail-closed にします。GPU はこれらの兄弟を DOM 順で描くため OSR と
76
+ 絵が変わっていました(2026-09-04 実測: 外接矩形 MAD 5.0082。OSR では z>0 の兄弟だけが canvas の前)。
77
+ `preserve3dOrderConflicts` を兄弟対まで広げれば次ラウンドで再解禁できます。親子対は従来どおり警告付きで通します
78
+ (実測パリティ 0.6374)。
79
+ composite の入口外条件は `three-sampled-condition:<条件名>` を報告します。方式 A の
80
+ `three-sampled-chain-css:<プロパティ>` ガードは残します。manifest は `entranceMode`、receipt は
81
+ `curve` / `sampled` / `composite` mode、sampling 費用、composite の DOM 要素数と canvas 中継・DOM 層費用の
82
+ p50/p95 を記録します。CSS animation のない scene は従来どおり `three-scene-canvas-direct` です。
68
83
 
69
84
  `render-cut --engine auto` は macOS / Windows で GPU を候補にし、プロジェクト全体が適格なら GPU、
70
85
  不適格なら OSR を使います。Linux の `auto` は legacy のままで、`--engine gpu` を明示した場合だけ
@@ -67,11 +67,27 @@ clone currently leaves those properties at their initial values in both GPU and
67
67
  opacity and transform keyframes still interpolate, so engine parity is preserved. Custom-property
68
68
  interpolation remains a separate export-sheet issue.
69
69
 
70
- Real 3D matrices fail closed as `three-entrance-3d-matrix`. Animation or transition outside the
71
- root-to-canvas ancestor chain fails closed as `three-html-animated-descendants`, because separate DOM
72
- rendering for fallback and decoration is not implemented in this version. Existing filter and clip-path
73
- blockers are unchanged. Manifests record `entranceMode`, and receipts record `curve` / `sampled` mode plus
74
- sample-count and p50/p95 sampling cost. Scenes without CSS animation remain `three-scene-canvas-direct`.
70
+ The sampled path admits `three-or-canvas-runtime` and `animation-timing`. When method A cannot describe the fragment
71
+ from its root-to-canvas chain, method B classifies it as `three-scene-sampled-composite`. Three.js still renders in
72
+ the overlay sheet; each frame copies that canvas into the matching canvas in the DOM-layer clone, hides
73
+ `[data-akari-3d-fallback]`, then transfers the whole fragment with `drawElementImage`. This preserves DOM order and
74
+ z-index around the canvas, while overlays remain ordered by track z and declaration index.
75
+
76
+ Composite scenes admit CSS 3D geometry and `advanced-css` both inside and outside the canvas chain. They reuse the
77
+ DOM layer's CSS 3D policy: depth transforms pass, preserve-3d order conflicts pass with a warning, and
78
+ `backface-visibility:hidden` with depth remains degraded as `css-3d-backface-hidden`. `@property` remains fail-closed
79
+ as `three-composite-property` until sheet/DOM custom-property interpolation parity is measured. Other conditions
80
+ outside the composite entry set report `three-sampled-condition:<condition>`. The method-A scan retains
81
+ `three-sampled-chain-css:<property>` as its own guard.
82
+
83
+ Composite scenes now fail closed as `three-composite-preserve-3d-siblings` when a `preserve-3d` element has both the
84
+ Three-canvas chain child and an off-chain child with a depth transform. GPU paints those siblings in DOM order, so it
85
+ diverged from OSR (measured 2026-09-04: bounding-box MAD 5.0082; OSR put only z>0 siblings in front of the canvas).
86
+ Extending `preserve3dOrderConflicts` to sibling pairs can re-enable this shape in a later round. Parent-child conflicts
87
+ continue to pass with a warning (measured parity 0.6374).
88
+ Manifests record `entranceMode`, and receipts record
89
+ `curve` / `sampled` / `composite`, sampling cost, plus composite DOM-element and p50/p95 copy/DOM-layer costs.
90
+ Scenes without CSS animation remain `three-scene-canvas-direct`.
75
91
 
76
92
  `render-cut --engine auto` considers GPU export on macOS and Windows, using it when the complete
77
93
  project is eligible and otherwise using OSR. On Linux, `auto` remains legacy and GPU export is
@@ -38,6 +38,10 @@ stem はファイル名に使える形へ sanitize されます。既存成果
38
38
 
39
39
  書き出した動画の空フレーム検証は既定で有効です。ffmpeg の `signalstats,metadata=print` を全フレームに 1 パスだけ実行し、全 YMAX 観測値の下位 5% の中央値を背景 YMAX として推定します。`YMAX <= 背景 + 8` のフレームを背景への張り付きとみなし、0.3 秒以上連続した区間だけを報告します。
40
40
 
41
- 走査には `-skip_frame` も縮小も使わないため、デコードした全フレームを測定し、報告可能な最小区間は 0.3 秒のままです。各区間は `verification.declared.blank_frames` と HTML レポートへ、活性な overlay / cut の ID とともに保存されます。宣言上活性な overlay または cut が 1 件以上あれば `warning`、0 件なら `info` です。これらの finding は検証 verdict を変えません。
41
+ 走査には `-skip_frame` も縮小も使わないため、デコードした全フレームを測定し、報告可能な最小区間は 0.3 秒のままです。各区間は `verify.declared.blank_frames` と HTML レポートへ、活性な overlay / cut の ID とともに保存されます。宣言上活性な overlay または cut が 1 件以上あれば `warning`、0 件なら `info` です。これらの finding は検証 verdict を変えません。
42
42
 
43
43
  この走査を無効にするには `--no-verify-blank` を指定します。
44
+
45
+ ## 開発専用の GPU 強制経路
46
+
47
+ degraded overlay を best-effort の DOM 層で検証する場合に限り、明示した `--engine gpu` とともに `AKARI_FORCE_GPU=1` を設定します。この迂回は検証専用で、出力には GPU 強制の刻印が付くため、納品物には絶対に使用しないでください。
@@ -39,6 +39,10 @@ allowed to replace a declared input.
39
39
 
40
40
  Blank-frame verification is enabled by default for rendered video artifacts. One full-frame ffmpeg pass runs `signalstats,metadata=print` and estimates the background YMAX as the median of the lowest 5% of YMAX observations. A frame is background-stuck when `YMAX <= background + 8`; only continuous intervals of at least 0.3 seconds are reported.
41
41
 
42
- The scan does not use `-skip_frame` or scaling, so every decoded frame is measured and the minimum reportable interval remains 0.3 seconds. Each interval is stored in `verification.declared.blank_frames` and shown in the HTML report with active overlay and cut IDs. An interval is a `warning` when at least one declared overlay or cut is active and `info` otherwise. These findings never change the verification verdict.
42
+ The scan does not use `-skip_frame` or scaling, so every decoded frame is measured and the minimum reportable interval remains 0.3 seconds. Each interval is stored in `verify.declared.blank_frames` and shown in the HTML report with active overlay and cut IDs. An interval is a `warning` when at least one declared overlay or cut is active and `info` otherwise. These findings never change the verification verdict.
43
43
 
44
44
  Use `--no-verify-blank` to disable this scan.
45
+
46
+ ## Development-only GPU override
47
+
48
+ Set `AKARI_FORCE_GPU=1` only when running an explicit `--engine gpu` export to evaluate degraded overlays through the best-effort DOM layer. This override is strictly for verification, marks the output as GPU-forced, and must never be used for a deliverable.
@@ -61,7 +61,7 @@ node の解決順は `AKARI_NODE_BIN` → PATH の node(20 以上)→ 同梱
61
61
  4. 同じコマンドを再実行し、error finding がなく `verdict: "pass"` になるまで繰り返す。analysis.json または captions.json が無い検査は `skipped[]` で確認する。
62
62
  5. 書き出し前は、使う出口に合わせて `--engine gpu` または `--engine osr` を追加して再実行する。
63
63
  書き出し側が出口を自動選択する場合は `--engine auto` を使い、エンジン適合性も PASS させる。
64
- 6. PASS 後に、カット境界と overlay の開始・終了フレームを実際に視認する。機械検査の PASS を意味的な品質確認の代わりにしない。
64
+ 6. PASS 後に、カット境界と overlay の開始・終了フレームを実際に視認する。**開始・終了フレームに加えて中間時刻(各区間の 1/4・1/2・3/4)も必ず視認する** — 拍ちょうど・カット境界ちょうどのフレームは区間の境界値(0% / 100% = 画面外・opacity 0)に必ず当たるため、正常な動きを事故と誤診する(`akari capture --auto` は各オーバーレイ / 字幕区間の中点を含む代表時刻を決定論で導出する)。機械検査の PASS を意味的な品質確認の代わりにしない。
65
65
  7. `<project>/.akari/reports/edit-lint-report.html` とフレーム視認結果を編集レポートへ反映し、checkpoint 状態と provenance を実態に合わせて閉じる。
66
66
 
67
67
  音声も確認するときだけ `--media` を追加する。無音区間と音量値は既定で warning になり、次の明示閾値を指定した検査だけが FAIL になり得る。
@@ -118,6 +118,21 @@ fragment は単一ルートとし、透明 canvas、任意の静的 fallback、
118
118
  clip を分けるので、1 個のモデルに複数の動きがあるときは `"*"` で束ねる(1 本しか再生しないと
119
119
  片方しか動かない)。存在しない clip 名を書いた場合はエラーになる。
120
120
  - `materialOverrides` は `{ "<material 名>": { "texture": "<画像または動画の相対パス>" } }` の形で、名前が一致するマテリアルの `emissiveMap` を差し替える。**差し替え先が `emissiveMap` である以上、貼り先の材質が発光しない(glTF の `emissiveFactor` が未設定 = 黒)と「0 × テクスチャ」で何も出ない**(2026-08-14 実害。詳細は後述「発光しない材質には貼れない」)。edit.json のあるディレクトリからの相対 PNG / JPEG / WebP 等、または MP4 / MOV / WebM を指定し、URL や CDN を書かない。該当するマテリアル名がモデル内にない場合は警告して無視される。
121
+ - `textureVar` は画面に映すものをツマミ(CSS カスタムプロパティ)にする任意キー。`"--screen-src"` のような変数名を書き、overlay の `vars` でその変数に相対パスを入れると `texture` の代わりに差し込まれる。空・未設定なら `texture` がそのまま使われる。
122
+ - `textureVar` を使う場合も `texture` は実在する相対パスのままにする。書き出し前の宣言済み入力検査が実ファイルを要求するため、`texture` に `var(--screen-src)` を直接書く形はライブプレビューでは動くが書き出しでは失敗する。
123
+ - `brightness` は任意の発光倍率(0〜4、既定 1)で、差し替えたテクスチャの `emissiveIntensity` に掛かる。`var(--screen-brightness)` のような CSS 変数も書き出しを含めて使える。既定の 1 のときは `emissiveIntensity` に触れず、既存宣言の見た目を維持する。
124
+ - ツマミへ結線する宣言例:
125
+ ```json
126
+ {
127
+ "materialOverrides": {
128
+ "ScreenMaterial": {
129
+ "texture": "placeholder.png",
130
+ "textureVar": "--screen-src",
131
+ "brightness": "var(--screen-brightness)"
132
+ }
133
+ }
134
+ }
135
+ ```
121
136
  - **パスは「edit.json のあるディレクトリ」から。断片の場所からの相対ではない。** `model` も同様。
122
137
  断片を `overlays/3d-phone/fragment.html` へ置いたなら `"overlays/3d-phone/model.glb"` と書く
123
138
  (`"model.glb"` はプロジェクト直下を探して ENOENT になる)。
@@ -131,3 +131,10 @@ base で `transform: translate(-50%, -50%)` により中央配置した要素に
131
131
 
132
132
  - 実例: 中央チップのポップイン中、接続線との間に隙間が発生(チップだけ右下へずれて拡大)
133
133
  - 直し方: keyframe の全ステップに base 分を含める(`translate(-50%,-50%) scale(0.2)` → `translate(-50%,-50%) scale(1)`)。そもそも centering は親ラッパー(grid `place-items: center`)に任せ、アニメ対象要素の base transform を空にしておくのが最安全
134
+
135
+ ### `duration` = 1 拍の要素は拍ちょうどのフレームで必ず 0% / 100% になる(2026-09-04 実測)
136
+
137
+ 拍やカット境界にぴったり合わせた要素(`duration` = ちょうど 1 拍)は、**その拍ちょうどのフレームで必ず境界値**(`0%` / `100%` = 画面外・`opacity: 0`)に当たる。拍ちょうど・カット境界ちょうどの時刻だけを抜いて眺めると、区間の中では正しく動いている要素が全コマ不在に見える。**断片の欠陥ではなく検収側のサンプリングの罠**である点が本節の他項目と異なる。
138
+
139
+ - 実例: 実制作の目視検収で、拍に合わせた入退場を持つ断片について **2 名が「アニメが死んでいる」と誤認しかけた**(実体は正常動作)
140
+ - 直し方: 目視を境界時刻だけで判定しない。`akari capture --auto` は各オーバーレイ / 字幕区間の**中点**を含む代表時刻を決定論で導出するので、**`capture --auto` の中点フレームで確認する**(`render.json` の `contact_sheet.timestamps_seconds` に中点が入っているかを先に見る)。逆に「本当に何も映っていない」区間の検出は目視に頼らず、render-cut の空フレーム走査(`verify.blank-frames`・連続 0.3 秒以上・活性 overlay / cut があれば `warning`、活性 0 件は `info`)に任せる
@@ -75,6 +75,11 @@ node の解決順は `AKARI_NODE_BIN` → PATH の node(20 以上)→ 同梱
75
75
  6. exit code と `.akari/render.json` を確認する。`0` は完走して verify PASS、`1` は拒否または verify FAIL、`2` は実行エラーを表す。`provenance.rasterizer` で採用手段と上位候補を落とした理由を確認する。`verify.findings` には
76
76
  尺・フレーム数厳密一致(`verify.frame-count`)・全フレームデコード成功(`verify.decode`)・解像度・fps・コーデック等が並ぶ。
77
77
  7. verify PASS 後、CLI が `<project>/.akari/reports/contact-sheet.png` へ自動生成したコンタクトシート(plan から決定論導出した代表時刻 — 冒頭・各カット境界の直後・各オーバーレイ/字幕区間の中点・終盤 — をタイル結合した静止画。`render.json` の `contact_sheet.timestamps_seconds` に時刻列を記録)をキーフレーム視認の起点にする。これで足りない区間(コンタクトシートの上限枚数を超えて間引かれた箇所など)だけ追加でフレーム抽出して視認する。カット元時刻、文字、位置、欠落、透明合成を確認する。
78
+
79
+ **サンプリングの罠 — 境界時刻のフレームだけで判定しない。** 拍ちょうど・カット境界ちょうどのフレームは、区間の境界値(0% / 100% = 画面外・opacity 0)に必ず当たる。duration がちょうど 1 拍の要素はこの位置で消えて見えるため、**正常な動きを「アニメが死んでいる」と誤診する**。
80
+
81
+ - **中間時刻(各区間の 1/4・1/2・3/4)を必ず見る**。時刻列(`render.json` の `contact_sheet.timestamps_seconds`)に各オーバーレイ / 字幕区間の**中点**が含まれていることを確認し、間引かれて中点が無い区間は [`akari capture --auto`](../../docs/contract-2026-08-29-capture-v0.md)(同じ代表時刻を決定論で導出。`-t <時刻>` との和集合も可)で撮り足す。
82
+ - **空フレーム走査 warning の読み方**(既定 ON・`--no-verify-blank` で OFF): `verify.findings` の `verify.blank-frames` は、輝度が背景レベルに張り付いた**連続 0.3 秒以上**の区間を挙げたもの。severity は、その区間に宣言上活性な overlay / cut が **1 件以上あれば `warning`**(カットの尺に対して中身のアニメが先に終わった疑い=要調査)、**活性 0 件なら `info`**(意図した黒区間の可能性が高く、そのまま無視してよい)。区間表と活性 id は `render.json` の `verify.declared.blank_frames` と HTML レポートで読む。これらの finding は verify の verdict を変えない。
78
83
  8. 機械検証値、成果物 SHA-256、採用したラスタライズ手段、フォールバック理由、コンタクトシート起点のキーフレーム視認結果を報告する。verify FAIL の場合は納品可能と表現せず、`.akari/render-tmp/` を保持して原因を報告する。
79
84
 
80
85
  ## 出力契約
@@ -52,6 +52,11 @@ npm run lint # eslint "extensions/*/src/**
52
52
  `shell-s12-preview-tab` / `shell-sc-repair` / `shell-strip-menu-repair` 等)。
53
53
  以下は `shell-s4-tabs`(report.md §6-2)と `preview-streaming`(report.md §2)で確立・実証済みの再現手順。
54
54
 
55
+ **フレーム視認は境界時刻だけで判定しない**。拍ちょうど・カット境界ちょうどのフレームは区間の境界値
56
+ (0% / 100% = 画面外・opacity 0)に必ず当たるため、正常な動きを事故と誤診する。区間の中間時刻
57
+ (1/4・1/2・3/4)も必ず撮る(`akari capture --auto` は各オーバーレイ / 字幕区間の中点を含む代表時刻を
58
+ 決定論で導出する)。
59
+
55
60
  ### 手順
56
61
 
57
62
  1. **ビルド**(L0 に加え electron 実体が要る):