akari-video 1.2.0-beta.2 → 1.2.0-beta.4

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-beta.2",
3
+ "version": "1.2.0-beta.4",
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": {
@@ -29,8 +29,8 @@ lint warning として無視する。legacy と v2 の投影は上表の値を
29
29
  lowcut は同一の 2 次 highpass を 2 段カスケードし、24 dB/oct の減衰特性で L1 ゲート
30
30
  (1 oct 下で 15 dB 以上の減衰)を満たすための裁定逸脱とする。
31
31
 
32
- denoise は `fft` なら `afftdn=nr=<12+strength*76>:nf=-30`、`nlm` なら
33
- `anlmdn=s=<0.00001+strength*0.0002>` とする。rubberband は `speed != 1` または
32
+ denoise は `fft` なら `afftdn=nr=<6+strength*18>:nf=-50:tn=1`、`nlm` なら
33
+ `anlmdn=s=<10^(-5+4*strength)>` とする。rubberband は `speed != 1` または
34
34
  `pitch_semitones != 0` のときだけ作る。全キーが既定なら入力もフィルタも追加せず、従来の
35
35
  filtergraph をバイト単位で維持する。
36
36
 
@@ -54,3 +54,21 @@ mtime・in/out・pad・recipe に加え、`atrim` を含む完全なフィルタ
54
54
  receipt の `provenance.audio.clip_fx` は `processed_items` と `filters` の件数を持つ。
55
55
  プレビューと書き出しで意図的に残す近似は、サイドカー FLAC の再圧縮とデコーダ/サンプル境界の
56
56
  差だけとする。
57
+
58
+ ## 2026-10-08 改訂 — ノイズ除去の対応式
59
+
60
+ - `fft`: 旧 `afftdn=nr=<12+strength*76>:nf=-30` から、ノイズ床を追従する
61
+ `afftdn=nr=<6+strength*18>:nf=-50:tn=1` へ変更した。旧式の固定ノイズ床は声の
62
+ 4〜8 kHz を 14〜18 dB、8〜16 kHz を 37〜50 dB 削ってこもらせていた。新式の声の
63
+ 4〜8 kHz の減少は実測 0.7〜1.7 dB。騒がしい録音の 8〜16 kHz は `nr=12` で最大
64
+ 5.9 dB、`nr=24` で最大 15.9 dB 減るため、`nr` の上限を 24 に抑える。
65
+ - `nlm`: 旧 `anlmdn=s=<0.00001+strength*0.0002>` から、強さを広く使える対数式
66
+ `anlmdn=s=<10^(-5+4*strength)>`(`s` は 0.00001〜0.1)へ変更した。実測では声の
67
+ 各帯域の変化は 0.4 dB 以内、弱い声の 8〜16 kHz の減少も 2.1 dB 以内。無音部の
68
+ ノイズは strength 0.5・SNR 38 dB で 21〜54 dB、strength 0.75・SNR 26 dB で
69
+ 10〜36 dB、strength 1・SNR 19 dB で 5〜20 dB 減少した。処理時間は `fft` の約
70
+ 10 倍だが、実時間の約 12 倍速で処理できた。
71
+ - 宣言済みの denoise を持つプロジェクトは、プレビューと書き出しの音が変わる。`fft` は
72
+ こもりが消え、効きは控えめになる。`nlm` は同じ strength で効きが強くなる。
73
+ サイドカーの cache key にはフィルタ文字列が入るため、自動で作り直される。
74
+ おすすめは `nlm`・`0.75`。
@@ -6,8 +6,10 @@ The project stores non-destructive source selections in `.akari/material-ranges.
6
6
  { "version": 1, "ranges": { "assets/example.mp4": { "in": 2.5, "out": 8 } } }
7
7
  ```
8
8
 
9
- Keys are project-relative media paths. `in` and `out` are seconds in the original media; `out` is greater than `in`. Removing a selection deletes its key. A missing or malformed file means no selections. Updates preserve unrelated top-level keys and other material entries and replace the file by writing a temporary file and renaming it.
9
+ Keys are project-relative media paths. `in` and `out` are seconds in the original media; `out` is greater than `in`. Handle changes round both values to 0.01 seconds before saving. Removing a selection deletes its key. A missing or malformed file means no selections. Updates preserve unrelated top-level keys and other material entries and replace the file by writing a temporary file and renaming it.
10
10
 
