akari-video 1.1.1 → 1.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/package.json +1 -1
  2. package/src/doctor-command.mjs +7 -2
  3. package/src/migrate-command.mjs +8 -3
  4. package/vendor/.akari-capability-sources.json +4 -0
  5. package/vendor/docs/contract-2026-07-13-asset-library.md +1 -1
  6. package/vendor/docs/contract-2026-07-25-project-structure-v0.md +3 -0
  7. package/vendor/docs/contract-2026-08-30-edit-json-v2-object-tree-v0.md +4 -0
  8. package/vendor/packages/akari-ear/README.md +9 -0
  9. package/vendor/packages/akari-ear/package.json +11 -0
  10. package/vendor/packages/akari-launcher/package.json +1 -1
  11. package/vendor/packages/akari-vibe/README.md +2 -2
  12. package/vendor/packages/asset-resolver/src/catalog.mjs +21 -4
  13. package/vendor/packages/asset-resolver/src/project-references.mjs +5 -0
  14. package/vendor/packages/asset-resolver/src/resolve.mjs +103 -1
  15. package/vendor/packages/asset-resolver/src/state.mjs +1 -1
  16. package/vendor/packages/asset-resolver/test/catalog-and-state.test.mjs +26 -1
  17. package/vendor/packages/asset-resolver/test/fixtures/fragment-root-timing.json +35 -0
  18. package/vendor/packages/asset-resolver/test/fragment-project-copy.test.mjs +45 -0
  19. package/vendor/packages/asset-resolver/test/project-references.test.mjs +6 -1
  20. package/vendor/packages/asset-resolver/test/resolve-fast.test.mjs +12 -0
  21. package/vendor/packages/edit-lint/src/engine-capabilities.json +1 -0
  22. package/vendor/packages/edit-lint/src/lint/captions.mjs +9 -0
  23. package/vendor/packages/edit-lint/src/lint/edit-v2.mjs +13 -0
  24. package/vendor/packages/edit-lint/src/lint/overlays.mjs +4 -26
  25. package/vendor/packages/edit-store/lib/caption-display.js +4 -2
  26. package/vendor/packages/edit-store/lib/caption-timeline.d.ts +1 -0
  27. package/vendor/packages/edit-store/lib/caption-timeline.js +43 -23
  28. package/vendor/packages/edit-store/lib/cut-ranges.js +500 -31
  29. package/vendor/packages/edit-store/lib/edit-v2.d.ts +8 -0
  30. package/vendor/packages/edit-store/lib/edit-v2.js +20 -2
  31. package/vendor/packages/edit-store/lib/empty-edit-v2.d.ts +8 -0
  32. package/vendor/packages/edit-store/lib/empty-edit-v2.js +17 -0
  33. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.d.ts +2 -2
  34. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.js +2 -1
  35. package/vendor/packages/edit-store/lib/index.d.ts +1 -0
  36. package/vendor/packages/edit-store/lib/index.js +1 -0
  37. package/vendor/packages/edit-store/lib/internal-model.js +2 -49
  38. package/vendor/packages/edit-store/lib/migrate/index.d.ts +2 -0
  39. package/vendor/packages/edit-store/lib/migrate/index.js +14 -1
  40. package/vendor/packages/edit-store/lib/webview-kernel.js +63 -57
  41. package/vendor/packages/frame-scene/README.md +62 -0
  42. package/vendor/packages/frame-scene/package.json +9 -0
  43. package/vendor/packages/overlay-runtime/src/fragment-source-write.mjs +16 -1
  44. package/vendor/packages/overlay-runtime/src/handle-geometry.js +50 -5
  45. package/vendor/packages/overlay-runtime/src/interaction.js +217 -151
  46. package/vendor/packages/project-scaffold/src/index.mjs +2 -6
  47. package/vendor/packages/render-cut/README.ja.md +2 -0
  48. package/vendor/packages/render-cut/README.md +2 -0
  49. package/vendor/packages/schemas/asset-meta.schema.json +25 -0
  50. package/vendor/packages/schemas/bin/validate-captions.mjs +6 -0
  51. package/vendor/packages/schemas/bin/validate-frame-scene.mjs +24 -0
  52. package/vendor/packages/schemas/edit.schema.json +9 -0
  53. package/vendor/packages/schemas/engine-capabilities.json +1 -0
  54. package/vendor/packages/schemas/fixtures/asset-meta-frame-scene/model-invalid-unit.json +129 -0
  55. package/vendor/packages/schemas/fixtures/asset-meta-frame-scene/model-valid.json +129 -0
  56. package/vendor/packages/schemas/fixtures/asset-meta-frame-scene/world-invalid-kind.json +114 -0
  57. package/vendor/packages/schemas/fixtures/asset-meta-frame-scene/world-valid.json +114 -0
  58. package/vendor/packages/schemas/fixtures/frame-scene/invalid/anchor.unresolved/expected.json +6 -0
  59. package/vendor/packages/schemas/fixtures/frame-scene/invalid/anchor.unresolved/scene.json +37 -0
  60. package/vendor/packages/schemas/fixtures/frame-scene/invalid/limit.objects/expected.json +6 -0
  61. package/vendor/packages/schemas/fixtures/frame-scene/invalid/limit.objects/scene.json +2029 -0
  62. package/vendor/packages/schemas/fixtures/frame-scene/invalid/object.id.duplicate/expected.json +6 -0
  63. package/vendor/packages/schemas/fixtures/frame-scene/invalid/object.id.duplicate/scene.json +37 -0
  64. package/vendor/packages/schemas/fixtures/frame-scene/invalid/object.rotation.exclusive/expected.json +6 -0
  65. package/vendor/packages/schemas/fixtures/frame-scene/invalid/object.rotation.exclusive/scene.json +30 -0
  66. package/vendor/packages/schemas/fixtures/frame-scene/invalid/object.size.exclusive/expected.json +6 -0
  67. package/vendor/packages/schemas/fixtures/frame-scene/invalid/object.size.exclusive/scene.json +30 -0
  68. package/vendor/packages/schemas/fixtures/frame-scene/invalid/on.unresolved/expected.json +6 -0
  69. package/vendor/packages/schemas/fixtures/frame-scene/invalid/on.unresolved/scene.json +29 -0
  70. package/vendor/packages/schemas/fixtures/frame-scene/invalid/pose.joint.unknown/expected.json +6 -0
  71. package/vendor/packages/schemas/fixtures/frame-scene/invalid/pose.joint.unknown/scene.json +35 -0
  72. package/vendor/packages/schemas/fixtures/frame-scene/invalid/pose.kind.invalid/expected.json +6 -0
  73. package/vendor/packages/schemas/fixtures/frame-scene/invalid/pose.kind.invalid/scene.json +34 -0
  74. package/vendor/packages/schemas/fixtures/frame-scene/invalid/scene.empty/expected.json +6 -0
  75. package/vendor/packages/schemas/fixtures/frame-scene/invalid/scene.empty/scene.json +16 -0
  76. package/vendor/packages/schemas/fixtures/frame-scene/invalid/unsupported_version/expected.json +6 -0
  77. package/vendor/packages/schemas/fixtures/frame-scene/invalid/unsupported_version/scene.json +28 -0
  78. package/vendor/packages/schemas/fixtures/frame-scene/valid/at-limits/expected.json +4 -0
  79. package/vendor/packages/schemas/fixtures/frame-scene/valid/at-limits/scene.json +1 -0
  80. package/vendor/packages/schemas/fixtures/frame-scene/valid/camera-a-b/expected.json +4 -0
  81. package/vendor/packages/schemas/fixtures/frame-scene/valid/camera-a-b/scene.json +36 -0
  82. package/vendor/packages/schemas/fixtures/frame-scene/valid/empty-stage-scribble/expected.json +4 -0
  83. package/vendor/packages/schemas/fixtures/frame-scene/valid/empty-stage-scribble/scene.json +30 -0
  84. package/vendor/packages/schemas/fixtures/frame-scene/valid/hdri-hidden/expected.json +4 -0
  85. package/vendor/packages/schemas/fixtures/frame-scene/valid/hdri-hidden/scene.json +33 -0
  86. package/vendor/packages/schemas/fixtures/frame-scene/valid/minimal/expected.json +4 -0
  87. package/vendor/packages/schemas/fixtures/frame-scene/valid/minimal/scene.json +28 -0
  88. package/vendor/packages/schemas/fixtures/frame-scene/valid/multi-annotation-anchor/expected.json +4 -0
  89. package/vendor/packages/schemas/fixtures/frame-scene/valid/multi-annotation-anchor/scene.json +70 -0
  90. package/vendor/packages/schemas/fixtures/frame-scene/valid/multi-scene-set/expected.json +4 -0
  91. package/vendor/packages/schemas/fixtures/frame-scene/valid/multi-scene-set/scene.json +40 -0
  92. package/vendor/packages/schemas/fixtures/frame-scene/valid/pose-targets/expected.json +4 -0
  93. package/vendor/packages/schemas/fixtures/frame-scene/valid/pose-targets/scene.json +40 -0
  94. package/vendor/packages/schemas/fixtures/generation-meta/frame-invalid-hash.json +84 -0
  95. package/vendor/packages/schemas/fixtures/generation-meta/frame-invalid-kind.json +84 -0
  96. package/vendor/packages/schemas/fixtures/generation-meta/frame-invalid-role.json +84 -0
  97. package/vendor/packages/schemas/fixtures/generation-meta/frame-valid.json +84 -0
  98. package/vendor/packages/schemas/frame-scene.schema.json +99 -0
  99. package/vendor/packages/schemas/generation-meta.schema.json +39 -0
  100. package/vendor/packages/schemas/test/edit-v2-schema.test.mjs +15 -0
  101. package/vendor/packages/schemas/test/engine-capabilities.test.mjs +1 -1
  102. package/vendor/packages/schemas/test/frame-scene-fixtures.test.mjs +18 -0
  103. package/vendor/packages/schemas/test/meta-frame-model3d-compat.test.mjs +25 -0
  104. package/vendor/packages/schemas/test/validate-captions.test.mjs +14 -0
  105. package/vendor/skills/overlay-authoring/SKILL.md +18 -1
  106. package/vendor/skills/overlay-authoring/table.md +17 -0
  107. package/vendor/skills/overlay-authoring/telop.md +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.1.1",
