akari-video 0.1.77 → 0.1.80

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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/narration-command.mjs +345 -36
  3. package/vendor/docs/contract-2026-07-18-edit-json-v1-sources.md +1 -0
  4. package/vendor/docs/contract-2026-07-20-edit-json-v1-narration.md +5 -0
  5. package/vendor/docs/contract-2026-08-02-preview-parity.md +9 -0
  6. package/vendor/docs/contract-2026-08-03-caption-display-encoding-qc-v1.md +1 -0
  7. package/vendor/docs/contract-2026-08-09-transform-keyframes-v0.md +9 -0
  8. package/vendor/docs/contract-2026-08-30-motion-and-keyframes-v0.md +9 -0
  9. package/vendor/docs/contract-2026-09-13-generation-v0.md +4 -0
  10. package/vendor/packages/akari-launcher/package.json +1 -1
  11. package/vendor/packages/asset-resolver/bin/akari-assets.mjs +23 -1
  12. package/vendor/packages/asset-resolver/src/library-check.mjs +151 -0
  13. package/vendor/packages/asset-resolver/src/library-usage.mjs +56 -0
  14. package/vendor/packages/asset-resolver/test/cli-guards.test.mjs +5 -3
  15. package/vendor/packages/asset-resolver/test/library-check.test.mjs +83 -0
  16. package/vendor/packages/asset-resolver/test/library-usage.test.mjs +25 -0
  17. package/vendor/packages/creator-root/src/index.mjs +32 -12
  18. package/vendor/packages/creator-root/test/library-migration.test.mjs +54 -1
  19. package/vendor/packages/edit-lint/src/edit-lint.mjs +22 -26
  20. package/vendor/packages/edit-store/lib/canonical.js +5 -1
  21. package/vendor/packages/edit-store/lib/caption-display.js +64 -12
  22. package/vendor/packages/edit-store/lib/caption-store.js +50 -1
  23. package/vendor/packages/edit-store/lib/caption-words-rederive.d.ts +20 -0
  24. package/vendor/packages/edit-store/lib/caption-words-rederive.js +295 -15
  25. package/vendor/packages/edit-store/lib/edit-store.d.ts +8 -0
  26. package/vendor/packages/edit-store/lib/edit-store.js +14 -10
  27. package/vendor/packages/edit-store/lib/edit-v2-item-write.d.ts +2 -0
  28. package/vendor/packages/edit-store/lib/edit-v2-item-write.js +31 -7
  29. package/vendor/packages/edit-store/lib/edit-v2.d.ts +2 -0
  30. package/vendor/packages/edit-store/lib/edit-v2.js +13 -3
  31. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.d.ts +2 -2
  32. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.js +2 -1
  33. package/vendor/packages/edit-store/lib/index.d.ts +1 -0
  34. package/vendor/packages/edit-store/lib/index.js +4 -1
  35. package/vendor/packages/edit-store/lib/internal-model.js +2 -0
  36. package/vendor/packages/edit-store/lib/transform.d.ts +8 -0
  37. package/vendor/packages/edit-store/lib/transform.js +21 -0
  38. package/vendor/packages/edit-store/lib/tree-ops.d.ts +1 -1
  39. package/vendor/packages/edit-store/lib/tree-ops.js +16 -5
  40. package/vendor/packages/edit-store/lib/webview-kernel.js +18 -7
  41. package/vendor/packages/schemas/bin/validate-edit.mjs +10 -5
  42. package/vendor/packages/schemas/bin/validate-sites.mjs +31 -0
  43. package/vendor/packages/schemas/edit.schema.json +8 -2
  44. package/vendor/packages/schemas/engine-capabilities.json +4 -3
  45. package/vendor/packages/schemas/package.json +4 -2
  46. package/vendor/packages/schemas/site.schema.json +26 -0
  47. package/vendor/packages/schemas/test/anisotropic-transform.test.mjs +22 -0
  48. package/vendor/packages/schemas/test/engine-capabilities.test.mjs +1 -1
  49. package/vendor/packages/schemas/test/validate-edit.test.mjs +28 -0
  50. package/vendor/packages/schemas/test/validate-sites.test.mjs +23 -0
  51. package/vendor/skills/generate-narration/engines.md +21 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.77",
3
+ "version": "0.1.80",
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": {
@@ -12,20 +12,38 @@ const VOICEVOX_RUN_ENV = "VOICEVOX_RUN";
12
12
  const VOICEVOX_STARTUP_TIMEOUT_MS = 60_000;
13
13
  const FAL_TTS_URL = "https://fal.run/fal-ai/qwen-3-tts/text-to-speech/1.7b";
14
14
  const FAL_USD_PER_1000_CHARS = 0.09;
15
+ const GEMINI_TTS_URL = "https://fal.run/fal-ai/gemini-tts";
16
+ const GEMINI_USD_PER_1000_CHARS = 0.05;
17
+ const GEMINI_VOICES = Object.entries({
18
+ Leda: "Youthful", Achernar: "Soft", Achird: "Friendly", Algenib: "Gravelly",
19
+ Algieba: "Smooth", Alnilam: "Firm", Aoede: "Breezy", Autonoe: "Bright",
20
+ Callirrhoe: "Easy-going", Charon: "Informative", Despina: "Smooth",
21
+ Enceladus: "Breathy", Erinome: "Clear", Fenrir: "Excitable", Gacrux: "Mature",
22
+ Iapetus: "Clear", Kore: "Firm", Laomedeia: "Upbeat", Orus: "Firm",
23
+ Pulcherrima: "Forward", Puck: "Upbeat", Rasalgethi: "Informative",
24
+ Sadachbia: "Lively", Sadaltager: "Knowledgeable", Schedar: "Even",
25
+ Sulafat: "Warm", Umbriel: "Easy-going", Vindemiatrix: "Gentle",
26
+ Zephyr: "Bright", Zubenelgenubi: "Casual",
27
+ }).map(([id, description]) => ({ id, label: `${id}(${description})`, ...(id === "Leda" ? { default: true } : {}) }));
15
28
 
