akari-video 1.2.0 → 1.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.2.0",
3
+ "version": "1.3.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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.2.0",
3
+ "version": "1.3.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": [
@@ -5,7 +5,7 @@ import { mkdtemp, rm, writeFile, chmod } from "node:fs/promises";
5
5
  import { syncBuiltinESMExports } from "node:module";
6
6
  import { tmpdir } from "node:os";
7
7
  import path from "node:path";
8
- import test from "node:test";
8
+ import test, { after } from "node:test";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
11
  import { resolveFfmpeg, resolveFfprobe } from "../src/index.mjs";
@@ -37,9 +37,10 @@ function baseEnv(overrides = {}) {
37
37
  };
38
38
  }
39
39
 
40
- // PATH からシステムの ffmpeg/ffprobe を外し、vendor/ 同梱バイナリへの
41
- // フォールバックだけが効く状態を作る(task.md 記載の `env PATH=/usr/bin:/bin` と同じ狙い)。
42
- const STRIPPED_PATH_ENV = baseEnv({ PATH: "/usr/bin:/bin" });
40
+ // PATH からシステムの ffmpeg/ffprobe を OS に依らず外す。
41
+ const emptyPathDirectory = fs.mkdtempSync(path.join(tmpdir(), 'akari-media-bin-empty-path-'));
42
+ after(() => fs.rmSync(emptyPathDirectory, { recursive: true, force: true }));
43
+ const STRIPPED_PATH_ENV = baseEnv({ PATH: emptyPathDirectory });
43
44
 
