akari-video 1.0.3 → 1.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/self-update.mjs +11 -1
  3. package/src/update-check.mjs +34 -29
  4. package/src/version.mjs +23 -0
  5. package/vendor/.akari-capability-sources.json +1 -0
  6. package/vendor/docs/contract-2026-08-28-gpu-export-v0.md +4 -0
  7. package/vendor/docs/contract-2026-09-02-audio-envelope-v1.md +9 -0
  8. package/vendor/docs/contract-2026-10-06-timeline-ripple-v0.md +33 -0
  9. package/vendor/packages/akari-launcher/package.json +1 -1
  10. package/vendor/packages/akari-tools/README.md +1 -1
  11. package/vendor/packages/edit-lint/src/engine-capabilities.json +2 -0
  12. package/vendor/packages/edit-lint/src/lint/edit-v2.mjs +10 -0
  13. package/vendor/packages/edit-store/lib/caption-store.d.ts +6 -0
  14. package/vendor/packages/edit-store/lib/caption-store.js +3 -2
  15. package/vendor/packages/edit-store/lib/cut-ranges.js +12 -50
  16. package/vendor/packages/edit-store/lib/edit-v2.d.ts +9 -0
  17. package/vendor/packages/edit-store/lib/edit-v2.js +12 -2
  18. package/vendor/packages/edit-store/lib/envelope.d.ts +10 -0
  19. package/vendor/packages/edit-store/lib/envelope.js +95 -0
  20. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.d.ts +2 -2
  21. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.js +3 -1
  22. package/vendor/packages/edit-store/lib/index.d.ts +1 -0
  23. package/vendor/packages/edit-store/lib/index.js +1 -0
  24. package/vendor/packages/edit-store/lib/migrate/index.js +15 -4
  25. package/vendor/packages/edit-store/lib/ripple.d.ts +42 -0
  26. package/vendor/packages/edit-store/lib/ripple.js +438 -0
  27. package/vendor/packages/edit-store/lib/shape-markup.js +5 -2
  28. package/vendor/packages/edit-store/lib/webview-kernel.js +104 -3
  29. package/vendor/packages/overlay-runtime/src/interaction.css +7 -0
  30. package/vendor/packages/overlay-runtime/src/interaction.js +68 -5
  31. package/vendor/packages/schemas/edit.schema.json +11 -1
  32. package/vendor/packages/schemas/engine-capabilities.json +2 -0
  33. package/vendor/packages/schemas/test/audio-fade-shape.test.mjs +31 -0
  34. package/vendor/packages/schemas/test/edit-v2-schema.test.mjs +12 -0
  35. package/vendor/packages/schemas/test/engine-capabilities.test.mjs +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.0.3",
3
+ "version": "1.1.0-beta.2",
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": {
@@ -4,7 +4,8 @@ import { spawnSync } from 'node:child_process';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
- import { resolveAkariHome } from './update-check.mjs';
7
+ import { readUpdateChannel, resolveAkariHome } from './update-check.mjs';
8
+ import { compareVersions } from './version.mjs';
8
9
 
9
10
  /**
10
11
  * `akari update` の実適用(update-and-versioning 契約(内部リポ)§11):
@@ -202,6 +203,15 @@ export async function stageSelfUpdate({
202
203
  }
203
204
 
204
205
  const version = feed.product;
206
+ if (readUpdateChannel(env) === 'stable' && version?.includes('-')) {
207
+ log('安定版だけを受け取る設定です。');
208
+ return { ok: false, reason: 'channel-mismatch' };
209
+ }
210
+ const installedVersion = readVersionAt(resolveAppDir(env));
211
+ if (installedVersion && compareVersions(version, installedVersion) < 0) {
212
+ log('現在より古い版は適用できません。');
213
+ return { ok: false, reason: 'older-version' };
214
+ }
205
215
  const stagingRoot = resolveStagingRoot(env);
206
216
  const stagingDir = resolveStagingDir(env, version);
207
217
  const downloadPath = join(stagingRoot, `.download-${version}.tar.gz`);
@@ -5,6 +5,8 @@ import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
7
  import { isRunningFromAppDir, resolveStagingDir, stageSelfUpdate, swapStagedApp } from './self-update.mjs';
8
+ import { compareVersions } from './version.mjs';
9
+ export { compareVersions } from './version.mjs';
8
10
 
9
11
  /**
10
12
  * CLI(と、同じ契約に従うシェル)の更新検知・通知の共通ロジック
@@ -35,6 +37,7 @@ import { isRunningFromAppDir, resolveStagingDir, stageSelfUpdate, swapStagedApp
35
37
  */
36
38
 
37
39
  export const DEFAULT_UPDATE_FEED_URL = 'https://github.com/AkariLabs/akari-video/releases/download/updates/latest.json';
40
+ export const PRERELEASE_UPDATE_FEED_URL = 'https://github.com/AkariLabs/akari-video/releases/download/updates/prerelease.json';
38
41
 
39
42
  const CACHE_SCHEMA = 1;
40
43
  const FETCH_TIMEOUT_MS = 5000;
