akari-video 0.1.76 → 0.1.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/narration-command.mjs +218 -34
- package/src/service-urls.cjs +38 -0
- package/src/service-urls.d.cts +9 -0
- package/src/store-device-connect.mjs +7 -6
- package/vendor/docs/contract-2026-07-13-asset-library.md +1 -1
- 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-02-asset-reference-model.md +4 -3
- package/vendor/docs/contract-2026-09-13-generation-v0.md +2 -0
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/asset-resolver/README.md +4 -4
- package/vendor/packages/asset-resolver/bin/akari-assets.mjs +3 -2
- package/vendor/packages/asset-resolver/package.json +1 -1
- package/vendor/packages/asset-resolver/src/env.mjs +5 -5
- package/vendor/packages/asset-resolver/src/library.mjs +4 -1
- package/vendor/packages/asset-resolver/src/service-urls.mjs +14 -0
- package/vendor/packages/asset-resolver/src/shell-reference.mjs +92 -0
- package/vendor/packages/asset-resolver/test/local-library.test.mjs +10 -5
- package/vendor/packages/asset-resolver/test/service-urls.test.mjs +100 -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 +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
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Shared by the launcher, resolver and shell. Keep this module browser-safe;
|
|
2
|
+
// CommonJS also lets the shell's compiled CommonJS modules load it in Node.
|
|
3
|
+
const AKARI_HOST = 'akari.video';
|
|
4
|
+
const LEGACY_AKARI_HOST = 'akari-oss.app';
|
|
5
|
+
const DEFAULT_STORE_API = `https://${AKARI_HOST}`;
|
|
6
|
+
const DEFAULT_STORE_BASE_URL = `${DEFAULT_STORE_API}/api/store`;
|
|
7
|
+
const DEFAULT_STORE_LAB_BASE_URL = `${DEFAULT_STORE_API}/lab`;
|
|
8
|
+
const DEFAULT_ASSETS_BASE_URL = `${DEFAULT_STORE_API}/assets/`;
|
|
9
|
+
const DEFAULT_CATALOG_URL = `${DEFAULT_ASSETS_BASE_URL}catalog.json`;
|
|
10
|
+
|
|
11
|
+
/** Migrate only the former official origin; preserve custom servers and paths. */
|
|
12
|
+
function normalizeAkariUrl(value) {
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(value);
|
|
15
|
+
if ((url.protocol === 'https:' || url.protocol === 'http:') && url.hostname === LEGACY_AKARI_HOST) {
|
|
16
|
+
url.protocol = 'https:';
|
|
17
|
+
url.hostname = AKARI_HOST;
|
|
18
|
+
return url.href;
|
|
19
|
+
}
|
|
20
|
+
} catch { /* Local paths and malformed values keep their existing handling. */ }
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Store credentials contain .../api/store; purchase pages live under .../lab. */
|
|
25
|
+
function deriveStoreLabBaseUrl(storeApiUrl) {
|
|
26
|
+
if (!storeApiUrl) return DEFAULT_STORE_LAB_BASE_URL;
|
|
27
|
+
return normalizeAkariUrl(storeApiUrl).replace(/\/api\/store\/?$/, '/lab');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
exports.AKARI_HOST = AKARI_HOST;
|
|
31
|
+
exports.LEGACY_AKARI_HOST = LEGACY_AKARI_HOST;
|
|
32
|
+
exports.DEFAULT_STORE_API = DEFAULT_STORE_API;
|
|
33
|
+
exports.DEFAULT_STORE_BASE_URL = DEFAULT_STORE_BASE_URL;
|
|
34
|
+
exports.DEFAULT_STORE_LAB_BASE_URL = DEFAULT_STORE_LAB_BASE_URL;
|
|
35
|
+
exports.DEFAULT_ASSETS_BASE_URL = DEFAULT_ASSETS_BASE_URL;
|
|
36
|
+
exports.DEFAULT_CATALOG_URL = DEFAULT_CATALOG_URL;
|
|
37
|
+
exports.normalizeAkariUrl = normalizeAkariUrl;
|
|
38
|
+
exports.deriveStoreLabBaseUrl = deriveStoreLabBaseUrl;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const AKARI_HOST: string;
|
|
2
|
+
export const LEGACY_AKARI_HOST: string;
|
|
3
|
+
export const DEFAULT_STORE_API: string;
|
|
4
|
+
export const DEFAULT_STORE_BASE_URL: string;
|
|
5
|
+
export const DEFAULT_STORE_LAB_BASE_URL: string;
|
|
6
|
+
export const DEFAULT_ASSETS_BASE_URL: string;
|
|
7
|
+
export const DEFAULT_CATALOG_URL: string;
|
|
8
|
+
export function normalizeAkariUrl(value: string): string;
|
|
9
|
+
export function deriveStoreLabBaseUrl(storeApiUrl: string | undefined): string;
|
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
import { homedir, hostname } from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
import { DEFAULT_STORE_BASE_URL, normalizeAkariUrl } from './service-urls.cjs';
|
|
9
|
+
export { DEFAULT_STORE_BASE_URL } from './service-urls.cjs';
|
|
9
10
|
const CREDENTIALS_FILE = 'store-credentials.json';
|
|
10
11
|
|
|
11
12
|
export function resolveAkariHome(env = process.env) {
|
|
@@ -22,7 +23,7 @@ export function readCredentials(env = process.env) {
|
|
|
22
23
|
try {
|
|
23
24
|
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
24
25
|
if (typeof parsed?.token !== 'string' || typeof parsed?.url !== 'string') return null;
|
|
25
|
-
return parsed;
|
|
26
|
+
return { ...parsed, url: normalizeAkariUrl(parsed.url) };
|
|
26
27
|
} catch {
|
|
27
28
|
return null;
|
|
28
29
|
}
|
|
@@ -57,7 +58,7 @@ export function defaultOpenBrowser(url, platform = process.platform) {
|
|
|
57
58
|
export async function fetchStoreEntitlements(fetchImpl, baseUrl, token) {
|
|
58
59
|
let response;
|
|
59
60
|
try {
|
|
60
|
-
response = await fetchImpl(`${baseUrl}/v1/entitlements`, {
|
|
61
|
+
response = await fetchImpl(`${normalizeAkariUrl(baseUrl).replace(/\/+$/, '')}/v1/entitlements`, {
|
|
61
62
|
headers: { authorization: `Bearer ${token}` }
|
|
62
63
|
});
|
|
63
64
|
} catch (error) {
|
|
@@ -95,7 +96,7 @@ export async function validateAndSaveCredentials(
|
|
|
95
96
|
return { status: 'error', error };
|
|
96
97
|
}
|
|
97
98
|
const credentials = {
|
|
98
|
-
url: baseUrl,
|
|
99
|
+
url: normalizeAkariUrl(baseUrl),
|
|
99
100
|
token,
|
|
100
101
|
email: data.email,
|
|
101
102
|
connected_at: now().toISOString()
|
|
@@ -113,7 +114,7 @@ export async function startDeviceConnection({
|
|
|
113
114
|
label = `AKARI Video (${hostname()})`,
|
|
114
115
|
openBrowser
|
|
115
116
|
} = {}) {
|
|
116
|
-
const normalizedBaseUrl = baseUrl.replace(
|
|
117
|
+
const normalizedBaseUrl = normalizeAkariUrl(baseUrl).replace(/\/+$/, '');
|
|
117
118
|
let response;
|
|
118
119
|
try {
|
|
119
120
|
response = await fetchImpl(`${normalizedBaseUrl}/device/start`, {
|
|
@@ -159,7 +160,7 @@ export async function pollDeviceConnection({
|
|
|
159
160
|
}) {
|
|
160
161
|
let response;
|
|
161
162
|
try {
|
|
162
|
-
response = await fetchImpl(`${baseUrl}/device/claim`, {
|
|
163
|
+
response = await fetchImpl(`${normalizeAkariUrl(baseUrl).replace(/\/+$/, '')}/device/claim`, {
|
|
163
164
|
method: 'POST',
|
|
164
165
|
headers: { 'content-type': 'application/json' },
|
|
165
166
|
body: JSON.stringify({ deviceCode })
|
|
@@ -442,7 +442,7 @@ catalog に載せる素材は、取得元のライセンスが CC0 相当(帰
|
|
|
442
442
|
出どころはカタログ掲載(lab)、明示 origin タグ(site / own)、AKARI 配布元の source.url(lab)、
|
|
443
443
|
その他の source.url あり(site)、それ以外(own)の順で決める。
|
|
444
444
|
AKARI 配布元は URL を解析し、ホスト github.com かつパスの最初のセグメントが AkariLabs、
|
|
445
|
-
またはホスト akari
|
|
445
|
+
またはホスト akari.video とそのサブドメインで判定する。移行期間中は旧公式ホストとそのサブドメインも受け入れる。壊れた URL は site とする。
|
|
446
446
|
素材ディレクトリの `CREDIT.txt` はクレジット文面 1 行。文面がある場合は
|
|
447
447
|
`license.attribution_required: true`、source がある場合はその attribution_required も true にする。
|
|
448
448
|
|
|
@@ -83,6 +83,7 @@ source 秒アンカー規則の対象外である。
|
|
|
83
83
|
`sourceT` または `sourceRange` と組にして source 座標を表す
|
|
84
84
|
- `src` の省略は単一ソース互換を意味する
|
|
85
85
|
- 字幕の重なり(`captions.overlap`)と並び順(`captions.order`)の判定は `src` ごとに行う
|
|
86
|
+
- `time_domain: "output"` の行(置いた文字)は重なり・並び順の検査対象外(2026-09-22 裁定。置き物なので同時に何個あってもよい)
|
|
86
87
|
|
|
87
88
|
analysis サイドカーは素材単位のままとし、構造は変更しない。参照時に `src` から source path を
|
|
88
89
|
解決する。
|
|
@@ -192,3 +192,8 @@ narration 音声ファイルの実尺解析(ffprobe 等)を要する別契
|
|
|
192
192
|
- ducking のサイドチェイン入力を narration トラックへ切り替える実装(プレビュー/書き出し双方)
|
|
193
193
|
- narration の実尺を考慮した区間重複検出(§6)
|
|
194
194
|
- `audio.narration[].provenance.provider` の enum 強制(現状は文書上の例示のみ)
|
|
195
|
+
|
|
196
|
+
## 追記 2026-09-22: `caption_ref`
|
|
197
|
+
|
|
198
|
+
ナレーションには、生成元の字幕 ID を表す任意の `caption_ref`(`c-` と 4 桁の数字)を追加できる。
|
|
199
|
+
字幕は別ファイルなので参照先の存在確認は行わない。追加のみの進化であり、edit.json の `version` は変更しない。
|
|
@@ -58,6 +58,15 @@ cut 境界の選択は宣言順ではなく解決済みタイムラインと z-o
|
|
|
58
58
|
|
|
59
59
|
### 2.2 framing、transform、opacity、freeze、keyframes
|
|
60
60
|
|
|
61
|
+
> **2026-09-22 — 非等方スケール v1**: `transform.scaleX` / `scaleY` は独立した正数。
|
|
62
|
+
> 有効値は `(scaleX ?? scale ?? 1, scaleY ?? scale ?? 1)`。負値・0 は不可。
|
|
63
|
+
> `source.kind === 'group'` は軸別指定を持てず、親は常に等比とする。
|
|
64
|
+
> 等比親との合成は子の各有効値に親の `scale` を掛け、逆変換は同じ値で割る。
|
|
65
|
+
> 書き込み時に両軸の有効値が等しければ `scale` に畳む。既存の `scale` だけの宣言は変更しない。
|
|
66
|
+
> keyframes は端点を各軸の有効値へ解決してから補間する(`scale` と `scaleX` の混在も可)。
|
|
67
|
+
> overlay の CSS は `translate(...) scale(sx, sy) rotate(...)`、素材は crop の幅・高さへ
|
|
68
|
+
> 各軸を掛けて回転する。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
|
|
69
|
+
|
|
61
70
|
- `framing.crop` は fit 済みフレームを窓抜きして出力寸法へ再拡大する。
|
|
62
71
|
- `framing.keyframes`、transform keyframes は cut 内の出力秒で評価し、hold / linear / ease-in-out の
|
|
63
72
|
指定補間を使う。
|
|
@@ -40,6 +40,7 @@ style/emphasis conflicts; non-NFC or trimmed text; invalid manual fragments; unr
|
|
|
40
40
|
overlap; and reference/output aspect mismatch. A resolved production render stores
|
|
41
41
|
`.akari/reports/caption-layout/<payload-sha256>.json`, including Node/ICU provenance and the boundary
|
|
42
42
|
projection digest, and its immutable render receipt references the file and summary.
|
|
43
|
+
Captions with `time_domain: "output"` (placed text) are exempt from overlap and order checks; any number may share the same time (2026-09-22 decision). Source-domain captions retain their per-source checks.
|
|
43
44
|
|
|
44
45
|
Under `display_policy`, an omitted `captions[].style` remains valid. The known word-display styles
|
|
45
46
|
`karaoke`, `pop`, `reveal`, and `reveal-word` remain `STYLE_CONFLICT` errors, while any other value is
|
|
@@ -17,6 +17,15 @@ updated: 2026-08-30
|
|
|
17
17
|
|
|
18
18
|
## 1. 意味論
|
|
19
19
|
|
|
20
|
+
> **2026-09-22 — 非等方スケール v1**: `transform.scaleX` / `scaleY` は独立した正数。
|
|
21
|
+
> 有効値は `(scaleX ?? scale ?? 1, scaleY ?? scale ?? 1)`。負値・0 は不可。
|
|
22
|
+
> `source.kind === 'group'` は軸別指定を持てず、親は常に等比とする。
|
|
23
|
+
> 等比親との合成は子の各有効値に親の `scale` を掛け、逆変換は同じ値で割る。
|
|
24
|
+
> 書き込み時に両軸の有効値が等しければ `scale` に畳む。既存の `scale` だけの宣言は変更しない。
|
|
25
|
+
> keyframes は端点を各軸の有効値へ解決してから補間する(`scale` と `scaleX` の混在も可)。
|
|
26
|
+
> overlay の CSS は `translate(...) scale(sx, sy) rotate(...)`、素材は crop の幅・高さへ
|
|
27
|
+
> 各軸を掛けて回転する。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
|
|
28
|
+
|
|
20
29
|
- `keyframes[]` はレイヤー / アイテムの `transform` / `crop` / `perspective` を時間で動かす共通機構
|
|
21
30
|
- `t` は**ローカル時間**: v1 `layers[].keyframes[].t` はレイヤー内秒(`layerItem.t` を 0 とする。`cuts[].framing.keyframes[].t` と同じ規約)、
|
|
22
31
|
v2 `items[].keyframes[].t` は**アイテム内の整数フレーム**(`item.at` を 0 とする)
|
|
@@ -63,6 +63,15 @@ updated: 2026-09-06
|
|
|
63
63
|
|
|
64
64
|
### 2.1 形(1 つだけ: 点形)
|
|
65
65
|
|
|
66
|
+
> **2026-09-22 — 非等方スケール v1**: `transform.scaleX` / `scaleY` は独立した正数。
|
|
67
|
+
> 有効値は `(scaleX ?? scale ?? 1, scaleY ?? scale ?? 1)`。負値・0 は不可。
|
|
68
|
+
> `source.kind === 'group'` は軸別指定を持てず、親は常に等比とする。
|
|
69
|
+
> 等比親との合成は子の各有効値に親の `scale` を掛け、逆変換は同じ値で割る。
|
|
70
|
+
> 書き込み時に両軸の有効値が等しければ `scale` に畳む。既存の `scale` だけの宣言は変更しない。
|
|
71
|
+
> keyframes は端点を各軸の有効値へ解決してから補間する(`scale` と `scaleX` の混在も可)。
|
|
72
|
+
> overlay の CSS は `translate(...) scale(sx, sy) rotate(...)`、素材は crop の幅・高さへ
|
|
73
|
+
> 各軸を掛けて回転する。四隅 resize は両軸へ同じ倍率を掛け、縦横比を維持する。
|
|
74
|
+
|
|
66
75
|
既存 `keyframeV2`(点ごとに任意プロパティ・`t` はアイテム相対の整数フレーム)を**唯一の形**として保ち、次を足す:
|
|
67
76
|
|
|
68
77
|
```jsonc
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
`akari-assets list`(または `akari assets list`)の先頭行で実際の置き場を確認する。
|
|
5
5
|
以下の `<ライブラリの置き場>` はその表示先を指し、音源はその下の `audio/` に入る。
|
|
6
6
|
|
|
7
|
-
- 状態:
|
|
7
|
+
- 状態: 実装済み(機械層 2026-09-02。シェル UI の採用 = 取り込みフローの reference 既定化・プロジェクト面の「参照」札・
|
|
8
|
+
プレビュー / タイムラインの経路・「素材をまとめる」は 2026-09-22 `task/2026-09-21-library-reference-in-shell` で採用)
|
|
8
9
|
- 決定日: 2026-09-02
|
|
9
10
|
- 実装: `packages/asset-resolver`(記帳・実体化)/ `packages/render-cut`・`packages/edit-lint`(解決)
|
|
10
11
|
|
|
@@ -99,8 +100,8 @@ render-cut は子プロセスの起動前に排他的に作成し、OSR / GPU(
|
|
|
99
100
|
|
|
100
101
|
## 5. スコープ外(後続)
|
|
101
102
|
|
|
102
|
-
-
|
|
103
|
-
|
|
103
|
+
- ~~シェル UI の採用(取り込みフローの reference 既定化・プロジェクト面の「参照」バッジ・
|
|
104
|
+
プレビュー経路のフォールバック)~~ → 2026-09-22 に採用済み(「状態」行を参照)。残るのは置き場側の変化で札を自動更新する件
|
|
104
105
|
- 共有キャッシュの容量管理 UI
|
|
105
106
|
|
|
106
107
|
出自: 2026-09-02 の素材パネル再設計ラウンドの裁定 3(実体 = 共有キャッシュ・プロジェクトには参照・
|