44
45
  test("packagedBinaryPath: packageRoot の ../../media-bin を候補にし、win32 は .exe を付ける", () => {
45
46
  const packageRoot = fileURLToPath(new URL("../", import.meta.url));
@@ -192,7 +193,8 @@ test("resolveFfmpeg: PATH に ffmpeg があればコマンド名を返す", () =
192
193
  assert.equal(resolved, "ffmpeg");
193
194
  });
194
195
 
195
- test("resolveFfmpeg: PATH から外すと vendor 同梱バイナリへフォールバックし、実行できる", () => {
196
+ test("resolveFfmpeg: PATH から外すと vendor 同梱バイナリへフォールバックし、実行できる", t => {
197
+ if (!fs.existsSync(vendorBinaryPath('ffmpeg'))) return t.skip('vendor ffmpeg is not installed when postinstall is skipped');
196
198
  const resolved = resolveFfmpeg({ env: STRIPPED_PATH_ENV });
197
199
  assert.notEqual(resolved, "ffmpeg");
198
200
  assert.ok(path.isAbsolute(resolved), `絶対パスのはず: ${resolved}`);
@@ -202,7 +204,8 @@ test("resolveFfmpeg: PATH から外すと vendor 同梱バイナリへフォー
202
204
  assert.match(result.stdout, /ffmpeg version/);
203
205
  });
204
206
 
205
- test("resolveFfprobe: PATH から外すと vendor 同梱バイナリへフォールバックし、実行できる", () => {
207
+ test("resolveFfprobe: PATH から外すと vendor 同梱バイナリへフォールバックし、実行できる", t => {
208
+ if (!fs.existsSync(vendorBinaryPath('ffprobe'))) return t.skip('vendor ffprobe is not installed when postinstall is skipped');
206
209
  const resolved = resolveFfprobe({ env: STRIPPED_PATH_ENV });
207
210
  assert.notEqual(resolved, "ffprobe");
208
211
  assert.ok(path.isAbsolute(resolved), `絶対パスのはず: ${resolved}`);
@@ -100,7 +100,7 @@ test('サイドカー生成中もイベントループは止まらない(spawn
100
100
  assert.equal(result.ok, true, result.reason);
101
101
  assert.equal(result.skipped, false);
102
102
  assert.deepEqual(Object.keys(result).sort(),
103
- ['channels', 'durationSec', 'key', 'ok', 'path', 'reason', 'sampleRate', 'skipped']);
103
+ ['channels', 'durationSec', 'format', 'key', 'ok', 'path', 'reason', 'sampleRate', 'skipped']);
104
104
  assert.equal(result.sampleRate, 48000);
105
105
  assert.equal(result.channels, 1);
106
106
  assert.equal(result.durationSec, 1);
@@ -1,6 +1,16 @@
1
1
  export declare function elementAddress(root: Element, element: Element): string | null;
2
- export declare function selectableElement(root: Element, element: Element, outputRect?: DOMRect): boolean;
3
- export declare function nearestSelectableElement(root: Element, hit: Element, outputRect?: DOMRect): Element | null;
4
- export declare function firstSelectableElement(root: Element, outputRect?: DOMRect): Element | null;
2
+ export interface SelectionBounds { left: number; top: number; right: number; bottom: number; width: number; height: number }
3
+ export declare function drawsSelectionContent(element: Element, style?: CSSStyleDeclaration): boolean;
4
+ export declare function selectionContentBounds(element: Element, options?: {
5
+ container?: Element;
6
+ visibleRect?: (rect: DOMRect, element: Element) => SelectionBounds | null;
7
+ rawRect?: (element: Element) => DOMRect;
8
+ }): SelectionBounds | null;
9
+ export declare function isSelectionContainer(element: Element): boolean;
10
+ export declare function selectableContainer(root: Element, element: Element, outputRect: DOMRect | undefined, bounds: SelectionBounds | null, options?: SelectionOptions): boolean;
11
+ export interface SelectionOptions { boundsFor?: (element: Element) => SelectionBounds | null }
12
+ export declare function selectableElement(root: Element, element: Element, outputRect?: DOMRect, options?: SelectionOptions): boolean;
13
+ export declare function nearestSelectableElement(root: Element, hit: Element, outputRect?: DOMRect, options?: SelectionOptions): Element | null;
14
+ export declare function firstSelectableElement(root: Element, outputRect?: DOMRect, options?: SelectionOptions): Element | null;
5
15
  export declare function elementByAddress(root: Element, ref: string): Element | null;
6
16
  export declare function elementLabel(root: Element, element: Element): string;
@@ -17,30 +17,163 @@ function elementAddress(root, element) {
17
17
  return index < 0 ? null : `${id ? '#' : '.'}${id || token}[${index}]`;
18
18
  }
19
19
 
20
- function selectableElement(root, element, outputRect) {
20
+ function drawsSelectionContent(element, style = getComputedStyle(element)) {
21
+ const tag = element.tagName.toUpperCase();
22
+ if (['BASE', 'HEAD', 'LINK', 'META', 'NOSCRIPT', 'SCRIPT', 'STYLE', 'TEMPLATE', 'TITLE'].includes(tag)) return false;
23
+ if (['AUDIO', 'CANVAS', 'EMBED', 'IFRAME', 'IMG', 'OBJECT', 'SVG', 'VIDEO'].includes(tag)) return true;
24
+ if (element.namespaceURI === 'http://www.w3.org/2000/svg'
25
+ && ['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'use', 'text', 'image'].includes(tag.toLowerCase())) return true;
26
+ if ([...element.childNodes].some(node => node.nodeType === 3 && node.textContent.trim())) return true;
27
+ const transparent = value => {
28
+ const color = String(value ?? '').trim().toLowerCase();
29
+ if (!color || color === 'transparent') return true;
30
+ const legacy = color.match(/^(?:rgba|hsla)\([^)]*,\s*([\d.]+)\)$/);
31
+ if (legacy) return Number(legacy[1]) <= 0;
32
+ const modern = color.match(/\/\s*([\d.]+)%?\s*\)$/);
33
+ return Boolean(modern && Number(modern[1]) <= 0);
34
+ };
35
+ const shadow = String(style.boxShadow ?? '').trim().toLowerCase();
36
+ const shadowColors = shadow.match(/(?:rgba?|hsla?|color)\([^)]*\)|transparent/g) ?? [];
37
+ if (!transparent(style.backgroundColor) || style.backgroundImage && style.backgroundImage !== 'none'
38
+ || shadow && shadow !== 'none' && (shadowColors.length === 0 || shadowColors.some(color => !transparent(color)))) return true;
39
+ for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
40
+ if (parseFloat(style[`border${side}Width`]) > 0
41
+ && !['none', 'hidden'].includes(style[`border${side}Style`])
42
+ && !transparent(style[`border${side}Color`])) return true;
43
+ }
44
+ return parseFloat(style.outlineWidth) > 0 && !['none', 'hidden'].includes(style.outlineStyle)
45
+ && !transparent(style.outlineColor);
46
+ }
47
+
48
+ function selectionVisibleRect(rect, element, container) {
49
+ if (!rect || !(rect.width > 0 && rect.height > 0)) return null;
50
+ let { left, top, right, bottom } = rect;
51
+ for (let node = element; node && node !== container; node = node.parentElement) {
52
+ const style = getComputedStyle(node);
53
+ const paint = /\b(?:paint|content|strict)\b/.test(style.contain ?? '');
54
+ const clipX = paint || node.tagName.toLowerCase() === 'svg' || (style.overflowX || style.overflow) !== 'visible';
55
+ const clipY = paint || node.tagName.toLowerCase() === 'svg' || (style.overflowY || style.overflow) !== 'visible';
56
+ if (clipX || clipY) {
57
+ const clip = node.getBoundingClientRect();
58
+ if (clipX) { left = Math.max(left, clip.left); right = Math.min(right, clip.right); }
59
+ if (clipY) { top = Math.max(top, clip.top); bottom = Math.min(bottom, clip.bottom); }
60
+ }
61
+ if (right <= left || bottom <= top) return null;
62
+ }
63
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
64
+ }
65
+
66
+ function selectionContentBounds(element, options = {}) {
67
+ if (!element) return null;
68
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
69
+ for (const node of [element, ...element.querySelectorAll('*')]) {
70
+ if (node.closest('[data-akari-interaction]') || node.closest('defs, symbol, pattern, clipPath, mask, marker')) continue;
71
+ let visible = true;
72
+ for (let ancestor = node; ancestor && ancestor !== options.container; ancestor = ancestor.parentElement) {
73
+ const style = getComputedStyle(ancestor);
74
+ if (style.display === 'none' || ['hidden', 'collapse'].includes(style.visibility)
75
+ || Number(style.opacity) === 0) { visible = false; break; }
76
+ }
77
+ if (!visible || !drawsSelectionContent(node)) continue;
78
+ if (node.tagName.toLowerCase() === 'svg'
79
+ && node.querySelector('path, rect, circle, ellipse, line, polyline, polygon, use, text, image')
80
+ && getComputedStyle(node).backgroundColor === 'rgba(0, 0, 0, 0)') continue;
81
+ const raw = options.rawRect?.(node) ?? node.getBoundingClientRect();
82
+ const rect = options.visibleRect ? options.visibleRect(raw, node)
83
+ : selectionVisibleRect(raw, node, options.container);
84
+ if (!rect || !(rect.width > 0 && rect.height > 0)) continue;
85
+ left = Math.min(left, rect.left); top = Math.min(top, rect.top);
86
+ right = Math.max(right, rect.right); bottom = Math.max(bottom, rect.bottom);
87
+ }
88
+ return Number.isFinite(left) ? { left, top, right, bottom, width: right - left, height: bottom - top } : null;
89
+ }
90
+
91
+ function selectableContentBranches(root, element, outputRect) {
92
+ let count = 0;
93
+ const visit = node => {
94
+ if (count > 1) return;
95
+ const style = getComputedStyle(node);
96
+ if (style.display === 'none' || ['hidden', 'collapse'].includes(style.visibility)
97
+ || Number(style.opacity) === 0 || isRuntimeElement(node)) return;
98
+ const rect = node.getBoundingClientRect();
99
+ if (node !== element && elementAddress(root, node) && rect.width > 1 && rect.height > 1
100
+ && drawsSelectionContent(node) && (!outputRect || rect.width < outputRect.width * 0.95
101
+ || rect.height < outputRect.height * 0.95)) { count++; return; }
102
+ for (const child of node.children) visit(child);
103
+ };
104
+ for (const child of element.children) visit(child);
105
+ return count;
106
+ }
107
+
108
+ function isSelectionContainer(element) {
109
+ return Boolean(element?.children?.length) && !drawsSelectionContent(element);
110
+ }
111
+
112
+ function selectionBoundsFor(element, options) {
113
+ const memo = options.boundsMemo ??= new Map();
114
+ if (!memo.has(element)) memo.set(element,
115
+ options.boundsFor ? options.boundsFor(element) : selectionContentBounds(element));
116
+ return memo.get(element);
117
+ }
118
+
119
+ function hasUncoveredPaintedContent(root, element, outputRect, options) {
120
+ const visit = node => {
121
+ if (node.closest('[data-akari-interaction], defs, symbol, pattern, clipPath, mask, marker')) return false;
122
+ if (selectableElement(root, node, outputRect, options)) return false;
123
+ const rect = node.getBoundingClientRect();
124
+ if (rect.width > 0 && Number(getComputedStyle(node).opacity) !== 0
125
+ && drawsSelectionContent(node)) return true;
126
+ return [...node.children].some(visit);
127
+ };
128
+ return [...element.children].some(visit);
129
+ }
130
+
131
+ function selectableContainer(root, element, outputRect, bounds, options = {}) {
132
+ if (!isSelectionContainer(element)) return true;
133
+ if (!bounds) return false;
134
+ // Keep an animated wrapper reachable while one of its painted phases is visible.
135
+ if (hasUncoveredPaintedContent(root, element, outputRect, options)) return true;
136
+ if (selectableContentBranches(root, element, outputRect) <= 1) return false;
137
+ const box = element.getBoundingClientRect();
138
+ return !(outputRect && (box.width >= outputRect.width * 0.5 || box.height >= outputRect.height * 0.5)
139
+ && (box.width >= bounds.width * 1.5 || box.height >= bounds.height * 1.5));
140
+ }
141
+
142
+ function selectableElement(root, element, outputRect, options = {}) {
143
+ const memo = options.selectableMemo ??= new Map();
144
+ if (memo.has(element)) return memo.get(element);
21
145
  if (!root || !element || element === root || !root.contains(element) || isRuntimeElement(element)
22
146
  || element.closest('svg') !== (element.tagName.toLowerCase() === 'svg' ? element : null)
23
- || !elementAddress(root, element)) return false;
147
+ || !elementAddress(root, element)) { memo.set(element, false); return false; }
24
148
  const rect = element.getBoundingClientRect();
25
- if (!(rect.width > 1 && rect.height > 1)) return false;
149
+ if (!(rect.width > 1 && rect.height > 1)) { memo.set(element, false); return false; }
26
150
  for (let ancestor = element; ancestor && root.contains(ancestor); ancestor = ancestor.parentElement) {
27
151
  const style = getComputedStyle(ancestor);
28
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
152
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
153
+ memo.set(element, false); return false;
154
+ }
29
155
  }
156
+ const bounds = selectionBoundsFor(element, options);
157
+ const coverage = bounds ?? rect;
30
158
  const replaced = ['img', 'video', 'canvas', 'svg'].includes(element.tagName.toLowerCase());
31
- return replaced || !outputRect || rect.width < outputRect.width * 0.95
32
- || rect.height < outputRect.height * 0.95;
159
+ const eligible = (replaced || !outputRect || coverage.width < outputRect.width * 0.95
160
+ || coverage.height < outputRect.height * 0.95)
161
+ && selectableContainer(root, element, outputRect, bounds, options);
162
+ memo.set(element, eligible);
163
+ return eligible;
33
164
  }
34
165
 
35
- function nearestSelectableElement(root, hit, outputRect) {
166
+ function nearestSelectableElement(root, hit, outputRect, options = {}) {
167
+ options = { ...options, selectableMemo: new Map(), boundsMemo: new Map() };
36
168
  for (let element = hit; element && element !== root; element = element.parentElement) {
37
- if (selectableElement(root, element, outputRect)) return element;
169
+ if (selectableElement(root, element, outputRect, options)) return element;
38
170
  }
39
171
  return null;
40
172
  }
41
173
 
42
- function firstSelectableElement(root, outputRect) {
43
- return [root, ...root.querySelectorAll('*')].find(element => selectableElement(root, element, outputRect)) ?? null;
174
+ function firstSelectableElement(root, outputRect, options = {}) {
175
+ options = { ...options, selectableMemo: new Map(), boundsMemo: new Map() };
176
+ return [root, ...root.querySelectorAll('*')].find(element => selectableElement(root, element, outputRect, options)) ?? null;
44
177
  }
45
178
 
46
179
  function elementByAddress(root, ref) {
@@ -57,5 +190,6 @@ function elementLabel(root, element) {
57
190
  return siblings.length > 1 ? `${name} ${siblings.indexOf(element) + 1}` : name;
58
191
  }
59
192
  // END element-selection
60
- export { isRuntimeElement, elementAddress, selectableElement, nearestSelectableElement,
193
+ export { isRuntimeElement, elementAddress, drawsSelectionContent, selectionContentBounds,
194
+ isSelectionContainer, selectableContainer, selectableElement, nearestSelectableElement,
61
195
  firstSelectableElement, elementByAddress, elementLabel };
@@ -122,30 +122,163 @@ function elementAddress(root, element) {
122
122
  return index < 0 ? null : `${id ? '#' : '.'}${id || token}[${index}]`;
123
123
  }
124
124
 
125
- function selectableElement(root, element, outputRect) {
125
+ function drawsSelectionContent(element, style = getComputedStyle(element)) {
126
+ const tag = element.tagName.toUpperCase();
127
+ if (['BASE', 'HEAD', 'LINK', 'META', 'NOSCRIPT', 'SCRIPT', 'STYLE', 'TEMPLATE', 'TITLE'].includes(tag)) return false;
128
+ if (['AUDIO', 'CANVAS', 'EMBED', 'IFRAME', 'IMG', 'OBJECT', 'SVG', 'VIDEO'].includes(tag)) return true;
129
+ if (element.namespaceURI === 'http://www.w3.org/2000/svg'
130
+ && ['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'use', 'text', 'image'].includes(tag.toLowerCase())) return true;
131
+ if ([...element.childNodes].some(node => node.nodeType === 3 && node.textContent.trim())) return true;
132
+ const transparent = value => {
133
+ const color = String(value ?? '').trim().toLowerCase();
134
+ if (!color || color === 'transparent') return true;
135
+ const legacy = color.match(/^(?:rgba|hsla)\([^)]*,\s*([\d.]+)\)$/);
136
+ if (legacy) return Number(legacy[1]) <= 0;
137
+ const modern = color.match(/\/\s*([\d.]+)%?\s*\)$/);
138
+ return Boolean(modern && Number(modern[1]) <= 0);
139
+ };
140
+ const shadow = String(style.boxShadow ?? '').trim().toLowerCase();
141
+ const shadowColors = shadow.match(/(?:rgba?|hsla?|color)\([^)]*\)|transparent/g) ?? [];
142
+ if (!transparent(style.backgroundColor) || style.backgroundImage && style.backgroundImage !== 'none'
143
+ || shadow && shadow !== 'none' && (shadowColors.length === 0 || shadowColors.some(color => !transparent(color)))) return true;
144
+ for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
145
+ if (parseFloat(style[`border${side}Width`]) > 0
146
+ && !['none', 'hidden'].includes(style[`border${side}Style`])
147
+ && !transparent(style[`border${side}Color`])) return true;
148
+ }
149
+ return parseFloat(style.outlineWidth) > 0 && !['none', 'hidden'].includes(style.outlineStyle)
150
+ && !transparent(style.outlineColor);
151
+ }
152
+
153
+ function selectionVisibleRect(rect, element, container) {
154
+ if (!rect || !(rect.width > 0 && rect.height > 0)) return null;
155
+ let { left, top, right, bottom } = rect;
156
+ for (let node = element; node && node !== container; node = node.parentElement) {
157
+ const style = getComputedStyle(node);
158
+ const paint = /\b(?:paint|content|strict)\b/.test(style.contain ?? '');
159
+ const clipX = paint || node.tagName.toLowerCase() === 'svg' || (style.overflowX || style.overflow) !== 'visible';
160
+ const clipY = paint || node.tagName.toLowerCase() === 'svg' || (style.overflowY || style.overflow) !== 'visible';
161
+ if (clipX || clipY) {
162
+ const clip = node.getBoundingClientRect();
163
+ if (clipX) { left = Math.max(left, clip.left); right = Math.min(right, clip.right); }
164
+ if (clipY) { top = Math.max(top, clip.top); bottom = Math.min(bottom, clip.bottom); }
165
+ }
166
+ if (right <= left || bottom <= top) return null;
167
+ }
168
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
169
+ }
170
+
171
+ function selectionContentBounds(element, options = {}) {
172
+ if (!element) return null;
173
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
174
+ for (const node of [element, ...element.querySelectorAll('*')]) {
175
+ if (node.closest('[data-akari-interaction]') || node.closest('defs, symbol, pattern, clipPath, mask, marker')) continue;
176
+ let visible = true;
177
+ for (let ancestor = node; ancestor && ancestor !== options.container; ancestor = ancestor.parentElement) {
178
+ const style = getComputedStyle(ancestor);
179
+ if (style.display === 'none' || ['hidden', 'collapse'].includes(style.visibility)
180
+ || Number(style.opacity) === 0) { visible = false; break; }
181
+ }
182
+ if (!visible || !drawsSelectionContent(node)) continue;
183
+ if (node.tagName.toLowerCase() === 'svg'
184
+ && node.querySelector('path, rect, circle, ellipse, line, polyline, polygon, use, text, image')
185
+ && getComputedStyle(node).backgroundColor === 'rgba(0, 0, 0, 0)') continue;
186
+ const raw = options.rawRect?.(node) ?? node.getBoundingClientRect();
187
+ const rect = options.visibleRect ? options.visibleRect(raw, node)
188
+ : selectionVisibleRect(raw, node, options.container);
189
+ if (!rect || !(rect.width > 0 && rect.height > 0)) continue;
190
+ left = Math.min(left, rect.left); top = Math.min(top, rect.top);
191
+ right = Math.max(right, rect.right); bottom = Math.max(bottom, rect.bottom);
192
+ }
193
+ return Number.isFinite(left) ? { left, top, right, bottom, width: right - left, height: bottom - top } : null;
194
+ }
195
+
196
+ function selectableContentBranches(root, element, outputRect) {
197
+ let count = 0;
198
+ const visit = node => {
199
+ if (count > 1) return;
200
+ const style = getComputedStyle(node);
201
+ if (style.display === 'none' || ['hidden', 'collapse'].includes(style.visibility)
202
+ || Number(style.opacity) === 0 || isRuntimeElement(node)) return;
203
+ const rect = node.getBoundingClientRect();
204
+ if (node !== element && elementAddress(root, node) && rect.width > 1 && rect.height > 1
205
+ && drawsSelectionContent(node) && (!outputRect || rect.width < outputRect.width * 0.95
206
+ || rect.height < outputRect.height * 0.95)) { count++; return; }
207
+ for (const child of node.children) visit(child);
208
+ };
209
+ for (const child of element.children) visit(child);
210
+ return count;
211
+ }
212
+
213
+ function isSelectionContainer(element) {
214
+ return Boolean(element?.children?.length) && !drawsSelectionContent(element);
215
+ }
216
+
217
+ function selectionBoundsFor(element, options) {
218
+ const memo = options.boundsMemo ??= new Map();
219
+ if (!memo.has(element)) memo.set(element,
220
+ options.boundsFor ? options.boundsFor(element) : selectionContentBounds(element));
221
+ return memo.get(element);
222
+ }
223
+
224
+ function hasUncoveredPaintedContent(root, element, outputRect, options) {
225
+ const visit = node => {
226
+ if (node.closest('[data-akari-interaction], defs, symbol, pattern, clipPath, mask, marker')) return false;
227
+ if (selectableElement(root, node, outputRect, options)) return false;
228
+ const rect = node.getBoundingClientRect();
229
+ if (rect.width > 0 && Number(getComputedStyle(node).opacity) !== 0
230
+ && drawsSelectionContent(node)) return true;
231
+ return [...node.children].some(visit);
232
+ };
233
+ return [...element.children].some(visit);
234
+ }
235
+
236
+ function selectableContainer(root, element, outputRect, bounds, options = {}) {
237
+ if (!isSelectionContainer(element)) return true;
238
+ if (!bounds) return false;
239
+ // Keep an animated wrapper reachable while one of its painted phases is visible.
240
+ if (hasUncoveredPaintedContent(root, element, outputRect, options)) return true;
241
+ if (selectableContentBranches(root, element, outputRect) <= 1) return false;
242
+ const box = element.getBoundingClientRect();
243
+ return !(outputRect && (box.width >= outputRect.width * 0.5 || box.height >= outputRect.height * 0.5)
244
+ && (box.width >= bounds.width * 1.5 || box.height >= bounds.height * 1.5));
245
+ }
246
+
247
+ function selectableElement(root, element, outputRect, options = {}) {
248
+ const memo = options.selectableMemo ??= new Map();
249
+ if (memo.has(element)) return memo.get(element);
126
250
  if (!root || !element || element === root || !root.contains(element) || isRuntimeElement(element)
127
251
  || element.closest('svg') !== (element.tagName.toLowerCase() === 'svg' ? element : null)
128
- || !elementAddress(root, element)) return false;
252
+ || !elementAddress(root, element)) { memo.set(element, false); return false; }
129
253
  const rect = element.getBoundingClientRect();
130
- if (!(rect.width > 1 && rect.height > 1)) return false;
254
+ if (!(rect.width > 1 && rect.height > 1)) { memo.set(element, false); return false; }
131
255
  for (let ancestor = element; ancestor && root.contains(ancestor); ancestor = ancestor.parentElement) {
132
256
  const style = getComputedStyle(ancestor);
133
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
257
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
258
+ memo.set(element, false); return false;
259
+ }
134
260
  }
261
+ const bounds = selectionBoundsFor(element, options);
262
+ const coverage = bounds ?? rect;
135
263
  const replaced = ['img', 'video', 'canvas', 'svg'].includes(element.tagName.toLowerCase());
136
- return replaced || !outputRect || rect.width < outputRect.width * 0.95
137
- || rect.height < outputRect.height * 0.95;
264
+ const eligible = (replaced || !outputRect || coverage.width < outputRect.width * 0.95
265
+ || coverage.height < outputRect.height * 0.95)
266
+ && selectableContainer(root, element, outputRect, bounds, options);
267
+ memo.set(element, eligible);
268
+ return eligible;
138
269
  }
139
270
 
140
- function nearestSelectableElement(root, hit, outputRect) {
271
+ function nearestSelectableElement(root, hit, outputRect, options = {}) {
272
+ options = { ...options, selectableMemo: new Map(), boundsMemo: new Map() };
141
273
  for (let element = hit; element && element !== root; element = element.parentElement) {
142
- if (selectableElement(root, element, outputRect)) return element;
274
+ if (selectableElement(root, element, outputRect, options)) return element;
143
275
  }
144
276
  return null;
145
277
  }
146
278
 
147
- function firstSelectableElement(root, outputRect) {
148
- return [root, ...root.querySelectorAll('*')].find(element => selectableElement(root, element, outputRect)) ?? null;
279
+ function firstSelectableElement(root, outputRect, options = {}) {
280
+ options = { ...options, selectableMemo: new Map(), boundsMemo: new Map() };
281
+ return [root, ...root.querySelectorAll('*')].find(element => selectableElement(root, element, outputRect, options)) ?? null;
149
282
  }
150
283
 
151
284
  function elementByAddress(root, ref) {
@@ -340,6 +473,96 @@ function elementBoxCompanions(style, parentStyle, width, height) {
340
473
  // テキスト編集で断片 HTML を保存するときに作者の元指定へ戻せるよう、初回値だけ保持する。
341
474
  const hitPolicyOriginalPointerEvents = new WeakMap();
342
475
  const hitPolicyAppliedContainers = new WeakSet();
476
+ const hitPolicyTimes = new WeakMap();
477
+ const staleHitPolicyContainers = new Set();
478
+ const pausedHitPolicyContainers = new Set();
479
+ let pausedHitPolicyTimer = null;
480
+ let lastPointerHitPolicyRefresh = -Infinity;
481
+
482
+ function hitPolicyOperationBusy() {
483
+ return Boolean(activeEdit || activeDrag || activeResize || activeRotate || activeLine
484
+ || marqueeFrame || pointerOwner != null);
485
+ }
486
+
487
+ function schedulePausedHitPolicyRefresh(delay = 120) {
488
+ if (typeof setTimeout !== 'function') return;
489
+ if (pausedHitPolicyTimer !== null) clearTimeout(pausedHitPolicyTimer);
490
+ pausedHitPolicyTimer = setTimeout(flushPausedHitPolicies, delay);
491
+ }
492
+
493
+ // runtime は可視コンテナの時刻だけを記録する。CSS の読み取りはここでは行わない。
494
+ function markOverlayHitPolicyStale(container, localTimeMs, paused = false, reentered = false,
495
+ animationReady = null) {
496
+ if (!container) return;
497
+ let state = hitPolicyTimes.get(container);
498
+ if (!state) {
499
+ state = { current: localTimeMs, measured: null, paused: null };
500
+ hitPolicyTimes.set(container, state);
501
+ }
502
+ const timeChanged = state.current !== localTimeMs;
503
+ const enteredPause = paused && state.paused !== true;
504
+ state.current = localTimeMs;
505
+ state.paused = paused;
506
+ if (reentered) {
507
+ state.measured = null;
508
+ hitPolicyAppliedContainers.delete(container);
509
+ }
510
+ if (state.measured !== localTimeMs) staleHitPolicyContainers.add(container);
511
+ // pause()/currentTime の pending 補正はこの tick の測定より後に解決し得る。
512
+ // 暫定 apply が stale を消しても、停止後の一回は別の印で必ず残す。
513
+ if (paused && (reentered || timeChanged || enteredPause)) pausedHitPolicyContainers.add(container);
514
+ if (!paused) pausedHitPolicyContainers.delete(container);
515
+ if (paused && pausedHitPolicyContainers.size) {
516
+ // スクラブ中は連続 tick をまとめ、停止後にポインタ無しで確定する。
517
+ schedulePausedHitPolicyRefresh();
518
+ }
519
+ if (paused && (reentered || timeChanged || enteredPause) && animationReady?.then) {
520
+ const expectedState = state;
521
+ animationReady.then(() => {
522
+ if (hitPolicyTimes.get(container) !== expectedState || !state.paused
523
+ || state.current !== localTimeMs) return;
524
+ // ready 後の currentTime 補正が 120ms の測定より遅れた場合も追い付く。
525
+ const alreadyRefreshed = !pausedHitPolicyContainers.has(container);
526
+ pausedHitPolicyContainers.add(container);
527
+ schedulePausedHitPolicyRefresh(alreadyRefreshed ? 0 : 120);
528
+ });
529
+ }
530
+ }
531
+
532
+ function flushPausedHitPolicies() {
533
+ pausedHitPolicyTimer = null;
534
+ if (pausedHitPolicyContainers.size === 0) return;
535
+ if (hitPolicyOperationBusy()) {
536
+ // 編集・ジェスチャーが終わるまで印を保持する。
537
+ pausedHitPolicyTimer = setTimeout(flushPausedHitPolicies, 120);
538
+ return;
539
+ }
540
+ refreshStaleHitPolicies(true);
541
+ }
542
+
543
+ function refreshStaleHitPolicies(includePaused = false) {
544
+ if (hitPolicyOperationBusy()) return false;
545
+ let refreshed = false;
546
+ const candidates = includePaused ? pausedHitPolicyContainers : staleHitPolicyContainers;
547
+ for (const container of candidates) {
548
+ if (includePaused) pausedHitPolicyContainers.delete(container);
549
+ if (!container.isConnected || container.style.visibility === 'hidden') {
550
+ staleHitPolicyContainers.delete(container);
551
+ continue;
552
+ }
553
+ invalidateOverlayHitPolicy(container);
554
+ applyOverlayHitPolicy(container);
555
+ refreshed = true;
556
+ }
557
+ return refreshed;
558
+ }
559
+
560
+ function forgetOverlayHitPolicyTracking(container) {
561
+ staleHitPolicyContainers.delete(container);
562
+ pausedHitPolicyContainers.delete(container);
563
+ hitPolicyTimes.delete(container);
564
+ hitPolicyAppliedContainers.delete(container);
565
+ }
343
566
 
344
567
  // overlay_write は操作順を保存する。selftest は今回の書き込みをこの記録から待つ。
345
568
  let writeTail = Promise.resolve();
@@ -486,6 +709,21 @@ function elementBoxCompanions(style, parentStyle, width, height) {
486
709
  return null;
487
710
  }
488
711
 
712
+ function contentBounds(element, container) {
713
+ return selectionContentBounds(element, {
714
+ container,
715
+ visibleRect: (rect, node) => visibleFragmentRect(rect, node, container),
716
+ rawRect: node => node.tagName.toUpperCase() === "CANVAS"
717
+ ? (canvasHasDecoration(node) ? node.getBoundingClientRect()
718
+ : window.akari.threeRuntime?.contentBounds?.(node)
719
+ ?? measureCanvasBounds(node) ?? node.getBoundingClientRect())
720
+ : node.getBoundingClientRect(),
721
+ });
722
+ }
723
+ function selectionOptions(root) {
724
+ return { boundsFor: element => contentBounds(element, root?.parentElement) };
725
+ }
726
+
489
727
  // 断片ルートは「inset:0 全画面ラッパー + flex/絶対配置」パターン(overlay-authoring
490
728
  // 規約・text-behind-person.md 等)を許容するため、ルート自身の矩形がコンテナ(断片の
491
729
  // 親 = [data-overlay-id] 要素。overlay-runtime.js の mount() が inset:0 で全画面付与)
@@ -504,7 +742,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
504
742
  );
505
743
  }
506
744
 
507
- function fragmentBounds(container) {
745
+ function legacyFragmentBounds(container) {
508
746
  const root = fragmentRoot(container);
509
747
  if (!root) return null;
510
748
 
@@ -594,6 +832,13 @@ function elementBoxCompanions(style, parentStyle, width, height) {
594
832
  return { left, top, right, bottom, width: right - left, height: bottom - top };
595
833
  }
596
834
 
835
+ function fragmentBounds(container) {
836
+ const root = fragmentRoot(container);
837
+ if (!root) return null;
838
+ if (drawsOwnContent(root, getComputedStyle(root))) return legacyFragmentBounds(container);
839
+ return contentBounds(root, container) ?? legacyFragmentBounds(container);
840
+ }
841
+
597
842
  function svgReferenceRect(element, value) {
598
843
  const id = String(value ?? '').match(/url\(["']?(?:[^#)]*#)([^)"']+)["']?\)/)?.[1];
599
844
  const svg = element.namespaceURI === 'http://www.w3.org/2000/svg'
@@ -982,7 +1227,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
982
1227
 
983
1228
  // 全画面の外側コンテナと断片ルートは素通しにし、実際に背景・枠・影・文字・置換要素を
984
1229
  // 描く可視の子孫だけを拾う。data-akari-hit は最寄りの指定を配下へ継承し、機械判定より
985
- // 優先する。字幕が 1,000 件級でも全件を走査しないよう、runtime の可視化時に一度だけ呼ぶ。
1230
+ // 優先する。字幕が 1,000 件級でも全件を走査しないよう、runtime は可視コンテナの
1231
+ // 初回・停止後・ポインタ入力時だけ適用し、再生 tick では時刻の印だけを付ける。
986
1232
  function fragmentRootCoversContainer(element, container) {
987
1233
  const rootRect = element.getBoundingClientRect();
988
1234
  const containerRect = container.getBoundingClientRect();
@@ -1056,6 +1302,9 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1056
1302
 
1057
1303
  for (const root of container.children) visit(root, null, true, true);
1058
1304
  hitPolicyAppliedContainers.add(container);
1305
+ const state = hitPolicyTimes.get(container);
1306
+ if (state) state.measured = state.current;
1307
+ staleHitPolicyContainers.delete(container);
1059
1308
  updateShapeLineHitProxyWidths();
1060
1309
  }
1061
1310
 
@@ -1169,6 +1418,34 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1169
1418
  }
1170
1419
  }
1171
1420
 
1421
+ // 08-07 裁定: 背景は選択・削除できる。旧矩形フォールバックと違い、stage 面に
1422
+ // 落ちたクリックで、全面を覆って自ら塗るルートだけを拾う。実際のヒット先が
1423
+ // あるときは使わず 4680d90d8 の巻き込みを防ぎ、素通し規約は変えない。
1424
+ function paintedStageOverlayAt(clientX, clientY) {
1425
+ const candidates = [...stage.children]
1426
+ .flatMap((container, index) => {
1427
+ if (!isSelectable(container)) return [];
1428
+ const value = Number.parseFloat(getComputedStyle(container).zIndex);
1429
+ return [{ container, index, zIndex: Number.isFinite(value) ? value : 0 }];
1430
+ })
1431
+ .sort((a, b) => b.zIndex - a.zIndex || b.index - a.index);
1432
+
1433
+ for (const { container } of candidates) {
1434
+ for (const root of container.children) {
1435
+ if (root.hasAttribute("data-akari-interaction") || root.getAttribute("data-akari-hit") === "pass") continue;
1436
+ const style = getComputedStyle(root);
1437
+ if (style.display === "none" || Number(style.opacity) <= 0
1438
+ || ["hidden", "collapse"].includes(style.visibility)
1439
+ || !fragmentRootCoversContainer(root, container)
1440
+ || !drawsOwnContent(root, style)) continue;
1441
+ const rect = root.getBoundingClientRect();
1442
+ if (clientX >= rect.left && clientX <= rect.right
1443
+ && clientY >= rect.top && clientY <= rect.bottom) return container;
1444
+ }
1445
+ }
1446
+ return null;
1447
+ }
1448
+
1172
1449
  function overlayForEvent(event) {
1173
1450
  const eventTargetOverlay = findOverlayContainer(event.target);
1174
1451
  if (eventTargetOverlay && !isSelectable(eventTargetOverlay)) {
@@ -1180,6 +1457,11 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1180
1457
  ) {
1181
1458
  return selftestOverlayOverride;
1182
1459
  }
1460
+ if (event.target === stage && !eventTargetOverlay
1461
+ && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
1462
+ const paintedOverlay = paintedStageOverlayAt(event.clientX, event.clientY);
1463
+ if (paintedOverlay) return paintedOverlay;
1464
+ }
1183
1465
  return isSelectable(eventTargetOverlay) ? eventTargetOverlay : null;
1184
1466
  }
1185
1467
 
@@ -1380,7 +1662,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1380
1662
  const transform = readTransform(selectedOverlay);
1381
1663
  const lineRect = lineFrameGeometry(selectedOverlay);
1382
1664
  const focused = focusedElement();
1383
- const focusedGeometry = focused ? elementScreenGeometry(focused) : null;
1665
+ const focusedGeometry = focused ? focusedSelectionGeometry(focused) : null;
1384
1666
  const focusedWidth = focusedGeometry && focusedGeometry.width * Math.hypot(focusedGeometry.axes.x.x, focusedGeometry.axes.x.y);
1385
1667
  const focusedHeight = focusedGeometry && focusedGeometry.height * Math.hypot(focusedGeometry.axes.y.x, focusedGeometry.axes.y.y);
1386
1668
  const focusedAngle = focusedGeometry && Math.atan2(focusedGeometry.axes.x.y, focusedGeometry.axes.x.x) * 180 / Math.PI;
@@ -1437,7 +1719,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1437
1719
  }
1438
1720
  const layout = elementHandleLayout(focusedWidth, focusedHeight);
1439
1721
  const name = [...handle.classList].find(token => /^is-(?:n|e|s|w|nw|ne|se|sw|rotate|move)$/.test(token))?.slice(3);
1440
- handle.style.display = name === 'move' ||
1722
+ handle.style.display = name === 'move' || (isSelectionContainer(focused) && name !== 'rotate') ||
1441
1723
  (['n', 's'].includes(name) && layout.hideHorizontalEdges) ||
1442
1724
  (['e', 'w'].includes(name) && layout.hideVerticalEdges) ? 'none' : '';
1443
1725
  if (name === 'rotate') {
@@ -1448,6 +1730,11 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1448
1730
  const y = name.includes('n') ? -layout.cornerOffsetY : layout.cornerOffsetY;
1449
1731
  if (x || y) handle.style.translate = `${x}px ${y}px`;
1450
1732
  else handle.style.removeProperty('translate');
1733
+ } else if (['n', 's', 'e', 'w'].includes(name)) {
1734
+ const x = name === 'w' ? -layout.cornerOffsetX : name === 'e' ? layout.cornerOffsetX : 0;
1735
+ const y = name === 'n' ? -layout.cornerOffsetY : name === 's' ? layout.cornerOffsetY : 0;
1736
+ if (x || y) handle.style.translate = `${x}px ${y}px`;
1737
+ else handle.style.removeProperty('translate');
1451
1738
  }
1452
1739
  if (name && name !== 'move' && name !== 'rotate') {
1453
1740
  const directions = ['ew-resize', 'nwse-resize', 'ns-resize', 'nesw-resize'];
@@ -1612,7 +1899,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1612
1899
  }
1613
1900
  function focusedElement() {
1614
1901
  if (!selectedElementFocus() || !selectedOverlay) return null;
1615
- return elementByAddress(fragmentRoot(selectedOverlay), elementFocus.ref);
1902
+ const root = fragmentRoot(selectedOverlay);
1903
+ return elementByAddress(root, elementFocus.ref);
1616
1904
  }
1617
1905
  function selectedElementFocus() {
1618
1906
  return elementFocus?.overlayId === selectedId
@@ -1621,10 +1909,23 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1621
1909
  function reconcileElementFocus() {
1622
1910
  if (!selectedElementFocus()) return;
1623
1911
  const root = fragmentRoot(selectedOverlay);
1624
- if (root && !elementByAddress(root, elementFocus.ref)) focusElement(null);
1912
+ if (!root) return;
1913
+ const ref = elementFocus.ref;
1914
+ const element = elementByAddress(root, ref);
1915
+ if (!element) { focusElement(null); return; }
1916
+ if (selectedOverlay?.isConnected && !selectableElement(root, element, stage?.getBoundingClientRect(), selectionOptions(root))) {
1917
+ requestAnimationFrame(() => {
1918
+ if (elementFocus?.ref === ref && selectedOverlay?.isConnected) {
1919
+ const currentRoot = fragmentRoot(selectedOverlay);
1920
+ const current = elementByAddress(currentRoot, ref);
1921
+ if (!current || !selectableElement(currentRoot, current, stage?.getBoundingClientRect(), selectionOptions(currentRoot))) focusElement(null);
1922
+ }
1923
+ });
1924
+ }
1625
1925
  }
1626
1926
  function focusElement(element, { notify = true } = {}) {
1627
1927
  const root = fragmentRoot(selectedOverlay);
1928
+ if (element && !selectableElement(root, element, stage?.getBoundingClientRect(), selectionOptions(root))) element = null;
1628
1929
  const ref = element && elementAddress(root, element);
1629
1930
  const next = ref ? { overlayId: selectedId, ref, tag: element.tagName.toLowerCase(),
1630
1931
  label: elementLabel(root, element) } : null;
@@ -1643,7 +1944,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1643
1944
  const root = fragmentRoot(container);
1644
1945
  const hit = event.target instanceof Element && root?.contains(event.target) ? event.target
1645
1946
  : document.elementsFromPoint(event.clientX, event.clientY).find(element => root?.contains(element)) ?? null;
1646
- focusElement(nearestSelectableElement(root, hit, stage?.getBoundingClientRect()));
1947
+ focusElement(nearestSelectableElement(root, hit, stage?.getBoundingClientRect(), selectionOptions(root)));
1647
1948
  }
1648
1949
  function focusElementAtPoint(overlayId, clientX, clientY, attempt = 0) {
1649
1950
  if (window.akari.capabilities?.elementSelection !== true || !Number.isFinite(clientX)
@@ -1662,7 +1963,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1662
1963
  return false;
1663
1964
  }
1664
1965
  const hit = document.elementsFromPoint(clientX, clientY).find(element => root.contains(element)) ?? null;
1665
- focusElement(nearestSelectableElement(root, hit, stage?.getBoundingClientRect()));
1966
+ focusElement(nearestSelectableElement(root, hit, stage?.getBoundingClientRect(), selectionOptions(root)));
1666
1967
  return true;
1667
1968
  }
1668
1969
 
@@ -1746,7 +2047,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1746
2047
  const element = target.element?.isConnected ? target.element
1747
2048
  : elementByAddress(fragmentRoot(containerById(target.overlayId)), target.ref);
1748
2049
  if (!element?.isConnected) return null;
1749
- const geometry = elementScreenGeometry(element);
2050
+ const geometry = focusedSelectionGeometry(element);
1750
2051
  const width = geometry.width * Math.hypot(geometry.axes.x.x, geometry.axes.x.y);
1751
2052
  const height = geometry.height * Math.hypot(geometry.axes.y.x, geometry.axes.y.y);
1752
2053
  return { rect: { left: geometry.center.x - width / 2, top: geometry.center.y - height / 2,
@@ -1773,7 +2074,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1773
2074
  const root = fragmentRoot(selectedOverlay);
1774
2075
  const path = [];
1775
2076
  for (let node = focusedElement(); node && node !== root; node = node.parentElement) {
1776
- if (selectableElement(root, node, stage?.getBoundingClientRect())) path.unshift(node);
2077
+ if (selectableElement(root, node, stage?.getBoundingClientRect(), selectionOptions(root))) path.unshift(node);
1777
2078
  }
1778
2079
  for (const node of path) append(elementLabel(root, node), () => focusElement(node),
1779
2080
  { kind: 'element', element: node, overlayId: selectedId, ref: elementAddress(root, node) });
@@ -2264,6 +2565,48 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2264
2565
  elementGeometryCache = { element, signature, geometry };
2265
2566
  return geometry;
2266
2567
  }
2568
+ function focusedSelectionGeometry(element) {
2569
+ const geometry = elementScreenGeometry(element);
2570
+ if (!isSelectionContainer(element)) return geometry;
2571
+ const container = selectedOverlay ?? element.parentElement;
2572
+ const bounds = contentBounds(element, container);
2573
+ if (!bounds) return geometry;
2574
+ const scaleX = Math.hypot(geometry.axes.x.x, geometry.axes.x.y);
2575
+ const scaleY = Math.hypot(geometry.axes.y.x, geometry.axes.y.y);
2576
+ if (!(scaleX > 0 && scaleY > 0)) return geometry;
2577
+ const ux = { x: geometry.axes.x.x / scaleX, y: geometry.axes.x.y / scaleX };
2578
+ const uy = { x: geometry.axes.y.x / scaleY, y: geometry.axes.y.y / scaleY };
2579
+ const angle = Math.abs(Math.atan2(ux.y, ux.x));
2580
+ if (angle < 0.001) return { ...geometry,
2581
+ center: { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 },
2582
+ width: bounds.width / scaleX, height: bounds.height / scaleY };
2583
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2584
+ for (const node of element.querySelectorAll('*')) {
2585
+ if (!drawsSelectionContent(node) || !isPaintedElement(node, container)) continue;
2586
+ const rect = visibleFragmentRect(node.getBoundingClientRect(), node, container);
2587
+ if (!rect) continue;
2588
+ const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
2589
+ const dx = center.x - geometry.center.x, dy = center.y - geometry.center.y;
2590
+ const x = dx * ux.x + dy * ux.y, y = dx * uy.x + dy * uy.y;
2591
+ const halfX = (node.offsetWidth || rect.width / scaleX) * scaleX / 2;
2592
+ const halfY = (node.offsetHeight || rect.height / scaleY) * scaleY / 2;
2593
+ minX = Math.min(minX, x - halfX); maxX = Math.max(maxX, x + halfX);
2594
+ minY = Math.min(minY, y - halfY); maxY = Math.max(maxY, y + halfY);
2595
+ }
2596
+ if (!Number.isFinite(minX)) return geometry;
2597
+ const midX = (minX + maxX) / 2, midY = (minY + maxY) / 2;
2598
+ return { ...geometry, center: { x: geometry.center.x + midX * ux.x + midY * uy.x,
2599
+ y: geometry.center.y + midX * ux.y + midY * uy.y },
2600
+ width: (maxX - minX) / scaleX, height: (maxY - minY) / scaleY };
2601
+ }
2602
+ function focusedSelectionRect(element) {
2603
+ const geometry = focusedSelectionGeometry(element);
2604
+ const width = geometry.width * Math.hypot(geometry.axes.x.x, geometry.axes.x.y);
2605
+ const height = geometry.height * Math.hypot(geometry.axes.y.x, geometry.axes.y.y);
2606
+ return { left: geometry.center.x - width / 2, top: geometry.center.y - height / 2,
2607
+ right: geometry.center.x + width / 2, bottom: geometry.center.y + height / 2,
2608
+ width, height };
2609
+ }
2267
2610
  function moveElementPointTo(gesture, horizontal, vertical, target) {
2268
2611
  const geometry = elementScreenGeometry(gesture.element);
2269
2612
  const point = elementPoint(geometry, horizontal, vertical);
@@ -2629,8 +2972,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2629
2972
  && (!scoped || scoped.selectId === container.dataset.overlayId)) {
2630
2973
  const root = fragmentRoot(container);
2631
2974
  const hit = event.target instanceof Element && root?.contains(event.target) ? event.target : null;
2632
- const element = nearestSelectableElement(root, hit, stage?.getBoundingClientRect());
2633
- const rect = element?.getBoundingClientRect() ?? fragmentBounds(container);
2975
+ const element = nearestSelectableElement(root, hit, stage?.getBoundingClientRect(), selectionOptions(root));
2976
+ const rect = element ? focusedSelectionRect(element) : fragmentBounds(container);
2634
2977
  if (!rect || selectedOverlay === container
2635
2978
  && selectedElementFocus()?.ref === elementAddress(root, element)) { hideHover(); return; }
2636
2979
  showHoverFrame(rect, null, container.dataset.overlayId, false, false);
@@ -3983,6 +4326,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
3983
4326
 
3984
4327
  function onPointerDown(event) {
3985
4328
  if (!interactionEnabled || !canBeginPointerInteraction(pointerOwner)) return;
4329
+ event = correctedHitPolicyEvent(event);
3986
4330
  if (event.button !== 0 || activeDrag || activeResize || activeRotate || activeLine) return;
3987
4331
  if (selectedId && stage && event.target instanceof Element) {
3988
4332
  const bounds = stage.getBoundingClientRect();
@@ -4042,7 +4386,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4042
4386
  if (window.akari.capabilities?.elementSelection === true && event.isTrusted && !event.shiftKey) {
4043
4387
  const root = fragmentRoot(hit);
4044
4388
  const target = event.target instanceof Element && root?.contains(event.target)
4045
- ? nearestSelectableElement(root, event.target, stage?.getBoundingClientRect()) : null;
4389
+ ? nearestSelectableElement(root, event.target, stage?.getBoundingClientRect(), selectionOptions(root)) : null;
4046
4390
  clickOrigin.hitId = hit.dataset.overlayId;
4047
4391
  clickOrigin.elementRef = target ? elementAddress(root, target) : null;
4048
4392
  }
@@ -4057,7 +4401,10 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4057
4401
  if (!selectedOverlay || (window.akari.capabilities?.elementSelection === true
4058
4402
  ? selectedId !== hit.dataset.overlayId : selectedOverlay !== hit)) return;
4059
4403
  if (!event.shiftKey) {
4060
- if (clickOrigin?.hitId === selectedId) {
4404
+ const focused = focusedElement();
4405
+ if (focused && isSelectionContainer(focused) && focused.contains(event.target)) {
4406
+ clickOrigin.preservedContainer = true;
4407
+ } else if (clickOrigin?.hitId === selectedId) {
4061
4408
  focusElement(elementByAddress(fragmentRoot(selectedOverlay), clickOrigin.elementRef));
4062
4409
  } else focusElementAt(selectedOverlay, event);
4063
4410
  } else if (selectedElementFocus()) focusElement(null);
@@ -4093,7 +4440,12 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4093
4440
  }
4094
4441
 
4095
4442
  selectOverlay(container);
4096
- if (!event.shiftKey) focusElementAt(container, event);
4443
+ if (!event.shiftKey) {
4444
+ const focused = focusedElement();
4445
+ if (focused && isSelectionContainer(focused) && focused.contains(event.target)) {
4446
+ clickOrigin.preservedContainer = true;
4447
+ } else focusElementAt(container, event);
4448
+ }
4097
4449
  else if (selectedElementFocus()) focusElement(null);
4098
4450
  if (window.akari.capabilities?.elementSelection === true && clickOrigin) {
4099
4451
  clickOrigin.hitId = selectedId;
@@ -4833,6 +5185,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4833
5185
 
4834
5186
  function onClick(event) {
4835
5187
  if (!interactionEnabled || activeEdit) return;
5188
+ event = correctedHitPolicyEvent(event);
4836
5189
  const hit = overlayForEvent(event);
4837
5190
  // pointerdown already toggled. Do not toggle twice or update the cycle clock.
4838
5191
  if (event.shiftKey && clickOrigin?.scopedHit) {
@@ -4855,7 +5208,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4855
5208
  else selectOverlay(hit);
4856
5209
  if (window.akari.capabilities?.elementSelection === true
4857
5210
  && event.isTrusted && !event.shiftKey && selectedOverlay) {
4858
- if (!nextId && clickOrigin?.hitId === selectedId) {
5211
+ if (clickOrigin?.preservedContainer) focusElementAt(selectedOverlay, event);
5212
+ else if (!nextId && clickOrigin?.hitId === selectedId) {
4859
5213
  focusElement(elementByAddress(fragmentRoot(selectedOverlay), clickOrigin.elementRef));
4860
5214
  } else focusElementAt(selectedOverlay, event);
4861
5215
  }
@@ -4923,7 +5277,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4923
5277
  if (event.key === 'Escape' || event.key === 'Enter' && event.shiftKey) {
4924
5278
  const root = fragmentRoot(selectedOverlay);
4925
5279
  const parent = nearestSelectableElement(root, focusedElement()?.parentElement,
4926
- stage?.getBoundingClientRect());
5280
+ stage?.getBoundingClientRect(), selectionOptions(root));
4927
5281
  focusElement(parent); stop(); return;
4928
5282
  }
4929
5283
  if ((event.key === 'Delete' || event.key === 'Backspace'
@@ -4935,7 +5289,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4935
5289
  }
4936
5290
  if (event.key === 'Enter' && !event.shiftKey && !activeEdit && !selectedElementFocus()
4937
5291
  && selectedOverlay && canFocusElement(selectedOverlay)) {
4938
- const first = firstSelectableElement(fragmentRoot(selectedOverlay), stage?.getBoundingClientRect());
5292
+ const root = fragmentRoot(selectedOverlay);
5293
+ const first = firstSelectableElement(root, stage?.getBoundingClientRect(), selectionOptions(root));
4939
5294
  if (first) {
4940
5295
  focusElement(first);
4941
5296
  event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation();
@@ -5329,11 +5684,73 @@ function elementBoxCompanions(style, parentStyle, width, height) {
5329
5684
  for (const type of ["pointerdown", "pointerup", "click", "dblclick"]) {
5330
5685
  window.addEventListener(type, passTransparentCanvasEvent, true);
5331
5686
  }
5687
+ const correctedHitPolicyEvents = new WeakMap();
5688
+ let redirectedHitPolicyClick = null;
5689
+ function pointerInsideStage(event) {
5690
+ if (!stage || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return false;
5691
+ const rect = stage.getBoundingClientRect();
5692
+ return event.clientX >= rect.left && event.clientX <= rect.right
5693
+ && event.clientY >= rect.top && event.clientY <= rect.bottom;
5694
+ }
5695
+ function trustedEventAtTarget(event, target) {
5696
+ // 合成 Event は isTrusted=false になり E2 の焦点を失う。元の信頼済み
5697
+ // イベントを保ったまま、選択処理に見せる target だけ現在の hit-test にする。
5698
+ return new Proxy(event, { get(original, key) {
5699
+ if (key === 'target') return target;
5700
+ const value = Reflect.get(original, key, original);
5701
+ return typeof value === 'function' ? value.bind(original) : value;
5702
+ } });
5703
+ }
5704
+ function correctHitPolicyEvent(event, target) {
5705
+ correctedHitPolicyEvents.set(event, target);
5706
+ // Window capture の後続ホストにも素の HTML hit と同じ target を見せる。
5707
+ // Event 自体は元の信頼済みイベントなので、再生停止や混在選択は一度だけ走る。
5708
+ try { Object.defineProperty(event, 'target', { configurable: true, value: target }); }
5709
+ catch { /* document 側は WeakMap の target へフォールバックする。 */ }
5710
+ }
5711
+ function correctedHitPolicyEvent(event) {
5712
+ const target = correctedHitPolicyEvents.get(event);
5713
+ return target && event.target !== target ? trustedEventAtTarget(event, target) : event;
5714
+ }
5715
+ function stopCorrectedHitPolicyPropagation(event) {
5716
+ // window のホスト捕捉は通し、古い preview-stage への下流だけ止める。
5717
+ if (findOverlayContainer(correctedHitPolicyEvents.get(event))) event.stopPropagation();
5718
+ }
5719
+ window.addEventListener('pointermove', event => {
5720
+ if (!pointerInsideStage(event) || !staleHitPolicyContainers.size
5721
+ || hitPolicyOperationBusy()) return;
5722
+ const now = performance.now();
5723
+ if (now - lastPointerHitPolicyRefresh < 125) return;
5724
+ if (!refreshStaleHitPolicies()) return;
5725
+ lastPointerHitPolicyRefresh = now;
5726
+ const target = document.elementFromPoint(event.clientX, event.clientY);
5727
+ if (target && target !== event.target) correctHitPolicyEvent(event, target);
5728
+ }, true);
5729
+ window.addEventListener('pointerdown', event => {
5730
+ if (event.button !== 0 || !pointerInsideStage(event) || hitPolicyOperationBusy()) return;
5731
+ redirectedHitPolicyClick = null;
5732
+ if (!refreshStaleHitPolicies()) return;
5733
+ const target = document.elementFromPoint(event.clientX, event.clientY);
5734
+ if (!target || target === event.target) return;
5735
+ redirectedHitPolicyClick = { x: event.clientX, y: event.clientY };
5736
+ correctHitPolicyEvent(event, target);
5737
+ }, true);
5738
+ window.addEventListener('click', event => {
5739
+ if (!redirectedHitPolicyClick) return;
5740
+ const redirect = redirectedHitPolicyClick;
5741
+ redirectedHitPolicyClick = null;
5742
+ if (Math.hypot(event.clientX - redirect.x, event.clientY - redirect.y) > 6) return;
5743
+ const target = document.elementFromPoint(event.clientX, event.clientY);
5744
+ if (!target || target === event.target) return;
5745
+ correctHitPolicyEvent(event, target);
5746
+ }, true);
5332
5747
  listenerRoot.addEventListener('pointermove', updateShapeLineHitProxyWidths, true);
5333
5748
  listenerRoot.addEventListener('pointerdown', updateShapeLineHitProxyWidths, true);
5334
5749
  window.addEventListener('resize', updateShapeLineHitProxyWidths);
5335
5750
  listenerRoot.addEventListener("click", onClick, true);
5336
5751
  listenerRoot.addEventListener("pointerdown", onPointerDown, true);
5752
+ listenerRoot.addEventListener("click", stopCorrectedHitPolicyPropagation, true);
5753
+ listenerRoot.addEventListener("pointerdown", stopCorrectedHitPolicyPropagation, true);
5337
5754
  listenerRoot.addEventListener("dblclick", onDoubleClick, true);
5338
5755
  listenerRoot.addEventListener("blur", onBlur, true);
5339
5756
  listenerRoot.addEventListener("input", onEditableInput, true);
@@ -5444,6 +5861,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
5444
5861
  // ㉑ 素通し: overlay-runtime.js の tick() が可視化タイミングで呼ぶ。
5445
5862
  applyOverlayHitPolicy,
5446
5863
  invalidateOverlayHitPolicy,
5864
+ markOverlayHitPolicyStale,
5865
+ forgetOverlayHitPolicyTracking,
5447
5866
  syncOverlayHitRegion,
5448
5867
  // Web UI(preview-server)が編集モードを抜けるときに選択枠を畳むための公開口
5449
5868
  // (Phase 2-4 一本化。shell では未使用の追加 export で挙動不変)。
@@ -341,6 +341,7 @@ function createOverlayRuntime(options = {}) {
341
341
 
342
342
  function unmount() {
343
343
  for (const overlay of mountedOverlays) {
344
+ window.akari.interaction?.forgetOverlayHitPolicyTracking?.(overlay.container);
344
345
  for (const animation of overlay.animations ?? []) releaseAnimation(animation);
345
346
  for (const runtime of overlayRuntimes(overlay)) runtime.dispose(overlay.container);
346
347
  }
@@ -490,6 +491,7 @@ function createOverlayRuntime(options = {}) {
490
491
  runtimesVersion: runtimeRegistryVersion,
491
492
  keepLayoutWhenHidden,
492
493
  hitPolicyPending: false,
494
+ hitPolicyEntry: false,
493
495
  // getAnimations({ subtree: true }) の 250ms キャッシュ(tick() 参照)
494
496
  animations: undefined,
495
497
  animationsAt: 0,
@@ -574,6 +576,7 @@ function createOverlayRuntime(options = {}) {
574
576
  }
575
577
  mounted.container.style.setProperty("opacity", String(statics.opacity));
576
578
  mounted.hitPolicyPending = true;
579
+ mounted.hitPolicyEntry = true;
577
580
  mounted.statics = statics;
578
581
  mounted.keyframes = Array.isArray(value.keyframes) ? value.keyframes : undefined;
579
582
  mounted.fps = finiteNumber(summary?.output?.fps, 30);
@@ -595,6 +598,7 @@ function createOverlayRuntime(options = {}) {
595
598
  mounted.animationsAt = 0;
596
599
  mounted.animationEndTimes = [];
597
600
  mounted.hitPolicyPending = true;
601
+ mounted.hitPolicyEntry = true;
598
602
  return true;
599
603
  }
600
604
 
@@ -636,7 +640,14 @@ function createOverlayRuntime(options = {}) {
636
640
  // することで、実際に存在する CSS Animation を「今可視のオーバーレイ分だけ」に
637
641
  // 抑え、この地雷を踏まない。
638
642
  overlay.container.toggleAttribute("data-akari-active", visible);
643
+ if (!visible && !isPlaying && overlay.animations?.length) {
644
+ // 停止中の連続シークが同一描画フレームで外→内へ戻っても、CSS が
645
+ // inactive を一度観測するようにする。これが無いとゲートの off/on が
646
+ // 相殺され、新しい Animation が生成されず初期 opacity 0 に残る。
647
+ void overlay.container.offsetWidth;
648
+ }
639
649
  overlay.hitPolicyPending = visible;
650
+ overlay.hitPolicyEntry = visible;
640
651
  // getAnimations() のキャッシュ(下記)は可視化フリップで必ず捨てる。ゲート属性の付け外しで
641
652
  // CSS animation の顔ぶれが変わるため、可視化直後の tick は引き直す。非表示化でも捨て、
642
653
  // 非可視の間 Animation 参照を持ち越さない。
@@ -650,6 +661,7 @@ function createOverlayRuntime(options = {}) {
650
661
  overlay.animationsAt = 0;
651
662
  overlay.animationEndTimes = [];
652
663
  if (!visible) {
664
+ window.akari.interaction?.forgetOverlayHitPolicyTracking?.(overlay.container);
653
665
  if (!overlay.keepLayoutWhenHidden) {
654
666
  overlay.container.setAttribute(RUNTIME_HIDDEN_ATTRIBUTE, "");
655
667
  }
@@ -762,15 +774,18 @@ function createOverlayRuntime(options = {}) {
762
774
  runtime.render(overlay.container, localTimeMs / 1000,
763
775
  { syncVideos: true, maxRenderSize, playing: Boolean(playing) });
764
776
  }
765
- // opacity と clip-path は現在時刻へ合わせ、可視な間は入場アニメの終了まで毎 tick
766
- // 測り直す。フリップ時だけでは通常再生の localTimeMs がほぼ 0 となり、0% 姿勢の
767
- // bbox が焼き付くため。キャッシュした有限アニメの終端とローカル時刻で確定を判断する。
768
- // 終わった tick で確定する。以後は呼ばず、無限ループも終端無しとして数えないので、
769
- // 対象を可視オーバーレイだけにする性能原則「見えている分だけ」は維持される。
777
+ const ready = !isPlaying
778
+ ? overlay.animations.map(animation => pendingReady.get(animation)).filter(Boolean) : [];
779
+ window.akari.interaction?.markOverlayHitPolicyStale?.(
780
+ overlay.container, localTimeMs, !isPlaying, overlay.hitPolicyEntry,
781
+ ready.length ? Promise.allSettled(ready) : null);
782
+ overlay.hitPolicyEntry = false;
783
+ // 初回の仮判定と有限アニメ終端の確定判定だけをここで適用する。
784
+ // 途中の姿は上の時刻印を使い、停止後またはポインタ入力時に interaction が
785
+ // 必要な可視コンテナだけ測り直す。終端 tick が存在しない断片も停止後に更新される。
770
786
  if (overlay.hitPolicyPending) {
771
787
  window.akari.interaction?.syncOverlayHitRegion?.(overlay.container);
772
- // 当たり判定ポリシーは初回適用が WeakSet でガードされるため、ここでの再呼び出しは
773
- // 実質 no-op(暫定適用)。確定姿勢に達した tick で invalidate してから測り直す。
788
+ // 初回以外は WeakSet のガードで no-op。再入場時は時刻印側が解除する。
774
789
  window.akari.interaction?.applyOverlayHitPolicy?.(overlay.container);
775
790
  if (entryAnimationsSettled(overlay.animationEndTimes, localTimeMs)) {
776
791
  window.akari.interaction?.invalidateOverlayHitPolicy?.(overlay.container);