16
29
  const usage = [
17
30
  "使い方:",
18
31
  " akari narration generate \\",
19
- " --project <projectDir> --engine <voicevox|fal-qwen3> \\",
20
- " --reading-file <読み原稿.txt> [--script-file <表示原稿.txt>] \\",
21
- " --t <タイムライン秒> [--gain-db 0] [--id n-0001] \\",
22
- " [--speaker 3] [--profile owner-ja] [--dry-run] [--yes] [--apply]",
32
+ " --project <projectDir> --engine <voicevox|gemini-tts|fal-qwen3> \\",
33
+ " (--reading-file <読み原稿.txt> | --text <原稿>) [--script-file <表示原稿.txt>] \\",
34
+ " [--t <タイムライン秒>] [--gain-db 0] [--id n-0001] \\",
35
+ " [--speaker 3] [--voice Leda] [--style <text>] [--speed 1] \\",
36
+ " [--profile owner-ja] [--caption-ref c-0001] [--dry-run] [--yes] [--apply] [--json]",
23
37
  ].join("\n");
24
38
  const commandUsage = [
25
39
  "使い方: akari narration <subcommand> [options]",
26
40
  "",
27
41
  "サブコマンド:",
28
42
  " generate 原稿からナレーション音声を生成する",
43
+ " engines エンジン一覧を表示する",
44
+ " start VOICEVOX エンジンを起動する",
45
+ " stop AKARI が起動した VOICEVOX エンジンを止める",
46
+ " voices 声一覧を表示する",
29
47
  "",
30
48
  usage,
31
49
  ].join("\n");
