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