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.
- package/package.json +1 -1
- package/src/narration-command.mjs +218 -34
- package/vendor/docs/contract-2026-07-18-edit-json-v1-sources.md +1 -0
- package/vendor/docs/contract-2026-07-20-edit-json-v1-narration.md +5 -0
- package/vendor/docs/contract-2026-08-02-preview-parity.md +9 -0
- package/vendor/docs/contract-2026-08-03-caption-display-encoding-qc-v1.md +1 -0
- package/vendor/docs/contract-2026-08-09-transform-keyframes-v0.md +9 -0
- package/vendor/docs/contract-2026-08-30-motion-and-keyframes-v0.md +9 -0
- package/vendor/docs/contract-2026-09-13-generation-v0.md +2 -0
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/creator-root/src/index.mjs +32 -12
- package/vendor/packages/creator-root/test/library-migration.test.mjs +54 -1
- package/vendor/packages/edit-lint/src/edit-lint.mjs +22 -26
- package/vendor/packages/edit-store/lib/canonical.js +5 -1
- package/vendor/packages/edit-store/lib/caption-display.js +4 -3
- package/vendor/packages/edit-store/lib/edit-store.d.ts +8 -0
- package/vendor/packages/edit-store/lib/edit-store.js +14 -10
- package/vendor/packages/edit-store/lib/edit-v2-item-write.d.ts +2 -0
- package/vendor/packages/edit-store/lib/edit-v2-item-write.js +31 -7
- package/vendor/packages/edit-store/lib/edit-v2.d.ts +2 -0
- package/vendor/packages/edit-store/lib/edit-v2.js +13 -3
- package/vendor/packages/edit-store/lib/generated/edit-v2-keys.d.ts +2 -2
- package/vendor/packages/edit-store/lib/generated/edit-v2-keys.js +2 -1
- package/vendor/packages/edit-store/lib/index.d.ts +1 -0
- package/vendor/packages/edit-store/lib/index.js +4 -1
- package/vendor/packages/edit-store/lib/internal-model.js +2 -0
- package/vendor/packages/edit-store/lib/transform.d.ts +8 -0
- package/vendor/packages/edit-store/lib/transform.js +21 -0
- package/vendor/packages/edit-store/lib/tree-ops.d.ts +1 -1
- package/vendor/packages/edit-store/lib/tree-ops.js +16 -5
- package/vendor/packages/edit-store/lib/webview-kernel.js +13 -3
- package/vendor/packages/schemas/bin/validate-edit.mjs +10 -5
- package/vendor/packages/schemas/bin/validate-sites.mjs +31 -0
- package/vendor/packages/schemas/edit.schema.json +8 -2
- package/vendor/packages/schemas/engine-capabilities.json +4 -3
- package/vendor/packages/schemas/package.json +4 -2
- package/vendor/packages/schemas/site.schema.json +26 -0
- package/vendor/packages/schemas/test/anisotropic-transform.test.mjs +22 -0
- package/vendor/packages/schemas/test/engine-capabilities.test.mjs +1 -1
- package/vendor/packages/schemas/test/validate-edit.test.mjs +28 -0
- package/vendor/packages/schemas/test/validate-sites.test.mjs +23 -0
- 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.
|
|
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] [--
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, "表示原稿") :
|
|
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
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
io.logError(
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
provider: "fal",
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
+
}
|