akari-video 0.1.81 → 0.1.82

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 (67) hide show
  1. package/bin/akari.mjs +2 -0
  2. package/package.json +1 -1
  3. package/src/accept-command.mjs +10 -3
  4. package/src/doctor-command.mjs +1 -0
  5. package/src/narration-command.mjs +58 -79
  6. package/src/runtime-diagnostics.mjs +16 -19
  7. package/src/status-core/integrity.mjs +20 -4
  8. package/src/voice-command.mjs +441 -0
  9. package/vendor/.akari-capability-sources.json +2 -0
  10. package/vendor/docs/contract-2026-07-13-asset-library.md +1 -0
  11. package/vendor/docs/contract-2026-08-02-creator-root-v1.md +3 -3
  12. package/vendor/docs/contract-2026-08-03-caption-display-encoding-qc-v1.md +22 -20
  13. package/vendor/docs/contract-2026-08-12-chat-approval-v0.md +1 -1
  14. package/vendor/docs/contract-2026-09-13-generation-v0.md +1 -0
  15. package/vendor/docs/contract-2026-09-24-style-v0.md +69 -0
  16. package/vendor/packages/akari-launcher/package.json +1 -1
  17. package/vendor/packages/akari-vibe/README.md +1 -1
  18. package/vendor/packages/creator-root/src/credentials.mjs +119 -0
  19. package/vendor/packages/creator-root/src/index.mjs +1 -0
  20. package/vendor/packages/creator-root/test/credentials.test.mjs +75 -0
  21. package/vendor/packages/edit-lint/src/edit-lint.mjs +6 -3
  22. package/vendor/packages/edit-store/lib/audio-schedule.d.ts +1 -0
  23. package/vendor/packages/edit-store/lib/audio-schedule.js +19 -18
  24. package/vendor/packages/edit-store/lib/caption-display.js +17 -5
  25. package/vendor/packages/edit-store/lib/caption-store.d.ts +3 -0
  26. package/vendor/packages/edit-store/lib/caption-store.js +15 -4
  27. package/vendor/packages/edit-store/lib/edit-v2-item-write.d.ts +3 -1
  28. package/vendor/packages/edit-store/lib/edit-v2-item-write.js +19 -4
  29. package/vendor/packages/edit-store/lib/index.d.ts +1 -0
  30. package/vendor/packages/edit-store/lib/index.js +1 -0
  31. package/vendor/packages/edit-store/lib/internal-model.d.ts +1 -0
  32. package/vendor/packages/edit-store/lib/internal-model.js +4 -3
  33. package/vendor/packages/edit-store/lib/legacy-audio-view.d.ts +3 -1
  34. package/vendor/packages/edit-store/lib/legacy-audio-view.js +10 -4
  35. package/vendor/packages/edit-store/lib/migrate/legacy-parse.d.ts +1 -0
  36. package/vendor/packages/edit-store/lib/migrate/legacy-parse.js +1 -0
  37. package/vendor/packages/edit-store/lib/transform-keyframe-edit.d.ts +9 -0
  38. package/vendor/packages/edit-store/lib/transform-keyframe-edit.js +222 -0
  39. package/vendor/packages/edit-store/lib/webview-kernel.js +33 -25
  40. package/vendor/packages/overlay-runtime/src/interaction.css +11 -0
  41. package/vendor/packages/overlay-runtime/src/interaction.js +138 -17
  42. package/vendor/packages/overlay-runtime/src/selection-scope.mjs +9 -1
  43. package/vendor/packages/schemas/bin/validate-captions.mjs +4 -1
  44. package/vendor/packages/schemas/captions.schema.json +1 -0
  45. package/vendor/packages/schemas/test/validate-captions.test.mjs +9 -0
  46. package/vendor/skills/analyze-footage/bin/person-matte/resolve-packages.mjs +97 -21
  47. package/vendor/skills/analyze-footage/bin/transcribe-cloud.mjs +27 -38
  48. package/vendor/skills/analyze-footage/test/entrypoint-symlink.test.mjs +26 -1
  49. package/vendor/skills/analyze-footage/test/resolve-packages-newest.test.mjs +129 -0
  50. package/vendor/skills/generate-narration/SKILL.md +1 -1
  51. package/vendor/skills/generate-narration/engines.md +11 -2
  52. package/vendor/skills/generate-narration/voice-profile-setup.md +39 -15
  53. package/vendor/skills/manage-connections/SKILL.md +3 -3
  54. package/vendor/skills/manage-connections/bin/doctor.mjs +21 -44
  55. package/vendor/skills/manage-connections/bin/resolve-packages.mjs +97 -21
  56. package/vendor/skills/manage-connections/bin/test/resolve-packages.test.mjs +31 -0
  57. package/vendor/skills/manage-connections/dev-fixtures/resolve-packages-newest/README.md +17 -0
  58. package/vendor/skills/manage-connections/dev-fixtures/resolve-packages-newest/reproduce.mjs +97 -0
  59. package/vendor/skills/manage-connections/test/resolve-packages-newest.test.mjs +129 -0
  60. package/vendor/skills/setup-audio-library/drop-folder.md +3 -3
  61. package/vendor/skills/setup-chat-approval/SKILL.md +1 -1
  62. package/vendor/skills/setup-chat-approval/bin/doctor.mjs +15 -27
  63. package/vendor/skills/setup-chat-approval/bin/find-chat-id.mjs +10 -10
  64. package/vendor/skills/setup-chat-approval/bin/resolve-packages.mjs +97 -21
  65. package/vendor/skills/setup-chat-approval/bin/test/distribution.test.mjs +33 -0
  66. package/vendor/skills/setup-chat-approval/setup-token.md +1 -1
  67. package/vendor/skills/setup-chat-approval/test/resolve-packages-newest.test.mjs +129 -0
