akari-video 1.5.0-beta.3 → 1.5.0-beta.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.5.0-beta.3",
3
+ "version": "1.5.0-beta.4",
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": {
package/src/kits.mjs CHANGED
@@ -257,6 +257,42 @@ export function linkKitSkills(kitDir, manifest, home, options = {}) {
257
257
  return { linked, blockers, warnings };
258
258
  }
259
259
 
260
+ export function linkKitPlaybooks(kitDir, manifest, home, options = {}) {
261
+ const linked = [];
262
+ const warnings = [];
263
+ for (const entry of manifest.playbooks ?? []) {
264
+ if (typeof entry?.dir !== 'string' || !/^playbooks\/[a-z0-9][a-z0-9-]*$/u.test(entry.dir)) {
265
+ throw new Error(cliText('cli.kits.error.pathOutside', { path: String(entry?.dir) }));
266
+ }
267
+ const id = path.posix.basename(entry.dir);
268
+ const source = safeKitPath(kitDir, entry.dir);
269
+ const realKit = realpathSync(kitDir);
270
+ const realSource = realpathSync(source);
271
+ if (!realSource.startsWith(`${realKit}${path.sep}`) || !statSync(realSource).isDirectory()) {
272
+ throw new Error(cliText('cli.kits.error.pathOutside', { path: entry.dir }));
273
+ }
274
+ const destination = path.join(home, 'playbooks', id);
275
+ try {
276
+ const stat = lstatSync(destination);
277
+ let owned = false;
278
+ if (stat.isSymbolicLink()) {
279
+ try { owned = realpathSync(destination) === realSource; } catch { /* Broken links are not owned. */ }
280
+ }
281
+ if (!owned) {
282
+ warnings.push(cliText('cli.kits.warning.directoryKept', { path: destination }));
283
+ continue;
284
+ }
285
+ } catch (error) {
286
+ if (error?.code !== 'ENOENT') throw error;
287
+ }
288
+ const result = replaceSymlink(source, destination, options);
289
+ if (result.status === 'linked') linked.push(id);
290
+ else warnings.push(cliText(result.status === 'permission-denied'
291
+ ? 'cli.kits.warning.symlinkFailed' : 'cli.kits.warning.directoryKept', { path: destination }));
292
+ }
293
+ return { linked, warnings };
294
+ }
295
+
260
296
  export function readKitsLedger(home) {
261
297
  const ledgerPath = path.join(home, 'kits', 'installed.json');
262
298
  if (!existsSync(ledgerPath)) return { schema: KITS_SCHEMA, kits: [] };
@@ -269,6 +305,12 @@ export function readKitsLedger(home) {
269
305
 
270
306
  export function writeKitsLedger(home, entry) {
271
307
  const ledger = readKitsLedger(home);
308
+ const previous = ledger.kits.find((kit) => kit.id === entry.id);
309
+ for (const id of previous?.playbooks ?? []) {
310
+ if (!(entry.playbooks ?? []).includes(id)) {
311
+ removeOwnedSymlink(path.join(home, 'playbooks', id), previous.kitDir);
312
+ }
313
+ }
272
314
  ledger.kits = [...ledger.kits.filter((kit) => kit.id !== entry.id), entry];
273
315
  const ledgerPath = path.join(home, 'kits', 'installed.json');
274
316
  mkdirSync(path.dirname(ledgerPath), { recursive: true });
@@ -283,6 +325,7 @@ export function removeKit(home, productId, env = process.env) {
283
325
  const entry = ledger.kits.find((kit) => kit.id === productId);
284
326
  if (!entry) return false;
285
327
  for (const name of entry.skills ?? []) removeOwnedSymlink(path.join(home, 'kits', 'plugin', 'skills', name), entry.kitDir);
328
+ for (const id of entry.playbooks ?? []) removeOwnedSymlink(path.join(home, 'playbooks', id), entry.kitDir);
286
329
  for (const asset of entry.assets ?? []) {
287
330
  for (const root of resolveAssetLibraryRoots({ ...env, AKARI_HOME: home }).read) removeOwnedSymlink(path.join(root, asset.category, asset.id), entry.kitDir);
288
331
  }
@@ -299,7 +342,14 @@ function removeOwnedSymlink(linkPath, kitDir) {
299
342
  try {
300
343
  if (!lstatSync(linkPath).isSymbolicLink()) return;
301
344
  const target = path.resolve(path.dirname(linkPath), readlinkSync(linkPath));
302
- if (target === path.resolve(kitDir) || target.startsWith(`${path.resolve(kitDir)}${path.sep}`)) rmSync(linkPath);
345
+ const root = path.resolve(kitDir);
346
+ let owned = target === root || target.startsWith(`${root}${path.sep}`);
347
+ try {
348
+ const realTarget = realpathSync(linkPath);
349
+ const realRoot = realpathSync(kitDir);
350
+ owned ||= realTarget === realRoot || realTarget.startsWith(`${realRoot}${path.sep}`);
351
+ } catch { /* Lexical ownership still handles a removed kit directory. */ }
352
+ if (owned) rmSync(linkPath);
303
353
  } catch (error) {
304
354
  if (error?.code !== 'ENOENT') throw error;
305
355
  }
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { spawnSync } from 'node:child_process';
2
3
  import {
3
4
  copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync,
@@ -32,6 +33,7 @@ import {
32
33
  enableHint,
33
34
  ensureKitsMarketplace,
34
35
  linkKitAssets,
36
+ linkKitPlaybooks,
35
37
  linkKitSkills,
36
38
  readKitManifest,
37
39
  readKitsLedger,
@@ -347,11 +349,75 @@ export async function runStoreCommand(args, options = {}) {
347
349
 
348
350
  if (sub === 'download') {
349
351
  const productId = args[1];
350
- if (!productId || productId.startsWith('--')) {
352
+ if (!isSafePathSegment(productId) || productId.startsWith('--')) {
351
353
  log(cliText('cli.store.help.downloadUsage'));
352
354
  return { exitCode: 1 };
353
355
  }
354
356
  const creds = readCredentials(env);
357
+ const publicBase = (options.baseUrl ?? creds?.url ?? DEFAULT_STORE_BASE_URL).replace(/\/$/, '');
358
+ let freeProduct;
359
+ try {
360
+ const listing = await fetchImpl(`${publicBase}/products`);
361
+ if (listing.ok) {
362
+ const data = await readJsonResponse(listing);
363
+ freeProduct = data?.products?.find(item => item.id === productId && item.tier === 'free'
364
+ && Number.isInteger(item.current_version) && item.current_version >= 1);
365
+ }
366
+ } catch { /* Paid download remains available when the public catalog is offline. */ }
367
+ if (!freeProduct) {
368
+ // Lab の無料の作り方(playbook)は商品データベースに載らず、無料 R2 の公開カタログ(/assets/catalog.json)にだけ載る。
369
+ // その行の files[0].key を /assets/<key> から取り、sha256 を照合して保存する。
370
+ try {
371
+ const catalogResponse = await fetchImpl(new URL('/assets/catalog.json', publicBase).href);
372
+ if (catalogResponse.ok) {
373
+ const catalog = await readJsonResponse(catalogResponse);
374
+ const row = catalog?.items?.find(item => item?.id === productId && item?.category === 'playbook' && item?.tier === 'free'
375
+ && Number.isInteger(item?.version) && item.version >= 1 && typeof item?.files?.[0]?.key === 'string');
376
+ const key = row?.files?.[0]?.key;
377
+ if (row && /^[a-z0-9][a-z0-9/._-]*\.zip$/.test(key) && !key.includes('..')) {
378
+ const response = await fetchImpl(new URL(`/assets/${key}`, publicBase).href);
379
+ if (!response.ok) {
380
+ log(cliText('cli.store.download.httpFailed', { status: response.status }));
381
+ return { exitCode: 1 };
382
+ }
383
+ const bytes = Buffer.from(await response.arrayBuffer());
384
+ const expected = row.files[0].sha256;
385
+ if (typeof expected === 'string' && createHash('sha256').update(bytes).digest('hex') !== expected) {
386
+ log(cliText('cli.store.download.failed', { message: 'sha256 mismatch' }));
387
+ return { exitCode: 1 };
388
+ }
389
+ const destDir = parseFlag(args, '--dest') ?? process.cwd();
390
+ mkdirSync(destDir, { recursive: true });
391
+ const filePath = path.join(destDir, `${productId}-v${row.version}.zip`);
392
+ writeFileSync(filePath, bytes);
393
+ log(cliText('cli.store.download.saved', { path: filePath }));
394
+ return { exitCode: 0, filePath };
395
+ }
396
+ }
397
+ } catch { /* 公開カタログが取れなければ従来の経路へ */ }
398
+ }
399
+ if (freeProduct) {
400
+ const version = freeProduct.current_version;
401
+ const assetUrl = new URL(`/assets/products/${productId}/v${version}/${productId}-v${version}.zip`, publicBase);
402
+ try {
403
+ const response = await fetchImpl(assetUrl.href);
404
+ if (response.ok) {
405
+ const destDir = parseFlag(args, '--dest') ?? process.cwd();
406
+ mkdirSync(destDir, { recursive: true });
407
+ const filePath = path.join(destDir, `${productId}-v${version}.zip`);
408
+ writeFileSync(filePath, Buffer.from(await response.arrayBuffer()));
409
+ log(cliText('cli.store.download.saved', { path: filePath }));
410
+ return { exitCode: 0, filePath };
411
+ }
412
+ if (response.status !== 404 || !creds) {
413
+ log(cliText('cli.store.download.httpFailed', { status: response.status }));
414
+ return { exitCode: 1 };
415
+ }
416
+ } catch (error) {
417
+ log(cliText('cli.store.download.failed', { message: error instanceof Error ? error.message : String(error) }));
418
+ return { exitCode: 1 };
419
+ }
420
+ }
355
421
  if (!creds) {
356
422
  log(cliText('cli.store.status.disconnectedGuide'));
357
423
  return { exitCode: 1 };
@@ -410,8 +476,8 @@ export async function runStoreCommand(args, options = {}) {
410
476
  log(cliText('cli.store.help.installUsage'));
411
477
  return { exitCode: 1 };
412
478
  }
413
- const hasFrom = args.includes('--from');
414
- const fromZip = parseFlag(args, '--from');
479
+ const hasFrom = args.includes('--from') || args.includes('--from-file');
480
+ const fromZip = parseFlag(args, '--from-file') ?? parseFlag(args, '--from');
415
481
  if (hasFrom && !fromZip) {
416
482
  log(cliText('cli.store.help.installUsage'));
417
483
  return { exitCode: 1 };
@@ -549,7 +615,7 @@ export async function runStoreCommand(args, options = {}) {
549
615
  return { exitCode: 1 };
550
616
  }
551
617
 
552
- const hadInstalledKit = readKitsLedger(home).kits.length > 0;
618
+ const hadSkillKit = readKitsLedger(home).kits.some(kit => Array.isArray(kit.skills) && kit.skills.length > 0);
553
619
  const assetLinks = linkKitAssets(kitDir, manifest, home, {
554
620
  env,
555
621
  assets: options.assets,
@@ -562,6 +628,8 @@ export async function runStoreCommand(args, options = {}) {
562
628
  for (const blocker of skillLinks.blockers) log(cliText('cli.store.install.blocked', { blocker }));
563
629
  return { exitCode: 1 };
564
630
  }
631
+ const playbookLinks = linkKitPlaybooks(kitDir, manifest, home, { platform: options.platform });
632
+ for (const warning of playbookLinks.warnings) log(cliText('cli.store.install.warning', { warning }));
565
633
  registerKitAssets(home, manifest, kitDir, assetLinks.items, env);
566
634
  writeKitsLedger(home, {
567
635
  id: manifest.id,
@@ -569,10 +637,11 @@ export async function runStoreCommand(args, options = {}) {
569
637
  installedAt: new Date().toISOString(),
570
638
  kitDir,
571
639
  skills: skillLinks.linked,
640
+ playbooks: playbookLinks.linked,
572
641
  assets: assetLinks.linked
573
642
  });
574
643
  ensureKitsMarketplace(home);
575
- if (!hadInstalledKit) log(enableHint());
644
+ if (skillLinks.linked.length > 0 && !hadSkillKit) log(enableHint());
576
645
  }
577
646
  const packPath = findFile(destDir, 'PACK.json');
578
647
  if (packPath) {
@@ -91,7 +91,10 @@
91
91
  "docs/contract-2026-10-11-ui-i18n.md",
92
92
  "docs/contract-2026-10-12-caption-look-v1.md",
93
93
  "docs/contract-2026-10-15-playbook-v0.md",
94
+ "docs/contract-2026-10-15-project-manifest-v0.md",
94
95
  "docs/contract-2026-10-15-script-lines-v0.md",
96
+ "docs/contract-2026-10-16-automation-gates-v0.md",
97
+ "docs/contract-2026-10-16-recipes-and-connectors-v0.md",
95
98
  "packages/akari-ear/package.json",
96
99
  "packages/akari-ear/README.md",
97
100
  "packages/akari-launcher/package.json",
@@ -8,6 +8,7 @@
8
8
  ~/.akari/assets/store/<productId>/
9
9
  ├── manifest.json
10
10
  ├── skills/<skill-name>/SKILL.md
11
+ ├── playbooks/<id>/{playbook.json, playbook.md, preview/}
11
12
  ├── templates/<name>.json
12
13
  ├── assets/<category>/<id>/{meta.json, …}
13
14
  ├── docs/*.md
@@ -35,6 +36,7 @@
35
36
  "products": ["akari-pop-motion-set"]
36
37
  },
37
38
  "skills": [{ "dir": "skills/design-world", "name": "design-world" }],
39
+ "playbooks": [{ "dir": "playbooks/world-intro" }],
38
40
  "templates": [{ "path": "templates/paper-to-browser.json", "for": "world-map", "label": "紙の地図 → ブラウザの中" }],
39
41
  "assets": [{ "category": "overlay", "id": "world-far-bands" }],
40
42
  "docs": [{ "path": "docs/world-hen.md", "label": "ワールド編(抜粋)" }],
@@ -44,6 +46,7 @@
44
46
  ```
45
47
 
46
48
  必須フィールドは `schemaVersion`、`id`、`kind`、`name`、`version`、`requires.cli`、`license`。`kind` は v0 では `kit` のみ。`validate-kit-manifest.mjs` はスキル実体、素材メタデータ、テンプレート用途語彙を含めて検査する。
49
+ 任意の `playbooks[]` は `playbooks/<id>` を指し、各ディレクトリに playbook-v0 形式の `playbook.json` と `playbook.md`、任意の `preview/` を置く。
47
50
 
48
51
  ## 3. 展開
49
52
 
@@ -53,8 +56,9 @@
53
56
  2. `assets[]` を `~/.akari/assets/<category>/<id>` へ相対 symlink で公開する。各素材はリンク前に `validate-asset.mjs` で検査する。
54
57
  素材の実体ファイルと checksum は `~/.akari/assets/installed.json` にも登録し、素材 id から解決できるようにする。
55
58
  3. `skills[]` を `~/.akari/kits/plugin/skills/<name>` へ相対 symlink で公開する。
56
- 4. `~/.akari/kits/installed.json` に id、version、導入日時、展開先、スキル、素材を記録する。
57
- 5. `templates[]` は移動せず、CLI が各展開先の manifest を列挙して読む。
59
+ 4. `playbooks[]` を `~/.akari/playbooks/<id>` へ相対 symlink で公開する。同じ id が手元に実体であれば上書きせず警告して飛ばす。
60
+ 5. `~/.akari/kits/installed.json` に id、version、導入日時、展開先、スキル、素材、公開した作り方を記録する。uninstall では記録した作り方の symlink も外す。
61
+ 6. `templates[]` は移動せず、CLI が各展開先の manifest を列挙して読む。
58
62
 
59
63
  manifest が無い商品は従来の素材商品として扱う。検査器や runtime registry が npm 配布物に同梱されておらず照合できない場合は、その検査だけを警告付きでスキップする。`akari store uninstall <productId>` は台帳に記録した symlink と台帳エントリを外し、再導入用の展開ディレクトリは残す。
60
64
 
@@ -48,6 +48,8 @@ updated: 2026-10-15
48
48
  | 確認シート | `<project>/.akari/reports/playbook-sheet.html`(読み取り専用・派生) | `akari new --from` |
49
49
 
50
50
  - 一覧の解決順は **チャンネル → 手元** の 2 択だけ(`create-project` の雛形解決と同じ規律。他の経路を作らない)
51
+ - Lab で入れた作り方は手元の棚に symlink で入る。解決順は 2 択のまま、画面の入手先は `~/.akari/kits/installed.json` の `playbooks` に載っていれば Lab と表示する。
52
+ - Lab の無料の作り方は商品データベースに載せない。無料 R2 の公開カタログ(`/assets/catalog.json`)の `category: playbook` の行の `files[0].key` を `/assets/<key>` から取り、`sha256` を照合してから入れる(`akari store install <id>`)
51
53
  - `preview/` は任意(下の項)
52
54
  - `preview` のパスはすべて **playbook.json からの相対**。棚(手元・チャンネル)では `preview/cover.png`(任意)と `preview/frames/01.png`〜`05.png`(重要な場面のコマ 3〜5 枚・長辺 640px 以下・作り方の画面比)を置く。表紙が無ければ `frames` の 1 枚目を表紙にする。動画(sample.mp4)は持たない(重いため)。プロジェクトの中では `planning/playbook-preview/frames/` に置く
53
55
  - `<id>` は英小文字・数字・ハイフン。同じ `id` は版で区別する(`version` 整数)
@@ -0,0 +1,186 @@
1
+ ---
2
+ lifecycle: accepted
3
+ created: 2026-10-15
4
+ updated: 2026-10-16
5
+ ---
6
+
7
+ # project-manifest-v0(プロジェクトの依存グラフ)契約
8
+
9
+ - 日付: 2026-10-15
10
+ - 状態: **accepted**(2026-10-16。同日の裁定で「作品」の器を廃し、プロジェクトで閉じる形に改訂。開けた問い 2 つは §8 の既定で確定)
11
+ - 前提: `contract-2026-07-17-data-contract-versioning.md`(版管理三原則。本契約は初版から適用する)/
12
+ `contract-2026-09-02-asset-reference-model.md`(プロジェクトの参照台帳 `.akari/asset-references.json`)/
13
+ `contract-2026-09-24-style-v0.md`(スタイルの利用台帳 `.akari/style-usage.json`)/
14
+ `contract-2026-10-10-channel-people-design-v0.md`(チャンネルの人物 `people.json`・デザイン素材)/
15
+ `contract-2026-07-25-project-structure-v0.md`(プロジェクト内の置き場所)/
16
+ `contract-2026-09-13-world-map-v0.md`(ワールドマップ `planning/world-map.json`。世界観がプロジェクトの中に置かれる先例)
17
+ - 発端: 1 本の動画を超えるもの(複数のシーン・ワールド・人物・外の道具の出力を束ねるもの)を
18
+ 扱うとき、「何が何を使っているか」だけを 1 枚で持ちたい。edit.json は 1 本の並びの正本であり、
19
+ シーンをまたぐ広がりは持たない。参照台帳と利用台帳は素材・スタイルに限られ、シーン・ワールド・
20
+ 人物・道具の出力を表せない。**単位は増やさない**: 器は従来どおりプロジェクト 1 つで、
21
+ シーンはその中に 1 段だけ入れ子にする(「作品」という別の器は作らない)
22
+ - スコープ: プロジェクト単位の依存グラフ `manifest.json` の**置き場・形・語彙・解決規則・派生物**と、
23
+ シーンを持つプロジェクトの最小の構成(§1)。既存の edit.json / 参照台帳 / 利用台帳は変えない(§5)。
24
+ **相関図や構成の木の UI 実装、Lab への公開形式、外の道具との自動連携、既存プロジェクトの一括移行、
25
+ アプリが入れ子の edit.json を開く実装は非スコープ**(§7)
26
+
27
+ ## 0. 位置づけ — 持つのは「関係」だけ
28
+
29
+ manifest は「このプロジェクトに、何が、どの版で関わっているか」の目録である。実体(動画・3D・
30
+ Blender のファイル)は持たず、編集の中身(カットの位置・字幕)も持たない。
31
+
32
+ | 正本 | 持つもの |
33
+ |---|---|
34
+ | `edit.json` | 1 本(1 シーン)の中の並び(カット・素材の使用範囲・字幕の時刻) |
35
+ | `.akari/manifest.json`(本契約) | プロジェクトに関わるものの一覧(nodes)と、誰が誰を使うかの線(edges) |
36
+ | `.akari/asset-references.json` | 参照する素材の `{category, id}`(従来どおり) |
37
+ | `.akari/style-usage.json` | 当てたスタイルの `style_uid` と `revision`(従来どおり) |
38
+
39
+ manifest から派生するもの(読み取り専用。正本に書き戻さない):
40
+ 相関図(HTML・inline SVG)/ 構成の木(プロジェクト → シーン → カット)/ 取り込む前の確認シート
41
+ (件数・入手先・要る道具・大きさ)/ 壊れ検査(§4)/ 作り方(playbook)の「使った部品」の章。
42
+
43
+ ## 1. 置き場 — プロジェクトで閉じる
44
+
45
+ ```text
46
+ <project>/ ← 器はこれ 1 つ(従来どおり)
47
+ ├─ edit.json captions.json ← 1 本のプロジェクトはここに(従来どおり・変更なし)
48
+ ├─ planning/ assets/ .akari/ ← project-structure-v0 のまま
49
+ ├─ scenes/ ← シーンを持つプロジェクトだけ。入れ子はこの 1 段だけ
50
+ │ ├─ scene-01/ edit.json captions.json assets/ .akari/ ← 中身は 1 本のプロジェクトと同じ構成
51
+ │ ├─ scene-02/ …
52
+ │ └─ scene-03/ … assets/scene3d/<id>/{scene.py, params} ← 道具のレシピ(手で開く道具だけ tools/)
53
+ ├─ world/<id>/world.json ← 世界観(置き場・小物・色。中身は別契約・未起票)
54
+ ├─ people.json ← このプロジェクトの人物。無ければチャンネルの people.json
55
+ └─ .akari/manifest.json ← 本契約。プロジェクトの外を指さない
56
+ ```
57
+
58
+ - manifest は `<project>/.akari/manifest.json` の 1 箇所。**プロジェクトの外(他のプロジェクト・
59
+ チャンネルの別フォルダ)を `where` で指さない**。プロジェクトをまたいで共有したいもの
60
+ (世界・人物・スタイル・キット)はライブラリかチャンネル経由の参照(`source`)で持ち、
61
+ 実体はプロジェクトに取り込むか、ライブラリで解決する
62
+ - `scenes/<scene>/` の中は 1 本のプロジェクトと同じ構成で、`edit.json` を持つ。
63
+ **`scenes/` の下にさらに `shots/` `layers/` のような層を作らない**(入れ子は 1 段まで)
64
+ - **シーンを開く**: アプリはシーンのフォルダ `scenes/<scene>/` をそのまま作業フォルダとして開く(中身が 1 本の
65
+ プロジェクトと同じ構成なので、edit.json・captions.json・assets/ の解決は 1 本のときと同じ)。
66
+ containment の根は**シーンのフォルダ**のまま(1 本のプロジェクトと同じ。`..` で外へ出る参照は従来どおり拒否)。
67
+ プロジェクト共通の `assets/` の素材をシーンで使うときは、そのシーンの `assets/` へ**ハードリンクで取り込む**
68
+ (同じボリュームでなければ複写)。manifest には `scene:<id>` → `asset:<category>/<id>` の線を 1 本足し、
69
+ どのシーンが共通の素材を使っているかは manifest で見る(`../../assets/` での直接参照は v0 では作らない)。
70
+ シーンのフォルダかどうかは「親の親に `.akari/` があり、親の名前が `scenes`」で判定する
71
+ - 1 本のプロジェクトは `scenes/` を持たず、従来どおりルートの `edit.json` で動く。
72
+ manifest は無くてもよい(§5)
73
+ - 外の道具は 2 種に分ける。**エージェントが回せる道具**(Blender の bpy・生成 AI の API・ffmpeg)は
74
+ レシピが正本で、出力は再生成できるキャッシュ(`contract-2026-07-14-3d-bake-recipe.md`。
75
+ レシピは `assets/scene3d/<id>/`、出力は通常の素材)。**人が手で開く道具**だけ
76
+ `<project または scene>/tools/<tool>/` に入力の覚え書き・出力ファイル(またはそのリンク)・記録を
77
+ 置いて往復する。どちらも道具の作業フォルダ本体は置かない
78
+ - `assets/` は平らに置く。AI がフォルダを切らない。意味づけ・分析は `analysis.json` と `meta.json` の側に持ち、
79
+ 画面の分類はそこから派生で描く
80
+
81
+ ## 2. 形
82
+
83
+ ```json
84
+ {
85
+ "version": 0,
86
+ "kind": "akari-project-manifest",
87
+ "project": { "id": "hoshi-no-oji", "name": "星の王子さま", "revision": 3 },
88
+ "nodes": [
89
+ { "id": "world:desert", "kind": "world", "rev": 2, "where": "world/desert" },
90
+ { "id": "person:prince", "kind": "person", "rev": 5, "where": "people.json#p-prince" },
91
+ { "id": "scene:01", "kind": "scene", "rev": 1, "where": "scenes/scene-01" },
92
+ { "id": "asset:still/rose", "kind": "asset", "rev": 1, "source": "lab", "category": "still" },
93
+ { "id": "style:caption-a", "kind": "style", "rev": 2, "uid": "st_01HZ…" },
94
+ { "id": "tool:blender/s01", "kind": "tool-output", "rev": 1, "tool": "Blender 4.2",
95
+ "where": "scenes/scene-01/assets/generated/<id>.mp4" }
96
+ ],
97
+ "edges": [
98
+ { "from": "scene:01", "uses": "world:desert" },
99
+ { "from": "scene:01", "uses": "person:prince" },
100
+ { "from": "scene:01", "uses": "tool:blender/s01" },
101
+ { "from": "tool:blender/s01", "uses": "person:prince", "note": "レンダーに使ったモデル" }
102
+ ]
103
+ }
104
+ ```
105
+
106
+ - `version`: 整数。初版は 0(原則 1)。`kind` は固定文字列 `"akari-project-manifest"`
107
+ - `project`: 任意。`id` は英小文字・数字・ハイフン。`revision` はプロジェクトの版(整数・増えるだけ)
108
+ - `nodes[]`: 必須。各要素は `id`・`kind`・`rev` が必須。`where` は**プロジェクトルートからの相対パス**
109
+ (`..` を含めない。`people.json#<id>` のようなフラグメント指定を許す)。
110
+ `source` は `builtin` / `lab` / `own` / `site` / `ai` / `channel`(入手先。参照台帳の
111
+ `sourceKind` と同じ語彙 + `ai` + `channel`)。`tool` は道具の名前と版の自由文。`tool-output` は任意で `recipe`(作ったレシピの `<id>@<版>`)・
112
+ `connector`(呼んだ接続〈サービス・ソフトウェア〉の名前と版)・`inputs_sha256`(入力のハッシュ)を持てる(再現と壊れ検査用)
113
+ - `edges[]`: 必須(空でもよい)。`from` と `uses` は nodes の `id`。`note` は任意。
114
+ 同じ `{from, uses}` の重複は持たない。自己参照と循環は持たない(§4 で検査)
115
+ - 並び: nodes は `kind` → `id`、edges は `from` → `uses` の安定ソート。書き込みは
116
+ tmp + rename の原子的更新。読み手は tolerant reader(未知フィールドは保持、欠損は既定値)
117
+
118
+ ## 3. 語彙(kind と id の接頭辞)
119
+
120
+ | kind | id の形 | 指すもの | 版の正本 |
121
+ |---|---|---|---|
122
+ | `project` | `project:<id>` | プロジェクトそのもの(親を指すときだけ使う) | manifest の `project.revision` |
123
+ | `scene` | `scene:<id>` | `scenes/<id>/`(中は 1 本のプロジェクトと同じ構成) | そのシーンの `edit.json` の更新 |
124
+ | `world` | `world:<id>` | 世界観(場所・小物・色の束。`world/<id>/`) | `world.json` の revision(別契約・未起票) |
125
+ | `person` | `person:<id>` | `people.json` の 1 件(人・アバター・団体)。プロジェクトに無ければチャンネル | `people.json` の `edited` |
126
+ | `asset` | `asset:<category>/<id>` | 素材(参照台帳と同じ `{category, id}`) | `.meta.json` の版 |
127
+ | `style` | `style:<id>` | スタイル(`style_uid` と `revision`) | `style.json` の revision |
128
+ | `kit` | `kit:<channel>` | チャンネルのキット(将来) | kit の revision |
129
+ | `playbook` | `playbook:<id>` | 作り方(将来) | playbook の version |
130
+ | `tool-output` | `tool:<tool>/<id>` | 外の道具が出したファイル(レシピから焼いたものは provenance を持つ) | レシピ由来は provenance の版、手作業は `rev` を手で上げる |
131
+ | `doc` | `doc:<name>` | 台本・ストーリーボード・企画書など読む文書 | ファイルの mtime |
132
+
133
+ 新しい kind は**この表に追記してから**使う。未知の kind を読んだ読み手は捨てず保持し、
134
+ 相関図では「その他」として描く(原則 3)。
135
+
136
+ ## 4. 解決と検査
137
+
138
+ - 解決: `where` はプロジェクトルートからの相対で解決する。`person` と `kit` だけは、プロジェクトに
139
+ 無ければチャンネル(`channels/<channel>/`)で再解決してよい(`source: channel`)。`asset` は
140
+ 参照台帳と同じ順(プロジェクト実体 → 共有ライブラリ)で解決する。どの経路でも字句・realpath の
141
+ containment を満たさないパスは fail-closed で拒否する
142
+ - 検査(`akari project check` を想定。exit 非 0 は要修正):
143
+ 1. `edges` の `from` / `uses` が nodes に無い → 欠落
144
+ 2. `where` の実体が無い → 未取得(`asset` で `source: lab` なら「未取得」、それ以外は「欠落」)
145
+ 3. 参照先の版が node の `rev` より新しい → 古い(情報。自動では直さない)
146
+ 4. 自己参照・循環 → 壊れ
147
+ 5. `source: site` または `own` で外部の実体を含む node が、公開用の写し(§6)に残っている → 公開不可
148
+ - 検査結果は `.akari/reports/manifest-check.json` に書き、HTML の派生と画面の帯はそこから描く
149
+
150
+ ## 5. 既存ファイルとの関係(追加のみ・移行なし)
151
+
152
+ - `.akari/asset-references.json` と `.akari/style-usage.json` は**そのまま**。manifest の
153
+ `asset` / `style` の nodes は、この 2 つから機械的に生成できる(`akari project sync`。
154
+ シーンを持つプロジェクトでは各 `scenes/<id>/.akari/` の台帳を束ねる)。
155
+ 手で足した nodes(world・person・tool-output・doc)は sync で消さない
156
+ - `edit.json` は変えない。`scene` の node から `scenes/<id>/edit.json` を開けばカットの並びが見える
157
+ - 1 本のプロジェクトでは manifest を作らなくてもよい(無ければ 2 つの台帳だけで従来どおり動く)
158
+
159
+ ## 6. 公開用の写し
160
+
161
+ 作り方やプロジェクトを手元の外へ出すときは、manifest の**公開用の写し**を別に作る。写しでは
162
+ `where` を落として `id`・`kind`・`rev`・`source` だけを残し、`source: own` / `site` の
163
+ node は仮枠(`placeholder: true`)に置き換える。写しの形は本契約と同じ(`version: 0`)。
164
+ どの node を仮枠にするかの規則と、見本の扱いは作り方(playbook)側の契約で定める(未起票)。
165
+
166
+ ## 7. 非スコープ
167
+
168
+ - 相関図・構成の木の画面(派生の描き方は別途)
169
+ - アプリが `scenes/<id>/edit.json` を開く実装(現状はルートの `edit.json` のみ)
170
+ - Lab での公開形式・取り込みの UI
171
+ - 外の道具(Blender 等)との自動連携・起動
172
+ - 既存プロジェクトへの manifest の一括生成
173
+ - `world` の中身の契約(`world.json`)と `kit` / `playbook` の契約
174
+
175
+ ## 8. 裁定済みと開けた問い
176
+
177
+ - 裁定(2026-10-15): 器はプロジェクト 1 つ。「作品」という別の器は作らず、シーンは `scenes/` に
178
+ 1 段だけ入れ子にする。manifest はプロジェクトの外を指さない
179
+ - 確定(2026-10-16。次の既定を採用):
180
+ 1. `person` の版は v0 では `people.json` の `edited`(ISO 時刻)を整数に写さず、node に `edited` を
181
+ そのまま持たせて比べる(`rev` は sync が `edited` の変化ごとに 1 上げる)。専用の revision は足さない
182
+ 2. `tool-output` の `rev` は手で上げてよいが、node に任意の `sha256` を持たせ、sync は実体のハッシュが
183
+ 変わったら `rev` を 1 上げる(手作業の上げ忘れを拾う)
184
+ - CLI: `akari project sync`(台帳 → nodes の生成・手で足した nodes は保持)/ `akari project check`
185
+ (§4 の検査 → `.akari/reports/manifest-check.json`・exit 非 0 は要修正)/ `akari project scene add <id>`
186
+ (`scenes/<id>/` を 1 本のプロジェクトと同じ雛形で作り、manifest に `scene` の node を足す)
@@ -0,0 +1,87 @@
1
+ ---
2
+ lifecycle: accepted
3
+ created: 2026-10-16
4
+ updated: 2026-10-16
5
+ ---
6
+
7
+ # automation-gates-v0(自動化の門と「やったこと」)契約
8
+
9
+ - 日付: 2026-10-16
10
+ - 状態: **accepted**
11
+ - 前提: `skills/edit-plan/autonomy.md`(おまかせ度 3 つと止まる条件 3 つ)/ `.akari/intake.json` の `autonomy`
12
+ (`full-auto` そのまま / `checkpoint` 提案つき・既定 / `collaborative` 一緒に作る)/
13
+ `contract-2026-08-12-chat-approval-v0.md`(チャットは配管・閉じた語彙の承認)/
14
+ `contract-2026-07-25-project-structure-v0.md`(`.akari/events/` = 作業の節目の記録。追記のみ)/
15
+ `contract-2026-09-13-generation-v0.md` §8(費用承認)
16
+ - 発端: エージェントに任せて見ていないあいだも作業を止めたくない。一方で、戻せないことは人が決めたい。
17
+ 線を「見ているか」ではなく **「戻せるか」** で引き直し、止まる場所(門)を 1 つの表にまとめる。
18
+ 止まらずに進んだことは、あとで一覧から見て戻せるようにする
19
+ - スコープ: 門の語彙と判定 / 門の依頼と返事の出来事 / エージェントの「やったこと」の出来事 / やったこと一覧と戻し方。
20
+ **新しいおまかせ度は作らない**(既存の 3 つを使う)
21
+
22
+ ## 1. 原則 — 戻せることは止めない、戻せないことだけ止める
23
+
24
+ - プロジェクトの中で閉じていて戻せる変更(edit.json・captions.json・planning/・生成した素材の追加)は、
25
+ 見ていなくても**そのまま書いてよい**。書いたら §3 の `agent-action` を 1 件残す。アプリはそれを節目として git に撮るので、
26
+ あとで一覧から戻せる
27
+ - 戻せないこと・外に出ること・お金と権利に触ることは **門**(§2)で止まり、人の返事を待つ
28
+ - おまかせ度は「門以外でどれだけ確認を挟むか」だけを変える。門は**どのおまかせ度でも止まる**
29
+
30
+ | おまかせ度 | 門 | 門以外の確認 |
31
+ |---|---|---|
32
+ | `full-auto` そのまま | 止まる | 挟まない(進めて後で見る) |
33
+ | `checkpoint` 提案つき(既定) | 止まる | 書き出しの前に 1 回 + 作り方が宣言した確認点 |
34
+ | `collaborative` 一緒に作る | 止まる | 方針・素材・実行の要所ごと(edit-plan の承認手順) |
35
+
36
+ ## 2. 門の語彙(閉じた表)
37
+
38
+ | `gate` | 何のとき | 戻せない理由 |
39
+ |---|---|---|
40
+ | `publish` | 公開・投稿(YouTube 等へのアップロード、共有リンクの発行) | 外に出る |
41
+ | `lab-submit` | Lab・ストアへの提出 | 外に出る |
42
+ | `paid` | 有償の生成・購入(費用承認と同じ。見積を添える) | お金 |
43
+ | `external-send` | 素材や文書を外部のサービスへ送る(生成 API に人物の写真を送る等) | 外に出る |
44
+ | `delete-original` | 元素材(`assets/` の取り込んだ実体)の削除・上書き | 戻せない |
45
+ | `license` | ライセンスの疑義(出どころ不明・商用不可の疑い・規約に触れる道具の使用) | 権利 |
46
+ | `playbook-checkpoint` | 作り方(`playbook.json` の steps の `checkpoint`)が宣言した確認点 | 作った人が止めたい所 |
47
+ | `human-asked` | 人が「確認して」と頼んだ所 | 人の指定 |
48
+
49
+ - 表に無いものは門にしない(書き出しは上書きしない限り戻せるので門ではない)。新しい門は**この表に追記してから**使う
50
+ - lint の FAIL が直せないときは門ではなく報告(autonomy.md §4 の 3 のまま)
51
+
52
+ ## 3. 出来事(`.akari/events/` に 1 件 1 ファイル・追記のみ)
53
+
54
+ ファイル名は `<ISO 時刻の数字>-<id>.json`。既存の出来事(`report-generated` 等)と同じ置き場・同じ規律(既存の記録は変更しない)。
55
+
56
+ ```json
57
+ { "version": 1, "id": "act-01J…", "type": "agent-action", "occurredAt": "2026-10-16T01:23:45+09:00",
58
+ "actor": "partner", "summary": "無音を 12 か所カット", "files": ["edit.json"], "skill": "silence-cut" }
59
+ ```
60
+
61
+ | `type` | 書く人 | 必須の項目 | アプリの振る舞い |
62
+ |---|---|---|---|
63
+ | `agent-action` | エージェント | `actor`(`partner` 中の相棒 / `external` 外のエージェント / `unattended` 無人)・`summary`(1 行)・`files` | 節目として git に撮る(コミットの本文に summary) |
64
+ | `gate-requested` | エージェント | `gate`(§2)・`summary`・`detail`(任意。見積・送り先・対象ファイル)・`requestId` | 門の帯を出す(承認 / やめる) |
65
+ | `gate-resolved` | アプリ(人の操作)か人 | `requestId`・`decision`(`approved` / `declined`)・`by`(`human`) | 帯を消す。エージェントはこれを読んで進むかやめる |
66
+
67
+ - エージェントは `gate-requested` を書いたら、その `requestId` の `gate-resolved` が来るまで、その門の向こうの操作をしない
68
+ (門と関係の無い戻せる作業は続けてよい)
69
+ - `gate-resolved` を書けるのは人の操作だけ(アプリの帯のボタン・チャットの承認の語彙)。エージェントは書かない
70
+ - 読み手は tolerant reader(未知の type は無視・未知のフィールドは保持)
71
+
72
+ ## 4. やったこと一覧と戻し方
73
+
74
+ - 一覧はプロジェクトの節目(git のコミット)を新しい順に並べたもの。行 = 時刻・誰が(あなた / 相棒 / 外のエージェント / 無人)・
75
+ 1 行の要約・変わったファイル数。`agent-action` から撮った節目は summary と actor を出す
76
+ - 行の操作: **変更を見る**(既存の差分表示)/ **ここまで戻す**(その節目の直前の状態にファイルを戻し、それを新しい節目として撮る。
77
+ 履歴は消さない = 戻したこと自体も戻せる)
78
+ - 入口: プロジェクトのホームの「履歴」と、門の帯の「やったこと」。プロジェクトが git を持たないときは一覧を出さず理由を 1 行
79
+ - 素材の実体(`assets/` の取り込んだファイル)と書き出し(`exports/`)は履歴に入らない(`history-policy.mjs`)。
80
+ 戻すのは編集データと文書だけで、元素材は戻らない(だから削除・上書きは門)
81
+
82
+ ## 5. 非スコープ
83
+
84
+ - 通知(OS の通知・メール)。v0 はアプリを開いたときの帯と一覧だけ
85
+ - 門の承認をチャット以外の外部(スマホ等)から返す経路
86
+ - おまかせ度の新設・名前の変更
87
+ - `publish` / `lab-submit` の実体(アップロード機能そのもの)。v0 は門の語彙と帯だけを用意する
@@ -0,0 +1,82 @@
1
+ ---
2
+ lifecycle: draft
3
+ created: 2026-10-16
4
+ updated: 2026-10-16
5
+ ---
6
+
7
+ # recipes-and-connectors-v0(レシピと接続の 2 層)契約
8
+
9
+ - 日付: 2026-10-16
10
+ - 状態: **ドラフト・語の裁定待ち**(§6。形と層の分け方は確定方向、「レシピ」の語の割り当てが未裁定)
11
+ - 前提: `contract-2026-07-14-3d-bake-recipe.md`(道具のレシピ = 正本、焼いた出力 = 再生成できるキャッシュ)/
12
+ `contract-2026-09-13-extension-kit-v0.md`(キットの manifest。v0 の `kind` は `kit` のみ)/
13
+ `packages/schemas/connections.schema.json`(接続の台帳。`kind`: genai / image / video / tts / music / sns / analytics / notify)/
14
+ `packages/schemas/gen-models.json`(生成モデルの台帳)/ `contract-2026-10-15-playbook-v0.md`(作り方)/
15
+ `contract-2026-10-15-project-manifest-v0.md`(tool-output の `recipe` / `connector`)/
16
+ `contract-2026-10-16-automation-gates-v0.md`(`paid` / `external-send` / `license` の門)
17
+ - 発端: 外の道具・サービス(生成 AI・音楽生成・3D・TTS)が増えると、「何をしたいか」と「どのサービスで叩くか」が
18
+ 1 つの手順に混ざり、サービスを差し替えるたびに手順が壊れる。公式の API が無いサービスは自前のアダプタが要り、
19
+ それはプラグイン的な部品になる
20
+ - スコープ: 層の分け方・能力(capability)の語彙・接続の宣言・アダプタの配り方・manifest への記録。
21
+ **個々のアダプタの実装・非公式な叩き方の同梱・MCP サーバーは扱わない**(§5)
22
+
23
+ ## 1. 3 つの層
24
+
25
+ | 層 | 単位 | 例 | 持つもの | 正本 |
26
+ |---|---|---|---|---|
27
+ | 作り方(Playbook) | 動画 1 本の流れ | 15 秒 CM | 工程と確認点。工程からレシピを呼ぶ | `playbook.json` |
28
+ | **レシピ** | **シチュエーション(やりたいこと)** | BGM を 30 秒作る・商品を 3D で回す・口パクを付ける | 入力・ツマミ・出力の形・確認点・**要る能力** | `recipe` の宣言(§3) |
29
+ | **接続** | **サービス・ソフトウェア** | fal・ElevenLabs・Blender・(音楽生成のサービス) | 認証・叩き方(公式 API / 自前アダプタ / ローカル実行)・料金・規約・**持つ能力** | `connections.json` の 1 件 + アダプタ |
30
+
31
+ - レシピはサービスの名前を書かない。**能力**だけを書く(`needs: ["music-generation"]`)。どの接続で叩くかは、
32
+ その時に使える接続(認証済み・規約の確認済み)から選ぶ。差し替えてもレシピと作り方は変わらない
33
+ - 3D ベイクのレシピ(`assets/scene3d/<id>/scene.py`)は「レシピ = 3D で回す」と「接続 = Blender(ローカル実行)」が
34
+ 一体になった形として読む。分けるのは 2 つ目の 3D の道具が要ったとき
35
+
36
+ ## 2. 能力の語彙(閉じた表・追記して増やす)
37
+
38
+ | capability | 中身 | 既存の対応 |
39
+ |---|---|---|
40
+ | `image-generation` | 画像を作る | connections `image` / gen-models `kind: image` |
41
+ | `video-generation` | 動画を作る(i2v・t2v) | connections `video` / gen-models `kind: video` |
42
+ | `music-generation` | 曲を作る | connections `music` |
43
+ | `speech-synthesis` | 読み上げ | connections `tts` |
44
+ | `3d-render` | 3D を焼く | 3D ベイクレシピ(Blender headless) |
45
+ | `transcription` | 文字起こし | 既存の transcribe 系 |
46
+ | `publish` | 投稿する | connections `sns`(門 `publish`) |
47
+
48
+ ## 3. 宣言の形(最小)
49
+
50
+ レシピ:
51
+ ```json
52
+ { "version": 0, "kind": "akari-recipe", "id": "bgm-30s", "name": "BGM を 30 秒作る",
53
+ "needs": ["music-generation"], "inputs": { "mood": "string", "durationSec": 30 },
54
+ "outputs": [{ "kind": "audio", "role": "bgm" }], "checkpoint": "聞いて選ぶ", "gates": ["paid", "external-send"] }
55
+ ```
56
+ 接続(`connections.json` の既存の 1 件に足す項目):
57
+ ```json
58
+ { "id": "example-music", "kind": "music", "provides": ["music-generation"],
59
+ "adapter": { "type": "official-api" }, "terms": { "checked": "2026-10-16", "commercialUse": "allowed" } }
60
+ ```
61
+ - `adapter.type`: `official-api`(公式 API)/ `local`(手元で実行する道具)/ `custom`(自前のアダプタ)
62
+ - `custom` のアダプタは拡張キットで配る(キットの `kind` に `tool` を足す。キット契約の改訂が要る)
63
+ - `terms.checked` が無い接続は `license` の門で止まる
64
+
65
+ ## 4. 記録
66
+
67
+ レシピを回して出たファイルは、プロジェクトの manifest に `tool-output` として載せる:
68
+ `recipe: "<id>@<version>"`・`connector: "<接続の id>"`・`inputs_sha256`。同じレシピと入力で作り直せること、
69
+ 接続を差し替えたら何が変わったかが追えること。
70
+
71
+ ## 5. 非スコープと規律
72
+
73
+ - 公式の API が無いサービスを非公式に叩くアダプタは**同梱しない**。配る場合は個別に審査する(相手の規約に触れうる)
74
+ - 有償・外部送信は門(`paid` / `external-send`)。どのおまかせ度でも止まる
75
+ - MCP サーバーは作らない(2 種類目のエージェントか外部アプリが要ったとき)。外から叩く口はファイル契約と `akari` の副コマンド
76
+ - 個々のアダプタの実装・レシピの GUI
77
+
78
+ ## 6. 開けた問い(オーナー裁定待ち)
79
+
80
+ 1. **語**: 「レシピ」は既に `contract-2026-07-25-recipe-v0.md`(好みの凍結 `recipe.json`。ドラフト・スキル 2 本が参照)と
81
+ 3D ベイクのレシピで使われている。シチュエーションの層を「レシピ」と呼ぶなら、好みの凍結の側を「好み」などへ改名するか
82
+ 2. 置き場: レシピの宣言を キット(`recipes/<id>/recipe.json`)と手元(`~/.akari/…`)のどこに置くか(1 の語が決まってから)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.5.0-beta.3",
3
+ "version": "1.5.0-beta.4",
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": [
@@ -9,6 +9,8 @@
9
9
  "report-project.files.empty.text": "None",
10
10
  "report-project.skills.version.heading": "Template version",
11
11
  "report-project.skills.version.missing": "Not recorded",
12
+ "report-project.playbook.inactive.heading": "Settings (planning guidance only)",
13
+ "report-project.playbook.inactive.status": "Not yet applied (planning guidance)",
12
14
  "report-project.git.result.heading": "Git initialization result",
13
15
  "report-project.git.action.label": "Performed",
14
16
  "report-project.git.action.done": "Yes",
@@ -2292,7 +2292,11 @@
2292
2292
  "surfaces.home.playbookSource.all.label": "All",
2293
2293
  "surfaces.home.playbookSource.local.label": "Mine",
2294
2294
  "surfaces.home.playbookSource.channel.label": "Channel",
2295
+ "surfaces.home.playbookSource.lab.label": "Lab",
2295
2296
  "surfaces.home.playbookEmpty.message": "No Playbooks yet. Record one from a finished project with record-playbook.",
2297
+ "surfaces.home.playbookLabEmpty.message": "No Playbooks installed from Lab yet.",
2298
+ "surfaces.home.playbookLabCatalog.link": "Browse Lab Playbooks",
2299
+ "surfaces.home.playbookLabInstall.message": "Install one with akari store install <id>.",
2296
2300
  "surfaces.home.playbookSelectHint.message": "Select a Playbook to see its review sheet.",
2297
2301
  "surfaces.home.playbookRead.button": "Read contents",
2298
2302
  "surfaces.home.playbookApply.button": "Import and start",
@@ -9,6 +9,8 @@
9
9
  "report-project.files.empty.text": "なし",
10
10
  "report-project.skills.version.heading": "雛形バージョン",
11
11
  "report-project.skills.version.missing": "記録なし",
12
+ "report-project.playbook.inactive.heading": "設定値(まだ効かない・作業の目安)",
13
+ "report-project.playbook.inactive.status": "まだ効かない(作業の目安)",
12
14
  "report-project.git.result.heading": "git 初期化の結果",
13
15
  "report-project.git.action.label": "実施",
14
16
  "report-project.git.action.done": "実施",
@@ -2292,7 +2292,11 @@
2292
2292
  "surfaces.home.playbookSource.all.label": "すべて",
2293
2293
  "surfaces.home.playbookSource.local.label": "自分の",
2294
2294
  "surfaces.home.playbookSource.channel.label": "チャンネル",
2295
+ "surfaces.home.playbookSource.lab.label": "Lab",
2295
2296
  "surfaces.home.playbookEmpty.message": "作り方はまだありません。終わったプロジェクトから record-playbook で記録できます",
2297
+ "surfaces.home.playbookLabEmpty.message": "Lab から入れた作り方はまだありません。",
2298
+ "surfaces.home.playbookLabCatalog.link": "Lab の作り方を見る",
2299
+ "surfaces.home.playbookLabInstall.message": "akari store install <id> で入れられます。",
2296
2300
  "surfaces.home.playbookSelectHint.message": "作り方を選ぶと確認シートが出ます。",
2297
2301
  "surfaces.home.playbookRead.button": "中身を読む",
2298
2302
  "surfaces.home.playbookApply.button": "取り込んで始める",
@@ -546,13 +546,20 @@ ${REPORT_STYLE}
546
546
  <dt${attr('git.eligibility.label')}>${q('git.eligibility.label')}</dt><dd>${escapeHtml(report.git.eligibility)}</dd>
547
547
  <dt${attr('git.reason.label')}>${q('git.reason.label')}</dt><dd>${escapeHtml(gitReason)}</dd>
548
548
  </dl>
549
- </section>${report.missingTools?.length ? `\n <section><h2>無い道具</h2>${renderList(report.missingTools, '')}</section>` : ''}
549
+ </section>${report.missingTools?.length ? `\n <section><h2>無い道具</h2>${renderList(report.missingTools, '')}</section>` : ''}${renderInactiveSettingsSection(report.beforeCommit?.inactiveSettings, lang)}
550
550
  </main>
551
551
  ${dictionary}</body>
552
552
  </html>
553
553
  `;
554
554
  }
555
555
 
556
+ function renderInactiveSettingsSection(inactiveSettings, lang) {
557
+ if (!inactiveSettings?.length) return '';
558
+ const q = key => reportText('report-project', `report-project.${key}`, lang);
559
+ const attr = key => lang === 'en' ? ` data-i18n="report-project.${key}"` : '';
560
+ return `\n <section data-akari-playbook-inactive><h2${attr('playbook.inactive.heading')}>${q('playbook.inactive.heading')}</h2>${renderList(inactiveSettings.map(key => `${key}: ${q('playbook.inactive.status')}`), q('files.empty.text'))}</section>`;
561
+ }
562
+
556
563
  function renderPlaybookSheetHtml(playbook, missingTools) {
557
564
  const row = (name, value) => `<dt>${escapeHtml(name)}</dt><dd>${value}</dd>`;
558
565
  const list = (items) => items.length ? `<ul>${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>` : '<p>なし</p>';
@@ -560,7 +567,8 @@ function renderPlaybookSheetHtml(playbook, missingTools) {
560
567
  const placeholders = (playbook.placeholders ?? []).map(item => `${item.role} / ${item.kind} / 見本 ${item.sample ? `あり (${item.sample.category}/${item.sample.id})` : 'なし'}`);
561
568
  const beats = (playbook.beats ?? []).map(beat => `${beat.at}–${beat.at + beat.duration} 秒 / ${beat.title}${beat.note ? ` / ${beat.note}` : ''}`);
562
569
  const parts = (playbook.parts ?? []).map(item => `${item.kind}: ${item.id} / ${item.source} / ${item.license} / Pro ${item.tier === 'pro' ? '要' : item.tier === 'free' ? '不要' : '不明'}`);
563
- const settings = Object.entries(playbook.settings).map(([key, value]) => `${key}: ${JSON.stringify(value)}`);
570
+ const inactiveSettings = inactivePlaybookSettings(playbook.settings);
571
+ const settings = Object.entries(playbook.settings).map(([key, value]) => `${key}: ${JSON.stringify(value)}${inactiveSettings.some(inactive => inactive === key || inactive.startsWith(`${key}.`)) ? '(まだ効かない・作業の目安)' : ''}`);
564
572
  const provenance = playbook.provenance;
565
573
  const applied = provenance.appliedFrom;
566
574
  const origin = `${provenance.fromProject} / ${provenance.recordedAt} / AKARI ${provenance.akariVersion}`
@@ -692,7 +700,16 @@ async function toolExists(name) {
692
700
  return false;
693
701
  }
694
702
 
695
- function applySettings(edit, settings) {
703
+ function inactivePlaybookSettings(settings) {
704
+ return [
705
+ ...(settings.captions?.styleUid ? ['captions.styleUid'] : []),
706
+ ...(settings.captions?.position ? ['captions.position'] : []),
707
+ ...(settings.transitions?.default ? ['transitions.default'] : []),
708
+ ...(settings.audio?.roles ? ['audio.roles'] : [])
709
+ ];
710
+ }
711
+
712
+ function applySettings(edit, intake, settings) {
696
713
  const aspect = settings.aspect;
697
714
  if (typeof aspect === 'string' && /^\d+:\d+$/.test(aspect)) {
698
715
  const [widthRatio, heightRatio] = aspect.split(':').map(Number);
@@ -703,17 +720,14 @@ function applySettings(edit, settings) {
703
720
  }
704
721
  }
705
722
  if (Number.isInteger(settings.fps) && settings.fps > 0) edit.output.fps = settings.fps;
706
- if (Number.isFinite(settings.targetDurationSec)) edit.output.target_duration_sec = settings.targetDurationSec;
707
- if (settings.captions?.styleUid) edit.output.caption_style_uid = settings.captions.styleUid;
708
- if (settings.captions?.position) edit.output.caption_position = settings.captions.position;
709
- if (settings.transitions?.default) edit.output.default_transition = settings.transitions.default;
710
- if (settings.audio?.roles) {
711
- edit.audio ??= {};
712
- edit.audio.role_gains_db = { ...settings.audio.roles };
723
+ if (Number.isFinite(settings.targetDurationSec) && settings.targetDurationSec > 0) {
724
+ intake.target ??= {};
725
+ intake.target.duration_s = settings.targetDurationSec;
726
+ intake.target.keep_length = false;
713
727
  }
714
728
  }
715
729
 
716
- function addPlaceholders(edit, placeholders, beats) {
730
+ function addPlaceholders(edit, placeholders, beats, targetDurationSec) {
717
731
  const fps = edit.output.fps;
718
732
  const hasBeats = Array.isArray(beats) && beats.length > 0;
719
733
  const track = { id: 'playbook-placeholders', name: '仮枠(映像)', lane: 'visual', items: [] };
@@ -721,8 +735,11 @@ function addPlaceholders(edit, placeholders, beats) {
721
735
  let at = 0;
722
736
  if (hasBeats) {
723
737
  for (const beat of [...beats].sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
724
- const start = Math.round(beat.at * fps);
725
- const duration = Math.max(1, Math.round(beat.duration * fps));
738
+ // 始まりと終わりをそれぞれコマに丸めて差を取る(別々に丸めると小数の秒で 1 コマ重なる)。
739
+ // 前の内訳と重なる書き方でも、同じ段で重ならないよう前の終わりから始める
740
+ const start = Math.max(at, Math.round(beat.at * fps));
741
+ const end = Math.round((beat.at + beat.duration) * fps);
742
+ const duration = Math.max(1, end - start);
726
743
  track.items.push({
727
744
  id: `playbook-${beat.id}`, at: start, duration, name: beat.title,
728
745
  source: { kind: 'group', canvas: { origin: 'plan', durationMode: 'fixed', intent: beat.note || beat.title,
@@ -750,7 +767,7 @@ function addPlaceholders(edit, placeholders, beats) {
750
767
  if (track.items.length) edit.tracks.push(track);
751
768
  for (const placeholder of placeholders) {
752
769
  if (placeholder.kind !== 'audio') continue;
753
- const duration = at || Math.max(1, Math.round((placeholder.durationHintSec ?? edit.output.target_duration_sec ?? 5) * fps));
770
+ const duration = at || Math.max(1, Math.round((placeholder.durationHintSec ?? targetDurationSec ?? 5) * fps));
754
771
  audioTrack.items.push({
755
772
  id: `playbook-${placeholder.id}`, at: 0, duration,
756
773
  name: placeholder.sample
@@ -809,19 +826,26 @@ export async function applyPlaybook({ projectDir, playbookDir, now = new Date(),
809
826
  }
810
827
  const planning = path.join(destination, 'planning');
811
828
  const editPath = path.join(destination, 'edit.json');
829
+ const intakePath = path.join(destination, '.akari', 'intake.json');
812
830
  const edit = JSON.parse(await fs.readFile(editPath, 'utf8'));
831
+ const intake = JSON.parse(await fs.readFile(intakePath, 'utf8'));
813
832
  if (edit.version !== 2) throw new Error('作り方の適用には edit.json v2 が必要です');
814
833
  const playbookJsonPath = path.join(planning, 'playbook.json');
815
834
  const playbookMdPath = path.join(planning, 'playbook.md');
816
835
  const sheetPath = path.join(destination, '.akari', 'reports', 'playbook-sheet.html');
836
+ const creationReportPath = path.join(destination, '.akari', 'reports', 'create-project-report.html');
837
+ const existingCreationReport = await fs.readFile(creationReportPath, 'utf8').catch(error => {
838
+ if (error.code === 'ENOENT') return null;
839
+ throw error;
840
+ });
817
841
  for (const file of [playbookJsonPath, playbookMdPath, sheetPath]) {
818
842
  try { await fs.access(file); throw new Error(`既存ファイルは上書きしません: ${file}`); }
819
843
  catch (error) { if (error.code !== 'ENOENT') throw error; }
820
844
  }
821
845
  playbook.provenance.appliedFrom = { path: source, playbookVersion: playbook.playbookVersion, appliedAt: now.toISOString() };
822
- applySettings(edit, playbook.settings);
846
+ applySettings(edit, intake, playbook.settings);
823
847
  const activeBeats = playbook.beats?.length ? playbook.beats : undefined;
824
- const placeholdersAdded = addPlaceholders(edit, playbook.placeholders ?? [], activeBeats);
848
+ const placeholdersAdded = addPlaceholders(edit, playbook.placeholders ?? [], activeBeats, playbook.settings.targetDurationSec);
825
849
  const scriptPath = path.join(planning, 'script.json');
826
850
  const existingScript = activeBeats ? await fs.readFile(scriptPath, 'utf8').then(JSON.parse).catch(error => {
827
851
  if (error.code === 'ENOENT') return null;
@@ -835,7 +859,17 @@ export async function applyPlaybook({ projectDir, playbookDir, now = new Date(),
835
859
  await fs.writeFile(playbookMdPath, markdown, { flag: 'wx' });
836
860
  await writeJsonAtomic(playbookJsonPath, playbook);
837
861
  await writeJsonAtomic(editPath, edit);
862
+ await writeJsonAtomic(intakePath, intake);
838
863
  if (script) await writeJsonAtomic(scriptPath, script);
839
864
  await fs.writeFile(sheetPath, renderReportHtml(null, { playbook, missingTools }), { flag: 'wx' });
840
- return { playbookJsonPath, playbookMdPath, sheetPath, placeholdersAdded, missingTools };
865
+ const inactiveSettings = inactivePlaybookSettings(playbook.settings);
866
+ if (existingCreationReport && inactiveSettings.length > 0
867
+ && !existingCreationReport.includes('data-akari-playbook-inactive')
868
+ && existingCreationReport.includes('</main>')) {
869
+ const lang = reportLanguage(/<html lang="([^"]+)"/.exec(existingCreationReport)?.[1]);
870
+ const section = renderInactiveSettingsSection(inactiveSettings, lang);
871
+ await fs.writeFile(creationReportPath, existingCreationReport.replace('</main>', `${section}\n </main>`), 'utf8');
872
+ }
873
+ return { playbookJsonPath, playbookMdPath, sheetPath, placeholdersAdded, missingTools,
874
+ inactiveSettings };
841
875
  }
@@ -49,6 +49,7 @@ validateManifest(manifest);
49
49
  validateRequiredFiles();
50
50
  validateRuntimes(manifest);
51
51
  validateSkills(manifest);
52
+ validatePlaybooks(manifest);
52
53
  validateTemplates(manifest);
53
54
  validateAssets(manifest);
54
55
  validateDocs(manifest);
@@ -89,7 +90,7 @@ function validateManifest(value) {
89
90
  }
90
91
 
91
92
  const required = ["schemaVersion", "id", "kind", "name", "version", "requires", "license"];
92
- const optional = ["skills", "templates", "assets", "docs", "provenance"];
93
+ const optional = ["skills", "templates", "assets", "docs", "playbooks", "provenance"];
93
94
  requireFields(value, required, "manifest");
94
95
  rejectUnknownFields(value, [...required, ...optional], "manifest");
95
96
 
@@ -108,6 +109,11 @@ function validateManifest(value) {
108
109
  validateNonEmptyString(entry.dir, `${label}.dir`);
109
110
  validateNonEmptyString(entry.name, `${label}.name`);
110
111
  });
112
+ validateObjectArray(value.playbooks, "playbooks", ["dir"], (entry, label) => {
113
+ if (typeof entry.dir !== "string" || !/^playbooks\/[a-z0-9][a-z0-9-]*$/u.test(entry.dir)) {
114
+ fail(`${label}.dir は playbooks/<id> である必要があります`);
115
+ }
116
+ });
111
117
  validateObjectArray(value.templates, "templates", ["path", "for", "label"], (entry, label) => {
112
118
  validateNonEmptyString(entry.path, `${label}.path`);
113
119
  if (entry.for !== "world-map") fail(`${label}.for は world-map である必要があります`);
@@ -270,6 +276,44 @@ function validateTemplates(value) {
270
276
  }
271
277
  }
272
278
 
279
+ function validatePlaybooks(value) {
280
+ if (!Array.isArray(value?.playbooks)) return;
281
+ const validator = path.join(ownDir, '..', 'validate-playbook.mjs');
282
+ const seen = new Set();
283
+ for (const [index, entry] of value.playbooks.entries()) {
284
+ if (!isPlainObject(entry) || typeof entry.dir !== 'string'
285
+ || !/^playbooks\/[a-z0-9][a-z0-9-]*$/u.test(entry.dir)) continue;
286
+ const id = path.posix.basename(entry.dir);
287
+ if (seen.has(id)) { fail(`playbooks[${index}].dir が重複しています: ${id}`); continue; }
288
+ seen.add(id);
289
+ const dir = path.resolve(kitDir, entry.dir);
290
+ if (!isDirectory(dir) || !isInside(kitRealDir, fs.realpathSync(dir))) {
291
+ fail(`playbooks[${index}].dir が見つからないか kit-dir の外を指します: ${entry.dir}`);
292
+ continue;
293
+ }
294
+ const json = path.join(dir, 'playbook.json');
295
+ const markdown = path.join(dir, 'playbook.md');
296
+ if (!isRegularFile(json) || !isRegularFile(markdown)
297
+ || !isInside(kitRealDir, fs.realpathSync(json))
298
+ || !isInside(kitRealDir, fs.realpathSync(markdown))) {
299
+ fail(`playbooks[${index}] に playbook.json と playbook.md が必要です: ${entry.dir}`);
300
+ continue;
301
+ }
302
+ try {
303
+ if (JSON.parse(fs.readFileSync(json, 'utf8')).id !== id) {
304
+ fail(`playbooks[${index}] の id がフォルダ名と一致しません: ${id}`);
305
+ }
306
+ } catch (error) {
307
+ fail(`playbooks[${index}] の playbook.json を読めません: ${messageOf(error)}`);
308
+ continue;
309
+ }
310
+ const result = spawnSync(process.execPath, [validator, json, markdown], { encoding: 'utf8' });
311
+ if (result.status !== 0) {
312
+ fail(`playbooks[${index}] が validate-playbook に失敗しました: ${(result.stderr || result.error?.message || '').trim()}`);
313
+ }
314
+ }
315
+ }
316
+
273
317
  function validateAssets(value) {
274
318
  if (!Array.isArray(value?.assets)) return;
275
319
  const validatorPath = path.join(ownDir, "validate-asset.mjs");
@@ -53,6 +53,17 @@
53
53
  }
54
54
  }
55
55
  },
56
+ "playbooks": {
57
+ "type": "array",
58
+ "items": {
59
+ "type": "object",
60
+ "additionalProperties": false,
61
+ "required": ["dir"],
62
+ "properties": {
63
+ "dir": { "type": "string", "pattern": "^playbooks/[a-z0-9][a-z0-9-]*$" }
64
+ }
65
+ }
66
+ },
56
67
  "templates": {
57
68
  "type": "array",
58
69
  "items": {
@@ -0,0 +1,44 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import Ajv2020 from 'ajv/dist/2020.js';
9
+
10
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
+ const sample = path.resolve(root, '../project-scaffold/test/fixtures/playbook-sample');
12
+ const schema = JSON.parse(readFileSync(path.join(root, 'kit-manifest.schema.json'), 'utf8'));
13
+ const validate = new Ajv2020({ strict: false }).compile(schema);
14
+
15
+ test('kit playbooks validate contents, matching id, and safe directory', () => {
16
+ const temp = mkdtempSync(path.join(tmpdir(), 'akari-kit-playbook-'));
17
+ const id = 'test-playbook';
18
+ const dir = path.join(temp, 'playbooks', id);
19
+ const manifest = { schemaVersion: 1, id: 'test-kit', kind: 'kit', name: 'Test', version: 1,
20
+ requires: { cli: '>=1.4.0' }, license: 'CC0-1.0', playbooks: [{ dir: `playbooks/${id}` }] };
21
+ const run = () => spawnSync(process.execPath, [path.join(root, 'bin', 'validate-kit-manifest.mjs'), temp], { encoding: 'utf8' });
22
+ try {
23
+ mkdirSync(dir, { recursive: true });
24
+ cpSync(path.join(sample, 'playbook.md'), path.join(dir, 'playbook.md'));
25
+ const playbook = JSON.parse(readFileSync(path.join(sample, 'playbook.json'), 'utf8'));
26
+ playbook.id = id;
27
+ const save = () => writeFileSync(path.join(dir, 'playbook.json'), JSON.stringify(playbook));
28
+ const saveManifest = () => writeFileSync(path.join(temp, 'manifest.json'), JSON.stringify(manifest));
29
+ save(); saveManifest();
30
+ writeFileSync(path.join(temp, 'README.md'), '# Test\n');
31
+ writeFileSync(path.join(temp, 'LICENSE.md'), '# CC0\n');
32
+ assert.equal(validate(manifest), true, JSON.stringify(validate.errors));
33
+ assert.equal(run().status, 0, run().stderr);
34
+ playbook.id = 'different'; save();
35
+ assert.match(run().stderr, /id がフォルダ名と一致しません/);
36
+ playbook.id = id; playbook.kind = 'wrong'; save();
37
+ assert.match(run().stderr, /validate-playbook に失敗/);
38
+ manifest.playbooks[0].dir = '../outside'; saveManifest();
39
+ assert.equal(validate(manifest), false);
40
+ assert.match(run().stderr, /playbooks\[0\]\.dir/);
41
+ } finally {
42
+ rmSync(temp, { recursive: true, force: true });
43
+ }
44
+ });
@@ -18,7 +18,7 @@ description: Record a finished AKARI Video project's workflow as planning/playbo
18
18
  ## Procedure
19
19
 
20
20
  1. Inspect `decisions.json`, the documents in `planning/`, and report types in `.akari/reports/` for the steps and human checkpoints. If the steps cannot be recovered, use four defaults: place media, confirm script, refine cut, export. Mark uncertain `what` text `要確認`.
21
- 2. Extract starting values from `edit.json`: aspect, fps, target duration, caption style, default transition, and audio roles. Record settings as values to start the next project, not as a copy of every cut.
21
+ 2. Extract aspect and fps from `edit.json`, and target duration from `.akari/intake.json` (`target.duration_s`), falling back to the used timeline length when no target is set. Caption style/position, default transition, and audio roles currently have no project-level default consumed when new work is created. Preserve those planning values from an existing `planning/playbook.json` when recording an applied playbook; otherwise infer transition from used cuts and audio roles from item gains where possible. Record settings as values to start the next project, not as a copy of every cut.
22
22
  3. Examine `edit.json` sources and available `analysis.json` tags or speaker observations to name placeholder roles. Use `.akari/asset-references.json` and `.akari/style-usage.json` to identify reusable parts and licenses. Keep only `builtin`, `lab`, and `ai` parts. Check used skills and recipe `requires` for required tools and skills. Derive `beats` from plan lines in `planning/script.json` when present; otherwise use chapter headings in captions or `planning/plan.md` together with visual cut boundaries. Write the `## 内訳` table in `playbook.md`.
23
23
  4. Run `node skills/record-playbook/bin/record-playbook.mjs <project-root> --name <name> --category <category> [--license <license>]` from a repository checkout, or use `.claude/skills/record-playbook/bin/record-playbook.mjs` from a scaffolded project. The helper writes deterministic structure and `要確認` defaults. If a video exists in `exports/`, it uses `ffmpeg` and `ffprobe` from PATH to extract 3–5 representative PNG frames into `planning/playbook-preview/frames/` and records relative paths in `preview.frames`. `AKARI_FFMPEG` and `AKARI_FFPROBE` can override those commands. If no export or tool is available, or extraction fails, the helper warns and the outcome section says to record again after export. It clears old PNG frames before each extraction. You may review and replace frame choices while keeping 3–5 frames, the playbook aspect ratio, and a maximum long edge of 640 px. Refine the step wording and role names in both files where evidence supports better names; preserve role grouping and validate after editing with `node packages/schemas/validate-playbook.mjs <project-root>/planning/playbook.json` when the repository checkout is available.
24
24
  5. Show the playbook and preview frames to the person, then run `node skills/record-playbook/bin/record-playbook.mjs <project-root> --install` if they want the reviewed files copied to their local shelf at `~/.akari/playbooks/<id>/`. This standalone install does not record again, so reviewed frame choices survive. The helper changes preview paths to `preview/frames/0N.png` in the shelf copy and replaces an existing copy only when `playbookVersion` is higher.
@@ -147,22 +147,22 @@ function defaultSteps(decisions) {
147
147
  ];
148
148
  }
149
149
 
150
- function settingsFromEdit(edit) {
150
+ function settingsFromEdit(edit, intake, priorSettings = {}) {
151
151
  const width = edit.output?.width;
152
152
  const height = edit.output?.height;
153
153
  const aspect = width && height ? (width < height ? '9:16' : width === height ? '1:1' : '16:9') : '16:9';
154
154
  const fps = Number.isInteger(edit.output?.fps) ? edit.output.fps : 30;
155
155
  const seconds = asArray(edit.tracks).flatMap(track => asArray(track.items).map(item => ((item.at ?? 0) + (item.duration ?? 0)) / fps));
156
- const gains = { ...(edit.audio?.role_gains_db ?? {}) };
156
+ const gains = { ...(priorSettings.audio?.roles ?? {}) };
157
157
  for (const track of asArray(edit.tracks)) for (const item of asArray(track.items)) {
158
158
  if (item.role && Number.isFinite(item.gain_db) && gains[item.role] === undefined) gains[item.role] = item.gain_db;
159
159
  }
160
160
  const transition = asArray(edit.tracks).flatMap(track => asArray(track.items))
161
161
  .map(item => item.source?.transition_out?.kind ?? item.source?.transition_out?.type).find(Boolean);
162
- return { aspect, fps, targetDurationSec: Math.ceil(Math.max(0, ...seconds)),
163
- ...(edit.output?.caption_style_uid ? { captions: { styleUid: edit.output.caption_style_uid,
164
- ...(edit.output.caption_position ? { position: edit.output.caption_position } : {}) } } : {}),
165
- transitions: { default: edit.output?.default_transition ?? transition ?? 'cut' },
162
+ return { aspect, fps, targetDurationSec: Number.isFinite(intake.target?.duration_s) && intake.target.duration_s > 0
163
+ ? intake.target.duration_s : Math.ceil(Math.max(0, ...seconds)),
164
+ ...(priorSettings.captions ? { captions: priorSettings.captions } : {}),
165
+ transitions: { default: priorSettings.transitions?.default ?? transition ?? 'cut' },
166
166
  ...(Object.keys(gains).length ? { audio: { roles: gains } } : {}) };
167
167
  }
168
168
 
@@ -393,6 +393,7 @@ export async function recordPlaybook(projectRoot, { name, category, license = 'C
393
393
  if (!name || !categories.has(category)) throw new Error('name と category(定義済み語彙)が必要です');
394
394
  const project = path.resolve(projectRoot);
395
395
  const edit = await readOptional(path.join(project, 'edit.json'));
396
+ const intake = await readOptional(path.join(project, '.akari', 'intake.json'));
396
397
  const analysis = await readOptional(path.join(project, 'analysis.json'));
397
398
  const references = await readOptional(path.join(project, '.akari', 'asset-references.json'));
398
399
  const styles = await readOptional(path.join(project, '.akari', 'style-usage.json'));
@@ -417,7 +418,7 @@ export async function recordPlaybook(projectRoot, { name, category, license = 'C
417
418
  playbookVersion: (prior?.playbookVersion ?? 0) + 1, depth: 2, category,
418
419
  tags: { form: [], length: [], technique: [], tools: [], style: [], industry: [], materials: [], scale: [] },
419
420
  requires: { cli: `>=${packageJson.version ?? '0.0.0'}`, tools, skills },
420
- steps, settings: settingsFromEdit(edit),
421
+ steps, settings: settingsFromEdit(edit, intake, prior?.settings),
421
422
  placeholders: collectPlaceholders(edit, analysis, references), parts: await collectParts(project, references, styles),
422
423
  license, provenance: { fromProject: path.basename(project), recordedAt: now.toISOString(),
423
424
  akariVersion: packageJson.version ?? '0.0.0', recordedBy: 'record-playbook' }