akari-video 0.1.77 → 0.1.79

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 (42) hide show
  1. package/package.json +1 -1
  2. package/src/narration-command.mjs +218 -34
  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 +2 -0
  10. package/vendor/packages/akari-launcher/package.json +1 -1
  11. package/vendor/packages/creator-root/src/index.mjs +32 -12
  12. package/vendor/packages/creator-root/test/library-migration.test.mjs +54 -1
  13. package/vendor/packages/edit-lint/src/edit-lint.mjs +22 -26
  14. package/vendor/packages/edit-store/lib/canonical.js +5 -1
  15. package/vendor/packages/edit-store/lib/caption-display.js +4 -3
  16. package/vendor/packages/edit-store/lib/edit-store.d.ts +8 -0
  17. package/vendor/packages/edit-store/lib/edit-store.js +14 -10
  18. package/vendor/packages/edit-store/lib/edit-v2-item-write.d.ts +2 -0
  19. package/vendor/packages/edit-store/lib/edit-v2-item-write.js +31 -7
  20. package/vendor/packages/edit-store/lib/edit-v2.d.ts +2 -0
  21. package/vendor/packages/edit-store/lib/edit-v2.js +13 -3
  22. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.d.ts +2 -2
  23. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.js +2 -1
  24. package/vendor/packages/edit-store/lib/index.d.ts +1 -0
  25. package/vendor/packages/edit-store/lib/index.js +4 -1
  26. package/vendor/packages/edit-store/lib/internal-model.js +2 -0
  27. package/vendor/packages/edit-store/lib/transform.d.ts +8 -0
  28. package/vendor/packages/edit-store/lib/transform.js +21 -0
  29. package/vendor/packages/edit-store/lib/tree-ops.d.ts +1 -1
  30. package/vendor/packages/edit-store/lib/tree-ops.js +16 -5
  31. package/vendor/packages/edit-store/lib/webview-kernel.js +13 -3
  32. package/vendor/packages/schemas/bin/validate-edit.mjs +10 -5
  33. package/vendor/packages/schemas/bin/validate-sites.mjs +31 -0
  34. package/vendor/packages/schemas/edit.schema.json +8 -2
  35. package/vendor/packages/schemas/engine-capabilities.json +4 -3
  36. package/vendor/packages/schemas/package.json +4 -2
  37. package/vendor/packages/schemas/site.schema.json +26 -0
  38. package/vendor/packages/schemas/test/anisotropic-transform.test.mjs +22 -0
  39. package/vendor/packages/schemas/test/engine-capabilities.test.mjs +1 -1
  40. package/vendor/packages/schemas/test/validate-edit.test.mjs +28 -0
  41. package/vendor/packages/schemas/test/validate-sites.test.mjs +23 -0
  42. package/vendor/skills/generate-narration/engines.md +14 -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.79",
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,36 @@ 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
+ " voices 声一覧を表示する",
29
45
  "",
30
46
  usage,
31
47
  ].join("\n");
