akari-video 0.1.76 → 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 (56) hide show
  1. package/package.json +1 -1
  2. package/src/narration-command.mjs +218 -34
  3. package/src/service-urls.cjs +38 -0
  4. package/src/service-urls.d.cts +9 -0
  5. package/src/store-device-connect.mjs +7 -6
  6. package/vendor/docs/contract-2026-07-13-asset-library.md +1 -1
  7. package/vendor/docs/contract-2026-07-18-edit-json-v1-sources.md +1 -0
  8. package/vendor/docs/contract-2026-07-20-edit-json-v1-narration.md +5 -0
  9. package/vendor/docs/contract-2026-08-02-preview-parity.md +9 -0
  10. package/vendor/docs/contract-2026-08-03-caption-display-encoding-qc-v1.md +1 -0
  11. package/vendor/docs/contract-2026-08-09-transform-keyframes-v0.md +9 -0
  12. package/vendor/docs/contract-2026-08-30-motion-and-keyframes-v0.md +9 -0
  13. package/vendor/docs/contract-2026-09-02-asset-reference-model.md +4 -3
  14. package/vendor/docs/contract-2026-09-13-generation-v0.md +2 -0
  15. package/vendor/packages/akari-launcher/package.json +1 -1
  16. package/vendor/packages/asset-resolver/README.md +4 -4
  17. package/vendor/packages/asset-resolver/bin/akari-assets.mjs +3 -2
  18. package/vendor/packages/asset-resolver/package.json +1 -1
  19. package/vendor/packages/asset-resolver/src/env.mjs +5 -5
  20. package/vendor/packages/asset-resolver/src/library.mjs +4 -1
  21. package/vendor/packages/asset-resolver/src/service-urls.mjs +14 -0
  22. package/vendor/packages/asset-resolver/src/shell-reference.mjs +92 -0
  23. package/vendor/packages/asset-resolver/test/local-library.test.mjs +10 -5
  24. package/vendor/packages/asset-resolver/test/service-urls.test.mjs +100 -0
  25. package/vendor/packages/creator-root/src/index.mjs +32 -12
  26. package/vendor/packages/creator-root/test/library-migration.test.mjs +54 -1
  27. package/vendor/packages/edit-lint/src/edit-lint.mjs +22 -26
  28. package/vendor/packages/edit-store/lib/canonical.js +5 -1
  29. package/vendor/packages/edit-store/lib/caption-display.js +4 -3
  30. package/vendor/packages/edit-store/lib/edit-store.d.ts +8 -0
  31. package/vendor/packages/edit-store/lib/edit-store.js +14 -10
  32. package/vendor/packages/edit-store/lib/edit-v2-item-write.d.ts +2 -0
  33. package/vendor/packages/edit-store/lib/edit-v2-item-write.js +31 -7
  34. package/vendor/packages/edit-store/lib/edit-v2.d.ts +2 -0
  35. package/vendor/packages/edit-store/lib/edit-v2.js +13 -3
  36. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.d.ts +2 -2
  37. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.js +2 -1
  38. package/vendor/packages/edit-store/lib/index.d.ts +1 -0
  39. package/vendor/packages/edit-store/lib/index.js +4 -1
  40. package/vendor/packages/edit-store/lib/internal-model.js +2 -0
  41. package/vendor/packages/edit-store/lib/transform.d.ts +8 -0
  42. package/vendor/packages/edit-store/lib/transform.js +21 -0
  43. package/vendor/packages/edit-store/lib/tree-ops.d.ts +1 -1
  44. package/vendor/packages/edit-store/lib/tree-ops.js +16 -5
  45. package/vendor/packages/edit-store/lib/webview-kernel.js +13 -3
  46. package/vendor/packages/schemas/bin/validate-edit.mjs +10 -5
  47. package/vendor/packages/schemas/bin/validate-sites.mjs +31 -0
  48. package/vendor/packages/schemas/edit.schema.json +8 -2
  49. package/vendor/packages/schemas/engine-capabilities.json +4 -3
  50. package/vendor/packages/schemas/package.json +4 -2
  51. package/vendor/packages/schemas/site.schema.json +26 -0
  52. package/vendor/packages/schemas/test/anisotropic-transform.test.mjs +22 -0
  53. package/vendor/packages/schemas/test/engine-capabilities.test.mjs +1 -1
  54. package/vendor/packages/schemas/test/validate-edit.test.mjs +28 -0
  55. package/vendor/packages/schemas/test/validate-sites.test.mjs +23 -0
  56. 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.76",
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
  }