@@ -42,8 +45,18 @@ const FETCH_TIMEOUT_MS = 5000;
42
45
  const THIS_FILE = fileURLToPath(import.meta.url);
43
46
  const PACKAGE_ROOT = dirname(dirname(THIS_FILE));
44
47
 
45
- export function resolveFeedUrl(env = process.env) {
46
- return env.AKARI_UPDATE_FEED_URL || DEFAULT_UPDATE_FEED_URL;
48
+ export function resolveUpdateChannel(value) {
49
+ return value === 'prerelease' ? 'prerelease' : 'stable';
50
+ }
51
+
52
+ export function readUpdateChannel(env = process.env) {
53
+ try { return resolveUpdateChannel(JSON.parse(readFileSync(join(resolveAkariHome(env), 'update-preferences.json'), 'utf8')).channel); }
54
+ catch { return 'stable'; }
55
+ }
56
+
57
+ // 明示 URL はテスト・運用の完全上書き。チャンネルは評価側でも独立に検査する。
58
+ export function resolveFeedUrl(env = process.env, channel = readUpdateChannel(env)) {
59
+ return env.AKARI_UPDATE_FEED_URL || (channel === 'prerelease' ? PRERELEASE_UPDATE_FEED_URL : DEFAULT_UPDATE_FEED_URL);
47
60
  }
48
61
 
49
62
  /** `~/.akari`(既定)または `AKARI_HOME`(テスト用にルートを差し替え可能)。 */
@@ -112,26 +125,6 @@ export function resolveInstalledVersionInfo({ env = process.env, cliVersion = re
112
125
  };
113
126
  }
114
127
 
115
- /** "major.minor.patch" の先頭 3 要素だけを数値比較する(prerelease 考慮不要 — 契約 D4: stable のみ)。 */
116
- export function compareVersions(a, b) {
117
- const pa = parseVersionTriplet(a);
118
- const pb = parseVersionTriplet(b);
119
- if (!pa || !pb) {
120
- return 0;
121
- }
122
- for (let i = 0; i < 3; i++) {
123
- if (pa[i] !== pb[i]) {
124
- return pa[i] < pb[i] ? -1 : 1;
125
- }
126
- }
127
- return 0;
128
- }
129
-
130
- function parseVersionTriplet(value) {
131
- const match = typeof value === 'string' ? value.trim().match(/^(\d+)\.(\d+)\.(\d+)/) : null;
132
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
133
- }
134
-
135
128
  /** `cache.feed` が最低限の形をしているか(壊れたフィードを弾く)。 */
136
129
  export function isValidFeedShape(feed) {
137
130
  return !!feed && typeof feed === 'object' && typeof feed.schema === 'number' && typeof feed.product === 'string';
@@ -141,7 +134,13 @@ export function isValidFeedShape(feed) {
141
134
  export function readCacheSync(cachePath) {
142
135
  try {
143
136
  const parsed = JSON.parse(readFileSync(cachePath, 'utf8'));
144
- return parsed && typeof parsed === 'object' ? parsed : null;
137
+ if (!parsed || typeof parsed !== 'object') return null;
138
+ // cli.mjs もこの関数でキャッシュを読む。設定切替直後の手動 update を含めて遮断。
139
+ let channel = 'stable';
140
+ try { channel = resolveUpdateChannel(JSON.parse(readFileSync(join(dirname(cachePath), 'update-preferences.json'), 'utf8')).channel); }
141
+ catch { /* 未設定は stable */ }
142
+ return channel === 'stable' && parsed.feed?.product?.includes('-')
143
+ ? { ...parsed, feed: null, staged: undefined } : parsed;
145
144
  } catch {
146
145
  return null;
147
146
  }
@@ -155,7 +154,7 @@ function writeCacheSync(cachePath, cache) {
155
154
  /**
156
155
  * キャッシュと現在版から、新版通知を出すべきかを判定する(同期・純粋関数・I/O なし)。
157
156
  */
158
- export function evaluateUpdateStatus({ currentVersion, cache, cliVersion = currentVersion, appVersion = null, source = 'cli-fallback', installRefStatus, installRefPath, installRefNeedsRepair, managedApp, mismatch = false }) {
157
+ export function evaluateUpdateStatus({ currentVersion, cache, channel = 'stable', feedUrl, cliVersion = currentVersion, appVersion = null, source = 'cli-fallback', installRefStatus, installRefPath, installRefNeedsRepair, managedApp, mismatch = false }) {
159
158
  const versionDetails = {
160
159
  currentVersion,
161
160
  cliVersion,
@@ -168,7 +167,8 @@ export function evaluateUpdateStatus({ currentVersion, cache, cliVersion = curre
168
167
  mismatch
169
168
  };
170
169
  const feed = cache?.feed;
171
- if (!isValidFeedShape(feed)) {
170
+ if (!isValidFeedShape(feed) || (channel === 'stable' && /-/.test(feed.product))
171
+ || (cache?.feed_url && feedUrl && cache.feed_url !== feedUrl)) {
172
172
  return { available: false, ...versionDetails };
173
173
  }
174
174
  const latest = feed.product;
@@ -200,7 +200,8 @@ export function checkForUpdateSync({ currentVersion, versionInfo, env = process.
200
200
  ?? (currentVersion === undefined
201
201
  ? resolveInstalledVersionInfo({ env })
202
202
  : { cliVersion: currentVersion, appVersion: null, currentVersion, source: 'cli-fallback', mismatch: false });
203
- return evaluateUpdateStatus({ ...versions, currentVersion: currentVersion ?? versions.currentVersion, cache });
203
+ return evaluateUpdateStatus({ ...versions, currentVersion: currentVersion ?? versions.currentVersion, cache,
204
+ channel: readUpdateChannel(env), feedUrl: resolveFeedUrl(env) });
204
205
  }
205
206
 
206
207
  /** 「この版の通知を今後出さない」を記録する(同期・ローカル I/O のみ)。 */
@@ -234,7 +235,8 @@ export async function maybeStageInBackground({ env = process.env, feed, fetchImp
234
235
  if (!appComponent?.url || !appComponent?.sha256) {
235
236
  return null;
236
237
  }
237
- if (compareVersions(feed.product, resolveInstalledVersionInfo({ env }).currentVersion) <= 0) {
238
+ if ((readUpdateChannel(env) === 'stable' && feed.product.includes('-'))
239
+ || compareVersions(feed.product, resolveInstalledVersionInfo({ env }).currentVersion) <= 0) {
238
240
  return null;
239
241
  }
240
242
  // npm 側 CLI と本体が分離していても、install-ref があれば管理対象の本体を更新できる。
@@ -262,7 +264,7 @@ export async function refreshUpdateFeed({ env = process.env, fetchImpl = globalT
262
264
  return;
263
265
  }
264
266
  const feed = await response.json();
265
- if (!isValidFeedShape(feed)) {
267
+ if (!isValidFeedShape(feed) || (readUpdateChannel(env) === 'stable' && feed.product.includes('-'))) {
266
268
  return null;
267
269
  }
268
270
  const existing = readCacheSync(cachePath);
@@ -270,6 +272,7 @@ export async function refreshUpdateFeed({ env = process.env, fetchImpl = globalT
270
272
  schema: CACHE_SCHEMA,
271
273
  fetched_at: new Date().toISOString(),
272
274
  feed,
275
+ feed_url: feedUrl,
273
276
  dismissed: existing?.dismissed ?? {}
274
277
  };
275
278
  writeCacheSync(cachePath, next);
@@ -347,7 +350,9 @@ export function maybeApplyPendingUpdateOnLaunch({ env = process.env, log = () =>
347
350
  const cache = readCacheSync(cachePath);
348
351
  const staged = cache?.staged;
349
352
  const feed = cache?.feed;
350
- if (!staged?.version || !isValidFeedShape(feed) || staged.version !== feed.product) {
353
+ if (!staged?.version || !isValidFeedShape(feed) || staged.version !== feed.product
354
+ || (readUpdateChannel(env) === 'stable' && feed.product.includes('-'))
355
+ || (cache.feed_url && cache.feed_url !== resolveFeedUrl(env))) {
351
356
  return { applied: false, reason: 'no-matching-staged-update' };
352
357
  }
353
358
 
@@ -0,0 +1,23 @@
1
+ /** npm 依存ゼロの launcher 用 semver 比較。build metadata は順序に影響しない。 */
2
+ export function compareVersions(a, b) {
3
+ const parse = value => {
4
+ const match = typeof value === 'string' ? value.trim().match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/) : null;
5
+ if (!match) return null;
6
+ const pre = match[4]?.split('.') ?? null;
7
+ if (pre?.some(id => /^0\d+$/.test(id))) return null;
8
+ return { core: match.slice(1, 4).map(Number), pre };
9
+ };
10
+ const left = parse(a), right = parse(b);
11
+ if (!left || !right) return 0;
12
+ for (let i = 0; i < 3; i++) if (left.core[i] !== right.core[i]) return Math.sign(left.core[i] - right.core[i]);
13
+ if (!left.pre || !right.pre) return left.pre === right.pre ? 0 : left.pre ? -1 : 1;
14
+ for (let i = 0; i < Math.min(left.pre.length, right.pre.length); i++) {
15
+ const x = left.pre[i], y = right.pre[i];
16
+ if (x === y) continue;
17
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
18
+ if (xn && yn) return Math.sign(BigInt(x) > BigInt(y) ? 1 : -1);
19
+ if (xn !== yn) return xn ? -1 : 1;
20
+ return x < y ? -1 : 1;
21
+ }
22
+ return Math.sign(left.pre.length - right.pre.length);
23
+ }
@@ -82,6 +82,7 @@
82
82
  "docs/contract-2026-10-02-fragment-font-subset-v0.md",
83
83
  "docs/contract-2026-10-02-textstyle-v1-rich.md",
84
84
  "docs/contract-2026-10-04-asset-tier-v1.md",
85
+ "docs/contract-2026-10-06-timeline-ripple-v0.md",
85
86
  "packages/akari-launcher/package.json",
86
87
  "packages/akari-launcher/README.md",
87
88
  "packages/akari-tools/package.json",
@@ -517,6 +517,10 @@ cut 段は `cut-audio.mp4`、尺延長が必要な場合は続けて `cut-audio-
517
517
 
518
518
  ## 12. 採寸不安定の根治・実行時フォールバック・生フレーム dump(2026-08-30 追記)
519
519
 
520
+ ### 字幕の文字送り(2026-10-06 追記)
521
+
522
+ `text_style.animation` の `typewriter` は書記素ごとの span を時刻に合わせて表示する。GPU 字幕スプライトはこの文字単位の表示に対応しないため、適格判定で除外する。`--engine auto` は OSR を選び、理由には「文字送りは OSR で書き出します」と表示する。明示的な GPU 指定は従来どおり適格判定で拒否する。
523
+
520
524
  ### 12.1 事実(2026-08-30・capture-v2-engine レーンの実測と司令塔のコード確認)
521
525
 
522
526
  - 内部 fieldtest 案件(11 秒・1080p・字幕 **3 cue・`words[]` 無し・style 無指定**・HTML オーバーレイ 2・LUT)で、
@@ -89,3 +89,12 @@ schema と reader は型・範囲・時刻順を error にする。lint は実
89
89
  | 許容する差 | デコーダとサンプル境界 | デコーダとサンプル境界 |
90
90
 
91
91
  afftdn、loudnorm、true peak guard は最終マスターだけの責務であり、プレビューには実装しない。
92
+
93
+ ## 7. フェードの形
94
+
95
+ 音声アイテムは `fade_in_shape` / `fade_out_shape` を任意に持つ。省略時は `linear` とし、
96
+ 既存のフェード秒数、クランプ規則、フィルタ文字列を維持する。進行率 `p`(0〜1)に対する
97
+ ゲイン係数は `linear` = `p`、`equal_power` = `sin(pπ/2)`、
98
+ `s_curve` = `(1−cos(pπ))/2`、`slow` = `p²` とする。フェードアウトは時間方向を反転する。
99
+ 書き出しの `afade` 曲線は順に `tri`、`qsin`、`hsin`、`qua` を使う。
100
+ プレビューではフェード区間を少なくとも 16 区間の折れ線で近似する。
@@ -0,0 +1,33 @@
1
+ ---
2
+ lifecycle: implemented
3
+ created: 2026-10-06
4
+ updated: 2026-10-06
5
+ ---
6
+
7
+ # タイムラインの範囲操作と詰めカーネル v0
8
+
9
+ 実装モジュールは `packages/edit-store/src/ripple.ts`。`cut-ranges.ts` はこのカーネルを呼び出す。edit.json v2 の時刻は整数フレーム。`tracks[]` の配列順は合成順として維持する。範囲編集関数は入力を変更せず `{ edit, changed, removedFrames?, blocked?, reason? }` を返す。`changed: false` では `reason` に操作不能の理由を入れる。`setTrackRippleMode` は新しい edit を直接返し、`findGapAt` と `editPoints` は読み取り値を返す。
10
+
11
+ ## トラックの札
12
+
13
+ `tracks[]` に任意の `target` / `sync` 真偽値を保存する。`resolveTrackRippleMode(track)` は `target: true` を `cut`、`target: false, sync: true` を `shift`、`target: false` とそれ以外の組を `fixed` とする。両キー省略時は visual が `cut`、audio は 1 件以上の全アイテムが `role: "bgm"` なら `fixed`、それ以外は `cut`。`sync` だけ指定したときは true が `shift`、false が `fixed`。`setTrackRippleMode` は `cut = (true,true)`、`shift = (false,true)`、`fixed = (false,false)` を保存する。
14
+
15
+ 操作ごとの `modeOverride` は一時指定で、`lockedTrackIds` は常に固定として優先する。固定トラックは範囲操作で変えない。選択アイテムを明示して消す場合はそのトラックを削除対象とするが、ロック中なら操作全体を拒否する。
16
+
17
+ | 関数 | 内容 |
18
+ |---|---|
19
+ | `splitAtFrame` | 再生ヘッドをまたぐ cut トラックの media / html / telop / filter / 独立音声を分割。`itemIds` または `trackIds` で絞れる。リンクした映像・音声は同時に分割する。リンク相手がロック行にある場合、または左右どちらかが `ceil(fps × 0.15)` フレーム未満なら、全体を拒否して `changed: false` と `reason: "リンク相手が固定中か、片側が最小尺未満です"` を返す。 |
20
+ | `liftRange` | cut トラックの範囲内を取り除き、隙間を残す。 |
21
+ | `extractRange` | cut トラックの範囲内を取り除き、後続を範囲長だけ前へ送る。shift トラックは中身を取り除かず、範囲終端以降に始まるアイテムだけを前へ送る。 |
22
+ | `rippleDeleteItems` | 選択アイテムの区間を詰める。他トラックは中身を消さずに後続を送る。リンク相手も既定で対象とし、`oneSide` で片側だけにできる。離れた選択は右から処理する。 |
23
+ | `findGapAt` / `closeGapAt` | 指定トラックでフレームを含む先頭またはアイテム間の隙間を見つけ、その長さだけ後続を送る。末尾の空きは対象外。 |
24
+ | `rippleTrimToPlayhead` | 最も近い前後の編集点から再生ヘッドまでを `extractRange` する。編集点が無ければ理由を返す。 |
25
+ | `editPoints` | cut トラックのアイテム端を昇順・重複なしで返す。 |
26
+ | `compactTrackGaps` | cut の visual トラックにある media だけを配列順に詰める。`fromItemId` があればそのアイテムの後続だけを詰める。既定では anchor 付きアイテムを直接動かさない。 |
27
+ | `applyCutRanges` | 素材秒で重なる media を選び、共通の区間除去部品で切除する。触った visual トラックの media だけを `at = 0` から配列順に詰める旧挙動を保つ。 |
28
+
29
+ shift は前のアイテムの終端より前に入らない。必要な移動量を確保できなかったアイテムの id を `blocked` に返し、重なりを作らない。`anchor` を持つアイテムは shift で直接動かさない。cut トラックなら範囲内の削除対象にはなる。位置の再解決は `refreshItemAnchors` の責務とする。`applyCutRanges` は旧挙動を維持するため、`compactTrackGaps` に `includeAnchored: true` を渡して触ったトラックの media を詰める。
30
+
31
+ 分割片の `source.in/out` は、素材秒をアイテムのフレーム比率(speed 込み)で按分する。音声の `keyframes[].gain_db` と映像の数値キーフレームは境界点を両片に入れ、ローカルフレーム時刻へ振り分ける。境界値は区間の終点側キーの `easing` を `easingProgress` で評価する。文字列指定は全プロパティ、Record 指定はプロパティ別(例: `gain_db`、`opacity`、`transform.x`)に適用し、左片の新しい境界点にも終点側の easing を引き継ぐ。線形区間の全フレーム値は分割前と一致する。非線形区間は境界値を一致させるが、分割後の区間内部の曲線は近似になる。`fade_in` は左片、`fade_out` は右片だけに残し、ダッキングなど他の属性は両片へ複製する。リンク音声の右片は映像の右片へ `link` を付け替える。
32
+
33
+ 字幕本文は `captions.json` のソース秒が正本であり、ここでは変更しない。切除後の表示位置は `timeline-map` の `sourceToOutput` で追従する。通常の Delete は隙間を残す。詰める処理は明示した操作でだけ行う。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.0.3",
3
+ "version": "1.1.0-beta.2",
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": [
@@ -8,7 +8,7 @@
8
8
  - `akari media waveform <target>` — 波形、無音区間、ラウドネスを調べる。
9
9
  - `akari media transcribe <target>` — ローカル既定の時刻付き文字起こしを返す。
10
10
  - `akari media transcribe-diff <target> [--engines a,b,c]` — 最新のエンジン別文字起こしを文字単位で比較し、`diff.json` と JSON 要約を返す。句読点だけの差は除外し、1 エンジンなら比較を省略する。
11
- - `akari media transcribe-cuts <target> [--basis b] [--silence-min 1.5] [--silence-break 3.0] [--silence-keep 0.5]` — フィラー・言い直し・無音・未認識を `cuts.json` に候補として保存し、JSON 要約を返す。既存の採否を引き継ぎ、手直しした字幕に重なる候補は既定 OFF にする。
11
+ - `akari media transcribe-cuts <target> [--basis b] [--silence-min 0.45] [--silence-break 3.0] [--silence-keep 0.15]` — フィラー・言い直し・無音・未認識を `cuts.json` に候補として保存し、JSON 要約を返す。既存の採否を引き継ぎ、手直しした字幕に重なる候補は既定 OFF にする。
12
12
  - `akari media audio-level <projectDir>` — `gain_db` 未指定の音声素材を測り、役割別の提案値を表にする。`--write` で `edit.json` へ数値を保存し、`--json` で JSON 配列を返す。
13
13
 
14
14
  ## 音声素材の挿入レベル
@@ -57,6 +57,8 @@
57
57
  { "path": "tracks[].items[].gain_db", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/internal-model.ts buildV2AudioItem gainDb projection" },
58
58
  { "path": "tracks[].items[].fade_in", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/internal-model.ts buildV2AudioItem fadeIn/fade_in projection" },
59
59
  { "path": "tracks[].items[].fade_out", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/internal-model.ts buildV2AudioItem fadeOut/fade_out projection" },
60
+ { "path": "tracks[].items[].fade_in_shape", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/envelope.ts projectAudioFadeShapes/audioFadeFfmpegCurve; packages/render-cut/src/plan.mjs fadeCurveOption" },
61
+ { "path": "tracks[].items[].fade_out_shape", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/envelope.ts projectAudioFadeShapes/audioFadeFfmpegCurve; packages/render-cut/src/plan.mjs fadeCurveOption" },
60
62
  { "path": "tracks[].items[].ducking", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/internal-model.ts buildV2AudioItem ducking projection" },
61
63
  { "path": "tracks[].items[].duck_db", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/internal-model.ts buildV2AudioItem duck projection; packages/edit-store/src/envelope.ts computeDuckEnvelope" },
62
64
  { "path": "tracks[].items[].duck_attack", "applies_to": ["audio"], "gpu": "other-subsystem", "osr": "other-subsystem", "consumer": "render-cut audio mix", "evidence": "packages/edit-store/src/envelope.ts computeDuckEnvelope attack ramp" },
@@ -460,6 +460,16 @@ export function validateEditV2(edit, findings) {
460
460
  path: `${itemPath}.${field}`,
461
461
  });
462
462
  }
463
+ for (const field of ["fade_in_shape", "fade_out_shape"]) {
464
+ if (!Object.hasOwn(item, field)
465
+ || ["linear", "equal_power", "s_curve", "slow"].includes(item[field])) continue;
466
+ addFinding(findings, {
467
+ severity: "error",
468
+ check: "audio.fade-shape",
469
+ message: `${field} must be linear, equal_power, s_curve, or slow`,
470
+ path: `${itemPath}.${field}`,
471
+ });
472
+ }
463
473
  if (isRecord(item.source)) {
464
474
  if (role === "narration") {
465
475
  for (const field of ["in", "out"]) {
@@ -191,6 +191,12 @@ export interface CaptionTextStylePatch {
191
191
  ease?: string | null;
192
192
  amp?: number | null;
193
193
  } | null;
194
+ loop?: {
195
+ id: string;
196
+ durationSec?: number;
197
+ ease?: string | null;
198
+ amp?: number | null;
199
+ } | null;
194
200
  out?: {
195
201
  id: string;
196
202
  durationSec?: number;
@@ -1607,7 +1607,7 @@ function validateTextStylePatch(updates) {
1607
1607
  throw new Error('字幕アニメの設定が不正です。');
1608
1608
  }
1609
1609
  if (updates.animation && typeof updates.animation === 'object') {
1610
- for (const slot of [updates.animation.in, updates.animation.out]) {
1610
+ for (const slot of [updates.animation.in, updates.animation.loop, updates.animation.out]) {
1611
1611
  if (slot === undefined || slot === null)
1612
1612
  continue;
1613
1613
  if (!slot || typeof slot !== 'object' || typeof slot.id !== 'string'
@@ -1786,13 +1786,14 @@ function updateAnimationStyleObject(source, updates, label) {
1786
1786
  if (!located) {
1787
1787
  const created = {
1788
1788
  ...(updates.in ? { in: animationSlotToJson(updates.in) } : {}),
1789
+ ...(updates.loop ? { loop: animationSlotToJson(updates.loop) } : {}),
1789
1790
  ...(updates.out ? { out: animationSlotToJson(updates.out) } : {})
1790
1791
  };
1791
1792
  return Object.keys(created).length > 0 ? appendJsonProperty(source, 'animation', created) : source;
1792
1793
  }
1793
1794
  const object = locateTopLevelObjectProperty(source, 'animation', label);
1794
1795
  let next = object.text;
1795
- for (const [slotName, slot] of [['in', updates.in], ['out', updates.out]]) {
1796
+ for (const [slotName, slot] of [['in', updates.in], ['loop', updates.loop], ['out', updates.out]]) {
1796
1797
  if (slot === undefined)
1797
1798
  continue;
1798
1799
  if (slot === null) {
@@ -2,13 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.detectEditVersion = detectEditVersion;
4
4
  exports.applyCutRanges = applyCutRanges;
5
- /**
6
- * v2 の分割・削除は akari-annotations の edit-v2-mutations.ts にある
7
- * splitItem :597-626 / removeItem :567-572 を出所とし、同じ按分規則を使う。
8
- * edit-store から Theia 拡張へ逆依存できないため、この最小部分だけを複製している。
9
- */
5
+ /** Source-time selection stays local; retained pieces share the ripple kernel's slicing rule. */
10
6
  const edit_store_1 = require("./edit-store");
11
7
  const edit_v2_1 = require("./edit-v2");
8
+ const ripple_1 = require("./ripple");
12
9
  const LEGACY_EDGE_SECONDS = 0.15;
13
10
  function detectEditVersion(source) {
14
11
  const version = JSON.parse(source).version;
@@ -115,21 +112,10 @@ function applyV2(source, ranges, opts) {
115
112
  if (!matched)
116
113
  warnings.push(`カット対象が見つかりません: ${range.in}–${range.out}`);
117
114
  }
118
- // performCompactCuts と同じく、対象となった visual track の media items だけを
119
- // 配列順に整数フレームのカーソルへ詰める。他 visual track / audio lane は不変。
120
- for (const track of visualTracks(edit)) {
121
- if (!affectedTrackIds.has(track.id))
122
- continue;
123
- let cursor = 0;
124
- for (const item of track.items) {
125
- if (item.source.kind !== 'media')
126
- continue;
127
- item.at = cursor;
128
- cursor += item.duration;
129
- }
130
- }
131
- (0, edit_v2_1.readEditV2)(edit);
132
- return { source: `${JSON.stringify(edit, null, 2)}\n`, removedFrames, warnings };
115
+ const modeOverride = Object.fromEntries(edit.tracks.map(track => [track.id, affectedTrackIds.has(track.id) ? 'cut' : 'fixed']));
116
+ const compacted = (0, ripple_1.compactTrackGaps)(edit, { modeOverride, includeAnchored: true }).edit;
117
+ (0, edit_v2_1.readEditV2)(compacted);
118
+ return { source: `${JSON.stringify(compacted, null, 2)}\n`, removedFrames, warnings };
133
119
  }
134
120
  function copyRangeMetadata(target, range) {
135
121
  if (range.reason !== undefined)
@@ -164,37 +150,13 @@ function splitAndRemove(item, overlapIn, overlapOut, edit) {
164
150
  const endOffset = clampFrame(Math.round((overlapOut - mediaItem.source.in) / sourceDuration * mediaItem.duration), mediaItem.duration);
165
151
  if (endOffset <= startOffset)
166
152
  return { items: [item], removedFrames: 0 };
167
- const items = [];
168
- if (startOffset > 0) {
169
- const first = cloneItem(mediaItem);
170
- first.duration = startOffset;
171
- first.source.out = mediaItem.source.in + sourceDuration * startOffset / mediaItem.duration;
172
- items.push(first);
173
- }
174
- if (endOffset < mediaItem.duration) {
175
- const second = cloneItem(mediaItem);
176
- second.id = nextItemId(edit, `${mediaItem.id}-split`);
177
- second.at = mediaItem.at + endOffset;
178
- second.duration = mediaItem.duration - endOffset;
179
- second.source.in = mediaItem.source.in + sourceDuration * endOffset / mediaItem.duration;
180
- items.push(second);
181
- }
182
- return { items, removedFrames: endOffset - startOffset };
183
- }
184
- function cloneItem(item) {
185
- return JSON.parse(JSON.stringify(item));
186
- }
187
- function nextItemId(edit, base) {
188
153
  const ids = new Set();
189
- for (const track of visualTracks(edit))
190
- for (const item of track.items)
191
- collectIds(item, ids);
192
- if (!ids.has(base))
193
- return base;
194
- let serial = 2;
195
- while (ids.has(`${base}-${serial}`))
196
- serial++;
197
- return `${base}-${serial}`;
154
+ for (const track of edit.tracks)
155
+ if ('items' in track)
156
+ for (const candidate of track.items)
157
+ collectIds(candidate, ids);
158
+ const items = (0, ripple_1.removeTimelineItemRange)(mediaItem, { start: mediaItem.at + startOffset, end: mediaItem.at + endOffset }, edit.output.fps, ids);
159
+ return { items, removedFrames: endOffset - startOffset };
198
160
  }
199
161
  function collectIds(item, ids) {
200
162
  ids.add(item.id);
@@ -1,3 +1,4 @@
1
+ import type { AudioFadeShape } from './envelope';
1
2
  export type LaneV2 = 'visual' | 'audio';
2
3
  export interface OutputV2 {
3
4
  width: number;
@@ -429,6 +430,8 @@ export interface AudioMediaItemV2 {
429
430
  keyframes?: KeyframeV2[];
430
431
  fade_in?: number;
431
432
  fade_out?: number;
433
+ fade_in_shape?: AudioFadeShape;
434
+ fade_out_shape?: AudioFadeShape;
432
435
  ducking?: boolean;
433
436
  duck_db?: number;
434
437
  duck_attack?: number;
@@ -461,6 +464,8 @@ export interface VisualItemsTrackV2 {
461
464
  name?: string;
462
465
  /** トラックの音声をミュート。visual は cut の埋め込み音声、audio は item を書き出し・プレビューから除外。 */
463
466
  muted?: boolean;
467
+ target?: boolean;
468
+ sync?: boolean;
464
469
  items: ItemV2[];
465
470
  }
466
471
  export interface AudioItemsTrackV2 {
@@ -469,6 +474,8 @@ export interface AudioItemsTrackV2 {
469
474
  name?: string;
470
475
  /** トラックの音声をミュート。visual は cut の埋め込み音声、audio は item を書き出し・プレビューから除外。 */
471
476
  muted?: boolean;
477
+ target?: boolean;
478
+ sync?: boolean;
472
479
  items: AudioMediaItemV2[];
473
480
  }
474
481
  export type ItemsTrackV2 = VisualItemsTrackV2 | AudioItemsTrackV2;
@@ -478,6 +485,8 @@ export interface ContentTrackV2 {
478
485
  name?: string;
479
486
  /** トラックの音声をミュート。visual は cut の埋め込み音声、audio は item を書き出し・プレビューから除外。 */
480
487
  muted?: boolean;
488
+ target?: boolean;
489
+ sync?: boolean;
481
490
  content: CaptionTrackContentV2;
482
491
  }
483
492
  export type TrackV2 = ItemsTrackV2 | ContentTrackV2;
@@ -12,7 +12,7 @@ const ITEM_KEYS = new Set([
12
12
  ]);
13
13
  const AUDIO_ITEM_KEYS = new Set([
14
14
  'id', 'name', 'hidden', 'locked', 'at', 'duration', 'role', 'link', 'mute', 'source', 'gain_db', 'keyframes',
15
- 'fade_in', 'fade_out', 'ducking', 'duck_db', 'duck_attack', 'duck_release',
15
+ 'fade_in', 'fade_out', 'fade_in_shape', 'fade_out_shape', 'ducking', 'duck_db', 'duck_attack', 'duck_release',
16
16
  'denoise', 'lowcut_hz', 'script', 'reading', 'caption_ref', 'provenance', 'anchor'
17
17
  ]);
18
18
  /**
@@ -121,7 +121,7 @@ function validateEditSource(value, index, ids) {
121
121
  function validateTrack(value, index, trackIds, itemIds, sourceIds) {
122
122
  const path = `edit.json.tracks[${index}]`;
123
123
  requireRecord(value, path);
124
- requireExactKeys(value, new Set(['id', 'lane', 'name', 'muted', 'items', 'content']), path);
124
+ requireExactKeys(value, new Set(['id', 'lane', 'name', 'muted', 'target', 'sync', 'items', 'content']), path);
125
125
  requireText(value.id, `${path}.id`);
126
126
  if (trackIds.has(value.id))
127
127
  throw invalid(`${path}.id`, `track id が重複しています: ${value.id}`);
@@ -135,6 +135,11 @@ function validateTrack(value, index, trackIds, itemIds, sourceIds) {
135
135
  if (hasOwn(value, 'muted') && typeof value.muted !== 'boolean') {
136
136
  throw invalid(`${path}.muted`, 'boolean である必要があります');
137
137
  }
138
+ for (const key of ['target', 'sync']) {
139
+ if (hasOwn(value, key) && typeof value[key] !== 'boolean') {
140
+ throw invalid(`${path}.${key}`, 'boolean である必要があります');
141
+ }
142
+ }
138
143
  const hasItems = hasOwn(value, 'items');
139
144
  const hasContent = hasOwn(value, 'content');
140
145
  if (hasItems === hasContent) {
@@ -190,6 +195,11 @@ function validateAudioItem(value, path, ids, sourceIds) {
190
195
  requireNonNegativeNumber(value.fade_in, `${path}.fade_in`);
191
196
  if (hasOwn(value, 'fade_out'))
192
197
  requireNonNegativeNumber(value.fade_out, `${path}.fade_out`);
198
+ for (const field of ['fade_in_shape', 'fade_out_shape']) {
199
+ if (hasOwn(value, field) && !['linear', 'equal_power', 's_curve', 'slow'].includes(value[field])) {
200
+ throw invalid(`${path}.${field}`, 'linear/equal_power/s_curve/slow のいずれかである必要があります');
201
+ }
202
+ }
193
203
  if (hasOwn(value, 'ducking') && typeof value.ducking !== 'boolean') {
194
204
  throw invalid(`${path}.ducking`, 'boolean である必要があります');
195
205
  }
@@ -12,6 +12,16 @@ export interface EnvelopeGainEvent {
12
12
  value: number;
13
13
  method: 'set' | 'linear' | 'exponential';
14
14
  }
15
+ export type AudioFadeShape = 'linear' | 'equal_power' | 's_curve' | 'slow';
16
+ /** The same progress curve is sampled by preview and named by the export filter. */
17
+ export declare function audioFadeProgress(shape: AudioFadeShape | undefined, progress: number): number;
18
+ export declare function audioFadeMultiplier(localSeconds: number, durationSeconds: number, fadeInSeconds: number, fadeOutSeconds: number, inShape?: AudioFadeShape, outShape?: AudioFadeShape): number;
19
+ /** Linear ramp events: 16 segments per active fade, including both endpoints. */
20
+ export declare function audioFadeGainEvents(durationSeconds: number, fadeInSeconds: number, fadeOutSeconds: number, inShape?: AudioFadeShape, outShape?: AudioFadeShape): EnvelopeGainEvent[];
21
+ /** afade's tri/qsin/hsin/qua correspond to p/sin(pπ/2)/(1−cos(pπ))/2/p². */
22
+ export declare function audioFadeFfmpegCurve(shape: AudioFadeShape | undefined): 'tri' | 'qsin' | 'hsin' | 'qua';
23
+ /** Carry v2-only shape declarations across the legacy audio projection without changing shape-less input. */
24
+ export declare function projectAudioFadeShapes<T extends Record<string, any>>(audio: T, tracks: unknown): T;
15
25
  export declare const DEFAULT_DUCK_DB = -12;
16
26
  export declare const DEFAULT_DUCK_ATTACK_SEC = 0.3;
17
27
  export declare const DEFAULT_DUCK_RELEASE_SEC = 0.8;