akari-video 0.1.80 → 0.1.81

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 (90) hide show
  1. package/package.json +1 -1
  2. package/src/assets-command.mjs +34 -0
  3. package/src/cli.mjs +12 -0
  4. package/src/narration-command.mjs +243 -17
  5. package/src/store-command.mjs +12 -10
  6. package/src/vendor-sources.mjs +14 -0
  7. package/vendor/.akari-capability-sources.json +2 -0
  8. package/vendor/docs/contract-2026-09-13-generation-v0.md +15 -1
  9. package/vendor/packages/akari-launcher/package.json +1 -1
  10. package/vendor/packages/akari-tools/src/world/invariants.mjs +117 -0
  11. package/vendor/packages/akari-tools/src/world/normalize.mjs +139 -0
  12. package/vendor/packages/asset-resolver/src/paid-zip.mjs +32 -12
  13. package/vendor/packages/asset-resolver/test/fieldreport-zip.test.mjs +24 -0
  14. package/vendor/packages/audio-library-setup/bin/fetch-akari-sounds.mjs +15 -61
  15. package/vendor/packages/audio-library-setup/test/fetch-akari-sounds.test.mjs +15 -11
  16. package/vendor/packages/edit-lint/src/edit-lint.mjs +54 -12
  17. package/vendor/packages/edit-lint/src/engine-capabilities.json +124 -0
  18. package/vendor/packages/edit-store/lib/audio-schedule.js +30 -0
  19. package/vendor/packages/edit-store/lib/caption-display.js +4 -2
  20. package/vendor/packages/edit-store/lib/caption-store.d.ts +8 -0
  21. package/vendor/packages/edit-store/lib/caption-store.js +114 -1
  22. package/vendor/packages/edit-store/lib/generation-meta.d.ts +3 -1
  23. package/vendor/packages/edit-store/lib/generation-meta.js +6 -0
  24. package/vendor/packages/edit-store/lib/webview-kernel.js +35 -0
  25. package/vendor/packages/overlay-runtime/runtimes.mjs +466 -0
  26. package/vendor/packages/overlay-runtime/src/fragment-source-write.d.ts +2 -0
  27. package/vendor/packages/overlay-runtime/src/fragment-source-write.mjs +103 -0
  28. package/vendor/packages/overlay-runtime/src/glass-runtime.js +657 -0
  29. package/vendor/packages/overlay-runtime/src/interaction.css +138 -0
  30. package/vendor/packages/overlay-runtime/src/interaction.js +3435 -0
  31. package/vendor/packages/overlay-runtime/src/keyframes.d.ts +24 -0
  32. package/vendor/packages/overlay-runtime/src/keyframes.mjs +176 -0
  33. package/vendor/packages/overlay-runtime/src/minimap.css +37 -0
  34. package/vendor/packages/overlay-runtime/src/minimap.js +110 -0
  35. package/vendor/packages/overlay-runtime/src/motion-vocab.css +47 -0
  36. package/vendor/packages/overlay-runtime/src/overlay-runtime.js +437 -0
  37. package/vendor/packages/overlay-runtime/src/parts.d.ts +40 -0
  38. package/vendor/packages/overlay-runtime/src/parts.mjs +527 -0
  39. package/vendor/packages/overlay-runtime/src/runtime-registry.js +19 -0
  40. package/vendor/packages/overlay-runtime/src/selection-scope.mjs +122 -0
  41. package/vendor/packages/overlay-runtime/src/slot-params.js +41 -0
  42. package/vendor/packages/overlay-runtime/src/text-split.js +202 -0
  43. package/vendor/packages/overlay-runtime/src/three-runtime.js +2692 -0
  44. package/vendor/packages/overlay-runtime/src/vendor/LICENSE.liquid-glass-js +21 -0
  45. package/vendor/packages/overlay-runtime/src/vendor/avatar-vrm-bundle.js +983 -0
  46. package/vendor/packages/overlay-runtime/src/vendor/budoux-LICENSE.txt +213 -0
  47. package/vendor/packages/overlay-runtime/src/vendor/budoux-ja-bundle.js +110 -0
  48. package/vendor/packages/overlay-runtime/src/vendor/matter-js-LICENSE.txt +21 -0
  49. package/vendor/packages/overlay-runtime/src/vendor/opentype.js-LICENSE.txt +20 -0
  50. package/vendor/packages/overlay-runtime/src/vendor/poly-decomp-LICENSE.txt +20 -0
  51. package/vendor/packages/overlay-runtime/src/vendor/three-LICENSE.txt +21 -0
  52. package/vendor/packages/overlay-runtime/src/vendor/three-bundle.js +4116 -0
  53. package/vendor/packages/overlay-runtime/src/vendor/three-vrm-LICENSE.txt +22 -0
  54. package/vendor/packages/overlay-runtime/src/vendor/troika-three-text-LICENSE.txt +22 -0
  55. package/vendor/packages/overlay-runtime/src/vendor/vendor-3d-text-bundle.js +248 -0
  56. package/vendor/packages/overlay-runtime/src/vendor/vgpu-LICENSE.txt +21 -0
  57. package/vendor/packages/overlay-runtime/src/vendor/vgpu-bundle.js +17 -0
  58. package/vendor/packages/overlay-runtime/src/vendor/world-camera.js +80 -0
  59. package/vendor/packages/overlay-runtime/src/vgpu-runtime.js +365 -0
  60. package/vendor/packages/overlay-runtime/src/video-fx.js +605 -0
  61. package/vendor/packages/overlay-runtime/src/viewport-units.js +148 -0
  62. package/vendor/packages/overlay-runtime/src/world-runtime.js +282 -0
  63. package/vendor/packages/overlay-runtime/test-harness/fonts/ZenKakuGothicNew-Black.ttf +0 -0
  64. package/vendor/packages/render-cut/src/caption-font.mjs +59 -0
  65. package/vendor/packages/render-cut/src/fragment-assets.mjs +212 -0
  66. package/vendor/packages/render-cut/src/html-scan.mjs +10 -0
  67. package/vendor/packages/render-cut/src/library-reference.mjs +97 -0
  68. package/vendor/packages/render-cut/src/render-inputs.mjs +508 -0
  69. package/vendor/packages/schemas/generation-meta.schema.json +1 -1
  70. package/vendor/packages/schemas/intake.schema.json +1 -1
  71. package/vendor/packages/schemas/test/generation-meta.test.mjs +9 -0
  72. package/vendor/packages/word-book/src/index.mjs +530 -0
  73. package/vendor/skills/address-review/bin/core/review-store.mjs +73 -39
  74. package/vendor/skills/address-review/test/review-loss.test.mjs +41 -0
  75. package/vendor/skills/compile-review-session/bin/core/install-root.mjs +48 -0
  76. package/vendor/skills/compile-review-session/bin/core/review-store.mjs +97 -23
  77. package/vendor/skills/compile-review-session/bin/core/time-mapping.mjs +56 -12
  78. package/vendor/skills/compile-review-session/dev-fixtures/issue-69-review-json/README.md +54 -0
  79. package/vendor/skills/compile-review-session/dev-fixtures/issue-69-review-json/reproduce.mjs +58 -0
  80. package/vendor/skills/compile-review-session/dev-fixtures/issue-79/README.md +3 -0
  81. package/vendor/skills/compile-review-session/dev-fixtures/issue-79/reproduce.mjs +9 -0
  82. package/vendor/skills/compile-review-session/test/helpers/issue-79-fixture.mjs +93 -0
  83. package/vendor/skills/compile-review-session/test/issue-79-snapshot.test.mjs +71 -0
  84. package/vendor/skills/compile-review-session/test/review-loss.test.mjs +52 -0
  85. package/vendor/skills/edit-lint/SKILL.md +2 -0
  86. package/vendor/skills/generate-narration/SKILL.md +1 -1
  87. package/vendor/skills/generate-narration/engines.md +19 -4
  88. package/vendor/skills/manage-connections/bin/doctor.mjs +3 -1
  89. package/vendor/skills/manage-connections/test/fieldreport-report-location.test.mjs +24 -0
  90. package/vendor/skills/overlay-authoring/motion.md +6 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.80",