11
- The selected list strip exposes two handles. Its minimum width is the larger of one second and 24 pixels expressed in source seconds, capped by the media duration. Dragging one handle keeps the other fixed. Double-clicking the strip or pressing × clears the selection. Changes are saved after 150 ms.
11
+ The selected list strip exposes two handles. Its minimum width is the larger of one second and 24 pixels expressed in source seconds, capped by the media duration. Dragging one handle keeps the other fixed. Pressing × clears the selection and announces the change; double-clicking the strip leaves the selection intact. An unselected strip shows an orange line at the saved range. Changes are saved after 150 ms.
12
12
 
13
- Material drag payloads and `akari.timeline.addMaterialAtPlayhead` requests may carry `in` and `out`. Drag placement uses the payload; playhead placement reads the saved range. Video and audio placement cap `out` at the media duration and use the full source when no valid range is present. `akari.materials.range.get` and `.set` expose the stored range to other views; changes emit `akari.materials.range.changed` with `{ relativePath, range }` on `window`. Range setters may include `source`; the event preserves it so a view can ignore echoes of its own changes.
13
+ Material drag payloads and placement requests may carry `in` and `out`. The range applies when a person places material through panel drag and drop, the panel's Add to Timeline action, a drop on the central output preview, or placement from the material preview. Other callers of `akari.timeline.addMaterialAtPlayhead` and `akari.timeline.addMaterialAtOutputPoint` use the full source unless their request explicitly supplies a valid pair. Video and audio placement cap `out` at the media duration; if `in` reaches or exceeds the media duration, placement uses the full source. `akari.materials.range.get` and `.set` expose the stored range to other views. Changes emit `akari.materials.range.changed` on `window` with `{ relativePath, range, source }` when the setter supplies `source`; views can use it to ignore echoes of their own changes.
14
+
15
+ Ranged audio is placed as an audio-track item by both playhead placement and output-preview drop, because the legacy `audio.sfx` form cannot store `in` and `out`.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.2.0-beta.2",
3
+ "version": "1.2.0-beta.4",
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": [
@@ -33,6 +33,11 @@ export type PreviewItemWriteCommand = ({
33
33
  y: number;
34
34
  };
35
35
  }[];
36
+ element?: {
37
+ ref: string;
38
+ tag: string;
39
+ style: Record<string, string | null>;
40
+ };
36
41
  };
