akari-video 0.1.14 → 0.1.15

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 (33) hide show
  1. package/package.json +1 -1
  2. package/vendor/.akari-capability-sources.json +1 -0
  3. package/vendor/docs/contract-2026-07-17-data-contract-versioning.md +8 -0
  4. package/vendor/docs/contract-2026-08-23-captions-emphasis-words-v0.md +76 -0
  5. package/vendor/packages/akari-launcher/package.json +1 -1
  6. package/vendor/packages/edit-lint/src/edit-lint.mjs +25 -7
  7. package/vendor/packages/edit-store/lib/edit-store.d.ts +2 -2
  8. package/vendor/packages/edit-store/lib/edit-store.js +3 -1
  9. package/vendor/packages/edit-store/lib/index.d.ts +1 -0
  10. package/vendor/packages/edit-store/lib/index.js +1 -0
  11. package/vendor/packages/edit-store/lib/internal-model.js +52 -53
  12. package/vendor/packages/edit-store/lib/legacy-audio-view.d.ts +16 -0
  13. package/vendor/packages/edit-store/lib/legacy-audio-view.js +60 -0
  14. package/vendor/packages/edit-store/lib/migrate/index.d.ts +8 -2
  15. package/vendor/packages/edit-store/lib/migrate/index.js +102 -12
  16. package/vendor/packages/edit-store/lib/migrate/legacy-parse.js +3 -1
  17. package/vendor/packages/edit-store/lib/timeline-map.d.ts +16 -0
  18. package/vendor/packages/edit-store/lib/timeline-map.js +65 -15
  19. package/vendor/packages/edit-store/lib/track-transition-compatibility.js +6 -3
  20. package/vendor/packages/edit-store/lib/webview-kernel.js +65 -16
  21. package/vendor/packages/edit-store/lib/write-gate.d.ts +2 -0
  22. package/vendor/packages/edit-store/lib/write-gate.js +27 -0
  23. package/vendor/packages/schemas/bin/validate-captions.mjs +51 -2
  24. package/vendor/packages/schemas/captions.schema.json +21 -2
  25. package/vendor/packages/schemas/examples/captions-emphasis-words-empty-word/captions.json +16 -0
  26. package/vendor/packages/schemas/examples/captions-emphasis-words-invalid-id/captions.json +16 -0
  27. package/vendor/packages/schemas/examples/captions-emphasis-words-missing-emotion/captions.json +16 -0
  28. package/vendor/packages/schemas/examples/captions-emphasis-words-range-invalid/captions.json +17 -0
  29. package/vendor/packages/schemas/examples/captions-emphasis-words-valid/captions.json +31 -0
  30. package/vendor/packages/schemas/test/validate-captions.test.mjs +37 -0
  31. package/vendor/skills/edit-plan/SKILL.md +6 -3
  32. package/vendor/skills/edit-plan/emphasis-detection.md +14 -9
  33. package/vendor/skills/research-plan/storyboard.md +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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": {
@@ -40,6 +40,7 @@
40
40
  "docs/contract-2026-08-13-avatar-drive-v0.md",
41
41
  "docs/contract-2026-08-14-avatar-vrm-v0.md",
42
42
  "docs/contract-2026-08-18-v1-render-parity.md",
43
+ "docs/contract-2026-08-23-captions-emphasis-words-v0.md",
43
44
  "docs/contract-2026-08-23-stroke-persistence.md",
44
45
  "packages/akari-launcher/package.json",
45
46
  "packages/akari-launcher/README.md",
@@ -97,3 +97,11 @@ edit.json の `version` は変わらないし、edit.json が `version: 1` に
97
97
  task `2026-08-20-v2-audio-tracks` に限り、「機能追加禁止・バグ修正のみ」の明示承認済み・1 回限りの例外として、v0/v1 の `audio.sfx[]`・`audio.narration[]`・`audio.bgm` を v2 の audio lane `tracks[].items[]` へ移す変換を追加した。出力側の `at` / `duration` は整数フレームへ確定し、素材側の `in` / `out` と fade / `bgm.in` は秒のまま保持する。実尺が旧形式だけでは決まらない音声は `duration: 0` センチネルとし、この変換器では ffprobe しない。トップレベル `audio` は `master` が宣言されている場合だけ残す。
