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 +1 -1
- package/vendor/docs/contract-2026-09-02-audio-clip-fx-v1.md +20 -2
- package/vendor/docs/contract-2026-10-07-material-ranges-v0.md +5 -3
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/edit-store/lib/edit-v2-item-write.d.ts +5 -0
- package/vendor/packages/edit-store/lib/edit-v2-item-write.js +35 -1
- package/vendor/packages/edit-store/lib/tasks-store.js +12 -5
- package/vendor/packages/media-bin/src/preview-audio-sidecar.mjs +5 -2
- package/vendor/packages/media-bin/test/audio-clip-fx.test.mjs +46 -10
- package/vendor/packages/overlay-runtime/src/element-geometry.d.ts +8 -0
- package/vendor/packages/overlay-runtime/src/element-geometry.mjs +75 -0
- package/vendor/packages/overlay-runtime/src/element-selection.d.ts +6 -0
- package/vendor/packages/overlay-runtime/src/element-selection.mjs +61 -0
- package/vendor/packages/overlay-runtime/src/interaction.css +1 -0
- package/vendor/packages/overlay-runtime/src/interaction.js +763 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "1.2.0-beta.
|
|
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=<
|
|
33
|
-
`anlmdn=s=<
|
|
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.
|
|
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
|
|
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.
|
|
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": [
|
|
@@ -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
|
|
206
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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=
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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; }
|