37
42
  } | {
38
43
  kind: 'layer';
@@ -51,7 +51,7 @@ function resolvePreviewItemWriteBatch(editText, commands) {
51
51
  }
52
52
  let candidateText = editText;
53
53
  for (const command of commands) {
54
- if (command.kind === 'overlay' && 'html' in command.patch) {
54
+ if (command.kind === 'overlay' && ('html' in command.patch || 'element' in command.patch)) {
55
55
  throw new Error('バッチでは外部 HTML 本文を書き込めません');
56
56
  }
57
57
  const resolved = resolvePreviewItemWrite(candidateText, command);
@@ -142,6 +142,40 @@ function resolveV2Write(parsed, command) {
142
142
  }
143
143
  };
144
144
  if (command.kind === 'overlay') {
145
+ if (command.patch.element) {
146
+ const { ref, tag, style } = command.patch.element;
147
+ if (item.source.kind !== 'html' || item.source.part) {
148
+ throw new Error(`要素の書き戻し対象ではありません: ${itemId}`);
149
+ }
150
+ if (['transform', 'html', 'text', 'duplicate', 'params', 'vars', 'xyKeyframes']
151
+ .some(key => key in command.patch)
152
+ || !/^[#.][^\s\[\]]+\[(0|[1-9]\d*)\]$/u.test(ref)
153
+ || !/^[a-z][a-z0-9-]*$/u.test(tag)
154
+ || !isRecord(style) || Object.keys(style).length === 0) {
155
+ throw new Error('要素の書き戻し指定が不正です');
156
+ }
157
+ const source = item.source;
158
+ const elements = { ...source.elements };
159
+ const current = { ...elements[ref]?.style };
160
+ for (const [key, value] of Object.entries(style)) {
161
+ if (!/^[a-z][a-z0-9-]*$/u.test(key) || (value !== null && typeof value !== 'string')) {
162
+ throw new Error('要素の style が不正です');
163
+ }
164
+ if (value === null || value === '')
165
+ delete current[key];
166
+ else
167
+ current[key] = value;
168
+ }
169
+ if (Object.keys(current).length)
170
+ elements[ref] = { style: current };
171
+ else
172
+ delete elements[ref];
173
+ if (Object.keys(elements).length)
174
+ source.elements = elements;
175
+ else
176
+ delete source.elements;
177
+ return { candidateText: stringifyEdit(edit) };
178
+ }
145
179
  if ('text' in command.patch) {
146
180
  if (typeof command.patch.text !== 'string') {
147
181
  throw new Error('部品の text は文字列である必要があります');
@@ -16,7 +16,7 @@ const TASKS_VERSION = 0;
16
16
  const PATCH_FIELDS = new Set([
17
17
  'state', 'via', 'priority', 'rank', 'outcome', 'title', 'body', 'target', 'anchor',
18
18
  'ref', 'attachments', 'gate', 'undo', 'batchId', 'sentAt', 'sentTo', 'response',
19
- 'needsConfirm', 'updatedAt', 'orphaned', 'kind', 'targetDetail', 'evidence',
19
+ 'needsConfirm', 'confirmedAt', 'updatedAt', 'orphaned', 'kind', 'targetDetail', 'evidence',
20
20
  'confidence', 'question', 'risk', 'route', 'dependsOn', 'origin', 'mergedFrom'
21
21
  ]);
22
22
  const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -132,6 +132,10 @@ function applyTaskPatch(task, patch, actor) {
132
132
  for (const key of Object.keys(patch))
133
133
  if (!PATCH_FIELDS.has(key))
134
134
  throw new Error(`${key} は変更できません。`);
135
+ if (patch.needsConfirm === false && actor !== 'human')
136
+ throw new Error('案の承認は人が行ってください。');
137
+ if (task.needsConfirm === true && patch.needsConfirm === false && !patch.confirmedAt)
138
+ throw new Error('承認時刻が必要です。');
135
139
  if (isObject(task.ref) && task.ref.kind === 'annotation') {
136
140
  for (const key of ['title', 'body', 'target', 'anchor', 'via', 'ref']) {
137
141
  if (key in patch)
@@ -202,8 +206,9 @@ function deriveTasks(reviewJsonText, overlayDoc) {
202
206
  if (!found)
203
207
  used.push({ id: virtualId, source: 'annotation', state: 'unsent', createdAt: String(annotation.createdAt ?? '') });
204
208
  const status = annotation.status;
205
- const state = status === 'addressed' ? 'review' : status === 'resolved' ? 'done'
206
- : found?.state === 'sent' ? 'sent' : found?.state === 'done' && found.outcome === 'dismissed' ? 'done' : 'unsent';
209
+ const dismissed = found?.state === 'done' && found.outcome === 'dismissed';
210
+ const state = dismissed ? 'done' : status === 'addressed' ? 'review' : status === 'resolved' ? 'done'
211
+ : found?.state === 'sent' ? 'sent' : 'unsent';
207
212
  const text = typeof annotation.text === 'string' ? annotation.text : '';
208
213
  const derived = {
209
214
  ...found, id: virtualId, source: 'annotation', state,
@@ -217,9 +222,11 @@ function deriveTasks(reviewJsonText, overlayDoc) {
217
222
  };
218
223
  if (text.startsWith('[要確認]'))
219
224
  derived.gate = 'ask';
220
- if (status === 'addressed')
225
+ if (dismissed)
226
+ derived.outcome = 'dismissed';
227
+ else if (status === 'addressed')
221
228
  derived.outcome = isObject(annotation.response) ? annotation.response.action ?? null : null;
222
- if (status !== 'addressed' && found?.outcome !== 'dismissed')
229
+ else
223
230
  delete derived.outcome;
224
231
  if (isObject(annotation.response))
225
232
  derived.response = annotation.response;
@@ -323,6 +323,9 @@ export function hasAudioClipFx(value = {}) {
323
323
  }
324
324
 
325
325
  // Single canonical clip-FX chain shared by render-cut and preview sidecars.
326
+ // A fixed nf=-30 treated the voice's high frequencies as noise and muffled them.
327
+ // Track the noise floor (tn=1) with a restrained nr instead.
328
+ // NLM preserves the voice, so its strength spans a wider range logarithmically.
326
329
  export function buildAudioClipFxFilters(value = {}) {
327
330
  const normalized = normalizeAudioClipFx(value);
328
331
  const filters = [];
@@ -331,9 +334,9 @@ export function buildAudioClipFxFilters(value = {}) {
331
334
  filters.push(highpass, highpass);
332
335
  }
333
336
  if (normalized.denoise?.method === 'fft') {
334
- filters.push(`afftdn=nr=${formatNumber(12 + normalized.denoise.strength * 76)}:nf=-30`);
337
+ filters.push(`afftdn=nr=${formatNumber(6 + normalized.denoise.strength * 18)}:nf=-50:tn=1`);
335
338
  } else if (normalized.denoise?.method === 'nlm') {
336
- filters.push(`anlmdn=s=${formatNumber(0.00001 + normalized.denoise.strength * 0.0002)}`);
339
+ filters.push(`anlmdn=s=${formatNumber(10 ** (-5 + 4 * normalized.denoise.strength))}`);
337
340
  }
338
341
  if (normalized.speed !== 1 || normalized.pitchSemitones !== 0) {
339
342
  filters.push([
@@ -1,7 +1,8 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
2
3
  import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
4
  import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
+ import { join, resolve } from 'node:path';
5
6
  import test from 'node:test';
6
7
 
7
8
  import {
@@ -33,21 +34,56 @@ test('clip FX chain orders trim, two-stage lowcut, denoise, then rubberband', ()
33
34
  'asetpts=PTS-STARTPTS',
34
35
  'highpass=f=120:p=2',
35
36
  'highpass=f=120:p=2',
36
- 'afftdn=nr=57.6:nf=-30',
37
+ 'afftdn=nr=16.8:nf=-50:tn=1',
37
38
  'rubberband=tempo=2:pitch=2:formant=preserved:pitchq=quality',
38
39
  ]);
39
40
  });
40
41
 
41
- test('fft denoise coefficient follows 12 + strength * 76', () => {
42
- assert.deepEqual(buildAudioClipFxFilters({ denoise: { method: 'fft', strength: 0.25 } }), [
43
- 'afftdn=nr=31:nf=-30',
44
- ]);
42
+ test('fft denoise coefficient follows 6 + strength * 18 with a tracked noise floor', () => {
43
+ for (const [strength, nr] of [[0, '6'], [0.25, '10.5'], [0.6, '16.8'], [1, '24']]) {
44
+ assert.deepEqual(buildAudioClipFxFilters({ denoise: { method: 'fft', strength } }), [
45
+ `afftdn=nr=${nr}:nf=-50:tn=1`,
46
+ ]);
47
+ }
45
48
  });
46
49
 
47
- test('nlm denoise coefficient follows 0.00001 + strength * 0.0002', () => {
48
- assert.deepEqual(buildAudioClipFxFilters({ denoise: { method: 'nlm', strength: 1 } }), [
49
- 'anlmdn=s=0.00021',
50
- ]);
50
+ test('nlm denoise coefficient follows 10^(-5 + 4 * strength) in decimal notation', () => {
51
+ for (const [strength, s] of [[0, '0.00001'], [0.5, '0.001'], [0.75, '0.01'], [1, '0.1']]) {
52
+ assert.deepEqual(buildAudioClipFxFilters({ denoise: { method: 'nlm', strength } }), [
53
+ `anlmdn=s=${s}`,
54
+ ]);
55
+ }
56
+ });
57
+
58
+ test('fft nr and nlm s increase monotonically with strength', () => {
59
+ for (const method of ['fft', 'nlm']) {
60
+ const coefficients = [0, 0.25, 0.5, 0.75, 1].map(strength => {
61
+ const filter = buildAudioClipFxFilters({ denoise: { method, strength } })[0];
62
+ return Number(filter.match(/(?:nr|s)=([\d.]+)/u)[1]);
63
+ });
64
+ for (let index = 1; index < coefficients.length; index += 1) {
65
+ assert.ok(coefficients[index] > coefficients[index - 1], `${method} at index ${index}`);
66
+ }
67
+ }
68
+ });
69
+
70
+ test('each new denoise filter differs from the old filter and produces a fresh sidecar key', () => {
71
+ const sourcePath = resolve(baseKeyInput.sourcePath);
72
+ const oldKey = oldFilter => createHash('sha1').update([
73
+ sourcePath, baseKeyInput.size, baseKeyInput.mtimeMs,
74
+ baseKeyInput.inSec, baseKeyInput.outSec, baseKeyInput.speed,
75
+ baseKeyInput.padBeforeSec, baseKeyInput.padAfterSec,
76
+ 'atrim=start=1:end=5', 'asetpts=PTS-STARTPTS', oldFilter, PREVIEW_AUDIO_RECIPE,
77
+ ].join('|')).digest('hex');
78
+ for (const [method, strength, oldFilter] of [
79
+ ['fft', 0.6, 'afftdn=nr=57.6:nf=-30'],
80
+ ['nlm', 0.5, 'anlmdn=s=0.00011'],
81
+ ]) {
82
+ const clipFx = { denoise: { method, strength } };
83
+ const newFilter = buildAudioClipFxFilters(clipFx)[0];
84
+ assert.notEqual(newFilter, oldFilter);
85
+ assert.notEqual(previewAudioSidecarKey({ ...baseKeyInput, clipFx }), oldKey(oldFilter));
86
+ }
51
87
  });
52
88
 
53
89
  test('rubberband pitch uses 2^(semitones/12)', () => {
@@ -0,0 +1,8 @@
1
+ export declare function elementAxes(center: {x:number;y:number}, xProbe:{x:number;y:number}, yProbe:{x:number;y:number}): {x:{x:number;y:number};y:{x:number;y:number}};
2
+ export declare function elementPoint(geometry: any, horizontal:number, vertical:number): {x:number;y:number};
3
+ export declare function elementHandlePoints(geometry:any, name:string): {dragged:{x:number;y:number};anchor:{x:number;y:number};signs:number[]} | null;
4
+ export declare function elementBoxSize(width:number, height:number, name:string, delta:{x:number;y:number}, shift:boolean): {width:number;height:number};
5
+ export declare function elementLocalDelta(screen:{x:number;y:number}, axes:any): {x:number;y:number};
6
+ export declare function elementAngle(value:number, shift:boolean): number;
7
+ export declare function elementHandleLayout(width:number, height:number): {hideHorizontalEdges:boolean;hideVerticalEdges:boolean;outsideX:boolean;outsideY:boolean;cornerOffsetX:number;cornerOffsetY:number;rotateTop:number};
8
+ export declare function elementBoxCompanions(style:CSSStyleDeclaration, parentStyle:CSSStyleDeclaration | null, width:number | null, height:number | null): Record<string,string>;
@@ -0,0 +1,75 @@
1
+ // Geometry shared with the marked copy in interaction.js. Coordinates are client pixels.
2
+ // BEGIN element-geometry
3
+ function elementAxes(center, xProbe, yProbe) {
4
+ return { x: { x: xProbe.x - center.x, y: xProbe.y - center.y },
5
+ y: { x: yProbe.x - center.x, y: yProbe.y - center.y } };
6
+ }
7
+
8
+ function elementPoint(geometry, horizontal, vertical) {
9
+ return { x: geometry.center.x + horizontal * geometry.width * geometry.axes.x.x / 2
10
+ + vertical * geometry.height * geometry.axes.y.x / 2,
11
+ y: geometry.center.y + horizontal * geometry.width * geometry.axes.x.y / 2
12
+ + vertical * geometry.height * geometry.axes.y.y / 2 };
13
+ }
14
+
15
+ function elementHandlePoints(geometry, name) {
16
+ const signs = { n: [0, -1], e: [1, 0], s: [0, 1], w: [-1, 0],
17
+ nw: [-1, -1], ne: [1, -1], se: [1, 1], sw: [-1, 1] }[name];
18
+ if (!signs) return null;
19
+ return { dragged: elementPoint(geometry, ...signs),
20
+ anchor: elementPoint(geometry, -signs[0], -signs[1]), signs };
21
+ }
22
+
23
+ function elementBoxSize(width, height, name, delta, shift) {
24
+ const signs = elementHandlePoints({ center: { x: 0, y: 0 }, width, height,
25
+ axes: { x: { x: 1, y: 0 }, y: { x: 0, y: 1 } } }, name)?.signs;
26
+ if (!signs) return { width, height };
27
+ let nextWidth = signs[0] ? Math.max(4, width + signs[0] * delta.x) : width;
28
+ let nextHeight = signs[1] ? Math.max(4, height + signs[1] * delta.y) : height;
29
+ if (signs[0] && signs[1] && !shift) {
30
+ const ratio = Math.max(4 / width, 4 / height,
31
+ (width * nextWidth + height * nextHeight) / (width * width + height * height));
32
+ nextWidth = width * ratio;
33
+ nextHeight = height * ratio;
34
+ }
35
+ return { width: Math.round(nextWidth * 100) / 100,
36
+ height: Math.round(nextHeight * 100) / 100 };
37
+ }
38
+
39
+ function elementLocalDelta(screen, axes) {
40
+ const determinant = axes.x.x * axes.y.y - axes.y.x * axes.x.y;
41
+ if (Math.abs(determinant) < 1e-6) return { x: 0, y: 0 };
42
+ return { x: (screen.x * axes.y.y - screen.y * axes.y.x) / determinant,
43
+ y: (screen.y * axes.x.x - screen.x * axes.x.y) / determinant };
44
+ }
45
+
46
+ function elementAngle(value, shift) {
47
+ const angle = ((value + 180) % 360 + 360) % 360 - 180;
48
+ return Math.round((shift ? Math.round(angle / 15) * 15 : angle) * 100) / 100;
49
+ }
50
+
51
+ function elementHandleLayout(width, height) {
52
+ return { hideHorizontalEdges: width < 30, hideVerticalEdges: height < 30,
53
+ outsideX: width < 36, outsideY: height < 36,
54
+ cornerOffsetX: width < 36 ? 14 : 0, cornerOffsetY: height < 36 ? 14 : 0,
55
+ rotateTop: -32 };
56
+ }
57
+
58
+ function elementBoxCompanions(style, parentStyle, width, height) {
59
+ const result = {};
60
+ if (style.boxSizing !== 'border-box') result['box-sizing'] = 'border-box';
61
+ if (style.display === 'inline') result.display = 'inline-block';
62
+ if (['flex', 'inline-flex'].includes(parentStyle?.display) && style.flex !== '0 0 auto') result.flex = '0 0 auto';
63
+ if (width !== null) {
64
+ if (Number.parseFloat(style.minWidth) > width) result['min-width'] = '0px';
65
+ if (Number.parseFloat(style.maxWidth) < width) result['max-width'] = 'none';
66
+ }
67
+ if (height !== null) {
68
+ if (Number.parseFloat(style.minHeight) > height) result['min-height'] = '0px';
69
+ if (Number.parseFloat(style.maxHeight) < height) result['max-height'] = 'none';
70
+ }
71
+ return result;
72
+ }
73
+ // END element-geometry
74
+ export { elementAxes, elementPoint, elementHandlePoints, elementBoxSize,
75
+ elementLocalDelta, elementAngle, elementHandleLayout, elementBoxCompanions };
@@ -0,0 +1,6 @@
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;
5
+ export declare function elementByAddress(root: Element, ref: string): Element | null;
6
+ export declare function elementLabel(root: Element, element: Element): string;
@@ -0,0 +1,61 @@
1
+ // Pure DOM selection rules. interaction.js carries this marked body verbatim.
2
+ // BEGIN element-selection
3
+ function isRuntimeElement(element) {
4
+ return element?.matches?.('script, style, template, [data-akari-hit-proxy], [data-akari-interaction], [data-akari-part-mask], .akari-u')
5
+ || Boolean(element?.closest?.('[data-akari-hit-proxy], [data-akari-interaction], [data-akari-part-mask]'));
6
+ }
7
+
8
+ function elementAddress(root, element) {
9
+ if (!root || !element || isRuntimeElement(element) || !root.contains(element)) return null;
10
+ const id = element.getAttribute('id');
11
+ const token = element.getAttribute('class')?.trim().split(/\s+/u).find(value => value && value !== 'akari-u');
12
+ if (!id && !token) return null;
13
+ const candidates = [root, ...root.querySelectorAll('*')].filter(candidate => !isRuntimeElement(candidate)
14
+ && (id ? candidate.getAttribute('id') === id
15
+ : candidate.getAttribute('class')?.split(/\s+/u).includes(token)));
16
+ const index = candidates.indexOf(element);
17
+ return index < 0 ? null : `${id ? '#' : '.'}${id || token}[${index}]`;
18
+ }
19
+
20
+ function selectableElement(root, element, outputRect) {
21
+ if (!root || !element || element === root || !root.contains(element) || isRuntimeElement(element)
22
+ || element.closest('svg') !== (element.tagName.toLowerCase() === 'svg' ? element : null)
23
+ || !elementAddress(root, element)) return false;
24
+ const rect = element.getBoundingClientRect();
25
+ if (!(rect.width > 1 && rect.height > 1)) return false;
26
+ for (let ancestor = element; ancestor && root.contains(ancestor); ancestor = ancestor.parentElement) {
27
+ const style = getComputedStyle(ancestor);
28
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
29
+ }
30
+ 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;
33
+ }
34
+
35
+ function nearestSelectableElement(root, hit, outputRect) {
36
+ for (let element = hit; element && element !== root; element = element.parentElement) {
37
+ if (selectableElement(root, element, outputRect)) return element;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function firstSelectableElement(root, outputRect) {
43
+ return [root, ...root.querySelectorAll('*')].find(element => selectableElement(root, element, outputRect)) ?? null;
44
+ }
45
+
46
+ function elementByAddress(root, ref) {
47
+ if (!root || typeof ref !== 'string') return null;
48
+ return [root, ...root.querySelectorAll('*')].find(element => elementAddress(root, element) === ref) ?? null;
49
+ }
50
+
51
+ function elementLabel(root, element) {
52
+ const ref = elementAddress(root, element);
53
+ if (!ref) return '';
54
+ if (ref.startsWith('#')) return ref.slice(0, ref.lastIndexOf('['));
55
+ const name = ref.slice(1, ref.lastIndexOf('['));
56
+ const siblings = [...element.parentElement.children].filter(sibling => sibling.getAttribute('class')?.split(/\s+/u).includes(name));
57
+ return siblings.length > 1 ? `${name} ${siblings.indexOf(element) + 1}` : name;
58
+ }
59
+ // END element-selection
60
+ export { isRuntimeElement, elementAddress, selectableElement, nearestSelectableElement,
61
+ firstSelectableElement, elementByAddress, elementLabel };
@@ -117,6 +117,7 @@
117
117
  .akari-interaction-handle.is-w { top: 50%; left: 0; transform: translate(-50%, -50%); cursor: ew-resize; }
118
118
  .akari-interaction-selection-frame:not([data-akari-selection-kind="leaf"]) .is-edge { display: none; }
119
119
  .akari-interaction-selection-frame.is-telop .is-edge { display: none; }
120
+ .akari-interaction-selection-frame.is-element-focus.is-telop .is-edge { display: block; }
120
121
  .akari-interaction-selection-frame.is-line .akari-interaction-handle:not(.is-line-start):not(.is-line-end):not(.akari-interaction-action) { display: none; }
121
122
  .akari-interaction-selection-frame:not(.is-line) .is-line-start,
122
123
  .akari-interaction-selection-frame:not(.is-line) .is-line-end { display: none; }