akari-video 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
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": [
@@ -20,6 +20,8 @@ import { resolveFfmpeg, resolveFfprobe } from "../../media-bin/src/index.mjs";
20
20
 
21
21
  const {
22
22
  areCutsAdjacent,
23
+ cutOverlapFrames,
24
+ findCrossTrackLayerEvacuations,
23
25
  findUnsupportedDeclaredTrackTransitions,
24
26
  projectLegacyEdit,
25
27
  readInternalEdit,
@@ -135,6 +137,7 @@ export async function lintProject(input, options = {}) {
135
137
  const rawEdit = edit;
136
138
  const internalEdit = readInternalEdit(rawEdit);
137
139
  const legacyEdit = projectLegacyEdit(internalEdit);
140
+ validateTransitionLayerEvacuations(rawEdit, internalEdit, findings);
138
141
  const rawAudio = isRecord(rawEdit.audio) ? rawEdit.audio : {};
139
142
  const projectedAudio = projectAudioForLint(internalEdit);
140
143
  edit = {
@@ -325,8 +328,24 @@ function validateTransitionAdjacency(cuts, segments, fps, findings) {
325
328
  const transition = cuts?.[earlier.index]?.transition_out;
326
329
  if (!isRecord(transition) || !isPositiveNumber(transition.duration)) continue;
327
330
  const later = segments.slice(position + 1).find(candidate => candidate.track === earlier.track);
328
- if (!later || later.start <= earlier.end) continue;
329
- if (areCutsAdjacent(
331
+ if (!later) continue;
332
+ const overlapFrames = cutOverlapFrames(
333
+ { tlEnd: earlier.end },
334
+ { tlStart: later.start },
335
+ fps,
336
+ );
337
+ if (overlapFrames === 0) {
338
+ addFinding(findings, {
339
+ severity: "warning",
340
+ check: "cuts.transition-out.zero-overlap",
341
+ message: "トランジションを宣言していますが、次のクリップと重なっていないため効きません。前のクリップの終わりを延ばして重なりを作るか、トランジションを削除してください。",
342
+ path: `edit.json#cuts[${earlier.index}].transition_out`,
343
+ range: { start: earlier.end, end: later.start },
344
+ });
345
+ continue;
346
+ }
347
+ // 正の重なりは、宣言尺未満なら既存の短縮クランプ、宣言尺超なら track-overlap が担当する。
348
+ if (overlapFrames > 0 || areCutsAdjacent(
330
349
  { tlEnd: earlier.end, transitionOut: { duration: transition.duration } },
331
350
  { tlStart: later.start },
332
351
  fps,
@@ -341,6 +360,50 @@ function validateTransitionAdjacency(cuts, segments, fps, findings) {
341
360
  }
342
361
  }
343
362
 
363
+ function validateTransitionLayerEvacuations(rawEdit, internalEdit, findings) {
364
+ if (!isRecord(rawEdit) || rawEdit.version !== 2) return;
365
+ const crossTrackCauses = new Map();
366
+ for (const cause of findCrossTrackLayerEvacuations(rawEdit)) {
367
+ if (!crossTrackCauses.has(cause.itemId)) crossTrackCauses.set(cause.itemId, cause);
368
+ }
369
+ const rawLocations = new Map();
370
+ if (Array.isArray(rawEdit.tracks)) {
371
+ rawEdit.tracks.forEach((track, trackIndex) => {
372
+ if (!isRecord(track) || !Array.isArray(track.items)) return;
373
+ track.items.forEach((item, itemIndex) => {
374
+ if (isRecord(item) && typeof item.id === "string") {
375
+ rawLocations.set(item.id, { trackIndex, itemIndex });
376
+ }
377
+ });
378
+ });
379
+ }
380
+ for (const track of internalEdit.tracks) {
381
+ for (const item of track.items) {
382
+ const transition = item.declaration?.transition_out;
383
+ if (item.legacy.collection !== "layers" || !isRecord(transition)) continue;
384
+ const location = rawLocations.get(item.id);
385
+ const cause = crossTrackCauses.get(item.id);
386
+ const reason = cause
387
+ ? `このクリップは他トラックのアイテム(${cause.causeItemId})と重なっているため PiP 経路へ退避され、宣言したトランジションは書き出されません。`
388
+ : "このクリップは合成機能または同一トラック内の重なりにより PiP 経路へ退避され、宣言したトランジションは書き出されません。";
389
+ addFinding(findings, {
390
+ severity: "warning",
391
+ check: "cuts.transition-out.layer-evacuated",
392
+ message: `${reason}重なりを解消するか、トランジションを削除してください。`,
393
+ path: location
394
+ ? `edit.json#tracks[${location.trackIndex}].items[${location.itemIndex}].source.transition_out`
395
+ : `edit.json#tracks[${track.z}].items`,
396
+ ...(cause ? {
397
+ range: {
398
+ start: cause.overlapStartFrames / internalEdit.output.fps,
399
+ end: cause.overlapEndFrames / internalEdit.output.fps,
400
+ },
401
+ } : {}),
402
+ });
403
+ }
404
+ }
405
+ }
406
+
344
407
  function projectAudioForLint(internalEdit) {
345
408
  const sfx = [];
346
409
  const narration = [];
@@ -12,6 +12,31 @@ export interface CutAdjacencyEarlierLike {
12
12
  export interface CutAdjacencyLaterLike {
13
13
  tlStart: number;
14
14
  }
15
+ export declare function effectiveCutFps(fps: number): number;
16
+ /**
17
+ * earlier の終端と later の開始を出力 fps へ量子化し、実重なりをフレーム数で返す。
18
+ * 正数は重なり、0 は突き合わせ、負数はすき間を表す。
19
+ */
20
+ export declare function cutOverlapFrames(earlier: Pick<CutAdjacencyEarlierLike, 'tlEnd'>, later: CutAdjacencyLaterLike, fps?: number): number;
21
+ export type TransitionHandleExtensionOutcome = 'already-overlapping' | 'full' | 'partial' | 'none';
22
+ export interface TransitionHandleExtensionPlan {
23
+ appliedSeconds: number;
24
+ effectiveSeconds: number;
25
+ appliedFrames: number;
26
+ outcome: TransitionHandleExtensionOutcome;
27
+ }
28
+ export interface TransitionHandleExtensionInput {
29
+ declaredSeconds: number;
30
+ earlierEndSeconds: number;
31
+ laterStartSeconds: number;
32
+ maxExtendSeconds: number;
33
+ fps?: number;
34
+ }
35
+ /**
36
+ * 突き合わせ境界へ宣言尺ぶんの重なりを作るため、outgoing を何フレーム延ばすか決める。
37
+ * メディア長は知らず、呼び出し側が maxExtendSeconds として渡した上限だけを使う。
38
+ */
39
+ export declare function planTransitionHandleExtension(input: TransitionHandleExtensionInput): TransitionHandleExtensionPlan;
15
40
  /**
16
41
  * ギャップ 0、または earlier の宣言済み transitionOut.duration で説明できる重なりだけを
17
42
  * 隣接とみなす。秒の誤差ではなく、出力 fps で量子化したフレーム数を比較する。
@@ -4,20 +4,63 @@
4
4
  * タイムライン UI と edit-lint は必ずこの関数を使い、フレーム量子化の条件式を複製しない。
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.effectiveCutFps = effectiveCutFps;
8
+ exports.cutOverlapFrames = cutOverlapFrames;
9
+ exports.planTransitionHandleExtension = planTransitionHandleExtension;
7
10
  exports.areCutsAdjacent = areCutsAdjacent;
8
11
  const DEFAULT_CUT_ADJACENCY_FPS = 30;
9
- function effectiveFps(fps) {
12
+ function effectiveCutFps(fps) {
10
13
  return Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_CUT_ADJACENCY_FPS;
11
14
  }
15
+ /**
16
+ * earlier の終端と later の開始を出力 fps へ量子化し、実重なりをフレーム数で返す。
17
+ * 正数は重なり、0 は突き合わせ、負数はすき間を表す。
18
+ */
19
+ function cutOverlapFrames(earlier, later, fps = DEFAULT_CUT_ADJACENCY_FPS) {
20
+ const resolvedFps = effectiveCutFps(fps);
21
+ return Math.round(earlier.tlEnd * resolvedFps) - Math.round(later.tlStart * resolvedFps);
22
+ }
23
+ /**
24
+ * 突き合わせ境界へ宣言尺ぶんの重なりを作るため、outgoing を何フレーム延ばすか決める。
25
+ * メディア長は知らず、呼び出し側が maxExtendSeconds として渡した上限だけを使う。
26
+ */
27
+ function planTransitionHandleExtension(input) {
28
+ const fps = effectiveCutFps(input.fps ?? DEFAULT_CUT_ADJACENCY_FPS);
29
+ const declaredFrames = Number.isFinite(input.declaredSeconds) && input.declaredSeconds > 0
30
+ ? Math.max(1, Math.round(input.declaredSeconds * fps)) : 0;
31
+ const overlapFrames = cutOverlapFrames({ tlEnd: input.earlierEndSeconds }, { tlStart: input.laterStartSeconds }, fps);
32
+ if (overlapFrames > 0) {
33
+ return {
34
+ appliedSeconds: 0,
35
+ effectiveSeconds: Math.min(declaredFrames, overlapFrames) / fps,
36
+ appliedFrames: 0,
37
+ outcome: 'already-overlapping'
38
+ };
39
+ }
40
+ const maximumFrames = input.maxExtendSeconds === Number.POSITIVE_INFINITY
41
+ ? Number.POSITIVE_INFINITY
42
+ : Number.isFinite(input.maxExtendSeconds) && input.maxExtendSeconds > 0
43
+ ? Math.max(0, Math.floor(input.maxExtendSeconds * fps + 1e-9)) : 0;
44
+ // すき間(負の overlapFrames)がある場合は、その穴を埋めたうえで宣言尺ぶん重ねる。
45
+ // UI は非隣接ガードでこの経路へ入れないが、純関数としては安全な値を返す。
46
+ const requiredFrames = Math.max(0, declaredFrames - overlapFrames);
47
+ const appliedFrames = Math.min(requiredFrames, maximumFrames);
48
+ const effectiveFrames = Math.max(0, Math.min(declaredFrames, overlapFrames + appliedFrames));
49
+ return {
50
+ appliedSeconds: appliedFrames / fps,
51
+ effectiveSeconds: effectiveFrames / fps,
52
+ appliedFrames,
53
+ outcome: appliedFrames <= 0 ? 'none'
54
+ : appliedFrames >= requiredFrames ? 'full' : 'partial'
55
+ };
56
+ }
12
57
  /**
13
58
  * ギャップ 0、または earlier の宣言済み transitionOut.duration で説明できる重なりだけを
14
59
  * 隣接とみなす。秒の誤差ではなく、出力 fps で量子化したフレーム数を比較する。
15
60
  */
16
61
  function areCutsAdjacent(earlier, later, fps = DEFAULT_CUT_ADJACENCY_FPS) {
17
- const resolvedFps = effectiveFps(fps);
18
- const earlierEndFrame = Math.round(earlier.tlEnd * resolvedFps);
19
- const laterStartFrame = Math.round(later.tlStart * resolvedFps);
20
- const overlapFrames = earlierEndFrame - laterStartFrame;
62
+ const resolvedFps = effectiveCutFps(fps);
63
+ const overlapFrames = cutOverlapFrames(earlier, later, resolvedFps);
21
64
  const declaredDuration = earlier.transitionOut?.duration;
22
65
  const declaredOverlapFrames = typeof declaredDuration === 'number'
23
66
  && Number.isFinite(declaredDuration) && declaredDuration > 0
@@ -1,3 +1,4 @@
1
+ import { type TransitionHandleExtensionPlan } from './cut-adjacency';
1
2
  export interface EditCut {
2
3
  in: number;
3
4
  out: number;
@@ -143,6 +144,26 @@ export declare function setCutTransitionOutInSource(source: string, cutIndex: nu
143
144
  type: 'dissolve' | 'fade-black' | 'fade-white' | 'reveal-down' | 'reveal-up';
144
145
  duration: number;
145
146
  } | null): string;
147
+ export interface SetV2TransitionOutWithHandleInput {
148
+ itemId: string;
149
+ transitionOut: {
150
+ type: 'dissolve' | 'fade-black' | 'fade-white' | 'reveal-down' | 'reveal-up';
151
+ duration: number;
152
+ };
153
+ earlierEndSeconds: number;
154
+ laterStartSeconds: number;
155
+ maxExtendSeconds: number;
156
+ fps: number;
157
+ }
158
+ export interface SetV2TransitionOutWithHandleResult {
159
+ source: string;
160
+ plan: TransitionHandleExtensionPlan;
161
+ }
162
+ /**
163
+ * v2 の transition_out 宣言と outgoing の source.out / duration 延長を 1 回の
164
+ * byte-preserving 手術で行う。maxExtendSeconds は出力秒軸の上限で、素材実尺は呼び出し側が決める。
165
+ */
166
+ export declare function setV2TransitionOutWithHandleInSource(source: string, input: SetV2TransitionOutWithHandleInput): SetV2TransitionOutWithHandleResult;
146
167
  export declare function reorderCutsInSource(source: string, fromIndex: number, toIndex: number): string;
147
168
  export declare function splitCutInSource(source: string, cutIndex: number, atSeconds: number): string;
148
169
  export declare function deleteCutInSource(source: string, cutIndex: number): {
@@ -9,6 +9,7 @@ exports.setCutSpeedInSource = setCutSpeedInSource;
9
9
  exports.updateCutTransformInSource = updateCutTransformInSource;
10
10
  exports.updateCutOpacityInSource = updateCutOpacityInSource;
11
11
  exports.setCutTransitionOutInSource = setCutTransitionOutInSource;
12
+ exports.setV2TransitionOutWithHandleInSource = setV2TransitionOutWithHandleInSource;
12
13
  exports.reorderCutsInSource = reorderCutsInSource;
13
14
  exports.splitCutInSource = splitCutInSource;
14
15
  exports.deleteCutInSource = deleteCutInSource;
@@ -37,6 +38,7 @@ exports.removeOverlayInSource = removeOverlayInSource;
37
38
  exports.writeTimelineTracksInSource = writeTimelineTracksInSource;
38
39
  exports.updateArrayElementByIndex = updateArrayElementByIndex;
39
40
  exports.updateOverlayVarInSource = updateOverlayVarInSource;
41
+ const cut_adjacency_1 = require("./cut-adjacency");
40
42
  const JSON_NUMBER = '-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?';
41
43
  const LAYER_BLEND_MODES = [
42
44
  'normal', 'screen', 'multiply', 'add', 'difference',
@@ -348,6 +350,104 @@ function setCutTransitionOutInSource(source, cutIndex, transitionOut) {
348
350
  }
349
351
  });
350
352
  }
353
+ /**
354
+ * v2 の transition_out 宣言と outgoing の source.out / duration 延長を 1 回の
355
+ * byte-preserving 手術で行う。maxExtendSeconds は出力秒軸の上限で、素材実尺は呼び出し側が決める。
356
+ */
357
+ function setV2TransitionOutWithHandleInSource(source, input) {
358
+ const raw = JSON.parse(source);
359
+ if (raw.version !== 2) {
360
+ throw new Error('v2 へ変換してから編集してください。');
361
+ }
362
+ assertTransitionOutValue(input.transitionOut);
363
+ if (!input.itemId) {
364
+ throw new Error('トランジション対象のアイテム id が空です。');
365
+ }
366
+ const tracks = locateArray(source, 'tracks');
367
+ const trackElements = splitTopLevelElements(tracks.inner);
368
+ const matches = [];
369
+ for (const track of trackElements) {
370
+ let items;
371
+ try {
372
+ items = locateArray(track.text, 'items');
373
+ }
374
+ catch {
375
+ continue;
376
+ }
377
+ const item = splitTopLevelElements(items.inner)
378
+ .find(candidate => readStringProperty(candidate.text, 'id') === input.itemId);
379
+ if (item)
380
+ matches.push({ track, items, item });
381
+ }
382
+ if (matches.length !== 1) {
383
+ throw new Error(matches.length === 0
384
+ ? `アイテム ${input.itemId} が見つかりません`
385
+ : `アイテム ${input.itemId} が複数あります`);
386
+ }
387
+ const match = matches[0];
388
+ const label = `アイテム ${input.itemId}`;
389
+ const durationFrames = readNumberProperty(match.item.text, 'duration', label);
390
+ const mediaSource = locateTopLevelObjectProperty(match.item.text, 'source');
391
+ if (readStringProperty(mediaSource.text, 'kind') !== 'media') {
392
+ throw new Error(`${label} は映像素材ではありません。`);
393
+ }
394
+ const sourceIn = readNumberProperty(mediaSource.text, 'in', label);
395
+ const sourceOut = readNumberProperty(mediaSource.text, 'out', label);
396
+ const outputDurationSeconds = durationFrames / input.fps;
397
+ const speed = (sourceOut - sourceIn) / outputDurationSeconds;
398
+ // 暗黙 speed を維持できない極端値は、素材窓を壊すより宣言だけを残す保守側へ倒す。
399
+ // 実用域外(1e-6 未満 / 1e6 超)の比率も浮動小数の増分が不安定なので延長しない。
400
+ const safeSpeed = Number.isFinite(speed) && speed >= 1e-6 && speed <= 1e6;
401
+ const plan = (0, cut_adjacency_1.planTransitionHandleExtension)({
402
+ declaredSeconds: input.transitionOut.duration,
403
+ earlierEndSeconds: input.earlierEndSeconds,
404
+ laterStartSeconds: input.laterStartSeconds,
405
+ maxExtendSeconds: safeSpeed ? input.maxExtendSeconds : 0,
406
+ fps: input.fps
407
+ });
408
+ let nextMediaSource = writeTransitionOutProperty(mediaSource.text, input.transitionOut);
409
+ if (plan.appliedFrames > 0) {
410
+ const nextOut = sourceOut + plan.appliedSeconds * speed;
411
+ if (!Number.isFinite(nextOut)) {
412
+ throw new Error(`${label} の out を安全に延長できません。`);
413
+ }
414
+ nextMediaSource = replaceNumberProperty(nextMediaSource, 'out', nextOut, label);
415
+ }
416
+ let nextItem = match.item.text.slice(0, mediaSource.start)
417
+ + nextMediaSource
418
+ + match.item.text.slice(mediaSource.end);
419
+ if (plan.appliedFrames > 0) {
420
+ nextItem = replaceNumberProperty(nextItem, 'duration', durationFrames + plan.appliedFrames, label);
421
+ }
422
+ const nextTrack = replaceElement(match.track.text, match.items.openIndex + 1, match.item, nextItem);
423
+ return {
424
+ source: replaceElement(source, tracks.openIndex + 1, match.track, nextTrack),
425
+ plan
426
+ };
427
+ }
428
+ function assertTransitionOutValue(transitionOut) {
429
+ if (transitionOut.type !== 'dissolve' && transitionOut.type !== 'fade-black'
430
+ && transitionOut.type !== 'fade-white' && transitionOut.type !== 'reveal-down'
431
+ && transitionOut.type !== 'reveal-up') {
432
+ throw new Error('トランジションの種別が不正です。');
433
+ }
434
+ if (!Number.isFinite(transitionOut.duration) || transitionOut.duration <= 0) {
435
+ throw new Error('トランジションの尺は正の数で指定してください。');
436
+ }
437
+ }
438
+ function writeTransitionOutProperty(mediaSource, transitionOut) {
439
+ const value = { type: transitionOut.type, duration: transitionOut.duration };
440
+ if (!hasTopLevelProperty(mediaSource, 'transition_out')) {
441
+ return appendJsonProperty(mediaSource, 'transition_out', value);
442
+ }
443
+ try {
444
+ const located = locateTopLevelObjectProperty(mediaSource, 'transition_out');
445
+ return mediaSource.slice(0, located.start) + JSON.stringify(value) + mediaSource.slice(located.end);
446
+ }
447
+ catch {
448
+ return appendJsonProperty(removeObjectProperty(mediaSource, 'transition_out'), 'transition_out', value);
449
+ }
450
+ }
351
451
  function reorderCutsInSource(source, fromIndex, toIndex) {
352
452
  const array = locateArray(source, 'cuts');
353
453
  const elements = splitTopLevelElements(array.inner);
@@ -167,6 +167,19 @@ export declare function readInternalSources(source: string | unknown): InternalS
167
167
  * 検証される側であり、検証対象自身を尺の分母に混ぜると常に「収まっている」判定になってしまう。
168
168
  */
169
169
  export declare function visualContentEndSeconds(internal: InternalEdit): number;
170
+ export interface CrossTrackLayerEvacuation {
171
+ itemId: string;
172
+ trackId: string;
173
+ causeItemId: string;
174
+ causeTrackId: string;
175
+ overlapStartFrames: number;
176
+ overlapEndFrames: number;
177
+ }
178
+ /**
179
+ * 別 visual track との重なりが原因で upper item が layers へ退避される組を返す。
180
+ * edit-lint と UI は理由文言に必要な相手 id を、この単一定義から得る。
181
+ */
182
+ export declare function findCrossTrackLayerEvacuations(edit: unknown): CrossTrackLayerEvacuation[];
170
183
  export interface LegacyEditView {
171
184
  cuts: EditCut[];
172
185
  sources?: EditSource[];
@@ -7,6 +7,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.readInternalEdit = readInternalEdit;
8
8
  exports.readInternalSources = readInternalSources;
9
9
  exports.visualContentEndSeconds = visualContentEndSeconds;
10
+ exports.findCrossTrackLayerEvacuations = findCrossTrackLayerEvacuations;
10
11
  exports.projectLegacyEdit = projectLegacyEdit;
11
12
  exports.toLegacyTrack = toLegacyTrack;
12
13
  exports.derivedLegacyTracks = derivedLegacyTracks;
@@ -258,26 +259,16 @@ function needsLayersEngine(item, chromaKeyOf, hasOverlappingSibling = false) {
258
259
  return true;
259
260
  return false;
260
261
  }
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 経路を保つ。
268
- //
269
- // transition_out は同一トラックの隣接カットが意図的に作る狭い重なりで、cuts/xfade が表現する。
270
- // その例外は同一トラックのペアだけに適用する。別トラックとの交差は transition の有無にかかわらず
271
- // 真の同時表示なので layers へ退避する。
272
- function computeOverlappingItemIds(itemGroups) {
262
+ function analyzeOverlappingItems(itemGroups) {
273
263
  const overlapping = new Set();
274
- const entries = itemGroups.flatMap((items, trackIndex) => items.map(item => ({ item, trackIndex })));
264
+ const crossTrackEvacuations = [];
265
+ const entries = itemGroups.flatMap((group, trackIndex) => group.items.map(item => ({ item, trackIndex, trackId: group.trackId })));
275
266
  for (let i = 0; i < entries.length; i++) {
276
- const { item: a, trackIndex: aTrackIndex } = entries[i];
267
+ const { item: a, trackIndex: aTrackIndex, trackId: aTrackId } = entries[i];
277
268
  if (a.source.kind !== 'media')
278
269
  continue;
279
270
  for (let j = i + 1; j < entries.length; j++) {
280
- const { item: b, trackIndex: bTrackIndex } = entries[j];
271
+ const { item: b, trackIndex: bTrackIndex, trackId: bTrackId } = entries[j];
281
272
  if (b.source.kind !== 'media')
282
273
  continue;
283
274
  if (!(a.at < b.at + b.duration && b.at < a.at + a.duration))
@@ -291,13 +282,40 @@ function computeOverlappingItemIds(itemGroups) {
291
282
  overlapping.add(b.id);
292
283
  }
293
284
  else {
294
- const upper = aTrackIndex > bTrackIndex ? a : b;
295
- if (needsCrossTrackLayers(upper))
285
+ const upperIsA = aTrackIndex > bTrackIndex;
286
+ const upper = upperIsA ? a : b;
287
+ const lower = upperIsA ? b : a;
288
+ if (needsCrossTrackLayers(upper)) {
296
289
  overlapping.add(upper.id);
290
+ crossTrackEvacuations.push({
291
+ itemId: upper.id,
292
+ trackId: upperIsA ? aTrackId : bTrackId,
293
+ causeItemId: lower.id,
294
+ causeTrackId: upperIsA ? bTrackId : aTrackId,
295
+ overlapStartFrames: Math.max(a.at, b.at),
296
+ overlapEndFrames: Math.min(a.at + a.duration, b.at + b.duration)
297
+ });
298
+ }
297
299
  }
298
300
  }
299
301
  }
300
- return overlapping;
302
+ return { itemIds: overlapping, crossTrackEvacuations };
303
+ }
304
+ function computeOverlappingItemIds(itemGroups) {
305
+ return analyzeOverlappingItems(itemGroups.map((items, index) => ({
306
+ items,
307
+ trackId: String(index)
308
+ }))).itemIds;
309
+ }
310
+ /**
311
+ * 別 visual track との重なりが原因で upper item が layers へ退避される組を返す。
312
+ * edit-lint と UI は理由文言に必要な相手 id を、この単一定義から得る。
313
+ */
314
+ function findCrossTrackLayerEvacuations(edit) {
315
+ const parsed = (0, edit_v2_1.readEditV2)(edit);
316
+ return analyzeOverlappingItems(parsed.tracks.flatMap(track => track.lane === 'visual' && 'items' in track
317
+ ? [{ items: track.items, trackId: track.id }]
318
+ : [])).crossTrackEvacuations;
301
319
  }
302
320
  // cuts の winner-take-all が下段を隠してよいのは、上段が全画面を不透明に覆う場合だけ。
303
321
  // transform の単位元を明示しただけなら従来経路を維持する。crop / 半透明 / keyframes は、