3
+ "version": "1.2.0-beta.1",
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": {
@@ -60,9 +60,14 @@ export function formatDoctorReport(report) {
60
60
  '',
61
61
  `判定: ${report.verdict}`,
62
62
  ];
63
- if (report.next_steps.length > 0) {
63
+ const steps = [...report.next_steps];
64
+ if (report.cli.node?.runtime === 'electron' && report.cli.node.exec_path && report.render_cut.path) {
65
+ const quote = (value) => `"${value.replaceAll('"', '\\"')}"`;
66
+ steps.push(`Node が PATH に無い場合の書き出し: ELECTRON_RUN_AS_NODE=1 ${quote(report.cli.node.exec_path)} ${quote(report.render_cut.path)} <project-root>`);
67
+ }
68
+ if (steps.length > 0) {
64
69
  lines.push('次の手順:');
65
- lines.push(...report.next_steps.map((step) => ` - ${step}`));
70
+ lines.push(...steps.map((step) => ` - ${step}`));
66
71
  }
67
72
  return lines.join('\n');
68
73
  }
@@ -64,11 +64,14 @@ export async function runMigrateCommand(args, options = {}) {
64
64
  }
65
65
  if (parsed.json) {
66
66
  log(JSON.stringify({
67
- ok: true, dryRun: parsed.dryRun, version: proposal.version,
67
+ ok: true, dryRun: parsed.dryRun, version: proposal.emptyProject ? null : proposal.version,
68
+ ...(proposal.emptyProject ? { emptyProject: true } : {}),
68
69
  filePath: proposal.filePath, backupPath: proposal.backupPath, changes: proposal.changes
69
70
  }));
70
71
  } else {
71
- log(`変換対象: ${proposal.filePath} (version ${proposal.version}${proposal.version === 2 ? ' 正規化' : ' -> 2'})`);
72
+ log(proposal.emptyProject
73
+ ? `初期化対象: ${proposal.filePath} (空の edit.json -> version 2)`
74
+ : `変換対象: ${proposal.filePath} (version ${proposal.version}${proposal.version === 2 ? ' 正規化' : ' -> 2'})`);
72
75
  for (const change of proposal.changes) log(`- ${change.path}: ${change.note}`);
73
76
  log(`変換前の退避先: ${proposal.backupPath}`);
74
77
  }