package/bin/akari.mjs CHANGED
@@ -3,6 +3,7 @@ import { run, runUpdateCommand } from '../src/cli.mjs';
3
3
  import { runInitCommand } from '../src/init-command.mjs';
4
4
  import { runNewCommand } from '../src/new-command.mjs';
5
5
  import { runNarrationCommand } from '../src/narration-command.mjs';
6
+ import { runVoiceCommand } from '../src/voice-command.mjs';
6
7
  import { runInternalCommand } from '../src/internal-command.mjs';
7
8
  import { runSoundsCommand } from '../src/sounds-setup.mjs';
8
9
  import { runStatusCommand } from '../src/status-command.mjs';
@@ -78,6 +79,7 @@ const invoke = (argv[0] === '--version' || argv[0] === '-v') ? printVersion()
78
79
  : argv[0] === 'new' ? runNewCommand(argv.slice(1))
79
80
  : argv[0] === 'skills' ? runSkillsCommand(argv.slice(1))
80
81
  : argv[0] === 'narration' ? runNarrationCommand(argv.slice(1))
82
+ : argv[0] === 'voice' ? runVoiceCommand(argv.slice(1))
81
83
  : argv[0] === 'internal' ? runInternalCommand(argv.slice(1))
82
84
  : argv[0] === 'sounds' ? runSoundsCommand(argv.slice(1))
83
85
  : argv[0] === 'status' ? runStatusCommand(argv.slice(1))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.81",
3
+ "version": "0.1.82",
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": {
@@ -42,9 +42,8 @@ export async function runAcceptCommand(argv, options = {}) {
42
42
  const candidate = before.candidate;
43
43
  const phrase = `ACCEPT ${candidate.artifact_sha256}`;
44
44
  log(`Artifact: ${candidate.artifact}\nArtifact SHA-256: ${candidate.artifact_sha256}\nReceipt: ${candidate.receipt}\nReceipt SHA-256: ${candidate.receipt_sha256}\n`);
45
- if (candidate.audio_qc?.verdict === "INCONCLUSIVE") {
46
- log(`WARNING: audio_qc is INCONCLUSIVE; this is not an audio conformance PASS.\nConfigured: ${JSON.stringify(candidate.audio_qc.configured)}\nFilter report: ${JSON.stringify(candidate.audio_qc.filter_report)}\nDecoded artifact measurement: ${JSON.stringify(candidate.audio_qc.decoded_measurement)}\n`);
47
- }
45
+ const audioQcMessage = formatAudioQcAcceptance(candidate.audio_qc);
46
+ if (audioQcMessage) log(audioQcMessage);
48
47
 
49
48
  const prompt = options.prompt ?? promptFromTTY;
50
49
  let actorId;
@@ -88,6 +87,14 @@ export async function runAcceptCommand(argv, options = {}) {
88
87
  }
89
88
  }
