akari-video 0.1.71 → 0.1.72
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/bin/akari.mjs +5 -1
- package/package.json +1 -1
- package/src/skills-command.mjs +160 -0
- package/vendor/.akari-capability-sources.json +1 -0
- package/vendor/docs/contract-2026-07-25-r6-audio-tracks-and-trim.md +123 -114
- package/vendor/docs/contract-2026-08-02-preview-parity.md +8 -0
- package/vendor/docs/contract-2026-08-03-caption-display-encoding-qc-v1.md +24 -3
- package/vendor/docs/contract-2026-09-13-world-map-v0.md +4 -2
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/edit-lint/README.md +6 -0
- package/vendor/packages/edit-lint/src/edit-lint.mjs +125 -0
- package/vendor/packages/edit-store/lib/audio-schedule.js +31 -3
- package/vendor/packages/edit-store/lib/internal-model.js +11 -0
- package/vendor/packages/edit-store/lib/webview-kernel.js +15 -3
- package/vendor/packages/edit-store/lib/write-gate.d.ts +22 -1
- package/vendor/packages/edit-store/lib/write-gate.js +81 -6
- package/vendor/packages/overlay-runtime/README.md +6 -1
- package/vendor/packages/schemas/engine-capabilities.json +2 -2
- package/vendor/skills/akari/SKILL.md +90 -0
- package/vendor/skills/akari/test/skills-command.test.mjs +2 -0
- package/vendor/skills/design-world/SKILL.md +3 -1
- package/vendor/skills/design-world/world.md +3 -0
- package/vendor/skills/overlay-authoring/3d.md +16 -5
package/bin/akari.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { runDoctorCommand } from '../src/doctor-command.mjs';
|
|
|
16
16
|
import { runGenerateCommand } from '../src/generate-command.mjs';
|
|
17
17
|
import { runStoryboardCommand } from '../src/storyboard-command.mjs';
|
|
18
18
|
import { runWorldCommand } from '../src/world-command.mjs';
|
|
19
|
+
import { runSkillsCommand, refreshEntrySkillOnLaunch } from '../src/skills-command.mjs';
|
|
19
20
|
import { resolveRuntimePaths } from '../src/runtime-diagnostics.mjs';
|
|
20
21
|
import { maybeApplyPendingUpdateOnLaunch, resolveInstalledVersionInfo } from '../src/update-check.mjs';
|
|
21
22
|
import { describeCliHelp, describeInstalledVersions } from '../src/messages.mjs';
|
|
@@ -38,7 +39,8 @@ async function printVersion() {
|
|
|
38
39
|
// `--help` は claude/opencode へそのまま転送されてしまっていた — AKARI Video 自身の
|
|
39
40
|
// コマンド一覧が一度も出ない行き止まりだったため新設した)。
|
|
40
41
|
async function printCliHelp() {
|
|
41
|
-
for (const line of [...describeCliHelp(), ' world ワールド地図を検査・生成・プレビュー・停留所移動'
|
|
42
|
+
for (const line of [...describeCliHelp(), ' world ワールド地図を検査・生成・プレビュー・停留所移動',
|
|
43
|
+
' skills 入口スキルを配置・削除・確認(install/remove --entry, status --json)']) {
|
|
42
44
|
console.log(line);
|
|
43
45
|
}
|
|
44
46
|
return { exitCode: 0 };
|
|
@@ -60,6 +62,7 @@ try {
|
|
|
60
62
|
} catch (error) {
|
|
61
63
|
console.error(`自動更新の適用確認でエラーが発生しました(続行します): ${error instanceof Error ? error.message : String(error)}`);
|
|
62
64
|
}
|
|
65
|
+
refreshEntrySkillOnLaunch({ env: process.env });
|
|
63
66
|
|
|
64
67
|
// `akari update` / `akari init` / `akari new` / `akari narration` / `akari internal` /
|
|
65
68
|
// `akari sounds` / `akari status` / `akari accept` / `akari capability` / `akari store` /
|
|
@@ -73,6 +76,7 @@ const invoke = (argv[0] === '--version' || argv[0] === '-v') ? printVersion()
|
|
|
73
76
|
: argv[0] === 'update' ? runUpdateCommand(argv.slice(1))
|
|
74
77
|
: argv[0] === 'init' ? runInitCommand(argv.slice(1))
|
|
75
78
|
: argv[0] === 'new' ? runNewCommand(argv.slice(1))
|
|
79
|
+
: argv[0] === 'skills' ? runSkillsCommand(argv.slice(1))
|
|
76
80
|
: argv[0] === 'narration' ? runNarrationCommand(argv.slice(1))
|
|
77
81
|
: argv[0] === 'internal' ? runInternalCommand(argv.slice(1))
|
|
78
82
|
: argv[0] === 'sounds' ? runSoundsCommand(argv.slice(1))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.72",
|
|
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": {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { cpSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
7
|
+
export const ENTRY_VERSION_FILE = '.akari-entry-version';
|
|
8
|
+
const usage = `使い方: akari skills install --entry [--target <dir>]...
|
|
9
|
+
akari skills remove --entry [--target <dir>]...
|
|
10
|
+
akari skills status --json
|
|
11
|
+
--target は入口スキル自体の配置先(例: ~/.codex/skills/akari)。既定の 2 か所に追加する。`;
|
|
12
|
+
|
|
13
|
+
function stat(path) {
|
|
14
|
+
try { return lstatSync(path); } catch (error) {
|
|
15
|
+
if (error.code === 'ENOENT') return null;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function canonicalBase(path) {
|
|
21
|
+
return stat(path) ? realpathSync(path) : resolve(path);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function entryTargets(env = process.env, extra = []) {
|
|
25
|
+
const home = canonicalBase(env.HOME || homedir());
|
|
26
|
+
return [...new Set([
|
|
27
|
+
join(home, '.claude', 'skills', 'akari'),
|
|
28
|
+
join(home, '.agents', 'skills', 'akari'),
|
|
29
|
+
...extra.map(path => resolve(path)),
|
|
30
|
+
])];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveEntrySource({ env = process.env, repoRoot = REPO_ROOT } = {}) {
|
|
34
|
+
const home = env.HOME || homedir();
|
|
35
|
+
for (const root of [join(env.AKARI_HOME || join(home, '.akari'), 'app'), repoRoot]) {
|
|
36
|
+
const source = join(root, 'skills', 'akari');
|
|
37
|
+
if (!stat(join(source, 'SKILL.md'))?.isFile()) continue;
|
|
38
|
+
const version = JSON.parse(readFileSync(join(root, 'packages', 'akari-launcher', 'package.json'), 'utf8')).version;
|
|
39
|
+
if (typeof version !== 'string' || !version.trim()) throw new Error(`ランチャー版が不正です: ${root}`);
|
|
40
|
+
return { source, version };
|
|
41
|
+
}
|
|
42
|
+
throw new Error('入口スキル skills/akari/SKILL.md が見つかりません。');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ディレクトリだけでなく配下の SKILL.md / 版印、親の skills がリンクの場合も触らない。
|
|
46
|
+
function hasLinkedAncestor(path) {
|
|
47
|
+
for (let current = path; ; current = dirname(current)) {
|
|
48
|
+
if (stat(current)?.isSymbolicLink()) return true;
|
|
49
|
+
if (dirname(current) === current) return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hasLinkedContent(path) {
|
|
54
|
+
const info = stat(path);
|
|
55
|
+
if (!info) return false;
|
|
56
|
+
if (info.isSymbolicLink()) return true;
|
|
57
|
+
return info.isDirectory() && readdirSync(path).some(name => hasLinkedContent(join(path, name)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function inspectTarget(path, currentVersion) {
|
|
61
|
+
const info = stat(path);
|
|
62
|
+
const symlink = hasLinkedAncestor(path);
|
|
63
|
+
const marker = join(path, ENTRY_VERSION_FILE);
|
|
64
|
+
const managed = !symlink && info?.isDirectory() === true && stat(marker)?.isFile() === true;
|
|
65
|
+
const version = managed ? readFileSync(marker, 'utf8').trim() : null;
|
|
66
|
+
return { path, exists: info !== null, managed, symlink, version, stale: managed && version !== currentVersion };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function entryStatus(options = {}) {
|
|
70
|
+
const { version } = resolveEntrySource(options);
|
|
71
|
+
return { version, targets: entryTargets(options.env).map(path => inspectTarget(path, version)) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function installTarget(path, source, version, warn) {
|
|
75
|
+
if (hasLinkedAncestor(path) || hasLinkedContent(path) || hasLinkedContent(source)) {
|
|
76
|
+
warn(`symlink は変更しません: ${path}`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const state = inspectTarget(path, version);
|
|
80
|
+
if (state.exists && !state.managed) {
|
|
81
|
+
warn(`入口スキルの版印がないため変更しません: ${path}`);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
mkdirSync(path, { recursive: true });
|
|
85
|
+
cpSync(source, path, {
|
|
86
|
+
recursive: true,
|
|
87
|
+
filter: entry => {
|
|
88
|
+
const relativePath = relative(source, entry);
|
|
89
|
+
if (!relativePath) return true;
|
|
90
|
+
const parts = relativePath.split(sep);
|
|
91
|
+
return parts[0] !== 'test' && !parts.some(part => part.startsWith('.'));
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
// コピーに失敗した場合は旧版印を維持して次の起動で再試行する。
|
|
95
|
+
writeFileSync(join(path, ENTRY_VERSION_FILE), `${version}\n`);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 明示 install 済みの既定先だけを更新する。起動を止めず、出力もしない。 */
|
|
100
|
+
export function refreshEntrySkillOnLaunch(options = {}) {
|
|
101
|
+
try {
|
|
102
|
+
const paths = entryTargets(options.env);
|
|
103
|
+
if (!paths.some(path => inspectTarget(path, null).managed)) return;
|
|
104
|
+
const { source, version } = resolveEntrySource(options);
|
|
105
|
+
for (const path of paths) {
|
|
106
|
+
try {
|
|
107
|
+
if (inspectTarget(path, version).stale) installTarget(path, source, version, () => {});
|
|
108
|
+
} catch { /* 次回起動で再試行 */ }
|
|
109
|
+
}
|
|
110
|
+
} catch { /* 入口の更新失敗で他のコマンドを止めない */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function runSkillsCommand(args, options = {}) {
|
|
114
|
+
const log = options.log ?? console.log;
|
|
115
|
+
const warn = options.logError ?? console.error;
|
|
116
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
117
|
+
log(usage);
|
|
118
|
+
return { exitCode: 0 };
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const [command, ...flags] = args;
|
|
122
|
+
const extra = [];
|
|
123
|
+
let entry = false;
|
|
124
|
+
let json = false;
|
|
125
|
+
for (let i = 0; i < flags.length; i++) {
|
|
126
|
+
if (flags[i] === '--entry') entry = true;
|
|
127
|
+
else if (flags[i] === '--json') json = true;
|
|
128
|
+
else if (flags[i] === '--target' && flags[i + 1] && !flags[i + 1].startsWith('-')) extra.push(flags[++i]);
|
|
129
|
+
else throw new Error(usage);
|
|
130
|
+
}
|
|
131
|
+
if (command === 'status' && json && !entry && !extra.length) {
|
|
132
|
+
log(JSON.stringify(entryStatus(options), null, 2));
|
|
133
|
+
return { exitCode: 0 };
|
|
134
|
+
}
|
|
135
|
+
if (!['install', 'remove'].includes(command) || !entry || json) throw new Error(usage);
|
|
136
|
+
const paths = entryTargets(options.env, extra);
|
|
137
|
+
const source = command === 'install' ? resolveEntrySource(options) : null;
|
|
138
|
+
let skipped = false;
|
|
139
|
+
for (const path of paths) {
|
|
140
|
+
if (command === 'install') {
|
|
141
|
+
if (installTarget(path, source.source, source.version, warn)) log(`入口スキルを配置しました: ${path}`);
|
|
142
|
+
else skipped = true;
|
|
143
|
+
} else {
|
|
144
|
+
if (!stat(path)) continue;
|
|
145
|
+
const state = inspectTarget(path, null);
|
|
146
|
+
if (!state.managed || hasLinkedContent(path)) {
|
|
147
|
+
warn(`版印がない、または symlink のため削除しません: ${path}`);
|
|
148
|
+
skipped = true;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
rmSync(path, { recursive: true });
|
|
152
|
+
log(`入口スキルを削除しました: ${path}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { exitCode: skipped ? 1 : 0 };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
warn(error instanceof Error ? error.message : String(error));
|
|
158
|
+
return { exitCode: 1 };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -119,6 +119,7 @@
|
|
|
119
119
|
"packages/schemas/package.json",
|
|
120
120
|
"packages/word-book/package.json",
|
|
121
121
|
"skills/address-review/SKILL.md",
|
|
122
|
+
"skills/akari/SKILL.md",
|
|
122
123
|
"skills/analyze-footage/analysis-json.md",
|
|
123
124
|
"skills/analyze-footage/bin/face-expression/vendor/tasks-vision-0.10.17/README-AKARI.md",
|
|
124
125
|
"skills/analyze-footage/events-and-hooks.md",
|
|
@@ -1,114 +1,123 @@
|
|
|
1
|
-
# R6 契約 — タイムライン配置原則・音源複数トラック化・音源トリム・ソーストリマー
|
|
2
|
-
|
|
3
|
-
- 日付: 2026-07-25
|
|
4
|
-
- 状態: draft(裁定は確定。実装と並走で approved 化)。本書は技術仕様のみ。
|
|
5
|
-
判断経緯・実装レーンの運用は非公開の内部記録で管理する(本リポには置かない方針)
|
|
6
|
-
- 前提: `contract-2026-07-14-edit-json-v1-audio.md`(audio スキーマ正本)、
|
|
7
|
-
`contract-2026-07-17-data-contract-versioning.md`(三原則)
|
|
8
|
-
|
|
9
|
-
## 1. 確定事項(2026-07-25 裁定)
|
|
10
|
-
|
|
11
|
-
1. **タイムライン配置原則 = Premiere 型を正式採用**:
|
|
12
|
-
- 音源グループは**最下段固定**(並べ替え不可)
|
|
13
|
-
- cuts 帯(Video)はその上の縦中心。上に layers / captions(重ね物)
|
|
14
|
-
- 映像系トラック内の縦順は従来どおり**上の行ほど前面**(z 順裁定は不変)
|
|
15
|
-
- ルーラー(メモリ)位置は現状のまま固定
|
|
16
|
-
- 既定スタック(下から audio→cuts→layers→captions)と整合。本裁定はこれを
|
|
17
|
-
「固定の配置原則」として明文化するもの
|
|
18
|
-
2. **音源の重なり解消 = 複数音声トラック化**:
|
|
19
|
-
- sfx の `track` フィールド(schema 既存)を UI で解放し、音声もトラックを増やせるようにする
|
|
20
|
-
- 従来の「audio は当面 ref 0 固定(単一トラック)」運用を本裁定で変更
|
|
21
|
-
- `timelineTrack` は kind:'audio' の複数宣言を既に許容(schema 変更不要)。
|
|
22
|
-
音声トラック群は配置原則 1 により常に最下段グループ内で増減する
|
|
23
|
-
3. **ソーストリマーの入口 = タイムラインのクリップ dblclick**:
|
|
24
|
-
- クリップをダブルクリック → カット外部分を薄く表示し、左右スライドで in/out 調整
|
|
25
|
-
- 素材ファイルの dblclick = 素のソース再生、とは両立(入口が別)
|
|
26
|
-
|
|
27
|
-
## 2. 音源トリム(schema 拡張)
|
|
28
|
-
|
|
29
|
-
### schema
|
|
30
|
-
|
|
31
|
-
- `sfxItem` に optional `in` / `out` を追加(**素材秒**。`in` ≥ 0 省略時 0、
|
|
32
|
-
`out` > `in` 省略時 素材末尾)。再生区間 = 素材の [in, out)、
|
|
33
|
-
タイムライン上の開始は従来どおり `t`(timeline 秒)、表示尺 = out − in
|
|
34
|
-
- `narrationItem` にも同じ optional `in` / `out` を追加する。再生区間・素材秒・既定値・
|
|
35
|
-
`out > in` の検証分担は sfx と同一で、タイムライン上の開始は narration の `t` とする
|
|
36
|
-
- `bgm` に optional `in` を追加(BGM ファイル内の開始オフセット素材秒。ループ・全体尺
|
|
37
|
-
トリムの既存意味論は不変)
|
|
38
|
-
- edit-lint: `out <= in` を error。実尺越えの検知は lint では行わない
|
|
39
|
-
(lint は ffprobe を持たない — クランプは消費側の責務)
|
|
40
|
-
- cuts 側の語彙に倣い、$comment に意味論を明記する
|
|
41
|
-
|
|
42
|
-
### 消費(render-cut + preview)
|
|
43
|
-
|
|
44
|
-
- render-cut: sfx の [in, out) 切り出しを出力に反映。bgm の `in` オフセット反映
|
|
45
|
-
- render-cut: narration の [in, out) 切り出しも出力に反映する。`in` が素材実尺以上なら 0 へ、
|
|
46
|
-
`out` が素材実尺を超えれば素材末尾へクランプして warning を出す。クランプ後に `out <= in` なら
|
|
47
|
-
その narration 要素だけを skip する。`in` / `out` の有無にかかわらず、各 narration 要素は
|
|
48
|
-
デコード可否の判定を兼ねて実尺を従来と同じ 1 回だけ probe し、デコードできなければ従来どおり
|
|
49
|
-
その要素だけを skip して warning を出す一方、両方省略された要素には `atrim` を前置きせず、
|
|
50
|
-
従来とバイト同一のフィルタ文字列を保つ
|
|
51
|
-
- preview(previewAudio): 同意味論で再生。実尺越え in/out は素材末尾へクランプ
|
|
52
|
-
|
|
53
|
-
### UI
|
|
54
|
-
|
|
55
|
-
- 音源バーの端ドラッグでトリム(in/out 書き戻し)。動画クリップのトリムと同じ操作感
|
|
56
|
-
- 複数音声トラック行の表示・追加・アイテムのトラック間移動(裁定 2)
|
|
57
|
-
- 配置原則(裁定 1)の実装: audio グループ最下段固定・cuts 縦中心・上に重ね物。ルーラー無移動
|
|
58
|
-
|
|
59
|
-
## 3. ソーストリマー
|
|
60
|
-
|
|
61
|
-
- 入口: クリップ dblclick(裁定 3)。トリマーモード中はカット外を薄く表示し、
|
|
62
|
-
左右スライドで in/out を調整。解除は Esc / 再 dblclick / 他クリップ選択
|
|
63
|
-
- サムネイルは素材全体のフィルムストリップを 1 回だけ焼き、窓移動は CSS
|
|
64
|
-
background-position のみで行う(トリム / スリップ操作で再焼成しない設計)
|
|
65
|
-
|
|
66
|
-
## 4. 受け入れの軸
|
|
67
|
-
|
|
68
|
-
- schema: schemas / edit-lint テスト全数 green
|
|
69
|
-
- 消費: in/out 付き sfx の出力音声を ffprobe / 波形で実測(切り出し位置・尺一致)。
|
|
70
|
-
preview 側も同 fixture で聴感 + 実測。クランプ動作の実測
|
|
71
|
-
- UI: 実機で (a) 配置原則どおりの表示 (b) 音声トラック追加とアイテム移動が edit.json に
|
|
72
|
-
書き戻る (c) 音源バー端ドラッグで in/out 書き戻り・リロード後保持 (d) トリマーの
|
|
73
|
-
表示・調整が機能 (e) 既存トラック UI・z 順の無退行
|
|
74
|
-
|
|
75
|
-
## 5. §2 追記 — sfx フェード(audio-clip-fades, 2026-08-18・オーナー裁定「クリップ主義」T2)
|
|
76
|
-
|
|
77
|
-
BGM をクリップ化する裁定(内部リポ `akari-video-internal` の該当タスク)に伴い、
|
|
78
|
-
「音楽をクリップ(audio.sfx[])として置いても BGM ベッドと同じフェード表現ができる」を
|
|
79
|
-
満たすため、`sfxItem` に optional の `fade_in` / `fade_out`(秒・0 以上)を追加のみ拡張する
|
|
80
|
-
(`version` 不変・`contract-2026-07-17-data-contract-versioning.md` の原則に従う)。
|
|
81
|
-
|
|
82
|
-
### schema
|
|
83
|
-
|
|
84
|
-
- `sfxItem.fade_in` / `fade_out`: 秒・省略時 0(フェードなし)。`audio.bgm.fadeIn` /
|
|
85
|
-
`fadeOut`(camelCase)とは異なり **snake_case**(既存の `gain_db` と同じ命名系列)
|
|
86
|
-
- フェード対象はこのクリップの実効再生窓 `[t, t + 実効尺)`。実効尺は §2 の `[in, out)` が
|
|
87
|
-
既知なら `out − in`、`in`/`out` 省略時は素材尺(消費側が実尺を解決できた場合のみ)
|
|
88
|
-
- クランプ規則は `audio.bgm.fadeIn`/`fadeOut` と同型: `fade_in`/`fade_out` それぞれ独立に
|
|
89
|
-
実効尺の半分までクランプ(render-cut が実装、edit-lint は `in`/`out` が両方既知のときだけ
|
|
90
|
-
警告できる — lint は ffprobe を持たないため実尺越えの検知は消費側の責務、という §2 本文の
|
|
91
|
-
既存原則をフェードにもそのまま適用)
|
|
92
|
-
|
|
93
|
-
### 消費(render-cut + preview 3 面)
|
|
94
|
-
|
|
95
|
-
- render-cut: sfx の afade を volume の直後・adelay の直前に挿入する(adelay 後だと
|
|
96
|
-
`st=0` が delay 由来の無音区間を指してしまうため)。`in`/`out` 併用時は atrim/asetpts で
|
|
97
|
-
尺をリセットした後の実効尺基準で afade を計算する
|
|
98
|
-
- シェルプレビュー(akari-preview): sfx は 1 回きりの `BufferSourceNode` 再生のため、
|
|
99
|
-
bgm の毎 tick 再計算(fadeMultiplier)ではなく、schedule 時点で
|
|
100
|
-
`gain.gain.setValueAtTime`/`linearRampToValueAtTime` によるブレークポイント列を組む
|
|
101
|
-
(`sfxFadeGainSchedule`、シーク再開時は経過秒からブレークポイントを再構成)
|
|
102
|
-
-
|
|
103
|
-
`
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
1
|
+
# R6 契約 — タイムライン配置原則・音源複数トラック化・音源トリム・ソーストリマー
|
|
2
|
+
|
|
3
|
+
- 日付: 2026-07-25
|
|
4
|
+
- 状態: draft(裁定は確定。実装と並走で approved 化)。本書は技術仕様のみ。
|
|
5
|
+
判断経緯・実装レーンの運用は非公開の内部記録で管理する(本リポには置かない方針)
|
|
6
|
+
- 前提: `contract-2026-07-14-edit-json-v1-audio.md`(audio スキーマ正本)、
|
|
7
|
+
`contract-2026-07-17-data-contract-versioning.md`(三原則)
|
|
8
|
+
|
|
9
|
+
## 1. 確定事項(2026-07-25 裁定)
|
|
10
|
+
|
|
11
|
+
1. **タイムライン配置原則 = Premiere 型を正式採用**:
|
|
12
|
+
- 音源グループは**最下段固定**(並べ替え不可)
|
|
13
|
+
- cuts 帯(Video)はその上の縦中心。上に layers / captions(重ね物)
|
|
14
|
+
- 映像系トラック内の縦順は従来どおり**上の行ほど前面**(z 順裁定は不変)
|
|
15
|
+
- ルーラー(メモリ)位置は現状のまま固定
|
|
16
|
+
- 既定スタック(下から audio→cuts→layers→captions)と整合。本裁定はこれを
|
|
17
|
+
「固定の配置原則」として明文化するもの
|
|
18
|
+
2. **音源の重なり解消 = 複数音声トラック化**:
|
|
19
|
+
- sfx の `track` フィールド(schema 既存)を UI で解放し、音声もトラックを増やせるようにする
|
|
20
|
+
- 従来の「audio は当面 ref 0 固定(単一トラック)」運用を本裁定で変更
|
|
21
|
+
- `timelineTrack` は kind:'audio' の複数宣言を既に許容(schema 変更不要)。
|
|
22
|
+
音声トラック群は配置原則 1 により常に最下段グループ内で増減する
|
|
23
|
+
3. **ソーストリマーの入口 = タイムラインのクリップ dblclick**:
|
|
24
|
+
- クリップをダブルクリック → カット外部分を薄く表示し、左右スライドで in/out 調整
|
|
25
|
+
- 素材ファイルの dblclick = 素のソース再生、とは両立(入口が別)
|
|
26
|
+
|
|
27
|
+
## 2. 音源トリム(schema 拡張)
|
|
28
|
+
|
|
29
|
+
### schema
|
|
30
|
+
|
|
31
|
+
- `sfxItem` に optional `in` / `out` を追加(**素材秒**。`in` ≥ 0 省略時 0、
|
|
32
|
+
`out` > `in` 省略時 素材末尾)。再生区間 = 素材の [in, out)、
|
|
33
|
+
タイムライン上の開始は従来どおり `t`(timeline 秒)、表示尺 = out − in
|
|
34
|
+
- `narrationItem` にも同じ optional `in` / `out` を追加する。再生区間・素材秒・既定値・
|
|
35
|
+
`out > in` の検証分担は sfx と同一で、タイムライン上の開始は narration の `t` とする
|
|
36
|
+
- `bgm` に optional `in` を追加(BGM ファイル内の開始オフセット素材秒。ループ・全体尺
|
|
37
|
+
トリムの既存意味論は不変)
|
|
38
|
+
- edit-lint: `out <= in` を error。実尺越えの検知は lint では行わない
|
|
39
|
+
(lint は ffprobe を持たない — クランプは消費側の責務)
|
|
40
|
+
- cuts 側の語彙に倣い、$comment に意味論を明記する
|
|
41
|
+
|
|
42
|
+
### 消費(render-cut + preview)
|
|
43
|
+
|
|
44
|
+
- render-cut: sfx の [in, out) 切り出しを出力に反映。bgm の `in` オフセット反映
|
|
45
|
+
- render-cut: narration の [in, out) 切り出しも出力に反映する。`in` が素材実尺以上なら 0 へ、
|
|
46
|
+
`out` が素材実尺を超えれば素材末尾へクランプして warning を出す。クランプ後に `out <= in` なら
|
|
47
|
+
その narration 要素だけを skip する。`in` / `out` の有無にかかわらず、各 narration 要素は
|
|
48
|
+
デコード可否の判定を兼ねて実尺を従来と同じ 1 回だけ probe し、デコードできなければ従来どおり
|
|
49
|
+
その要素だけを skip して warning を出す一方、両方省略された要素には `atrim` を前置きせず、
|
|
50
|
+
従来とバイト同一のフィルタ文字列を保つ
|
|
51
|
+
- preview(previewAudio): 同意味論で再生。実尺越え in/out は素材末尾へクランプ
|
|
52
|
+
|
|
53
|
+
### UI
|
|
54
|
+
|
|
55
|
+
- 音源バーの端ドラッグでトリム(in/out 書き戻し)。動画クリップのトリムと同じ操作感
|
|
56
|
+
- 複数音声トラック行の表示・追加・アイテムのトラック間移動(裁定 2)
|
|
57
|
+
- 配置原則(裁定 1)の実装: audio グループ最下段固定・cuts 縦中心・上に重ね物。ルーラー無移動
|
|
58
|
+
|
|
59
|
+
## 3. ソーストリマー
|
|
60
|
+
|
|
61
|
+
- 入口: クリップ dblclick(裁定 3)。トリマーモード中はカット外を薄く表示し、
|
|
62
|
+
左右スライドで in/out を調整。解除は Esc / 再 dblclick / 他クリップ選択
|
|
63
|
+
- サムネイルは素材全体のフィルムストリップを 1 回だけ焼き、窓移動は CSS
|
|
64
|
+
background-position のみで行う(トリム / スリップ操作で再焼成しない設計)
|
|
65
|
+
|
|
66
|
+
## 4. 受け入れの軸
|
|
67
|
+
|
|
68
|
+
- schema: schemas / edit-lint テスト全数 green
|
|
69
|
+
- 消費: in/out 付き sfx の出力音声を ffprobe / 波形で実測(切り出し位置・尺一致)。
|
|
70
|
+
preview 側も同 fixture で聴感 + 実測。クランプ動作の実測
|
|
71
|
+
- UI: 実機で (a) 配置原則どおりの表示 (b) 音声トラック追加とアイテム移動が edit.json に
|
|
72
|
+
書き戻る (c) 音源バー端ドラッグで in/out 書き戻り・リロード後保持 (d) トリマーの
|
|
73
|
+
表示・調整が機能 (e) 既存トラック UI・z 順の無退行
|
|
74
|
+
|
|
75
|
+
## 5. §2 追記 — sfx フェード(audio-clip-fades, 2026-08-18・オーナー裁定「クリップ主義」T2)
|
|
76
|
+
|
|
77
|
+
BGM をクリップ化する裁定(内部リポ `akari-video-internal` の該当タスク)に伴い、
|
|
78
|
+
「音楽をクリップ(audio.sfx[])として置いても BGM ベッドと同じフェード表現ができる」を
|
|
79
|
+
満たすため、`sfxItem` に optional の `fade_in` / `fade_out`(秒・0 以上)を追加のみ拡張する
|
|
80
|
+
(`version` 不変・`contract-2026-07-17-data-contract-versioning.md` の原則に従う)。
|
|
81
|
+
|
|
82
|
+
### schema
|
|
83
|
+
|
|
84
|
+
- `sfxItem.fade_in` / `fade_out`: 秒・省略時 0(フェードなし)。`audio.bgm.fadeIn` /
|
|
85
|
+
`fadeOut`(camelCase)とは異なり **snake_case**(既存の `gain_db` と同じ命名系列)
|
|
86
|
+
- フェード対象はこのクリップの実効再生窓 `[t, t + 実効尺)`。実効尺は §2 の `[in, out)` が
|
|
87
|
+
既知なら `out − in`、`in`/`out` 省略時は素材尺(消費側が実尺を解決できた場合のみ)
|
|
88
|
+
- クランプ規則は `audio.bgm.fadeIn`/`fadeOut` と同型: `fade_in`/`fade_out` それぞれ独立に
|
|
89
|
+
実効尺の半分までクランプ(render-cut が実装、edit-lint は `in`/`out` が両方既知のときだけ
|
|
90
|
+
警告できる — lint は ffprobe を持たないため実尺越えの検知は消費側の責務、という §2 本文の
|
|
91
|
+
既存原則をフェードにもそのまま適用)
|
|
92
|
+
|
|
93
|
+
### 消費(render-cut + preview 3 面)
|
|
94
|
+
|
|
95
|
+
- render-cut: sfx の afade を volume の直後・adelay の直前に挿入する(adelay 後だと
|
|
96
|
+
`st=0` が delay 由来の無音区間を指してしまうため)。`in`/`out` 併用時は atrim/asetpts で
|
|
97
|
+
尺をリセットした後の実効尺基準で afade を計算する
|
|
98
|
+
- シェルプレビュー(akari-preview): sfx は 1 回きりの `BufferSourceNode` 再生のため、
|
|
99
|
+
bgm の毎 tick 再計算(fadeMultiplier)ではなく、schedule 時点で
|
|
100
|
+
`gain.gain.setValueAtTime`/`linearRampToValueAtTime` によるブレークポイント列を組む
|
|
101
|
+
(`sfxFadeGainSchedule`、シーク再開時は経過秒からブレークポイントを再構成)
|
|
102
|
+
- 同・会話音声(narration / 音声レーンの `role:'speech'`): 2026-09-18 追記。上と同じ
|
|
103
|
+
ブレークポイント列の仕組みに乗せる(`buildWebAudioSchedule` は kind に依らず
|
|
104
|
+
`fadeGainEvents` を通す)。**窓の取り方だけが sfx と非対称**で、sfx は item の実効尺を
|
|
105
|
+
そのまま使う一方、narration は `min(track.durationSec, max(0, duration − track.t))` と
|
|
106
|
+
タイムライン末尾で切る。これは `render-cut/src/plan.mjs` の実際の扱いに合わせたもので、
|
|
107
|
+
揃えると sfx が書き出しと食い違う。クランプ規則(実効尺の半分まで独立に)は共通
|
|
108
|
+
- なお cuts / layers の撮影素材音声(プレビューの kind `'speech'`)は宣言にフェード項目を
|
|
109
|
+
持たず、書き出し側も cut 音声に afade を掛けない。ここにフェードを足すと逆に
|
|
110
|
+
書き出しとの食い違いを作るため、**意図的に対象外**とする
|
|
111
|
+
- Web UI(preview-server): bgm と同じ毎 tick 再計算方式。ただしこの層は現状 sfx の
|
|
112
|
+
`in`/`out` トリム自体を未実装のため、フェードの実効尺は常にデコード済み素材全長を使う
|
|
113
|
+
(トリム実装時に合わせて見直す)
|
|
114
|
+
|
|
115
|
+
### インスペクター
|
|
116
|
+
|
|
117
|
+
- akari-annotations: sfx 選択時に bgm と同じ「フェード」タブ(`fadeIn`/`fadeOut` ノブ)を出す。
|
|
118
|
+
ducking は bgm 概念のため sfx には出さない
|
|
119
|
+
- 正本は `packages/edit-store`(edit.json テキスト手術)だが、本追記の実装レーン
|
|
120
|
+
(task 2026-08-18-audio-clip-fades)のファイル境界が `packages/edit-store` を含まないため、
|
|
121
|
+
書き戻りは `apps/shell/extensions/akari-annotations/src/common/sfx-fade-store.ts` に
|
|
122
|
+
境界内で完結する独立実装として置いた(`updateArrayElementByIndex` 等 edit-store の
|
|
123
|
+
export 済みユーティリティは再利用)。将来 edit-store 側の担当タスクが正本へ統合してよい
|
|
@@ -76,6 +76,14 @@ cut 境界の選択は宣言順ではなく解決済みタイムラインと z-o
|
|
|
76
76
|
語彙は `"source"` の 1 つだけで、マーカーが立つのは「今 fit 基準で描かれている全 media item に `scale × fit`
|
|
77
77
|
(`fit = min(outputW / srcW, outputH / srcH)`・srcW / srcH は表示回転後)を一度だけ焼き込んだ」ことを意味する
|
|
78
78
|
(部分適用は禁止。移行は `packages/edit-store/bin/normalize-geometry.mjs`)。x / y / rotate は両基準で同じ意味なので触らない。
|
|
79
|
+
**「ソース実寸」= 原本の論理寸法(表示回転後)であり、復号フレームの画素寸法ではない(2026-09-18 追記)**。
|
|
80
|
+
プロキシを復号していてもこの基準は動かない。ここが曖昧だったため、ベースカットのクロップ計算が
|
|
81
|
+
復号フレーム=プロキシ寸法を使い、追加レイヤーと寸法基準が食い違っていた(不具合メモ 第10項:
|
|
82
|
+
1920×1080 原本 / 960×540 プロキシで crop 幅 0.5・scale 1 が 960×1080 ではなく 480×540 になる)。
|
|
83
|
+
frame-engine 側は `NativeFrameSource.logicalSize` の宣言を基準に使い、宣言が無い / 壊れている
|
|
84
|
+
ときだけ復号寸法へ退避する(`compositionSourceSize`)。したがってプロキシを復号し得る呼び出し側は
|
|
85
|
+
原本メタデータから `logicalSize` を宣言する責務を負う。宣言を足すのと、プロキシ差し替えに伴う
|
|
86
|
+
倍率補償を外すのは、二重補正を避けるため**同一の作業単位**で行う。
|
|
79
87
|
**G1(マーカー・移行・lint の warning `geometry.fit-compat`)ではエンジンはこのマーカーを読まず、描画は 1 バイトも変わらない。
|
|
80
88
|
描画へ反映するのは G2**。cross ref: `docs/contract-2026-07-22-render-basics.md` §4-1(#6 画角操作)。
|
|
81
89
|
|
|
@@ -10,9 +10,30 @@
|
|
|
10
10
|
`packages/edit-store/src/caption-display.ts` is the only resolver for the opt-in
|
|
11
11
|
`display_policy.mode: "single_line_sequential"` contract. It projects source captions through a
|
|
12
12
|
linear cut/speed/multi-source timeline, then resolves one or two fragments, timing, source
|
|
13
|
-
provenance, merged style variables, and reference-pixel geometry.
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
provenance, merged style variables, and reference-pixel geometry. Browser code only selects
|
|
14
|
+
already-resolved timeline cues; it must not call `Intl.Segmenter` or implement the split algorithm.
|
|
15
|
+
|
|
16
|
+
Every consumer reaches that kernel through one shared entry point,
|
|
17
|
+
`packages/render-cut/src/caption-resolve.mjs` (`resolveCaptionPlan`), which also owns the steps in
|
|
18
|
+
front of it: style-preset resolution, excluded-cue filtering, word-book protected terms, and cut
|
|
19
|
+
normalization. The consumers are render-cut's internal render path, preview-server, gpu-export,
|
|
20
|
+
and osr-export. Naming the kernel alone was not enough: until 2026-09-20 each consumer assembled
|
|
21
|
+
that front half itself, so preview-server handed the kernel a v2 edit with no derived `cuts` and
|
|
22
|
+
resolved zero display cues, while gpu-export and osr-export never consulted `display_policy` at all
|
|
23
|
+
and re-split captions through the legacy overlay generator. A consumer that calls the kernel
|
|
24
|
+
directly, or rebuilds any of those front-half steps, is a deviation.
|
|
25
|
+
|
|
26
|
+
**Known remaining deviation — the shell backend.** `AkariPreviewService.resolveCaptionDisplay`
|
|
27
|
+
(`apps/shell/extensions/akari-preview/src/node/akari-preview-service.ts`) still calls the kernel
|
|
28
|
+
directly and rebuilds the front half on its own: its own preset resolution, its own cut
|
|
29
|
+
normalization (`captionCompatibleCuts`, computed in frames off `internal.tracks` rather than the
|
|
30
|
+
shared `captionDisplayEdit`), and its own word-book lookup that walks up from `__dirname` and
|
|
31
|
+
silently degrades to no protected terms on any failure. **It never applies excluded-cue
|
|
32
|
+
filtering**, so a cue excluded through `tracks[].items[].source.exclude` still appears in the
|
|
33
|
+
in-app preview while the other four paths drop it. This is a preview-parity hazard of the same
|
|
34
|
+
class as the two defects above. It is listed here rather than silently tolerated; the shell is
|
|
35
|
+
bundled by Theia and cannot assume `packages/` sits next to it, so routing it through
|
|
36
|
+
`resolveCaptionPlan` is a packaging question, not a one-line import.
|
|
16
37
|
|
|
17
38
|
The policy rejects unsupported `at`, `track`, transition, and timeline winner semantics; caption
|
|
18
39
|
style/emphasis conflicts; non-NFC or trimmed text; invalid manual fragments; unresolved long text;
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
## 3. 描画
|
|
26
26
|
|
|
27
27
|
flat world は 1 個の overlay 断片で構成する。Canvas 層は背景、格子、遠景、portal、cut の覆いを描き、DOM sheet 層は素材と文字を持つ。各 world は直下の `.akari-world-sheet[data-world]`、zone はその子の `.akari-world-zone[data-zone]` とし、sheet 自身は left / top 0、zone の px は bounds 原点を引かない world 座標そのままとする。sheet の transform は authoring 時に固定せず、ランタイムが `camera(t)` から設定する。DOM と Canvas の混在出力は rasterize 経路を使う。
|
|
28
|
+
素材の時計の起点は同じ zone id の stop の `at + delay`(`delay` は秒・省略 0・0 以上)とし、到着前は 0 秒で止める。
|
|
29
|
+
`role: "background"` の素材を含む zone はカリングしない。role 省略時も `vars` の `world-width` / `world-height`(`--` 接頭辞も可)があれば背景として扱う。
|
|
28
30
|
|
|
29
31
|
spatial world は `akari world build` が `assets/world/world.glb` と `overlays/world.html` の
|
|
30
32
|
three 断片へ決定論的に焼く。GLB は `worlds[].spatial.floor`、`background`、`haze`、
|
|
@@ -40,7 +42,7 @@ three 宣言は `model`、`camera.fromModel: "TourCamera"`、`animationClip: "To
|
|
|
40
42
|
- `akari world build`: flat は宣言、sheet、zone、解決済み素材断片を `overlays/world.html` に生成する。spatial は世界 GLB と three 断片を生成する。どちらも edit.json の `world` item を id 安定で upsert する。edit.json が version 2 でなければ変更せず停止するため、先に `akari migrate <project-root>` を実行する。
|
|
41
43
|
- `akari world preview [--measure]`: flat / spatial とも rasterize 経路で stop と edge の代表時点を PNG と `camera-proof.json` にする。measure 時は非 move edge の全画素 RGB 標準偏差が 2 以下になる完全被覆区間を 30 Hz で測り、該当する `transition.cover` だけを書き戻す。
|
|
42
44
|
- `preview --measure` は入口では C7 を問わず、実測値を書き戻した後に C7 を含む全項目を検査する。
|
|
43
|
-
- `akari world overview`:
|
|
45
|
+
- `akari world overview`: `overlays/world.html` の実断片を srcdoc iframe に同じ時刻で埋め込み、全世界を収める view で並べる。ピンクの撮影枠・カメラ軌道・場面ジャンプ・拡縮パン・カメラ追従・右欄の `.akari/out` 最新 MP4 を持ち、build 前は床だけへフォールバックする。外部通信 0・`file://` 直開き可で、`--json` は生成先を返す。
|
|
44
46
|
- `akari world move-stop <project-root> --stop <id> --c x,y[,scale] [--json]`: flat の停留所座標だけを更新する。元テキストの整形と他の欄を変えず、bounds 外・spatial・不変条件違反では一切書き込まない。
|
|
45
47
|
|
|
46
48
|
同じ入力から得る HTML と画像は決定論的でなければならない。素材 id は asset resolver で解決し、未解決時は失敗として扱う。
|
|
@@ -58,7 +60,7 @@ akari world overview .
|
|
|
58
60
|
## 5. 地図 UI
|
|
59
61
|
|
|
60
62
|
- 実装のマーカー判定は `akari-shell-strip` の ContextKey `akari.worldMap` に一元化する。
|
|
61
|
-
- main の「地図」タブは `akari-world-view`
|
|
63
|
+
- main の「地図」タブは `akari-world-view` が担い、`akari world overview --json` が生成した同じ HTML を webview に表示する(描画実装は 1 か所)。
|
|
62
64
|
- タイムラインのワールド帯と地図インスペクターは `akari-annotations` が担う。
|
|
63
65
|
|
|
64
66
|
地図 UI は 2D 俯瞰、ワールド帯、再生時刻に追従する撮影枠、選択中の stop / edge 詳細を提供する。v1 では flat の停留所の座標だけを ⌥ ドラッグで `world-map.json` へ書き戻せる。書き手は `akari world move-stop` の 1 本に限定し、bounds 外・spatial・不変条件違反では書き込まない。`world-map.json` は edit.json の履歴の外にあるため、undo / redo は未対応とする。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.72",
|
|
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": [
|
|
@@ -53,6 +53,12 @@ item. Each source is probed once, and media finding paths include the source ID.
|
|
|
53
53
|
past the container duration are reported by `media.source-range` instead.
|
|
54
54
|
- `media.caption-silence-coverage`: warns when more than 30% of caption display time overlaps a
|
|
55
55
|
silence interval of at least one second. `--caption-silence-warn-percent` changes the threshold.
|
|
56
|
+
- `media.crop-scale-proxy-ratio`: warns when a cropped media item's `transform.scale` equals its
|
|
57
|
+
source's original-to-proxy dimension ratio. That value was the workaround while preview decoded
|
|
58
|
+
the proxy and export preferred it; now that both decode the original it enlarges the framing by
|
|
59
|
+
the ratio. Needs the dimensions of both files, so it only runs under `--media`; sources without a
|
|
60
|
+
`proxy`, items without a `crop` or a declared `scale`, equal-size proxies (a ratio of 1 cannot be
|
|
61
|
+
told apart from the default `scale`), and files whose dimensions cannot be read are all skipped.
|
|
56
62
|
- `audio.narration.trim`: warns when a narration media item's `in` is at or beyond its audio-stream
|
|
57
63
|
duration but still inside the container duration. Invalid or reversed narration `in`/`out` values
|
|
58
64
|
are errors even without `--media`; an `in` past the container duration is a `media.source-range`
|
|
@@ -5754,6 +5754,7 @@ function runReferencedMediaChecks(rawEdit, projectedEdit, findings, skipped, pat
|
|
|
5754
5754
|
);
|
|
5755
5755
|
validateMediaSourceRange(rangeItems, probeBySourceId, sourcesById, findings, skipped, fps);
|
|
5756
5756
|
validateNarrationMediaStart(narrationItems, probeBySourceId, findings, skipped);
|
|
5757
|
+
validateCropScaleProxyRatio(rangeItems, sourcesById, findings, skipped, paths, options);
|
|
5757
5758
|
|
|
5758
5759
|
if (Array.isArray(rawEdit?.audio?.narration)) {
|
|
5759
5760
|
for (const [index, item] of rawEdit.audio.narration.entries()) {
|
|
@@ -6258,6 +6259,130 @@ function probeMediaAudio(sourcePath, configuredCommand) {
|
|
|
6258
6259
|
};
|
|
6259
6260
|
}
|
|
6260
6261
|
|
|
6262
|
+
// 原本 / プロキシの寸法比と scale の一致判定に使う相対許容差。回避策期間の値は
|
|
6263
|
+
// 「原本 ÷ プロキシ」をそのまま書いた比なので、浮動小数の丸め分だけ見ればよい。
|
|
6264
|
+
const CROP_SCALE_PROXY_RATIO_TOLERANCE = 1e-3;
|
|
6265
|
+
|
|
6266
|
+
/**
|
|
6267
|
+
* 回避策期間(プレビューがプロキシを復号し、書き出しもプレビュー用プロキシを優先していた頃)に
|
|
6268
|
+
* 保存された `transform.scale` を拾う。当時は crop を持つ item の scale へ「原本 ÷ プロキシ」の
|
|
6269
|
+
* 寸法比を入れて自己整合させていたため、書き出しが原本を復号する現在はその値がそのまま効いて
|
|
6270
|
+
* 構図が寸法比の分だけ膨らむ(不具合メモ 第10項 / 第18項の根本修正後に残る実害)。
|
|
6271
|
+
*
|
|
6272
|
+
* 寸法比と偶然一致する正当なズームもあり得るので warning に留める(止めずに知らせる)。
|
|
6273
|
+
* 判定は静的な `transform.scale` だけを見る。proxy 宣言の無い source・crop の無い item・
|
|
6274
|
+
* 寸法が読めない素材は対象外にして誤検知を出さない。
|
|
6275
|
+
*/
|
|
6276
|
+
export function findCropScaleProxyRatioFindings(items, ratioOf) {
|
|
6277
|
+
// 半々配置の案件では同じ素材の cropped item が 100 件近く並ぶ(実機 2026-09-15: 95 件)。
|
|
6278
|
+
// 直せる値は素材ごとに 1 つなので、素材 × scale 単位で 1 件へまとめて件数を添える
|
|
6279
|
+
// (geometry.fit-compat が移行案内を 1 件で出すのと同じ方針)。
|
|
6280
|
+
const groups = new Map();
|
|
6281
|
+
for (const entry of items) {
|
|
6282
|
+
const item = entry?.item;
|
|
6283
|
+
if (!isRecord(item) || !isRecord(item.crop) || !isRecord(item.transform)) continue;
|
|
6284
|
+
const scale = item.transform.scale;
|
|
6285
|
+
if (!isPositiveNumber(scale)) continue;
|
|
6286
|
+
const measured = ratioOf(entry.sourceId);
|
|
6287
|
+
if (!isRecord(measured) || !isPositiveNumber(measured.ratio)) continue;
|
|
6288
|
+
const ratio = measured.ratio;
|
|
6289
|
+
// 等寸プロキシ(比 1)は既定値 scale=1 と区別できないため見ない。
|
|
6290
|
+
if (ratio <= 1 + CROP_SCALE_PROXY_RATIO_TOLERANCE) continue;
|
|
6291
|
+
if (Math.abs(scale - ratio) > CROP_SCALE_PROXY_RATIO_TOLERANCE * ratio) continue;
|
|
6292
|
+
const key = `${entry.sourceId}${scale}`;
|
|
6293
|
+
const group = groups.get(key);
|
|
6294
|
+
if (group === undefined) groups.set(key, { entry, scale, measured, count: 1 });
|
|
6295
|
+
else group.count += 1;
|
|
6296
|
+
}
|
|
6297
|
+
return [...groups.values()].map(({ entry, scale, measured, count }) => {
|
|
6298
|
+
const { ratio, original, proxy } = measured;
|
|
6299
|
+
return {
|
|
6300
|
+
severity: "warning",
|
|
6301
|
+
check: "media.crop-scale-proxy-ratio",
|
|
6302
|
+
message: `素材 ${entry.sourceId} の crop を持つ item ${count} 件の transform.scale ${formatNumber(scale)} が、`
|
|
6303
|
+
+ `原本 ÷ プロキシの寸法比(${original.width}x${original.height} ÷ ${proxy.width}x${proxy.height}`
|
|
6304
|
+
+ ` = ${formatNumber(ratio)})と一致します。プレビューがプロキシを復号していた時期の回避策の値`
|
|
6305
|
+
+ `である可能性が高く、原本を復号する現在は構図が約 ${formatNumber(ratio)} 倍に拡大します。`
|
|
6306
|
+
+ `意図したズームでなければ transform.scale を原本基準(通常 1)へ戻してください。`,
|
|
6307
|
+
path: `${entry.itemPath}.transform.scale`,
|
|
6308
|
+
};
|
|
6309
|
+
});
|
|
6310
|
+
}
|
|
6311
|
+
|
|
6312
|
+
function validateCropScaleProxyRatio(items, sourcesById, findings, skipped, paths, options) {
|
|
6313
|
+
const candidates = items.filter((entry) => isRecord(entry?.item)
|
|
6314
|
+
&& isRecord(entry.item.crop)
|
|
6315
|
+
&& isRecord(entry.item.transform)
|
|
6316
|
+
&& isPositiveNumber(entry.item.transform.scale)
|
|
6317
|
+
&& isNonEmptyString(sourcesById.get(entry.sourceId)?.proxy));
|
|
6318
|
+
if (candidates.length === 0) return;
|
|
6319
|
+
|
|
6320
|
+
let command;
|
|
6321
|
+
try {
|
|
6322
|
+
command = options.ffprobeCommand ?? process.env.FFPROBE ?? resolveFfprobe();
|
|
6323
|
+
} catch (error) {
|
|
6324
|
+
addSkipped(skipped, "media.crop-scale-proxy-ratio",
|
|
6325
|
+
`source dimensions unavailable: ${messageOf(error)}`);
|
|
6326
|
+
return;
|
|
6327
|
+
}
|
|
6328
|
+
|
|
6329
|
+
const ratioBySourceId = new Map();
|
|
6330
|
+
const ratioOf = (sourceId) => {
|
|
6331
|
+
if (!ratioBySourceId.has(sourceId)) {
|
|
6332
|
+
ratioBySourceId.set(sourceId, proxyDimensionRatio(sourcesById.get(sourceId), paths, command));
|
|
6333
|
+
}
|
|
6334
|
+
return ratioBySourceId.get(sourceId);
|
|
6335
|
+
};
|
|
6336
|
+
for (const finding of findCropScaleProxyRatioFindings(candidates, ratioOf)) {
|
|
6337
|
+
addFinding(findings, finding);
|
|
6338
|
+
}
|
|
6339
|
+
for (const [sourceId, measured] of ratioBySourceId) {
|
|
6340
|
+
if (measured === null) {
|
|
6341
|
+
addSkipped(skipped, "media.crop-scale-proxy-ratio",
|
|
6342
|
+
`source ${sourceId}: original / proxy dimensions are unavailable`);
|
|
6343
|
+
}
|
|
6344
|
+
}
|
|
6345
|
+
}
|
|
6346
|
+
|
|
6347
|
+
/** 原本とプロキシの寸法比。片方でも読めない・縦横で比が違う場合は null(対象外)。 */
|
|
6348
|
+
function proxyDimensionRatio(source, paths, command) {
|
|
6349
|
+
if (!isRecord(source) || !isNonEmptyString(source.path) || !isNonEmptyString(source.proxy)) {
|
|
6350
|
+
return null;
|
|
6351
|
+
}
|
|
6352
|
+
const original = probeVideoDimensions(resolveReference(paths.editPath, source.path, paths), command);
|
|
6353
|
+
const proxy = probeVideoDimensions(resolveReference(paths.editPath, source.proxy, paths), command);
|
|
6354
|
+
if (original === null || proxy === null) return null;
|
|
6355
|
+
const widthRatio = original.width / proxy.width;
|
|
6356
|
+
const heightRatio = original.height / proxy.height;
|
|
6357
|
+
// アスペクト比を変えたプロキシでは「寸法比」が一意に決まらないので判定しない。
|
|
6358
|
+
if (Math.abs(widthRatio - heightRatio) > CROP_SCALE_PROXY_RATIO_TOLERANCE * widthRatio) {
|
|
6359
|
+
return null;
|
|
6360
|
+
}
|
|
6361
|
+
return { ratio: widthRatio, original, proxy };
|
|
6362
|
+
}
|
|
6363
|
+
|
|
6364
|
+
function probeVideoDimensions(filePath, command) {
|
|
6365
|
+
const result = spawnSync(command, [
|
|
6366
|
+
"-v", "error",
|
|
6367
|
+
"-select_streams", "v:0",
|
|
6368
|
+
"-show_entries", "stream=width,height",
|
|
6369
|
+
"-of", "json",
|
|
6370
|
+
filePath,
|
|
6371
|
+
], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
6372
|
+
if (result.error || result.status !== 0) return null;
|
|
6373
|
+
let parsed;
|
|
6374
|
+
try {
|
|
6375
|
+
parsed = JSON.parse(String(result.stdout ?? ""));
|
|
6376
|
+
} catch {
|
|
6377
|
+
return null;
|
|
6378
|
+
}
|
|
6379
|
+
const stream = Array.isArray(parsed?.streams) ? parsed.streams[0] : undefined;
|
|
6380
|
+
const width = Number(stream?.width);
|
|
6381
|
+
const height = Number(stream?.height);
|
|
6382
|
+
if (!isPositiveNumber(width) || !isPositiveNumber(height)) return null;
|
|
6383
|
+
return { width, height };
|
|
6384
|
+
}
|
|
6385
|
+
|
|
6261
6386
|
async function validateProxyGops(rawEdit, findings, paths, options) {
|
|
6262
6387
|
const declarations = [];
|
|
6263
6388
|
if (isRecord(rawEdit?.source) && isNonEmptyString(rawEdit.source.proxy)) {
|
|
@@ -145,9 +145,20 @@ function scheduleTimed(item, timelineDurationSec, startAtSec, duckIntervals) {
|
|
|
145
145
|
return null;
|
|
146
146
|
const timelineStartSec = startAtSec + delaySec;
|
|
147
147
|
const baseGain = dbToLinear(item.gainDb);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
// 会話音声(audio.narration と、音声レーンの role:'speech' — プレビューは後者も kind
|
|
149
|
+
// 'narration' として流す)も sfx と同じクリップフェードを持つ。フェード窓の取り方だけ
|
|
150
|
+
// kind ごとに render-cut へ合わせる(クランプ規則は fadeGainEvents が両者共通で持つ):
|
|
151
|
+
// sfx -> trim の実効尺そのまま。plan.mjs は
|
|
152
|
+
// resolveSfxFadeSeconds(sfx, trim.effectiveDuration) をタイムライン末尾で
|
|
153
|
+
// 切らずに使う(envelope 用の別変数だけが clamp される)
|
|
154
|
+
// narration -> タイムライン末尾で切った尺。plan.mjs の
|
|
155
|
+
// narrationDuration = Math.min(track.durationSec, Math.max(0, duration - track.t))
|
|
156
|
+
// と同一。これは elapsedIntoItemSec + durationSec(fadeGainEvents の windowEnd)
|
|
157
|
+
// と恒等だが、対応先が読めるよう plan.mjs と同じ式で書く
|
|
158
|
+
const fadeWindowSec = item.kind === 'sfx'
|
|
159
|
+
? item.itemDurationSec
|
|
160
|
+
: Math.min(item.itemDurationSec, Math.max(0, timelineDurationSec - item.t));
|
|
161
|
+
const gainEvents = fadeGainEvents(item.spec.fade_in ?? item.spec.fadeIn, item.spec.fade_out ?? item.spec.fadeOut, fadeWindowSec, elapsedIntoItemSec, durationSec, baseGain);
|
|
151
162
|
return {
|
|
152
163
|
kind: item.kind,
|
|
153
164
|
id: item.id,
|
|
@@ -273,6 +284,9 @@ function scheduleSpeech(spec, timelineDurationSec, startAtSec, warnings) {
|
|
|
273
284
|
if (!(durationSec > 0))
|
|
274
285
|
return null;
|
|
275
286
|
const baseGain = dbToLinear(gainDb);
|
|
287
|
+
// kind 'speech' は cuts / layers の撮影素材音声(projectSpeechDeclarations の産物)で、
|
|
288
|
+
// クリップフェード宣言を持たない(render-cut 側も cut 音声に afade を掛けない)。
|
|
289
|
+
// 音声レーンの role:'speech' アイテムは kind 'narration' 側へ流れ、そこでフェードが掛かる。
|
|
276
290
|
const gainEvents = speechCrossfadeGainEvents(effectiveDurationSec, elapsedIntoItemSec, durationSec, crossfadeInSec, crossfadeOutSec, baseGain);
|
|
277
291
|
return {
|
|
278
292
|
kind: 'speech',
|
|
@@ -498,6 +512,20 @@ function normalizedGainDb(spec, label, warnings) {
|
|
|
498
512
|
warnings.push(`${label}: gain_db clamped to [-60, 12]`);
|
|
499
513
|
return clamped;
|
|
500
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* クリップフェード(`audio.sfx[]` / `audio.narration[]` / 音声レーンの role:'speech' の
|
|
517
|
+
* `fade_in` / `fade_out`)のブレークポイント列。
|
|
518
|
+
*
|
|
519
|
+
* render-cut の `resolveSfxFadeSeconds` + `audioFadeFilters`(`packages/render-cut/src/plan.mjs`)
|
|
520
|
+
* と同じ意味論を持つ:
|
|
521
|
+
* - `fade_in` / `fade_out` はそれぞれ独立に実効尺(`itemDurationSec`)の半分までクランプ
|
|
522
|
+
* - 未指定・非有限・負値は「フェードなし」として 0 扱い
|
|
523
|
+
* - フェードインは窓頭(`afade=t=in:st=0:d=fade_in`)、フェードアウトは
|
|
524
|
+
* `itemDurationSec - fade_out`(`afade=t=out:st=…:d=fade_out`)から線形
|
|
525
|
+
*
|
|
526
|
+
* 契約: `docs/contract-2026-07-25-r6-audio-tracks-and-trim.md` §2 追記(audio-clip-fades)。
|
|
527
|
+
* シーク再開時は `elapsedIntoItemSec` から窓を切り直すだけで、掛かり方は変えない。
|
|
528
|
+
*/
|
|
501
529
|
function fadeGainEvents(rawFadeIn, rawFadeOut, itemDurationSec, elapsedIntoItemSec, availableSec, baseGain) {
|
|
502
530
|
const ceiling = itemDurationSec / 2;
|
|
503
531
|
const fadeIn = finitePositive(rawFadeIn) ? Math.min(rawFadeIn, ceiling) : 0;
|
|
@@ -706,6 +706,10 @@ function buildV2VisualItem(item, fps, ref, pathOf, chromaKeyOf, legacyIndexCount
|
|
|
706
706
|
id: item.id, t: at, duration, kind: 'video', src: path ?? item.source.src,
|
|
707
707
|
in: item.source.in,
|
|
708
708
|
track: ref, ...common, ...copyMediaSourceFields(item.source, captionSwitch),
|
|
709
|
+
// cuts 側(下の EditCut / declaration)と同じく、素材窓が出力尺と 1 フレーム超ずれた
|
|
710
|
+
// ときの再生速度をレイヤー宣言にも渡す。落とすと out - in ≠ duration の追加映像が
|
|
711
|
+
// 等倍のまま伸びて(= 速度が落ちて)書き出される。
|
|
712
|
+
...(speed !== undefined ? { speed } : {}),
|
|
709
713
|
...('audio' in item && item.audio === false ? { audio: false } : {})
|
|
710
714
|
};
|
|
711
715
|
const value = declaration;
|
|
@@ -898,6 +902,11 @@ function buildV2AudioItem(item, fps, ref, pathOf, legacyIndexCounters) {
|
|
|
898
902
|
t: at,
|
|
899
903
|
path: resolvedPath,
|
|
900
904
|
track: ref,
|
|
905
|
+
// fade_in / fade_out は render-cut の resolveSfxFadeSeconds が snake_case で読む
|
|
906
|
+
// (sfx 宣言と同じ綴り。bgm だけが camelCase の fadeIn / fadeOut)。
|
|
907
|
+
// 落とすと afade が生成コマンドから丸ごと消え、会話音声のフェードが書き出しに乗らない。
|
|
908
|
+
...(item.fade_in !== undefined ? { fade_in: item.fade_in } : {}),
|
|
909
|
+
...(item.fade_out !== undefined ? { fade_out: item.fade_out } : {}),
|
|
901
910
|
...(item.gain_db !== undefined ? { gainDb: item.gain_db } : {}),
|
|
902
911
|
...sourceClipFx,
|
|
903
912
|
...itemClipFx,
|
|
@@ -917,6 +926,8 @@ function buildV2AudioItem(item, fps, ref, pathOf, legacyIndexCounters) {
|
|
|
917
926
|
id: item.id, atFrames, durationFrames, at, duration, children: [], source,
|
|
918
927
|
declaration: {
|
|
919
928
|
id: item.id, t: at, path: resolvedPath,
|
|
929
|
+
...(item.fade_in !== undefined ? { fade_in: item.fade_in } : {}),
|
|
930
|
+
...(item.fade_out !== undefined ? { fade_out: item.fade_out } : {}),
|
|
920
931
|
...(item.gain_db !== undefined ? { gain_db: item.gain_db } : {}),
|
|
921
932
|
...sourceClipFx,
|
|
922
933
|
...itemClipFx,
|
|
@@ -1393,14 +1393,15 @@ var AkariEditKernel = (() => {
|
|
|
1393
1393
|
if (!(durationSec > 0)) return null;
|
|
1394
1394
|
const timelineStartSec = startAtSec + delaySec;
|
|
1395
1395
|
const baseGain = dbToLinear2(item.gainDb);
|
|
1396
|
-
const
|
|
1396
|
+
const fadeWindowSec = item.kind === "sfx" ? item.itemDurationSec : Math.min(item.itemDurationSec, Math.max(0, timelineDurationSec - item.t));
|
|
1397
|
+
const gainEvents = fadeGainEvents(
|
|
1397
1398
|
item.spec.fade_in ?? item.spec.fadeIn,
|
|
1398
1399
|
item.spec.fade_out ?? item.spec.fadeOut,
|
|
1399
|
-
|
|
1400
|
+
fadeWindowSec,
|
|
1400
1401
|
elapsedIntoItemSec,
|
|
1401
1402
|
durationSec,
|
|
1402
1403
|
baseGain
|
|
1403
|
-
)
|
|
1404
|
+
);
|
|
1404
1405
|
return {
|
|
1405
1406
|
kind: item.kind,
|
|
1406
1407
|
id: item.id,
|
|
@@ -3105,6 +3106,10 @@ var AkariEditKernel = (() => {
|
|
|
3105
3106
|
track: ref,
|
|
3106
3107
|
...common,
|
|
3107
3108
|
...copyMediaSourceFields(item.source, captionSwitch),
|
|
3109
|
+
// cuts 側(下の EditCut / declaration)と同じく、素材窓が出力尺と 1 フレーム超ずれた
|
|
3110
|
+
// ときの再生速度をレイヤー宣言にも渡す。落とすと out - in ≠ duration の追加映像が
|
|
3111
|
+
// 等倍のまま伸びて(= 速度が落ちて)書き出される。
|
|
3112
|
+
...speed !== void 0 ? { speed } : {},
|
|
3108
3113
|
..."audio" in item && item.audio === false ? { audio: false } : {}
|
|
3109
3114
|
};
|
|
3110
3115
|
const value2 = declaration;
|
|
@@ -3372,6 +3377,11 @@ var AkariEditKernel = (() => {
|
|
|
3372
3377
|
t: at,
|
|
3373
3378
|
path: resolvedPath,
|
|
3374
3379
|
track: ref,
|
|
3380
|
+
// fade_in / fade_out は render-cut の resolveSfxFadeSeconds が snake_case で読む
|
|
3381
|
+
// (sfx 宣言と同じ綴り。bgm だけが camelCase の fadeIn / fadeOut)。
|
|
3382
|
+
// 落とすと afade が生成コマンドから丸ごと消え、会話音声のフェードが書き出しに乗らない。
|
|
3383
|
+
...item.fade_in !== void 0 ? { fade_in: item.fade_in } : {},
|
|
3384
|
+
...item.fade_out !== void 0 ? { fade_out: item.fade_out } : {},
|
|
3375
3385
|
...item.gain_db !== void 0 ? { gainDb: item.gain_db } : {},
|
|
3376
3386
|
...sourceClipFx,
|
|
3377
3387
|
...itemClipFx,
|
|
@@ -3399,6 +3409,8 @@ var AkariEditKernel = (() => {
|
|
|
3399
3409
|
id: item.id,
|
|
3400
3410
|
t: at,
|
|
3401
3411
|
path: resolvedPath,
|
|
3412
|
+
...item.fade_in !== void 0 ? { fade_in: item.fade_in } : {},
|
|
3413
|
+
...item.fade_out !== void 0 ? { fade_out: item.fade_out } : {},
|
|
3402
3414
|
...item.gain_db !== void 0 ? { gain_db: item.gain_db } : {},
|
|
3403
3415
|
...sourceClipFx,
|
|
3404
3416
|
...itemClipFx,
|
|
@@ -43,6 +43,20 @@ export interface DeferredLintOptions {
|
|
|
43
43
|
*/
|
|
44
44
|
onDidWrite?: (filePath: string, content: string) => void;
|
|
45
45
|
}
|
|
46
|
+
/** 影プロジェクトの 1 エントリをどう実体化したか。 */
|
|
47
|
+
export type ShadowEntryStrategy = 'symlink' | 'copy' | 'skip';
|
|
48
|
+
/**
|
|
49
|
+
* 影プロジェクト構築の決定論テスト用シーム。本番呼び出しは既定(fs.symlink)のままで、
|
|
50
|
+
* 何も渡さなければ挙動は従来と同一。
|
|
51
|
+
*/
|
|
52
|
+
export interface ShadowLintHooks {
|
|
53
|
+
/** symlink の差し替え口。権限のある環境/無い環境をテストから作り分けるためだけに使う。 */
|
|
54
|
+
symlink?: (target: string, path: string, type: 'junction' | 'file') => Promise<void>;
|
|
55
|
+
/** エントリ 1 件ごとに実体化の手段を通知する観測口。 */
|
|
56
|
+
onShadowEntry?: (name: string, strategy: ShadowEntryStrategy) => void;
|
|
57
|
+
/** 影プロジェクトを作れず候補のメモリ検証へ退避したことを通知する観測口。 */
|
|
58
|
+
onShadowUnavailable?: (reason: string) => void;
|
|
59
|
+
}
|
|
46
60
|
/**
|
|
47
61
|
* 実ファイルは変更せず、候補全文だけを options.inputOverrides で差し替えて検証する。
|
|
48
62
|
* 既存 export のシグネチャは維持し、preview-server の保存前検査にも使える。
|
|
@@ -52,8 +66,15 @@ export declare function lintProjectCandidates(projectRoot: string, candidates: L
|
|
|
52
66
|
* 実ディスクを直接読む lint check(motion 袋参照等)を含め、候補一式を保存前に検証する。
|
|
53
67
|
* 元プロジェクトの直下エントリは影プロジェクトへ symlink し、候補の祖先だけを実体化する。
|
|
54
68
|
* 既存 lintProjectCandidates の inputOverrides 契約は変更せず、Project API だけがこの入口を使う。
|
|
69
|
+
*
|
|
70
|
+
* リンクが使えない環境(Windows の非特権ユーザー等)では**ファイルだけコピーへ倒す**。
|
|
71
|
+
* 影プロジェクトは lint の読み取り専用ステージングなので、ファイルはコピーで等価であり、
|
|
72
|
+
* かつ影側への書き込みが元プロジェクトへ伝播しない(symlink 経路と同じ安全性)。
|
|
73
|
+
* ディレクトリはコピーしない: プロジェクト直下には assets/(4K 原本が何十 GB)が来るため、
|
|
74
|
+
* junction も作れない環境では影プロジェクトの構築自体を諦め、候補のメモリ差し替えだけで
|
|
75
|
+
* 検証する(実ディスクを読む check は落ちるが、保存は止めない — 冒頭の fail-open 裁定)。
|
|
55
76
|
*/
|
|
56
|
-
export declare function lintProjectCandidatesOnDisk(projectRoot: string, candidates: LintCandidates): Promise<EditLintGateResult>;
|
|
77
|
+
export declare function lintProjectCandidatesOnDisk(projectRoot: string, candidates: LintCandidates, hooks?: ShadowLintHooks): Promise<EditLintGateResult>;
|
|
57
78
|
/** 互換 API。保存後 lint への移行後も、明示的に検証したい呼び出し側向けに残す。 */
|
|
58
79
|
export declare function assertLintPasses(projectRoot: string, candidates: LintCandidates): Promise<void>;
|
|
59
80
|
/** atomic 保存を即時完了し、lint は末尾 debounce で非同期に実行する。 */
|
|
@@ -32,6 +32,28 @@ const fs_1 = require("fs");
|
|
|
32
32
|
const path_1 = require("path");
|
|
33
33
|
const url_1 = require("url");
|
|
34
34
|
const os_1 = require("os");
|
|
35
|
+
/**
|
|
36
|
+
* 「OS / ファイルシステムがリンク作成自体を拒んだ」エラーコード。入力が誤っている系
|
|
37
|
+
* (EEXIST・ENOENT・ENOTDIR 等)は含めない — それらは従来どおり throw して原因を隠さない。
|
|
38
|
+
*
|
|
39
|
+
* Windows ではディレクトリ junction は権限不要だが、ファイル symlink は管理者権限か
|
|
40
|
+
* 開発者モードが必要。そのため一般の Windows 機では `.gitignore` 等のファイルリンクが
|
|
41
|
+
* 必ず EPERM になり、保存前の検証ステージングが本番の書き込みより先に落ちていた。
|
|
42
|
+
*/
|
|
43
|
+
const LINK_UNSUPPORTED_CODES = new Set([
|
|
44
|
+
'EPERM', 'EACCES', 'EINVAL', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'UNKNOWN'
|
|
45
|
+
]);
|
|
46
|
+
/** ディレクトリをリンクできなかったことを示す内部シグナル(素材の実体コピーは選ばない)。 */
|
|
47
|
+
class ShadowLinkUnavailable extends Error {
|
|
48
|
+
constructor(entryPath, cause) {
|
|
49
|
+
super(`影プロジェクトへ ${entryPath} をリンクできませんでした(${cause.code ?? cause.message})`);
|
|
50
|
+
this.name = 'ShadowLinkUnavailable';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function isLinkUnsupported(error) {
|
|
54
|
+
const code = error?.code;
|
|
55
|
+
return typeof code === 'string' && LINK_UNSUPPORTED_CODES.has(code);
|
|
56
|
+
}
|
|
35
57
|
const DEFAULT_LINT_DEBOUNCE_MS = 400;
|
|
36
58
|
const lintTimers = new Map();
|
|
37
59
|
const lintRevisions = new Map();
|
|
@@ -48,27 +70,80 @@ async function lintProjectCandidates(projectRoot, candidates) {
|
|
|
48
70
|
* 実ディスクを直接読む lint check(motion 袋参照等)を含め、候補一式を保存前に検証する。
|
|
49
71
|
* 元プロジェクトの直下エントリは影プロジェクトへ symlink し、候補の祖先だけを実体化する。
|
|
50
72
|
* 既存 lintProjectCandidates の inputOverrides 契約は変更せず、Project API だけがこの入口を使う。
|
|
73
|
+
*
|
|
74
|
+
* リンクが使えない環境(Windows の非特権ユーザー等)では**ファイルだけコピーへ倒す**。
|
|
75
|
+
* 影プロジェクトは lint の読み取り専用ステージングなので、ファイルはコピーで等価であり、
|
|
76
|
+
* かつ影側への書き込みが元プロジェクトへ伝播しない(symlink 経路と同じ安全性)。
|
|
77
|
+
* ディレクトリはコピーしない: プロジェクト直下には assets/(4K 原本が何十 GB)が来るため、
|
|
78
|
+
* junction も作れない環境では影プロジェクトの構築自体を諦め、候補のメモリ差し替えだけで
|
|
79
|
+
* 検証する(実ディスクを読む check は落ちるが、保存は止めない — 冒頭の fail-open 裁定)。
|
|
51
80
|
*/
|
|
52
|
-
async function lintProjectCandidatesOnDisk(projectRoot, candidates) {
|
|
81
|
+
async function lintProjectCandidatesOnDisk(projectRoot, candidates, hooks = {}) {
|
|
53
82
|
const shadowRoot = await fs_1.promises.mkdtemp((0, path_1.join)((0, os_1.tmpdir)(), 'akari-edit-store-lint-'));
|
|
54
83
|
try {
|
|
55
84
|
for (const entry of await fs_1.promises.readdir(projectRoot, { withFileTypes: true })) {
|
|
56
|
-
await
|
|
85
|
+
await materializeShadowEntry((0, path_1.resolve)(projectRoot, entry.name), (0, path_1.join)(shadowRoot, entry.name), entry.isDirectory(), entry.name, hooks);
|
|
57
86
|
}
|
|
58
87
|
for (const [relativePath, text] of Object.entries(candidates)) {
|
|
59
88
|
const segments = candidateSegments(relativePath);
|
|
60
89
|
const destination = (0, path_1.join)(shadowRoot, ...segments);
|
|
61
|
-
await materializeShadowDirectory(shadowRoot, (0, path_1.dirname)(destination));
|
|
90
|
+
await materializeShadowDirectory(shadowRoot, (0, path_1.dirname)(destination), hooks);
|
|
62
91
|
await fs_1.promises.rm(destination, { recursive: true, force: true });
|
|
63
92
|
if (text !== null)
|
|
64
93
|
await fs_1.promises.writeFile(destination, text, 'utf8');
|
|
65
94
|
}
|
|
66
95
|
return await runEditLint(shadowRoot, undefined, false);
|
|
67
96
|
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (!(error instanceof ShadowLinkUnavailable))
|
|
99
|
+
throw error;
|
|
100
|
+
warnShadowUnavailableOnce(error);
|
|
101
|
+
hooks.onShadowUnavailable?.(error.message);
|
|
102
|
+
return await lintProjectCandidates(projectRoot, candidates);
|
|
103
|
+
}
|
|
68
104
|
finally {
|
|
69
105
|
await fs_1.promises.rm(shadowRoot, { recursive: true, force: true });
|
|
70
106
|
}
|
|
71
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* 影プロジェクトへ 1 エントリを写す。まず従来どおり symlink / junction を試し、
|
|
110
|
+
* OS がリンク作成を拒んだときだけファイルコピーへ倒す(権限のある環境の挙動は変えない)。
|
|
111
|
+
*/
|
|
112
|
+
async function materializeShadowEntry(source, destination, preferDirectory, label, hooks) {
|
|
113
|
+
const symlink = hooks.symlink
|
|
114
|
+
?? ((target, path, type) => fs_1.promises.symlink(target, path, type));
|
|
115
|
+
try {
|
|
116
|
+
await symlink(source, destination, preferDirectory ? 'junction' : 'file');
|
|
117
|
+
hooks.onShadowEntry?.(label, 'symlink');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (!isLinkUnsupported(error))
|
|
122
|
+
throw error;
|
|
123
|
+
if (preferDirectory)
|
|
124
|
+
throw new ShadowLinkUnavailable(source, error);
|
|
125
|
+
// symlink エントリはリンク先を辿って種別を決める(リンクの実体がディレクトリなら
|
|
126
|
+
// コピーしない)。辿れない壊れたリンクは lint も読めないので影へは作らない。
|
|
127
|
+
const stats = await fs_1.promises.stat(source).catch(() => null);
|
|
128
|
+
if (stats === null) {
|
|
129
|
+
hooks.onShadowEntry?.(label, 'skip');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (stats.isDirectory())
|
|
133
|
+
throw new ShadowLinkUnavailable(source, error);
|
|
134
|
+
await fs_1.promises.copyFile(source, destination);
|
|
135
|
+
hooks.onShadowEntry?.(label, 'copy');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
let shadowUnavailableWarned = false;
|
|
139
|
+
function warnShadowUnavailableOnce(error) {
|
|
140
|
+
if (shadowUnavailableWarned) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
shadowUnavailableWarned = true;
|
|
144
|
+
console.warn('[edit-store] 影プロジェクトを作れないため、実ディスクを読む lint check を省いて'
|
|
145
|
+
+ '候補のメモリ検証だけで保存しています。', error.message);
|
|
146
|
+
}
|
|
72
147
|
function candidateSegments(relativePath) {
|
|
73
148
|
const segments = relativePath.split('/');
|
|
74
149
|
if (relativePath.length === 0 || relativePath.startsWith('/') || relativePath.includes('\\')
|
|
@@ -77,10 +152,10 @@ function candidateSegments(relativePath) {
|
|
|
77
152
|
}
|
|
78
153
|
return segments;
|
|
79
154
|
}
|
|
80
|
-
async function materializeShadowDirectory(shadowRoot, directory) {
|
|
155
|
+
async function materializeShadowDirectory(shadowRoot, directory, hooks) {
|
|
81
156
|
if (directory === shadowRoot)
|
|
82
157
|
return;
|
|
83
|
-
await materializeShadowDirectory(shadowRoot, (0, path_1.dirname)(directory));
|
|
158
|
+
await materializeShadowDirectory(shadowRoot, (0, path_1.dirname)(directory), hooks);
|
|
84
159
|
try {
|
|
85
160
|
const stat = await fs_1.promises.lstat(directory);
|
|
86
161
|
if (!stat.isSymbolicLink()) {
|
|
@@ -92,7 +167,7 @@ async function materializeShadowDirectory(shadowRoot, directory) {
|
|
|
92
167
|
await fs_1.promises.unlink(directory);
|
|
93
168
|
await fs_1.promises.mkdir(directory);
|
|
94
169
|
for (const entry of await fs_1.promises.readdir(source, { withFileTypes: true })) {
|
|
95
|
-
await
|
|
170
|
+
await materializeShadowEntry((0, path_1.resolve)(source, entry.name), (0, path_1.join)(directory, entry.name), entry.isDirectory(), entry.name, hooks);
|
|
96
171
|
}
|
|
97
172
|
}
|
|
98
173
|
catch (error) {
|
|
@@ -282,7 +282,7 @@ runtime.configure({ premount: false }); // 無効化
|
|
|
282
282
|
|
|
283
283
|
### ライブプレビューの tick 性能(3D 描画バッファ上限・`getAnimations()` キャッシュ)
|
|
284
284
|
|
|
285
|
-
`tick()` は 3D 断片の `threeRuntime.render(container, localSeconds, { syncVideos: true, maxRenderSize })`
|
|
285
|
+
`tick(t, playing)` は 3D 断片の `threeRuntime.render(container, localSeconds, { syncVideos: true, maxRenderSize, playing })`
|
|
286
286
|
に描画バッファの長辺上限 `maxRenderSize`(px、既定 `720` = preview-server の `app.js` の
|
|
287
287
|
`PREVIEW_3D_MAX_RENDER_SIZE` と同値)を渡す。`three-runtime.js` の `rendererSize()` は CSS 上の
|
|
288
288
|
寸法とカメラのアスペクトを変えずに WebGL の描画バッファだけを縮める(4% のヒステリシス付き)ため、
|
|
@@ -292,6 +292,11 @@ runtime.configure({ premount: false }); // 無効化
|
|
|
292
292
|
`configure({ maxRenderSize })` / `createOverlayRuntime({ maxRenderSize })` で上書きできる
|
|
293
293
|
(正の数 = 長辺 px、`null` / `0` = 無効(等倍)。キー未指定の `mount(summary)` は保持値を引き継ぐ)。
|
|
294
294
|
|
|
295
|
+
`playing` は動画テクスチャ(`materialOverrides` の動画)の同期方式に使う(2026-09-16): 再生中は
|
|
296
|
+
`<video>` を走らせて `playbackRate` で寄せ、停止・スクラブ中はシークで追従する(シーク中は次を積まない)。
|
|
297
|
+
`<video>` は `crossOrigin="anonymous"` で作る(asset stream は別オリジン)。GPU への転送は提示フレームが
|
|
298
|
+
変わったときだけ。詳細は `skills/overlay-authoring/3d.md` の VideoTexture 節。
|
|
299
|
+
|
|
295
300
|
非 3D 断片の `tick()` は `container.getAnimations({ subtree: true })` の結果を overlay ごとに
|
|
296
301
|
250ms キャッシュし(`app.js` と同じ)、可視化フリップ直後の tick・250ms 経過・未取得のときだけ
|
|
297
302
|
引き直す(非表示化でキャッシュは捨てる)。`getAnimations()` のコストはドキュメント全体に現存する
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
{ "path": "tracks[].items[].source.src", "applies_to": ["cuts", "layers"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/edit-store/src/internal-model.ts buildV2VisualItem; packages/frame-engine/src/timeline/plan.ts layerFromPlacement/resolvedCompositeLayers" },
|
|
69
69
|
{ "path": "tracks[].items[].source.src", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/internal-model.ts buildV2AudioItem" },
|
|
70
70
|
{ "path": "tracks[].items[].source.in", "applies_to": ["cuts"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/frame-engine/src/timeline/plan.ts layerFromPlacement/buildResolvedTimelinePlan" },
|
|
71
|
-
{ "path": "tracks[].items[].source.in", "applies_to": ["layers"], "gpu": "
|
|
71
|
+
{ "path": "tracks[].items[].source.in", "applies_to": ["layers"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/edit-store/src/internal-model.ts buildV2VisualItem layer declaration emits `in: item.source.in`; packages/frame-engine/src/timeline/plan.ts resolvedCompositeLayers sourceTimeUs = finite(layer.in, 0) + localSeconds * speed" },
|
|
72
72
|
{ "path": "tracks[].items[].source.in", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/internal-model.ts buildV2AudioItem" },
|
|
73
73
|
{ "path": "tracks[].items[].source.out", "applies_to": ["cuts"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/frame-engine/src/timeline/plan.ts buildResolvedTimelinePlan/layerFromPlacement" },
|
|
74
|
-
{ "path": "tracks[].items[].source.out", "applies_to": ["layers"], "gpu": "
|
|
74
|
+
{ "path": "tracks[].items[].source.out", "applies_to": ["layers"], "gpu": "partial", "osr": "partial", "evidence": "packages/edit-store/src/internal-model.ts buildV2VisualItem folds out - in into the layer declaration's speed when the span disagrees with item.duration by more than a frame; packages/frame-engine/src/timeline/plan.ts resolvedCompositeLayers consumes layer.speed", "hint": "layers の表示尺は item.duration で決まる。source.out は out - in が duration と 1 フレーム超ずれたときだけ再生速度として効く(素材窓のクリップには使われない)" },
|
|
75
75
|
{ "path": "tracks[].items[].source.out", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/internal-model.ts buildV2AudioItem" },
|
|
76
76
|
{ "path": "tracks[].items[].source.framing", "applies_to": ["cuts"], "gpu": "consumed", "osr": "consumed", "evidence": "packages/frame-engine/src/timeline/plan.ts visualAt/interpolateFraming" },
|
|
77
77
|
{ "path": "tracks[].items[].source.framing", "applies_to": ["layers"], "gpu": "ignored", "osr": "ignored", "runtime_warning": true, "evidence": "packages/edit-store/src/internal-model.ts copyMediaSourceFields; packages/frame-engine/src/timeline/plan.ts KNOWN_LAYER_KEYS omits framing" },
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: akari
|
|
3
|
+
description: AKARI Video のプロジェクトを作る・開く・続きから再開する。`.akari/` の無いフォルダで動画を作りたいと言われたとき、または AKARI のプロジェクトの状態を知りたいときに使う。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# AKARI Video を始める・再開する
|
|
7
|
+
|
|
8
|
+
> **Language**: Respond in the user's language — 対話・質問・承認確認・レポートはユーザーの使用言語に合わせる(例: 英語で話しかけられたら英語で応答する)。
|
|
9
|
+
|
|
10
|
+
この入口は次の 3 分岐だけを担当する。本体スキルの手順はここに複製しない。
|
|
11
|
+
以下の `akari` は PATH 上の CLI を使う。無ければ
|
|
12
|
+
`node "${AKARI_HOME:-$HOME/.akari}/app/packages/akari-launcher/bin/akari.mjs"`、
|
|
13
|
+
それも無ければ実在するリポ checkout の `packages/akari-launcher/bin/akari.mjs` を
|
|
14
|
+
`node` で実行する。どれも無い場合は不足を伝えて止め、パスを捏造しない。
|
|
15
|
+
|
|
16
|
+
## 続きから
|
|
17
|
+
|
|
18
|
+
1. cwd から祖先方向へ `.akari/` を探す。見つかったプロジェクトを `<project>` とする。
|
|
19
|
+
`.akari/root.json` の `schema: "creator-root/v1"` は作業場の印であり、作品の印ではない。
|
|
20
|
+
作業場だけが見つかった場合は、依頼に応じて「作る」か「開く」へ進む。
|
|
21
|
+
2. `akari status "$PWD" --json` を実行する。`next_skill` / `waiting_on` を根拠に、
|
|
22
|
+
次の一手を 1〜2 文で案内する。失敗や `state_health: inconclusive` は取得不能と伝え、
|
|
23
|
+
ファイルの断片から工程を推測しない。
|
|
24
|
+
3. `next_skill` がある場合は `<project>/.claude/skills/<next_skill>/SKILL.md` を
|
|
25
|
+
**パスで直接読む**。無ければ不足を伝え、別の手順を発明しない。
|
|
26
|
+
`waiting_on` がある場合はその操作を案内し、本体スキルの待ち条件を飛ばさない。
|
|
27
|
+
|
|
28
|
+
## 作る
|
|
29
|
+
|
|
30
|
+
`.akari/` が無い、または新規作成を明示された場合に使う。
|
|
31
|
+
|
|
32
|
+
作業場が無いことを検出しても、**利用者の同意なしに作成しない**(提案 → 同意 → 作成の順)。
|
|
33
|
+
|
|
34
|
+
1. 作業場を次の 2 経路で検出する(どちらか一方でも見つかれば作業場あり)。
|
|
35
|
+
- `<AKARI_HOME>/creator-root.json`(未設定時は `~/.akari/creator-root.json`)の
|
|
36
|
+
`lastRoot` が実在するか確認する。
|
|
37
|
+
- cwd から祖先方向へ `.akari/root.json` を探し、
|
|
38
|
+
`schema` が `"creator-root/v1"` であることを確認する。
|
|
39
|
+
見つかった作業場のマーカーも読む。JSON 破損・未知 schema は上書きせず報告する。
|
|
40
|
+
2. **1 問だけ**「作業場 `~/Akari/` に作りますか、このフォルダに作りますか?
|
|
41
|
+
作業場が無ければ `akari init` で作成します」と尋ねる。
|
|
42
|
+
既存の作業場が見つかった場合は、その実パスで `~/Akari/` を置き換える。
|
|
43
|
+
作業場を選んだときの具体的な宛先も同じ質問に含める:
|
|
44
|
+
`<作業場>/channels/<channel>/videos/<日付-スラッグ>/`。
|
|
45
|
+
`<channel>` は `root.json` の `channels` の先頭、無ければ `my-channel`。
|
|
46
|
+
日付と依頼内容から短いスラッグを提案し、既存の作品と同名なら別名を提案する。
|
|
47
|
+
このフォルダを選んだ場合の `<target>` は cwd。
|
|
48
|
+
3. 同意後、作業場を選び未作成の場合だけ `akari init` を実行する。
|
|
49
|
+
引数なしの ensure 動作は冪等で、既存作業場があれば stdout 1 行目にそのパスを返す。
|
|
50
|
+
返された場所が同意済みの場所と異なる場合は、勝手に別の場所へ作らない。
|
|
51
|
+
4. `akari new "<target>"` を実行する。既存ファイルを上書きせず不足分だけ補完し、
|
|
52
|
+
既存リポジトリの内側では git init しない F18 ガードを CLI に委ねる。
|
|
53
|
+
本体スキルは実体コピーのまま、`AKARI-SKILLS-VERSION` で作成時点の版を記録する。
|
|
54
|
+
5. stdout の作成レポートのパスと「次は `edit-plan`」を案内し、
|
|
55
|
+
`<target>/.claude/skills/edit-plan/SKILL.md` を**パスで直接読む**。
|
|
56
|
+
セッション途中に作ったスキルは列挙に載らない場合がある。次回そのプロジェクトで
|
|
57
|
+
エージェントを起動すれば列挙に載るため、今のセッションでは直接読む。
|
|
58
|
+
|
|
59
|
+
上の 1 問が同意取得を兼ねる。既に宛先まで明示して同意されている場合は再確認しない。
|
|
60
|
+
CLI が実行できない場合の作業場だけの手動生成は、同意済みの場所に限り、
|
|
61
|
+
`akari.md`、`channels/<channel>/videos/`、`library/`、`inbox/`、
|
|
62
|
+
`.akari/memory/`、`.akari/cache/` を作る。
|
|
63
|
+
`akari.md` は次のスタブを使う:
|
|
64
|
+
|
|
65
|
+
```markdown
|
|
66
|
+
# akari.md
|
|
67
|
+
|
|
68
|
+
この作業場(CreatorRoot)の規約・好みを書く場所です。
|
|
69
|
+
AKARI Video のエージェントは動画を作る前に、まずこのファイルを読みます。
|
|
70
|
+
|
|
71
|
+
## 好み
|
|
72
|
+
|
|
73
|
+
(まだ何も書かれていません)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
手動生成では次の 2 規律を必ず守る。
|
|
77
|
+
|
|
78
|
+
1. **既存ファイルを一切上書きしない**。`root.json` や `akari.md` があれば書かず「検出」に戻る
|
|
79
|
+
2. **`root.json` は最後に書く**。全ディレクトリと `akari.md` の作成完了後に作業場マーカーを置く
|
|
80
|
+
|
|
81
|
+
`.akari/root.json` は
|
|
82
|
+
`{"schema":"creator-root/v1","createdAt":<ISO8601>,"channels":[<channel>]}` とする。
|
|
83
|
+
フォールバックでは `<AKARI_HOME>/creator-root.json` を書かない。
|
|
84
|
+
CLI 不在のままプロジェクト生成まで済んだとは扱わず、`akari new` の不足を伝える。
|
|
85
|
+
|
|
86
|
+
## 開く
|
|
87
|
+
|
|
88
|
+
「作る」の検出手順だけで作業場を探し、`<作業場>/channels/*/videos/*` の実在する
|
|
89
|
+
プロジェクトディレクトリを列挙して選ばせる。見つからなければ、その旨と「作る」を案内する。
|
|
90
|
+
選んだプロジェクトを cwd にして「続きから」へ進む。読み取りだけで作業場を新設しない。
|
|
@@ -69,6 +69,8 @@ description: 「ワールドを作って」「地図で見せる動画」「紙
|
|
|
69
69
|
4. `akari world check "$PROJECT"` を実行し、エラーが 0 件になるまで台本を直して再展開する。
|
|
70
70
|
5. 素材は、世界観の束(例: Pop Motion ワールド対応版)が持つ **背景 / 飛び込み口 / モチーフ** の 3 層で考える。背景で world ごとの材質を作り、飛び込み口で portal の通過を読ませ、モチーフを各停留所へ置く。必要な素材は `akari assets fetch <id>` で取得でき、`akari world build` がプロジェクト内の素材を解決する。
|
|
71
71
|
6. `planning/world-items.json` を作り、素材を zone に対応づける。`asset` は必ず `overlay/<id>` と書く。`offset`、`scale`、`vars` は任意。
|
|
72
|
+
`delay: 0.5` のように 0 以上の秒数を指定すると、素材の時計は同じ zone id の stop の `at + delay` から始まる(省略 0)。
|
|
73
|
+
世界サイズの背景 item は `role: "background"` を付けると zone が画面外でも消えない。role 省略時も `vars` の `world-width` / `world-height`(`--` 接頭辞も可)で背景と判定する。
|
|
72
74
|
|
|
73
75
|
```json
|
|
74
76
|
{
|
|
@@ -89,6 +91,6 @@ description: 「ワールドを作って」「地図で見せる動画」「紙
|
|
|
89
91
|
`asset` の `<使う素材の id>` は実際に使う素材の id に置き換え、`zone` は `planning/world-map.json` の zone id と一致させる。同じ zone の items は配列順に重なり、後ろの item が上に乗る。素材ごとに基準点と既定サイズが異なるため、`meta.json` / `fragment.html` を実測して `offset` を決める。詳しくは [world.md の「構図の目安」](world.md#構図の目安) を見る。
|
|
90
92
|
7. `akari world build "$PROJECT"` を実行する。`overlays/world.html` と `edit.json` の world item が生成される。
|
|
91
93
|
8. `akari world preview "$PROJECT" --measure` を実行して `planning/world-map.json` の cover を実測値へ置き換える。生成済みの `overlays/world.html` は暫定値のままなので、`akari world build "$PROJECT"` をもう一度実行し、`akari world check "$PROJECT" --strict` を通す。順番は **build → preview --measure → build → check --strict**。
|
|
92
|
-
9. `akari world overview "$PROJECT"`
|
|
94
|
+
9. `akari world overview "$PROJECT"` を実行し、実素材が同じ時刻で並ぶ俯瞰地図を人に見せる。停留所の順、世界境界、carry、portal/cut の位置が意図どおりか確認する。
|
|
93
95
|
|
|
94
96
|
判断に迷ったら [world.md](world.md) の型を使う。概念と地図タブの読み方は [guide.md](guide.md) を見る。
|
|
@@ -26,6 +26,9 @@ expand は最初の build を可能にするため、portal の `transition.cove
|
|
|
26
26
|
|
|
27
27
|
## 構図の目安
|
|
28
28
|
|
|
29
|
+
素材の `delay: 0.5` は stop の `at` からの開始待ち秒数(0 以上・省略 0)で、到着前の素材の時計は 0 秒に固定される。
|
|
30
|
+
背景 item に `role: "background"` を付けると zone は非カリングになる。省略時も `vars` の `world-width` / `world-height`(`--` 接頭辞も可)があれば背景として扱う。
|
|
31
|
+
|
|
29
32
|
出力幅を `W`、出力高を `H`、停留所の `c` を `[x, y, scale]` とすると、画面に映る world の撮影枠は world px で次のようになる。
|
|
30
33
|
|
|
31
34
|
```text
|
|
@@ -690,9 +690,13 @@ container を渡しても、断片の外側からは同じ値が同じ式で読
|
|
|
690
690
|
- 時刻を決めるのは常に**外側**。ランタイムは `autoplay` しない。wall-clock 再生へ任せない。
|
|
691
691
|
- **export**: rasterize がフレーム精度シーク(提示フレームの確定まで待つ)を済ませてから
|
|
692
692
|
3D を描く。ランタイムは `currentTime` を書かない
|
|
693
|
-
- **preview**: tick が `render(container, seconds, { syncVideos: true })` を渡し、ランタイムが
|
|
694
|
-
overlay
|
|
695
|
-
|
|
693
|
+
- **preview**: tick が `render(container, seconds, { syncVideos: true, playing })` を渡し、ランタイムが
|
|
694
|
+
overlay のローカル時刻へ合わせる(2026-09-16 改訂)。**再生中(`playing: true`)は `<video>` を
|
|
695
|
+
走らせ、ズレは `playbackRate`(±0.25 まで)で寄せる**。ハードシークは 1 秒以上ズレたときだけ。
|
|
696
|
+
**停止・スクラブ中は `currentTime` でシーク**するが、前のシークが終わる(`seeked`)まで次を積まない。
|
|
697
|
+
毎 tick シークすると keyframe から復号し直しが連続し、画面が黒のまま / 完了した瞬間だけ絵が出る
|
|
698
|
+
「ちらつき」になり、完了するようになると frame-engine が遅れてタイムラインが減速する
|
|
699
|
+
(実測 0.2〜0.5 倍)。壁時計で進むので提示フレームの確定は待たず、1 フレーム前後ずれることがある
|
|
696
700
|
- `syncVideos` は **preview 専用の opt-in**。export で渡すと確定済みの提示フレームを崩して
|
|
697
701
|
決定性が壊れる
|
|
698
702
|
- ランタイムは `<video loop>` を立てて作る。export のシークは loop 宣言のある素材の時刻を尺で
|
|
@@ -713,8 +717,15 @@ container を渡しても、断片の外側からは同じ値が同じ式で読
|
|
|
713
717
|
自発光の絵を飲み込む。UV の無いメッシュには差せない((0,0) を拾って真っ黒になる)。
|
|
714
718
|
- 素材の縦横比を差し込み先に合わせる。縦動画を横画面に差すと引き伸ばされる。
|
|
715
719
|
- source の寸法・format を変える場合は既存 texture を dispose し、作り直す。
|
|
716
|
-
- preview
|
|
717
|
-
既に十分近い時刻(20ms
|
|
720
|
+
- preview の停止・スクラブ中はシークで追従するため、**編集用 720p プロキシでないと詰まる**。原本を差さない。
|
|
721
|
+
既に十分近い時刻(20ms 以内)なら書かず、シーク中は次を積まない。
|
|
722
|
+
- `<video>` は `crossOrigin="anonymous"` で作る。シェルのライブプレビューは素材を別オリジン
|
|
723
|
+
(`127.0.0.1` の asset stream、`Access-Control-Allow-Origin: *`)から配るため、CORS 無しだと canvas が
|
|
724
|
+
汚染されて WebGL へ転送できず**画面が真っ黒**になる(静止画は `TextureLoader` が既定で CORS 読みなので
|
|
725
|
+
出ていた)。同一オリジン・`file:` では無害。
|
|
726
|
+
- ライブプレビューでの GPU 転送は**提示フレームが変わったとき**(`requestVideoFrameCallback`)だけ。
|
|
727
|
+
毎 tick 無条件に `needsUpdate` すると frame-engine の描画が遅れる。書き出し(`syncVideos` 無し)は
|
|
728
|
+
従来どおり毎 draw 上げ直す。
|
|
718
729
|
|
|
719
730
|
公式: [Three.js VideoTexture](https://threejs.org/docs/pages/VideoTexture.html)
|
|
720
731
|
|