3
+ "version": "0.1.81",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
2
4
 
3
5
  import { resolveLauncherAssets } from './repo-assets.mjs';
4
6
  import { assetsResolverUnavailableError } from './messages.mjs';
@@ -29,6 +31,19 @@ export async function runAssetsCommand(args, options = {}) {
29
31
  const env = options.env ?? process.env;
30
32
  const assets = options.assets ?? resolveLauncherAssets();
31
33
 
34
+ // These catalog pack IDs are reference entries; their tracks are distributed by the
35
+ // first-party Sounds fetcher, while the resolver catalog contains only individual IDs.
36
+ if (args[0] === 'fetch' && /^akari-sounds-[a-z0-9-]+$/u.test(args[1] ?? '')) {
37
+ if (!assets?.audioFetchScriptPath) {
38
+ logError('AKARI Sounds の取得スクリプトが同梱されていません');
39
+ return { exitCode: 1 };
40
+ }
41
+ const spawn = options.spawnSoundsCli ?? defaultSpawnAssetsCli;
42
+ const fetchArgs = ['--pack', args[1], ...(args.includes('--force') ? ['--force'] : [])];
43
+ const result = spawn(assets.audioFetchScriptPath, fetchArgs, env);
44
+ return { exitCode: typeof result.status === 'number' ? result.status : 1 };
45
+ }
46
+
32
47
  const cliPath = assets?.assetResolverCliPath;
33
48
  if (!cliPath) {
34
49
  logError(assetsResolverUnavailableError());
@@ -36,6 +51,25 @@ export async function runAssetsCommand(args, options = {}) {
36
51
  }
37
52
 
38
53
  const spawnAssetsCli = options.spawnAssetsCli ?? defaultSpawnAssetsCli;
54
+ if (args[0] === 'fetch' && assets?.repoRoot) {
55
+ try {
56
+ const load = options.loadCatalog ?? (await import(pathToFileURL(resolve(dirname(cliPath), '../src/catalog.mjs')).href)).loadCatalog;
57
+ const catalog = await load({ env });
58
+ if (!catalog.items.some(item => item.id === args[1])) {
59
+ const members = catalog.items.filter(item => Array.isArray(item.tags) && item.tags.includes(`pack:${args[1]}`));
60
+ if (members.length > 0) {
61
+ let exitCode = 0;
62
+ for (const item of members) {
63
+ const result = spawnAssetsCli(cliPath, ['fetch', item.id, ...args.slice(2)], env);
64
+ if (result.status !== 0) exitCode = 1;
65
+ }
66
+ return { exitCode };
67
+ }
68
+ }
69
+ } catch {
70
+ // Delegate to the resolver, which owns catalog errors and offline guidance.
71
+ }
72
+ }
39
73
  const result = spawnAssetsCli(cliPath, args, env);
40
74
  const exitCode = typeof result.status === 'number' ? result.status : (result.error ? 1 : 0);
41
75
  return { exitCode };
package/src/cli.mjs CHANGED
@@ -75,6 +75,13 @@ export async function run(args, options = {}) {
75
75
  );
76
76
 
77
77
  let projectRoot = options.projectRoot ?? process.cwd();
78
+ const bareNonInteractive = args.length === 0
79
+ && !(options.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY));
80
+ if (bareNonInteractive && !detectProjectState(projectRoot).scaffolded) {
81
+ log(`このフォルダーは AKARI Video プロジェクトとしてまだセットアップされていません: ${projectRoot}`);
82
+ log('非対話シェルのため状態確認のみで終了します。');
83
+ return { exitCode: 0, scaffolded: false, claudeLaunched: false, opencodeLaunched: false };
84
+ }
78
85
 
79
86
  // 作業場(creator-root)の初回動線(契約 §5・§6-1)。`--here` はお試しモード強制
80
87
  // (現行動作)のため丸ごとスキップする(契約 §9・非 TTY と同じ現行動作互換の扱い)。
@@ -125,6 +132,11 @@ export async function run(args, options = {}) {
125
132
  log(describeVersionStatus(versionInfo, readCacheSync(resolveCachePath(env)), runtimeDiagnostics));
126
133
  }
127
134
 
135
+ if (bareNonInteractive) {
136
+ log('非対話シェルのため状態確認のみで終了します。');
137
+ return { exitCode: 0, scaffolded: state.scaffolded, claudeLaunched: false, opencodeLaunched: false };
138
+ }
139
+
128
140
  // 新版通知(契約 §4-1): キャッシュの読み比較のみ・ネットワークには一切触れない
129
141
  // (起動をブロックしない)。fetch は detached な子プロセスへ切り離し、
130
142
  // 結果は次回セッションで効く。
@@ -5,7 +5,8 @@ import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { spawn, spawnSync } from "node:child_process";
8
- import { fileURLToPath } from "node:url";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ import { resolveLauncherAssets } from "./repo-assets.mjs";
9
10
 
10
11
  const VOICEVOX_BASE_URL = "http://127.0.0.1:50021";
11
12
  const VOICEVOX_RUN_ENV = "VOICEVOX_RUN";
@@ -14,6 +15,28 @@ const FAL_TTS_URL = "https://fal.run/fal-ai/qwen-3-tts/text-to-speech/1.7b";
14
15
  const FAL_USD_PER_1000_CHARS = 0.09;
15
16
  const GEMINI_TTS_URL = "https://fal.run/fal-ai/gemini-tts";
16
17
  const GEMINI_USD_PER_1000_CHARS = 0.05;
18
+ const IRODORI_DEFAULT_URL = "http://127.0.0.1:8088";
19
+ const IRODORI_SETUP_URL = "https://github.com/Aratako/Irodori-TTS-Server";
20
+ const IRODORI_RECIPES = [
21
+ { id: "narrator-male", label: "落ち着いた男性ナレーター", caption: "落ち着いた低めの男性の声。聞き取りやすく、ナレーションのように丁寧に話す。", default: true },
22
+ { id: "bright-female", label: "明るい若い女性", caption: "明るく元気な若い女性の声。はきはきと楽しそうに話す。" },
23
+ { id: "slow-explainer", label: "低くゆっくりした解説", caption: "低めで落ち着いた声。ゆっくり、一語ずつ丁寧に説明する。" },
24
+ ];
25
+ function irodoriEndpoint(value, env = process.env) {
26
+ const raw = value ?? env.AKARI_IRODORI_URL ?? IRODORI_DEFAULT_URL;
27
+ let url;
28
+ try { url = new URL(raw); } catch { throw new PublicError("--irodori-url は http または https の URL にしてください", 2); }
29
+ if (!["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password || url.search || url.hash) {
30
+ throw new PublicError("--irodori-url は http または https のサーバー URL にしてください", 2);
31
+ }
32
+ return { base: `${url.origin}${url.pathname.replace(/\/+$/, "")}`, server: `${url.hostname}:${url.port || (url.protocol === "https:" ? "443" : "80")}`,
33
+ network: !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) };
34
+ }
35
+ function irodoriTimeout(env = process.env) {
36
+ const value = Number(env.AKARI_IRODORI_TIMEOUT_MS ?? 600_000);
37
+ if (!Number.isSafeInteger(value) || value <= 0) throw new PublicError("AKARI_IRODORI_TIMEOUT_MS は正の整数にしてください", 2);
38
+ return value;
39
+ }
17
40
  const GEMINI_VOICES = Object.entries({
18
41
  Leda: "Youthful", Achernar: "Soft", Achird: "Friendly", Algenib: "Gravelly",
19
42
  Algieba: "Smooth", Alnilam: "Firm", Aoede: "Breezy", Autonoe: "Bright",
@@ -29,11 +52,11 @@ const GEMINI_VOICES = Object.entries({
29
52
  const usage = [
30
53
  "使い方:",
31
54
  " akari narration generate \\",
32
- " --project <projectDir> --engine <voicevox|gemini-tts|fal-qwen3> \\",
55
+ " --project <projectDir> --engine <voicevox|gemini-tts|irodori|fal-qwen3> \\",
33
56
  " (--reading-file <読み原稿.txt> | --text <原稿>) [--script-file <表示原稿.txt>] \\",
34
57
  " [--t <タイムライン秒>] [--gain-db 0] [--id n-0001] \\",
35
58
  " [--speaker 3] [--voice Leda] [--style <text>] [--speed 1] \\",
36
- " [--profile owner-ja] [--caption-ref c-0001] [--dry-run] [--yes] [--apply] [--json]",
59
+ " [--profile owner-ja] [--irodori-url http://127.0.0.1:8088] [--caption-ref c-0001] [--dry-run] [--yes] [--apply] [--json]",
37
60
  ].join("\n");
38
61
  const commandUsage = [
39
62
  "使い方: akari narration <subcommand> [options]",
@@ -44,9 +67,144 @@ const commandUsage = [
44
67
  " start VOICEVOX エンジンを起動する",
45
68
  " stop AKARI が起動した VOICEVOX エンジンを止める",
46
69
  " voices 声一覧を表示する",
70
+ " verify 生成音声をこの Mac で聞き取り、字幕と照合する",
47
71
  "",
48
72
  usage,
49
73
  ].join("\n");
74
+ const verifyUsage = "使い方: akari narration verify --project <root> (--id <n-NNNN> | --audio <path> --text <字幕の文字>) [--reading <読み原稿>] [--backend auto|speechanalyzer|whisper] [--record <dir>] --json";
75
+ export const VERIFY_THRESHOLDS = Object.freeze({ ok: 0.9, check: 0.7 });
76
+
77
+ export function normalizeVerifyText(value) {
78
+ return String(value ?? "").normalize("NFKC").toLowerCase().replace(/[\p{P}\p{S}\s]/gu, "");
79
+ }
80
+
81
+ export function verifyLanguage(expected) {
82
+ return /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u.test(String(expected ?? "").normalize("NFKC")) ? "ja" : "auto";
83
+ }
84
+
85
+ export function compareNarrationText(expected, heard) {
86
+ const a = [...normalizeVerifyText(expected)], b = [...normalizeVerifyText(heard)];
87
+ const dp = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
88
+ for (let i = 0; i <= a.length; i++) dp[i][0] = i;
89
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j;
90
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
91
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
92
+ }
93
+ const operations = [];
94
+ let i = a.length, j = b.length;
95
+ while (i || j) {
96
+ if (i && j && a[i - 1] === b[j - 1] && dp[i][j] === dp[i - 1][j - 1]) {
97
+ operations.push({ equal: true }); i--; j--;
98
+ } else if (i && j && dp[i][j] === dp[i - 1][j - 1] + 1) {
99
+ operations.push({ expected: a[--i], heard: b[--j] });
100
+ } else if (i && dp[i][j] === dp[i - 1][j] + 1) {
101
+ operations.push({ expected: a[--i], heard: "" });
102
+ } else {
103
+ operations.push({ expected: "", heard: b[--j] });
104
+ }
105
+ }
106
+ const diffs = [];
107
+ let current;
108
+ for (const operation of operations.reverse()) {
109
+ if (operation.equal) { if (current) diffs.push(current); current = null; }
110
+ else {
111
+ current ??= { expected: "", heard: "" };
112
+ current.expected += operation.expected; current.heard += operation.heard;
113
+ }
114
+ }
115
+ if (current) diffs.push(current);
116
+ const rawScore = a.length || b.length ? 1 - dp[a.length][b.length] / Math.max(a.length, b.length) : 1;
117
+ const score = Number(rawScore.toFixed(3));
118
+ return { score, verdict: score >= VERIFY_THRESHOLDS.ok ? "ok" : score >= VERIFY_THRESHOLDS.check ? "check" : "ng", diffs };
119
+ }
120
+
121
+ function parseVerifyArguments(args) {
122
+ const options = { backend: "auto", reading: null, record: null, checkBackend: false };
123
+ const keys = new Set(["--project", "--id", "--audio", "--text", "--reading", "--backend", "--record"]);
124
+ for (let index = 1; index < args.length; index++) {
125
+ const flag = args[index];
126
+ if (flag === "--json" || flag === "--check-backend") { if (flag === "--check-backend") options.checkBackend = true; continue; }
127
+ if (!keys.has(flag) || !args[index + 1] || args[index + 1].startsWith("--")) throw new PublicError(`引数が不正です: ${flag}\n${verifyUsage}`, 2);
128
+ options[flag.slice(2)] = args[++index];
129
+ }
130
+ if (!options.project || !["auto", "speechanalyzer", "whisper"].includes(options.backend)) throw new PublicError(verifyUsage, 2);
131
+ if (!options.checkBackend && ((!!options.id) === (!!options.audio)) ) throw new PublicError(verifyUsage, 2);
132
+ if (options.id && !/^n-\d{4}$/.test(options.id)) throw new PublicError("--id は n- に続く 4 桁です", 2);
133
+ if (options.audio && options.text === undefined) throw new PublicError("--audio には --text が必要です", 2);
134
+ return options;
135
+ }
136
+
137
+ function resolveTranscribeModulePath(runtime = {}) {
138
+ const assets = (runtime.resolveLauncherAssets ?? resolveLauncherAssets)();
139
+ const relative = path.join("packages", "akari-tools", "src", "media", "transcribe.mjs");
140
+ const candidates = [path.join(assets.repoRoot, relative),
141
+ assets.mediaScript ? path.resolve(path.dirname(assets.mediaScript), "..", "src", "media", "transcribe.mjs") : null,
142
+ path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "vendor", relative)];
143
+ return candidates.find(candidate => candidate && fs.existsSync(candidate)) ?? null;
144
+ }
145
+
146
+ async function loadTranscribeModule(runtime = {}) {
147
+ const modulePath = (runtime.resolveTranscribeModulePath ?? resolveTranscribeModulePath)(runtime);
148
+ if (!modulePath) throw new PublicError("ローカル文字起こしの実装が同梱されていません", 3);
149
+ try { return await import(pathToFileURL(modulePath).href); }
150
+ catch { throw new PublicError("ローカル文字起こしの実装を読み込めません", 3); }
151
+ }
152
+
153
+ function verifyBackend(requested, runtime, transcribe) {
154
+ const speech = (runtime.speechAnalyzerAvailable ?? transcribe.speechAnalyzerAvailable)();
155
+ const whisper = (runtime.resolveWhisper ?? transcribe.resolveWhisper)();
156
+ if (requested === "speechanalyzer" && speech || requested === "whisper" && whisper || requested === "auto" && (speech || whisper)) {
157
+ return requested === "auto" ? speech ? "speech-analyzer" : "whisper-cpp" : requested === "whisper" ? "whisper-cpp" : "speech-analyzer";
158
+ }
159
+ throw new PublicError(requested === "speechanalyzer" ? "SpeechAnalyzer を利用できません" : requested === "whisper" ? "whisper.cpp の実行ファイルまたはモデルが見つかりません" : "この Mac に利用できる文字起こし backend がありません(SpeechAnalyzer / whisper.cpp)", 3);
160
+ }
161
+
162
+ async function runVerify(options, io, runtime = {}) {
163
+ let backend;
164
+ let transcribe;
165
+ try {
166
+ transcribe = await (runtime.loadTranscribeModule ?? loadTranscribeModule)(runtime);
167
+ backend = verifyBackend(options.backend, runtime, transcribe);
168
+ }
169
+ catch (error) {
170
+ if (error instanceof PublicError && error.exitCode === 3) { printCompactJson({ status: "unavailable", reason: error.message }, io.log); return 3; }
171
+ throw error;
172
+ }
173
+ if (options.checkBackend) { printCompactJson({ status: "ok", backend }, io.log); return 0; }
174
+ const root = path.resolve(options.project);
175
+ let entry;
176
+ if (options.id) {
177
+ const edit = JSON.parse(fs.readFileSync(path.join(root, "edit.json"), "utf8"));
178
+ entry = edit.audio?.narration?.find(item => item.id === options.id);
179
+ if (!entry?.path || typeof entry.script !== "string") throw new PublicError(`音声 ${options.id} または字幕の文字が見つかりません`, 2);
180
+ }
181
+ const expected = entry?.script ?? options.text;
182
+ const audio = path.resolve(root, entry?.path ?? options.audio);
183
+ if (entry && !audio.startsWith(root + path.sep)) throw new PublicError("音声パスがプロジェクト外です", 2);
184
+ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "akari-narration-verify-"));
185
+ const started = performance.now();
186
+ try {
187
+ const copy = path.join(temporary, path.basename(audio));
188
+ fs.copyFileSync(audio, copy);
189
+ const transcript = await (runtime.transcribeMedia ?? transcribe.transcribeMedia)(copy, {
190
+ cwd: temporary, noRecord: true, wordBook: false, lang: verifyLanguage(expected),
191
+ ...(options.backend === "auto" ? {} : { backend }),
192
+ });
193
+ const heard = (transcript.segments ?? []).map(segment => segment.text ?? "").join("").trim();
194
+ const comparison = compareNarrationText(expected, heard);
195
+ const result = { version: 1, status: "ok", id: options.id ?? null, ...comparison, expected, heard,
196
+ ...(options.reading !== null ? { reading: options.reading } : {}),
197
+ backend: transcript.backend ?? backend, elapsed_s: Number(((performance.now() - started) / 1000).toFixed(3)) };
198
+ if (options.record) {
199
+ fs.mkdirSync(options.record, { recursive: true });
200
+ fs.appendFileSync(path.join(options.record, "narration-verify.jsonl"), `${JSON.stringify({ expected, reading: options.reading ?? entry?.reading ?? null,
201
+ heard, score: result.score, verdict: result.verdict, engine: entry?.provenance?.engine ?? null,
202
+ voice: entry?.provenance?.voice ?? null, backend: result.backend, generated_at: new Date().toISOString() })}\n`);
203
+ }
204
+ printCompactJson(result, io.log);
205
+ return 0;
206
+ } finally { fs.rmSync(temporary, { recursive: true, force: true }); }
207
+ }
50
208
 
51
209
  class PublicError extends Error {
52
210
  constructor(message, exitCode = 1) {
@@ -75,7 +233,7 @@ function isFiniteNumber(value) {
75
233
  const VALUE_OPTIONS = new Set([
76
234
  "--project", "--engine", "--reading-file", "--script-file",
77
235
  "--t", "--gain-db", "--id", "--speaker", "--profile",
78
- "--voice", "--style", "--speed", "--text", "--caption-ref",
236
+ "--voice", "--style", "--speed", "--text", "--caption-ref", "--irodori-url",
79
237
  ]);
80
238
  const FLAG_OPTIONS = new Set(["--dry-run", "--yes", "--apply", "--json"]);
81
239
 
@@ -93,7 +251,7 @@ function parseArguments(argv) {
93
251
  id: null,
94
252
  speaker: "3",
95
253
  profile: null,
96
- voice: "Leda", style: null, speed: null, text: null, captionRef: null, json: false,
254
+ voice: null, style: null, speed: null, text: null, captionRef: null, irodoriUrl: null, json: false,
97
255
  dryRun: false,
98
256
  yes: false,
99
257
  apply: false,
@@ -121,6 +279,7 @@ function parseArguments(argv) {
121
279
  case "--speed": options.speed = Number(value); break;
122
280
  case "--text": options.text = value; break;
123
281
  case "--caption-ref": options.captionRef = value; break;
282
+ case "--irodori-url": options.irodoriUrl = value; break;
124
283
  default: break;
125
284
  }
126
285
  } else if (argument === "--dry-run") {
@@ -137,15 +296,22 @@ function parseArguments(argv) {
137
296
  }
138
297
 
139
298
  if (!options.project) throw new PublicError("--project が必要です");
140
- if (!["voicevox", "gemini-tts", "fal-qwen3"].includes(options.engine)) {
141
- throw new PublicError("--engine には voicevox、gemini-tts または fal-qwen3 を指定してください", 2);
299
+ if (!["voicevox", "gemini-tts", "irodori", "fal-qwen3"].includes(options.engine)) {
300
+ throw new PublicError("--engine には voicevox、gemini-tts、irodori または fal-qwen3 を指定してください", 2);
142
301
  }
302
+ if (options.engine === "irodori") {
303
+ options.voice ??= "narrator-male";
304
+ if (!IRODORI_RECIPES.some(recipe => recipe.id === options.voice) && options.voice !== "custom") throw new PublicError("彩の声レシピが不明です", 2);
305
+ if (options.voice === "custom" && !options.style?.trim()) throw new PublicError("自分で書く声には --style が必要です", 2);
306
+ options.irodori = irodoriEndpoint(options.irodoriUrl);
307
+ irodoriTimeout();
308
+ } else options.voice ??= "Leda";
143
309
  if (!options.readingFile && !options.text) throw new PublicError("--reading-file または --text が必要です");
144
310
  if (options.engine === "gemini-tts" && !GEMINI_VOICES.some(({ id }) => id === options.voice)) {
145
311
  throw new PublicError(`--voice に使える声: ${GEMINI_VOICES.map(({ id }) => id).join(", ")}`, 2);
146
312
  }
147
- if (options.speed !== null && (!isFiniteNumber(options.speed) || options.speed < 0.5 || options.speed > 2)) {
148
- throw new PublicError("--speed は 0.5 から 2.0 の範囲で指定してください", 2);
313
+ if (options.speed !== null && (!isFiniteNumber(options.speed) || options.speed < (options.engine === "irodori" ? 0.25 : 0.5) || options.speed > (options.engine === "irodori" ? 4 : 2))) {
314
+ throw new PublicError(options.engine === "irodori" ? "--speed は 0.25 から 4.0 の範囲で指定してください" : "--speed は 0.5 から 2.0 の範囲で指定してください", 2);
149
315
  }
150
316
  if (options.captionRef !== null && !/^c-\d{4}$/.test(options.captionRef)) {
151
317
  throw new PublicError("--caption-ref は c- に続く 4 桁の数字で指定してください", 2);
@@ -203,7 +369,7 @@ function computeNextId(projectDir) {
203
369
  }
204
370
 
205
371
  function extensionFor(engine) {
206
- return engine === "voicevox" ? "wav" : "mp3";
372
+ return ["voicevox", "irodori"].includes(engine) ? "wav" : "mp3";
207
373
  }
208
374
 
209
375
  function relativeOutputPath(id, engine) {
@@ -612,6 +778,30 @@ function maskKey(secret) {
612
778
  return secret ? "***configured***" : "***unconfigured***";
613
779
  }
614
780
 
781
+ async function probeIrodori(endpoint, fetchImpl = fetch) {
782
+ try {
783
+ const response = await fetchImpl(`${endpoint.base}/health`, { signal: AbortSignal.timeout(2000) });
784
+ return response.ok;
785
+ } catch { return false; }
786
+ }
787
+
788
+ async function synthesizeIrodori(readingText, options, fetchImpl = fetch) {
789
+ const caption = options.style?.trim() || IRODORI_RECIPES.find(recipe => recipe.id === options.voice)?.caption;
790
+ let response;
791
+ try {
792
+ response = await fetchImpl(`${options.irodori.base}/v1/audio/speech`, {
793
+ method: "POST", headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(irodoriTimeout()),
794
+ body: JSON.stringify({ model: "irodori-tts", input: readingText, voice: "none", response_format: "wav",
795
+ speed: options.speed ?? 1, irodori: { caption } }),
796
+ });
797
+ } catch (error) { throw new PublicError(`彩サーバーに接続できません: ${error?.message ?? error}`); }
798
+ const buffer = Buffer.from(await response.arrayBuffer());
799
+ if (!response.ok || buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") {
800
+ throw new PublicError(`彩サーバーが音声を返しませんでした(HTTP ${response.status}): ${buffer.toString("utf8", 0, 300)}`);
801
+ }
802
+ return buffer;
803
+ }
804
+
615
805
  async function listEngines(runtime = {}) {
616
806
  let voicevox;
617
807
  const state = await probeVoicevox(runtime.fetchImpl || fetch);
@@ -635,15 +825,27 @@ async function listEngines(runtime = {}) {
635
825
  : { state: "unconfigured", label: "fal の鍵を登録" };
636
826
  const profilesDir = path.join(os.homedir(), ".config", "akari-video", "voice-profiles");
637
827
  const hasProfiles = fs.existsSync(profilesDir) && fs.readdirSync(profilesDir, { withFileTypes: true }).some((entry) => entry.isDirectory());
828
+ let endpoint;
829
+ try { endpoint = irodoriEndpoint(runtime.irodoriUrl, runtime.env || process.env); }
830
+ catch (error) { if (!(error instanceof PublicError)) throw error; }
831
+ const irodoriAvailable = endpoint ? await probeIrodori(endpoint, runtime.fetchImpl || fetch) : false;
638
832
  return { version: 1, engines: [
639
833
  { id: "voicevox", label: "VOICEVOX", place: "local", price: { usd_per_1000_chars: 0, verified: true }, availability: voicevox, credit_required: true, supports: { speed: true, style: false } },
640
834
  { id: "gemini-tts", label: "Gemini 2.5 Flash TTS", place: "cloud", provider: "fal", price: { usd_per_1000_chars: GEMINI_USD_PER_1000_CHARS, verified: false, as_of: "2026-09-22" }, availability: falAvailability, default_voice: "Leda", credit_required: false, supports: { speed: false, style: true } },
641
- { id: "irodori", label: "彩(Irodori-TTS)", place: "local", availability: { state: "unsupported", label: "近日" } },
835
+ { id: "irodori", label: "彩(Irodori-TTS)", place: endpoint?.network ? "network" : "local", experimental: true,
836
+ price: { usd_per_1000_chars: 0, verified: true }, credit_required: false, default_voice: "narrator-male",
837
+ supports: { speed: true, style: true }, availability: !endpoint
838
+ ? { state: "unconfigured", label: "接続先 URL が正しくありません(お試し)", detail: { setup_url: IRODORI_SETUP_URL } }
839
+ : irodoriAvailable
840
+ ? { state: "available", label: "お試し · 接続済み", detail: { url: endpoint.server } }
841
+ : { state: "unconfigured", label: "Irodori サーバーにつながりません(お試し)", detail: { setup_url: IRODORI_SETUP_URL } } },
642
842
  { id: "fal-qwen3", label: "fal Qwen3-TTS", place: "cloud", provider: "fal", price: { usd_per_1000_chars: FAL_USD_PER_1000_CHARS, verified: false }, availability: !configured ? falAvailability : hasProfiles ? { state: "available", label: "声プロファイルを使用できます" } : { state: "needs", label: "声プロファイルを作成" }, credit_required: false, supports: { speed: false, style: false } },
643
843
  ] };
644
844
  }
645
845
 
646
846
  async function listVoices(engine) {
847
+ if (engine === "irodori") return [...IRODORI_RECIPES.map(({ id, label, default: isDefault }) => ({ id, label, ...(isDefault ? { default: true } : {}) })),
848
+ { id: "custom", label: "自分で書く(声の指示)" }];
647
849
  if (engine === "gemini-tts") return GEMINI_VOICES;
648
850
  if (engine === "voicevox") {
649
851
  const handle = await ensureVoicevoxEngine();
@@ -668,13 +870,15 @@ async function listVoices(engine) {
668
870
 
669
871
  function parseListArguments(args) {
670
872
  let engine = null;
873
+ let irodoriUrl = null;
671
874
  for (let index = 1; index < args.length; index += 1) {
672
875
  if (args[index] === "--json") continue;
673
876
  if (args[index] === "--engine" && args[index + 1]) { engine = args[++index]; continue; }
877
+ if (args[index] === "--irodori-url" && args[index + 1]) { irodoriUrl = args[++index]; continue; }
674
878
  throw new PublicError(`不明な引数です: ${args[index]}`, 2);
675
879
  }
676
880
  if (args[0] === "voices" && !engine) throw new PublicError("voices には --engine が必要です", 2);
677
- return engine;
881
+ return { engine, irodoriUrl };
678
882
  }
679
883
 
680
884
  async function runDryRun(options, readingText, io) {
@@ -708,6 +912,13 @@ async function runDryRun(options, readingText, io) {
708
912
  }, io.log);
709
913
  return;
710
914
  }
915
+ if (options.engine === "irodori") {
916
+ emit({ dry_run: true, engine: options.engine, output_path: outputPath, estimated_cost_usd: 0,
917
+ request: { endpoint: `${options.irodori.base}/v1/audio/speech`, body: { model: "irodori-tts", input: readingText,
918
+ voice: "none", response_format: "wav", speed: options.speed ?? 1,
919
+ irodori: { caption: options.style?.trim() || IRODORI_RECIPES.find(recipe => recipe.id === options.voice)?.caption } } } }, io.log);
920
+ return;
921
+ }
711
922
  const falKey = resolveFalKey();
712
923
  const meta = readProfileMeta(options.profile);
713
924
  const payload = buildFalPayload(readingText, meta);
@@ -726,7 +937,7 @@ async function runDryRun(options, readingText, io) {
726
937
  }
727
938
 
728
939
  function durationForAudio(buffer, outputPath, engine, warnings) {
729
- if (engine === "voicevox") {
940
+ if (["voicevox", "irodori"].includes(engine)) {
730
941
  if (buffer.length >= 44 && buffer.toString("ascii", 0, 4) === "RIFF") {
731
942
  const bytesPerSecond = buffer.readUInt32LE(28);
732
943
  const dataOffset = buffer.indexOf("data", 36, "ascii");
@@ -763,7 +974,7 @@ async function runGenerate(options, io) {
763
974
  let provenance;
764
975
  let costUsd = 0;
765
976
  const warnings = [];
766
- if (options.speed !== null && options.engine !== "voicevox") {
977
+ if (options.speed !== null && !["voicevox", "irodori"].includes(options.engine)) {
767
978
  const warning = `${options.engine} は --speed に対応していないため無視しました`;
768
979
  warnings.push(warning);
769
980
  io.logError(warning);
@@ -791,6 +1002,10 @@ async function runGenerate(options, io) {
791
1002
  } finally {
792
1003
  if (engineHandle.startedByUs) await stopVoicevoxEngine(engineHandle.child);
793
1004
  }
1005
+ } else if (options.engine === "irodori") {
1006
+ audioBuffer = await synthesizeIrodori(readingText, options);
1007
+ provenance = { provider: "irodori", engine: "irodori-tts-v4-small", voice: options.style?.trim() ? "caption:custom" : `recipe:${options.voice}`,
1008
+ generated_at: new Date().toISOString(), experimental: true, server: options.irodori.server };
794
1009
  } else {
795
1010
  const estimatedCostUsd = options.engine === "gemini-tts"
796
1011
  ? estimateGeminiTtsCostUsd(readingText.length) : estimateFalCostUsd(readingText);
@@ -837,7 +1052,7 @@ async function runGenerate(options, io) {
837
1052
  if (options.json) printCompactJson({ version: 1, status: "ok", id, path: relativePath,
838
1053
  duration_s: duration, engine: options.engine, voice: provenance.voice, cost_usd: costUsd,
839
1054
  applied: options.apply, caption_ref: options.captionRef, provenance, warnings,
840
- ...(options.speed !== null ? { speed_applied: options.engine === "voicevox" } : {}) }, io.log);
1055
+ ...(options.speed !== null ? { speed_applied: ["voicevox", "irodori"].includes(options.engine) } : {}) }, io.log);
841
1056
  else printJson(entry, io.log);
842
1057
  return 0;
843
1058
  }
@@ -856,6 +1071,16 @@ export async function runNarrationCommand(args, commandOptions = {}) {
856
1071
  io.log(usage);
857
1072
  return { exitCode: 0 };
858
1073
  }
1074
+ if (args[0] === 'verify') {
1075
+ try { return { exitCode: await runVerify(parseVerifyArguments(args), io, commandOptions.verifyRuntime) }; }
1076
+ catch (error) {
1077
+ const code = error instanceof PublicError ? error.exitCode : 1;
1078
+ const message = error instanceof PublicError ? error.message : `聞き取りに失敗しました: ${error?.message ?? error}`;
1079
+ io.logError(message);
1080
+ printCompactJson({ error: message }, io.log);
1081
+ return { exitCode: code };
1082
+ }
1083
+ }
859
1084
  if (["start", "stop"].includes(args[0])) {
860
1085
  try {
861
1086
  if (args.length !== 4 || args[1] !== "--engine" || args[2] !== "voicevox" || args[3] !== "--json") {
@@ -871,8 +1096,9 @@ export async function runNarrationCommand(args, commandOptions = {}) {
871
1096
  }
872
1097
  if (["engines", "voices"].includes(args[0])) {
873
1098
  try {
874
- const engine = parseListArguments(args);
875
- printCompactJson(args[0] === "engines" ? await listEngines(commandOptions.engineRuntime) : { version: 1, engine, voices: await listVoices(engine) }, io.log);
1099
+ const { engine, irodoriUrl } = parseListArguments(args);
1100
+ printCompactJson(args[0] === "engines" ? await listEngines({ ...commandOptions.engineRuntime, irodoriUrl }) :
1101
+ { version: 1, engine, voices: await listVoices(engine) }, io.log);
876
1102
  return { exitCode: 0 };
877
1103
  } catch (error) {
878
1104
  const message = error instanceof PublicError ? error.message : "声一覧を取得できませんでした";
@@ -412,17 +412,19 @@ export async function runStoreCommand(args, options = {}) {
412
412
  if (dl.exitCode !== 0) return { exitCode: 1 };
413
413
  const extractDir = path.join(stage, 'x');
414
414
  mkdirSync(extractDir, { recursive: true });
415
- // unzip 優先・無ければ bsdtar(Windows 10+ の tar は zip を読める)
415
+ // 共有 zip 展開器は tar → unzip → Expand-Archive の順で試す。
416
416
  const extracted = options.extract
417
417
  ? options.extract(dl.filePath, extractDir)
418
- : ['unzip', 'tar'].some((tool) => {
419
- const cmdArgs = tool === 'unzip' ? ['-o', '-q', dl.filePath, '-d', extractDir] : ['-xf', dl.filePath, '-C', extractDir];
418
+ : await (async () => {
420
419
  try {
421
- return spawnSync(tool, cmdArgs, { stdio: 'ignore' }).status === 0;
422
- } catch {
423
- return false;
424
- }
425
- });
420
+ const checkoutModule = new URL('../../asset-resolver/src/paid-zip.mjs', import.meta.url);
421
+ const vendorModule = new URL('../vendor/packages/asset-resolver/src/paid-zip.mjs', import.meta.url);
422
+ const moduleUrl = existsSync(checkoutModule) ? checkoutModule : vendorModule;
423
+ const { extractZipWithTools } = await import(moduleUrl.href);
424
+ extractZipWithTools(dl.filePath, extractDir);
425
+ return true;
426
+ } catch (error) { log(error.message); return false; }
427
+ })();
426
428
  if (!extracted) {
427
429
  log(`zip の展開に失敗しました。手動で展開してください: ${dl.filePath}`);
428
430
  return { exitCode: 1 };
@@ -508,8 +510,8 @@ export async function runStoreCommand(args, options = {}) {
508
510
  }
509
511
  }
510
512
  if (!runtimeIds) {
511
- // overlay-runtime は launcher の npm tarball に同梱されない。照合不能は
512
- // manifest 不備ではないため warning に落とし、要求 id を既知扱いして続行する。
513
+ // 通常の launcher 配布には overlay-runtime が入る。旧配布物や破損した
514
+ // 配置で照合不能なら warning に落とし、要求 id を既知扱いして続行する。
513
515
  requirementWarnings.push('runtime registry が見つからないため runtime id の照合をスキップしました。');
514
516
  runtimeIds = manifest.requires?.runtimes ?? [];
515
517
  }
@@ -14,6 +14,20 @@ export const VENDOR_SOURCES = [
14
14
  'presets/textanim',
15
15
  'assets/font/noto-sans-jp/NotoSansJP-Variable.ttf',
16
16
  'packages/schemas',
17
+ // schemas の asset / kit 検証は runtimes.mjs を実行し、runtime 宣言は src の
18
+ // レジストリ・ブラウザスクリプトと既定フォントをファイルとして読み込む。
19
+ 'packages/overlay-runtime/runtimes.mjs',
20
+ 'packages/overlay-runtime/src',
21
+ 'packages/overlay-runtime/test-harness/fonts/ZenKakuGothicNew-Black.ttf',
22
+ // edit-lint と world-map validator が参照する実行時閉包。
23
+ 'packages/render-cut/src/fragment-assets.mjs',
24
+ 'packages/render-cut/src/render-inputs.mjs',
25
+ 'packages/render-cut/src/caption-font.mjs',
26
+ 'packages/render-cut/src/html-scan.mjs',
27
+ 'packages/render-cut/src/library-reference.mjs',
28
+ 'packages/word-book/src/index.mjs',
29
+ 'packages/akari-tools/src/world/invariants.mjs',
30
+ 'packages/akari-tools/src/world/normalize.mjs',
17
31
  'packages/project-scaffold',
18
32
  // analysis-report は package.json / README.md を capability source として既に収集する。
19
33
  // 実行に必要な CLI と同居必須テンプレートだけを追加し、test/ は配布しない。
@@ -146,6 +146,8 @@
146
146
  "skills/beat-sync-edit/SKILL.md",
147
147
  "skills/beat-sync-edit/verify-loop.md",
148
148
  "skills/compile-review-session/compilation-rules.md",
149
+ "skills/compile-review-session/dev-fixtures/issue-69-review-json/README.md",
150
+ "skills/compile-review-session/dev-fixtures/issue-79/README.md",
149
151
  "skills/compile-review-session/dev-fixtures/README.md",
150
152
  "skills/compile-review-session/SKILL.md",
151
153
  "skills/create-project/SKILL.md",
@@ -3,6 +3,13 @@
3
3
  > 正本の写し(2026-09-13・2026-09-21 改訂)・技術部分のみ。内部正本の §1〜§9 と §11 を公開向けに収録する。
4
4
  > 節番号の対応: 本書 §1〜§9 = 内部正本 §1〜§9、本書 §10(コマ保存)= 内部正本 §11。
5
5
 
6
+ ### 0-5. 裁定 D(確定・2026-09-23)
7
+
8
+ | # | 決定 | 理由 |
9
+ |---|---|---|
10
+ | D-1 | **音の空の枠** = 枠の長さの無音 wav を素材に持つ音声の media item。隣の meta.json は `kind: "audio"`・`status: "planned"`。置き場は映像の空の枠と同じ `assets/generated/` | ナレーションの差し替え先にできる |
11
+ | D-2 | **音声トラックの表示名は上から数える**(映像に近い方が A1、下へ A2, A3)。映像は下から(V1 がいちばん下の映像)。edit.json の `tracks[]` の並び・id は変えない | A1 の下に足したトラックが A2 になり、A1 の名前を保てる |
12
+
6
13
  ## 1. 継ぎ目(仮枠とタイムライン)
7
14
 
8
15
  1. 仮枠は **edit.json v2 の media item** で、`sources[].path` が静止画(png / jpg)か文字カード png を指す。cuts で尺を持つ(静止画クリップ契約 2026-08-12 のとおり)
@@ -13,6 +20,7 @@
13
20
  6. plan.json の仮枠役(`confidence` / `fill`)は退役。`plan-comments.json` の `pass: "scaffold"` の対象は `slot` から **clip id** へ
14
21
  7. **空の枠**(2026-09-21): 仮枠ツール(F)で空いているところに描いた枠は、prompt 未記入の**文字カード png** を素材に持つ media item(絵のないクリップを文字カード png で表す規則の延長)。0.5 秒刻み・端に吸着・隣に食い込まない・0.5 秒未満にしない。専用トラックは作らない(どのトラックにも置ける)
15
22
  実装: 選択 V / 分割 C / 仮枠 F(従来の A / B も有効)。仮枠はスナップ OFF でも近い可視端・再生ヘッドを 0.5 秒格子より優先し、`assets/generated/frame-<時刻>-<一意接尾辞>.png` と隣の `.meta.json` に保存する。
23
+ 音声トラックにも無音 wav の枠を描ける。トラックの無い場所では上に映像トラック、下に音声トラックを足して置き、undo 1 回で追加したトラック・枠・source を消す。
16
24
  8. **すき間から作る枠**(2026-09-21): すき間をクリック →「あいだを生成」で、すき間の位置と長さの枠を置く。前のクリップの最後のコマ・次のクリップの最初のコマを抽出して両端(②③)に入れる。動画からの抽出は 1 コマを `assets/captures/` へ書き出す(§10)
17
25
  実装: 同じ映像トラックで前後にクリップがある 0.5 秒以上の空きだけをすき間とする。前は `source.out − 1/output.fps`(in 以上)、次は `source.in` の合成前のコマを使い、静止画は元パスを使う。
18
26
  抽出 PNG は `assets/captures/frame-<素材名>-<秒>-<素材・更新時刻等のハッシュ>.png` に保存し、同じ素材・同じ時刻は再利用する。
@@ -47,7 +55,7 @@ meta の無い静止画は、最初に下書きを書くときにシェルが `k
47
55
  ```json
48
56
  {
49
57
  "version": 1,
50
- "kind": "video", // "still" | "video" | "frames"
58
+ "kind": "video", // "still" | "video" | "frames" | "audio"(音の空の枠)
51
59
  "status": "done", // "planned" | "generating" | "done" | "failed"
52
60
  "model": { "id": "fal:h3-i2v", "endpoint": "minimax/h3/image-to-video", "as_of": "2026-09-12" },
53
61
  "inputs": { // §2 の 9 スロット。値の無いスロットは null / []
@@ -207,6 +215,8 @@ Kling v3 standard i2v / Kling v3 pro i2v / Veo 3.1 first-last / Veo 3.1 referenc
207
215
 
208
216
  ## 7. 差し替え規則(done になったとき)
209
217
 
218
+ 空の枠・静止画は AI タブの「静止画」(Codex)で作った絵へ差し替えられる。item と映像・色の設定を保ち、差し替えは undo 1 回で戻る。
219
+
210
220
  ### 7-1. 何を書き換えるか
211
221
 
212
222
  同じ item の `sources[].path` を mp4 に。item id・トラック・`at` は不変。静止画の path は生成物 meta の `placeholder`(§3-2)に残る(由来。9/13 時点の meta は `inputs.first_frame`)。**1 手の undo**。
@@ -226,6 +236,8 @@ Kling v3 standard i2v / Kling v3 pro i2v / Veo 3.1 first-last / Veo 3.1 referenc
226
236
 
227
237
  生成クリップの音声トラックは**既定 mute**(`item.mute: true`)。ナレーションと BGM が正。右パネルで解除できる。H3 のように欄なしで音が付くモデルでも同じ。
228
238
 
239
+ 音の空の枠は AI タブのナレーションで声の素材へ差し替える。声が枠より長く、同じトラックの後ろに重なる場合は下の空いた音声トラックへ置き、既存のクリップは動かさない。
240
+
229
241
  ## 8. 費用承認(有償生成のゲート)
230
242
 
231
243
  1. 語彙: 有償生成の承認は「**費用承認**」。「判子」は 9/6 契約どおり書き出しの 1 回にだけ使う
@@ -236,6 +248,8 @@ Kling v3 standard i2v / Kling v3 pro i2v / Veo 3.1 first-last / Veo 3.1 referenc
236
248
  ## 9. 用語(混同を防ぐ)
237
249
 
238
250
  右パネルのタブ表示名は「AI」(id は `generation`)。入口は行為のタイル一覧で、選ぶと既存の生成フォームを専用パネルに表示する。
251
+ AI タブの「直す」に文字起こしを置き、台本パネルの処理を呼ぶ。済みなら字幕を見せる。
252
+ 素材の AI タブは素材パネルで選ぶと右パネルに表示し、結果は新しい素材として増やす(文字起こしは台本)。
239
253
 
240
254
  | 語 | 意味 |
241
255
  |---|---|
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.80",
3
+ "version": "0.1.81",
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": [