90
89
 
90
+ export function formatAudioQcAcceptance(audioQc) {
91
+ if (audioQc?.verdict === "PASS") return "Audio QC: PASS.\n";
92
+ if (audioQc?.verdict === "INCONCLUSIVE") {
93
+ return `WARNING: audio_qc is INCONCLUSIVE; this is not an audio conformance PASS.\nConfigured: ${JSON.stringify(audioQc.configured)}\nFilter report: ${JSON.stringify(audioQc.filter_report)}\nDecoded artifact measurement: ${JSON.stringify(audioQc.decoded_measurement)}\n`;
94
+ }
95
+ return "";
96
+ }
97
+
91
98
  export function parseAcceptArguments(argv, cwd = process.cwd()) {
92
99
  let projectRoot = cwd;
93
100
  let pathSeen = false;
@@ -41,6 +41,7 @@ export function formatDoctorReport(report) {
41
41
  : report.fal_key?.source === 'credentials.env'
42
42
  ? report.fal_key.credentials_path
43
43
  : '環境変数にも credentials.env にもありません'],
44
+ ['鍵の置き場', report.fal_key?.location ?? 'なし', '新 / 旧 / 両方'],
44
45
  ['path', report.path.on_path ? 'ok' : 'missing', report.path.cli_shim_dir],
45
46
  ];
46
47
  const widths = [
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { spawn, spawnSync } from "node:child_process";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { resolveLauncherAssets } from "./repo-assets.mjs";
10
+ import { listProfiles, readFalKey, resolveVoiceProfile } from "./voice-command.mjs";
10
11
 
11
12
  const VOICEVOX_BASE_URL = "http://127.0.0.1:50021";
12
13
  const VOICEVOX_RUN_ENV = "VOICEVOX_RUN";
@@ -206,6 +207,14 @@ async function runVerify(options, io, runtime = {}) {
206
207
  } finally { fs.rmSync(temporary, { recursive: true, force: true }); }
207
208
  }
208
209
 
