akari-video 1.0.2 → 1.0.3
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/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/asset-resolver/src/place-library-asset.mjs +54 -0
- package/vendor/packages/asset-resolver/src/project-reference-listing.mjs +22 -0
- package/vendor/packages/asset-resolver/src/project-references.mjs +24 -1
- package/vendor/packages/asset-resolver/src/resolve-async-library.mjs +113 -0
- package/vendor/packages/asset-resolver/src/resolve.mjs +40 -13
- package/vendor/packages/asset-resolver/src/shell-reference.mjs +27 -14
- package/vendor/packages/asset-resolver/test/library-fetch-no-spawn-reference.test.mjs +61 -0
- package/vendor/packages/asset-resolver/test/resolve-legacy-meta-tier.test.mjs +52 -0
- package/vendor/packages/edit-store/lib/shape-markup-v1.js +17 -13
- package/vendor/packages/edit-store/lib/webview-kernel.js +14 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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": [
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
3
|
+
import { recordProjectReference } from './project-references.mjs';
|
|
4
|
+
import { appendLibraryUsage } from './library-usage.mjs';
|
|
5
|
+
import { ASSET_CATEGORIES } from './library.mjs';
|
|
6
|
+
import { resolveAssetLibraryRoots } from '../../creator-root/src/index.mjs';
|
|
7
|
+
|
|
8
|
+
const within = (root, target) => {
|
|
9
|
+
const rel = relative(root, target);
|
|
10
|
+
return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function placeLibraryAsset(source, projectPath, env = process.env) {
|
|
14
|
+
if (!source || !ASSET_CATEGORIES.includes(source.category)
|
|
15
|
+
|| typeof source.id !== 'string' || !source.id || source.id === '.'
|
|
16
|
+
|| source.id.includes('..') || source.id.includes('/') || source.id.includes(String.fromCharCode(92))
|
|
17
|
+
|| typeof source.libraryDir !== 'string' || !isAbsolute(source.libraryDir)) {
|
|
18
|
+
throw new Error('素材の種類・名前・置き場が不正です');
|
|
19
|
+
}
|
|
20
|
+
const actual = await realpath(source.libraryDir);
|
|
21
|
+
if (basename(actual) !== source.id || basename(dirname(actual)) !== source.category
|
|
22
|
+
|| !(await stat(actual)).isDirectory()) {
|
|
23
|
+
throw new Error('素材の置き場と種類・名前が一致しません');
|
|
24
|
+
}
|
|
25
|
+
let allowed = false;
|
|
26
|
+
for (const root of resolveAssetLibraryRoots(env).read) {
|
|
27
|
+
try { if (within(await realpath(root), actual)) allowed = true; }
|
|
28
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
29
|
+
}
|
|
30
|
+
if (!allowed) throw new Error('素材がライブラリの置き場の外にあります');
|
|
31
|
+
const project = await realpath(projectPath);
|
|
32
|
+
if (!(await stat(project)).isDirectory()) throw new Error('プロジェクトがフォルダではありません');
|
|
33
|
+
const destination = join(project, 'assets', source.category, source.id);
|
|
34
|
+
let parent = dirname(destination);
|
|
35
|
+
let actualDestination;
|
|
36
|
+
while (true) {
|
|
37
|
+
try {
|
|
38
|
+
const actualParent = await realpath(parent);
|
|
39
|
+
if (!within(project, actualParent)) throw new Error('配置先がプロジェクトの外にあります');
|
|
40
|
+
actualDestination = join(actualParent, relative(parent, destination));
|
|
41
|
+
break;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code !== 'ENOENT') throw error;
|
|
44
|
+
parent = dirname(parent);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (within(actualDestination, actual) || within(actual, actualDestination)) {
|
|
48
|
+
throw new Error('素材の大元と重なる配置はできません');
|
|
49
|
+
}
|
|
50
|
+
await recordProjectReference(project, { category: source.category, id: source.id });
|
|
51
|
+
await appendLibraryUsage({ category: source.category, id: source.id, project });
|
|
52
|
+
return { success: true, projectAssetPath: join(projectPath, 'assets', source.category, source.id),
|
|
53
|
+
reference: true, libraryDir: actual };
|
|
54
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { listProjectReferenceAssets } from './shell-reference.mjs';
|
|
3
|
+
import { sourceFields } from './library.mjs';
|
|
4
|
+
import { readCatalogCache } from './catalog.mjs';
|
|
5
|
+
import { resolveCatalogSource } from './env.mjs';
|
|
6
|
+
|
|
7
|
+
export async function listProjectAssetReferences(project, env = process.env) {
|
|
8
|
+
let catalog = await readCatalogCache(env);
|
|
9
|
+
const source = resolveCatalogSource(env);
|
|
10
|
+
if (source.kind === 'file') { try { catalog = JSON.parse(await readFile(source.value, 'utf8')); } catch {} }
|
|
11
|
+
const entries = await listProjectReferenceAssets(project, env);
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
let meta;
|
|
14
|
+
try { meta = JSON.parse(await readFile(entry.files.find(file => file.name === 'meta.json').path, 'utf8')); } catch {}
|
|
15
|
+
entry.tags = Array.isArray(meta?.tags) ? meta.tags.filter(tag => typeof tag === 'string') : [];
|
|
16
|
+
const known = catalog?.items?.find(item => item.id === entry.id && item.category === entry.category);
|
|
17
|
+
entry.title = typeof meta?.title === 'string' ? meta.title : typeof known?.title === 'string' ? known.title : entry.id;
|
|
18
|
+
if (!meta && known) entry.tags = Array.isArray(known.tags) ? known.tags.filter(tag => typeof tag === 'string') : [];
|
|
19
|
+
if (meta || known) entry.sourceKind = sourceFields(meta ?? known, !!known).sourceKind;
|
|
20
|
+
}
|
|
21
|
+
return entries;
|
|
22
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { lstatSync, realpathSync } from 'node:fs';
|
|
3
|
-
import fsPromises, { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import fsPromises, { lstat, realpath, mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
@@ -219,3 +219,26 @@ export function resolveLibraryFallback({ declaredPath, references, akariAssetsDi
|
|
|
219
219
|
return null;
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
|
+
|
|
223
|
+
export async function resolveLibraryFallbackAsync({ declaredPath, references, akariAssetsDir, rootRealpaths }) {
|
|
224
|
+
const parsed = parseDeclaredAssetPath(declaredPath);
|
|
225
|
+
if (!parsed || typeof akariAssetsDir !== 'string' || akariAssetsDir.length === 0) return null;
|
|
226
|
+
if (!normalizeReferences(references).some(
|
|
227
|
+
entry => entry.category === parsed.category && entry.id === parsed.id,
|
|
228
|
+
)) return null;
|
|
229
|
+
const lexicalRoot = path.resolve(akariAssetsDir);
|
|
230
|
+
const lexicalTarget = path.resolve(lexicalRoot, parsed.category, parsed.id, ...parsed.rest);
|
|
231
|
+
if (!isWithin(lexicalRoot, lexicalTarget)) return null;
|
|
232
|
+
try {
|
|
233
|
+
let actualRoot = rootRealpaths?.get(lexicalRoot);
|
|
234
|
+
if (!actualRoot) {
|
|
235
|
+
actualRoot = realpath(lexicalRoot);
|
|
236
|
+
rootRealpaths?.set(lexicalRoot, actualRoot);
|
|
237
|
+
}
|
|
238
|
+
const [root, target] = await Promise.all([actualRoot, realpath(lexicalTarget)]);
|
|
239
|
+
if (!isWithin(root, target) || !(await lstat(target)).isFile()) return null;
|
|
240
|
+
return target;
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readdir, stat, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveAssetLibraryRoots } from '../../creator-root/src/index.mjs';
|
|
4
|
+
import { assetTier } from './tier.mjs';
|
|
5
|
+
import { loadCatalog, readCatalogCache } from './catalog.mjs';
|
|
6
|
+
import { mergeInstalledItems, INSTALLED_ASSETS_SCHEMA } from './installed.mjs';
|
|
7
|
+
import { resolveCatalogSource } from './env.mjs';
|
|
8
|
+
|
|
9
|
+
export async function cachedAssetDirAsync(env, category, id) {
|
|
10
|
+
for (const root of resolveAssetLibraryRoots(env).read) {
|
|
11
|
+
const dir = path.resolve(root, category, id);
|
|
12
|
+
try {
|
|
13
|
+
if ((await stat(dir)).isDirectory() && (await readdir(dir)).length) return dir;
|
|
14
|
+
} catch { /* next root */ }
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const safe = value => typeof value === 'string' && value.length > 0
|
|
20
|
+
&& value !== '.' && value !== '..' && !/[\\/]/.test(value);
|
|
21
|
+
|
|
22
|
+
function within(root, ...parts) {
|
|
23
|
+
const base = path.resolve(root);
|
|
24
|
+
const candidate = path.resolve(base, ...parts);
|
|
25
|
+
if (candidate !== base && !candidate.startsWith(`${base}${path.sep}`)) {
|
|
26
|
+
throw new Error(`導入済み素材のパスがパック外を指しています: ${parts.join('/')}`);
|
|
27
|
+
}
|
|
28
|
+
return candidate;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function installedItemsAsync(env) {
|
|
32
|
+
const roots = resolveAssetLibraryRoots(env).read;
|
|
33
|
+
const byId = new Map();
|
|
34
|
+
for (const libraryRoot of [...roots].reverse()) {
|
|
35
|
+
const indexPath = path.join(libraryRoot, 'installed.json');
|
|
36
|
+
let index;
|
|
37
|
+
try { index = JSON.parse(await readFile(indexPath, 'utf8')); }
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error?.code === 'ENOENT') continue;
|
|
40
|
+
throw new Error(`導入済み素材索引を読めません: ${indexPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
41
|
+
}
|
|
42
|
+
if (index?.schema !== INSTALLED_ASSETS_SCHEMA
|
|
43
|
+
|| !index.packs || typeof index.packs !== 'object' || Array.isArray(index.packs)) {
|
|
44
|
+
throw new Error(`導入済み素材索引の形式が想定と違います: ${indexPath}`);
|
|
45
|
+
}
|
|
46
|
+
for (const [packId, pack] of Object.entries(index.packs)) {
|
|
47
|
+
if (!safe(packId) || !pack || typeof pack.root !== 'string' || !path.isAbsolute(pack.root)
|
|
48
|
+
|| (typeof pack.version !== 'string' && typeof pack.version !== 'number')
|
|
49
|
+
|| typeof pack.installedAt !== 'string' || !pack.installedAt || !Array.isArray(pack.items)) {
|
|
50
|
+
throw new Error(`導入済み素材索引に不正な pack があります: ${packId}`);
|
|
51
|
+
}
|
|
52
|
+
const originalRoot = roots.find(root => {
|
|
53
|
+
try { within(path.join(root, 'store', packId), pack.root); return true; } catch { return false; }
|
|
54
|
+
});
|
|
55
|
+
if (!originalRoot) throw new Error(`導入済み素材の root がパック外を指しています: ${pack.root}`);
|
|
56
|
+
const relativeRoot = path.relative(originalRoot, pack.root);
|
|
57
|
+
const readRoots = roots.map(root => path.join(root, relativeRoot));
|
|
58
|
+
for (const item of pack.items) {
|
|
59
|
+
if (!item || !safe(item.id) || typeof item.title !== 'string' || !item.title
|
|
60
|
+
|| typeof item.path !== 'string' || !item.path || item.version == null) {
|
|
61
|
+
throw new Error(`導入済み素材索引に不正な item があります: ${packId}`);
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(item.files) || item.files.length === 0) {
|
|
64
|
+
throw new Error(`導入済み素材索引の item に files[] がありません: ${item.id}`);
|
|
65
|
+
}
|
|
66
|
+
const itemRoots = readRoots.map(root => within(root, item.path));
|
|
67
|
+
let metaTier;
|
|
68
|
+
for (const root of itemRoots) {
|
|
69
|
+
try {
|
|
70
|
+
const meta = JSON.parse(await readFile(path.join(root, 'meta.json'), 'utf8'));
|
|
71
|
+
if (Object.hasOwn(meta, 'tier') || Object.hasOwn(meta, 'price')) metaTier = assetTier(meta);
|
|
72
|
+
break;
|
|
73
|
+
} catch (error) { if (error?.code !== 'ENOENT') break; }
|
|
74
|
+
}
|
|
75
|
+
const categoryParts = item.path.replaceAll('\\', '/').split('/').filter(Boolean);
|
|
76
|
+
const category = categoryParts[0] === 'assets' && safe(categoryParts[1]) ? categoryParts[1] : 'pack';
|
|
77
|
+
const files = await Promise.all(item.files.map(async file => {
|
|
78
|
+
if (!file || typeof file.path !== 'string' || !file.path
|
|
79
|
+
|| !Number.isInteger(file.bytes) || file.bytes < 0
|
|
80
|
+
|| typeof file.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(file.sha256)) {
|
|
81
|
+
throw new Error(`導入済み素材索引の files[] が不正です: ${item.id}`);
|
|
82
|
+
}
|
|
83
|
+
const candidates = itemRoots.map(root => within(root, file.path));
|
|
84
|
+
let localPath = candidates[0];
|
|
85
|
+
for (const candidate of candidates) {
|
|
86
|
+
if (await stat(candidate).then(() => true, () => false)) { localPath = candidate; break; }
|
|
87
|
+
}
|
|
88
|
+
return { name: file.path, local_path: localPath, sha256: file.sha256, bytes: file.bytes };
|
|
89
|
+
}));
|
|
90
|
+
byId.set(item.id, { id: item.id, title: item.title, category, version: item.version,
|
|
91
|
+
price: 0, ...((Object.hasOwn(item, 'tier') ? assetTier(item) : metaTier)
|
|
92
|
+
? { tier: Object.hasOwn(item, 'tier') ? assetTier(item) : metaTier } : {}),
|
|
93
|
+
source: 'installed', files });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [...byId.values()];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function loadCatalogForResolveAsync(ref, { env = process.env, fetchImpl = fetch, timeouts } = {}) {
|
|
101
|
+
const matches = typeof ref === 'string' && ref.includes('/')
|
|
102
|
+
? item => `${item.category}/${item.id}` === ref : item => item.id === ref;
|
|
103
|
+
const installedItems = await installedItemsAsync(env);
|
|
104
|
+
if (installedItems.some(matches)) {
|
|
105
|
+
return { schema: 'akari-assets-catalog/v0', version: null, base: null, items: installedItems };
|
|
106
|
+
}
|
|
107
|
+
if (resolveCatalogSource(env).kind === 'url') {
|
|
108
|
+
const cached = await readCatalogCache(env);
|
|
109
|
+
if (cached && cached.items.some(matches)) return mergeInstalledItems(cached, installedItems);
|
|
110
|
+
}
|
|
111
|
+
const catalog = await loadCatalog({ env, fetchImpl, timeouts, fallbackToCache: false, includeInstalled: false });
|
|
112
|
+
return mergeInstalledItems(catalog, installedItems);
|
|
113
|
+
}
|
|
@@ -11,19 +11,18 @@ import { resolveAssetLibraryRoots } from '../../creator-root/src/index.mjs';
|
|
|
11
11
|
// なら resolvePaidZip() が `/api/store/v1/download/<id>` から zip を取得し、展開 →
|
|
12
12
|
// checksums.txt 検証(paid-zip.mjs)→ 同じ validate-asset / 原子的 move の経路に合流する。
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
import { constants
|
|
16
|
-
import { cp, mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { constants } from 'node:fs';
|
|
16
|
+
import { cp, mkdir, mkdtemp, readFile, rename, rm, realpath, stat, writeFile } from 'node:fs/promises';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
19
19
|
import { format } from 'node:util';
|
|
20
|
-
import {
|
|
20
|
+
import { loadCatalogForResolveAsync, cachedAssetDirAsync } from './resolve-async-library.mjs';
|
|
21
21
|
import { resolveEffectiveBase } from './env.mjs';
|
|
22
22
|
import { AssetResolverError } from './errors.mjs';
|
|
23
23
|
import { fetchEntitlements, readStoreCredentials } from './entitlements.mjs';
|
|
24
24
|
import { materialize, resolveFileLocation } from './fetch-file.mjs';
|
|
25
25
|
import { sha256File } from './hash.mjs';
|
|
26
|
-
import { cachedAssetDir, localAssetDir } from './library.mjs';
|
|
27
26
|
import { downloadPaidZip, extractZip, verifyPaidZipContents } from './paid-zip.mjs';
|
|
28
27
|
import { recordProjectReference } from './project-references.mjs';
|
|
29
28
|
import { assetTier, isAssetEntitled } from './tier.mjs';
|
|
@@ -81,7 +80,15 @@ async function validateAsset(assetDir) {
|
|
|
81
80
|
return await run;
|
|
82
81
|
} catch {
|
|
83
82
|
// Module loading failures are outside the validator's ordinary NG result.
|
|
84
|
-
return
|
|
83
|
+
return new Promise((resolveResult, reject) => {
|
|
84
|
+
const child = spawn(process.execPath, [VALIDATE_ASSET_SCRIPT, assetDir]);
|
|
85
|
+
let stdout = '';
|
|
86
|
+
let stderr = '';
|
|
87
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
88
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
89
|
+
child.on('error', reject);
|
|
90
|
+
child.on('close', status => resolveResult({ status, stdout, stderr }));
|
|
91
|
+
});
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
|
|
@@ -110,7 +117,7 @@ export async function copyIntoProject(sourceDir, projectDir, category, id) {
|
|
|
110
117
|
// 素材箱側が「meta.json を含むディレクトリ = 1 カード」でグルーピングする際、
|
|
111
118
|
// 深さではなくディレクトリ形で判定するため、置き場の形をライブラリと合わせておく必要はないが、
|
|
112
119
|
// カテゴリ別に整理された配置の方が人間が見ても分かりやすいのでライブラリ型に統一する。
|
|
113
|
-
const realSourceDir =
|
|
120
|
+
const realSourceDir = await realpath(sourceDir);
|
|
114
121
|
const dest = path.join(path.resolve(projectDir), 'assets', category, id);
|
|
115
122
|
await mkdir(path.dirname(dest), { recursive: true });
|
|
116
123
|
await rm(dest, { recursive: true, force: true });
|
|
@@ -119,8 +126,12 @@ export async function copyIntoProject(sourceDir, projectDir, category, id) {
|
|
|
119
126
|
// このマシンの Node(libuv)は clonefileat 相当が ENOSYS を返し、fs.cp の
|
|
120
127
|
// COPYFILE_FICLONE では節約が効かない(前段 2026-08-09-project-copy-cow-clone で実測確認済み)。
|
|
121
128
|
// BSD cp -c は clonefile(2) を Node を介さず直接使うため、同じ OS/FS 上で実際にクローンできる。
|
|
122
|
-
const
|
|
123
|
-
|
|
129
|
+
const cloned = await new Promise(resolveResult => {
|
|
130
|
+
const child = spawn('/bin/cp', ['-Rc', realSourceDir, dest], { stdio: 'ignore' });
|
|
131
|
+
child.on('error', () => resolveResult(false));
|
|
132
|
+
child.on('close', code => resolveResult(code === 0));
|
|
133
|
+
});
|
|
134
|
+
if (cloned) {
|
|
124
135
|
return dest;
|
|
125
136
|
}
|
|
126
137
|
// クロスボリューム等で -c が失敗したケース。部分的に書かれた dest を掃除してから
|
|
@@ -136,7 +147,7 @@ export async function copyIntoProject(sourceDir, projectDir, category, id) {
|
|
|
136
147
|
|
|
137
148
|
async function moveIntoLibrary(tempDir, destDir) {
|
|
138
149
|
await mkdir(path.dirname(destDir), { recursive: true });
|
|
139
|
-
if (
|
|
150
|
+
if (await stat(destDir).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; })) {
|
|
140
151
|
await rm(destDir, { recursive: true, force: true });
|
|
141
152
|
}
|
|
142
153
|
try {
|
|
@@ -152,6 +163,19 @@ async function moveIntoLibrary(tempDir, destDir) {
|
|
|
152
163
|
}
|
|
153
164
|
}
|
|
154
165
|
|
|
166
|
+
async function backfillLegacyMetaTier(metaPath, item) {
|
|
167
|
+
let meta;
|
|
168
|
+
try {
|
|
169
|
+
meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
170
|
+
} catch {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta) || Object.hasOwn(meta, 'tier')) return;
|
|
174
|
+
// R2 に公開済みの旧 meta.json(tier 導入前・price のみ)を、sha256 検証済みのうえでカタログの tier で補う互換経路。R2 の meta.json を tier 付きで上げ直したら外せる。
|
|
175
|
+
meta.tier = assetTier(item);
|
|
176
|
+
await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`);
|
|
177
|
+
}
|
|
178
|
+
|
|
155
179
|
/**
|
|
156
180
|
* @param {string} ref カタログの category/id。bare id は一意な場合のみ互換解決する
|
|
157
181
|
* @param {{ env?: object, fetchImpl?: Function, project?: string|null, force?: boolean, reference?: boolean }} options
|
|
@@ -161,16 +185,16 @@ export async function resolve(
|
|
|
161
185
|
{ env = process.env, fetchImpl = fetch, project = null, force = false, reference = false, timeouts } = {},
|
|
162
186
|
) {
|
|
163
187
|
const home = resolveAssetLibraryRoots(env).write;
|
|
164
|
-
const catalog = await
|
|
188
|
+
const catalog = await loadCatalogForResolveAsync(ref, { env, fetchImpl, timeouts });
|
|
165
189
|
const item = findCatalogItem(catalog, ref);
|
|
166
190
|
|
|
167
191
|
const tier = assetTier(item);
|
|
168
192
|
const hasFiles = Array.isArray(item.files) && item.files.length > 0;
|
|
169
|
-
const destDir =
|
|
193
|
+
const destDir = path.join(home, item.category, item.id);
|
|
170
194
|
|
|
171
195
|
// キャッシュヒット → 即返す(未購入だったとしても、一度取得済みなら手元にある実体をそのまま使う。
|
|
172
196
|
// ゲートは「新規に取得するとき」だけにかける)
|
|
173
|
-
const cachedDir =
|
|
197
|
+
const cachedDir = await cachedAssetDirAsync(env, item.category, item.id);
|
|
174
198
|
if (!force && cachedDir) {
|
|
175
199
|
const result = { id: item.id, category: item.category, dir: cachedDir, cached: true };
|
|
176
200
|
if (project && reference) {
|
|
@@ -244,6 +268,7 @@ export async function resolve(
|
|
|
244
268
|
|
|
245
269
|
// still / scene3d 等、meta.json を実体に持つ素材は validate-asset で契約検証してから登録する
|
|
246
270
|
if (hasMeta) {
|
|
271
|
+
await backfillLegacyMetaTier(path.join(tempAssetDir, 'meta.json'), item);
|
|
247
272
|
const result = await validateAsset(tempAssetDir);
|
|
248
273
|
if (result.status !== 0) {
|
|
249
274
|
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
|
|
@@ -327,6 +352,8 @@ async function resolvePaidZip(item, { env, fetchImpl, project, reference, home,
|
|
|
327
352
|
});
|
|
328
353
|
}
|
|
329
354
|
|
|
355
|
+
await backfillLegacyMetaTier(path.join(tempAssetDir, 'meta.json'), item);
|
|
356
|
+
|
|
330
357
|
{
|
|
331
358
|
const result = await validateAsset(tempAssetDir);
|
|
332
359
|
if (result.status !== 0) {
|
|
@@ -3,19 +3,19 @@ import { lstat, readdir, realpath, stat } from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { resolveAssetLibraryRoots } from '../../creator-root/src/index.mjs';
|
|
6
|
-
import { readProjectReferences,
|
|
6
|
+
import { readProjectReferences, resolveLibraryFallbackAsync } from './project-references.mjs';
|
|
7
7
|
|
|
8
8
|
const within = (root, target) => {
|
|
9
9
|
const rel = path.relative(root, target);
|
|
10
10
|
return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
export async function resolveProjectAssetPath(project, declaredPath, env = process.env) {
|
|
13
|
+
export async function resolveProjectAssetPath(project, declaredPath, env = process.env, context) {
|
|
14
14
|
const normalized = declaredPath.replaceAll('\\', '/');
|
|
15
15
|
if (path.isAbsolute(normalized) || normalized.split('/').some(part => part === '..' || part === '.')) {
|
|
16
16
|
throw new Error('素材パスがプロジェクトの外を指しています');
|
|
17
17
|
}
|
|
18
|
-
const root = await realpath(project);
|
|
18
|
+
const root = context?.projectRoot ?? await realpath(project);
|
|
19
19
|
const local = path.resolve(root, normalized);
|
|
20
20
|
if (!within(root, local)) throw new Error('素材パスがプロジェクトの外を指しています');
|
|
21
21
|
// An existing local entry always wins, including a broken or escaping symlink:
|
|
@@ -35,34 +35,44 @@ export async function resolveProjectAssetPath(project, declaredPath, env = proce
|
|
|
35
35
|
if (!within(root, actual)) throw new Error('素材パスがプロジェクトの外を指しています');
|
|
36
36
|
return (await stat(actual)).isFile() ? actual : null;
|
|
37
37
|
}
|
|
38
|
-
const references = await readProjectReferences(root);
|
|
39
|
-
for (const akariAssetsDir of resolveAssetLibraryRoots(env).read) {
|
|
40
|
-
const found =
|
|
38
|
+
const references = context?.references ?? await readProjectReferences(root);
|
|
39
|
+
for (const akariAssetsDir of (context?.libraryRoots ?? resolveAssetLibraryRoots(env).read)) {
|
|
40
|
+
const found = await resolveLibraryFallbackAsync({ declaredPath: normalized, references, akariAssetsDir,
|
|
41
|
+
rootRealpaths: context?.rootRealpaths });
|
|
41
42
|
if (found) return found;
|
|
42
43
|
}
|
|
43
44
|
return null;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/** Enumerate only ledger-authorized files, in library read order; no symlink traversal. */
|
|
47
|
-
export async function listProjectReferenceAssets(project, env = process.env) {
|
|
48
|
-
const references = await readProjectReferences(project);
|
|
48
|
+
export async function listProjectReferenceAssets(project, env = process.env, context) {
|
|
49
|
+
const references = context?.references ?? await readProjectReferences(project);
|
|
50
|
+
const libraryRoots = context?.libraryRoots ?? resolveAssetLibraryRoots(env).read;
|
|
51
|
+
const rootRealpaths = context?.rootRealpaths ?? new Map();
|
|
49
52
|
return Promise.all(references.map(async reference => {
|
|
50
53
|
const files = new Map();
|
|
51
54
|
let libraryDir;
|
|
52
55
|
if ([reference.category, reference.id].some(value => !value || value === '.' || value === '..' || /[\\/]/.test(value))) {
|
|
53
56
|
return { ...reference, files: [] };
|
|
54
57
|
}
|
|
55
|
-
for (const root of
|
|
58
|
+
for (const root of libraryRoots) {
|
|
56
59
|
const directory = path.resolve(root, reference.category, reference.id);
|
|
57
60
|
if (!within(path.resolve(root), directory)) continue;
|
|
58
|
-
|
|
61
|
+
const lexicalRoot = path.resolve(root);
|
|
62
|
+
let actualRoot = rootRealpaths.get(lexicalRoot);
|
|
63
|
+
if (!actualRoot) {
|
|
64
|
+
actualRoot = realpath(lexicalRoot);
|
|
65
|
+
rootRealpaths.set(lexicalRoot, actualRoot);
|
|
66
|
+
}
|
|
67
|
+
try { if (!within(await actualRoot, await realpath(directory))) continue; } catch { continue; }
|
|
59
68
|
const walk = async (dir, prefix = '') => {
|
|
60
69
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
61
70
|
const name = prefix + entry.name;
|
|
62
71
|
if (entry.isDirectory()) await walk(path.join(dir, entry.name), `${name}/`);
|
|
63
72
|
else {
|
|
64
|
-
const absolute =
|
|
73
|
+
const absolute = await resolveLibraryFallbackAsync({
|
|
65
74
|
declaredPath: `assets/${reference.category}/${reference.id}/${name}`, references, akariAssetsDir: root,
|
|
75
|
+
rootRealpaths,
|
|
66
76
|
});
|
|
67
77
|
if (absolute && !files.has(name)) {
|
|
68
78
|
libraryDir ??= directory;
|
|
@@ -81,11 +91,14 @@ export async function listProjectReferenceAssets(project, env = process.env) {
|
|
|
81
91
|
export async function projectReferenceMediaUris(project, env = process.env, declaredPaths = []) {
|
|
82
92
|
const uris = {};
|
|
83
93
|
const paths = new Set(declaredPaths);
|
|
84
|
-
|
|
94
|
+
const context = { projectRoot: await realpath(project), references: await readProjectReferences(project),
|
|
95
|
+
libraryRoots: resolveAssetLibraryRoots(env).read, rootRealpaths: new Map() };
|
|
96
|
+
for (const asset of await listProjectReferenceAssets(project, env, context)) {
|
|
85
97
|
for (const file of asset.files) paths.add(`assets/${asset.category}/${asset.id}/${file.name}`);
|
|
86
98
|
}
|
|
87
|
-
|
|
88
|
-
|
|
99
|
+
const resolved = await Promise.all([...paths].map(async declared =>
|
|
100
|
+
[declared, await resolveProjectAssetPath(project, declared, env, context)]));
|
|
101
|
+
for (const [declared, actual] of resolved) {
|
|
89
102
|
if (actual) uris[declared.replaceAll('\\', '/')] = pathToFileURL(actual).href;
|
|
90
103
|
}
|
|
91
104
|
return uris;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
4
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import { listProjectReferenceAssets, projectReferenceMediaUris } from '../src/shell-reference.mjs';
|
|
10
|
+
|
|
11
|
+
const names = ['realpathSync', 'lstatSync', 'statSync', 'readFileSync', 'readdirSync', 'existsSync'];
|
|
12
|
+
|
|
13
|
+
async function fixture(t) {
|
|
14
|
+
const root = await mkdtemp(path.join(tmpdir(), 'reference-async-'));
|
|
15
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
16
|
+
const project = path.join(root, 'project');
|
|
17
|
+
const library = path.join(root, 'library');
|
|
18
|
+
await mkdir(path.join(project, '.akari'), { recursive: true });
|
|
19
|
+
await mkdir(path.join(library, 'still', 'sample'), { recursive: true });
|
|
20
|
+
await writeFile(path.join(project, '.akari', 'asset-references.json'),
|
|
21
|
+
JSON.stringify({ version: 0, references: [{ category: 'still', id: 'sample' }] }));
|
|
22
|
+
const env = { ...process.env, AKARI_HOME: path.join(root, 'home'),
|
|
23
|
+
AKARI_CREATOR_ROOT: path.join(root, 'creator'), AKARI_LIBRARY_ROOT: library };
|
|
24
|
+
return { project, library, env };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('参照 URI と列挙は素材数に応じた同期 I/O を行わない', async t => {
|
|
28
|
+
const f = await fixture(t);
|
|
29
|
+
const originals = Object.fromEntries(names.map(name => [name, fs[name]]));
|
|
30
|
+
let direct = 0;
|
|
31
|
+
let total = 0;
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
fs[name] = (...args) => {
|
|
34
|
+
total++;
|
|
35
|
+
const caller = (new Error().stack ?? '').split('\n')[2] ?? '';
|
|
36
|
+
if (/asset-resolver[/\\]src[/\\](shell-reference|project-references)\.mjs/.test(caller)) direct++;
|
|
37
|
+
return originals[name](...args);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
syncBuiltinESMExports();
|
|
41
|
+
t.after(() => {
|
|
42
|
+
for (const name of names) fs[name] = originals[name];
|
|
43
|
+
syncBuiltinESMExports();
|
|
44
|
+
});
|
|
45
|
+
const counts = [];
|
|
46
|
+
for (const size of [3, 30]) {
|
|
47
|
+
const dir = path.join(f.library, 'still', 'sample');
|
|
48
|
+
for (let n = 0; n < size; n++) await writeFile(path.join(dir, `frame-${n}.png`), 'image');
|
|
49
|
+
const before = total;
|
|
50
|
+
const entries = await listProjectReferenceAssets(f.project, f.env);
|
|
51
|
+
const uris = await projectReferenceMediaUris(f.project, f.env);
|
|
52
|
+
counts.push(total - before);
|
|
53
|
+
assert.equal(entries[0].files.length, size);
|
|
54
|
+
assert.deepEqual(new Set(Object.keys(uris)), new Set(Array.from({ length: size }, (_, n) =>
|
|
55
|
+
`assets/still/sample/frame-${n}.png`)));
|
|
56
|
+
assert.equal(uris['assets/still/sample/frame-0.png'],
|
|
57
|
+
pathToFileURL(path.join(dir, 'frame-0.png')).href);
|
|
58
|
+
}
|
|
59
|
+
assert.equal(direct, 0);
|
|
60
|
+
assert.equal(counts[1], counts[0]);
|
|
61
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { resolve as resolveAsset } from '../src/resolve.mjs';
|
|
7
|
+
import { setupFixtureEnv } from './helpers.mjs';
|
|
8
|
+
|
|
9
|
+
function sha256(buffer) {
|
|
10
|
+
return createHash('sha256').update(buffer).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function serveMeta({ id = 'mini-still', tier, catalogTier = 'free', omitCatalogTier = false } = {}) {
|
|
14
|
+
const fixture = setupFixtureEnv();
|
|
15
|
+
const item = fixture.catalog.items.find(entry => entry.id === id);
|
|
16
|
+
const file = item.files.find(entry => entry.name === 'meta.json');
|
|
17
|
+
const servedMeta = JSON.parse(readFileSync(path.join(fixture.baseDir, file.key), 'utf8'));
|
|
18
|
+
delete servedMeta.tier;
|
|
19
|
+
if (tier !== undefined) servedMeta.tier = tier;
|
|
20
|
+
const bytes = Buffer.from(`${JSON.stringify(servedMeta, null, 2)}\n`);
|
|
21
|
+
writeFileSync(path.join(fixture.baseDir, file.key), bytes);
|
|
22
|
+
file.sha256 = sha256(bytes);
|
|
23
|
+
file.bytes = bytes.length;
|
|
24
|
+
if (omitCatalogTier) delete item.tier;
|
|
25
|
+
else item.tier = catalogTier;
|
|
26
|
+
writeFileSync(fixture.catalogPath, `${JSON.stringify(fixture.catalog, null, 2)}\n`);
|
|
27
|
+
return { ...fixture, bytes };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('resolve: sha256 検証済みの旧 free meta.json に catalog tier を補完する', async () => {
|
|
31
|
+
const { env, home } = serveMeta({ catalogTier: 'free' });
|
|
32
|
+
const result = await resolveAsset('mini-still', { env });
|
|
33
|
+
|
|
34
|
+
assert.equal(result.cached, false);
|
|
35
|
+
assert.equal(JSON.parse(readFileSync(path.join(result.dir, 'meta.json'), 'utf8')).tier, 'free');
|
|
36
|
+
assert.deepEqual(readdirSync(home).filter(name => name.startsWith('.tmp-resolve-')), []);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('resolve: catalog に tier がなく price: 0 の旧 meta.json は free として解決する', async () => {
|
|
40
|
+
const { env } = serveMeta({ omitCatalogTier: true });
|
|
41
|
+
const result = await resolveAsset('mini-still', { env });
|
|
42
|
+
assert.equal(result.cached, false);
|
|
43
|
+
assert.equal(JSON.parse(readFileSync(path.join(result.dir, 'meta.json'), 'utf8')).tier, 'free');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('resolve: meta.json に tier がある場合は配信バイト列を保持する', async () => {
|
|
47
|
+
const { env, bytes } = serveMeta({ tier: 'free', catalogTier: 'free' });
|
|
48
|
+
const result = await resolveAsset('mini-still', { env });
|
|
49
|
+
assert.equal(result.cached, false);
|
|
50
|
+
assert.equal(readFileSync(path.join(result.dir, 'meta.json'), 'utf8'), bytes.toString('utf8'));
|
|
51
|
+
assert.equal(existsSync(path.join(result.dir, 'meta.json')), true);
|
|
52
|
+
});
|
|
@@ -75,15 +75,7 @@ function cap(kind, filled, x, y, direction, size, color, sw, minimumOutline) {
|
|
|
75
75
|
const ow = Math.max(minimumOutline, sw * .7);
|
|
76
76
|
const outline = `fill="${filled ? color : 'none'}" stroke="${color}" stroke-width="${num(ow)}"`;
|
|
77
77
|
if (kind === 'triangle') {
|
|
78
|
-
|
|
79
|
-
if (!filled) {
|
|
80
|
-
tipInset = ow * Math.sqrt(1.25);
|
|
81
|
-
for (let i = 0; i < 12; i++) {
|
|
82
|
-
tipInset = ow * Math.hypot(size - tipInset, h) / (2 * h);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
const tip = x - direction * tipInset;
|
|
86
|
-
return `<polygon points="${num(tip)},${num(y)} ${num(x - direction * size)},${num(y - h)} ${num(x - direction * size)},${num(y + h)}" ${filled ? `fill="${color}"` : outline}/>`;
|
|
78
|
+
return `<polygon points="${num(x)},${num(y)} ${num(x - direction * size)},${num(y - h)} ${num(x - direction * size)},${num(y + h)}" fill="${color}"/>`;
|
|
87
79
|
}
|
|
88
80
|
if (kind === 'chevron') {
|
|
89
81
|
// Account for the miter at the tip and the stroke at the two roots.
|
|
@@ -112,6 +104,12 @@ function capInset(kind, size, strokeWidth) {
|
|
|
112
104
|
return strokeWidth / 2;
|
|
113
105
|
if (kind === 'chevron')
|
|
114
106
|
return chevronGeometry(size, strokeWidth).miter;
|
|
107
|
+
if (kind === 'diamond')
|
|
108
|
+
return size - Math.min(strokeWidth / 2, size / 2);
|
|
109
|
+
if (kind === 'circle') {
|
|
110
|
+
const radius = size / 2;
|
|
111
|
+
return radius + Math.sqrt(Math.max(0, radius * radius - (strokeWidth / 2) ** 2));
|
|
112
|
+
}
|
|
115
113
|
return size;
|
|
116
114
|
}
|
|
117
115
|
function strokeMetrics(visibleWidth, scaleX, scaleY) {
|
|
@@ -119,7 +117,7 @@ function strokeMetrics(visibleWidth, scaleX, scaleY) {
|
|
|
119
117
|
return {
|
|
120
118
|
width: visibleWidth / correction,
|
|
121
119
|
gap: Math.max(visibleWidth * 2, 3) / correction,
|
|
122
|
-
capSize: Math.max(visibleWidth *
|
|
120
|
+
capSize: Math.max(visibleWidth * 3.75, 12) / correction,
|
|
123
121
|
minimumOutline: 1 / correction,
|
|
124
122
|
};
|
|
125
123
|
}
|
|
@@ -142,10 +140,16 @@ function lineBody(p, width, height, metrics, color, visibleStrokeWidth, scaleX,
|
|
|
142
140
|
const rounded = p.lineCap === 'round' && dash !== 'dot';
|
|
143
141
|
const dashAttr = dashAttribute(dash, metrics, rounded);
|
|
144
142
|
const stretched = scaleX !== scaleY;
|
|
145
|
-
const capSize = stretched ? Math.max(visibleStrokeWidth *
|
|
146
|
-
const startSize = capSize * clamp(p.startCapScale, 1, .5, 3);
|
|
147
|
-
const endSize = capSize * clamp(p.endCapScale, 1, .5, 3);
|
|
143
|
+
const capSize = stretched ? Math.max(visibleStrokeWidth * 3.75, 12) : size;
|
|
148
144
|
const capStroke = stretched ? visibleStrokeWidth : sw;
|
|
145
|
+
// At the smallest slider value the open arms must remain longer than 3w.
|
|
146
|
+
// A 4w envelope gives a centerline arm of about 3.16w.
|
|
147
|
+
const effectiveSize = (kind, scale) => {
|
|
148
|
+
const scaled = capSize * clamp(scale, 1, .5, 3);
|
|
149
|
+
return kind === 'chevron' ? Math.max(scaled, capStroke * 4) : scaled;
|
|
150
|
+
};
|
|
151
|
+
const startSize = effectiveSize(start, p.startCapScale);
|
|
152
|
+
const endSize = effectiveSize(end, p.endCapScale);
|
|
149
153
|
const inset = (kind, capLength) => capInset(kind, capLength, capStroke) / (stretched ? scaleX : 1);
|
|
150
154
|
const extension = rounded ? sw / 2 : 0;
|
|
151
155
|
const x1 = inset(start, startSize) + extension;
|
|
@@ -4035,15 +4035,7 @@ var AkariEditKernel = (() => {
|
|
|
4035
4035
|
const ow = Math.max(minimumOutline, sw * 0.7);
|
|
4036
4036
|
const outline = `fill="${filled ? color2 : "none"}" stroke="${color2}" stroke-width="${num(ow)}"`;
|
|
4037
4037
|
if (kind === "triangle") {
|
|
4038
|
-
|
|
4039
|
-
if (!filled) {
|
|
4040
|
-
tipInset = ow * Math.sqrt(1.25);
|
|
4041
|
-
for (let i = 0; i < 12; i++) {
|
|
4042
|
-
tipInset = ow * Math.hypot(size - tipInset, h) / (2 * h);
|
|
4043
|
-
}
|
|
4044
|
-
}
|
|
4045
|
-
const tip = x - direction * tipInset;
|
|
4046
|
-
return `<polygon points="${num(tip)},${num(y)} ${num(x - direction * size)},${num(y - h)} ${num(x - direction * size)},${num(y + h)}" ${filled ? `fill="${color2}"` : outline}/>`;
|
|
4038
|
+
return `<polygon points="${num(x)},${num(y)} ${num(x - direction * size)},${num(y - h)} ${num(x - direction * size)},${num(y + h)}" fill="${color2}"/>`;
|
|
4047
4039
|
}
|
|
4048
4040
|
if (kind === "chevron") {
|
|
4049
4041
|
const { halfWidth, miter, root } = chevronGeometry(size, sw);
|
|
@@ -4067,6 +4059,11 @@ var AkariEditKernel = (() => {
|
|
|
4067
4059
|
if (kind === "none") return 0;
|
|
4068
4060
|
if (kind === "bar") return strokeWidth / 2;
|
|
4069
4061
|
if (kind === "chevron") return chevronGeometry(size, strokeWidth).miter;
|
|
4062
|
+
if (kind === "diamond") return size - Math.min(strokeWidth / 2, size / 2);
|
|
4063
|
+
if (kind === "circle") {
|
|
4064
|
+
const radius = size / 2;
|
|
4065
|
+
return radius + Math.sqrt(Math.max(0, radius * radius - (strokeWidth / 2) ** 2));
|
|
4066
|
+
}
|
|
4070
4067
|
return size;
|
|
4071
4068
|
}
|
|
4072
4069
|
function strokeMetrics(visibleWidth, scaleX, scaleY) {
|
|
@@ -4074,7 +4071,7 @@ var AkariEditKernel = (() => {
|
|
|
4074
4071
|
return {
|
|
4075
4072
|
width: visibleWidth / correction,
|
|
4076
4073
|
gap: Math.max(visibleWidth * 2, 3) / correction,
|
|
4077
|
-
capSize: Math.max(visibleWidth *
|
|
4074
|
+
capSize: Math.max(visibleWidth * 3.75, 12) / correction,
|
|
4078
4075
|
minimumOutline: 1 / correction
|
|
4079
4076
|
};
|
|
4080
4077
|
}
|
|
@@ -4096,10 +4093,14 @@ var AkariEditKernel = (() => {
|
|
|
4096
4093
|
const rounded = p.lineCap === "round" && dash !== "dot";
|
|
4097
4094
|
const dashAttr = dashAttribute(dash, metrics, rounded);
|
|
4098
4095
|
const stretched = scaleX !== scaleY;
|
|
4099
|
-
const capSize = stretched ? Math.max(visibleStrokeWidth *
|
|
4100
|
-
const startSize = capSize * clamp3(p.startCapScale, 1, 0.5, 3);
|
|
4101
|
-
const endSize = capSize * clamp3(p.endCapScale, 1, 0.5, 3);
|
|
4096
|
+
const capSize = stretched ? Math.max(visibleStrokeWidth * 3.75, 12) : size;
|
|
4102
4097
|
const capStroke = stretched ? visibleStrokeWidth : sw;
|
|
4098
|
+
const effectiveSize = (kind, scale) => {
|
|
4099
|
+
const scaled = capSize * clamp3(scale, 1, 0.5, 3);
|
|
4100
|
+
return kind === "chevron" ? Math.max(scaled, capStroke * 4) : scaled;
|
|
4101
|
+
};
|
|
4102
|
+
const startSize = effectiveSize(start, p.startCapScale);
|
|
4103
|
+
const endSize = effectiveSize(end, p.endCapScale);
|
|
4103
4104
|
const inset = (kind, capLength) => capInset(kind, capLength, capStroke) / (stretched ? scaleX : 1);
|
|
4104
4105
|
const extension = rounded ? sw / 2 : 0;
|
|
4105
4106
|
const x1 = inset(start, startSize) + extension;
|