@@ -40,6 +58,7 @@ class PublicError extends Error {
40
58
  function printJson(value, log = (line) => console.log(line)) {
41
59
  log(JSON.stringify(value, null, 2));
42
60
  }
61
+ function printCompactJson(value, log) { log(JSON.stringify(value)); }
43
62
 
44
63
  function sleep(ms) {
45
64
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -56,8 +75,9 @@ function isFiniteNumber(value) {
56
75
  const VALUE_OPTIONS = new Set([
57
76
  "--project", "--engine", "--reading-file", "--script-file",
58
77
  "--t", "--gain-db", "--id", "--speaker", "--profile",
78
+ "--voice", "--style", "--speed", "--text", "--caption-ref",
59
79
  ]);
60
- const FLAG_OPTIONS = new Set(["--dry-run", "--yes", "--apply"]);
80
+ const FLAG_OPTIONS = new Set(["--dry-run", "--yes", "--apply", "--json"]);
61
81
 
62
82
  function parseArguments(argv) {
63
83
  if (argv[0] !== "generate") {
@@ -73,6 +93,7 @@ function parseArguments(argv) {
73
93
  id: null,
74
94
  speaker: "3",
75
95
  profile: null,
96
+ voice: "Leda", style: null, speed: null, text: null, captionRef: null, json: false,
76
97
  dryRun: false,
77
98
  yes: false,
78
99
  apply: false,
@@ -95,6 +116,11 @@ function parseArguments(argv) {
95
116
  case "--id": options.id = value; break;
96
117
  case "--speaker": options.speaker = value; break;
97
118
  case "--profile": options.profile = value; break;
119
+ case "--voice": options.voice = value; break;
120
+ case "--style": options.style = value; break;
121
+ case "--speed": options.speed = Number(value); break;
122
+ case "--text": options.text = value; break;
123
+ case "--caption-ref": options.captionRef = value; break;
98
124
  default: break;
99
125
  }
100
126
  } else if (argument === "--dry-run") {
@@ -103,16 +129,28 @@ function parseArguments(argv) {
103
129
  options.yes = true;
104
130
  } else if (argument === "--apply") {
105
131
  options.apply = true;
132
+ } else if (argument === "--json") {
133
+ options.json = true;
106
134
  } else {
107
135
  throw new PublicError(`不明な引数です: ${argument}\n${usage}`, 2);
108
136
  }
109
137
  }
110
138
 
111
139
  if (!options.project) throw new PublicError("--project が必要です");
112
- if (options.engine !== "voicevox" && options.engine !== "fal-qwen3") {
113
- throw new PublicError("--engine には voicevox または fal-qwen3 を指定してください");
140
+ if (!["voicevox", "gemini-tts", "fal-qwen3"].includes(options.engine)) {
141
+ throw new PublicError("--engine には voicevox、gemini-tts または fal-qwen3 を指定してください", 2);
114
142
  }
115
- if (!options.readingFile) throw new PublicError("--reading-file が必要です");
143
+ if (!options.readingFile && !options.text) throw new PublicError("--reading-file または --text が必要です");
144
+ if (options.engine === "gemini-tts" && !GEMINI_VOICES.some(({ id }) => id === options.voice)) {
145
+ throw new PublicError(`--voice に使える声: ${GEMINI_VOICES.map(({ id }) => id).join(", ")}`, 2);
146
+ }
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);
149
+ }
150
+ if (options.captionRef !== null && !/^c-\d{4}$/.test(options.captionRef)) {
151
+ throw new PublicError("--caption-ref は c- に続く 4 桁の数字で指定してください", 2);
152
+ }
153
+ if (options.t === null && !options.apply) options.t = 0;
116
154
  if (!isFiniteNumber(options.t) || options.t < 0) {
117
155
  throw new PublicError("--t には 0 以上の有限数を指定してください");
118
156
  }
@@ -154,6 +192,9 @@ function computeNextId(projectDir) {
154
192
  .map((item) => item?.id)
155
193
  .filter((id) => typeof id === "string" && /^n-\d{4}$/.test(id));
156
194
  }
195
+ for (const track of edit?.tracks ?? []) for (const item of track?.items ?? []) {
196
+ if (typeof item?.id === "string" && /^n-\d{4}$/.test(item.id)) ids.push(item.id);
197
+ }
157
198
  } catch {
158
199
  // edit.json が無い、または narration 配列が無い場合は n-0001 から開始する。
159
200
  }
@@ -258,6 +299,33 @@ function buildFalPayload(readingText, meta) {
258
299
  function estimateFalCostUsd(readingText) {
259
300
  return Number(((readingText.length / 1000) * FAL_USD_PER_1000_CHARS).toFixed(6));
260
301
  }
302
+ function estimateGeminiTtsCostUsd(chars) {
303
+ return Number(((chars / 1000) * GEMINI_USD_PER_1000_CHARS).toFixed(6));
304
+ }
305
+
306
+ async function synthesizeGeminiTts(readingText, { voice, model, style, falKey }) {
307
+ const payload = {
308
+ prompt: readingText, voice, model,
309
+ output_format: "mp3", language_code: "Japanese (Japan)",
310
+ ...(style ? { style_instructions: style } : {}),
311
+ };
312
+ let response;
313
+ try {
314
+ response = await fetch(GEMINI_TTS_URL, {
315
+ method: "POST",
316
+ headers: { Authorization: `Key ${falKey}`, "Content-Type": "application/json" },
317
+ body: JSON.stringify(payload),
318
+ });
319
+ } catch {
320
+ throw new PublicError("fal API へのネットワーク接続に失敗しました");
321
+ }
322
+ if (!response.ok) throw new PublicError(`fal API が HTTP ${response.status} を返しました`);
323
+ const audioUrl = (await response.json())?.audio?.url;
324
+ if (typeof audioUrl !== "string" || !audioUrl) throw new PublicError("fal API の応答に audio.url がありません");
325
+ const audioResponse = await fetch(audioUrl);
326
+ if (!audioResponse.ok) throw new PublicError(`生成音声の取得に失敗しました(HTTP ${audioResponse.status})`);
327
+ return Buffer.from(await audioResponse.arrayBuffer());
328
+ }
261
329
 
262
330
  async function synthesizeFal(readingText, meta, falKey) {
263
331
  const payload = buildFalPayload(readingText, meta);
@@ -292,12 +360,14 @@ async function synthesizeFal(readingText, meta, falKey) {
292
360
  * 純粋関数にして `platform` / `env` を注入でき、実プラットフォームに依存せずテストできる
293
361
  * ようにする(darwin 既定パスは不変。win32 既定パスの根拠は report.md 参照)。
294
362
  */
295
- export function resolveVoicevoxRunPath(platform = process.platform, env = process.env) {
363
+ export function resolveVoicevoxRunPath(platform = process.platform, env = process.env, exists = fs.existsSync) {
296
364
  const override = env[VOICEVOX_RUN_ENV];
297
365
  if (override) return override;
298
366
 
299
367
  if (platform === "darwin") {
300
- return "/Applications/VOICEVOX.app/Contents/Resources/vv-engine/run";
368
+ const candidates = ["/Applications/VOICEVOX.app/Contents/Resources/vv-engine/run",
369
+ path.join(env.HOME || os.homedir(), "Applications", "VOICEVOX.app", "Contents", "Resources", "vv-engine", "run")];
370
+ return candidates.find(candidate => exists(candidate)) || candidates[0];
301
371
  }
302
372
  if (platform === "win32") {
303
373
  // VOICEVOX 0.16+ の既定インストーラ配置先(root repo 未検証・GitHub issue で確認済み。
@@ -362,24 +432,128 @@ async function getVoicevoxVersion() {
362
432
  return String(await response.json()).trim();
363
433
  }
364
434
 
435
+ function voicevoxPidPath(env = process.env, homeDir = os.homedir()) {
436
+ return path.join(env.AKARI_HOME || path.join(homeDir, ".akari"), "run", "voicevox.pid");
437
+ }
438
+
439
+ async function probeVoicevox(fetchImpl = fetch) {
440
+ const controller = new AbortController();
441
+ const timer = setTimeout(() => controller.abort(), 1500);
442
+ try {
443
+ const response = await fetchImpl(`${VOICEVOX_BASE_URL}/version`, { signal: controller.signal });
444
+ return response.ok ? { running: true, version: String(await response.json()).trim() } : { running: false };
445
+ } catch { return { running: false }; }
446
+ finally { clearTimeout(timer); }
447
+ }
448
+
449
+ function defaultProcessAlive(pid) {
450
+ try { process.kill(pid, 0); return true; }
451
+ catch { return false; }
452
+ }
453
+
454
+ function defaultProcessCommand(pid) {
455
+ if (process.platform === "win32") return null;
456
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], {
457
+ encoding: "utf8", timeout: 3_000, maxBuffer: 64 * 1024,
458
+ });
459
+ return result.status === 0 ? result.stdout.trim() : null;
460
+ }
461
+
462
+ function managedVoicevoxPid(runtime = {}) {
463
+ const fileSystem = runtime.fsImpl || fs;
464
+ const env = runtime.env || process.env;
465
+ const pidPath = runtime.pidPath || voicevoxPidPath(env, runtime.homeDir);
466
+ let pid;
467
+ try {
468
+ pid = Number(fileSystem.readFileSync(pidPath, "utf8").trim());
469
+ } catch { return null; }
470
+ let runPath;
471
+ try {
472
+ runPath = resolveVoicevoxRunPath(runtime.platform || process.platform, env,
473
+ candidate => fileSystem.existsSync(candidate));
474
+ } catch { /* 実行ファイルを特定できなければ停止しない */ }
475
+ let command = null;
476
+ try {
477
+ if (Number.isSafeInteger(pid) && pid > 0 && (runtime.isProcessAlive || defaultProcessAlive)(pid)) {
478
+ command = (runtime.readProcessCommand || defaultProcessCommand)(pid);
479
+ }
480
+ } catch { /* 照合できない PID は stale として扱う */ }
481
+ const matches = runPath && typeof command === "string" &&
482
+ [runPath, `"${runPath}"`].some(executable => command === executable || command.startsWith(`${executable} `));
483
+ if (matches) return pid;
484
+ fileSystem.rmSync(pidPath, { force: true });
485
+ return null;
486
+ }
487
+
488
+ async function startVoicevox(runtime = {}) {
489
+ const fetchImpl = runtime.fetchImpl || fetch;
490
+ const fileSystem = runtime.fsImpl || fs;
491
+ const env = runtime.env || process.env;
492
+ const pidPath = runtime.pidPath || voicevoxPidPath(env, runtime.homeDir);
493
+ managedVoicevoxPid(runtime); // 前回の PID が stale なら、既に別経路で起動したアプリを守るため削除する。
494
+ const probe = await probeVoicevox(fetchImpl);
495
+ if (probe.running) return { status: "ok", already_running: true, version: probe.version };
496
+ const runPath = resolveVoicevoxRunPath(runtime.platform || process.platform, env, candidate => fileSystem.existsSync(candidate));
497
+ if (!fileSystem.existsSync(runPath)) throw new PublicError("VOICEVOX エンジンが見つかりません。公式サイトから導入してください。");
498
+ const child = (runtime.spawnImpl || spawn)(runPath, [], { detached: true, windowsHide: true, stdio: "ignore", env });
499
+ child.on?.('error', () => { /* pid が無い場合は直後に失敗として扱う */ });
500
+ if (!Number.isSafeInteger(child.pid) || child.pid <= 0) throw new PublicError("VOICEVOX を起動できませんでした。");
501
+ child.unref();
502
+ fileSystem.mkdirSync(path.dirname(pidPath), { recursive: true });
503
+ fileSystem.writeFileSync(pidPath, `${child.pid}\n`, { mode: 0o600 });
504
+ const now = runtime.now || Date.now;
505
+ const pause = runtime.sleep || sleep;
506
+ const deadline = now() + VOICEVOX_STARTUP_TIMEOUT_MS;
507
+ while (now() < deadline) {
508
+ const state = await probeVoicevox(fetchImpl);
509
+ if (state.running) return { status: "ok", already_running: false, version: state.version };
510
+ await pause(1000);
511
+ }
512
+ try { (runtime.killProcess || process.kill)(child.pid, "SIGTERM"); } catch { /* 終了済み */ }
513
+ fileSystem.rmSync(pidPath, { force: true });
514
+ throw new PublicError("VOICEVOX エンジンの起動が 60 秒でタイムアウトしました。");
515
+ }
516
+
517
+ async function stopManagedVoicevox(runtime = {}) {
518
+ const fileSystem = runtime.fsImpl || fs;
519
+ const pidPath = runtime.pidPath || voicevoxPidPath(runtime.env || process.env, runtime.homeDir);
520
+ const pid = managedVoicevoxPid(runtime);
521
+ if (!pid) return { status: "ok", stopped: false, managed: false };
522
+ try { (runtime.killProcess || process.kill)(pid, "SIGTERM"); }
523
+ catch (error) {
524
+ if (error?.code !== "ESRCH") throw error;
525
+ fileSystem.rmSync(pidPath, { force: true });
526
+ return { status: "ok", stopped: false, managed: false };
527
+ }
528
+ fileSystem.rmSync(pidPath, { force: true });
529
+ const pause = runtime.sleep || sleep;
530
+ for (let attempt = 0; attempt < 20; attempt++) {
531
+ if (!(await probeVoicevox(runtime.fetchImpl || fetch)).running) break;
532
+ await pause(250);
533
+ }
534
+ return { status: "ok", stopped: true, managed: true };
535
+ }
536
+
365
537
  async function resolveVoicevoxSpeakerName(speakerId) {
366
538
  const response = await fetch(`${VOICEVOX_BASE_URL}/speakers`);
367
539
  if (!response.ok) throw new PublicError(`VOICEVOX /speakers が HTTP ${response.status} を返しました`);
368
540
  const speakers = await response.json();
369
541
  for (const speaker of Array.isArray(speakers) ? speakers : []) {
370
542
  const styles = Array.isArray(speaker?.styles) ? speaker.styles : [];
371
- if (styles.some((style) => style.id === speakerId)) return String(speaker.name);
543
+ const style = styles.find((candidate) => candidate.id === speakerId);
544
+ if (style) return `${speaker.name} ${style.name}`;
372
545
  }
373
546
  return "unknown";
374
547
  }
375
548
 
376
- async function synthesizeVoicevox(readingText, speakerId) {
549
+ async function synthesizeVoicevox(readingText, speakerId, speed) {
377
550
  const queryUrl = `${VOICEVOX_BASE_URL}/audio_query?speaker=${speakerId}&text=${encodeURIComponent(readingText)}`;
378
551
  const queryResponse = await fetch(queryUrl, { method: "POST" });
379
552
  if (!queryResponse.ok) {
380
553
  throw new PublicError(`VOICEVOX /audio_query が HTTP ${queryResponse.status} を返しました`);
381
554
  }
382
555
  const query = await queryResponse.json();
556
+ if (speed !== null) query.speedScale = speed;
383
557
 
384
558
  const synthesisUrl = `${VOICEVOX_BASE_URL}/synthesis?speaker=${speakerId}`;
385
559
  const synthesisResponse = await fetch(synthesisUrl, {
@@ -438,10 +612,76 @@ function maskKey(secret) {
438
612
  return secret ? "***configured***" : "***unconfigured***";
439
613
  }
440
614
 
615
+ async function listEngines(runtime = {}) {
616
+ let voicevox;
617
+ const state = await probeVoicevox(runtime.fetchImpl || fetch);
618
+ const fileSystem = runtime.fsImpl || fs;
619
+ let appFound = false;
620
+ try { appFound = fileSystem.existsSync(resolveVoicevoxRunPath(runtime.platform || process.platform, runtime.env || process.env,
621
+ candidate => fileSystem.existsSync(candidate))); } catch { /* 未対応 OS */ }
622
+ const ownedPid = managedVoicevoxPid(runtime);
623
+ const detail = { running: state.running, ...(state.version ? { version: state.version } : {}), app_found: appFound,
624
+ managed: state.running && Boolean(ownedPid) };
625
+ if (state.running) {
626
+ voicevox = { state: "available", label: "VOICEVOX を使用できます", detail };
627
+ } else {
628
+ voicevox = appFound
629
+ ? { state: "needs", label: "VOICEVOX を起動します(自動)", detail }
630
+ : { state: "unconfigured", label: "VOICEVOX をインストール", detail: { ...detail, setup_url: "https://voicevox.hiroshiba.jp/" } };
631
+ }
632
+ const configured = Boolean(readCredentials().get("FAL_KEY"));
633
+ const falAvailability = configured
634
+ ? { state: "available", label: "fal を使用できます" }
635
+ : { state: "unconfigured", label: "fal の鍵を登録" };
636
+ const profilesDir = path.join(os.homedir(), ".config", "akari-video", "voice-profiles");
637
+ const hasProfiles = fs.existsSync(profilesDir) && fs.readdirSync(profilesDir, { withFileTypes: true }).some((entry) => entry.isDirectory());
638
+ return { version: 1, engines: [
639
+ { 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
+ { 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: "近日" } },
642
+ { 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
+ ] };
644
+ }
645
+
646
+ async function listVoices(engine) {
647
+ if (engine === "gemini-tts") return GEMINI_VOICES;
648
+ if (engine === "voicevox") {
649
+ const handle = await ensureVoicevoxEngine();
650
+ try {
651
+ const response = await fetch(`${VOICEVOX_BASE_URL}/speakers`);
652
+ if (!response.ok) throw new PublicError(`VOICEVOX /speakers が HTTP ${response.status} を返しました`, 3);
653
+ const speakers = await response.json();
654
+ return speakers.flatMap((speaker) => (speaker.styles ?? []).map((style) => ({
655
+ id: String(style.id), label: `${speaker.name} ${style.name}`, group: speaker.name,
656
+ })));
657
+ } finally {
658
+ if (handle.startedByUs) await stopVoicevoxEngine(handle.child);
659
+ }
660
+ }
661
+ if (engine === "fal-qwen3") {
662
+ const profilesDir = path.join(os.homedir(), ".config", "akari-video", "voice-profiles");
663
+ return fs.existsSync(profilesDir) ? fs.readdirSync(profilesDir, { withFileTypes: true })
664
+ .filter((entry) => entry.isDirectory()).map((entry) => ({ id: entry.name, label: entry.name })) : [];
665
+ }
666
+ throw new PublicError(`声一覧に対応していないエンジンです: ${engine}`, 2);
667
+ }
668
+
669
+ function parseListArguments(args) {
670
+ let engine = null;
671
+ for (let index = 1; index < args.length; index += 1) {
672
+ if (args[index] === "--json") continue;
673
+ if (args[index] === "--engine" && args[index + 1]) { engine = args[++index]; continue; }
674
+ throw new PublicError(`不明な引数です: ${args[index]}`, 2);
675
+ }
676
+ if (args[0] === "voices" && !engine) throw new PublicError("voices には --engine が必要です", 2);
677
+ return engine;
678
+ }
679
+
441
680
  async function runDryRun(options, readingText, io) {
442
681
  const outputPath = relativeOutputPath(options.id ?? computeNextId(options.project), options.engine);
682
+ const emit = options.json ? printCompactJson : printJson;
443
683
  if (options.engine === "voicevox") {
444
- printJson({
684
+ emit({
445
685
  dry_run: true,
446
686
  engine: "voicevox",
447
687
  output_path: outputPath,
@@ -459,11 +699,20 @@ async function runDryRun(options, readingText, io) {
459
699
  return;
460
700
  }
461
701
 
702
+ if (options.engine === "gemini-tts") {
703
+ emit({
704
+ dry_run: true, engine: options.engine, output_path: outputPath,
705
+ estimated_cost_usd: estimateGeminiTtsCostUsd(readingText.length),
706
+ request: { endpoint: GEMINI_TTS_URL, headers: { Authorization: `Key ${maskKey(readCredentials().get("FAL_KEY"))}` },
707
+ body: { prompt: readingText, voice: options.voice, model: "gemini-2.5-flash-tts", output_format: "mp3", language_code: "Japanese (Japan)", ...(options.style ? { style_instructions: options.style } : {}) } },
708
+ }, io.log);
709
+ return;
710
+ }
462
711
  const falKey = resolveFalKey();
463
712
  const meta = readProfileMeta(options.profile);
464
713
  const payload = buildFalPayload(readingText, meta);
465
714
  const estimatedCostUsd = estimateFalCostUsd(readingText);
466
- printJson({
715
+ emit({
467
716
  dry_run: true,
468
717
  engine: "fal-qwen3",
469
718
  output_path: outputPath,
@@ -476,11 +725,30 @@ async function runDryRun(options, readingText, io) {
476
725
  }, io.log);
477
726
  }
478
727
 
728
+ function durationForAudio(buffer, outputPath, engine, warnings) {
729
+ if (engine === "voicevox") {
730
+ if (buffer.length >= 44 && buffer.toString("ascii", 0, 4) === "RIFF") {
731
+ const bytesPerSecond = buffer.readUInt32LE(28);
732
+ const dataOffset = buffer.indexOf("data", 36, "ascii");
733
+ if (bytesPerSecond > 0 && dataOffset >= 0 && dataOffset + 8 <= buffer.length) {
734
+ return Number((buffer.readUInt32LE(dataOffset + 4) / bytesPerSecond).toFixed(3));
735
+ }
736
+ }
737
+ } else {
738
+ const result = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", outputPath], { encoding: "utf8" });
739
+ const seconds = Number(result.stdout?.trim());
740
+ if (result.status === 0 && Number.isFinite(seconds) && seconds >= 0) return Number(seconds.toFixed(3));
741
+ }
742
+ warnings.push("音声の実尺を取得できませんでした");
743
+ return null;
744
+ }
745
+
479
746
  // --- generate 本体 ---
480
747
 
481
748
  async function runGenerate(options, io) {
482
- const readingText = readTextFile(options.readingFile, "読み原稿");
483
- const scriptText = options.scriptFile ? readTextFile(options.scriptFile, "表示原稿") : null;
749
+ const readingText = options.readingFile ? readTextFile(options.readingFile, "読み原稿") : options.text.trim();
750
+ const scriptText = options.scriptFile ? readTextFile(options.scriptFile, "表示原稿") : options.text;
751
+ if (!readingText) throw new PublicError("読み原稿が空です", 2);
484
752
 
485
753
  if (options.dryRun) {
486
754
  await runDryRun(options, readingText, io);
@@ -493,6 +761,13 @@ async function runGenerate(options, io) {
493
761
 
494
762
  let audioBuffer;
495
763
  let provenance;
764
+ let costUsd = 0;
765
+ const warnings = [];
766
+ if (options.speed !== null && options.engine !== "voicevox") {
767
+ const warning = `${options.engine} は --speed に対応していないため無視しました`;
768
+ warnings.push(warning);
769
+ io.logError(warning);
770
+ }
496
771
 
497
772
  if (options.engine === "voicevox") {
498
773
  const speakerId = Number(options.speaker);
@@ -501,7 +776,7 @@ async function runGenerate(options, io) {
501
776
  }
502
777
  const engineHandle = await ensureVoicevoxEngine();
503
778
  try {
504
- audioBuffer = await synthesizeVoicevox(readingText, speakerId);
779
+ audioBuffer = await synthesizeVoicevox(readingText, speakerId, options.speed);
505
780
  const [version, speakerName] = await Promise.all([
506
781
  getVoicevoxVersion(),
507
782
  resolveVoicevoxSpeakerName(speakerId),
@@ -517,31 +792,34 @@ async function runGenerate(options, io) {
517
792
  if (engineHandle.startedByUs) await stopVoicevoxEngine(engineHandle.child);
518
793
  }
519
794
  } else {
520
- const falKey = resolveFalKey();
521
- const meta = readProfileMeta(options.profile);
522
- const estimatedCostUsd = estimateFalCostUsd(readingText);
523
- io.logError(`fal-qwen3 推定費用: 約 $${estimatedCostUsd}(${readingText.length} 文字 × $${FAL_USD_PER_1000_CHARS}/1000字)`);
795
+ const estimatedCostUsd = options.engine === "gemini-tts"
796
+ ? estimateGeminiTtsCostUsd(readingText.length) : estimateFalCostUsd(readingText);
797
+ costUsd = estimatedCostUsd;
798
+ io.logError(`${options.engine} 推定費用: 約 $${estimatedCostUsd}(${readingText.length} 文字)`);
524
799
  if (!options.yes) {
525
- printJson({
526
- sent: false,
527
- engine: "fal-qwen3",
528
- estimated_cost_usd: estimatedCostUsd,
529
- reason: "費用承認(--yes)がありません。実リクエストは送信していません。",
530
- }, io.log);
800
+ if (options.json) printCompactJson({ version: 1, status: "needs_approval", estimate_usd: estimatedCostUsd, chars: readingText.length,
801
+ ...(options.speed !== null ? { speed_applied: false, warnings } : {}) }, io.log);
802
+ else printJson({ sent: false, engine: options.engine, estimated_cost_usd: estimatedCostUsd,
803
+ reason: "費用承認(--yes)がありません。実リクエストは送信していません。" }, io.log);
531
804
  return 2;
532
805
  }
806
+ const falKey = resolveFalKey();
533
807
  // --yes が明示された場合のみ、ここで初めて課金の発生する fal API を呼び出す。
534
- audioBuffer = await synthesizeFal(readingText, meta, falKey);
535
- provenance = {
536
- provider: "fal",
537
- engine: "qwen-3-tts-1.7b",
538
- voice: `profile:${options.profile}`,
539
- generated_at: new Date().toISOString(),
540
- };
808
+ if (options.engine === "gemini-tts") {
809
+ audioBuffer = await synthesizeGeminiTts(readingText, { voice: options.voice, model: "gemini-2.5-flash-tts", style: options.style, falKey });
810
+ provenance = { provider: "fal", engine: "gemini-2.5-flash-tts", voice: `gemini:${options.voice}`,
811
+ generated_at: new Date().toISOString(), price_verified: false };
812
+ } else {
813
+ const meta = readProfileMeta(options.profile);
814
+ audioBuffer = await synthesizeFal(readingText, meta, falKey);
815
+ provenance = { provider: "fal", engine: "qwen-3-tts-1.7b", voice: `profile:${options.profile}`,
816
+ generated_at: new Date().toISOString() };
817
+ }
541
818
  }
542
819
 
543
820
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
544
821
  fs.writeFileSync(outputPath, audioBuffer);
822
+ const duration = durationForAudio(audioBuffer, outputPath, options.engine, warnings);
545
823
 
546
824
  const entry = {
547
825
  id,
@@ -550,12 +828,17 @@ async function runGenerate(options, io) {
550
828
  gain_db: options.gainDb,
551
829
  ...(scriptText ? { script: scriptText } : {}),
552
830
  reading: readingText,
831
+ ...(options.captionRef ? { caption_ref: options.captionRef } : {}),
553
832
  provenance,
554
833
  };
555
834
 
556
835
  if (options.apply) applyToEditJson(options.project, entry, io);
557
836
 
558
- printJson(entry, io.log);
837
+ if (options.json) printCompactJson({ version: 1, status: "ok", id, path: relativePath,
838
+ duration_s: duration, engine: options.engine, voice: provenance.voice, cost_usd: costUsd,
839
+ applied: options.apply, caption_ref: options.captionRef, provenance, warnings,
840
+ ...(options.speed !== null ? { speed_applied: options.engine === "voicevox" } : {}) }, io.log);
841
+ else printJson(entry, io.log);
559
842
  return 0;
560
843
  }
561
844
 
@@ -573,6 +856,31 @@ export async function runNarrationCommand(args, commandOptions = {}) {
573
856
  io.log(usage);
574
857
  return { exitCode: 0 };
575
858
  }
859
+ if (["start", "stop"].includes(args[0])) {
860
+ try {
861
+ if (args.length !== 4 || args[1] !== "--engine" || args[2] !== "voicevox" || args[3] !== "--json") {
862
+ throw new PublicError("start/stop には --engine voicevox --json が必要です", 2);
863
+ }
864
+ printCompactJson(args[0] === "start" ? await startVoicevox(commandOptions.engineRuntime) : await stopManagedVoicevox(commandOptions.engineRuntime), io.log);
865
+ return { exitCode: 0 };
866
+ } catch (error) {
867
+ const message = error instanceof PublicError ? error.message : `VOICEVOX を操作できませんでした: ${error?.message ?? error}`;
868
+ io.logError(message); printCompactJson({ error: message }, io.log);
869
+ return { exitCode: error instanceof PublicError ? error.exitCode : 1 };
870
+ }
871
+ }
872
+ if (["engines", "voices"].includes(args[0])) {
873
+ try {
874
+ const engine = parseListArguments(args);
875
+ printCompactJson(args[0] === "engines" ? await listEngines(commandOptions.engineRuntime) : { version: 1, engine, voices: await listVoices(engine) }, io.log);
876
+ return { exitCode: 0 };
877
+ } catch (error) {
878
+ const message = error instanceof PublicError ? error.message : "声一覧を取得できませんでした";
879
+ io.logError(message);
880
+ printCompactJson({ error: message }, io.log);
881
+ return { exitCode: args[0] === "voices" ? 3 : 2 };
882
+ }
883
+ }
576
884
 
577
885
  let parsedOptions;
578
886
  try {
@@ -583,7 +891,8 @@ export async function runNarrationCommand(args, commandOptions = {}) {
583
891
  const exitCode = error instanceof PublicError ? error.exitCode : 1;
584
892
  const message = error instanceof PublicError ? error.message : `内部処理に失敗しました: ${error?.message ?? error}`;
585
893
  io.logError(message);
586
- printJson({ error: message }, io.log);
894
+ if (args.includes("--json")) printCompactJson({ error: message }, io.log);
895
+ else printJson({ error: message }, io.log);
587
896
  return { exitCode };
588
897
  }
589
898
  }
@@ -83,6 +83,7 @@ source 秒アンカー規則の対象外である。
83
83
  `sourceT` または `sourceRange` と組にして source 座標を表す
84
84
  - `src` の省略は単一ソース互換を意味する
85
85
  - 字幕の重なり(`captions.overlap`)と並び順(`captions.order`)の判定は `src` ごとに行う
86
+ - `time_domain: "output"` の行(置いた文字)は重なり・並び順の検査対象外(2026-09-22 裁定。置き物なので同時に何個あってもよい)
86
87
 
87
88
  analysis サイドカーは素材単位のままとし、構造は変更しない。参照時に `src` から source path を
88
89
  解決する。
@@ -192,3 +192,8 @@ narration 音声ファイルの実尺解析(ffprobe 等)を要する別契
192
192
  - ducking のサイドチェイン入力を narration トラックへ切り替える実装(プレビュー/書き出し双方)
193
193
  - narration の実尺を考慮した区間重複検出(§6)
194
194
  - `audio.narration[].provenance.provider` の enum 強制(現状は文書上の例示のみ)
195
+
196
+ ## 追記 2026-09-22: `caption_ref`
197
+
198
+ ナレーションには、生成元の字幕 ID を表す任意の `caption_ref`(`c-` と 4 桁の数字)を追加できる。
199
+ 字幕は別ファイルなので参照先の存在確認は行わない。追加のみの進化であり、edit.json の `version` は変更しない。
@@ -58,6 +58,15 @@ cut 境界の選択は宣言順ではなく解決済みタイムラインと z-o
58
58
 
59
59
  ### 2.2 framing、transform、opacity、freeze、keyframes
60
60
 
61
+ > **2026-09-22 — 非等方スケール v1**: `transform.scaleX` / `scaleY` は独立した正数。
62
+ > 有効値は `(scaleX ?? scale ?? 1, scaleY ?? scale ?? 1)`。負値・0 は不可。
63
+ > `source.kind === 'group'` は軸別指定を持てず、親は常に等比とする。
64
+ > 等比親との合成は子の各有効値に親の `scale` を掛け、逆変換は同じ値で割る。
65
+ > 書き込み時に両軸の有効値が等しければ `scale` に畳む。既存の `scale` だけの宣言は変更しない。
66
+ > keyframes は端点を各軸の有効値へ解決してから補間する(`scale` と `scaleX` の混在も可)。
67
+ > overlay の CSS は `translate(...) rotate(...) scale(sx, sy)`。overlay も素材も
68
+ > 各軸で伸ばしてから回転する(R·S)。素材は crop の幅・高さへ各軸を掛ける。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
69
+
61
70
  - `framing.crop` は fit 済みフレームを窓抜きして出力寸法へ再拡大する。
62
71
  - `framing.keyframes`、transform keyframes は cut 内の出力秒で評価し、hold / linear / ease-in-out の
63
72
  指定補間を使う。
@@ -40,6 +40,7 @@ style/emphasis conflicts; non-NFC or trimmed text; invalid manual fragments; unr
40
40
  overlap; and reference/output aspect mismatch. A resolved production render stores
41
41
  `.akari/reports/caption-layout/<payload-sha256>.json`, including Node/ICU provenance and the boundary
42
42
  projection digest, and its immutable render receipt references the file and summary.
43
+ Captions with `time_domain: "output"` (placed text) are exempt from overlap and order checks; any number may share the same time (2026-09-22 decision). Source-domain captions retain their per-source checks.
43
44
 
44
45
  Under `display_policy`, an omitted `captions[].style` remains valid. The known word-display styles
45
46
  `karaoke`, `pop`, `reveal`, and `reveal-word` remain `STYLE_CONFLICT` errors, while any other value is
@@ -17,6 +17,15 @@ updated: 2026-08-30
17
17
 
18
18
  ## 1. 意味論
19
19
 
20
+ > **2026-09-22 — 非等方スケール v1**: `transform.scaleX` / `scaleY` は独立した正数。
21
+ > 有効値は `(scaleX ?? scale ?? 1, scaleY ?? scale ?? 1)`。負値・0 は不可。
22
+ > `source.kind === 'group'` は軸別指定を持てず、親は常に等比とする。
23
+ > 等比親との合成は子の各有効値に親の `scale` を掛け、逆変換は同じ値で割る。
24
+ > 書き込み時に両軸の有効値が等しければ `scale` に畳む。既存の `scale` だけの宣言は変更しない。
25
+ > keyframes は端点を各軸の有効値へ解決してから補間する(`scale` と `scaleX` の混在も可)。
26
+ > overlay の CSS は `translate(...) rotate(...) scale(sx, sy)`。overlay も素材も
27
+ > 各軸で伸ばしてから回転する(R·S)。素材は crop の幅・高さへ各軸を掛ける。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
28
+
20
29
  - `keyframes[]` はレイヤー / アイテムの `transform` / `crop` / `perspective` を時間で動かす共通機構
21
30
  - `t` は**ローカル時間**: v1 `layers[].keyframes[].t` はレイヤー内秒(`layerItem.t` を 0 とする。`cuts[].framing.keyframes[].t` と同じ規約)、
22
31
  v2 `items[].keyframes[].t` は**アイテム内の整数フレーム**(`item.at` を 0 とする)
@@ -63,6 +63,15 @@ updated: 2026-09-06
63
63
 
64
64
  ### 2.1 形(1 つだけ: 点形)
65
65
 
66
+ > **2026-09-22 — 非等方スケール v1**: `transform.scaleX` / `scaleY` は独立した正数。
67
+ > 有効値は `(scaleX ?? scale ?? 1, scaleY ?? scale ?? 1)`。負値・0 は不可。
68
+ > `source.kind === 'group'` は軸別指定を持てず、親は常に等比とする。
69
+ > 等比親との合成は子の各有効値に親の `scale` を掛け、逆変換は同じ値で割る。
70
+ > 書き込み時に両軸の有効値が等しければ `scale` に畳む。既存の `scale` だけの宣言は変更しない。
71
+ > keyframes は端点を各軸の有効値へ解決してから補間する(`scale` と `scaleX` の混在も可)。
72
+ > overlay の CSS は `translate(...) rotate(...) scale(sx, sy)`。overlay も素材も
73
+ > 各軸で伸ばしてから回転する(R·S)。素材は crop の幅・高さへ各軸を掛ける。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
74
+
66
75
  既存 `keyframeV2`(点ごとに任意プロパティ・`t` はアイテム相対の整数フレーム)を**唯一の形**として保ち、次を足す:
67
76
 
68
77
  ```jsonc