210
+ // 録音の原稿照合を、narration verify と同じ backend・正規化・一致率で再利用する。
211
+ export async function verifyNarrationAudio(audio, expected, backend = "auto", runtime = {}) {
212
+ const lines = [];
213
+ const code = await runVerify({ project: path.dirname(audio), audio, text: expected, backend,
214
+ reading: null, record: null, checkBackend: false }, { log: line => lines.push(line), logError: () => {} }, runtime);
215
+ return { code, result: JSON.parse(lines.at(-1)) };
216
+ }
217
+
209
218
  class PublicError extends Error {
210
219
  constructor(message, exitCode = 1) {
211
220
  super(message);
@@ -301,7 +310,7 @@ function parseArguments(argv) {
301
310
  }
302
311
  if (options.engine === "irodori") {
303
312
  options.voice ??= "narrator-male";
304
- if (!IRODORI_RECIPES.some(recipe => recipe.id === options.voice) && options.voice !== "custom") throw new PublicError("彩の声レシピが不明です", 2);
313
+ if (!options.profile && !IRODORI_RECIPES.some(recipe => recipe.id === options.voice) && options.voice !== "custom") throw new PublicError("彩の声レシピが不明です", 2);
305
314
  if (options.voice === "custom" && !options.style?.trim()) throw new PublicError("自分で書く声には --style が必要です", 2);
306
315
  options.irodori = irodoriEndpoint(options.irodoriUrl);
307
316
  irodoriTimeout();
@@ -376,80 +385,34 @@ function relativeOutputPath(id, engine) {
376
385
  return `out/narration/${id}.${extensionFor(engine)}`;
377
386
  }
378
387
 
379
- // --- credentials.env(fal-qwen3 用。skills/analyze-footage/bin/transcribe-cloud.mjs と同型) ---
380
-
381
- function credentialsPath() {
382
- return path.resolve(
383
- process.env.AKARI_CREDENTIALS_FILE
384
- ?? path.join(os.homedir(), ".config", "akari-video", "credentials.env"),
385
- );
386
- }
387
-
388
- function readCredentials() {
389
- let source;
390
- try {
391
- source = fs.readFileSync(credentialsPath(), "utf8");
392
- } catch (error) {
393
- if (error?.code === "ENOENT") return new Map();
394
- throw new PublicError("credentials.env を読めません");
395
- }
396
- const values = new Map();
397
- for (const originalLine of source.split(/\r?\n/)) {
398
- const line = originalLine.trim();
399
- if (!line || line.startsWith("#")) continue;
400
- const separator = line.indexOf("=");
401
- if (separator < 1) continue;
402
- const name = line.slice(0, separator).trim();
403
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
404
- let value = line.slice(separator + 1).trim();
405
- if (
406
- value.length >= 2
407
- && ((value.startsWith('"') && value.endsWith('"'))
408
- || (value.startsWith("'") && value.endsWith("'")))
409
- ) {
410
- value = value.slice(1, -1);
411
- }
412
- values.set(name, value);
413
- }
414
- return values;
415
- }
416
-
417
388
  function resolveFalKey() {
418
- const secret = readCredentials().get("FAL_KEY");
389
+ const secret = readFalKey(process.env);
419
390
  if (!secret) {
420
391
  throw new PublicError(
421
- `FAL_KEY が未設定です。${credentialsPath()} に FAL_KEY=... を 1 行追加してください`
392
+ `FAL_KEY が未設定です。<AKARI_HOME>/credentials.env に FAL_KEY=... を 1 行追加してください`
422
393
  + "(取得先: https://fal.ai/dashboard/keys)。",
423
394
  );
424
395
  }
425
396
  return secret;
426
397
  }
427
398
 
428
- function profileMetaPath(profileName) {
429
- return path.join(os.homedir(), ".config", "akari-video", "voice-profiles", profileName, "meta.json");
399
+ function readProfileMeta(profileName) {
400
+ let meta;
401
+ try { meta = resolveVoiceProfile(profileName).meta; }
402
+ catch { throw new PublicError(`声プロファイルが見つかりません: ${profileName}`, 2); }
403
+ const embedding_source_url = meta.engines?.["fal-qwen3"]?.embedding_source_url;
404
+ if (!embedding_source_url) throw new PublicError(`この声には fal の写しがありません。akari voice copy で作ってください`, 2);
405
+ if (!meta.reference_text) throw new PublicError(`声プロファイルの reference_text がありません: ${profileName}`, 2);
406
+ return { ...meta, embedding_source_url };
430
407
  }
431
408
 
432
- function readProfileMeta(profileName) {
433
- const metaPath = profileMetaPath(profileName);
434
- let raw;
435
- try {
436
- raw = fs.readFileSync(metaPath, "utf8");
437
- } catch {
438
- throw new PublicError(`声プロファイルが見つかりません: ${metaPath}`);
439
- }
409
+ function irodoriProfileVoice(profileName) {
440
410
  let meta;
441
- try {
442
- meta = JSON.parse(raw);
443
- } catch {
444
- throw new PublicError(`声プロファイルの meta.json が不正な JSON です: ${metaPath}`);
445
- }
446
- if (typeof meta.embedding_source_url !== "string" || !meta.embedding_source_url) {
447
- throw new PublicError(`声プロファイルの meta.json に embedding_source_url がありません: ${metaPath}`);
448
- }
449
- if (typeof meta.reference_text !== "string" || !meta.reference_text) {
450
- throw new PublicError(`声プロファイルの meta.json に reference_text がありません: ${metaPath}`);
451
- }
452
- return meta;
411
+ try { meta = resolveVoiceProfile(profileName).meta; }
412
+ catch { throw new PublicError(`声プロファイルが見つかりません: ${profileName}`, 2); }
413
+ const voiceId = meta.engines?.irodori?.voice_id;
414
+ if (!voiceId) throw new PublicError("この声には彩の写しがありません。akari voice copy で作ってください", 2);
415
+ return voiceId;
453
416
  }
454
417
 
455
418
  function buildFalPayload(readingText, meta) {
@@ -786,13 +749,14 @@ async function probeIrodori(endpoint, fetchImpl = fetch) {
786
749
  }
787
750
 
788
751
  async function synthesizeIrodori(readingText, options, fetchImpl = fetch) {
789
- const caption = options.style?.trim() || IRODORI_RECIPES.find(recipe => recipe.id === options.voice)?.caption;
752
+ const caption = options.profile ? null : options.style?.trim() || IRODORI_RECIPES.find(recipe => recipe.id === options.voice)?.caption;
753
+ const profileVoice = options.profile ? irodoriProfileVoice(options.profile) : null;
790
754
  let response;
791
755
  try {
792
756
  response = await fetchImpl(`${options.irodori.base}/v1/audio/speech`, {
793
757
  method: "POST", headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(irodoriTimeout()),
794
- body: JSON.stringify({ model: "irodori-tts", input: readingText, voice: "none", response_format: "wav",
795
- speed: options.speed ?? 1, irodori: { caption } }),
758
+ body: JSON.stringify({ model: "irodori-tts", input: readingText, voice: profileVoice ?? "none", response_format: "wav",
759
+ speed: options.speed ?? 1, ...(caption ? { irodori: { caption } } : {}) }),
796
760
  });
797
761
  } catch (error) { throw new PublicError(`彩サーバーに接続できません: ${error?.message ?? error}`); }
798
762
  const buffer = Buffer.from(await response.arrayBuffer());
@@ -802,6 +766,21 @@ async function synthesizeIrodori(readingText, options, fetchImpl = fetch) {
802
766
  return buffer;
803
767
  }
804
768
 
769
+ function resolveProfileEnv(runtime = {}) {
770
+ const env = runtime.env || process.env;
771
+ const homeDir = env.HOME || env.USERPROFILE || os.homedir();
772
+ return { ...env, HOME: homeDir, AKARI_HOME: env.AKARI_HOME || path.join(homeDir, ".akari") };
773
+ }
774
+
775
+ function listFalProfiles(runtime = {}) {
776
+ try {
777
+ return listProfiles(resolveProfileEnv(runtime)).filter((profile) => profile.legacy || profile.engines.includes("fal-qwen3"));
778
+ } catch {
779
+ // listProfiles はメタデータの読み取り失敗時に throw する。CLI の一覧は維持する。
780
+ return [];
781
+ }
782
+ }
783
+
805
784
  async function listEngines(runtime = {}) {
806
785
  let voicevox;
807
786
  const state = await probeVoicevox(runtime.fetchImpl || fetch);
@@ -819,12 +798,13 @@ async function listEngines(runtime = {}) {
819
798
  ? { state: "needs", label: "VOICEVOX を起動します(自動)", detail }
820
799
  : { state: "unconfigured", label: "VOICEVOX をインストール", detail: { ...detail, setup_url: "https://voicevox.hiroshiba.jp/" } };
821
800
  }
822
- const configured = Boolean(readCredentials().get("FAL_KEY"));
801
+ let configured = false;
802
+ try { configured = Boolean(readFalKey(runtime.env || process.env)); }
803
+ catch { /* 配布物の creator-root や鍵ファイルを読めなくても一覧は返す。 */ }
823
804
  const falAvailability = configured
824
805
  ? { state: "available", label: "fal を使用できます" }
825
806
  : { state: "unconfigured", label: "fal の鍵を登録" };
826
- const profilesDir = path.join(os.homedir(), ".config", "akari-video", "voice-profiles");
827
- const hasProfiles = fs.existsSync(profilesDir) && fs.readdirSync(profilesDir, { withFileTypes: true }).some((entry) => entry.isDirectory());
807
+ const profilesWithFal = listFalProfiles(runtime).length;
828
808
  let endpoint;
829
809
  try { endpoint = irodoriEndpoint(runtime.irodoriUrl, runtime.env || process.env); }
830
810
  catch (error) { if (!(error instanceof PublicError)) throw error; }
@@ -839,11 +819,11 @@ async function listEngines(runtime = {}) {
839
819
  : irodoriAvailable
840
820
  ? { state: "available", label: "お試し · 接続済み", detail: { url: endpoint.server } }
841
821
  : { state: "unconfigured", label: "Irodori サーバーにつながりません(お試し)", detail: { setup_url: IRODORI_SETUP_URL } } },
842
- { id: "fal-qwen3", label: "fal Qwen3-TTS", place: "cloud", provider: "fal", price: { usd_per_1000_chars: FAL_USD_PER_1000_CHARS, verified: false }, availability: !configured ? falAvailability : hasProfiles ? { state: "available", label: "声プロファイルを使用できます" } : { state: "needs", label: "声プロファイルを作成" }, credit_required: false, supports: { speed: false, style: false } },
822
+ { 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, detail: { profiles_with_fal: profilesWithFal } } : profilesWithFal ? { state: "available", label: "声プロファイルを使用できます", detail: { profiles_with_fal: profilesWithFal } } : { state: "needs", label: "fal の写しがある声がありません(自分の声をつくる)", detail: { profiles_with_fal: 0 } }, credit_required: false, supports: { speed: false, style: false } },
843
823
  ] };
844
824
  }
845
825
 
846
- async function listVoices(engine) {
826
+ async function listVoices(engine, runtime = {}) {
847
827
  if (engine === "irodori") return [...IRODORI_RECIPES.map(({ id, label, default: isDefault }) => ({ id, label, ...(isDefault ? { default: true } : {}) })),
848
828
  { id: "custom", label: "自分で書く(声の指示)" }];
849
829
  if (engine === "gemini-tts") return GEMINI_VOICES;
@@ -861,9 +841,7 @@ async function listVoices(engine) {
861
841
  }
862
842
  }
863
843
  if (engine === "fal-qwen3") {
864
- const profilesDir = path.join(os.homedir(), ".config", "akari-video", "voice-profiles");
865
- return fs.existsSync(profilesDir) ? fs.readdirSync(profilesDir, { withFileTypes: true })
866
- .filter((entry) => entry.isDirectory()).map((entry) => ({ id: entry.name, label: entry.name })) : [];
844
+ return listFalProfiles(runtime).map(({ id, label, legacy }) => ({ id, label, ...(legacy ? { legacy: true } : {}) }));
867
845
  }
868
846
  throw new PublicError(`声一覧に対応していないエンジンです: ${engine}`, 2);
869
847
  }
@@ -907,16 +885,17 @@ async function runDryRun(options, readingText, io) {
907
885
  emit({
908
886
  dry_run: true, engine: options.engine, output_path: outputPath,
909
887
  estimated_cost_usd: estimateGeminiTtsCostUsd(readingText.length),
910
- request: { endpoint: GEMINI_TTS_URL, headers: { Authorization: `Key ${maskKey(readCredentials().get("FAL_KEY"))}` },
888
+ request: { endpoint: GEMINI_TTS_URL, headers: { Authorization: `Key ${maskKey(readFalKey(process.env))}` },
911
889
  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 } : {}) } },
912
890
  }, io.log);
913
891
  return;
914
892
  }
915
893
  if (options.engine === "irodori") {
894
+ const profileVoice = options.profile ? irodoriProfileVoice(options.profile) : null;
916
895
  emit({ dry_run: true, engine: options.engine, output_path: outputPath, estimated_cost_usd: 0,
917
896
  request: { endpoint: `${options.irodori.base}/v1/audio/speech`, body: { model: "irodori-tts", input: readingText,
918
- voice: "none", response_format: "wav", speed: options.speed ?? 1,
919
- irodori: { caption: options.style?.trim() || IRODORI_RECIPES.find(recipe => recipe.id === options.voice)?.caption } } } }, io.log);
897
+ voice: profileVoice ?? "none", response_format: "wav", speed: options.speed ?? 1,
898
+ ...(profileVoice ? {} : { irodori: { caption: options.style?.trim() || IRODORI_RECIPES.find(recipe => recipe.id === options.voice)?.caption } }) } } }, io.log);
920
899
  return;