@@ -92,7 +95,9 @@ export async function runMigrateCommand(args, options = {}) {
92
95
  }
93
96
  await migrate.applyMigration(proposal);
94
97
  if (!parsed.json) {
95
- log(proposal.version === 2
98
+ log(proposal.emptyProject
99
+ ? `空の edit.json を初期化しました。元ファイル: ${proposal.backupPath}`
100
+ : proposal.version === 2
96
101
  ? `version 2 を正規化しました。元ファイル: ${proposal.backupPath}`
97
102
  : `version 2 へ変換しました。元ファイル: ${proposal.backupPath}`);
98
103
  }
@@ -83,6 +83,8 @@
83
83
  "docs/contract-2026-10-02-textstyle-v1-rich.md",
84
84
  "docs/contract-2026-10-04-asset-tier-v1.md",
85
85
  "docs/contract-2026-10-06-timeline-ripple-v0.md",
86
+ "packages/akari-ear/package.json",
87
+ "packages/akari-ear/README.md",
86
88
  "packages/akari-launcher/package.json",
87
89
  "packages/akari-launcher/README.md",
88
90
  "packages/akari-tools/package.json",
@@ -109,6 +111,8 @@
109
111
  "packages/frame-engine/package.json",
110
112
  "packages/frame-engine/README.ja.md",
111
113
  "packages/frame-engine/README.md",
114
+ "packages/frame-scene/package.json",
115
+ "packages/frame-scene/README.md",
112
116
  "packages/generate/package.json",
113
117
  "packages/generate/README.md",
114
118
  "packages/gpu-export/package.json",
@@ -373,7 +373,7 @@ catalog に載せる素材は、取得元のライセンスが CC0 相当(帰
373
373
 
374
374
  | v1 category | 形の定義 | 判定 | 旧 category |
375
375
  |---|---|---|---|
376
- | `overlay` | 時間を持つ HTML 断片 | `fragment.html` + `data-start` / `data-duration` を持ち `overlays[]` から合成 | `telop` / `motion` |
376
+ | `overlay` | 時間を持つ HTML 断片 | `fragment.html` を `overlays[]` から合成。断片ルートに時刻を置かず、素材本来の長さは `data-akari-natural-duration`(秒)に記録 | `telop` / `motion` |
377
377
  | `still` | 時間を持たない HTML シート | `fragment.html`。決定的スクショで画像に焼く | `thumbnail` |
378
378
  | `scene3d` | 3D モデル + 表示断片、またはベイクレシピ | `fragment.html` + glTF、または `scene.py` | `3d` |
379
379
  | `audio` | 音声トラックに載るバイナリ | `edit.json` の `audio.bgm` / `sfx` が参照 | `audio`(変更なし) |
@@ -40,6 +40,7 @@ updated: 2026-09-06
40
40
  |---|---|---|
41
41
  | 人間の一次素材 | `assets/`(下位分類自由) | 人間・取り込み UI |
42
42
  | 企画・プラン | `planning/` | スキル + 人間承認 |
43
+ | 仮枠のシーン JSON(AI が書く・人が微調整。仮枠の meta の `frame.content.scene` が指す) | `planning/frames/` | スキル・仮枠の編集 |
43
44
  | エージェント中間物・ad-hoc 検証スクリプトと結果 | `.akari/work/` | スキル・エージェント |
44
45
  | 検証証跡・レポート(キーフレーム視認 PNG・render-report 等) | `.akari/reports/` | スキル |
45
46
  | 絵コンテ(タイムラインの印刷・初回だけ・AI は後から読まない) | `.akari/reports/storyboard/` | スキル |
@@ -47,6 +48,8 @@ updated: 2026-09-06
47
48
  | 納品物 | `exports/` | `render-cut` |
48
49
  | 契約サイドカー(既存) | `.akari/`(`sidecars/` `diffs/` `events/` 等) | 既存のまま |
49
50
 
51
+ `planning/frames/<stem>.scene.json` は仮枠(生成 v0 契約)の中身を記述する正本で、変更履歴に入る(`planning/**`)。仮枠の絵(png)と meta は従来どおり `assets/generated/`。
52
+
50
53
  `.akari/` 直下にはこの表にない契約サイドカーファイルも存在する(`intake.json`・
51
54
  `workflow.json`・`connections.json`・`lint.json`・`render.json` 等)。これらは各自の
52
55
  契約文書が正本であり、本契約は「置き場所の層」を追加で確定させるだけで、個々のファイル形は
@@ -334,3 +334,7 @@ await p.save(); // 正規直列化 → lint ゲート
334
334
  - 観察・手術の CLI コマンド(作らない。§5.2-5)
335
335
  - 種類ごとに違うグループ(sequence / scene 等)— 「段」と「グループ」の 2 つだけ
336
336
  - 文字ごとのキーフレーム(姉妹契約 §8)
337
+
338
+ ## 音声片のカット端
339
+
340
+ edit.json v2 の audio lane にある分離済み speech item は、カットで素材端が失われたとき、任意の `cut_edge: { "in": 秒, "out": 秒, "at": 整数フレーム }` を持てる。`in` と `out` はこの音声片自身の元の素材端であり、頭がまたぎで削られた場合だけ `in` が現在の `source.in` と異なり、尻がまたぎで削られた場合だけ `out` が現在の `source.out` と異なる。両端が現在の素材端と等しくなれば欄を消す。`at` は「頭を `cut_edge.in` まで戻したときの開始フレーム」で、現在の `at - round((source.in - cut_edge.in) / 1 フレームぶんの素材秒)` から求める派生値である。カットで音声片を割ったときはそれぞれの片の端だけを保持し、結合時は左片の `in` と右片の `out` を採る。`in` と `out` は 0 以上の有限数で `in < out`、`at` は整数。先行カットのリップルを反映した途中状態では `cut_edge.at` が負になることもある。再生範囲の `source.in/out` と現在位置の `at` はそのまま正本とする。`provenance` はナレーションの生成元専用で、この用途には使わない。欄のない既存 item の意味は変わらない。
@@ -0,0 +1,9 @@
1
+ # AKARI バイブの耳
2
+
3
+ 耳はマイクを開き、音声を文字にして辞書を適用する共有部品です。判断サービスの資格や鍵に依存せず、マイクの持ち主を 1 つにします。
4
+
5
+ 段階 A では、試し聞きとメモ、区画の「いま」が耳を使います。Jev は従来どおり自分のマイクを使い、同時利用は禁止します。Jev 動作中は耳を使用中として扱います。
6
+
7
+ 段階 B では、Jev の聞き取りも耳へ統一します。発話と音量を係の `/say` と `/level` へ渡し、マイクの持ち主を耳に一本化します。
8
+
9
+ 現時点は骨組みのみで、実装は今後行います。
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@akari-video/akari-ear",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "description": "AKARI バイブの耳(マイク → 文字起こし → 辞書)の共有部品。依存ゼロ。",
6
+ "type": "module",
7
+ "main": "src/index.mjs",
8
+ "scripts": {
9
+ "test": "node --test \"test/*.test.mjs\""
10
+ }
11
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.1.1",
3
+ "version": "1.2.0-beta.1",
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": [
@@ -1,7 +1,7 @@
1
1
  # AKARI バイブ
2
2
 
3
- このディレクトリは内部リポジトリの正本から自動生成する配布物です。直接編集しないでください。
4
- 変更は内部へ戻し、検査を通して書き出し直します。版はシェルと一緒に管理します。
3
+ このディレクトリは公開リポジトリが正本です。変更は通常のプルリクエスト・コミットで行います。
4
+ 版はシェルと一緒に管理します。
5
5
  判断は `https://akari.video/api/vibe` のサービスで行い、その実装は同梱しません。
6
6
 
7
7
  ## 起動
@@ -2,7 +2,7 @@
2
2
  // ~/.akari/catalog-cache.json へ自動キャッシュ(オフライン時のフォールバック)。
3
3
  // ローカルパス指定(開発・テスト)はファイルをそのまま読む。
4
4
 
5
- import { readFile, mkdir, writeFile } from 'node:fs/promises';
5
+ import { readFile, mkdir, writeFile, stat } from 'node:fs/promises';
6
6
  import { catalogCachePath, resolveAkariHome, resolveCatalogSource } from './env.mjs';
7
7
  import { join } from 'node:path';
8
8
  import { loadInstalledItems, mergeInstalledItems } from './installed.mjs';
@@ -16,9 +16,26 @@ function normalizeCatalog(catalog) {
16
16
  return catalog;
17
17
  }
18
18
 
19
+ // The local catalog is large; keep the parsed value until the file changes.
20
+ const parsedCatalogs = new Map();
21
+ async function readLocalCatalog(file, visible = false) {
22
+ const info = await stat(file);
23
+ const cached = parsedCatalogs.get(file);
24
+ if (cached && cached.mtimeMs === info.mtimeMs && cached.ctimeMs === info.ctimeMs
25
+ && cached.size === info.size) {
26
+ if (visible && !cached.public) cached.public = publicCatalog(cached.catalog);
27
+ return visible ? cached.public : cached.catalog;
28
+ }
29
+ const catalog = normalizeCatalog(JSON.parse(await readFile(file, 'utf8')));
30
+ const entry = { mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, size: info.size, catalog,
31
+ ...(visible ? { public: publicCatalog(catalog) } : {}) };
32
+ parsedCatalogs.set(file, entry);
33
+ return visible ? entry.public : catalog;
34
+ }
35
+
19
36
  export async function readCatalogCache(env = process.env) {
20
37
  try {
21
- return publicCatalog(normalizeCatalog(JSON.parse(await readFile(catalogCachePath(env), 'utf8'))));
38
+ return await readLocalCatalog(catalogCachePath(env), true);
22
39
  } catch {
23
40
  return null;
24
41
  }
@@ -53,6 +70,7 @@ export async function cacheCatalog(env = process.env, catalog) {
53
70
  const home = resolveAkariHome(env);
54
71
  await mkdir(home, { recursive: true });
55
72
  await writeFile(catalogCachePath(env), `${JSON.stringify(publicCatalog(normalizeCatalog(catalog)), null, 2)}\n`);
73
+ parsedCatalogs.delete(catalogCachePath(env));
56
74
  }
57
75
 
58
76
  /**
@@ -70,8 +88,7 @@ export async function loadCatalog({ env = process.env, fetchImpl = fetch, includ
70
88
  }
71
89
 
72
90
  if (source.kind === 'file') {
73
- const raw = await readFile(source.value, 'utf8');
74
- catalog = normalizeCatalog(JSON.parse(raw));
91
+ catalog = await readLocalCatalog(source.value);
75
92
  } else {
76
93
  try {
77
94
  const { response: res, controller } = await fetchTimed(source.value, { fetchImpl, timeouts, label: 'カタログ' });
@@ -157,8 +157,13 @@ export async function readProjectReferences(projectDir) {
157
157
 
158
158
  export async function recordProjectReference(projectDir, reference) {
159
159
  assertReference(reference);
160
+ const alreadyRecorded = references => references.some(entry =>
161
+ entry.id === reference.id && entry.category === reference.category);
162
+ const current = await readProjectReferences(projectDir);
163
+ if (alreadyRecorded(current)) return current;
160
164
  return withReferencesLock(projectDir, async () => {
161
165
  const references = await readProjectReferences(projectDir);
166
+ if (alreadyRecorded(references)) return references;
162
167
  references.push({ id: reference.id, category: reference.category });
163
168
  const normalized = normalizeReferences(references);
164
169
  await writeProjectReferences(projectDir, normalized);
@@ -12,7 +12,7 @@ import { resolveAssetLibraryRoots } from '../../creator-root/src/index.mjs';
12
12
 
13
13
  import { spawn } from 'node:child_process';
14
14
  import { constants } from 'node:fs';
15
- import { cp, mkdir, mkdtemp, readFile, rename, rm, realpath, stat, writeFile } from 'node:fs/promises';
15
+ import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, realpath, stat, writeFile } from 'node:fs/promises';
16
16
  import path from 'node:path';
17
17
  import { fileURLToPath, pathToFileURL } from 'node:url';
18
18
  import { format } from 'node:util';
@@ -94,6 +94,52 @@ async function validateAsset(assetDir) {
94
94
 
95
95
  export { AssetResolverError };
96
96
 
97
+ // overlay-runtime は resolver の依存にしない。断片ルートの変換規則は両方を同じテストで固定する。
98
+ export function withoutFragmentRootTiming(source, { preserveNaturalDuration = false } = {}) {
99
+ let cursor = 0;
100
+ while (cursor < source.length) {
101
+ const start = source.indexOf('<', cursor);
102
+ if (start < 0) break;
103
+ if (source.startsWith('<!--', start)) {
104
+ const end = source.indexOf('-->', start + 4);
105
+ if (end < 0) throw new Error('HTML コメントが閉じていません');
106
+ cursor = end + 3;
107
+ continue;
108
+ }
109
+ const tag = /^<([A-Za-z][\w:-]*)(?:"[^"]*"|'[^']*'|[^'">])*>/u.exec(source.slice(start));
110
+ if (!tag) { cursor = start + 1; continue; }
111
+ if (/^(?:style|script)$/iu.test(tag[1])) {
112
+ const closing = new RegExp(`</${tag[1]}\\s*>`, 'iu').exec(source.slice(start + tag[0].length));
113
+ if (!closing) throw new Error('HTML の前置要素が閉じていません');
114
+ cursor = start + tag[0].length + closing.index + closing[0].length;
115
+ continue;
116
+ }
117
+ if (/^link$/iu.test(tag[1])) { cursor = start + tag[0].length; continue; }
118
+ const nameEnd = tag[1].length + 1;
119
+ const attributes = tag[0].slice(nameEnd, -1);
120
+ const tokens = /([^\s=/>]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gu;
121
+ const removals = [];
122
+ let duration = null;
123
+ let hasNaturalDuration = false;
124
+ for (const token of attributes.matchAll(tokens)) {
125
+ if (preserveNaturalDuration && /^data-akari-natural-duration$/iu.test(token[1])) hasNaturalDuration = true;
126
+ if (!/^data-(?:start|duration)$/iu.test(token[1])) continue;
127
+ if (preserveNaturalDuration && /^data-duration$/iu.test(token[1])) {
128
+ const value = token[0].match(/=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/u)?.slice(1).find(part => part !== undefined);
129
+ if (value !== undefined && Number.isFinite(Number(value)) && Number(value) > 0) duration = value;
130
+ }
131
+ let from = token.index;
132
+ while (from > 0 && /\s/u.test(attributes[from - 1])) from--;
133
+ removals.push([from, token.index + token[0].length]);
134
+ }
135
+ let changed = attributes;
136
+ for (const [from, to] of removals.reverse()) changed = changed.slice(0, from) + changed.slice(to);
137
+ if (preserveNaturalDuration && !hasNaturalDuration && duration !== null) changed += ` data-akari-natural-duration="${duration}"`;
138
+ return source.slice(0, start + nameEnd) + changed + source.slice(start + tag[0].length - 1);
139
+ }
140
+ throw new Error('HTML 断片のルート要素がありません');
141
+ }
142
+
97
143
  /** category/id を優先し、bare id はカタログ内で一意な場合だけ解決する。 */
98
144
  export function findCatalogItem(catalog, ref) {
99
145
  if (typeof ref === 'string' && ref.includes('/')) {
@@ -132,6 +178,12 @@ export async function copyIntoProject(sourceDir, projectDir, category, id) {
132
178
  child.on('close', code => resolveResult(code === 0));
133
179
  });
134
180
  if (cloned) {
181
+ try {
182
+ await normalizeCopiedFragments(dest, category);
183
+ } catch (error) {
184
+ await rm(dest, { recursive: true, force: true });
185
+ throw error;
186
+ }
135
187
  return dest;
136
188
  }
137
189
  // クロスボリューム等で -c が失敗したケース。部分的に書かれた dest を掃除してから
@@ -142,9 +194,41 @@ export async function copyIntoProject(sourceDir, projectDir, category, id) {
142
194
  // COPYFILE_FICLONE(_FORCE ではない): 対応 FS(APFS 等)では CoW クローンで実体化コピーを
143
195
  // 省略し、非対応環境では黙って通常コピーへフォールバックする(失敗しない)。
144
196
  await cp(realSourceDir, dest, { recursive: true, mode: constants.COPYFILE_FICLONE });
197
+ try {
198
+ await normalizeCopiedFragments(dest, category);
199
+ } catch (error) {
200
+ await rm(dest, { recursive: true, force: true });
201
+ throw error;
202
+ }
145
203
  return dest;
146
204
  }
147
205
 
206
+ async function normalizeCopiedFragments(dest, category) {
207
+ if (category !== 'overlay' && category !== 'scene3d') return;
208
+ const files = ['fragment.html'];
209
+ const variants = path.join(dest, 'variants');
210
+ const variantInfo = await lstat(variants).catch(error => {
211
+ if (error.code === 'ENOENT') return null;
212
+ throw error;
213
+ });
214
+ if (variantInfo && !variantInfo.isDirectory()) throw new Error('variants が通常のディレクトリではありません');
215
+ for (const entry of variantInfo ? await readdir(variants) : []) {
216
+ if (entry.endsWith('.html')) files.push(path.join('variants', entry));
217
+ }
218
+ for (const file of files) {
219
+ const target = path.join(dest, file);
220
+ const info = await lstat(target).catch(error => {
221
+ if (error.code === 'ENOENT') return null;
222
+ throw error;
223
+ });
224
+ if (info === null) continue;
225
+ if (!info.isFile()) throw new Error(`断片ファイルが通常のファイルではありません: ${file}`);
226
+ const source = await readFile(target, 'utf8');
227
+ const cleaned = withoutFragmentRootTiming(source, { preserveNaturalDuration: true });
228
+ if (cleaned !== source) await writeFile(target, cleaned);
229
+ }
230
+ }
231
+
148
232
  async function moveIntoLibrary(tempDir, destDir) {
149
233
  await mkdir(path.dirname(destDir), { recursive: true });
150
234
  if (await stat(destDir).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; })) {
@@ -282,6 +366,24 @@ export async function resolve(
282
366
  { env = process.env, fetchImpl = fetch, project = null, force = false, reference = false, timeouts } = {},
283
367
  ) {
284
368
  const home = resolveAssetLibraryRoots(env).write;
369
+ // A qualified reference already names the local directory. Avoid parsing the
370
+ // large catalog on the common placement path; uncached and bare IDs still use it.
371
+ const localRef = typeof ref === 'string' ? ref.split('/') : [];
372
+ const qualified = localRef.length === 2 && localRef.every(part => part.length > 0
373
+ && part !== '.' && part !== '..' && !/[\\\0]/.test(part));
374
+ if (!force && qualified) {
375
+ const cachedDir = await cachedAssetDirAsync(env, localRef[0], localRef[1]);
376
+ if (cachedDir) {
377
+ const result = { id: localRef[1], category: localRef[0], dir: cachedDir, cached: true };
378
+ if (project && reference) {
379
+ await recordProjectReference(project, { id: localRef[1], category: localRef[0] });
380
+ result.referenced = true;
381
+ } else if (project) {
382
+ result.projectDir = await copyIntoProject(cachedDir, project, localRef[0], localRef[1]);
383
+ }
384
+ return result;
385
+ }
386
+ }
285
387
  const catalog = await loadCatalogForResolveAsync(ref, { env, fetchImpl, timeouts });
286
388
  const item = findCatalogItem(catalog, ref);
287
389
 
@@ -82,7 +82,7 @@ export async function composeState({ env = process.env, fetchImpl = fetch, inten
82
82
  localItems.delete(key);
83
83
  // Preserve remote download descriptors/preview keys for existing consumers.
84
84
  // Local-only assets use the actual directory listing assembled above.
85
- return local ? { ...item, ...local, tier: item.tier ?? assetTier(item), files: item.files ?? local.files, preview: item.preview ?? local.preview,
85
+ return local ? { ...item, ...local, tier: item.tier ?? assetTier(item), files: item.files ?? local.files, preview: local.libraryDir && local.preview ? local.preview : item.preview ?? local.preview,
86
86
  ...(item.source === 'installed' ? { source: item.source } : {}) } : item;
87
87
  });
88
88
  const localOnlyKeys = new Set(localItems.keys());
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, readFileSync, utimesSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import test from 'node:test';
5
5
  import { cacheCatalog, loadCatalog, readCatalogCache } from '../src/catalog.mjs';
@@ -13,6 +13,18 @@ test('loadCatalog はローカルパス指定のカタログを読める', async
13
13
  assert.equal(loaded.items.length, catalog.items.length);
14
14
  });
15
15
 
16
+ test('ローカルカタログは mtime が同じ間は解析結果を再利用し、更新後に読み直す', async () => {
17
+ const { env, catalog, catalogPath } = setupFixtureEnv();
18
+ const first = await loadCatalog({ env, includeInstalled: false });
19
+ assert.equal(await loadCatalog({ env, includeInstalled: false }), first);
20
+ writeFileSync(catalogPath, JSON.stringify({ ...catalog, items: [...catalog.items, { id: 'new', category: 'still' }] }));
21
+ const updated = new Date(Date.now() + 5000);
22
+ utimesSync(catalogPath, updated, updated);
23
+ const next = await loadCatalog({ env, includeInstalled: false });
24
+ assert.notEqual(next, first);
25
+ assert.equal(next.items.at(-1).id, 'new');
26
+ });
27
+
16
28
  test('catalog cache drops files for locked and Pro items before writing and on old-cache reads', async () => {
17
29
  const { env, home, catalog } = setupFixtureEnv();
18
30
  catalog.items[0].tier = 'free';
@@ -88,6 +100,19 @@ test('composeState: ローカルに実体があるものは cached になる', a
88
100
  assert.equal(free.state, 'cached');
89
101
  });
90
102
 
103
+ test('composeState: cached preview uses local filename over catalog key', async () => {
104
+ const { env, home, catalog, catalogPath } = setupFixtureEnv();
105
+ catalog.items[0].preview = 'still/mini-still/v1/preview.png';
106
+ writeFileSync(catalogPath, JSON.stringify(catalog));
107
+ const dir = path.join(home, 'assets', 'still', 'mini-still');
108
+ mkdirSync(dir, { recursive: true });
109
+ writeFileSync(path.join(dir, 'preview.png'), 'local preview');
110
+ const item = (await composeState({ env })).items.find(entry => entry.id === 'mini-still');
111
+ assert.equal(item.state, 'cached');
112
+ assert.equal(item.preview, 'preview.png');
113
+ assert.equal(item.libraryDir, dir);
114
+ });
115
+
91
116
  test('composeState: legacy local-only metadata without tier displays free while catalog stays fail-closed', async () => {
92
117
  const { env, home, catalog, catalogPath } = setupFixtureEnv();
93
118
  const dir = path.join(home, 'assets', 'audio', 'old-local');
@@ -0,0 +1,35 @@
1
+ {
2
+ "version": 1,
3
+ "cases": [
4
+ {
5
+ "name": "comment and child timing",
6
+ "source": "<!-- data-duration=\"90\" -->\n<div data-start=\"0\" data-duration=\"8\"><span data-duration=\"2\">text</span></div>",
7
+ "plain": "<!-- data-duration=\"90\" -->\n<div><span data-duration=\"2\">text</span></div>",
8
+ "preserved": "<!-- data-duration=\"90\" -->\n<div data-akari-natural-duration=\"8\"><span data-duration=\"2\">text</span></div>"
9
+ },
10
+ {
11
+ "name": "leading style and existing natural duration",
12
+ "source": "<style>.x{color:red}</style><div data-start=0 data-duration=6 data-akari-natural-duration=\"9\">text</div>",
13
+ "plain": "<style>.x{color:red}</style><div data-akari-natural-duration=\"9\">text</div>",
14
+ "preserved": "<style>.x{color:red}</style><div data-akari-natural-duration=\"9\">text</div>"
15
+ },
16
+ {
17
+ "name": "duration before class",
18
+ "source": "<div data-start=\"0\" data-duration=\"4\" class=\"x\">text</div>",
19
+ "plain": "<div class=\"x\">text</div>",
20
+ "preserved": "<div class=\"x\" data-akari-natural-duration=\"4\">text</div>"
21
+ },
22
+ {
23
+ "name": "start only with child duration",
24
+ "source": "<div data-start=\"3\"><span data-duration=\"2\">text</span></div>",
25
+ "plain": "<div><span data-duration=\"2\">text</span></div>",
26
+ "preserved": "<div><span data-duration=\"2\">text</span></div>"
27
+ },
28
+ {
29
+ "name": "single quoted duration",
30
+ "source": "<div data-duration='2.5' data-start='0'>text</div>",
31
+ "plain": "<div>text</div>",
32
+ "preserved": "<div data-akari-natural-duration=\"2.5\">text</div>"
33
+ }
34
+ ]
35
+ }
@@ -0,0 +1,45 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+ import { copyIntoProject, withoutFragmentRootTiming as resolverTiming } from '../src/resolve.mjs';
7
+
8
+ const rootTag = html => html.match(/<div\b[^>]*>/)?.[0] ?? '';
9
+ const { version, cases } = JSON.parse(await readFile(new URL('./fixtures/fragment-root-timing.json', import.meta.url), 'utf8'));
10
+
11
+ test('resolver は通常・素材コピーの両モードで共通の入出力表に従う', () => {
12
+ assert.equal(version, 1);
13
+ for (const { name, source, plain, preserved } of cases) {
14
+ for (const [options, expected] of [[undefined, plain], [{ preserveNaturalDuration: true }, preserved]]) {
15
+ const actual = resolverTiming(source, options);
16
+ assert.equal(actual, expected, name);
17
+ assert.doesNotMatch(rootTag(actual), /\bdata-(?:start|duration)\s*=/);
18
+ }
19
+ }
20
+ });
21
+
22
+ for (const category of ['overlay', 'scene3d']) {
23
+ test(`copyIntoProject は ${category} の fragment と variants だけを正規化する`, async t => {
24
+ const root = await mkdtemp(path.join(tmpdir(), 'fragment-project-copy-'));
25
+ t.after(() => rm(root, { recursive: true, force: true }));
26
+ const source = path.join(root, 'source');
27
+ const project = path.join(root, 'project');
28
+ await mkdir(path.join(source, 'variants'), { recursive: true });
29
+ await mkdir(project);
30
+ await writeFile(path.join(source, 'fragment.html'), cases[0].source);
31
+ await writeFile(path.join(source, 'variants', 'alternate.html'), cases[2].source);
32
+ await writeFile(path.join(source, 'variants', 'note.txt'), 'data-start="0"');
33
+ await writeFile(path.join(source, 'meta.json'), '{"id":"board"}\n');
34
+
35
+ const dest = await copyIntoProject(source, project, category, 'board');
36
+ for (const name of ['fragment.html', 'variants/alternate.html']) {
37
+ const actual = await readFile(path.join(dest, name), 'utf8');
38
+ assert.doesNotMatch(rootTag(actual), /\bdata-(?:start|duration)\s*=/);
39
+ assert.match(rootTag(actual), /data-akari-natural-duration=/);
40
+ }
41
+ assert.equal(await readFile(path.join(source, 'fragment.html'), 'utf8'), cases[0].source);
42
+ assert.equal(await readFile(path.join(dest, 'variants/note.txt'), 'utf8'), 'data-start="0"');
43
+ assert.deepEqual((await readdir(dest)).sort(), ['fragment.html', 'meta.json', 'variants']);
44
+ });
45
+ }
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { existsSync, realpathSync } from 'node:fs';
3
- import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import test from 'node:test';
6
6
 
@@ -26,6 +26,11 @@ test('project references: tolerant read, idempotent upsert, stable sort, and rem
26
26
 
27
27
  await recordProjectReference(project, { id: 'zeta', category: 'still' });
28
28
  await recordProjectReference(project, { id: 'alpha', category: 'still' });
29
+ const unchanged = await stat(ledger);
30
+ await recordProjectReference(project, { id: 'alpha', category: 'still' });
31
+ const repeated = await stat(ledger);
32
+ assert.equal(repeated.mtimeMs, unchanged.mtimeMs);
33
+ assert.equal(repeated.ino, unchanged.ino);
29
34
  await recordProjectReference(project, { id: 'theme', category: 'audio' });
30
35
  await recordProjectReference(project, { id: 'alpha', category: 'still' });
31
36
  assert.deepEqual(await readProjectReferences(project), [
@@ -32,6 +32,18 @@ test('取得済み id はカタログもファイルも通信しない', async (
32
32
  assert.equal(calls, 0);
33
33
  });
34
34
 
35
+ test('category/id の手元素材はカタログファイルが無くても配置できる', async () => {
36
+ const { env, item } = fixture();
37
+ env.AKARI_ASSETS_CATALOG = '/missing-catalog.json';
38
+ const dir = localAssetDir(env, item.category, item.id);
39
+ mkdirSync(dir, { recursive: true });
40
+ writeFileSync(path.join(dir, 'part-0.bin'), 'ready');
41
+ const result = await resolve(`${item.category}/${item.id}`, { env,
42
+ fetchImpl: async () => { throw Error('catalog was fetched'); } });
43
+ assert.equal(result.cached, true);
44
+ assert.equal(result.dir, dir);
45
+ });
46
+
35
47
  test('控えに id があれば未取得でもファイルだけ取得する', async () => {
36
48
  const { env, item, catalog } = fixture();
37
49
  await cacheCatalog(env, catalog);
@@ -5,6 +5,7 @@
5
5
  "fields": [
6
6
  { "path": "tracks[].items[].audio", "applies_to": ["cuts"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/render-cut/src/internal-render.mjs projectRendererCompatibilityEdit; packages/render-cut/src/plan.mjs cut_audio uses isCutAudioAudible before GPU/OSR audioSourcePath mux" },
7
7
  { "path": "tracks[].items[].link", "applies_to": ["audio"], "gpu": "ignored", "osr": "ignored", "evidence": "Editing association only. Engines use the audio item source and timing; link is not consumed." },
8
+ { "path": "tracks[].items[].cut_edge", "applies_to": ["audio"], "gpu": "ignored", "osr": "ignored", "evidence": "Editing-only restore record of the pre-cut audio edge; engines use the item source and timing." },
8
9
  { "path": "tracks[].items[].mute", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/audio-ownership.ts isAudioItemAudible; packages/render-cut/src/internal-render.mjs and plan.mjs audio mix" },
9
10
  { "path": "tracks[].items[].id", "applies_to": ["cuts", "layers", "overlays", "baked", "captions", "group"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/edit-store/src/internal-model.ts buildV2VisualItem; packages/frame-engine/src/timeline/plan.ts layerFromPlacement/resolvedCompositeLayers; packages/render-cut/src/internal-render.mjs captionItemOverlays/renderItemDeclaration" },
10
11
  { "path": "tracks[].items[].id", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/internal-model.ts buildV2AudioItem" },
@@ -270,6 +270,15 @@ export function validateCaptions(captions, edit, analysis, findings, paths, cuts
270
270
  if (Object.hasOwn(caption, "words")) {
271
271
  validateCaptionWords(caption.words, caption, findings, itemPath);
272
272
  }
273
+ if ((caption.style === "karaoke" || caption.style === "pop")
274
+ && (!Array.isArray(caption.words) || caption.words.length === 0)) {
275
+ addFinding(findings, {
276
+ severity: "warning",
277
+ check: `captions.${caption.style}-without-words`,
278
+ message: `字幕 ${caption.id ?? "(unknown)"}: 語の時刻がない字幕は${caption.style === "karaoke" ? "カラオケ" : "ポップ"}で表示できません。通常の表示で書き出します`,
279
+ path: itemPath,
280
+ });
281
+ }
273
282
  if (Object.hasOwn(caption, "unrecognized")) {
274
283
  validateCaptionUnrecognized(caption.unrecognized, caption, findings, itemPath);
275
284
  }
@@ -432,6 +432,19 @@ export function validateEditV2(edit, findings) {
432
432
 
433
433
  if (track.lane === "audio") {
434
434
  const role = item.role ?? "sfx";
435
+ if (Object.hasOwn(item, "cut_edge")) {
436
+ const edge = item.cut_edge;
437
+ if (!isRecord(edge) || !isFiniteNumber(edge.in) || edge.in < 0
438
+ || !isFiniteNumber(edge.out) || edge.out <= edge.in
439
+ || !Number.isInteger(edge.at)
440
+ || Object.keys(edge).some(key => !["in", "out", "at"].includes(key))) {
441
+ addFinding(findings, {
442
+ severity: "error", check: "v2.audio-cut-edge",
443
+ message: "cut_edge requires non-negative in < out (seconds) and at (integer frames)",
444
+ path: `${itemPath}.cut_edge`,
445
+ });
446
+ }
447
+ }
435
448
  if (Object.hasOwn(item, "gain_db")
436
449
  && (!isFiniteNumber(item.gain_db) || item.gain_db < -60 || item.gain_db > 12)) {
437
450
  addFinding(findings, {