@@ -40,6 +56,7 @@ class PublicError extends Error {
40
56
  function printJson(value, log = (line) => console.log(line)) {
41
57
  log(JSON.stringify(value, null, 2));
42
58
  }
59
+ function printCompactJson(value, log) { log(JSON.stringify(value)); }
43
60
 
44
61
  function sleep(ms) {
45
62
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -56,8 +73,9 @@ function isFiniteNumber(value) {
56
73
  const VALUE_OPTIONS = new Set([
57
74
  "--project", "--engine", "--reading-file", "--script-file",
58
75
  "--t", "--gain-db", "--id", "--speaker", "--profile",
76
+ "--voice", "--style", "--speed", "--text", "--caption-ref",
59
77
  ]);
60
- const FLAG_OPTIONS = new Set(["--dry-run", "--yes", "--apply"]);
78
+ const FLAG_OPTIONS = new Set(["--dry-run", "--yes", "--apply", "--json"]);
61
79
 
62
80
  function parseArguments(argv) {
63
81
  if (argv[0] !== "generate") {
@@ -73,6 +91,7 @@ function parseArguments(argv) {
73
91
  id: null,
74
92
  speaker: "3",
75
93
  profile: null,
94
+ voice: "Leda", style: null, speed: null, text: null, captionRef: null, json: false,
76
95
  dryRun: false,
77
96
  yes: false,
78
97
  apply: false,
@@ -95,6 +114,11 @@ function parseArguments(argv) {
95
114
  case "--id": options.id = value; break;
96
115
  case "--speaker": options.speaker = value; break;
97
116
  case "--profile": options.profile = value; break;
117
+ case "--voice": options.voice = value; break;
118
+ case "--style": options.style = value; break;
119
+ case "--speed": options.speed = Number(value); break;
120
+ case "--text": options.text = value; break;
121
+ case "--caption-ref": options.captionRef = value; break;
98
122
  default: break;
99
123
  }
100
124
  } else if (argument === "--dry-run") {
@@ -103,16 +127,28 @@ function parseArguments(argv) {
103
127
  options.yes = true;
104
128
  } else if (argument === "--apply") {
105
129
  options.apply = true;
130
+ } else if (argument === "--json") {
131
+ options.json = true;
106
132
  } else {
107
133
  throw new PublicError(`不明な引数です: ${argument}\n${usage}`, 2);
108
134
  }
109
135
  }
110
136
 
111
137
  if (!options.project) throw new PublicError("--project が必要です");
112
- if (options.engine !== "voicevox" && options.engine !== "fal-qwen3") {
113
- throw new PublicError("--engine には voicevox または fal-qwen3 を指定してください");
138
+ if (!["voicevox", "gemini-tts", "fal-qwen3"].includes(options.engine)) {
139
+ throw new PublicError("--engine には voicevox、gemini-tts または fal-qwen3 を指定してください", 2);
114
140
  }
115
- if (!options.readingFile) throw new PublicError("--reading-file が必要です");
141
+ if (!options.readingFile && !options.text) throw new PublicError("--reading-file または --text が必要です");
142
+ if (options.engine === "gemini-tts" && !GEMINI_VOICES.some(({ id }) => id === options.voice)) {
143
+ throw new PublicError(`--voice に使える声: ${GEMINI_VOICES.map(({ id }) => id).join(", ")}`, 2);
144
+ }
145
+ if (options.speed !== null && (!isFiniteNumber(options.speed) || options.speed < 0.5 || options.speed > 2)) {
146
+ throw new PublicError("--speed は 0.5 から 2.0 の範囲で指定してください", 2);
147
+ }
148
+ if (options.captionRef !== null && !/^c-\d{4}$/.test(options.captionRef)) {
149
+ throw new PublicError("--caption-ref は c- に続く 4 桁の数字で指定してください", 2);
150
+ }
151
+ if (options.t === null && !options.apply) options.t = 0;
116
152
  if (!isFiniteNumber(options.t) || options.t < 0) {
117
153
  throw new PublicError("--t には 0 以上の有限数を指定してください");
118
154
  }
@@ -154,6 +190,9 @@ function computeNextId(projectDir) {
154
190
  .map((item) => item?.id)
155
191
  .filter((id) => typeof id === "string" && /^n-\d{4}$/.test(id));
156
192
  }
193
+ for (const track of edit?.tracks ?? []) for (const item of track?.items ?? []) {
194
+ if (typeof item?.id === "string" && /^n-\d{4}$/.test(item.id)) ids.push(item.id);
195
+ }
157
196
  } catch {
158
197
  // edit.json が無い、または narration 配列が無い場合は n-0001 から開始する。
159
198
  }
@@ -258,6 +297,33 @@ function buildFalPayload(readingText, meta) {
258
297
  function estimateFalCostUsd(readingText) {
259
298
  return Number(((readingText.length / 1000) * FAL_USD_PER_1000_CHARS).toFixed(6));
260
299
  }
300
+ function estimateGeminiTtsCostUsd(chars) {
301
+ return Number(((chars / 1000) * GEMINI_USD_PER_1000_CHARS).toFixed(6));
302
+ }
303
+
304
+ async function synthesizeGeminiTts(readingText, { voice, model, style, falKey }) {
305
+ const payload = {
306
+ prompt: readingText, voice, model,
307
+ output_format: "mp3", language_code: "Japanese (Japan)",
308
+ ...(style ? { style_instructions: style } : {}),
309
+ };
310
+ let response;
311
+ try {
312
+ response = await fetch(GEMINI_TTS_URL, {
313
+ method: "POST",
314
+ headers: { Authorization: `Key ${falKey}`, "Content-Type": "application/json" },
315
+ body: JSON.stringify(payload),
316
+ });
317
+ } catch {
318
+ throw new PublicError("fal API へのネットワーク接続に失敗しました");
319
+ }
320
+ if (!response.ok) throw new PublicError(`fal API が HTTP ${response.status} を返しました`);
321
+ const audioUrl = (await response.json())?.audio?.url;
322
+ if (typeof audioUrl !== "string" || !audioUrl) throw new PublicError("fal API の応答に audio.url がありません");
323
+ const audioResponse = await fetch(audioUrl);
324
+ if (!audioResponse.ok) throw new PublicError(`生成音声の取得に失敗しました(HTTP ${audioResponse.status})`);
325
+ return Buffer.from(await audioResponse.arrayBuffer());
326
+ }
261
327
 
262
328
  async function synthesizeFal(readingText, meta, falKey) {
263
329
  const payload = buildFalPayload(readingText, meta);
@@ -368,18 +434,20 @@ async function resolveVoicevoxSpeakerName(speakerId) {
368
434
  const speakers = await response.json();
369
435
  for (const speaker of Array.isArray(speakers) ? speakers : []) {
370
436
  const styles = Array.isArray(speaker?.styles) ? speaker.styles : [];
371
- if (styles.some((style) => style.id === speakerId)) return String(speaker.name);
437
+ const style = styles.find((candidate) => candidate.id === speakerId);
438
+ if (style) return `${speaker.name} ${style.name}`;
372
439
  }
373
440
  return "unknown";
374
441
  }
375
442
 
376
- async function synthesizeVoicevox(readingText, speakerId) {
443
+ async function synthesizeVoicevox(readingText, speakerId, speed) {
377
444
  const queryUrl = `${VOICEVOX_BASE_URL}/audio_query?speaker=${speakerId}&text=${encodeURIComponent(readingText)}`;
378
445
  const queryResponse = await fetch(queryUrl, { method: "POST" });
379
446
  if (!queryResponse.ok) {
380
447
  throw new PublicError(`VOICEVOX /audio_query が HTTP ${queryResponse.status} を返しました`);
381
448
  }
382
449
  const query = await queryResponse.json();
450
+ if (speed !== null) query.speedScale = speed;
383
451
 
384
452
  const synthesisUrl = `${VOICEVOX_BASE_URL}/synthesis?speaker=${speakerId}`;
385
453
  const synthesisResponse = await fetch(synthesisUrl, {
@@ -438,10 +506,70 @@ function maskKey(secret) {
438
506
  return secret ? "***configured***" : "***unconfigured***";
439
507
  }
440
508
 
509
+ async function listEngines() {
510
+ let voicevox;
511
+ if (await isVoicevoxUp()) {
512
+ voicevox = { state: "available", label: "VOICEVOX を使用できます" };
513
+ } else {
514
+ let installed = false;
515
+ try { installed = fs.existsSync(resolveVoicevoxRunPath()); } catch { /* 未対応 OS */ }
516
+ voicevox = installed
517
+ ? { state: "needs", label: "VOICEVOX を起動します(自動)" }
518
+ : { state: "unconfigured", label: "VOICEVOX をインストール", detail: { setup_url: "https://voicevox.hiroshiba.jp/" } };
519
+ }
520
+ const configured = Boolean(readCredentials().get("FAL_KEY"));
521
+ const falAvailability = configured
522
+ ? { state: "available", label: "fal を使用できます" }
523
+ : { state: "unconfigured", label: "fal の鍵を登録" };
524
+ const profilesDir = path.join(os.homedir(), ".config", "akari-video", "voice-profiles");
525
+ const hasProfiles = fs.existsSync(profilesDir) && fs.readdirSync(profilesDir, { withFileTypes: true }).some((entry) => entry.isDirectory());
526
+ return { version: 1, engines: [
527
+ { id: "voicevox", label: "VOICEVOX", place: "local", price: { usd_per_1000_chars: 0, verified: true }, availability: voicevox, credit_required: true, supports: { speed: true, style: false } },
528
+ { 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 } },
529
+ { id: "irodori", label: "彩(Irodori-TTS)", place: "local", availability: { state: "unsupported", label: "近日" } },
530
+ { 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 } },
531
+ ] };
532
+ }
533
+
534
+ async function listVoices(engine) {
535
+ if (engine === "gemini-tts") return GEMINI_VOICES;
536
+ if (engine === "voicevox") {
537
+ const handle = await ensureVoicevoxEngine();
538
+ try {
539
+ const response = await fetch(`${VOICEVOX_BASE_URL}/speakers`);
540
+ if (!response.ok) throw new PublicError(`VOICEVOX /speakers が HTTP ${response.status} を返しました`, 3);
541
+ const speakers = await response.json();
542
+ return speakers.flatMap((speaker) => (speaker.styles ?? []).map((style) => ({
543
+ id: String(style.id), label: `${speaker.name} ${style.name}`, group: speaker.name,
544
+ })));
545
+ } finally {
546
+ if (handle.startedByUs) await stopVoicevoxEngine(handle.child);
547
+ }
548
+ }
549
+ if (engine === "fal-qwen3") {
550
+ const profilesDir = path.join(os.homedir(), ".config", "akari-video", "voice-profiles");
551
+ return fs.existsSync(profilesDir) ? fs.readdirSync(profilesDir, { withFileTypes: true })
552
+ .filter((entry) => entry.isDirectory()).map((entry) => ({ id: entry.name, label: entry.name })) : [];
553
+ }
554
+ throw new PublicError(`声一覧に対応していないエンジンです: ${engine}`, 2);
555
+ }
556
+
557
+ function parseListArguments(args) {
558
+ let engine = null;
559
+ for (let index = 1; index < args.length; index += 1) {
560
+ if (args[index] === "--json") continue;
561
+ if (args[index] === "--engine" && args[index + 1]) { engine = args[++index]; continue; }
562
+ throw new PublicError(`不明な引数です: ${args[index]}`, 2);
563
+ }
564
+ if (args[0] === "voices" && !engine) throw new PublicError("voices には --engine が必要です", 2);
565
+ return engine;
566
+ }
567
+
441
568
  async function runDryRun(options, readingText, io) {
442
569
  const outputPath = relativeOutputPath(options.id ?? computeNextId(options.project), options.engine);
570
+ const emit = options.json ? printCompactJson : printJson;
443
571
  if (options.engine === "voicevox") {
444
- printJson({
572
+ emit({
445
573
  dry_run: true,
446
574
  engine: "voicevox",
447
575
  output_path: outputPath,
@@ -459,11 +587,20 @@ async function runDryRun(options, readingText, io) {
459
587
  return;
460
588
  }
461
589
 
590
+ if (options.engine === "gemini-tts") {
591
+ emit({
592
+ dry_run: true, engine: options.engine, output_path: outputPath,
593
+ estimated_cost_usd: estimateGeminiTtsCostUsd(readingText.length),
594
+ request: { endpoint: GEMINI_TTS_URL, headers: { Authorization: `Key ${maskKey(readCredentials().get("FAL_KEY"))}` },
595
+ 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 } : {}) } },
596
+ }, io.log);
597
+ return;
598
+ }
462
599
  const falKey = resolveFalKey();
463
600
  const meta = readProfileMeta(options.profile);
464
601
  const payload = buildFalPayload(readingText, meta);
465
602
  const estimatedCostUsd = estimateFalCostUsd(readingText);
466
- printJson({
603
+ emit({
467
604
  dry_run: true,
468
605
  engine: "fal-qwen3",
469
606
  output_path: outputPath,
@@ -476,11 +613,30 @@ async function runDryRun(options, readingText, io) {
476
613
  }, io.log);
477
614
  }
478
615
 
616
+ function durationForAudio(buffer, outputPath, engine, warnings) {
617
+ if (engine === "voicevox") {
618
+ if (buffer.length >= 44 && buffer.toString("ascii", 0, 4) === "RIFF") {
619
+ const bytesPerSecond = buffer.readUInt32LE(28);
620
+ const dataOffset = buffer.indexOf("data", 36, "ascii");
621
+ if (bytesPerSecond > 0 && dataOffset >= 0 && dataOffset + 8 <= buffer.length) {
622
+ return Number((buffer.readUInt32LE(dataOffset + 4) / bytesPerSecond).toFixed(3));
623
+ }
624
+ }
625
+ } else {
626
+ const result = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", outputPath], { encoding: "utf8" });
627
+ const seconds = Number(result.stdout?.trim());
628
+ if (result.status === 0 && Number.isFinite(seconds) && seconds >= 0) return Number(seconds.toFixed(3));
629
+ }
630
+ warnings.push("音声の実尺を取得できませんでした");
631
+ return null;
632
+ }
633
+
479
634
  // --- generate 本体 ---
480
635
 
481
636
  async function runGenerate(options, io) {
482
- const readingText = readTextFile(options.readingFile, "読み原稿");
483
- const scriptText = options.scriptFile ? readTextFile(options.scriptFile, "表示原稿") : null;
637
+ const readingText = options.readingFile ? readTextFile(options.readingFile, "読み原稿") : options.text.trim();
638
+ const scriptText = options.scriptFile ? readTextFile(options.scriptFile, "表示原稿") : options.text;
639
+ if (!readingText) throw new PublicError("読み原稿が空です", 2);
484
640
 
485
641
  if (options.dryRun) {
486
642
  await runDryRun(options, readingText, io);
@@ -493,6 +649,13 @@ async function runGenerate(options, io) {
493
649
 
494
650
  let audioBuffer;
495
651
  let provenance;
652
+ let costUsd = 0;
653
+ const warnings = [];
654
+ if (options.speed !== null && options.engine !== "voicevox") {
655
+ const warning = `${options.engine} は --speed に対応していないため無視しました`;
656
+ warnings.push(warning);
657
+ io.logError(warning);
658
+ }
496
659
 
497
660
  if (options.engine === "voicevox") {
498
661
  const speakerId = Number(options.speaker);
@@ -501,7 +664,7 @@ async function runGenerate(options, io) {
501
664
  }
502
665
  const engineHandle = await ensureVoicevoxEngine();
503
666
  try {
504
- audioBuffer = await synthesizeVoicevox(readingText, speakerId);
667
+ audioBuffer = await synthesizeVoicevox(readingText, speakerId, options.speed);
505
668
  const [version, speakerName] = await Promise.all([
506
669
  getVoicevoxVersion(),
507
670
  resolveVoicevoxSpeakerName(speakerId),
@@ -517,31 +680,34 @@ async function runGenerate(options, io) {
517
680
  if (engineHandle.startedByUs) await stopVoicevoxEngine(engineHandle.child);
518
681
  }
519
682
  } 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字)`);
683
+ const estimatedCostUsd = options.engine === "gemini-tts"
684
+ ? estimateGeminiTtsCostUsd(readingText.length) : estimateFalCostUsd(readingText);
685
+ costUsd = estimatedCostUsd;
686
+ io.logError(`${options.engine} 推定費用: 約 $${estimatedCostUsd}(${readingText.length} 文字)`);
524
687
  if (!options.yes) {
525
- printJson({
526
- sent: false,
527
- engine: "fal-qwen3",
528
- estimated_cost_usd: estimatedCostUsd,
529
- reason: "費用承認(--yes)がありません。実リクエストは送信していません。",
530
- }, io.log);
688
+ if (options.json) printCompactJson({ version: 1, status: "needs_approval", estimate_usd: estimatedCostUsd, chars: readingText.length,
689
+ ...(options.speed !== null ? { speed_applied: false, warnings } : {}) }, io.log);
690
+ else printJson({ sent: false, engine: options.engine, estimated_cost_usd: estimatedCostUsd,
691
+ reason: "費用承認(--yes)がありません。実リクエストは送信していません。" }, io.log);
531
692
  return 2;
532
693
  }
694
+ const falKey = resolveFalKey();
533
695
  // --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
- };
696
+ if (options.engine === "gemini-tts") {
697
+ audioBuffer = await synthesizeGeminiTts(readingText, { voice: options.voice, model: "gemini-2.5-flash-tts", style: options.style, falKey });
698
+ provenance = { provider: "fal", engine: "gemini-2.5-flash-tts", voice: `gemini:${options.voice}`,
699
+ generated_at: new Date().toISOString(), price_verified: false };
700
+ } else {
701
+ const meta = readProfileMeta(options.profile);
702
+ audioBuffer = await synthesizeFal(readingText, meta, falKey);
703
+ provenance = { provider: "fal", engine: "qwen-3-tts-1.7b", voice: `profile:${options.profile}`,
704
+ generated_at: new Date().toISOString() };
705
+ }
541
706
  }
542
707
 
543
708
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
544
709
  fs.writeFileSync(outputPath, audioBuffer);
710
+ const duration = durationForAudio(audioBuffer, outputPath, options.engine, warnings);
545
711
 
546
712
  const entry = {
547
713
  id,
@@ -550,12 +716,17 @@ async function runGenerate(options, io) {
550
716
  gain_db: options.gainDb,
551
717
  ...(scriptText ? { script: scriptText } : {}),
552
718
  reading: readingText,
719
+ ...(options.captionRef ? { caption_ref: options.captionRef } : {}),
553
720
  provenance,
554
721
  };
555
722
 
556
723
  if (options.apply) applyToEditJson(options.project, entry, io);
557
724
 
558
- printJson(entry, io.log);
725
+ if (options.json) printCompactJson({ version: 1, status: "ok", id, path: relativePath,
726
+ duration_s: duration, engine: options.engine, voice: provenance.voice, cost_usd: costUsd,
727
+ applied: options.apply, caption_ref: options.captionRef, provenance, warnings,
728
+ ...(options.speed !== null ? { speed_applied: options.engine === "voicevox" } : {}) }, io.log);
729
+ else printJson(entry, io.log);
559
730
  return 0;
560
731
  }
561
732
 
@@ -573,6 +744,18 @@ export async function runNarrationCommand(args, commandOptions = {}) {
573
744
  io.log(usage);
574
745
  return { exitCode: 0 };
575
746
  }
747
+ if (["engines", "voices"].includes(args[0])) {
748
+ try {
749
+ const engine = parseListArguments(args);
750
+ printCompactJson(args[0] === "engines" ? await listEngines() : { version: 1, engine, voices: await listVoices(engine) }, io.log);
751
+ return { exitCode: 0 };
752
+ } catch (error) {
753
+ const message = error instanceof PublicError ? error.message : "声一覧を取得できませんでした";
754
+ io.logError(message);
755
+ printCompactJson({ error: message }, io.log);
756
+ return { exitCode: args[0] === "voices" ? 3 : 2 };
757
+ }
758
+ }
576
759
 
577
760
  let parsedOptions;
578
761
  try {
@@ -583,7 +766,8 @@ export async function runNarrationCommand(args, commandOptions = {}) {
583
766
  const exitCode = error instanceof PublicError ? error.exitCode : 1;
584
767
  const message = error instanceof PublicError ? error.message : `内部処理に失敗しました: ${error?.message ?? error}`;
585
768
  io.logError(message);
586
- printJson({ error: message }, io.log);
769
+ if (args.includes("--json")) printCompactJson({ error: message }, io.log);
770
+ else printJson({ error: message }, io.log);
587
771
  return { exitCode };
588
772
  }
589
773
  }
@@ -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(...) scale(sx, sy) rotate(...)`、素材は crop の幅・高さへ
68
+ > 各軸を掛けて回転する。四隅 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(...) scale(sx, sy) rotate(...)`、素材は crop の幅・高さへ
27
+ > 各軸を掛けて回転する。四隅 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(...) scale(sx, sy) rotate(...)`、素材は crop の幅・高さへ
73
+ > 各軸を掛けて回転する。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
74
+
66
75
  既存 `keyframeV2`(点ごとに任意プロパティ・`t` はアイテム相対の整数フレーム)を**唯一の形**として保ち、次を足す:
67
76
 
68
77
  ```jsonc
@@ -42,6 +42,8 @@
42
42
 
43
43
  置き場: 生成物は `assets/generated/<file>`、サイドカーは `assets/generated/<file>.meta.json`。実写や既存素材から生成するときも、**生成物の隣**に置く(元素材の隣ではない)。
44
44
 
45
+ meta の無い静止画は、最初に下書きを書くときにシェルが `kind: still, status: done`(取り込み画像)の meta を新設する。動画・音声・html には新設しない。
46
+
45
47
  ```json
46
48
  {
47
49
  "version": 1,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.77",
3
+ "version": "0.1.79",
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": [
@@ -684,7 +684,8 @@ export function readLibraryLocation(env = process.env, { platform = process.plat
684
684
  try {
685
685
  const value = JSON.parse(readFileSync(path.join(resolveAkariHome(env, { platform }), 'library-location.json'), 'utf8'));
686
686
  if (value?.version !== LIBRARY_LOCATION_VERSION || typeof value.root !== 'string'
687
- || !path.isAbsolute(value.root) || !LIBRARY_STATES.has(value.state)) return null;
687
+ || !path.isAbsolute(value.root) || !LIBRARY_STATES.has(value.state)
688
+ || (value.previousRoot !== undefined && (typeof value.previousRoot !== 'string' || !path.isAbsolute(value.previousRoot)))) return null;
688
689
  return value;
689
690
  } catch { return null; }
690
691
  }
@@ -693,9 +694,11 @@ export function resolveAssetLibraryRoots(env = process.env, { platform = process
693
694
  const legacy = path.resolve(resolveAkariHome(env, { platform }), 'assets');
694
695
  const location = readLibraryLocation(env, { platform });
695
696
  const write = env.AKARI_LIBRARY_ROOT ? path.resolve(env.AKARI_LIBRARY_ROOT)
696
- : location && ['migrating', 'done'].includes(location.state) ? path.resolve(location.root) : legacy;
697
+ : location && ['migrating', 'done'].includes(location.state) ? path.resolve(location.root)
698
+ : location?.previousRoot ? path.resolve(location.previousRoot) : legacy;
697
699
  const seen = new Set();
698
- const read = [write, legacy].filter(root => {
700
+ const read = [write, location?.previousRoot, legacy].filter(root => {
701
+ if (!root) return false;
699
702
  let actual;
700
703
  try { actual = realpathSync(root); } catch { actual = root; }
701
704
  if (seen.has(actual)) return false;
@@ -703,11 +706,13 @@ export function resolveAssetLibraryRoots(env = process.env, { platform = process
703
706
  return true;
704
707
  });
705
708
  return { write, read,
706
- source: env.AKARI_LIBRARY_ROOT ? 'env' : location && ['migrating', 'done'].includes(location.state) ? 'location' : 'legacy' };
709
+ source: env.AKARI_LIBRARY_ROOT ? 'env' : location && (['migrating', 'done'].includes(location.state) || location.previousRoot)
710
+ ? 'location' : 'legacy' };
707
711
  }
708
712
 
709
713
  export async function writeLibraryLocation(value, env = process.env, { platform = process.platform } = {}) {
710
- if (!path.isAbsolute(value.root) || !LIBRARY_STATES.has(value.state)) throw new Error('Invalid library location');
714
+ if (!path.isAbsolute(value.root) || !LIBRARY_STATES.has(value.state)
715
+ || (value.previousRoot !== undefined && !path.isAbsolute(value.previousRoot))) throw new Error('Invalid library location');
711
716
  await atomicWriteJson(path.join(resolveAkariHome(env, { platform }), 'library-location.json'),
712
717
  { ...value, version: LIBRARY_LOCATION_VERSION });
713
718
  }
@@ -868,14 +873,15 @@ async function repairLibraryLinks(legacy, root, home, preserved = new Set()) {
868
873
 
869
874
  /** Explicit migrate rechecks late writes by old CLI versions; startup only resumes undecided work. */
870
875
  export async function migrateAssetLibrary({ env = process.env, platform = process.platform,
871
- dryRun = false, automatic = false, notify, fsOps = {} } = {}) {
876
+ dryRun = false, automatic = false, notify, fsOps = {}, sourceRoot, targetRoot, allowCloud = false, onProgress } = {}) {
872
877
  const home = path.resolve(resolveAkariHome(env, { platform }));
873
- const legacy = path.join(home, 'assets');
874
878
  let location = readLibraryLocation(env, { platform });
879
+ const legacy = sourceRoot ? path.resolve(sourceRoot) : location?.previousRoot && ['migrating', 'pending'].includes(location.state)
880
+ ? path.resolve(location.previousRoot) : path.join(home, 'assets');
875
881
  const result = { state: location?.state ?? null, root: null, moved: 0, bytes: 0, totalBytes: 0,
876
882
  skipped: [], failures: [], cloud: null, notified: false };
877
- if (location?.state === 'declined') return result;
878
- let root = env.AKARI_LIBRARY_ROOT ? path.resolve(env.AKARI_LIBRARY_ROOT) : location?.root;
883
+ if (location?.state === 'declined' && !targetRoot) return result;
884
+ let root = targetRoot ? path.resolve(targetRoot) : env.AKARI_LIBRARY_ROOT ? path.resolve(env.AKARI_LIBRARY_ROOT) : location?.root;
879
885
  if (!location?.root) {
880
886
  const creator = env.AKARI_CREATOR_ROOT || await tryMachinePointer(env, platform);
881
887
  if (!creator || !(await tryReadRootManifest(creator)).ok) return result;
@@ -915,11 +921,12 @@ export async function migrateAssetLibrary({ env = process.env, platform = proces
915
921
  if (!release) return { ...result, busy: true };
916
922
  try {
917
923
  location = readLibraryLocation(env, { platform });
918
- if (location?.state === 'declined') return { ...result, state: 'declined' };
924
+ if (location?.state === 'declined' && !targetRoot) return { ...result, state: 'declined' };
919
925
  location = { ...location, version: LIBRARY_LOCATION_VERSION, root,
926
+ ...(sourceRoot ? { previousRoot: path.resolve(sourceRoot) } : {}),
920
927
  decidedAt: location?.decidedAt ?? new Date().toISOString() };
921
928
  const save = () => writeLibraryLocation(location, env, { platform });
922
- if (result.cloud) {
929
+ if (result.cloud && !allowCloud) {
923
930
  location.state = 'pending'; await save(); return { ...result, state: 'pending' };
924
931
  }
925
932
  if (!(automatic && location.state === 'done')) {
@@ -972,6 +979,7 @@ export async function migrateAssetLibrary({ env = process.env, platform = proces
972
979
  for (const name of (await fs.readdir(legacy).catch(error => { if (error.code === 'ENOENT') return []; throw error; })).sort()) {
973
980
  try { await moveEntry(path.join(legacy, name), path.join(root, name)); }
974
981
  catch (error) { result.failures.push({ path: name, message: error.message }); }
982
+ onProgress?.({ moved: result.moved, bytes: result.bytes, totalBytes: result.totalBytes });
975
983
  }
976
984
  try { await repairLibraryLinks(legacy, root, home, preserved); }
977
985
  catch (error) { result.failures.push({ path: root, message: error.message }); }
@@ -996,8 +1004,9 @@ export async function migrateAssetLibrary({ env = process.env, platform = proces
996
1004
  result.failures.push({ path: path.relative(legacy, alias.source), message: error.message });
997
1005
  }
998
1006
  }
999
- location.state = result.failures.length ? 'migrating' : 'done';
1007
+ location.state = result.failures.length || result.skipped.length ? 'migrating' : 'done';
1000
1008
  if (location.state === 'done') location.migratedAt ??= new Date().toISOString();
1009
+ if (location.state === 'done') delete location.previousRoot;
1001
1010
  await save();
1002
1011
  }
1003
1012
  if (location.state === 'done' && !location.notifiedAt && notify) {
@@ -1010,3 +1019,14 @@ export async function migrateAssetLibrary({ env = process.env, platform = proces
1010
1019
  return { ...result, state: readLibraryLocation(env, { platform })?.state ?? result.state };
1011
1020
  } finally { await release(); }
1012
1021
  }
1022
+
1023
+ /** Place changes reuse the same locked, verified migration as first launch. */
1024
+ export async function changeAssetLibraryLocation(root, { env = process.env, platform = process.platform, onProgress } = {}) {
1025
+ if (!path.isAbsolute(root)) throw new Error('Invalid library location');
1026
+ if (env.AKARI_LIBRARY_ROOT && path.resolve(env.AKARI_LIBRARY_ROOT) !== path.resolve(root)) {
1027
+ throw new Error('環境変数で素材の置き場が固定されています');
1028
+ }
1029
+ const current = resolveAssetLibraryRoots(env, { platform }).write;
1030
+ if (path.resolve(current) === path.resolve(root)) return { state: 'done', root, moved: 0, bytes: 0, failures: [] };
1031
+ return migrateAssetLibrary({ env, platform, sourceRoot: current, targetRoot: root, onProgress });
1032
+ }