98
98
 
99
99
  これは凍結前に許可された最後の機能追加である。今後この変換器へさらに変換能力を足さず、§6 の期限どおり本体から分離・削除する。
100
+
101
+ ### 6.2 filter レイヤー転写と emphasis_words 移送(2026-08-23・1 回限りの例外)
102
+
103
+ task `2026-08-23-migrate-filter-emphasis-exception` に限り、2026-08-23 オーナー裁定による「機能追加禁止・バグ修正のみ」の明示承認済み・1 回限りの例外として、v0/v1 の独立した `kind: "filter"` レイヤーを v2 の filter source へ転写し、トップレベル `emphasis_words[]` を edit.json v2 から除外して object ルートの `captions.json.emphasis_words[]` へ移送する変換を追加した。media 系レイヤー(`video` / `baked`)に直付けされた `filter` は v2 に等価表現が無いため転写対象外とし、理由付きで変換を停止する。移送時は edit.json と captions.json の原文をそれぞれ退避し、1 手の revert で両方を復元する。移送先が無い、配列ルートである、または既に `emphasis_words` を持つ場合は理由付きで停止する。
104
+
105
+ 2026-08-21 の廃止裁定は fieldtest 60 本を母集団とし、本番リールでの使用が調査圏外だったという母集団瑕疵があったため、本例外で既存データを損失なく移す。
106
+
107
+ これ以後この変換器へさらに変換能力を足さず、§6 の期限どおり本体から分離・削除する。
@@ -0,0 +1,76 @@
1
+ # captions.json v0 語レベル演出(emphasis_words)データ契約
2
+
3
+ - 日付: 2026-08-23
4
+ - 状態: 実装ラウンドの SSOT(`captions.json` の席と優先順を確定)
5
+ - 前提: [edit.json v1 語レベル演出契約](contract-2026-07-23-edit-json-v1-emphasis-words.md)
6
+ - スコープ: `captions.json` の object ルートに置くトップレベル任意フィールド
7
+ `emphasis_words[]` と、旧 `edit.json` 席との読取優先順
8
+
9
+ ## 背景
10
+
11
+ 字幕表示に属する語レベル演出は、編集タイムラインではなく字幕の SSOT である `captions.json` 側へ置く。
12
+ `edit.json` v2 から `emphasis_words` を廃止した 2026-08-21 裁定は維持し、v2 の exact-keys に席を
13
+ 戻さない。一方、その裁定の根拠だった fieldtest 60 本の使用 0 件は母集団外の本番リールを含んでおらず、
14
+ 本番リールでは 15 語が実際に使用されていたため、「語レベル演出自体が不要」という根拠は訂正する。
15
+
16
+ ## 1. 席
17
+
18
+ `captions.json` が object ルートのときだけ、トップレベルに `emphasis_words[]` を任意で置ける。
19
+
20
+ ```jsonc
21
+ {
22
+ "emphasis_words": [
23
+ {
24
+ "id": "e-0001",
25
+ "src": "main",
26
+ "t_start": 12.08,
27
+ "t_end": 12.44,
28
+ "word": "最高",
29
+ "emotion": "joy",
30
+ "style_hint": "size-pulse"
31
+ }
32
+ ],
33
+ "captions": [ /* 既存のまま */ ]
34
+ }
35
+ ```
36
+
37
+ 従来の配列ルート(`[{ ...caption... }]`)にはトップレベルのフィールドを置けないため、
38
+ `emphasis_words[]` の席はない。語レベル演出を書く場合は object ルートへ移し、既存配列を
39
+ `captions[]` に包む。
40
+
41
+ ## 2. レコード契約と差分
42
+
43
+ レコード形、フィールド語彙、source 秒アンカー、実測 word-level タイムスタンプを写す規律、
44
+ 選定規律、劣化規約はすべて
45
+ [edit.json v1 語レベル演出契約](contract-2026-07-23-edit-json-v1-emphasis-words.md) と同じである。
46
+ 本契約が定める差分は次の 2 点だけである。
47
+
48
+ 1. 配列の置き場を `edit.json.emphasis_words` から、object ルートの
49
+ `captions.json.emphasis_words` へ移す。
50
+ 2. `edit.json` v2 へは書かない。v0/v1 の旧席は後方互換の読取専用として残す。
51
+
52
+ したがって各レコードは `{ id, word, emotion, src?, t_start, t_end, style_hint? }` で、`id` は
53
+ `^e-\d{4}$` かつファイル内一意、`word` と `emotion` は空でない文字列、時刻は source 秒で
54
+ `0 <= t_start < t_end` とする。`emotion` は `joy` / `pain` / `surprise` / `anger` / `sadness` /
55
+ `emphasis` を標準語彙として使うが、v1 契約どおり enum 強制はしない。`style_hint` も描画側への
56
+ 提案に留まる。
57
+
58
+ ## 3. 読取優先順
59
+
60
+ 消費側は次の優先順で 1 つの席だけを読む。
61
+
62
+ 1. object ルートの `captions.json` に `emphasis_words` キーが在れば、その値を採用する。
63
+ 2. キーが無ければ、v0/v1 の `edit.json.emphasis_words` を後方互換として読む。
64
+ 3. どちらにも無ければ語レベル演出なしとする。
65
+
66
+ 両方の席が在る場合もマージしない。`captions.json` 側だけを採用し、警告は出さない。これにより
67
+ 新しい字幕 SSOT が常に優先される一方、`captions.json` に席を持たない既存 v0/v1 プロジェクトは
68
+ 従来どおり `edit.json` 側だけで描画され、回帰しない。
69
+
70
+ ## 4. 検証責務
71
+
72
+ `captions.schema.json` は object ルートだけに任意の `emphasis_words[]` を定義し、要素の必須項目、
73
+ 型、`id` 形式、非負時刻、非空の `word` / `emotion` を検査する。JSON Schema 標準では兄弟値を
74
+ 比較できない `t_end > t_start` と、配列内の `id` 一意性は `validate-captions.mjs` が検査する。
75
+ 語と `captions[].words[]` の実測値の突き合わせは v1 契約と同じく書き手の規律であり、静的検証では
76
+ 行わない。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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. Run them from a full AKARI Video app installation (normally ~/.akari/app) or a monorepo checkout.]",
5
5
  "type": "module",