921
900
  }
922
901
  const falKey = resolveFalKey();
@@ -1004,7 +983,7 @@ async function runGenerate(options, io) {
1004
983
  }
1005
984
  } else if (options.engine === "irodori") {
1006
985
  audioBuffer = await synthesizeIrodori(readingText, options);
1007
- provenance = { provider: "irodori", engine: "irodori-tts-v4-small", voice: options.style?.trim() ? "caption:custom" : `recipe:${options.voice}`,
986
+ provenance = { provider: "irodori", engine: "irodori-tts-v4-small", voice: options.profile ? `profile:${options.profile}` : options.style?.trim() ? "caption:custom" : `recipe:${options.voice}`,
1008
987
  generated_at: new Date().toISOString(), experimental: true, server: options.irodori.server };
1009
988
  } else {
1010
989
  const estimatedCostUsd = options.engine === "gemini-tts"
@@ -1098,7 +1077,7 @@ export async function runNarrationCommand(args, commandOptions = {}) {
1098
1077
  try {
1099
1078
  const { engine, irodoriUrl } = parseListArguments(args);
1100
1079
  printCompactJson(args[0] === "engines" ? await listEngines({ ...commandOptions.engineRuntime, irodoriUrl }) :
1101
- { version: 1, engine, voices: await listVoices(engine) }, io.log);
1080
+ { version: 1, engine, voices: await listVoices(engine, commandOptions.engineRuntime) }, io.log);
1102
1081
  return { exitCode: 0 };
1103
1082
  } catch (error) {
1104
1083
  const message = error instanceof PublicError ? error.message : "声一覧を取得できませんでした";
@@ -1,10 +1,14 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import path, { dirname, join, resolve } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
 
6
6
  import { findExecutable } from './path-lookup.mjs';
7
7
  import { readInstalledAppVersionInfo, readOwnVersion, resolveAkariHome } from './update-check.mjs';
8
+ import { resolveLauncherAssets } from './repo-assets.mjs';
9
+
10
+ const creatorRootModulePath = resolveLauncherAssets().creatorRootModulePath;
11
+ const creatorCredentials = creatorRootModulePath ? await import(pathToFileURL(creatorRootModulePath).href) : null;
8
12
 
9
13
  const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
10
14
  const ANCESTOR_SEARCH_MAX_DEPTH = 10;
@@ -162,26 +166,19 @@ export async function resolveDoctorReport(options = {}) {
162
166
  }
163
167
 
164
168
  function resolveFalKeySource({ env, homeDirectory }) {
165
- const credentialsPath = env.AKARI_CREDENTIALS_FILE
166
- ?? join(homeDirectory ?? homedir(), '.config', 'akari-video', 'credentials.env');
169
+ const credentialEnv = homeDirectory ? { ...env, HOME: homeDirectory, USERPROFILE: homeDirectory } : env;
170
+ const credentialsPath = creatorCredentials?.credentialsPaths(credentialEnv).primary
171
+ ?? join(resolveAkariHome(credentialEnv), 'credentials.env');
172
+ let state;
173
+ try { state = creatorCredentials?.readCredentials(credentialEnv); }
174
+ catch { state = { values: new Map(), primaryExists: false, legacyExists: false }; }
175
+ state ??= { values: new Map(), primaryExists: false, legacyExists: false };
176
+ const location = state.primaryExists && state.legacyExists ? '両方' : state.primaryExists ? '新' : state.legacyExists ? '旧' : 'なし';
167
177
  if (typeof env.FAL_KEY === 'string' && env.FAL_KEY.trim().length > 0) {
168
- return { source: 'env', credentials_path: credentialsPath };
169
- }
170
- try {
171
- const source = readFileSync(credentialsPath, 'utf8');
172
- for (const originalLine of source.split(/\r?\n/u)) {
173
- const line = originalLine.trim();
174
- if (!line || line.startsWith('#')) continue;
175
- const separator = line.indexOf('=');
176
- if (separator < 1 || line.slice(0, separator).trim() !== 'FAL_KEY') continue;
177
- if (line.slice(separator + 1).trim().length > 0) {
178
- return { source: 'credentials.env', credentials_path: credentialsPath };
179
- }
180
- }
181
- } catch {
182
- // credentials.env が無い・読めない場合も doctor 自体は継続する。
178
+ return { source: 'env', credentials_path: credentialsPath, location };
183
179
  }
184
- return { source: 'missing', credentials_path: credentialsPath };
180
+ if (state.values.get('FAL_KEY')?.trim()) return { source: 'credentials.env', credentials_path: credentialsPath, location };
181
+ return { source: 'missing', credentials_path: credentialsPath, location };
185
182
  }
186
183
 
187
184
  export async function resolveGpuExportAvailability(options = {}) {
@@ -202,7 +202,7 @@ export function contentAddressedJsonFilenameMatches(filePath, sha256Value, basen
202
202
  return basenameImpl(filePath) === `${sha256Value}.json`;
203
203
  }
204
204
 
205
- function validateAudioQc(value, problems, warnings) {
205
+ export function validateAudioQc(value, problems, warnings) {
206
206
  if (!isRecord(value) || !isRecord(value.configured)
207
207
  || !isFiniteNumber(value.configured.integrated_lufs) || !isFiniteNumber(value.configured.true_peak_dbtp)
208
208
  || !isNonEmptyString(value.tool_version)) {
@@ -226,7 +226,7 @@ function validateAudioQc(value, problems, warnings) {
226
226
  }
227
227
  return;
228
228
  }
229
- if (value.verdict !== "INCONCLUSIVE") {
229
+ if (value.verdict !== "INCONCLUSIVE" && value.verdict !== "PASS") {
230
230
  problems.push("audio_qc verdict is invalid");
231
231
  return;
232
232
  }
@@ -236,10 +236,26 @@ function validateAudioQc(value, problems, warnings) {
236
236
  } else {
237
237
  validateQcReport(value.decoded_measurement, ["input_i", "input_tp"], "audio_qc.decoded_measurement", problems);
238
238
  }
239
- if (value.error !== undefined) problems.push("audio_qc INCONCLUSIVE must not contain an error");
240
- warnings.push("audio_qc is INCONCLUSIVE; configured target, filter report, and decoded measurement require human review");
239
+ if (value.error !== undefined) problems.push("audio_qc successful measurement must not contain an error");
240
+ if (value.verdict === "PASS") {
241
+ const inputI = value.decoded_measurement?.normalized?.input_i;
242
+ const inputTp = value.decoded_measurement?.normalized?.input_tp;
243
+ if (!isNonEmptyString(value.tool_version)
244
+ || !isFiniteNumber(inputI)
245
+ || !isFiniteNumber(inputTp)
246
+ || Math.abs(inputI - value.configured.integrated_lufs) > INTEGRATED_LOUDNESS_TOLERANCE_LU
247
+ || inputTp > value.configured.true_peak_dbtp + TRUE_PEAK_EXCEEDED_TOLERANCE_DB) {
248
+ problems.push("audio_qc PASS does not match configured target and decoded measurement");
249
+ }
250
+ } else {
251
+ warnings.push("audio_qc is INCONCLUSIVE; configured target, filter report, and decoded measurement require human review");
252
+ }
241
253
  }
242
254
 
255
+ // Keep these in parity with render-cut/src/audio-qc.mjs; status-core is a self-contained mirror.
256
+ const INTEGRATED_LOUDNESS_TOLERANCE_LU = 1.0;
257
+ const TRUE_PEAK_EXCEEDED_TOLERANCE_DB = 0.1;
258
+
243
259
  // akari-audio-qc-decimal-v1: exact finite base-10 text only. This intentionally duplicates the
244
260
  // render-cut grammar because status-core is distributed as a self-contained generated mirror.
245
261
  const STRICT_FINITE_DECIMAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u;