@@ -0,0 +1,38 @@
1
+ // Shared by the launcher, resolver and shell. Keep this module browser-safe;
2
+ // CommonJS also lets the shell's compiled CommonJS modules load it in Node.
3
+ const AKARI_HOST = 'akari.video';
4
+ const LEGACY_AKARI_HOST = 'akari-oss.app';
5
+ const DEFAULT_STORE_API = `https://${AKARI_HOST}`;
6
+ const DEFAULT_STORE_BASE_URL = `${DEFAULT_STORE_API}/api/store`;
7
+ const DEFAULT_STORE_LAB_BASE_URL = `${DEFAULT_STORE_API}/lab`;
8
+ const DEFAULT_ASSETS_BASE_URL = `${DEFAULT_STORE_API}/assets/`;
9
+ const DEFAULT_CATALOG_URL = `${DEFAULT_ASSETS_BASE_URL}catalog.json`;
10
+
11
+ /** Migrate only the former official origin; preserve custom servers and paths. */
12
+ function normalizeAkariUrl(value) {
13
+ try {
14
+ const url = new URL(value);
15
+ if ((url.protocol === 'https:' || url.protocol === 'http:') && url.hostname === LEGACY_AKARI_HOST) {
16
+ url.protocol = 'https:';
17
+ url.hostname = AKARI_HOST;
18
+ return url.href;
19
+ }
20
+ } catch { /* Local paths and malformed values keep their existing handling. */ }
21
+ return value;
22
+ }
23
+
24
+ /** Store credentials contain .../api/store; purchase pages live under .../lab. */
25
+ function deriveStoreLabBaseUrl(storeApiUrl) {
26
+ if (!storeApiUrl) return DEFAULT_STORE_LAB_BASE_URL;
27
+ return normalizeAkariUrl(storeApiUrl).replace(/\/api\/store\/?$/, '/lab');
28
+ }
29
+
30
+ exports.AKARI_HOST = AKARI_HOST;
31
+ exports.LEGACY_AKARI_HOST = LEGACY_AKARI_HOST;
32
+ exports.DEFAULT_STORE_API = DEFAULT_STORE_API;
33
+ exports.DEFAULT_STORE_BASE_URL = DEFAULT_STORE_BASE_URL;
34
+ exports.DEFAULT_STORE_LAB_BASE_URL = DEFAULT_STORE_LAB_BASE_URL;
35
+ exports.DEFAULT_ASSETS_BASE_URL = DEFAULT_ASSETS_BASE_URL;
36
+ exports.DEFAULT_CATALOG_URL = DEFAULT_CATALOG_URL;
37
+ exports.normalizeAkariUrl = normalizeAkariUrl;
38
+ exports.deriveStoreLabBaseUrl = deriveStoreLabBaseUrl;
@@ -0,0 +1,9 @@
1
+ export const AKARI_HOST: string;
2
+ export const LEGACY_AKARI_HOST: string;
3
+ export const DEFAULT_STORE_API: string;
4
+ export const DEFAULT_STORE_BASE_URL: string;
5
+ export const DEFAULT_STORE_LAB_BASE_URL: string;
6
+ export const DEFAULT_ASSETS_BASE_URL: string;
7
+ export const DEFAULT_CATALOG_URL: string;
8
+ export function normalizeAkariUrl(value: string): string;
9
+ export function deriveStoreLabBaseUrl(storeApiUrl: string | undefined): string;
@@ -5,7 +5,8 @@ import {
5
5
  import { homedir, hostname } from 'node:os';
6
6
  import path from 'node:path';
7
7
 
8
- export const DEFAULT_STORE_BASE_URL = 'https://akari-oss.app/api/store';
8
+ import { DEFAULT_STORE_BASE_URL, normalizeAkariUrl } from './service-urls.cjs';
9
+ export { DEFAULT_STORE_BASE_URL } from './service-urls.cjs';
9
10
  const CREDENTIALS_FILE = 'store-credentials.json';
10
11
 
11
12
  export function resolveAkariHome(env = process.env) {
@@ -22,7 +23,7 @@ export function readCredentials(env = process.env) {
22
23
  try {
23
24
  const parsed = JSON.parse(readFileSync(file, 'utf8'));
24
25
  if (typeof parsed?.token !== 'string' || typeof parsed?.url !== 'string') return null;
25
- return parsed;
26
+ return { ...parsed, url: normalizeAkariUrl(parsed.url) };
26
27
  } catch {
27
28
  return null;
28
29
  }
@@ -57,7 +58,7 @@ export function defaultOpenBrowser(url, platform = process.platform) {
57
58
  export async function fetchStoreEntitlements(fetchImpl, baseUrl, token) {
58
59
  let response;
59
60
  try {
60
- response = await fetchImpl(`${baseUrl}/v1/entitlements`, {
61
+ response = await fetchImpl(`${normalizeAkariUrl(baseUrl).replace(/\/+$/, '')}/v1/entitlements`, {
61
62
  headers: { authorization: `Bearer ${token}` }
62
63
  });
63
64
  } catch (error) {
@@ -95,7 +96,7 @@ export async function validateAndSaveCredentials(
95
96
  return { status: 'error', error };
96
97
  }
97
98
  const credentials = {
98
- url: baseUrl,
99
+ url: normalizeAkariUrl(baseUrl),
99
100
  token,
100
101
  email: data.email,
101
102
  connected_at: now().toISOString()
@@ -113,7 +114,7 @@ export async function startDeviceConnection({
113
114
  label = `AKARI Video (${hostname()})`,
114
115
  openBrowser
115
116
  } = {}) {
116
- const normalizedBaseUrl = baseUrl.replace(/\/$/, '');
117
+ const normalizedBaseUrl = normalizeAkariUrl(baseUrl).replace(/\/+$/, '');
117
118
  let response;
118
119
  try {
119
120
  response = await fetchImpl(`${normalizedBaseUrl}/device/start`, {
@@ -159,7 +160,7 @@ export async function pollDeviceConnection({
159
160
  }) {
160
161
  let response;
161
162
  try {
162
- response = await fetchImpl(`${baseUrl}/device/claim`, {
163
+ response = await fetchImpl(`${normalizeAkariUrl(baseUrl).replace(/\/+$/, '')}/device/claim`, {
163
164
  method: 'POST',
164
165
  headers: { 'content-type': 'application/json' },
165
166
  body: JSON.stringify({ deviceCode })
@@ -442,7 +442,7 @@ catalog に載せる素材は、取得元のライセンスが CC0 相当(帰
442
442
  出どころはカタログ掲載(lab)、明示 origin タグ(site / own)、AKARI 配布元の source.url(lab)、
443
443
  その他の source.url あり(site)、それ以外(own)の順で決める。
444
444
  AKARI 配布元は URL を解析し、ホスト github.com かつパスの最初のセグメントが AkariLabs、
445
- またはホスト akari-oss.app とそのサブドメインで判定する。壊れた URL は site とする。
445
+ またはホスト akari.video とそのサブドメインで判定する。移行期間中は旧公式ホストとそのサブドメインも受け入れる。壊れた URL は site とする。
446
446
  素材ディレクトリの `CREDIT.txt` はクレジット文面 1 行。文面がある場合は
447
447
  `license.attribution_required: true`、source がある場合はその attribution_required も true にする。
448
448
 
@@ -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
@@ -4,7 +4,8 @@
4
4
  `akari-assets list`(または `akari assets list`)の先頭行で実際の置き場を確認する。
5
5
  以下の `<ライブラリの置き場>` はその表示先を指し、音源はその下の `audio/` に入る。
6
6
 
7
- - 状態: 実装済み(機械層のみ。シェル UI の採用は後続)
7
+ - 状態: 実装済み(機械層 2026-09-02。シェル UI の採用 = 取り込みフローの reference 既定化・プロジェクト面の「参照」札・
8
+ プレビュー / タイムラインの経路・「素材をまとめる」は 2026-09-22 `task/2026-09-21-library-reference-in-shell` で採用)
8
9
  - 決定日: 2026-09-02
9
10
  - 実装: `packages/asset-resolver`(記帳・実体化)/ `packages/render-cut`・`packages/edit-lint`(解決)
10
11
 
@@ -99,8 +100,8 @@ render-cut は子プロセスの起動前に排他的に作成し、OSR / GPU(
99
100
 
100
101
  ## 5. スコープ外(後続)
101
102
 
102
- - シェル UI の採用(取り込みフローの reference 既定化・プロジェクト面の「参照」バッジ・
103
- プレビュー経路のフォールバック)
103
+ - ~~シェル UI の採用(取り込みフローの reference 既定化・プロジェクト面の「参照」バッジ・
104
+ プレビュー経路のフォールバック)~~ → 2026-09-22 に採用済み(「状態」行を参照)。残るのは置き場側の変化で札を自動更新する件
104
105
  - 共有キャッシュの容量管理 UI
105
106
 
106
107
  出自: 2026-09-02 の素材パネル再設計ラウンドの裁定 3(実体 = 共有キャッシュ・プロジェクトには参照・