6
6
  "files": [
@@ -254,6 +254,7 @@ export async function lintProject(input, options = {}) {
254
254
  validateCutTransformFields(edit.cuts, findings);
255
255
  validateStillImageCuts(edit, findings);
256
256
  const cutTrackSegments = computeCutTrackSegments(edit.cuts);
257
+ validateTransitionAdjacency(edit.cuts, cutTrackSegments, edit.fps, findings);
257
258
  for (const segment of findTrackOverlaps(cutTrackSegments)) {
258
259
  if (isDeclaredTransitionOverlap(edit.cuts, cutTrackSegments, segment, edit.fps)) continue;
259
260
  addFinding(findings, {
@@ -313,6 +314,28 @@ export async function lintProject(input, options = {}) {
313
314
  return writeResult(findings, skipped, inputs, paths, options);
314
315
  }
315
316
 
317
+ function validateTransitionAdjacency(cuts, segments, fps, findings) {
318
+ for (let position = 0; position < segments.length; position += 1) {
319
+ const earlier = segments[position];
320
+ const transition = cuts?.[earlier.index]?.transition_out;
321
+ if (!isRecord(transition) || !isPositiveNumber(transition.duration)) continue;
322
+ const later = segments.slice(position + 1).find(candidate => candidate.track === earlier.track);
323
+ if (!later || later.start <= earlier.end) continue;
324
+ if (areCutsAdjacent(
325
+ { tlEnd: earlier.end, transitionOut: { duration: transition.duration } },
326
+ { tlStart: later.start },
327
+ fps,
328
+ )) continue;
329
+ addFinding(findings, {
330
+ severity: "error",
331
+ check: "cuts.transition-out.non-adjacent",
332
+ message: "transition_out の次のクリップとの間にすき間があります。すき間を詰めるか、トランジションを削除してください。",
333
+ path: `edit.json#cuts[${earlier.index}].transition_out`,
334
+ range: { start: earlier.end, end: later.start },
335
+ });
336
+ }
337
+ }
338
+
316
339
  function projectAudioForLint(internalEdit) {
317
340
  const sfx = [];
318
341
  const narration = [];
@@ -1314,13 +1337,8 @@ export function validateTrackTransitionOutCompatibility(edit, findings) {
1314
1337
  severity: "error",
1315
1338
  check: "cuts.track-transition-unsupported",
1316
1339
  message:
1317
- `cuts[].transition_out is declared on track ${trackRef}, which timeline.tracks composites through the `
1318
- + `gap-aware track engine. That engine treats adjacent same-track cuts as separate, non-overlapping `
1319
- + `windows, so it cannot represent an xfade's intentional overlap -- the composited window and the `
1320
- + `actually-shrunk clip diverge, and content disappears early (verified with a real render: the base `
1321
- + `track's background visibly leaked through where the dissolved clip should still have been `
1322
- + `playing). Remove transition_out from this track's cuts, or drop the custom timeline.tracks order `
1323
- + `for this track so it renders through the plain sequential path instead.`,
1340
+ `映像トラック ${trackRef} の transition_out は、PiP または複数トラックを合成する方式では書き出せません。`
1341
+ + `トランジションを削除するか、映像を単一の cuts トラックへ戻してください。`,
1324
1342
  path: `edit.json#cuts[${cutIndex}]`,
1325
1343
  });
1326
1344
  }
@@ -11,7 +11,7 @@ export interface EditCut {
11
11
  opacity?: number;
12
12
  speed?: number;
13
13
  transitionOut?: {
14
- type: 'dissolve' | 'fade-black' | 'fade-white';
14
+ type: 'dissolve' | 'fade-black' | 'fade-white' | 'reveal-down' | 'reveal-up';
15
15
  duration: number;
16
16
  };
17
17
  at?: number;
@@ -140,7 +140,7 @@ export declare function updateCutTransformInSource(source: string, cutIndex: num
140
140
  }): string;
141
141
  export declare function updateCutOpacityInSource(source: string, cutIndex: number, opacity: number | null): string;
142
142
  export declare function setCutTransitionOutInSource(source: string, cutIndex: number, transitionOut: {
143
- type: 'dissolve' | 'fade-black' | 'fade-white';
143
+ type: 'dissolve' | 'fade-black' | 'fade-white' | 'reveal-down' | 'reveal-up';
144
144
  duration: number;
145
145
  } | null): string;
146
146
  export declare function reorderCutsInSource(source: string, fromIndex: number, toIndex: number): string;
@@ -318,7 +318,9 @@ function updateCutOpacityInSource(source, cutIndex, opacity) {
318
318
  }
319
319
  function setCutTransitionOutInSource(source, cutIndex, transitionOut) {
320
320
  if (transitionOut !== null) {
321
- if (transitionOut.type !== 'dissolve' && transitionOut.type !== 'fade-black' && transitionOut.type !== 'fade-white') {
321
+ if (transitionOut.type !== 'dissolve' && transitionOut.type !== 'fade-black'
322
+ && transitionOut.type !== 'fade-white' && transitionOut.type !== 'reveal-down'
323
+ && transitionOut.type !== 'reveal-up') {
322
324
  throw new Error('トランジションの種別が不正です。');
323
325
  }
324
326
  if (!Number.isFinite(transitionOut.duration) || transitionOut.duration <= 0) {
@@ -11,6 +11,7 @@ export * from './caption-display';
11
11
  export * from './edit-v2';
12
12
  export * from './edit-v2-item-write';
13
13
  export * from './internal-model';
14
+ export * from './legacy-audio-view';
14
15
  export * from './retime';
15
16
  export * from './track-order';
16
17
  export * from './track-transition-compatibility';
@@ -28,6 +28,7 @@ __exportStar(require("./caption-display"), exports);
28
28
  __exportStar(require("./edit-v2"), exports);
29
29
  __exportStar(require("./edit-v2-item-write"), exports);
30
30
  __exportStar(require("./internal-model"), exports);
31
+ __exportStar(require("./legacy-audio-view"), exports);
31
32
  __exportStar(require("./retime"), exports);
32
33
  __exportStar(require("./track-order"), exports);
33
34
  __exportStar(require("./track-transition-compatibility"), exports);
@@ -121,8 +121,13 @@ function readV2Internal(raw) {
121
121
  // 推測(旧 mainVisualTrackId)自体を撤去した。media アイテムの旧種別は常に 'cuts'
122
122
  // (= layers 相当の見た目・機能も含めて描ける唯一の経路)。'layers' に残るのは、
123
123
  // まだ cuts 経路へ移していない機能(非 normal blend の合成時ブレンド演算・
124
- // アニメーションする perspective)を宣言するアイテムだけ(needsLayersEngine 参照。
125
- // これも段ではなくアイテム自身の宣言だけで決まる)。
124
+ // アニメーションする perspective)を宣言するアイテムと、cuts の構造では同時表示できない
125
+ // 時間重なりだけ(needsLayersEngine / computeOverlappingItemIds 参照)。
126
+ // cuts の winner-take-all 経路では、同一トラック内だけでなく別 visual トラック間の
127
+ // 同時表示も表現できない。先に全 visual media アイテムを横断して区間交差を求め、
128
+ // 参加したアイテムを各トラックの build へ同じ集合として渡す。これにより preview / render
129
+ // の両方が、トラックごとに独立した要素を合成できる layers 経路を選ぶ。
130
+ const overlappingItemIds = computeOverlappingItemIds(edit.tracks.flatMap(track => 'items' in track && track.lane === 'visual' ? [track.items] : []));
126
131
  const tracks = edit.tracks.map(track => {
127
132
  // P0 2026-08-21 render-path-unification (実測で発覚): 'cuts' 経路(concat チェーン)は
128
133
  // 同じトラック上の複数アイテムを「順番に連結される別セグメント」として扱う構造的前提を
@@ -140,8 +145,6 @@ function readV2Internal(raw) {
140
145
  // 倒れているのに track 自体は 'cuts' 名乗ったままになり、usesDefaultInternalTrackOrder が
141
146
  // 無関係に buildTrackStackPlan(実際には不要な余分なエンコード世代)へ倒れてしまう
142
147
  // (非回帰監査で実測: pip-perspective-crop-check が本来要らない track_stack を経由していた)。
143
- const overlappingItemIds = 'items' in track && track.lane === 'visual'
144
- ? computeOverlappingItemIds(track.items) : new Set();
145
148
  const kind = legacyKindOfV2Track(track, chromaKeyOf, overlappingItemIds);
146
149
  const ref = kind === 'captions' ? undefined : nextRef(refCounters, kind);
147
150
  const items = [];
@@ -206,13 +209,15 @@ function legacyKindOfV2Track(track, chromaKeyOf, overlappingItemIds) {
206
209
  // 'layers' は別カウンタなので、空トラックの存在が実クリップの分類・ref に影響しない
207
210
  // (P0 2026-08-20 track-identity-and-duration r1 で踏んだのと同じ罠)。
208
211
  default: return first === undefined
212
+ || track.items.some(item => overlappingItemIds.has(item.id))
209
213
  || needsLayersEngine(first, chromaKeyOf, overlappingItemIds.has(first.id))
210
214
  ? 'layers' : 'cuts';
211
215
  }
212
216
  }
213
217
  // P0 2026-08-21 render-path-unification: cuts 経路(packages/render-cut/src/cut-transform.mjs)へ
214
- // まだ移していない機能を宣言する media アイテムだけが 'layers' に残る。段(トラック)は一切見ない
215
- // — アイテム自身の宣言だけで決まるので、移動しても判定は変わらない。
218
+ // まだ移していない機能を宣言する media アイテム、または computeOverlappingItemIds が
219
+ // 同時表示のために指定した media アイテムだけが 'layers' に残る。機能判定自体は段を見ないが、
220
+ // 重なり判定は宣言された時刻とトラックの z 関係を使う。
216
221
  // - blend: 'normal' 以外は合成時(前面までに何があるか)に依存するブレンド演算が要り、
217
222
  // それは packages/render-cut/src/layers.mjs にしか実装が無い
218
223
  // - perspective keyframes: ffmpeg の perspective フィルタはフレームごとの式評価に対応しないため、
@@ -253,66 +258,60 @@ function needsLayersEngine(item, chromaKeyOf, hasOverlappingSibling = false) {
253
258
  return true;
254
259
  return false;
255
260
  }
256
- // P0 2026-08-21 render-path-unification: 'cuts' 経路は同一トラック上の複数アイテムを
257
- // 「順に連結される別セグメント」として扱えるだけで、時間的に重なる(同時に映る)複数アイテムは
258
- // 表現できない(buildMultiSourceCutCommand の concat 前提。needsLayersEngine 自身のコメント参照)。
259
- // このトラックの items[] を総当りで比較し、他のどれかと時間区間が重なる media アイテムの id を
260
- // 集める。
261
+ // P0 2026-08-23 cuts-cross-track-overlap: 'cuts' 経路は同一トラック内の同時表示だけでなく、
262
+ // 別トラック間の同時表示も winner-take-all になり、下段を描画できない。全 visual トラックの
263
+ // items[] を総当りで比較し、半開区間 [at, at+duration) が正の長さで交差する media アイテムを
264
+ // layers へ退避する。終端と開始が接するだけなら交差ではない。同一トラックの交差は両方を
265
+ // layers へ送る。別トラックの交差は、下段を cuts の不透明な基底として残し、上段が全画面不透明
266
+ // ではない宣言を持つときだけ layers へ送る。上段が全画面不透明なら winner-take-all でも同じ絵に
267
+ // なるため、従来の cuts / track-stack 経路を保つ。
261
268
  //
262
- // r2(合流前ゲート検収 REJECT・実測 204/205 で発見): 判定を「at/duration がわずかでも交差したら
263
- // 重なり」から「at と duration が完全一致(同一の開始・同一の尺 = 完全に同一の時間区間)」へ
264
- // 絞った。理由: apps/shell/extensions/akari-annotations の insertCutIntoEdit
265
- // (timeline-material-insert.ts)は sequential モードでの挿入時、**既存アイテムの at を
266
- // 再計算しない**という明示契約を持つ(同ファイル自身のコメント参照)。そのため、タイムライン
267
- // 中間へドラッグ挿入すると、新規挿入アイテムの at(挿入直前のアイテムの終端 = 挿入前は後続
268
- // アイテムの at と同じ位置)と、まだ古い at のままの後続アイテムが、同じ at で始まる**部分的な**
269
- // 重なりを起こす(例: 新規 at=120/duration=60 と、後続の古い at=120/duration=90 —
270
- // 実測: insertCutIntoEdit で cut-1[0,120) → cut-3[120,180) → cut-2[120,210) という配列になる)。
271
- // これは「本当に同時に映る PiP」ではなく、単に配列順で連結されるはずの 2 アイテムの片方の at が
272
- // まだ更新されていないだけ(insertCutIntoEdit の『at 不変・配列順が正』という契約どおりの、
273
- // 一時的に不正確な at)。v2 の at/duration はどちらも常に必須の整数フレーム値で、
274
- // 「宣言された絶対配置」と「まだ書き戻されていない sequential 連結」を区別する情報が
275
- // スキーマ上に残らないため、両者を汎用に見分けることはできない。一方、実際に 'layers' への
276
- // 退避が必要だと判明している唯一の実例(fieldtest/2026-08-06-pip-perspective-crop-check の
277
- // pip-crop-demo/pip-perspective-demo、いずれも at=0・duration=240 で完全同一区間)は、
278
- // 常に完全一致(同じ開始・同じ尺)のケースだった(このファイル自身の発見コメント・
279
- // packages/edit-store/test/internal-model.test.mjs の該当テスト名 "fully overlapping
280
- // at/duration" も参照)。完全一致のみを重なりとみなすことで、実証済みの本物の重なりは
281
- // 引き続き検出しつつ、insertCutIntoEdit の sequential 挿入という日常操作を誤検知しなくなる。
282
- function computeOverlappingItemIds(items) {
269
+ // transition_out は同一トラックの隣接カットが意図的に作る狭い重なりで、cuts/xfade が表現する。
270
+ // その例外は同一トラックのペアだけに適用する。別トラックとの交差は transition の有無にかかわらず
271
+ // 真の同時表示なので layers へ退避する。
272
+ function computeOverlappingItemIds(itemGroups) {
283
273
  const overlapping = new Set();
284
- for (let i = 0; i < items.length; i++) {
285
- const a = items[i];
274
+ const entries = itemGroups.flatMap((items, trackIndex) => items.map(item => ({ item, trackIndex })));
275
+ for (let i = 0; i < entries.length; i++) {
276
+ const { item: a, trackIndex: aTrackIndex } = entries[i];
286
277
  if (a.source.kind !== 'media')
287
278
  continue;
288
- for (let j = i + 1; j < items.length; j++) {
289
- const b = items[j];
279
+ for (let j = i + 1; j < entries.length; j++) {
280
+ const { item: b, trackIndex: bTrackIndex } = entries[j];
290
281
  if (b.source.kind !== 'media')
291
282
  continue;
292
- // r3 (Codex re-review, MINOR): a zero-duration item is an empty interval that can
293
- // never actually be visible on screen at the same instant as anything else, so two
294
- // zero-duration items sharing the same `at` are not a genuine overlap -- require a
295
- // positive duration too, or an empty-interval pair would be forced to 'layers' for
296
- // no real reason.
297
- if (a.at === b.at && a.duration === b.duration && a.duration > 0) {
298
- // cuts[].transition_out (a crossfade into the next cut) is a DELIBERATE, narrow
299
- // overlap between two otherwise-sequential same-track items -- the concat engine's
300
- // own xfade support (packages/render-cut/src/plan.mjs) already represents this
301
- // correctly, so it must not be caught by this "cuts can't represent overlap" rule
302
- // (only a genuine simultaneous-PiP overlap, with no transition_out involved at
303
- // all, structurally can't be represented by concat). Declaring transition_out on
304
- // either item in an overlapping pair is enough to exclude it: a real simultaneous
305
- // PiP overlay never declares transition_out (it has no "next clip" to transition
306
- // into within the same track).
307
- if (a.source.transition_out !== undefined || b.source.transition_out !== undefined)
308
- continue;
283
+ if (!(a.at < b.at + b.duration && b.at < a.at + a.duration))
284
+ continue;
285
+ const sameTrack = aTrackIndex === bTrackIndex;
286
+ if (sameTrack
287
+ && (a.source.transition_out !== undefined || b.source.transition_out !== undefined))
288
+ continue;
289
+ if (sameTrack) {
309
290
  overlapping.add(a.id);
310
291
  overlapping.add(b.id);
311
292
  }
293
+ else {
294
+ const upper = aTrackIndex > bTrackIndex ? a : b;
295
+ if (needsCrossTrackLayers(upper))
296
+ overlapping.add(upper.id);
297
+ }
312
298
  }
313
299
  }
314
300
  return overlapping;
315
301
  }
302
+ // cuts の winner-take-all が下段を隠してよいのは、上段が全画面を不透明に覆う場合だけ。
303
+ // transform の単位元を明示しただけなら従来経路を維持する。crop / 半透明 / keyframes は、
304
+ // 現在または途中フレームで下段が見える可能性があるため宣言の存在だけで layers へ退避する。
305
+ function needsCrossTrackLayers(item) {
306
+ const transform = item.transform;
307
+ return (transform?.scale !== undefined && transform.scale !== 1)
308
+ || (transform?.x !== undefined && transform.x !== 0)
309
+ || (transform?.y !== undefined && transform.y !== 0)
310
+ || (transform?.rotate !== undefined && transform.rotate !== 0)
311
+ || item.crop !== undefined
312
+ || (item.opacity !== undefined && item.opacity < 1)
313
+ || item.keyframes !== undefined;
314
+ }
316
315
  function nextRef(counters, kind) {
317
316
  const ref = counters.get(kind) ?? 0;
318
317
  counters.set(kind, ref + 1);
@@ -0,0 +1,16 @@
1
+ import type { InternalEdit } from './internal-model';
2
+ export type LegacyAudioDeclaration = Record<string, unknown>;
3
+ /**
4
+ * tracks-first の内部表現を、旧 audio 消費者が読む形へ射影したビュー。
5
+ * 生の edit.json.audio は参照せず、同じ宣言を二重に列挙しない。
6
+ */
7
+ export interface LegacyAudioView {
8
+ bgm?: LegacyAudioDeclaration;
9
+ sfx: LegacyAudioDeclaration[];
10
+ narration: LegacyAudioDeclaration[];
11
+ }
12
+ /**
13
+ * 内部表現の audio item だけから legacy audio 形を組み立てる純関数。
14
+ * render-cut の互換射影と同じく legacy.index 順で処理し、bgm は単数として後勝ちにする。
15
+ */
16
+ export declare function projectLegacyAudioView(internal: InternalEdit): LegacyAudioView;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.projectLegacyAudioView = projectLegacyAudioView;
4
+ /**
5
+ * 内部表現の audio item だけから legacy audio 形を組み立てる純関数。
6
+ * render-cut の互換射影と同じく legacy.index 順で処理し、bgm は単数として後勝ちにする。
7
+ */
8
+ function projectLegacyAudioView(internal) {
9
+ const ordered = internal.tracks
10
+ .flatMap(track => track.items)
11
+ .filter(item => item.legacy.collection === 'sfx'
12
+ || item.legacy.collection === 'narration'
13
+ || item.legacy.collection === 'bgm')
14
+ .sort((left, right) => left.legacy.index - right.legacy.index);
15
+ const sfx = [];
16
+ const narration = [];
17
+ let bgm;
18
+ for (const item of ordered) {
19
+ if (item.legacy.value === undefined)
20
+ continue;
21
+ const declaration = projectAudioDeclaration(item);
22
+ switch (item.legacy.collection) {
23
+ case 'sfx':
24
+ sfx.push(declaration);
25
+ break;
26
+ case 'narration':
27
+ narration.push(declaration);
28
+ break;
29
+ case 'bgm':
30
+ bgm = declaration;
31
+ break;
32
+ default:
33
+ break;
34
+ }
35
+ }
36
+ return {
37
+ ...(bgm !== undefined ? { bgm } : {}),
38
+ sfx,
39
+ narration
40
+ };
41
+ }
42
+ // Internal legacy values use camelCase display fields. Legacy audio consumers retain the
43
+ // historical JSON spelling for gain_db while declaration-only compatibility fields stay intact.
44
+ function projectAudioDeclaration(item) {
45
+ const value = item.legacy.value;
46
+ if (item.source.kind === 'media' && item.source.sourceId === undefined && isRecord(item.declaration)) {
47
+ return {
48
+ ...item.declaration,
49
+ ...(value.gainDb !== undefined ? { gain_db: value.gainDb } : {})
50
+ };
51
+ }
52
+ return {
53
+ ...(isRecord(item.declaration) ? item.declaration : {}),
54
+ ...value,
55
+ ...(value.gainDb !== undefined ? { gain_db: value.gainDb } : {})
56
+ };
57
+ }
58
+ function isRecord(value) {
59
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
60
+ }
@@ -33,6 +33,12 @@ export interface MigrationProposal {
33
33
  nextText: string;
34
34
  previousText: string;
35
35
  backupPath: string;
36
+ captions?: {
37
+ filePath: string;
38
+ nextText: string;
39
+ previousText: string;
40
+ backupPath: string;
41
+ };
36
42
  }
37
43
  export interface MigrationBlocked {
38
44
  ok: false;
@@ -47,7 +53,7 @@ export declare function planMigration(projectRoot: string, editPath: string, tex
47
53
  hasCaptions?: boolean;
48
54
  now?: Date;
49
55
  }): MigrationProposal | MigrationBlocked;
50
- /** 承認後のみ実行する。先に .akari/backup/ へ原文を退避し、次に atomic rename する。 */
56
+ /** 承認後のみ実行する。先に全原文を .akari/backup/ へ退避し、次に atomic rename する。 */
51
57
  export declare function applyMigration(proposal: MigrationProposal): Promise<void>;
52
- /** 退避した原文を 1 手で edit.json へ戻す。backup 自体は監査記録として残す。 */
58
+ /** 退避した原文を 1 手で edit.json / captions.json へ戻す。backup 自体は監査記録として残す。 */
53
59
  export declare function revertMigration(proposal: MigrationProposal): Promise<void>;