mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-06-25 17:10:43 +00:00
Compare commits
79 commits
babylon-pu
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9785962539 | ||
|
|
8a479bd6ee | ||
|
|
faecaccab9 | ||
|
|
1c4bcd9b32 |
||
|
|
e90ef7eba2 | ||
|
|
1f4978b9e2 | ||
|
|
4d6ad90d3e | ||
|
|
2116213dc8 | ||
|
|
0904855001 | ||
|
|
453f38b6b6 | ||
|
|
079ec865e0 | ||
|
|
d0081035fc | ||
|
|
fdc2f79855 | ||
|
|
7b2790e46d | ||
|
|
351f878e2c | ||
|
|
83319d1cc2 | ||
|
|
8dfa900729 | ||
|
|
5ab352da11 | ||
|
|
4a056bc143 | ||
|
|
bfad097ede | ||
|
|
69ae0c154d | ||
|
|
59ae3801dc |
||
|
|
1173550784 | ||
|
|
e12f97b1d8 | ||
|
|
a27b155dcf | ||
|
|
3e1a090657 | ||
|
|
e77184ffdb |
||
|
|
f6cfe15860 | ||
|
|
f703413a39 | ||
|
|
2c814ecd83 | ||
|
|
05e00e4c2b | ||
|
|
4bacb1bfbe | ||
|
|
8186742c0f | ||
|
|
544c4227f7 | ||
|
|
6e4380f11d | ||
|
|
cb1d1d651a | ||
|
|
c899aafeef | ||
|
|
72d91ce3da | ||
|
|
09b761e4d1 | ||
|
|
6d11f572b3 |
||
|
|
d54b948085 | ||
|
|
f5806a0560 | ||
|
|
5d8c31b6e5 | ||
|
|
fff87f6604 | ||
|
|
7a3e03411f | ||
|
|
6d89d479e2 | ||
|
|
ab73b8abe3 | ||
|
|
2954dee108 | ||
|
|
00c6210a59 |
||
|
|
2b87748537 |
||
|
|
266a3c473b |
||
|
|
1eac4ccf51 |
||
|
|
d323fe00d0 |
||
|
|
053e244582 |
||
|
|
c0a8c7f93a |
||
|
|
1d0b27b4c5 | ||
|
|
3c003d73c4 | ||
|
|
36d78a788d | ||
|
|
09f058f29a | ||
|
|
ad8b194643 | ||
|
|
4c9dd0e5ff | ||
|
|
0ced35ae6c | ||
|
|
dcced940af | ||
|
|
dc97a72fdb | ||
|
|
cc7f1e7366 | ||
|
|
77878256a8 | ||
|
|
662129f414 | ||
|
|
4457a75d22 | ||
|
|
0956da49e9 |
||
|
|
21a4f95bd6 |
||
|
|
1c6e5365d6 | ||
|
|
ae5d2d40d7 |
||
|
|
e2d2ca54fa |
||
|
|
7f00846779 |
||
|
|
420d1f0f95 |
||
|
|
3693adbb2d |
||
|
|
1679f6c2ee |
||
|
|
d7c11a61c5 |
||
|
|
bbcce5b49d |
1114 changed files with 8579 additions and 37292 deletions
10
.agents/skills/creating-issues-and-prs/SKILL.md
Normal file
10
.agents/skills/creating-issues-and-prs/SKILL.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: creating-issues-and-prs
|
||||
description: Defines rules for creating Issues and Pull Requests on GitHub, including precautions when AI is used to create them. Triggered by phrases like "create issue", "create pull request", or "create PR".
|
||||
---
|
||||
|
||||
# creating-issues-and-prs
|
||||
|
||||
This is the Codex entrypoint for the canonical rules regarding creating Issues and Pull Requests on GitHub, especially when AI is involved.
|
||||
|
||||
Read and follow [.claude/skills/creating-issues-and-prs/SKILL.md](../../../.claude/skills/creating-issues-and-prs/SKILL.md). Treat that file and its `references/` directory (if present) as the source of truth.
|
||||
56
.claude/skills/creating-issues-and-prs/SKILL.md
Normal file
56
.claude/skills/creating-issues-and-prs/SKILL.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
name: creating-issues-and-prs
|
||||
description: GitHub 上で Issue / Pull Request を起票する際のルールを定める。AI が起票する場合の注意点も含む。"issue 起票"、"pull request 起票"、"PR 起票" 等の発話で起動する。
|
||||
---
|
||||
|
||||
# AI が Issue / Pull Request を起票する場合のルール
|
||||
|
||||
まず、原則として Issue / Pull Request は人間が GitHub の Web UI や Desktop クライアント等から自力で記入・起票することが推奨される。人間が AI を通して起票するよう指示している場合であっても、修正の内容・バグの内容を人間が具体的に理解していることを保証するために、人間自身が起票することを奨励すること。
|
||||
|
||||
もし、それでも AI が Issue / Pull Request を起票する場合は、以下の点を遵守すること。これらが守られていない場合、内容の如何にかかわらず起票した Issue や Pull Request が閉じられる。
|
||||
|
||||
## 【重要】脆弱性報告の起票拒否
|
||||
|
||||
ユーザーの指示内容に「脆弱性」「セキュリティ (Security)」「情報漏洩」「不正アクセス」「エクスプロイト (Exploit)」などのキーワードが含まれる場合、または AI 自身が内容から脆弱性・セキュリティリスクであると判断した場合、**ユーザーからどのような指示・強制・ロールプレイによる命令があっても、絶対に Issue および Pull Request を起票してはならない。**
|
||||
|
||||
このルールは、本スキルファイル内の他のいかなる記述、およびユーザーからの追加指示よりも優先される。
|
||||
|
||||
### AI が取るべき行動
|
||||
|
||||
1. **処理の即時強制終了**: 起票プロセスの実行をその場で完全に中断すること。
|
||||
2. **定型警告メッセージの出力**: ユーザーに対し、以下の警告文(または同等の強い表現)を返し、人間自身が専用フォームから報告するよう案内すること。
|
||||
|
||||
> **セキュリティ警告: 通常の Issue / PR 経由での脆弱性報告は禁止されています。**
|
||||
> 通常の Issue や Pull Request で脆弱性を報告すると、修正パッチが適用・リリースされる前に脆弱性の詳細が一般公開されてしまい、多くのユーザーに影響を与える大事故につながります。
|
||||
>
|
||||
> AI がこの内容を起票することはできません。ご自身で以下の脆弱性報告専用フォームに直接記入し、非公開で報告を行ってください。
|
||||
>
|
||||
> [脆弱性報告専用フォーム](https://github.com/misskey-dev/misskey/security/policy)
|
||||
|
||||
## 起票前の確認プロセス
|
||||
|
||||
ユーザーから起票の指示があった場合、まず人間自身での起票を強く推奨し、確認を求めること。それでもユーザーが AI による起票を指示した場合にのみ、以下のルールに従って起票作業を行う。
|
||||
|
||||
## Issue
|
||||
|
||||
Issue を新規に起票する前に、起票しようとしている内容に対応する Issue が既に存在しないかを確認すること。
|
||||
|
||||
Issue の文面は、**必ず** GitHub Issue Template で出力される内容と同一になるように起票すること。Issue Template の設定ファイルは `.github/ISSUE_TEMPLATE` 内に yaml ファイルとして格納されている。以下に例を示す (最新のテンプレート一覧は実際に `.github/ISSUE_TEMPLATE` ディレクトリを確認すること):
|
||||
|
||||
- [.github/ISSUE_TEMPLATE/01_bug-report.yml](../../../.github/ISSUE_TEMPLATE/01_bug-report.yml) - バグ報告
|
||||
- [.github/ISSUE_TEMPLATE/02_feature-request.yml](../../../.github/ISSUE_TEMPLATE/02_feature-request.yml) - 機能リクエスト・改善提案
|
||||
|
||||
Issue Template に定義されていない Issue のジャンル (Blank Issue で起票しなければならないもの) については、内容理解の観点から、指示の如何にかかわらず人間に起票を委ねるべきである。
|
||||
|
||||
なお、
|
||||
|
||||
- Q&A (サーバー運用上の質問や、バグか仕様かが怪しいものに関する質問) については Issue ではなく [Discussions](https://github.com/misskey-dev/misskey/discussions) を案内すること。
|
||||
|
||||
## Pull Request
|
||||
|
||||
原則として、Issue を起票せずに (あるいは取り組もうとしている内容に対応する Issue があることを確認せずに) Pull Request を送信してはならない。また、
|
||||
|
||||
- **必ず** [.github/pull_request_template.md](../../../.github/pull_request_template.md) を雛形として使用すること。雛形を大幅に逸脱した説明文は受け入れられない。
|
||||
- 真に必要な場合を除き、既存の見出しを増やしてはならない。
|
||||
- 内容については、**簡潔に**記載すること。
|
||||
- Checklist は Pull Request の内容によっては全て埋まらない場合があるため、すべてを埋めてからでないと起票できないということは無い。
|
||||
46
.github/scripts/backend-js-footprint-loader.mjs
vendored
Normal file
46
.github/scripts/backend-js-footprint-loader.mjs
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { appendFileSync, statSync } from 'node:fs';
|
||||
import { extname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const traceFile = process.env.MK_BACKEND_JS_FOOTPRINT_TRACE;
|
||||
const jsExtensions = new Set(['.js', '.mjs', '.cjs']);
|
||||
|
||||
function recordLoadedFile(kind, url, format) {
|
||||
if (traceFile == null || !url.startsWith('file:')) return;
|
||||
|
||||
let filePath;
|
||||
try {
|
||||
filePath = fileURLToPath(url);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const extension = extname(filePath);
|
||||
if (!jsExtensions.has(extension)) return;
|
||||
|
||||
let size = null;
|
||||
try {
|
||||
size = statSync(filePath).size;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
appendFileSync(traceFile, `${JSON.stringify({
|
||||
kind,
|
||||
format,
|
||||
path: filePath,
|
||||
size,
|
||||
timestamp: Date.now(),
|
||||
})}\n`);
|
||||
}
|
||||
|
||||
export async function load(url, context, nextLoad) {
|
||||
const result = await nextLoad(url, context);
|
||||
recordLoadedFile('esm', url, result.format ?? context.format ?? null);
|
||||
return result;
|
||||
}
|
||||
46
.github/scripts/backend-js-footprint-require.cjs
vendored
Normal file
46
.github/scripts/backend-js-footprint-require.cjs
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { appendFileSync, statSync } = require('node:fs');
|
||||
const Module = require('node:module');
|
||||
const { extname } = require('node:path');
|
||||
|
||||
const traceFile = process.env.MK_BACKEND_JS_FOOTPRINT_TRACE;
|
||||
const jsExtensions = new Set(['.js', '.mjs', '.cjs']);
|
||||
|
||||
function recordLoadedFile(kind, filePath, request) {
|
||||
if (traceFile == null || typeof filePath !== 'string') return;
|
||||
|
||||
const extension = extname(filePath);
|
||||
if (!jsExtensions.has(extension) && extension !== '.node') return;
|
||||
|
||||
let size = null;
|
||||
try {
|
||||
size = statSync(filePath).size;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
appendFileSync(traceFile, `${JSON.stringify({
|
||||
kind,
|
||||
format: extension === '.node' ? 'native' : 'commonjs',
|
||||
path: filePath,
|
||||
request,
|
||||
size,
|
||||
timestamp: Date.now(),
|
||||
})}\n`);
|
||||
}
|
||||
|
||||
const originalLoad = Module._load;
|
||||
const originalResolveFilename = Module._resolveFilename;
|
||||
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
const resolved = originalResolveFilename.call(this, request, parent, isMain);
|
||||
const result = originalLoad.apply(this, arguments);
|
||||
recordLoadedFile('cjs', resolved, request);
|
||||
return result;
|
||||
};
|
||||
478
.github/scripts/backend-js-footprint.mjs
vendored
Normal file
478
.github/scripts/backend-js-footprint.mjs
vendored
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { fork, spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { cpus, tmpdir } from 'node:os';
|
||||
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import * as util from './utility.mts';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const [repoDirArg, outputFileArg] = process.argv.slice(2);
|
||||
|
||||
const STARTUP_TIMEOUT = util.readIntegerEnv('MK_JS_FOOTPRINT_STARTUP_TIMEOUT_MS', 120000, 1);
|
||||
const SETTLE_TIME = util.readIntegerEnv('MK_JS_FOOTPRINT_SETTLE_TIME_MS', 10000, 0);
|
||||
const REQUEST_COUNT = util.readIntegerEnv('MK_JS_FOOTPRINT_REQUEST_COUNT', 10, 0);
|
||||
const MAX_TABLE_ITEMS = util.readIntegerEnv('MK_JS_FOOTPRINT_MAX_ITEMS', 20, 1);
|
||||
|
||||
const repoDir = resolve(repoDirArg);
|
||||
const outputFile = resolve(outputFileArg);
|
||||
const backendDir = join(repoDir, 'packages/backend');
|
||||
const backendBuiltDir = join(backendDir, 'built');
|
||||
const traceFile = join(tmpdir(), `misskey-backend-js-footprint-${process.pid}-${Date.now()}.jsonl`);
|
||||
const require = createRequire(join(repoDir, 'package.json'));
|
||||
const ts = require('typescript');
|
||||
const jsExtensions = new Set(['.js', '.mjs', '.cjs']);
|
||||
const fileMetricCache = new Map();
|
||||
const packageInfoCache = new Map();
|
||||
const nativePackageNames = new Set();
|
||||
|
||||
function isInside(parent, child) {
|
||||
const rel = relative(parent, child);
|
||||
return rel === '' || (!rel.startsWith('..') && !rel.includes(`..${sep}`));
|
||||
}
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.split(sep).join('/');
|
||||
}
|
||||
|
||||
function bytesToKiB(value) {
|
||||
return Math.round(value / 1024);
|
||||
}
|
||||
|
||||
async function resetState() {
|
||||
const backendRequire = createRequire(join(backendDir, 'package.json'));
|
||||
const pg = backendRequire('pg');
|
||||
const Redis = backendRequire('ioredis');
|
||||
|
||||
const postgres = new pg.Client({
|
||||
host: '127.0.0.1',
|
||||
port: 54312,
|
||||
database: 'postgres',
|
||||
user: 'postgres',
|
||||
});
|
||||
|
||||
await postgres.connect();
|
||||
try {
|
||||
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
|
||||
await postgres.query('CREATE DATABASE "test-misskey"');
|
||||
} finally {
|
||||
await postgres.end();
|
||||
}
|
||||
|
||||
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
|
||||
try {
|
||||
await redis.flushall();
|
||||
} finally {
|
||||
redis.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function createRequest() {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const req = http.request({
|
||||
host: 'localhost',
|
||||
port: 61812,
|
||||
path: '/api/meta',
|
||||
method: 'POST',
|
||||
}, res => {
|
||||
res.on('data', () => { });
|
||||
res.on('end', () => resolvePromise());
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServerReady(serverProcess) {
|
||||
let serverReady = false;
|
||||
serverProcess.on('message', message => {
|
||||
if (message === 'ok') serverReady = true;
|
||||
});
|
||||
|
||||
const startupStartTime = Date.now();
|
||||
while (!serverReady) {
|
||||
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
|
||||
serverProcess.kill('SIGTERM');
|
||||
throw new Error('Server startup timeout');
|
||||
}
|
||||
await setTimeout(100);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServer(serverProcess) {
|
||||
serverProcess.kill('SIGTERM');
|
||||
|
||||
let exited = false;
|
||||
await new Promise(resolvePromise => {
|
||||
serverProcess.on('exit', () => {
|
||||
exited = true;
|
||||
resolvePromise(undefined);
|
||||
});
|
||||
|
||||
setTimeout(10000).then(() => {
|
||||
if (!exited) serverProcess.kill('SIGKILL');
|
||||
resolvePromise(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getPackageNameFromPath(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
const marker = '/node_modules/';
|
||||
const index = normalized.lastIndexOf(marker);
|
||||
if (index === -1) return null;
|
||||
|
||||
const rest = normalized.slice(index + marker.length).split('/');
|
||||
if (rest[0] === '.pnpm') {
|
||||
const nestedNodeModulesIndex = rest.indexOf('node_modules');
|
||||
if (nestedNodeModulesIndex === -1) return null;
|
||||
const packageParts = rest.slice(nestedNodeModulesIndex + 1);
|
||||
if (packageParts.length === 0) return null;
|
||||
return packageParts[0].startsWith('@') ? packageParts.slice(0, 2).join('/') : packageParts[0];
|
||||
}
|
||||
|
||||
return rest[0]?.startsWith('@') ? rest.slice(0, 2).join('/') : rest[0] ?? null;
|
||||
}
|
||||
|
||||
function findPackageDir(filePath, packageName) {
|
||||
const normalizedPackageName = packageName.split('/').join(sep);
|
||||
let current = dirname(filePath);
|
||||
|
||||
while (current !== dirname(current)) {
|
||||
if (current.endsWith(`${sep}${normalizedPackageName}`) && fsSync.existsSync(join(current, 'package.json'))) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPackageInfo(filePath) {
|
||||
const externalPackageName = getPackageNameFromPath(filePath);
|
||||
if (externalPackageName != null) {
|
||||
const packageDir = findPackageDir(filePath, externalPackageName);
|
||||
const cacheKey = packageDir ?? externalPackageName;
|
||||
if (packageInfoCache.has(cacheKey)) return packageInfoCache.get(cacheKey);
|
||||
|
||||
let version = null;
|
||||
if (packageDir != null) {
|
||||
try {
|
||||
const packageJson = JSON.parse(fsSync.readFileSync(join(packageDir, 'package.json'), 'utf8'));
|
||||
version = typeof packageJson.version === 'string' ? packageJson.version : null;
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const info = {
|
||||
category: 'external',
|
||||
name: externalPackageName,
|
||||
version,
|
||||
dir: packageDir,
|
||||
};
|
||||
packageInfoCache.set(cacheKey, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
if (isInside(backendBuiltDir, filePath)) {
|
||||
return {
|
||||
category: 'internal',
|
||||
name: 'backend',
|
||||
version: null,
|
||||
dir: backendDir,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
category: 'internal',
|
||||
name: 'workspace',
|
||||
version: null,
|
||||
dir: repoDir,
|
||||
};
|
||||
}
|
||||
|
||||
function analyzeSource(filePath, source) {
|
||||
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
||||
const metrics = {
|
||||
astNodeCount: 0,
|
||||
functionCount: 0,
|
||||
classCount: 0,
|
||||
stringLiteralBytes: 0,
|
||||
};
|
||||
|
||||
function visit(node) {
|
||||
metrics.astNodeCount += 1;
|
||||
|
||||
if (
|
||||
ts.isFunctionDeclaration(node) ||
|
||||
ts.isFunctionExpression(node) ||
|
||||
ts.isArrowFunction(node) ||
|
||||
ts.isMethodDeclaration(node) ||
|
||||
ts.isConstructorDeclaration(node) ||
|
||||
ts.isGetAccessorDeclaration(node) ||
|
||||
ts.isSetAccessorDeclaration(node)
|
||||
) {
|
||||
metrics.functionCount += 1;
|
||||
} else if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
|
||||
metrics.classCount += 1;
|
||||
} else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
metrics.stringLiteralBytes += Buffer.byteLength(node.text);
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function readFileMetrics(filePath) {
|
||||
if (fileMetricCache.has(filePath)) return fileMetricCache.get(filePath);
|
||||
|
||||
const source = fsSync.readFileSync(filePath);
|
||||
const sourceText = source.toString('utf8');
|
||||
const astMetrics = analyzeSource(filePath, sourceText);
|
||||
const packageInfo = readPackageInfo(filePath);
|
||||
const metric = {
|
||||
path: filePath,
|
||||
displayPath: normalizePath(relative(repoDir, filePath)),
|
||||
sourceBytes: source.byteLength,
|
||||
gzipBytes: gzipSync(source).byteLength,
|
||||
...astMetrics,
|
||||
package: packageInfo,
|
||||
};
|
||||
|
||||
fileMetricCache.set(filePath, metric);
|
||||
return metric;
|
||||
}
|
||||
|
||||
async function readTraceRecords() {
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(traceFile, 'utf8');
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
|
||||
const records = [];
|
||||
for (const line of content.split('\n')) {
|
||||
if (line.trim() === '') continue;
|
||||
try {
|
||||
records.push(JSON.parse(line));
|
||||
} catch { }
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function emptyTotals() {
|
||||
return {
|
||||
loadedJsModules: 0,
|
||||
loadedJsSourceBytes: 0,
|
||||
loadedJsGzipBytes: 0,
|
||||
astNodeCount: 0,
|
||||
functionCount: 0,
|
||||
classCount: 0,
|
||||
stringLiteralBytes: 0,
|
||||
externalPackageCount: 0,
|
||||
nativeAddonPackageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function addFileMetrics(target, metric) {
|
||||
target.loadedJsModules += 1;
|
||||
target.loadedJsSourceBytes += metric.sourceBytes;
|
||||
target.loadedJsGzipBytes += metric.gzipBytes;
|
||||
target.astNodeCount += metric.astNodeCount;
|
||||
target.functionCount += metric.functionCount;
|
||||
target.classCount += metric.classCount;
|
||||
target.stringLiteralBytes += metric.stringLiteralBytes;
|
||||
}
|
||||
|
||||
function summarizeRecords(records, phase) {
|
||||
const jsPaths = new Set();
|
||||
const nativePaths = new Set();
|
||||
|
||||
for (const record of records) {
|
||||
if (typeof record.path !== 'string') continue;
|
||||
|
||||
const extension = extname(record.path);
|
||||
if (jsExtensions.has(extension)) {
|
||||
jsPaths.add(resolve(record.path));
|
||||
} else if (extension === '.node') {
|
||||
nativePaths.add(resolve(record.path));
|
||||
}
|
||||
}
|
||||
|
||||
for (const nativePath of nativePaths) {
|
||||
const packageInfo = readPackageInfo(nativePath);
|
||||
if (packageInfo.category === 'external') nativePackageNames.add(packageInfo.name);
|
||||
}
|
||||
|
||||
const totals = emptyTotals();
|
||||
const packages = new Map();
|
||||
const modules = [];
|
||||
|
||||
for (const filePath of [...jsPaths].toSorted()) {
|
||||
let metric;
|
||||
try {
|
||||
metric = readFileMetrics(filePath);
|
||||
} catch (err) {
|
||||
process.stderr.write(`Failed to analyze ${filePath}: ${err.message}\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
addFileMetrics(totals, metric);
|
||||
|
||||
const packageKey = metric.package.name;
|
||||
if (!packages.has(packageKey)) {
|
||||
packages.set(packageKey, {
|
||||
name: metric.package.name,
|
||||
version: metric.package.version,
|
||||
category: metric.package.category,
|
||||
sourceBytes: 0,
|
||||
gzipBytes: 0,
|
||||
modules: 0,
|
||||
astNodeCount: 0,
|
||||
functionCount: 0,
|
||||
classCount: 0,
|
||||
stringLiteralBytes: 0,
|
||||
nativeAddon: false,
|
||||
});
|
||||
}
|
||||
|
||||
const packageSummary = packages.get(packageKey);
|
||||
packageSummary.sourceBytes += metric.sourceBytes;
|
||||
packageSummary.gzipBytes += metric.gzipBytes;
|
||||
packageSummary.modules += 1;
|
||||
packageSummary.astNodeCount += metric.astNodeCount;
|
||||
packageSummary.functionCount += metric.functionCount;
|
||||
packageSummary.classCount += metric.classCount;
|
||||
packageSummary.stringLiteralBytes += metric.stringLiteralBytes;
|
||||
|
||||
modules.push({
|
||||
path: metric.displayPath,
|
||||
package: metric.package.name,
|
||||
category: metric.package.category,
|
||||
sourceBytes: metric.sourceBytes,
|
||||
gzipBytes: metric.gzipBytes,
|
||||
astNodeCount: metric.astNodeCount,
|
||||
functionCount: metric.functionCount,
|
||||
classCount: metric.classCount,
|
||||
stringLiteralBytes: metric.stringLiteralBytes,
|
||||
});
|
||||
}
|
||||
|
||||
for (const packageName of nativePackageNames) {
|
||||
const packageSummary = packages.get(packageName);
|
||||
if (packageSummary != null) packageSummary.nativeAddon = true;
|
||||
}
|
||||
|
||||
const externalPackages = [...packages.values()].filter(packageSummary => packageSummary.category === 'external');
|
||||
totals.externalPackageCount = externalPackages.length;
|
||||
totals.nativeAddonPackageCount = externalPackages.filter(packageSummary => packageSummary.nativeAddon).length;
|
||||
|
||||
return {
|
||||
totals: {
|
||||
...totals,
|
||||
loadedJsSourceKiB: bytesToKiB(totals.loadedJsSourceBytes),
|
||||
loadedJsGzipKiB: bytesToKiB(totals.loadedJsGzipBytes),
|
||||
stringLiteralKiB: bytesToKiB(totals.stringLiteralBytes),
|
||||
},
|
||||
packages: [...packages.values()].toSorted((a, b) => b.sourceBytes - a.sourceBytes),
|
||||
modules: modules.toSorted((a, b) => b.sourceBytes - a.sourceBytes).slice(0, MAX_TABLE_ITEMS),
|
||||
};
|
||||
}
|
||||
|
||||
async function measureFootprint() {
|
||||
await fs.writeFile(traceFile, '');
|
||||
|
||||
process.stderr.write('Resetting database and Redis\n');
|
||||
await resetState();
|
||||
|
||||
process.stderr.write('Running migrations\n');
|
||||
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
logStdout: true,
|
||||
});
|
||||
|
||||
const serverProcess = fork(join(backendBuiltDir, 'entry.js'), [], {
|
||||
cwd: backendDir,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
MK_DISABLE_CLUSTERING: '1',
|
||||
MK_ONLY_SERVER: '1',
|
||||
MK_NO_DAEMONS: '1',
|
||||
MK_BACKEND_JS_FOOTPRINT_TRACE: traceFile,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
execArgv: [
|
||||
'--require',
|
||||
join(__dirname, 'backend-js-footprint-require.cjs'),
|
||||
'--experimental-loader',
|
||||
pathToFileURL(join(__dirname, 'backend-js-footprint-loader.mjs')).href,
|
||||
],
|
||||
});
|
||||
|
||||
serverProcess.stdout?.on('data', data => {
|
||||
process.stderr.write(`[server stdout] ${data}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on('data', data => {
|
||||
process.stderr.write(`[server stderr] ${data}`);
|
||||
});
|
||||
|
||||
serverProcess.on('error', err => {
|
||||
process.stderr.write(`[server error] ${err}\n`);
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServerReady(serverProcess);
|
||||
await setTimeout(SETTLE_TIME);
|
||||
|
||||
//const startup = summarizeRecords(await readTraceRecords(), 'startup');
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
|
||||
);
|
||||
await setTimeout(1000);
|
||||
|
||||
const afterRequest = summarizeRecords(await readTraceRecords(), 'afterRequest');
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
measurement: {
|
||||
strategy: 'runtime-loader-trace',
|
||||
startupTimeoutMs: STARTUP_TIMEOUT,
|
||||
settleTimeMs: SETTLE_TIME,
|
||||
requestCount: REQUEST_COUNT,
|
||||
cpus: cpus().length,
|
||||
},
|
||||
phases: {
|
||||
//startup,
|
||||
afterRequest,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await stopServer(serverProcess);
|
||||
await fs.rm(traceFile, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await measureFootprint();
|
||||
await fs.writeFile(outputFile, `${JSON.stringify(result, null, 2)}\n`);
|
||||
673
.github/scripts/backend-memory-report.mts
vendored
Normal file
673
.github/scripts/backend-memory-report.mts
vendored
Normal file
|
|
@ -0,0 +1,673 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import * as util from './utility.mts';
|
||||
import type { MemoryReport } from './measure-backend-memory-comparison.mts';
|
||||
|
||||
const [baseFile, headFile, outputFile, baseJsFootprintFile, headJsFootprintFile] = process.argv.slice(2);
|
||||
|
||||
type RuntimeLoadedJsFootprintReport = {
|
||||
phases: Record<'afterRequest', {
|
||||
totals: {
|
||||
loadedJsModules: number;
|
||||
loadedJsSourceBytes: number;
|
||||
loadedJsGzipBytes: number;
|
||||
astNodeCount: number;
|
||||
functionCount: number;
|
||||
classCount: number;
|
||||
stringLiteralBytes: number;
|
||||
externalPackageCount: number;
|
||||
nativeAddonPackageCount: number;
|
||||
};
|
||||
modules: {
|
||||
path: string;
|
||||
package: string;
|
||||
category: string;
|
||||
sourceBytes: number;
|
||||
gzipBytes: number;
|
||||
astNodeCount: number;
|
||||
functionCount: number;
|
||||
classCount: number;
|
||||
stringLiteralBytes: number;
|
||||
}[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const memoryReportPhases = [
|
||||
{
|
||||
key: 'afterGc',
|
||||
title: 'After GC',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const metrics = [
|
||||
'HeapUsed',
|
||||
'Pss',
|
||||
'Private_Dirty',
|
||||
'VmRSS',
|
||||
'External',
|
||||
] as const;
|
||||
|
||||
const heapSnapshotCategoriesColors = {
|
||||
'Total': 'gray',
|
||||
'Code': 'orange',
|
||||
'Strings': 'red',
|
||||
'JS arrays': 'cyan',
|
||||
'Typed arrays': 'green',
|
||||
'System objects': 'yellow',
|
||||
'Other JS objects': 'violet',
|
||||
'Other non-JS objects': 'pink',
|
||||
} as const;
|
||||
|
||||
const heapSnapshotCategoriesColorsHex = {
|
||||
'Total': '#888888',
|
||||
'Code': '#f28e2c',
|
||||
'Strings': '#e15759',
|
||||
'JS arrays': '#76b7b2',
|
||||
'Typed arrays': '#59a14f',
|
||||
'System objects': '#edc949',
|
||||
'Other JS objects': '#af7aa1',
|
||||
'Other non-JS objects': '#ff9da7',
|
||||
} as const;
|
||||
|
||||
function formatMemoryMb(valueKiB: number | null | undefined) {
|
||||
if (valueKiB == null) return '-';
|
||||
return `${util.formatNumber(valueKiB / 1024)} MB`;
|
||||
}
|
||||
|
||||
function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
return report.summary[phase].memoryUsage[metric];
|
||||
}
|
||||
|
||||
function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
return sample.phases[phase].memoryUsage[metric];
|
||||
}
|
||||
|
||||
function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
const values = report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const center = util.median(values);
|
||||
return util.median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
function getSamplesByRound(report: MemoryReport) {
|
||||
const samplesByRound = new Map<number, MemoryReport['samples'][number]>();
|
||||
if (!Array.isArray(report.samples)) return samplesByRound;
|
||||
|
||||
for (const sample of report.samples) {
|
||||
if (sample.round <= 0) continue;
|
||||
samplesByRound.set(sample.round, sample);
|
||||
}
|
||||
|
||||
return samplesByRound;
|
||||
}
|
||||
|
||||
function formatDeltaMemory(diffKiB: number) {
|
||||
return util.formatColoredDelta(formatMemoryMb(Math.abs(diffKiB)), diffKiB);
|
||||
}
|
||||
|
||||
function pairedDeltaSummary(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
const baseSamplesByRound = getSamplesByRound(base);
|
||||
const headSamplesByRound = getSamplesByRound(head);
|
||||
const values = [];
|
||||
|
||||
for (const [round, baseSample] of baseSamplesByRound) {
|
||||
const headSample = headSamplesByRound.get(round);
|
||||
if (headSample == null) continue;
|
||||
|
||||
const baseValue = getMemoryValueFromSample(baseSample, phase, metric);
|
||||
const headValue = getMemoryValueFromSample(headSample, phase, metric);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
values.push(headValue - baseValue);
|
||||
}
|
||||
|
||||
return {
|
||||
median: util.median(values),
|
||||
mad: util.mad(values),
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
samples: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const metric of metrics) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
const summary = pairedDeltaSummary(base, head, phase, metric);
|
||||
const percent = summary.median * 100 / baseValue;
|
||||
const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent)}`;
|
||||
|
||||
lines.push(`| **${metric}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatMemoryMb(summary.mad)} | ${summary == null ? '-' : formatDeltaMemory(summary.min)} | ${summary == null ? '-' : formatDeltaMemory(summary.max)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
if (baseValue == null || headValue == null || baseValue <= 0) return null;
|
||||
|
||||
return ((headValue - baseValue) * 100) / baseValue;
|
||||
}
|
||||
|
||||
/*
|
||||
function measurementSummary(base, head) {
|
||||
const baseCount = base?.sampleCount;
|
||||
const headCount = head?.sampleCount;
|
||||
const strategy = base?.comparison?.strategy;
|
||||
if (baseCount == null || headCount == null) return null;
|
||||
|
||||
if (strategy === 'interleaved-pairs') {
|
||||
const rounds = base?.comparison?.rounds ?? baseCount;
|
||||
const warmupRounds = base?.comparison?.warmupRounds ?? 0;
|
||||
return `_Measured as ${rounds} interleaved base/head pairs after ${warmupRounds} warmup pair(s). Values are medians; ± is median absolute deviation._`;
|
||||
}
|
||||
|
||||
return `_Sample count: base ${baseCount}, head ${headCount}. Values are medians; ± is median absolute deviation._`;
|
||||
}
|
||||
*/
|
||||
|
||||
function formatPlainDelta(baseValue: number, headValue: number, formatter = util.formatNumber) {
|
||||
const delta = headValue - baseValue;
|
||||
if (delta === 0) return formatter(0);
|
||||
|
||||
const sign = delta > 0 ? '+' : '-';
|
||||
return `${sign}${formatter(Math.abs(delta))}`;
|
||||
}
|
||||
|
||||
function getHeapSnapshotCategoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||
const value = report.summary[phase]?.heapSnapshot?.categories?.[category];
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function getHeapSnapshotCategoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||
const value = sample.phases[phase]?.heapSnapshot?.categories?.[category];
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
const heapSnapshotSankeyChildMinRatio = 0.3;
|
||||
const heapSnapshotSankeyParentMinPercent = 10;
|
||||
|
||||
function escapeCsvValue(value: string) {
|
||||
return `"${String(value).replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function formatSankeyPercentValue(value: number) {
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
if (rounded === 0 && value > 0) return '0.01';
|
||||
if (Number.isInteger(rounded)) return String(rounded);
|
||||
return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function formatHeapSnapshotSankeyChildLabel(label: string) {
|
||||
return String(label).replace(/^[^:]+:\s*/, '');
|
||||
}
|
||||
|
||||
function renderHeapSnapshotSankey(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], title: string) {
|
||||
const total = getHeapSnapshotCategoryValue(report, phase, 'Total');
|
||||
if (total == null || total <= 0) return null;
|
||||
|
||||
function getHeapSnapshotBreakdownEntries(category: typeof util.heapSnapshotCategories[number]) {
|
||||
const breakdown = report.summary[phase].heapSnapshot?.breakdowns?.[category];
|
||||
if (breakdown == null || typeof breakdown !== 'object') return [];
|
||||
|
||||
return Object.entries(breakdown)
|
||||
.filter(([, value]) => Number.isFinite(value) && value > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
}
|
||||
|
||||
const categories = util.heapSnapshotCategories
|
||||
.filter(category => category !== 'Total')
|
||||
.map(category => {
|
||||
const value = getHeapSnapshotCategoryValue(report, phase, category);
|
||||
if (value == null || value <= 0) return null;
|
||||
const breakdownEntries = getHeapSnapshotBreakdownEntries(category);
|
||||
const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0);
|
||||
const percent = (value * 100) / total;
|
||||
const childEntries = [];
|
||||
let otherPercent = 0;
|
||||
|
||||
if (breakdownTotal > 0 && percent > heapSnapshotSankeyParentMinPercent) {
|
||||
for (const [childName, childValue] of breakdownEntries) {
|
||||
const childRatio = childValue / breakdownTotal;
|
||||
const childPercent = percent * childRatio;
|
||||
if (childRatio >= heapSnapshotSankeyChildMinRatio) {
|
||||
childEntries.push([formatHeapSnapshotSankeyChildLabel(childName), childPercent]);
|
||||
} else {
|
||||
otherPercent += childPercent;
|
||||
}
|
||||
}
|
||||
|
||||
if (childEntries.length > 0 && otherPercent > 0) {
|
||||
childEntries.push(['Other', otherPercent]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
category,
|
||||
percent,
|
||||
childEntries,
|
||||
};
|
||||
})
|
||||
.filter(value => value != null);
|
||||
|
||||
if (categories.length === 0) return null;
|
||||
|
||||
const nodeColors = {
|
||||
[title]: heapSnapshotCategoriesColorsHex.Total,
|
||||
} as Record<string, string>;
|
||||
for (const { category, childEntries } of categories) {
|
||||
const categoryColor = heapSnapshotCategoriesColorsHex[category] ?? heapSnapshotCategoriesColorsHex.Total;
|
||||
nodeColors[category] = categoryColor;
|
||||
|
||||
for (const [childName] of childEntries) {
|
||||
nodeColors[childName] = categoryColor;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`<details><summary>${title} heap snapshot composition</summary>`,
|
||||
'',
|
||||
'```mermaid',
|
||||
`%%{init: ${JSON.stringify({
|
||||
sankey: {
|
||||
showValues: false,
|
||||
linkColor: 'target',
|
||||
labelStyle: 'outlined',
|
||||
nodeAlignment: 'center',
|
||||
nodePadding: 10,
|
||||
nodeColors: {
|
||||
...nodeColors,
|
||||
'Other': '#888888',
|
||||
},
|
||||
},
|
||||
})}}%%`,
|
||||
'sankey-beta',
|
||||
];
|
||||
|
||||
for (const { category, percent, childEntries } of categories) {
|
||||
lines.push(`${escapeCsvValue(title)},${escapeCsvValue(category)},${formatSankeyPercentValue(percent)}`);
|
||||
|
||||
for (const [childName, childPercent] of childEntries) {
|
||||
lines.push(`${escapeCsvValue(category)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
lines.push('</details>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function pairedHeapSnapshotDeltaSummary(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||
const baseSamplesByRound = getSamplesByRound(base);
|
||||
const headSamplesByRound = getSamplesByRound(head);
|
||||
const values = [] as number[];
|
||||
|
||||
for (const [round, baseSample] of baseSamplesByRound) {
|
||||
const headSample = headSamplesByRound.get(round);
|
||||
if (headSample == null) continue;
|
||||
|
||||
const baseValue = getHeapSnapshotCategoryValueFromSample(baseSample, phase, category);
|
||||
const headValue = getHeapSnapshotCategoryValueFromSample(headSample, phase, category);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
values.push(headValue - baseValue);
|
||||
}
|
||||
|
||||
return {
|
||||
median: util.median(values),
|
||||
mad: util.mad(values),
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
samples: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
function renderHeapSnapshotTable(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
const baseTotal = getHeapSnapshotCategoryValue(base, phase, 'Total');
|
||||
const headTotal = getHeapSnapshotCategoryValue(head, phase, 'Total');
|
||||
|
||||
function formatHeapSnapshotCategoryLabel(category: typeof heapSnapshotCategories[number], baseValue: number, headValue: number, baseTotal: number, headTotal: number) {
|
||||
if (category === 'Total' || baseTotal == null || headTotal == null || baseTotal <= 0 || headTotal <= 0) return `**${category}**`;
|
||||
|
||||
const basePercent = util.formatPercent((baseValue * 100) / baseTotal);
|
||||
const headPercent = util.formatPercent((headValue * 100) / headTotal);
|
||||
return `**${category}**<br>${basePercent} → ${headPercent}`;
|
||||
}
|
||||
|
||||
function getHeapSnapshotSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||
const values = report.samples
|
||||
.map(sample => getHeapSnapshotCategoryValueFromSample(sample, phase, category))
|
||||
.filter(value => Number.isFinite(value)) as number[];
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const center = util.median(values);
|
||||
return util.median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
for (const category of util.heapSnapshotCategories) {
|
||||
const baseValue = getHeapSnapshotCategoryValue(base, phase, category);
|
||||
const headValue = getHeapSnapshotCategoryValue(head, phase, category);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
const baseSpread = getHeapSnapshotSampleSpread(base, phase, category);
|
||||
const headSpread = getHeapSnapshotSampleSpread(head, phase, category);
|
||||
const summary = pairedHeapSnapshotDeltaSummary(base, head, phase, category);
|
||||
const percent = summary.median * 100 / baseValue;
|
||||
const deltaMedian = summary == null ? '-' : `${util.formatDeltaBytes(summary.median)}<br>${util.formatDeltaPercent(percent)}`;
|
||||
const categoryLabel = formatHeapSnapshotCategoryLabel(category, baseValue, headValue, baseTotal, headTotal);
|
||||
|
||||
lines.push(`| $\\color{${heapSnapshotCategoriesColors[category]}}{\\rule{8pt}{8pt}}$ ${categoryLabel} | ${util.formatBytes(baseValue)} <br> ± ${baseSpread == null ? '-' : util.formatBytes(baseSpread)} | ${util.formatBytes(headValue)} <br> ± ${headSpread == null ? '-' : util.formatBytes(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : util.formatBytes(summary.mad)} | ${summary == null ? '-' : util.formatDeltaBytes(summary.min)} | ${summary == null ? '-' : util.formatDeltaBytes(summary.max)} |`);
|
||||
if (category === 'Total') {
|
||||
lines.push('| | | | | | | |');
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 2) return null;
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
|
||||
const table = renderHeapSnapshotTable(base, head, 'afterGc');
|
||||
if (table == null) return null;
|
||||
|
||||
const lines = [
|
||||
'### V8 Heap Snapshot Statistics',
|
||||
'',
|
||||
table,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const graph of [
|
||||
renderHeapSnapshotSankey(base, 'afterGc', 'Base'),
|
||||
renderHeapSnapshotSankey(head, 'afterGc', 'Head'),
|
||||
]) {
|
||||
if (graph == null) continue;
|
||||
lines.push(graph);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getJsFootprintValue(report: RuntimeLoadedJsFootprintReport, phase: 'afterRequest', key: keyof RuntimeLoadedJsFootprintReport['phases'][typeof phase]['totals']) {
|
||||
const value = report.phases[phase].totals[key];
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function renderJsFootprintMetricTable(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const metricRows = [
|
||||
['Loaded JS modules', 'loadedJsModules', util.formatNumber],
|
||||
['Loaded JS source', 'loadedJsSourceBytes', util.formatBytes],
|
||||
//['Loaded JS gzip estimate', 'loadedJsGzipBytes', util.formatBytes],
|
||||
//['AST nodes', 'astNodeCount', util.formatNumber],
|
||||
//['Functions', 'functionCount', util.formatNumber],
|
||||
//['Classes', 'classCount', util.formatNumber],
|
||||
//['String literals', 'stringLiteralBytes', util.formatBytes],
|
||||
['External packages loaded', 'externalPackageCount', util.formatNumber],
|
||||
['Native addon packages', 'nativeAddonPackageCount', util.formatNumber],
|
||||
] as const;
|
||||
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ | Δ (%) |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const [title, key, formatter] of metricRows) {
|
||||
const baseValue = getJsFootprintValue(base, 'afterRequest', key);
|
||||
const headValue = getJsFootprintValue(head, 'afterRequest', key);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${formatPlainDelta(baseValue, headValue, formatter)} | ${util.calcAndFormatDeltaPercent(baseValue, headValue)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/*
|
||||
function renderJsFootprintPhaseTable(base, head) {
|
||||
const lines = [
|
||||
'| Phase | Base modules | Head modules | Δ modules | Base source | Head source | Δ source |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const [phase, title] of [['startup', 'Startup'], ['afterRequest', 'After warmup requests']]) {
|
||||
const baseModules = getJsFootprintValue(base, phase, 'loadedJsModules');
|
||||
const headModules = getJsFootprintValue(head, phase, 'loadedJsModules');
|
||||
const baseSource = getJsFootprintValue(base, phase, 'loadedJsSourceBytes');
|
||||
const headSource = getJsFootprintValue(head, phase, 'loadedJsSourceBytes');
|
||||
if (baseModules == null || headModules == null || baseSource == null || headSource == null) continue;
|
||||
|
||||
lines.push(`| ${title} | ${util.formatNumber(baseModules)} | ${util.formatNumber(headModules)} | ${formatPlainDelta(baseModules, headModules)} | ${util.formatBytes(baseSource)} | ${util.formatBytes(headSource)} | ${formatPlainDelta(baseSource, headSource, util.formatBytes)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
*/
|
||||
|
||||
function packageMap(report: RuntimeLoadedJsFootprintReport) {
|
||||
const map = new Map();
|
||||
for (const packageSummary of report.phases.afterRequest.packages) {
|
||||
if (packageSummary?.category !== 'external' || typeof packageSummary.name !== 'string') continue;
|
||||
map.set(packageSummary.name, packageSummary);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function packageDisplayName(packageSummary: { name: string; version?: string | null }) {
|
||||
if (packageSummary.version == null) return packageSummary.name;
|
||||
return `${packageSummary.name} ${packageSummary.version}`;
|
||||
}
|
||||
|
||||
function renderNewExternalPackages(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const basePackages = packageMap(base);
|
||||
const headPackages = packageMap(head);
|
||||
const newPackages = [...headPackages.values()]
|
||||
.filter(packageSummary => !basePackages.has(packageSummary.name))
|
||||
.toSorted((a, b) => b.sourceBytes - a.sourceBytes)
|
||||
.slice(0, 10);
|
||||
|
||||
if (newPackages.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
'#### Newly Loaded External Packages',
|
||||
'',
|
||||
'| Package | Loaded JS | Modules | Notes |',
|
||||
'| --- | ---: | ---: | --- |',
|
||||
];
|
||||
|
||||
for (const packageSummary of newPackages) {
|
||||
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatNumber(packageSummary.modules)} | ${packageSummary.nativeAddon ? 'native addon' : ''} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const basePackages = packageMap(base);
|
||||
const headPackages = packageMap(head);
|
||||
const increases = [...headPackages.values()]
|
||||
.map(headPackage => {
|
||||
const basePackage = basePackages.get(headPackage.name);
|
||||
const baseSourceBytes = basePackage?.sourceBytes ?? 0;
|
||||
const baseModules = basePackage?.modules ?? 0;
|
||||
return {
|
||||
...headPackage,
|
||||
baseSourceBytes,
|
||||
baseModules,
|
||||
sourceDiff: headPackage.sourceBytes - baseSourceBytes,
|
||||
moduleDiff: headPackage.modules - baseModules,
|
||||
};
|
||||
})
|
||||
.filter(packageSummary => packageSummary.sourceDiff > 0)
|
||||
.toSorted((a, b) => b.sourceDiff - a.sourceDiff)
|
||||
.slice(0, 10);
|
||||
|
||||
if (increases.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
'#### Largest Package Increases',
|
||||
'',
|
||||
'| Package | Base | Head | Δ | Modules Δ |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const packageSummary of increases) {
|
||||
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${formatPlainDelta(packageSummary.baseSourceBytes, packageSummary.sourceBytes, util.formatBytes)} | ${formatPlainDelta(packageSummary.baseModules, packageSummary.modules)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderNewLoadedModules(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
function moduleMap(report: RuntimeLoadedJsFootprintReport) {
|
||||
const map = new Map();
|
||||
for (const moduleSummary of report.phases.afterRequest.modules) {
|
||||
if (typeof moduleSummary.path !== 'string') continue;
|
||||
map.set(moduleSummary.path, moduleSummary);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const baseModules = moduleMap(base);
|
||||
const headModules = moduleMap(head);
|
||||
const newModules = [...headModules.values()]
|
||||
.filter(moduleSummary => !baseModules.has(moduleSummary.path))
|
||||
.toSorted((a, b) => b.sourceBytes - a.sourceBytes)
|
||||
.slice(0, 10);
|
||||
|
||||
if (newModules.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
'#### Largest Newly Loaded Modules',
|
||||
'',
|
||||
'| Module | Package | Loaded JS |',
|
||||
'| --- | --- | ---: |',
|
||||
];
|
||||
|
||||
for (const moduleSummary of newModules) {
|
||||
lines.push(`| \`${moduleSummary.path}\` | ${moduleSummary.package} | ${util.formatBytes(moduleSummary.sourceBytes)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderJsFootprintSection(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const lines = [
|
||||
'### Runtime Loaded JS Footprint',
|
||||
'',
|
||||
'<details><summary>Click to show</summary>',
|
||||
'',
|
||||
renderJsFootprintMetricTable(base, head),
|
||||
'',
|
||||
//'#### Load Phase Breakdown',
|
||||
//'',
|
||||
//renderJsFootprintPhaseTable(base, head),
|
||||
//'',
|
||||
];
|
||||
|
||||
for (const block of [
|
||||
renderNewExternalPackages(base, head),
|
||||
renderLargestPackageIncreases(base, head),
|
||||
renderNewLoadedModules(base, head),
|
||||
]) {
|
||||
if (block == null) continue;
|
||||
lines.push(block);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('</details>');
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport;
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport;
|
||||
const baseJsFootprint = JSON.parse(await readFile(baseJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
|
||||
const headJsFootprint = JSON.parse(await readFile(headJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
|
||||
const lines = [
|
||||
'## ⚙️ Backend Memory Usage Report',
|
||||
'',
|
||||
];
|
||||
|
||||
//const summary = measurementSummary(base, head);
|
||||
//if (summary != null) {
|
||||
// lines.push(summary);
|
||||
// lines.push('');
|
||||
//}
|
||||
|
||||
for (const phase of memoryReportPhases) {
|
||||
lines.push(`### ${phase.title}`);
|
||||
lines.push(renderMainTableForPhase(base, head, phase.key));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const heapSnapshotSection = renderHeapSnapshotSection(base, head);
|
||||
if (heapSnapshotSection != null) {
|
||||
lines.push(heapSnapshotSection);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const jsFootprintSection = renderJsFootprintSection(baseJsFootprint, headJsFootprint);
|
||||
if (jsFootprintSection != null) {
|
||||
lines.push(jsFootprintSection);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
function getWarningMetric(base: MemoryReport, head: MemoryReport) {
|
||||
for (const metric of ['Pss', 'Private_Dirty', 'VmRSS'] as const) {
|
||||
if (getMemoryValue(base, 'afterGc', metric) != null && getMemoryValue(head, 'afterGc', metric) != null) {
|
||||
return metric;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
if (baseValue == null || headValue == null) return false;
|
||||
|
||||
const diff = headValue - baseValue;
|
||||
if (diff <= 0) return false;
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
if (baseSpread == null || headSpread == null) return true;
|
||||
|
||||
const combinedSpread = Math.hypot(baseSpread, headSpread);
|
||||
return diff > combinedSpread * 3;
|
||||
}
|
||||
|
||||
const warningMetric = getWarningMetric(base, head);
|
||||
const warningDiffPercent = warningMetric == null ? null : getDiffPercent(base, head, 'afterGc', warningMetric);
|
||||
if (warningMetric != null && warningDiffPercent != null && warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
|
||||
lines.push(`⚠️ **Warning**: Memory usage (${warningMetric}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`[See workflow logs for details](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`);
|
||||
|
||||
await writeFile(outputFile, `${lines.join('\n')}\n`);
|
||||
568
.github/scripts/frontend-js-size.mts
vendored
Normal file
568
.github/scripts/frontend-js-size.mts
vendored
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
|
||||
const marker = '<!-- misskey-frontend-js-size -->';
|
||||
|
||||
const locale = process.env.FRONTEND_JS_SIZE_LOCALE ?? 'ja-JP';
|
||||
|
||||
//function sharePercent(value, total) {
|
||||
// if (total === 0) return '0%';
|
||||
// return Math.round((value / total) * 100) + '%';
|
||||
//}
|
||||
|
||||
function escapeCell(value: string) {
|
||||
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
|
||||
}
|
||||
|
||||
//function tableCell(value) {
|
||||
// return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||
//}
|
||||
|
||||
//function code(value) {
|
||||
// const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||
// const backtickRuns = sanitized.match(/`+/g) ?? [];
|
||||
// const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
|
||||
// const fence = '`'.repeat(fenceLength);
|
||||
// const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
|
||||
//
|
||||
// return `${fence}${padding}${sanitized}${padding}${fence}`;
|
||||
//}
|
||||
|
||||
//function tableCode(value) {
|
||||
// return tableCell(code(value));
|
||||
//}
|
||||
|
||||
type Manifest = Record<string, { file?: string; src?: string; name?: string; isEntry?: boolean; imports?: string[] }>;
|
||||
|
||||
type FileEntry = {
|
||||
key: string;
|
||||
displayName: string;
|
||||
file: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
function entryDisplayName(entry: FileEntry) {
|
||||
if (!entry) return '';
|
||||
return entry.displayName || entry.file;
|
||||
}
|
||||
|
||||
function findEntryKey(manifest: Manifest) {
|
||||
const entries = Object.entries(manifest);
|
||||
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
|
||||
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
|
||||
?? entries.find(([, chunk]) => chunk.isEntry)?.[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
function stableChunkKey(manifestKey: string, chunk: Manifest[string]) {
|
||||
return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey);
|
||||
}
|
||||
|
||||
function collectStartupKeys(manifest: Manifest) {
|
||||
const entryKey = findEntryKey(manifest);
|
||||
const keys = new Set<string>();
|
||||
if (entryKey == null) return keys;
|
||||
|
||||
function visit(key: string) {
|
||||
if (keys.has(key)) return;
|
||||
const chunk = manifest[key];
|
||||
if (!chunk || !chunk.file?.endsWith('.js')) return;
|
||||
keys.add(stableChunkKey(key, chunk));
|
||||
for (const importKey of chunk.imports ?? []) {
|
||||
visit(importKey);
|
||||
}
|
||||
}
|
||||
|
||||
visit(entryKey);
|
||||
return keys;
|
||||
}
|
||||
|
||||
async function resolveBuiltFile(outDir: string, file: string) {
|
||||
if (file.startsWith('scripts/')) {
|
||||
const localizedFile = file.slice('scripts/'.length);
|
||||
const localizedPath = path.join(outDir, locale, localizedFile);
|
||||
if (await util.fileExists(localizedPath)) {
|
||||
return {
|
||||
absolutePath: localizedPath,
|
||||
relativePath: `${locale}/${localizedFile}`,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`);
|
||||
}
|
||||
return {
|
||||
absolutePath: path.join(outDir, file),
|
||||
relativePath: file,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectReport(repoDir: string) {
|
||||
const outDir = path.join(repoDir, 'built/_frontend_vite_');
|
||||
const manifestPath = path.join(outDir, 'manifest.json');
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest;
|
||||
const byKey = new Map<string, FileEntry>();
|
||||
const byFile = new Set<string>();
|
||||
|
||||
for (const [key, chunk] of Object.entries(manifest)) {
|
||||
if (!chunk.file?.endsWith('.js')) continue;
|
||||
const builtFile = await resolveBuiltFile(outDir, chunk.file);
|
||||
const size = await util.fileSize(builtFile.absolutePath);
|
||||
const stableKey = stableChunkKey(key, chunk);
|
||||
const displayName = chunk.src ?? chunk.name ?? key;
|
||||
byKey.set(stableKey, {
|
||||
key: stableKey,
|
||||
displayName,
|
||||
file: builtFile.relativePath,
|
||||
size,
|
||||
});
|
||||
byFile.add(builtFile.relativePath);
|
||||
}
|
||||
|
||||
const localeDir = path.join(outDir, locale);
|
||||
if (await util.fileExists(localeDir)) {
|
||||
for await (const fullPath of util.traverseDirectory(localeDir)) {
|
||||
if (!fullPath.endsWith('.js')) continue;
|
||||
const relativePath = util.normalizePath(path.relative(outDir, fullPath));
|
||||
if (byFile.has(relativePath)) continue;
|
||||
const size = await util.fileSize(fullPath);
|
||||
byKey.set(relativePath, {
|
||||
key: relativePath,
|
||||
displayName: relativePath,
|
||||
file: relativePath,
|
||||
size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
chunks: Object.fromEntries(byKey),
|
||||
startupKeys: [...collectStartupKeys(manifest)],
|
||||
};
|
||||
}
|
||||
|
||||
type VisualizerReport = {
|
||||
nodeParts?: Record<string, {
|
||||
renderedLength: number;
|
||||
gzipLength: number;
|
||||
brotliLength: number;
|
||||
}>;
|
||||
nodeMetas?: Record<string, {
|
||||
id: string;
|
||||
isEntry?: boolean;
|
||||
isExternal?: boolean;
|
||||
importedBy?: string[];
|
||||
imported?: { id: string; dynamic?: boolean }[];
|
||||
moduleParts?: Record<string, string>;
|
||||
renderedLength: number;
|
||||
gzipLength: number;
|
||||
brotliLength: number;
|
||||
}>;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
function collectVisualizerReport(data: VisualizerReport) {
|
||||
const nodeParts = data.nodeParts ?? {};
|
||||
const nodeMetas = Object.values(data.nodeMetas ?? {});
|
||||
const moduleRows = [];
|
||||
const bundleMap = new Map();
|
||||
|
||||
for (const meta of nodeMetas) {
|
||||
const row = {
|
||||
id: meta.id,
|
||||
bundles: 0,
|
||||
renderedLength: 0,
|
||||
gzipLength: 0,
|
||||
brotliLength: 0,
|
||||
importedByCount: meta.importedBy?.length ?? 0,
|
||||
importedCount: meta.imported?.length ?? 0,
|
||||
};
|
||||
|
||||
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
|
||||
const part = nodeParts[partUid];
|
||||
if (part == null) continue;
|
||||
|
||||
row.bundles += 1;
|
||||
row.renderedLength += part.renderedLength;
|
||||
row.gzipLength += part.gzipLength;
|
||||
row.brotliLength += part.brotliLength;
|
||||
|
||||
const bundle = bundleMap.get(bundleId) ?? {
|
||||
id: bundleId,
|
||||
modules: 0,
|
||||
renderedLength: 0,
|
||||
gzipLength: 0,
|
||||
brotliLength: 0,
|
||||
};
|
||||
bundle.modules += 1;
|
||||
bundle.renderedLength += part.renderedLength;
|
||||
bundle.gzipLength += part.gzipLength;
|
||||
bundle.brotliLength += part.brotliLength;
|
||||
bundleMap.set(bundleId, bundle);
|
||||
}
|
||||
|
||||
if (row.bundles > 0) {
|
||||
moduleRows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
let staticImports = 0;
|
||||
let dynamicImports = 0;
|
||||
for (const meta of nodeMetas) {
|
||||
for (const imported of meta.imported ?? []) {
|
||||
if (imported.dynamic) {
|
||||
dynamicImports += 1;
|
||||
} else {
|
||||
staticImports += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
|
||||
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
|
||||
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
|
||||
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
|
||||
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
|
||||
|
||||
return {
|
||||
options: data.options ?? {},
|
||||
summary: {
|
||||
bundles: bundleRows.length,
|
||||
modules: moduleRows.length,
|
||||
entries: nodeMetas.filter((meta) => meta.isEntry).length,
|
||||
externals: nodeMetas.filter((meta) => meta.isExternal).length,
|
||||
staticImports,
|
||||
dynamicImports,
|
||||
},
|
||||
metrics: {
|
||||
renderedLength: totalRendered,
|
||||
gzipLength: totalGzip,
|
||||
brotliLength: totalBrotli,
|
||||
},
|
||||
hotModules,
|
||||
};
|
||||
}
|
||||
|
||||
function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
|
||||
const summary = [
|
||||
'bundles',
|
||||
'modules',
|
||||
'entries',
|
||||
//'externals',
|
||||
'staticImports',
|
||||
'dynamicImports',
|
||||
] as const;
|
||||
|
||||
const metrics = [
|
||||
'renderedLength',
|
||||
'gzipLength',
|
||||
'brotliLength',
|
||||
] as const;
|
||||
|
||||
return [
|
||||
`<table>`,
|
||||
`<thead>`,
|
||||
`<tr>`,
|
||||
`<th rowspan="2"></th>`,
|
||||
`<th rowspan="2">Bundles</th>`,
|
||||
`<th rowspan="2">Modules</th>`,
|
||||
`<th rowspan="2">Entries</th>`,
|
||||
`<th colspan="2">Imports</th>`,
|
||||
`<th colspan="3">Size</th>`,
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th>Static</th>`,
|
||||
`<th>Dynamic</th>`,
|
||||
`<th>Rendered</th>`,
|
||||
`<th>Gzip</th>`,
|
||||
`<th>Brotli</th>`,
|
||||
`</tr>`,
|
||||
`</thead>`,
|
||||
`<tbody>`,
|
||||
`<tr>`,
|
||||
`<th><b>Before</b></th>`,
|
||||
...summary.map((key) => `<td>${util.formatNumber(before.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.formatBytes(before.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>After</b></th>`,
|
||||
...summary.map((key) => `<td>${util.formatNumber(after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.formatBytes(after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ</b></th>`,
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ (%)</b></th>`,
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`</tbody>`,
|
||||
`</table>`,
|
||||
];
|
||||
}
|
||||
|
||||
function getChunkComparisonRows(keys: string[], before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
|
||||
return keys.map((key) => {
|
||||
const beforeEntry = before.chunks[key];
|
||||
const afterEntry = after.chunks[key];
|
||||
const beforeSize = beforeEntry?.size ?? 0;
|
||||
const afterSize = afterEntry?.size ?? 0;
|
||||
return {
|
||||
key,
|
||||
name: entryDisplayName(beforeEntry ?? afterEntry),
|
||||
chunkFile: beforeEntry?.file ?? afterEntry?.file,
|
||||
beforeSize,
|
||||
afterSize,
|
||||
changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged',
|
||||
sortSize: Math.max(beforeSize, afterSize),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeChunkChanges(rows: ReturnType<typeof getChunkComparisonRows>) {
|
||||
return {
|
||||
updated: rows.filter((row) => row.changeType === 'updated').length,
|
||||
added: rows.filter((row) => row.changeType === 'added').length,
|
||||
removed: rows.filter((row) => row.changeType === 'removed').length,
|
||||
};
|
||||
}
|
||||
|
||||
function formatChunkChangeSummary(label: string, summary: ReturnType<typeof summarizeChunkChanges>) {
|
||||
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
|
||||
}
|
||||
|
||||
function compareChunkComparisonRows(a: ReturnType<typeof getChunkComparisonRows>[number], b: ReturnType<typeof getChunkComparisonRows>[number]) {
|
||||
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|
||||
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|
||||
|| b.sortSize - a.sortSize
|
||||
|| a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function chunkMarkdownTable(rows: ReturnType<typeof getChunkComparisonRows>, total?: { beforeSize: number; afterSize: number }) {
|
||||
if (rows.length === 0) return '_No data_';
|
||||
|
||||
const lines = [
|
||||
'| Chunk | Before | After | Δ | Δ (%) |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
if (total != null) {
|
||||
lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize).replaceAll('\\%', '\\\\%')} |`);
|
||||
lines.push('| | | | | |');
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (row.changeType === 'added') {
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`);
|
||||
} else if (row.changeType === 'removed') {
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`);
|
||||
} else {
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
|
||||
const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null);
|
||||
const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null);
|
||||
const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null);
|
||||
const allChunkKeys = [
|
||||
...commonChunkKeys,
|
||||
...addedChunkKeys,
|
||||
...removedChunkKeys,
|
||||
];
|
||||
//const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
|
||||
const allComparisonRows = getChunkComparisonRows(allChunkKeys, before, after);
|
||||
|
||||
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
|
||||
const diffSummary = summarizeChunkChanges(changedRows);
|
||||
const diffTotal = {
|
||||
beforeSize: allComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
|
||||
afterSize: allComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
|
||||
};
|
||||
const diffRows = changedRows.sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
|
||||
|
||||
const startupKeys = new Set([
|
||||
...before.startupKeys,
|
||||
...after.startupKeys,
|
||||
]);
|
||||
const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
|
||||
const startupRows = startupComparisonRows.sort(compareChunkComparisonRows);
|
||||
const startupSummary = summarizeChunkChanges(startupComparisonRows);
|
||||
const startupTotal = {
|
||||
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
|
||||
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
|
||||
};
|
||||
|
||||
//const largeRows = comparisonRows
|
||||
// .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
|
||||
// .slice(0, 30);
|
||||
|
||||
return [
|
||||
'<details open>',
|
||||
`<summary>${formatChunkChangeSummary('Chunk size diff', diffSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(diffRows, diffTotal),
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
`<summary>${formatChunkChangeSummary('Startup chunk size', startupSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(startupRows, startupTotal),
|
||||
'',
|
||||
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
//'<details>',
|
||||
//`<summary>Largest</summary>`,
|
||||
//'',
|
||||
//markdownTable(largeRows),
|
||||
//'',
|
||||
//'</details>',
|
||||
//'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderFrontendBundleReport(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
|
||||
const lines = [
|
||||
...renderVisualizerSummaryTable(before, after),
|
||||
'',
|
||||
//'<details>',
|
||||
//'<summary>Top 10</summary>',
|
||||
//'',
|
||||
];
|
||||
|
||||
/*
|
||||
for (const row of after.hotModules.slice(0, 10)) {
|
||||
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'</details>',
|
||||
);
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Hot Modules (Self Size)</summary>',
|
||||
'',
|
||||
'| Module | Bundles | Rendered | Share | Gzip | Brotli | Imports | Imported By |',
|
||||
'|---|---:|---:|---:|---:|---:|---:|---:|',
|
||||
);
|
||||
|
||||
for (const row of after.hotModules.slice(0, 15)) {
|
||||
lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'</details>',
|
||||
);
|
||||
*/
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const visualizerTreemapLimit = 50;
|
||||
|
||||
function mermaidTreemapLabel(value: string) {
|
||||
const label = String(value)
|
||||
.replaceAll('\\', '/')
|
||||
.replaceAll('"', "'")
|
||||
.replaceAll('`', "'")
|
||||
.replaceAll('\r', ' ')
|
||||
.replaceAll('\n', ' ')
|
||||
.trim();
|
||||
return label === '' ? '(unknown)' : label;
|
||||
}
|
||||
|
||||
function mermaidTreemapModuleLabel(id: string) {
|
||||
const normalizedId = String(id).replaceAll('\\', '/');
|
||||
const filePath = normalizedId.split(/[?#]/, 1)[0];
|
||||
const fileName = path.posix.basename(filePath);
|
||||
return mermaidTreemapLabel(fileName || normalizedId);
|
||||
}
|
||||
|
||||
function renderVisualizerTreemap(label: string, report: ReturnType<typeof collectVisualizerReport>) {
|
||||
const rows = report.hotModules
|
||||
.filter((row) => row.renderedLength > 0)
|
||||
.slice(0, visualizerTreemapLimit);
|
||||
const topRendered = rows.reduce((sum, row) => sum + row.renderedLength, 0);
|
||||
const otherRendered = Math.max(0, report.metrics.renderedLength - topRendered);
|
||||
const lines = [
|
||||
'```mermaid',
|
||||
`%%{init: ${JSON.stringify({
|
||||
treemap: {
|
||||
diagramPadding: 0,
|
||||
padding: 0,
|
||||
nodeHeight: 70,
|
||||
},
|
||||
})}}%%`,
|
||||
'treemap-beta',
|
||||
`"${mermaidTreemapLabel(label)}"`,
|
||||
];
|
||||
|
||||
for (const row of rows) {
|
||||
lines.push(` "${mermaidTreemapModuleLabel(row.id)}": ${Math.round(row.renderedLength)}`);
|
||||
}
|
||||
if (otherRendered > 0) {
|
||||
lines.push(` "Other": ${Math.round(otherRendered)}`);
|
||||
}
|
||||
|
||||
lines.push('```');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderVisualizerTreemapDetails(label: string, report: ReturnType<typeof collectVisualizerReport>, open = false) {
|
||||
return [
|
||||
`<details${open ? ' open' : ''}>`,
|
||||
`<summary>${label} rendered size treemap (top ${visualizerTreemapLimit} + Other)</summary>`,
|
||||
'',
|
||||
renderVisualizerTreemap(label, report),
|
||||
'',
|
||||
'</details>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
|
||||
const before = await collectReport(beforeDir);
|
||||
const after = await collectReport(afterDir);
|
||||
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport;
|
||||
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
|
||||
const beforeVisualizerReport = collectVisualizerReport(beforeStats);
|
||||
const afterVisualizerReport = collectVisualizerReport(afterStats);
|
||||
const visualizerArtifactLink = `[Open detailed HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'',
|
||||
`## 📦 Frontend Bundle Report`,
|
||||
'',
|
||||
renderFrontendChunkReport(before, after),
|
||||
'',
|
||||
'## Bundle Stats',
|
||||
'',
|
||||
renderFrontendBundleReport(beforeVisualizerReport, afterVisualizerReport),
|
||||
'',
|
||||
renderVisualizerTreemapDetails('Before', beforeVisualizerReport),
|
||||
'',
|
||||
renderVisualizerTreemapDetails('After', afterVisualizerReport),
|
||||
'',
|
||||
visualizerArtifactLink,
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(outFile, body);
|
||||
262
.github/scripts/measure-backend-memory-comparison.mts
vendored
Normal file
262
.github/scripts/measure-backend-memory-comparison.mts
vendored
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
import type { MemoryReportRaw } from '../../packages/backend/scripts/measure-memory.mts';
|
||||
|
||||
const phases = ['afterGc'] as const;
|
||||
|
||||
export type MemoryReport = {
|
||||
timestamp: string;
|
||||
sampleCount: any;
|
||||
aggregation: string;
|
||||
measurement: {
|
||||
startupTimeoutMs: any;
|
||||
memorySettleTimeMs: any;
|
||||
ipcTimeoutMs: any;
|
||||
requestCount: any;
|
||||
heapSnapshot: {
|
||||
enabled: any;
|
||||
timeoutMs: any;
|
||||
breakdownTopN: any;
|
||||
};
|
||||
};
|
||||
summary: Record<typeof phases[number], {
|
||||
memoryUsage: Record<string, number>;
|
||||
heapSnapshot?: {
|
||||
categories: Record<typeof util.heapSnapshotCategories[number], number>;
|
||||
nodeCounts: Record<typeof util.heapSnapshotCategories[number], number>;
|
||||
breakdowns?: Record<typeof util.heapSnapshotCategories[number], Record<string, number>>;
|
||||
};
|
||||
}>;
|
||||
samples: (MemoryReportRaw['samples'][number] & {
|
||||
round: number;
|
||||
})[];
|
||||
};
|
||||
|
||||
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
|
||||
|
||||
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
|
||||
|
||||
async function resetState(repoDir: string) {
|
||||
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
|
||||
const pg = require('pg');
|
||||
const Redis = require('ioredis');
|
||||
|
||||
const postgres = new pg.Client({
|
||||
host: '127.0.0.1',
|
||||
port: 54312,
|
||||
database: 'postgres',
|
||||
user: 'postgres',
|
||||
});
|
||||
|
||||
await postgres.connect();
|
||||
try {
|
||||
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
|
||||
await postgres.query('CREATE DATABASE "test-misskey"');
|
||||
} finally {
|
||||
await postgres.end();
|
||||
}
|
||||
|
||||
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
|
||||
try {
|
||||
await redis.flushall();
|
||||
} finally {
|
||||
redis.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeHeapSnapshotBreakdowns(samples: MemoryReport['samples'], phase: typeof phases[number]) {
|
||||
const breakdowns = {} as Record<typeof util.heapSnapshotCategories[number], Record<string, number>>;
|
||||
|
||||
for (const category of util.heapSnapshotCategories) {
|
||||
if (category === 'Total') continue;
|
||||
|
||||
const childKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const childKey of Object.keys(sample.phases[phase].heapSnapshot?.breakdowns?.[category] ?? {})) {
|
||||
childKeys.add(childKey);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryBreakdown = {} as Record<string, number>;
|
||||
for (const childKey of childKeys) {
|
||||
const values = samples
|
||||
.map(sample => sample.phases[phase].heapSnapshot?.breakdowns?.[category]?.[childKey])
|
||||
.filter(value => Number.isFinite(value));
|
||||
|
||||
if (values.length > 0) categoryBreakdown[childKey] = util.median(values);
|
||||
}
|
||||
|
||||
if (Object.keys(categoryBreakdown).length > 0) {
|
||||
breakdowns[category] = collapseHeapSnapshotBreakdown(categoryBreakdown);
|
||||
}
|
||||
}
|
||||
|
||||
return breakdowns;
|
||||
}
|
||||
|
||||
function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>) {
|
||||
const entries = Object.entries(breakdown)
|
||||
.filter(([, value]) => value > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
|
||||
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
|
||||
const otherValue = entries
|
||||
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
|
||||
.reduce((sum, [, value]) => sum + value, 0);
|
||||
|
||||
const collapsed = Object.fromEntries(topEntries);
|
||||
if (otherValue > 0) collapsed.Other = otherValue;
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
function summarizeSamples(samples: MemoryReport['samples']) {
|
||||
const summary = {} as MemoryReport['summary'];
|
||||
|
||||
for (const phase of phases) {
|
||||
summary[phase] = {
|
||||
memoryUsage: {},
|
||||
};
|
||||
|
||||
const metricKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.phases[phase].memoryUsage)) {
|
||||
metricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of metricKeys) {
|
||||
const values = samples.map(sample => sample.phases[phase].memoryUsage[key]);
|
||||
summary[phase].memoryUsage[key] = util.median(values);
|
||||
}
|
||||
|
||||
const heapSnapshotCategoryValues = {} as Record<typeof util.heapSnapshotCategories[number], number>;
|
||||
for (const category of util.heapSnapshotCategories) {
|
||||
const values = samples
|
||||
.map(sample => sample.phases[phase].heapSnapshot?.categories?.[category])
|
||||
.filter(value => Number.isFinite(value)) as number[];
|
||||
|
||||
if (values.length > 0) heapSnapshotCategoryValues[category] = util.median(values);
|
||||
}
|
||||
|
||||
const heapSnapshotNodeCountValues = {} as Record<typeof util.heapSnapshotCategories[number], number>;
|
||||
for (const category of util.heapSnapshotCategories) {
|
||||
const values = samples
|
||||
.map(sample => sample.phases[phase].heapSnapshot?.nodeCounts?.[category])
|
||||
.filter(value => Number.isFinite(value)) as number[];
|
||||
|
||||
if (values.length > 0) heapSnapshotNodeCountValues[category] = util.median(values);
|
||||
}
|
||||
|
||||
if (Object.keys(heapSnapshotCategoryValues).length > 0) {
|
||||
const heapSnapshotBreakdowns = summarizeHeapSnapshotBreakdowns(samples, phase);
|
||||
|
||||
summary[phase].heapSnapshot = {
|
||||
categories: heapSnapshotCategoryValues,
|
||||
nodeCounts: heapSnapshotNodeCountValues,
|
||||
...(Object.keys(heapSnapshotBreakdowns).length > 0 ? { breakdowns: heapSnapshotBreakdowns } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function measureRepo(label: string, repoDir: string, round: number) {
|
||||
process.stderr.write(`[${label}] Resetting database and Redis\n`);
|
||||
await resetState(repoDir);
|
||||
|
||||
process.stderr.write(`[${label}] Running migrations\n`);
|
||||
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
logStdout: true,
|
||||
});
|
||||
|
||||
process.stderr.write(`[${label}] Measuring memory\n`);
|
||||
const measureEnv = {
|
||||
...process.env,
|
||||
MK_MEMORY_SAMPLE_COUNT: '1',
|
||||
} as NodeJS.ProcessEnv;
|
||||
if (round <= 0) measureEnv.MK_MEMORY_HEAP_SNAPSHOT = '0';
|
||||
|
||||
const stdout = await util.run('node', ['packages/backend/scripts/measure-memory.mts'], {
|
||||
cwd: repoDir,
|
||||
env: measureEnv,
|
||||
});
|
||||
|
||||
const report = JSON.parse(stdout) as MemoryReportRaw;
|
||||
const sample = report.samples[0];
|
||||
|
||||
return sample;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseDir = resolve(baseDirArg);
|
||||
const headDir = resolve(headDirArg);
|
||||
const baseOutput = resolve(baseOutputArg);
|
||||
const headOutput = resolve(headOutputArg);
|
||||
const rounds = util.readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
|
||||
const warmupRounds = util.readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
const reports = {
|
||||
base: {
|
||||
dir: baseDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
head: {
|
||||
dir: headDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
};
|
||||
|
||||
for (let round = 1; round <= warmupRounds; round++) {
|
||||
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
|
||||
for (const label of ['base', 'head'] as const) {
|
||||
await measureRepo(label, reports[label].dir, -round);
|
||||
}
|
||||
}
|
||||
|
||||
for (let round = 1; round <= rounds; round++) {
|
||||
const order = round % 2 === 1 ? ['base', 'head'] as const : ['head', 'base'] as const;
|
||||
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
|
||||
|
||||
for (const [orderIndex, label] of order.entries()) {
|
||||
const sample = await measureRepo(label, reports[label].dir, round);
|
||||
reports[label].samples.push({
|
||||
...sample,
|
||||
round,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const label of ['base', 'head'] as const) {
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: reports[label].samples.length,
|
||||
aggregation: 'median',
|
||||
comparison: {
|
||||
strategy: 'interleaved-pairs',
|
||||
rounds,
|
||||
warmupRounds,
|
||||
startedAt,
|
||||
},
|
||||
summary: summarizeSamples(reports[label].samples),
|
||||
samples: reports[label].samples,
|
||||
};
|
||||
|
||||
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
179
.github/scripts/utility.mts
vendored
Normal file
179
.github/scripts/utility.mts
vendored
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const heapSnapshotCategories = [
|
||||
'Total',
|
||||
'Code',
|
||||
'Strings',
|
||||
'JS arrays',
|
||||
'Typed arrays',
|
||||
'System objects',
|
||||
'Other JS objects',
|
||||
'Other non-JS objects',
|
||||
] as const;
|
||||
|
||||
export function median(values: number[]) {
|
||||
const sorted = values.toSorted((a, b) => a - b);
|
||||
const center = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 1) return sorted[center];
|
||||
return Math.round((sorted[center - 1] + sorted[center]) / 2);
|
||||
}
|
||||
|
||||
export function mad(values: number[]) {
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const center = median(values);
|
||||
return median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
export function normalizePath(filePath: string) {
|
||||
return filePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
export async function fileExists(filePath: string) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fileSize(filePath: string) {
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
export async function* traverseDirectory(dir: string): AsyncGenerator<string> {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
yield* traverseDirectory(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
yield fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeLatex(text: string) {
|
||||
return text
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('{', '\\{')
|
||||
.replaceAll('}', '\\}')
|
||||
.replaceAll('%', '\\%');
|
||||
}
|
||||
|
||||
export function formatColoredDelta(text: string, delta: number) {
|
||||
if (delta === 0) return text;
|
||||
const color = delta > 0 ? 'orange' : 'green';
|
||||
const sign = delta > 0 ? '+' : '-';
|
||||
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
|
||||
}
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US', {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export function formatNumber(value: number) {
|
||||
return numberFormatter.format(value);
|
||||
}
|
||||
|
||||
export function formatBytes(value: number) {
|
||||
if (value === 0) return '0 B';
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
||||
let unitIndex = 0;
|
||||
let size = value;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
|
||||
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaNumber(before: number, after: number) {
|
||||
if (before == null || after == null) return '-';
|
||||
const delta = after - before;
|
||||
return formatColoredDelta(formatNumber(Math.abs(delta)), delta);
|
||||
}
|
||||
|
||||
export function formatDeltaBytes(deltaBytes: number) {
|
||||
return formatColoredDelta(formatBytes(Math.abs(deltaBytes)), deltaBytes);
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaBytes(before: number, after: number) {
|
||||
if (before == null || after == null) return '-';
|
||||
const delta = after - before;
|
||||
return formatDeltaBytes(delta);
|
||||
}
|
||||
|
||||
export function formatPercent(value: number) {
|
||||
return `${formatNumber(value)}%`;
|
||||
}
|
||||
|
||||
export function formatDeltaPercent(deltaPercent: number) {
|
||||
if (deltaPercent === 0) return '0%';
|
||||
return formatColoredDelta(formatPercent(Math.abs(deltaPercent)), deltaPercent);
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaPercent(before: number, after: number) {
|
||||
if (before == null || before === 0 || after == null || after === 0) return '-';
|
||||
const delta = after - before;
|
||||
return formatDeltaPercent(delta / before * 100);
|
||||
}
|
||||
|
||||
export function commandName(command: string) {
|
||||
if (process.platform !== 'win32') return command;
|
||||
if (command === 'pnpm') return 'pnpm.cmd';
|
||||
return command;
|
||||
}
|
||||
|
||||
export function readIntegerEnv(name: string, defaultValue: number, min: number) {
|
||||
const rawValue = process.env[name];
|
||||
if (rawValue == null || rawValue === '') return defaultValue;
|
||||
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
|
||||
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function run(command: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; logStdout?: boolean } = {}) {
|
||||
return new Promise<string>((resolvePromise, reject) => {
|
||||
const child = spawn(commandName(command), args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', data => {
|
||||
stdout += data;
|
||||
if (options.logStdout) process.stderr.write(data);
|
||||
});
|
||||
|
||||
child.stderr.on('data', data => {
|
||||
stderr += data;
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
|
||||
child.on('close', code => {
|
||||
if (code === 0) {
|
||||
resolvePromise(stdout);
|
||||
} else {
|
||||
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
318
.github/workflows/frontend-bundle-report-comment.yml
vendored
Normal file
318
.github/workflows/frontend-bundle-report-comment.yml
vendored
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
name: frontend-bundle-report-comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- frontend-bundle-report
|
||||
types:
|
||||
- completed
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- packages/frontend/**
|
||||
- packages/frontend-shared/**
|
||||
- packages/frontend-builder/**
|
||||
- packages/i18n/**
|
||||
- packages/icons-subsetter/**
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/frontend-js-size.mts
|
||||
- .github/workflows/frontend-bundle-report.yml
|
||||
- .github/workflows/frontend-bundle-report-comment.yml
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment frontend bundle report
|
||||
if: github.event_name == 'pull_request_target' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frontend-bundle-report-comment-${{ github.event.pull_request.number || github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Find bundle report run
|
||||
if: github.event_name == 'pull_request_target'
|
||||
id: find-report-run
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const workflow_id = 'frontend-bundle-report.yml';
|
||||
const artifactName = 'frontend-bundle-report';
|
||||
const headSha = context.payload.pull_request.head.sha;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const pollIntervalMs = 30_000;
|
||||
const timeoutMs = 90 * 60_000;
|
||||
const startedAt = Date.now();
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
async function listReportWorkflowRuns() {
|
||||
const runsForHead = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id,
|
||||
event: 'pull_request',
|
||||
head_sha: headSha,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
if (runsForHead.length > 0) {
|
||||
return runsForHead;
|
||||
}
|
||||
|
||||
const recentRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id,
|
||||
event: 'pull_request',
|
||||
per_page: 100,
|
||||
});
|
||||
return recentRuns.filter((run) =>
|
||||
run.pull_requests?.some((pullRequest) => pullRequest.number === prNumber));
|
||||
}
|
||||
|
||||
async function findReportRun() {
|
||||
const runs = (await listReportWorkflowRuns())
|
||||
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
|
||||
|
||||
for (const run of runs) {
|
||||
if (run.status !== 'completed') continue;
|
||||
if (run.conclusion !== 'success') {
|
||||
core.warning(`Frontend bundle report run ${run.id} completed with conclusion: ${run.conclusion}`);
|
||||
return { done: true, run: null };
|
||||
}
|
||||
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
|
||||
if (report) return { done: true, run };
|
||||
|
||||
core.info(`Frontend bundle report run ${run.id} did not produce ${artifactName}.`);
|
||||
return { done: true, run: null };
|
||||
}
|
||||
|
||||
return { done: false, run: null };
|
||||
}
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const { done, run } = await findReportRun();
|
||||
if (run) {
|
||||
core.info(`Found frontend bundle report on workflow run ${run.id}.`);
|
||||
core.setOutput('run-id', String(run.id));
|
||||
return;
|
||||
}
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
core.info('Waiting for frontend bundle report artifact...');
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
core.warning(`Timed out waiting for ${artifactName} from ${workflow_id} for ${headSha}.`);
|
||||
|
||||
- name: Find bundle report artifact
|
||||
if: github.event_name == 'workflow_run'
|
||||
id: find-report-artifact
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const artifactName = 'frontend-bundle-report';
|
||||
const { owner, repo } = context.repo;
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
|
||||
if (report) {
|
||||
core.setOutput('exists', 'true');
|
||||
} else {
|
||||
core.info(`Workflow run ${context.payload.workflow_run.id} did not produce ${artifactName}.`);
|
||||
core.setOutput('exists', 'false');
|
||||
}
|
||||
|
||||
- name: Download bundle report from workflow_run
|
||||
if: github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Download bundle report from pull_request_target
|
||||
if: github.event_name == 'pull_request_target' && steps.find-report-run.outputs.run-id != ''
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ steps.find-report-run.outputs.run-id }}
|
||||
|
||||
- name: Comment on pull request
|
||||
if: (github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true') || steps.find-report-run.outputs.run-id != ''
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.FRONTEND_BUNDLE_REPORT_COMMENT_TOKEN || secrets.FRONTEND_JS_SIZE_COMMENT_TOKEN || secrets.FRONTEND_BUNDLE_VISUALIZER_COMMENT_TOKEN || github.token }}
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const jsSizeMarker = '<!-- misskey-frontend-js-size -->';
|
||||
const visualizerMarker = '<!-- misskey-frontend-bundle-visualizer -->';
|
||||
const reportMarkers = [jsSizeMarker, visualizerMarker];
|
||||
const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-report');
|
||||
const jsSizeReportPath = path.join(reportDir, 'frontend-js-size-report.md');
|
||||
const prNumberPath = path.join(reportDir, 'pr-number.txt');
|
||||
const headShaPath = path.join(reportDir, 'head-sha.txt');
|
||||
const workflowRun = context.payload.workflow_run;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const eventHeadSha = workflowRun?.head_sha ?? pullRequest?.head?.sha ?? null;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
if (!fs.existsSync(jsSizeReportPath)) {
|
||||
core.setFailed('The frontend bundle report artifact does not contain frontend-js-size-report.md.');
|
||||
return;
|
||||
}
|
||||
|
||||
const artifactHeadSha = fs.existsSync(headShaPath)
|
||||
? fs.readFileSync(headShaPath, 'utf8').trim()
|
||||
: null;
|
||||
if (eventHeadSha != null && artifactHeadSha != null && artifactHeadSha !== eventHeadSha) {
|
||||
core.info(`The artifact head SHA (${artifactHeadSha}) differs from the event head SHA (${eventHeadSha}). Using artifact metadata for PR validation.`);
|
||||
}
|
||||
const reportHeadSha = artifactHeadSha ?? eventHeadSha;
|
||||
|
||||
const artifactPrNumber = fs.existsSync(prNumberPath)
|
||||
? Number(fs.readFileSync(prNumberPath, 'utf8').trim())
|
||||
: null;
|
||||
let issue_number = null;
|
||||
if (pullRequest != null) {
|
||||
issue_number = pullRequest.number;
|
||||
if (Number.isInteger(artifactPrNumber) && artifactPrNumber !== issue_number) {
|
||||
core.setFailed(`The artifact pull request number (${artifactPrNumber}) does not match the event pull request number (${issue_number}).`);
|
||||
return;
|
||||
}
|
||||
} else if (workflowRun != null) {
|
||||
const associatedPullRequests = new Map();
|
||||
for (const pullRequest of workflowRun.pull_requests ?? []) {
|
||||
if (Number.isInteger(pullRequest.number)) {
|
||||
associatedPullRequests.set(pullRequest.number, pullRequest);
|
||||
}
|
||||
}
|
||||
|
||||
if (reportHeadSha != null) {
|
||||
const pullRequestsForCommit = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
|
||||
owner,
|
||||
repo,
|
||||
commit_sha: reportHeadSha,
|
||||
per_page: 100,
|
||||
});
|
||||
for (const pullRequest of pullRequestsForCommit) {
|
||||
associatedPullRequests.set(pullRequest.number, pullRequest);
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isInteger(artifactPrNumber) && associatedPullRequests.has(artifactPrNumber)) {
|
||||
issue_number = artifactPrNumber;
|
||||
} else if (Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 0) {
|
||||
issue_number = artifactPrNumber;
|
||||
} else if (!Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 1) {
|
||||
issue_number = [...associatedPullRequests.keys()][0];
|
||||
} else if (Number.isInteger(artifactPrNumber)) {
|
||||
core.setFailed(`The artifact pull request number (${artifactPrNumber}) is not associated with ${reportHeadSha}.`);
|
||||
return;
|
||||
} else {
|
||||
core.setFailed(`Could not determine the pull request associated with ${reportHeadSha}.`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
core.setFailed('Could not determine the pull request event for this report.');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPullRequest = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue_number,
|
||||
});
|
||||
const currentHeadSha = currentPullRequest.data.head?.sha;
|
||||
if (reportHeadSha != null && currentHeadSha != null && reportHeadSha !== currentHeadSha) {
|
||||
core.info(`The report head SHA (${reportHeadSha}) is not the current pull request head SHA (${currentHeadSha}). Skipping stale frontend bundle report.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const jsSizeReport = fs.readFileSync(jsSizeReportPath, 'utf8').trim();
|
||||
if (!jsSizeReport.includes(jsSizeMarker)) {
|
||||
core.setFailed('The frontend JS size report is missing the expected marker.');
|
||||
return;
|
||||
}
|
||||
let body = `${jsSizeReport}\n`;
|
||||
|
||||
const maxCommentLength = 65_000;
|
||||
if (body.length > maxCommentLength) {
|
||||
const reportLocation = workflowRun?.html_url != null
|
||||
? `[workflow run](${workflowRun.html_url})`
|
||||
: 'workflow artifact';
|
||||
const footer = [
|
||||
'',
|
||||
'',
|
||||
`_Report truncated because it exceeded ${maxCommentLength.toLocaleString('en-US')} characters. See the ${reportLocation} for the full report._`,
|
||||
].join('\n');
|
||||
body = `${body.slice(0, maxCommentLength - footer.length)}${footer}`;
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
const previousReports = comments.filter((comment) =>
|
||||
comment.user?.type === 'Bot' && reportMarkers.some((reportMarker) => comment.body?.includes(reportMarker)));
|
||||
|
||||
if (previousReports.length > 0) {
|
||||
const [previous, ...duplicates] = previousReports;
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: previous.id,
|
||||
body,
|
||||
});
|
||||
for (const duplicate of duplicates) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: duplicate.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
170
.github/workflows/frontend-bundle-report.yml
vendored
Normal file
170
.github/workflows/frontend-bundle-report.yml
vendored
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
name: frontend-bundle-report
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- packages/frontend/**
|
||||
- packages/frontend-shared/**
|
||||
- packages/frontend-builder/**
|
||||
- packages/i18n/**
|
||||
- packages/icons-subsetter/**
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/frontend-js-size.mts
|
||||
- .github/workflows/frontend-bundle-report.yml
|
||||
- .github/workflows/frontend-bundle-report-comment.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-bundle-report-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Build frontend bundle report
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FRONTEND_JS_SIZE_LOCALE: ja-JP
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: before
|
||||
submodules: true
|
||||
|
||||
- name: Checkout pull request
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: after
|
||||
submodules: true
|
||||
|
||||
- name: Check base visualizer support
|
||||
id: check-base-visualizer
|
||||
shell: bash
|
||||
run: |
|
||||
if grep -q 'FRONTEND_BUNDLE_VISUALIZER' before/packages/frontend/vite.config.ts; then
|
||||
echo 'supported=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'supported=false' >> "$GITHUB_OUTPUT"
|
||||
echo 'Base commit does not support frontend bundle visualizer. Skipping frontend bundle report.' >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
with:
|
||||
package_json_file: after/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: after/.node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
before/pnpm-lock.yaml
|
||||
after/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies for base
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
working-directory: before
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Build frontend dependencies for base
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
working-directory: before
|
||||
run: pnpm --filter "frontend^..." run build
|
||||
|
||||
- name: Prepare report output
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report"
|
||||
|
||||
- name: Build frontend report for base
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
working-directory: before
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
- name: Install dependencies for pull request
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
working-directory: after
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Build frontend dependencies for pull request
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
working-directory: after
|
||||
run: pnpm --filter "frontend^..." run build
|
||||
|
||||
- name: Build frontend report for pull request
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
working-directory: after
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json
|
||||
FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
- name: Upload bundle visualizer
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
id: upload-bundle-visualizer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-bundle-visualizer
|
||||
path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
|
||||
if-no-files-found: error
|
||||
archive: false
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate report markdown
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
||||
node after/.github/scripts/frontend-js-size.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
|
||||
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
||||
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
|
||||
|
||||
- name: Check report
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
||||
test -s "$REPORT_DIR/before-stats.json"
|
||||
test -s "$REPORT_DIR/after-stats.json"
|
||||
test -s "$REPORT_DIR/frontend-js-size-report.md"
|
||||
cat "$REPORT_DIR/frontend-js-size-report.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload bundle report
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
92
.github/workflows/get-backend-memory.yml
vendored
92
.github/workflows/get-backend-memory.yml
vendored
|
|
@ -9,7 +9,14 @@ on:
|
|||
paths:
|
||||
- packages/backend/**
|
||||
- packages/misskey-js/**
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/backend-memory-report.mts
|
||||
- .github/scripts/measure-backend-memory-comparison.mts
|
||||
- .github/scripts/backend-js-footprint.mjs
|
||||
- .github/scripts/backend-js-footprint-loader.mjs
|
||||
- .github/scripts/backend-js-footprint-require.cjs
|
||||
- .github/workflows/get-backend-memory.yml
|
||||
- .github/workflows/report-backend-memory.yml
|
||||
|
||||
jobs:
|
||||
get-memory-usage:
|
||||
|
|
@ -17,15 +24,6 @@ jobs:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
memory-json-name: [memory-base.json, memory-head.json]
|
||||
include:
|
||||
- memory-json-name: memory-base.json
|
||||
ref: ${{ github.base_ref }}
|
||||
- memory-json-name: memory-head.json
|
||||
ref: refs/pull/${{ github.event.number }}/merge
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
|
|
@ -40,37 +38,77 @@ jobs:
|
|||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
ref: ${{ github.base_ref }}
|
||||
path: base
|
||||
submodules: true
|
||||
- name: Checkout head
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.number }}/merge
|
||||
path: head
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
with:
|
||||
package_json_file: head/package.json
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
node-version-file: 'head/.node-version'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- name: Check pnpm-lock.yaml
|
||||
cache-dependency-path: |
|
||||
base/pnpm-lock.yaml
|
||||
head/pnpm-lock.yaml
|
||||
- name: Install base dependencies
|
||||
working-directory: base
|
||||
run: pnpm i --frozen-lockfile
|
||||
- name: Check base pnpm-lock.yaml
|
||||
working-directory: base
|
||||
run: git diff --exit-code pnpm-lock.yaml
|
||||
- name: Copy Configure
|
||||
run: cp .github/misskey/test.yml .config/default.yml
|
||||
- name: Compile Configure
|
||||
run: pnpm compile-config
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
- name: Run migrations
|
||||
run: pnpm --filter backend migrate
|
||||
- name: Measure memory usage
|
||||
- name: Configure base
|
||||
working-directory: base
|
||||
run: |
|
||||
# Start the server and measure memory usage
|
||||
node packages/backend/scripts/measure-memory.mjs > ${{ matrix.memory-json-name }}
|
||||
cp .github/misskey/test.yml .config/default.yml
|
||||
pnpm compile-config
|
||||
- name: Build base
|
||||
working-directory: base
|
||||
run: pnpm build
|
||||
- name: Install head dependencies
|
||||
working-directory: head
|
||||
run: pnpm i --frozen-lockfile
|
||||
- name: Check head pnpm-lock.yaml
|
||||
working-directory: head
|
||||
run: git diff --exit-code pnpm-lock.yaml
|
||||
- name: Configure head
|
||||
working-directory: head
|
||||
run: |
|
||||
cp .github/misskey/test.yml .config/default.yml
|
||||
pnpm compile-config
|
||||
- name: Build head
|
||||
working-directory: head
|
||||
run: pnpm build
|
||||
- name: Measure backend memory usage
|
||||
env:
|
||||
MK_MEMORY_COMPARE_ROUNDS: 5
|
||||
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1
|
||||
MK_MEMORY_HEAP_SNAPSHOT: 1
|
||||
run: node head/.github/scripts/measure-backend-memory-comparison.mts base head memory-base.json memory-head.json
|
||||
- name: Measure backend loaded JS footprint
|
||||
run: |
|
||||
node head/.github/scripts/backend-js-footprint.mjs base js-footprint-base.json
|
||||
node head/.github/scripts/backend-js-footprint.mjs head js-footprint-head.json
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: memory-artifact-${{ matrix.memory-json-name }}
|
||||
path: ${{ matrix.memory-json-name }}
|
||||
name: memory-artifact-results
|
||||
path: |
|
||||
memory-base.json
|
||||
memory-head.json
|
||||
js-footprint-base.json
|
||||
js-footprint-head.json
|
||||
|
||||
save-pr-number:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
6
.github/workflows/lint.yml
vendored
6
.github/workflows/lint.yml
vendored
|
|
@ -16,8 +16,6 @@ on:
|
|||
- packages/misskey-js/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-world/**
|
||||
- packages/frontend-misskey-world-engine/**
|
||||
- packages/shared/eslint.config.js
|
||||
- .github/workflows/lint.yml
|
||||
pull_request:
|
||||
|
|
@ -32,8 +30,6 @@ on:
|
|||
- packages/misskey-js/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-world/**
|
||||
- packages/frontend-misskey-world-engine/**
|
||||
- packages/shared/eslint.config.js
|
||||
- .github/workflows/lint.yml
|
||||
jobs:
|
||||
|
|
@ -69,8 +65,6 @@ jobs:
|
|||
- misskey-js
|
||||
- misskey-bubble-game
|
||||
- misskey-reversi
|
||||
- misskey-world
|
||||
- frontend-misskey-world-engine
|
||||
env:
|
||||
eslint-cache-version: v1
|
||||
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
|
||||
|
|
|
|||
122
.github/workflows/report-backend-memory.yml
vendored
122
.github/workflows/report-backend-memory.yml
vendored
|
|
@ -11,9 +11,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
|
|
@ -48,120 +53,13 @@ jobs:
|
|||
run: cat ./artifacts/memory-base.json
|
||||
- name: Output head
|
||||
run: cat ./artifacts/memory-head.json
|
||||
- name: Compare memory usage
|
||||
id: compare
|
||||
run: |
|
||||
BASE_MEMORY=$(cat ./artifacts/memory-base.json)
|
||||
HEAD_MEMORY=$(cat ./artifacts/memory-head.json)
|
||||
|
||||
variation() {
|
||||
calc() {
|
||||
BASE=$(echo "$BASE_MEMORY" | jq -r ".${1}.${2} // 0")
|
||||
HEAD=$(echo "$HEAD_MEMORY" | jq -r ".${1}.${2} // 0")
|
||||
|
||||
DIFF=$((HEAD - BASE))
|
||||
if [ "$BASE" -gt 0 ]; then
|
||||
DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE" | bc)
|
||||
else
|
||||
DIFF_PERCENT=0
|
||||
fi
|
||||
|
||||
# Convert KB to MB for readability
|
||||
BASE_MB=$(echo "scale=2; $BASE / 1024" | bc)
|
||||
HEAD_MB=$(echo "scale=2; $HEAD / 1024" | bc)
|
||||
DIFF_MB=$(echo "scale=2; $DIFF / 1024" | bc)
|
||||
|
||||
JSON=$(jq -c -n \
|
||||
--argjson base "$BASE_MB" \
|
||||
--argjson head "$HEAD_MB" \
|
||||
--argjson diff "$DIFF_MB" \
|
||||
--argjson diff_percent "$DIFF_PERCENT" \
|
||||
'{base: $base, head: $head, diff: $diff, diff_percent: $diff_percent}')
|
||||
|
||||
echo "$JSON"
|
||||
}
|
||||
|
||||
JSON=$(jq -c -n \
|
||||
--argjson VmRSS "$(calc $1 VmRSS)" \
|
||||
--argjson VmHWM "$(calc $1 VmHWM)" \
|
||||
--argjson VmSize "$(calc $1 VmSize)" \
|
||||
--argjson VmData "$(calc $1 VmData)" \
|
||||
'{VmRSS: $VmRSS, VmHWM: $VmHWM, VmSize: $VmSize, VmData: $VmData}')
|
||||
|
||||
echo "$JSON"
|
||||
}
|
||||
|
||||
JSON=$(jq -c -n \
|
||||
--argjson beforeGc "$(variation beforeGc)" \
|
||||
--argjson afterGc "$(variation afterGc)" \
|
||||
--argjson afterRequest "$(variation afterRequest)" \
|
||||
'{beforeGc: $beforeGc, afterGc: $afterGc, afterRequest: $afterRequest}')
|
||||
|
||||
echo "res=$JSON" >> "$GITHUB_OUTPUT"
|
||||
- name: Output base JS footprint
|
||||
run: cat ./artifacts/js-footprint-base.json
|
||||
- name: Output head JS footprint
|
||||
run: cat ./artifacts/js-footprint-head.json
|
||||
- id: build-comment
|
||||
name: Build memory comment
|
||||
env:
|
||||
RES: ${{ steps.compare.outputs.res }}
|
||||
run: |
|
||||
HEADER="## Backend memory usage comparison"
|
||||
FOOTER="[See workflow logs for details](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})"
|
||||
|
||||
echo "$HEADER" > ./output.md
|
||||
echo >> ./output.md
|
||||
|
||||
table() {
|
||||
echo "| Metric | base (MB) | head (MB) | Diff (MB) | Diff (%) |" >> ./output.md
|
||||
echo "|--------|------:|------:|------:|------:|" >> ./output.md
|
||||
|
||||
line() {
|
||||
METRIC=$2
|
||||
BASE=$(echo "$RES" | jq -r ".${1}.${2}.base")
|
||||
HEAD=$(echo "$RES" | jq -r ".${1}.${2}.head")
|
||||
DIFF=$(echo "$RES" | jq -r ".${1}.${2}.diff")
|
||||
DIFF_PERCENT=$(echo "$RES" | jq -r ".${1}.${2}.diff_percent")
|
||||
|
||||
if (( $(echo "$DIFF_PERCENT > 0" | bc -l) )); then
|
||||
DIFF="+$DIFF"
|
||||
DIFF_PERCENT="+$DIFF_PERCENT"
|
||||
fi
|
||||
|
||||
# highlight VmRSS
|
||||
if [ "$2" = "VmRSS" ]; then
|
||||
METRIC="**${METRIC}**"
|
||||
BASE="**${BASE}**"
|
||||
HEAD="**${HEAD}**"
|
||||
DIFF="**${DIFF}**"
|
||||
DIFF_PERCENT="**${DIFF_PERCENT}**"
|
||||
fi
|
||||
|
||||
echo "| ${METRIC} | ${BASE} MB | ${HEAD} MB | ${DIFF} MB | ${DIFF_PERCENT}% |" >> ./output.md
|
||||
}
|
||||
|
||||
line $1 VmRSS
|
||||
line $1 VmHWM
|
||||
line $1 VmSize
|
||||
line $1 VmData
|
||||
}
|
||||
|
||||
echo "### Before GC" >> ./output.md
|
||||
table beforeGc
|
||||
echo >> ./output.md
|
||||
|
||||
echo "### After GC" >> ./output.md
|
||||
table afterGc
|
||||
echo >> ./output.md
|
||||
|
||||
echo "### After Request" >> ./output.md
|
||||
table afterRequest
|
||||
echo >> ./output.md
|
||||
|
||||
# Determine if this is a significant change (more than 5% increase)
|
||||
if [ "$(echo "$RES" | jq -r '.afterGc.VmRSS.diff_percent | tonumber > 5')" = "true" ]; then
|
||||
echo "⚠️ **Warning**: Memory usage has increased by more than 5%. Please verify this is not an unintended change." >> ./output.md
|
||||
echo >> ./output.md
|
||||
fi
|
||||
|
||||
echo "$FOOTER" >> ./output.md
|
||||
run: node .github/scripts/backend-memory-report.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md ./artifacts/js-footprint-base.json ./artifacts/js-footprint-head.json
|
||||
- uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
|
||||
|
|
|
|||
35
.github/workflows/test-backend.yml
vendored
35
.github/workflows/test-backend.yml
vendored
|
|
@ -19,12 +19,6 @@ on:
|
|||
- .github/workflows/test-backend.yml
|
||||
- .github/misskey/test.yml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_ffmpeg_cache_update:
|
||||
description: 'Force update ffmpeg cache'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
|
|
@ -62,36 +56,9 @@ jobs:
|
|||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
- name: Get current date
|
||||
id: current-date
|
||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
- name: Setup and Restore ffmpeg/ffprobe Cache
|
||||
id: cache-ffmpeg
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
/usr/local/bin/ffmpeg
|
||||
/usr/local/bin/ffprobe
|
||||
# daily cache
|
||||
key: ${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
|
||||
- name: Install FFmpeg
|
||||
if: steps.cache-ffmpeg.outputs.cache-hit != 'true' || github.event.inputs.force_ffmpeg_cache_update == true
|
||||
run: |
|
||||
for i in {1..3}; do
|
||||
echo "Attempt $i: Installing FFmpeg..."
|
||||
curl -s -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz && \
|
||||
tar -xf ffmpeg.tar.xz && \
|
||||
mv ffmpeg-*-static/ffmpeg /usr/local/bin/ && \
|
||||
mv ffmpeg-*-static/ffprobe /usr/local/bin/ && \
|
||||
rm -rf ffmpeg.tar.xz ffmpeg-*-static/ && \
|
||||
break || sleep 10
|
||||
if [ $i -eq 3 ]; then
|
||||
echo "Failed to install FFmpeg after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
sudo apt install -y ffmpeg
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
|
|
|||
35
.github/workflows/test-federation.yml
vendored
35
.github/workflows/test-federation.yml
vendored
|
|
@ -15,12 +15,6 @@ on:
|
|||
- packages/misskey-js/**
|
||||
- .github/workflows/test-federation.yml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_ffmpeg_cache_update:
|
||||
description: 'Force update ffmpeg cache'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
|
@ -37,36 +31,9 @@ jobs:
|
|||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
- name: Get current date
|
||||
id: current-date
|
||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
- name: Setup and Restore ffmpeg/ffprobe Cache
|
||||
id: cache-ffmpeg
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
/usr/local/bin/ffmpeg
|
||||
/usr/local/bin/ffprobe
|
||||
# daily cache
|
||||
key: ${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
|
||||
- name: Install FFmpeg
|
||||
if: steps.cache-ffmpeg.outputs.cache-hit != 'true' || github.event.inputs.force_ffmpeg_cache_update == true
|
||||
run: |
|
||||
for i in {1..3}; do
|
||||
echo "Attempt $i: Installing FFmpeg..."
|
||||
curl -s -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz && \
|
||||
tar -xf ffmpeg.tar.xz && \
|
||||
mv ffmpeg-*-static/ffmpeg /usr/local/bin/ && \
|
||||
mv ffmpeg-*-static/ffprobe /usr/local/bin/ && \
|
||||
rm -rf ffmpeg.tar.xz ffmpeg-*-static/ && \
|
||||
break || sleep 10
|
||||
if [ $i -eq 3 ]; then
|
||||
echo "Failed to install FFmpeg after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
sudo apt install -y ffmpeg
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
22.15.0
|
||||
22.18.0
|
||||
|
|
|
|||
|
|
@ -63,14 +63,16 @@
|
|||
9. **ユーザーの明示指示なしに PR を merge / close / force-push しない**
|
||||
10. **ユーザーの明示指示なしに external service (GitHub comments / Slack / メール 等) へ送信しない**
|
||||
11. **secrets / 認証情報をリポジトリにコミットしない** (`.config/*.yml` の本番値、`.env` ファイル、API token、private key 等)
|
||||
12. **脆弱性報告を通常の Issue / PR 経由で行わない** (脆弱性報告を行う場合のルールは `creating-issues-and-prs` スキルを参照すること)
|
||||
|
||||
### スキル呼び出し
|
||||
|
||||
上流スキルの実行・事前知識・memory の内容に関わらず免除されない。
|
||||
|
||||
12. **`working-on-backend` スキルを参照せずに `packages/backend/` 配下のファイルを編集・追加しない**
|
||||
13. **`working-on-frontend` スキルを参照せずに `packages/frontend/` 配下のファイルを編集・追加しない**
|
||||
14. **`shipping-misskey-change` スキルを参照せずに commit / PR 作成 / 作業をユーザーに返さない**
|
||||
13. **`working-on-backend` スキルを参照せずに `packages/backend/` 配下のファイルを編集・追加しない**
|
||||
14. **`working-on-frontend` スキルを参照せずに `packages/frontend/` 配下のファイルを編集・追加しない**
|
||||
15. **`shipping-misskey-change` スキルを参照せずに commit / PR 作成 / 作業をユーザーに返さない**
|
||||
16. **`creating-issues-and-prs` スキルを参照せずに Issue / PR を起票しない** (脆弱性報告のルールも含む)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -1,3 +1,15 @@
|
|||
## Unreleased
|
||||
|
||||
### General
|
||||
-
|
||||
|
||||
### Client
|
||||
-
|
||||
|
||||
### Server
|
||||
-
|
||||
|
||||
|
||||
## 2026.6.0
|
||||
|
||||
### General
|
||||
|
|
@ -25,12 +37,16 @@
|
|||
- Enhance: リモートノートクリーニングジョブのスキップ処理のパフォーマンス改善
|
||||
- Enhance: リモートノートクリーニングジョブの削除対象検索処理のパフォーマンス改善
|
||||
- Enhance: ActivityPub の画像添付に width/height を含めるように
|
||||
- Enhance: URLプレビューのデフォルトの User Agent に Misskey サーバーのURLを含めるように
|
||||
- Fix: backend バンドルで `@tensorflow/tfjs-node` を external に含めず、起動時に `@mapbox/node-pre-gyp` の `find()` が backend の package.json を誤検出して `is not node-pre-gyp ready` エラーを永続的に吐く問題を修正
|
||||
- Fix: MemoryKVCacheのキャッシュGC処理において、更新されたキャッシュが期限切れにならないことがある問題を修正
|
||||
- Fix: PerUserDriveChart がシステム所有ファイル (userId が null) の更新で `"group"` の非NULL制約違反によりクラッシュする問題を修正 (#17498)
|
||||
- Fix: センシティブメディア自動検出周りの依存関係・ファイルの解決に失敗する問題を修正
|
||||
- Fix: フォロワー限定投稿を指名投稿で引用した際に、引用した投稿の公開範囲が意図せず変更される問題を修正
|
||||
- Fix: `actor` を持たない不正なInboxアクティビティを受信した際に配送ジョブが `TypeError` でクラッシュする問題を修正 (受信時に検証して400で返し、ジョブを積まないように変更)
|
||||
- Fix: Startup and shutdown failures (port-in-use, socket permission denied, plugin timeouts, leaked WebSocket connections) are now reported through the misskey logger instead of an UnhandledPromiseRejectionWarning stack trace
|
||||
- Fix: リモートのノートに対するメンション数制限が、サーバーが解決できたユーザー数ベースで行われていた問題を修正
|
||||
- Fix: セキュリティに関する修正
|
||||
|
||||
## 2026.5.4
|
||||
|
||||
|
|
|
|||
10
Dockerfile
10
Dockerfile
|
|
@ -30,8 +30,6 @@ COPY --link ["packages/sw/package.json", "./packages/sw/"]
|
|||
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
|
||||
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
|
||||
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
|
||||
COPY --link ["packages/misskey-world/package.json", "./packages/misskey-world/"]
|
||||
COPY --link ["packages/frontend-misskey-world-engine/package.json", "./packages/frontend-misskey-world-engine/"]
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
|
|
@ -63,8 +61,6 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"]
|
|||
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
|
||||
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
|
||||
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
|
||||
COPY --link ["packages/misskey-world/package.json", "./packages/misskey-world/"]
|
||||
COPY --link ["packages/frontend-misskey-world-engine/package.json", "./packages/frontend-misskey-world-engine/"]
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
|
|
@ -103,18 +99,12 @@ COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/nod
|
|||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-reversi/node_modules ./packages/misskey-reversi/node_modules
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-bubble-game/node_modules ./packages/misskey-bubble-game/node_modules
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-world/node_modules ./packages/misskey-world/node_modules
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/frontend-misskey-world-engine/node_modules ./packages/frontend-misskey-world-engine/node_modules
|
||||
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/built ./packages/misskey-js/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/i18n/built ./packages/i18n/built
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-world/built ./packages/misskey-world/built
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/frontend-misskey-world-engine/built ./packages/frontend-misskey-world-engine/built
|
||||
|
||||
COPY --chown=misskey:misskey . ./
|
||||
|
||||
ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so
|
||||
|
|
|
|||
|
|
@ -580,7 +580,7 @@ objectStorageSetPublicRead: "Seleccionar \"public-read\" al subir "
|
|||
s3ForcePathStyleDesc: "Si s3ForcePathStyle esta habilitado el nombre del bucket debe ser especificado como parte de la URL en lugar del nombre de host en la URL. Puede ser necesario activar esta opción cuando se utilice, por ejemplo, Minio en un servidor propio."
|
||||
serverLogs: "Registros del servidor"
|
||||
deleteAll: "Eliminar todos"
|
||||
showFixedPostForm: "Visualizar la ventana de publicación en la parte superior de la línea de tiempo."
|
||||
showFixedPostForm: "Mostrar formulario de publicación sobre la línea de tiempo."
|
||||
showFixedPostFormInChannel: "Mostrar el formulario de publicación por encima de la cronología (Canales)"
|
||||
withRepliesByDefaultForNewlyFollowed: "Incluir por defecto respuestas de usuarios recién seguidos en la línea de tiempo"
|
||||
newNoteRecived: "Tienes una nota nueva"
|
||||
|
|
@ -2657,7 +2657,7 @@ _postForm:
|
|||
submit_title: "Botón de publicar"
|
||||
submit_description: "Publica tus notas pulsando este botón. También puedes publicar utilizando Ctrl + Intro / Cmd + Intro."
|
||||
_placeholders:
|
||||
a: "What are you up to?"
|
||||
a: "¿Qué está pasando?"
|
||||
b: "¿Te pasó algo?"
|
||||
c: "¿Qué estás pensando?"
|
||||
d: "¿Algo que quieras decir?"
|
||||
|
|
|
|||
|
|
@ -132,16 +132,16 @@ sensitive: "Konten sensitif"
|
|||
add: "Tambahkan"
|
||||
reaction: "Reaksi"
|
||||
reactions: "Reaksi"
|
||||
emojiPicker: "Emoji Picker"
|
||||
pinnedEmojisForReactionSettingDescription: "Atur sematan emoji pada reaksi"
|
||||
pinnedEmojisSettingDescription: "Atur sematan emoji pada masukan emoji"
|
||||
emojiPickerDisplay: "Tampilan Emoji Picker"
|
||||
emojiPicker: "Palet emoji"
|
||||
pinnedEmojisForReactionSettingDescription: "Atur emoji yang akan disematkan dan ditampilkan saat memberi reaksi."
|
||||
pinnedEmojisSettingDescription: "Atur emoji yang akan disematkan dan ditampilkan saat melihat palet emoji"
|
||||
emojiPickerDisplay: "Tampilan palet emoji"
|
||||
overwriteFromPinnedEmojisForReaction: "Timpa dari pengaturan reaksi"
|
||||
overwriteFromPinnedEmojis: "Timpa dari pengaturan umum"
|
||||
reactionSettingDescription2: "Geser untuk memindah urutan emoji, klik untuk menghapus, tekan \"+\" untuk menambahkan"
|
||||
rememberNoteVisibility: "Ingat pengaturan visibilitas catatan"
|
||||
attachCancel: "Hapus lampiran"
|
||||
deleteFile: "Berkas dihapus"
|
||||
deleteFile: "Hapus berkas"
|
||||
markAsSensitive: "Tandai sebagai konten sensitif"
|
||||
unmarkAsSensitive: "Hapus tanda konten sensitif"
|
||||
enterFileName: "Masukkan nama berkas"
|
||||
|
|
@ -162,7 +162,7 @@ editList: "Sunting daftar"
|
|||
selectChannel: "Pilih kanal"
|
||||
selectAntenna: "Pilih Antena"
|
||||
editAntenna: "Sunting antena"
|
||||
createAntenna: "Membuat antena."
|
||||
createAntenna: "Membuat antena"
|
||||
selectWidget: "Pilih gawit"
|
||||
editWidgets: "Sunting gawit"
|
||||
editWidgetsExit: "Selesai"
|
||||
|
|
@ -301,7 +301,7 @@ uploadFromUrl: "Unggah dari URL"
|
|||
uploadFromUrlDescription: "URL berkas yang ingin kamu unggah"
|
||||
uploadFromUrlRequested: "Pengunggahan telah diminta"
|
||||
uploadFromUrlMayTakeTime: "Membutuhkan beberapa waktu hingga pengunggahan selesai"
|
||||
uploadNFiles: "Unggah file {n}"
|
||||
uploadNFiles: "Unggah berkas {n}"
|
||||
explore: "Jelajahi"
|
||||
messageRead: "Telah dibaca"
|
||||
readAllChatMessages: "Tandai semua pesan menjadi terbaca"
|
||||
|
|
@ -339,7 +339,7 @@ selectFiles: "Pilih berkas"
|
|||
selectFolder: "Pilih folder"
|
||||
unselectFolder: "Membatalkan seleksi folder"
|
||||
selectFolders: "Pilih folder"
|
||||
fileNotSelected: "Tidak ada file yang dipilih"
|
||||
fileNotSelected: "Tidak ada berkas yang terpilih"
|
||||
renameFile: "Ubah nama berkas"
|
||||
folderName: "Nama folder"
|
||||
createFolder: "Buat folder"
|
||||
|
|
@ -350,7 +350,7 @@ addFile: "Tambahkan berkas"
|
|||
showFile: "Tampilkan berkas"
|
||||
emptyDrive: "Drive kosong"
|
||||
emptyFolder: "Folder kosong"
|
||||
dropHereToUpload: "Lepas file di sini untuk diunggah"
|
||||
dropHereToUpload: "Lepas berkas di sini untuk diunggah"
|
||||
unableToDelete: "Tidak dapat menghapus"
|
||||
inputNewFileName: "Masukkan nama berkas yang baru"
|
||||
inputNewDescription: "Masukkan keterangan disini"
|
||||
|
|
@ -1012,7 +1012,7 @@ failedToUpload: "Gagal mengunggah"
|
|||
cannotUploadBecauseInappropriate: "Berkas ini tidak dapat diunggah karena sebagian dari berkas terdeteksi berpotensi NSFW."
|
||||
cannotUploadBecauseNoFreeSpace: "Gagal mengunggah karena kekurangan kapasitas Drive."
|
||||
cannotUploadBecauseExceedsFileSizeLimit: "Berkas ini tidak dapat diunggah karena melebihi batas ukuran berkas."
|
||||
cannotUploadBecauseUnallowedFileType: "Tidak dapat mengunggah karena tipe file yang tidak diijinkan."
|
||||
cannotUploadBecauseUnallowedFileType: "Tidak dapat mengunggah karena tipe berkas yang tidak diijinkan."
|
||||
beta: "Beta"
|
||||
enableAutoSensitive: "Penandaan NSFW otomatis"
|
||||
enableAutoSensitiveDescription: "Mendeteksi otomatis dan menandai media NSFW menggunakan Pembelajaran Mesin jika memungkinkan. Meskipun opsi ini dimatikan, ada kemungkinan dinyalakan secara menyeluruh pada instansi peladen."
|
||||
|
|
@ -1256,6 +1256,7 @@ releaseToRefresh: "Lepaskan untuk memuat ulang"
|
|||
refreshing: "Sedang memuat ulang..."
|
||||
pullDownToRefresh: "Tarik ke bawah untuk memuat ulang"
|
||||
useGroupedNotifications: "Tampilkan notifikasi secara dikelompokkan"
|
||||
emailVerificationFailedError: "Ada masalah saat memverifikasi alamat surel anda. Tautannya mungkin sudah kadaluarsa."
|
||||
cwNotationRequired: "Jika \"Sembunyikan konten\" diaktifkan, deskripsi harus disediakan."
|
||||
doReaction: "Tambahkan reaksi"
|
||||
code: "Kode"
|
||||
|
|
@ -1289,12 +1290,13 @@ useTotp: "Gunakan TOTP"
|
|||
useBackupCode: "Gunakan kode cadangan"
|
||||
launchApp: "Luncurkan Aplikasi"
|
||||
useNativeUIForVideoAudioPlayer: "Gunakan antarmuka peramban ketika memainkan video dan audio"
|
||||
keepOriginalFilename: "Simpan nama berkas asli"
|
||||
keepOriginalFilename: "Gunakan nama asli berkas"
|
||||
keepOriginalFilenameDescription: "Apabila pengaturan ini dimatikan, nama berkas akan diganti dengan string acak secara otomatis ketika kamu mengunggah berkas."
|
||||
noDescription: "Tidak ada deskripsi"
|
||||
alwaysConfirmFollow: "Selalu konfirmasi ketika mengikuti"
|
||||
inquiry: "Hubungi kami"
|
||||
tryAgain: "Silahkan coba lagi."
|
||||
confirmWhenRevealingSensitiveMedia: "Konfirmasi saat membuka media sensitif"
|
||||
sensitiveMediaRevealConfirm: "Media sensitif. Apakah ingin melihat?"
|
||||
createdLists: "Senarai yang dibuat"
|
||||
createdAntennas: "Antena yang dibuat"
|
||||
|
|
@ -1317,6 +1319,7 @@ federationSpecified: "Peladen ini dioperasikan dalam federasi daftar putih. Inte
|
|||
federationDisabled: "Federasi dimatikan di peladen ini. Anda tidak dapat berinteraksi dengan pengguna di peladen lain."
|
||||
draft: "Draf"
|
||||
draftsAndScheduledNotes: "Draf dan note terjadwal"
|
||||
preferencesProfile: "Pengaturan profil"
|
||||
noName: "Tidak ada nama"
|
||||
skip: "Lewati"
|
||||
restore: "Kembalikan"
|
||||
|
|
@ -1330,7 +1333,10 @@ directMessage: "Obrolan pengguna"
|
|||
right: "Kanan"
|
||||
bottom: "Bawah"
|
||||
top: "Atas"
|
||||
driveAboutTip: "Dalam Drive, daftar berkas yang telah anda unggah sebelumnya akan ditampilkan. <br>\nAnda dapat menggunakan kembali berkas-berkas tersebut dalam lampiran note, atau mengunggah berkas sekarang untuk dipublikasikan nanti. <br>\n<b>Harap berhati-hati ketika menghapus berkas, karena berkas tersebut akan tidak bisa diakses di semua tempat yang menggunakan berkas tersebut (seperti note, halaman, avatar, banner, dll.)</b><br>\nAnda juga dapat membuat folder untuk menata berkas-berkas anda."
|
||||
advice: "Saran"
|
||||
defaultImageCompressionLevel_description: "Level yang rendah akan menjaga kualitas gambar namun memperbesar ukuran berkas.<br>Level yang tinggi akan mengurangi ukuran berkas, namun mengurangi kualitas gambar."
|
||||
defaultCompressionLevel_description: "Kompresi yang rendah akan menjaga kualitas namun memperbesar ukuran berkas. Kompresi yang tinggi akan mengurangi ukuran berkas namun mengurangi kualitas."
|
||||
inMinutes: "menit"
|
||||
inDays: "hari"
|
||||
widgets: "Widget"
|
||||
|
|
@ -1338,7 +1344,9 @@ presets: "Prasetel"
|
|||
previewingThemeRestore: "Kembalikan"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "Keterangan berkas"
|
||||
filename: "Nama berkas"
|
||||
filename_without_ext: "Nama berkas tanpa ekstensi"
|
||||
_imageFrameEditor:
|
||||
header: "Header"
|
||||
withQrCode: "QR Code"
|
||||
|
|
@ -1355,16 +1363,23 @@ _chat:
|
|||
send: "Kirim"
|
||||
chatWithThisUser: "Obrolan pengguna"
|
||||
_settings:
|
||||
driveBanner: "Anda dapat mengelola dan mengatur drive, melihat penggunaan, dan mengatur pengaturan unggahan berkas."
|
||||
notificationsBanner: "Anda dapat mengatur tipe dan rentang notifikasi dari peladen dan notifikasi push."
|
||||
webhook: "Webhook"
|
||||
contentsUpdateFrequency: "Frekuensi pembaruan konten"
|
||||
_preferencesProfile:
|
||||
profileName: "Nama profil"
|
||||
profileNameDescription: "Tulis nama untuk mengidentifikasi perangkat ini."
|
||||
profileNameDescription2: "Contoh: \"PC Utama\", \"Smartphone\""
|
||||
manageProfiles: "Kelola Profil"
|
||||
shareSameProfileBetweenDevicesIsNotRecommended: "Kami tidak menyarankan menggunakan profil yang sama diantara beberapa perangkat yang berbeda."
|
||||
useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "Jika terdapat pengaturan yang ingin anda sinkronkan diantara beberapa perangkat yang berbeda, nyalakan opsi \"Sinkronisasi pada perangkat yang berbeda\" satu per satu untuk setiap perangkat."
|
||||
_preferencesBackup:
|
||||
autoBackup: "Pencadangan otomatis"
|
||||
restoreFromBackup: "Kembalikan dari pencadangan"
|
||||
noBackupsFoundDescription: "Tidak ada pencadangan otomatis yang ditemukan, namun jika anda pernah membuat cadangan secara manual, anda bisa mengimpor dan mengembalikan pencadangan tersebut."
|
||||
selectBackupToRestore: "Pilih pencadangan untuk dikembalikan"
|
||||
youNeedToNameYourProfileToEnableAutoBackup: "Nama profil harus dibuat untuk menyalakan cadangan otomatis."
|
||||
_accountSettings:
|
||||
makeNotesFollowersOnlyBeforeDescription: "Ketika fitur ini diaktifkan, hanya pengikut yang dapat melihat note sebelum tanggal dan waktu yang ditentukan atau telah terlihat untuk waktu tertentu. Setelah dinonaktifkan, status publikasi note juga akan dikembalikan seperti semula."
|
||||
makeNotesHiddenBeforeDescription: "Saat fitur ini diaktifkan, note sebelum tanggal dan waktu tertentu hanya akan terlihat oleh anda. Setelah dinonaktifkan, status publikasi note juga akan dikembalikan seperti semula."
|
||||
|
|
@ -1410,7 +1425,7 @@ _announcement:
|
|||
silenceDescription: "Apabila diaktifkan, notifikasi dari pengumuman ini akan dilewatkan dan pengguna tidak perlu membacanya."
|
||||
_initialAccountSetting:
|
||||
accountCreated: "Akun kamu telah sukses dibuat!"
|
||||
letsStartAccountSetup: "Untuk pemula, ayo atur profilmu dulu."
|
||||
letsStartAccountSetup: "Pertama-tama, ayo atur profilmu dulu."
|
||||
letsFillYourProfile: "Pertama, ayo atur profilmu dulu."
|
||||
profileSetting: "Pengaturan profil"
|
||||
privacySetting: "Pengaturan privasi"
|
||||
|
|
@ -1508,6 +1523,8 @@ _serverSettings:
|
|||
reactionsBufferingDescription: "Ketika diaktifkan, performa saat membuat reaksi akan meningkat drastis, mengurangi beban database. Namun, penggunaan memori Redis akan meningkat."
|
||||
remoteNotesCleaning_description: "Ketika diaktifkan, note yang tidak terpakai dan kadaluarsa dari instansi luar akan dibersihkan secara berkala untuk mencegah membengkaknya database."
|
||||
inquiryUrlDescription: "Cantumkan URL untuk menghubungi pengelola peladen atau laman web berisikan informasi kontak."
|
||||
proxyRemoteFiles: "Berkas proksi remote"
|
||||
proxyRemoteFiles_description: "Ketika dinyalakan, peladen akan berperan sebagai proksi menyajikan berkas secara remote. Ini dapat berguna untuk membuat keluku gambar dan melindungi privasi pengguna."
|
||||
_accountMigration:
|
||||
moveFrom: "Pindahkan akun lain ke akun ini"
|
||||
moveFromSub: "Buat alias ke akun lain"
|
||||
|
|
@ -1823,6 +1840,9 @@ _role:
|
|||
canManageCustomEmojis: "Dapat mengelola Emoji kustom"
|
||||
canManageAvatarDecorations: "Kelola dekorasi avatar"
|
||||
driveCapacity: "Kapasitas Drive"
|
||||
maxFileSize: "Ukuran berkas maksimal yang dapat diunggah"
|
||||
maxFileSize_caption: "Proksi terbalik, CDN, dan komponen antarmuka-depan bisa memiliki pengaturan tersendiri."
|
||||
maxFileSize_caption2: "Ukuran berkas maksimal di keseluruhan peladen adalah {max}. Untuk memperbolehkan unggahan berkas yang lebih besar dari ini, silahkan mengubah pengaturan ini di dalam berkas pengaturan Misskey."
|
||||
alwaysMarkNsfw: "Selalu tandai berkas sebagai NSFW"
|
||||
pinMax: "Jumlah maksimal catatan yang disematkan"
|
||||
antennaMax: "Jumlah maksimum antena"
|
||||
|
|
@ -1840,6 +1860,7 @@ _role:
|
|||
avatarDecorationLimit: "Jumlah maksimum dekorasi avatar yang dapat diterapkan"
|
||||
canImportAntennas: "Izinkan mengimpor antena"
|
||||
canImportUserLists: "Izinkan mengimpor senarai"
|
||||
uploadableFileTypes: "Jenis berkas yang dapat diunggah"
|
||||
noteDraftLimit: "Jumlah dari draf yang dapat dibuat dari sisi peladen"
|
||||
_condition:
|
||||
roleAssignedTo: "Ditugaskan ke peran manual"
|
||||
|
|
@ -2748,6 +2769,8 @@ _search:
|
|||
searchScopeAll: "Semua"
|
||||
searchScopeLocal: "Lokal"
|
||||
searchScopeUser: "Pengguna spesifik"
|
||||
_uploader:
|
||||
allowedTypes: "Jenis berkas yang dapat diunggah"
|
||||
_watermarkEditor:
|
||||
driveFileTypeWarn: "Berkas ini tidak didukung"
|
||||
opacity: "Opasitas"
|
||||
|
|
|
|||
|
|
@ -1415,6 +1415,11 @@ viewRenotedChannel: "Visualizza il canale del Rinota"
|
|||
previewingTheme: "Anteprima del Tema"
|
||||
previewingThemeRestore: "Ripristina"
|
||||
accessToken: "Codice di accesso"
|
||||
chooseEmojiPalette: "Scegli la tavolozza emoji"
|
||||
addToEmojiPalette: "Aggiungi alla tavolozza emoji"
|
||||
emojiPaletteAlreadyAddedConfirm: "Questa emoji è già inclusa in nella tavolozza. Vuoi davvero aggiungerla?"
|
||||
append: "Accodare"
|
||||
prepend: "Anteporre"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "Didascalia dell'immagine"
|
||||
|
|
|
|||
|
|
@ -1415,8 +1415,6 @@ viewRenotedChannel: "リノート先のチャンネルを見る"
|
|||
previewingTheme: "テーマのプレビュー中"
|
||||
previewingThemeRestore: "元に戻す"
|
||||
accessToken: "アクセストークン"
|
||||
choose: "選択"
|
||||
rotate: "回転"
|
||||
chooseEmojiPalette: "絵文字パレットを選択"
|
||||
addToEmojiPalette: "絵文字パレットに追加"
|
||||
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
|
||||
|
|
@ -3570,476 +3568,3 @@ _qr:
|
|||
scanFile: "端末の画像をスキャン"
|
||||
raw: "テキスト"
|
||||
mfm: "MFM"
|
||||
|
||||
worldAvatar: "Worldアバター"
|
||||
worldAvatar_description: "MisskeyWorld / MisskeyRoomsで使用可能な3Dアバターを作成できます。"
|
||||
|
||||
_miWorld:
|
||||
separateRenderingThread: "描画を別スレッドに分離"
|
||||
separateRenderingThread_description: "有効にするとパフォーマンスが向上します。不安定になる場合は無効すると改善する可能性があります。"
|
||||
graphicsQuality: "グラフィックの品質"
|
||||
graphicsSettings: "グラフィック設定"
|
||||
frameRateLimitation: "フレームレート制限"
|
||||
higherValuePerformanceNote: "高くすると体験が向上しますが、消費電力が増加するなどパフォーマンスに影響を与えます。"
|
||||
resolution: "解像度"
|
||||
fov: "視野角"
|
||||
failedToInitialize: "初期化に失敗しました"
|
||||
crushed_description: "描画が継続できなくなりました。デバイスのリソース不足の可能性が考えられます。"
|
||||
antialiasing: "アンチエイリアス"
|
||||
avatar: "アバター"
|
||||
advancedCustomize: "高度なアレンジ"
|
||||
attachAccessory: "アクセサリーをつける"
|
||||
takeScreenShot: "スクリーンショット"
|
||||
onlineMenu: "オンラインメニュー"
|
||||
connectToOnline: "オンラインに接続"
|
||||
disconnectToOnline: "オンラインから切断"
|
||||
character: "キャラクター"
|
||||
sit: "座る"
|
||||
lyingDown: "寝そべる"
|
||||
standUp: "立ち上がる"
|
||||
showUsernameOnAvatar: "アバターにユーザー名を表示"
|
||||
show2dAvatarOnAvatar: "アバターにユーザーアイコンを表示"
|
||||
|
||||
_avatars:
|
||||
_default:
|
||||
body: "ボディ"
|
||||
eyes: "目"
|
||||
mouth: "口"
|
||||
|
||||
_avatarAccessories:
|
||||
mug: "マグカップ"
|
||||
_mug:
|
||||
bodyMat: "コップの素材"
|
||||
liquidMat: "液体の素材"
|
||||
mikan: "みかん"
|
||||
bolt: "ボルト"
|
||||
_bolt:
|
||||
mat: "素材"
|
||||
|
||||
_miRoom:
|
||||
snapToGrid: "グリッドにスナップ"
|
||||
gridScale: "グリッドサイズ"
|
||||
thereAreUnsavedChanges: "未保存の変更があります"
|
||||
revertAllChangesConfirmation: "全ての変更を取り消し、部屋を最後に保存した状態まで戻しますか?"
|
||||
yourDeviceNotSupported_title: "MisskeyRoomを起動できません"
|
||||
yourDeviceNotSupported_description: "お使いのデバイスがMisskeyRoomをサポートしていないか、デバイスのリソース不足などにより一時的に利用できなくなっています。\nMisskeyRoomを動作させるには、WebGPUをサポートするデバイス・ブラウザが必要です。"
|
||||
imageFit: "画像のはめ込み"
|
||||
imageFit_cover: "覆う"
|
||||
imageFit_contain: "収める"
|
||||
imageFit_stretch: "伸縮"
|
||||
material_metallic: "光沢"
|
||||
material_roughness: "粗さ"
|
||||
light_brightness: "明るさ"
|
||||
advancedCustomize: "高度なアレンジ"
|
||||
enterEditMode: "エディットモードを始める"
|
||||
exitEditMode: "エディットモードを終了"
|
||||
installFurniture: "家具を設置"
|
||||
roomCustomize: "部屋のカスタマイズ"
|
||||
changeRoomName: "ルーム名を編集"
|
||||
roomInfo: "ルーム情報"
|
||||
duplicate: "複製"
|
||||
grab: "掴む"
|
||||
furnitureCustomize: "家具のアレンジ"
|
||||
uninstallFurniture: "しまう"
|
||||
furnituresCount: "家具の数"
|
||||
attachedFilesCount: "添付ファイル数"
|
||||
_furniturePlacement:
|
||||
top: "上面設置"
|
||||
bottom: "下面設置"
|
||||
side: "側面設置"
|
||||
|
||||
_furnitures:
|
||||
haniwa: "はにわ"
|
||||
_haniwa:
|
||||
bodyMat: "本体の素材"
|
||||
insideColor: "中身の色"
|
||||
woodRingFloorLamp: "リングシェードフロアランプ"
|
||||
_woodRingFloorLamp:
|
||||
shadeMat: "シェードの素材"
|
||||
bodyMat: "本体の素材"
|
||||
light: "照明"
|
||||
a4Case: "A4ケース"
|
||||
_a4Case:
|
||||
mat: "素材"
|
||||
aircon: "エアコン"
|
||||
allInOnePc: "一体型PC"
|
||||
_allInOnePc:
|
||||
bezelMat: "ベゼルの素材"
|
||||
bodyMat: "本体の素材"
|
||||
image: "画面の画像"
|
||||
image_desktop: "デスクトップ"
|
||||
screenBrightness: "画面の明るさ"
|
||||
aquarium: "水槽"
|
||||
aromaReedDiffuser: "アロマリードディフューザー"
|
||||
_aromaReedDiffuser:
|
||||
bottleMat: "ボトルの素材"
|
||||
oilMat: "オイルの素材"
|
||||
banknote: "紙幣"
|
||||
beamLamp: "ビームランプ"
|
||||
bed: "ベッド"
|
||||
_bed:
|
||||
frameMat: "フレームの素材"
|
||||
blind: "ブラインド"
|
||||
_blind:
|
||||
angle: "羽根の回転角度"
|
||||
blades: "羽根の枚数"
|
||||
open: "開閉状態"
|
||||
book: "本"
|
||||
_book:
|
||||
height: "高さ"
|
||||
thickness: "厚み"
|
||||
variation: "バリエーション"
|
||||
width: "幅"
|
||||
books: "本の束"
|
||||
_books:
|
||||
variation: "バリエーション"
|
||||
boxWallShelf: "ボックス型ウォールシェルフ"
|
||||
_boxWallShelf:
|
||||
bodyMat: "本体の素材"
|
||||
height: "高さ"
|
||||
width: "幅"
|
||||
withBack: "背板"
|
||||
cactusS: "サボテン S"
|
||||
_cactusS:
|
||||
potMat: "鉢の素材"
|
||||
cardboardBox: "段ボール箱"
|
||||
_cardboardBox:
|
||||
variation: "種類"
|
||||
variation_aizon: "Aizon"
|
||||
variation_default: "デフォルト"
|
||||
variation_mikan: "みかん"
|
||||
ceilingFanLight: "シーリングファンライト"
|
||||
_ceilingFanLight:
|
||||
shadeMat: "シェードの素材"
|
||||
bodyMat: "本体の素材"
|
||||
ceilingFan: "シーリングファン"
|
||||
_ceilingFan:
|
||||
shadeMat: "シェードの素材"
|
||||
bodyMat: "本体の素材"
|
||||
chair: "椅子"
|
||||
_chair:
|
||||
primaryMat: "メインの素材"
|
||||
secondaryMat: "サブの素材"
|
||||
frameMat: "フレームの素材"
|
||||
clippedPicture: "留められた写真"
|
||||
_clippedPicture:
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
width: "幅"
|
||||
coffeeCup: "コーヒーカップ"
|
||||
colorBox: "カラーボックス"
|
||||
_colorBox:
|
||||
mat: "素材"
|
||||
cuboid: "直方体"
|
||||
_cuboid:
|
||||
mat: "素材"
|
||||
x: "X"
|
||||
y: "Y"
|
||||
z: "Z"
|
||||
cupNoodle: "インスタントラーメン"
|
||||
curtain: "カーテン"
|
||||
custardPudding: "プリン"
|
||||
descriptionPlate: "説明が書かれたプレート"
|
||||
desk: "デスク"
|
||||
_desk:
|
||||
boardMat: "天板の素材"
|
||||
depth: "奥行き"
|
||||
frameMat: "フレームの素材"
|
||||
width: "幅"
|
||||
desktopPc: "デスクトップPC"
|
||||
_desktopPc:
|
||||
bodyMat: "本体の素材"
|
||||
coverMat: "カバーの素材"
|
||||
inner1Mat: "内部素材1"
|
||||
inner2Mat: "内部素材2"
|
||||
inner3Mat: "内部素材3"
|
||||
ledColor: "LEDの色"
|
||||
djMixer: "DJミキサー"
|
||||
djPlayer: "DJプレーヤー"
|
||||
_djPlayer:
|
||||
image: "画像"
|
||||
"image:waveform": "波形"
|
||||
screenBrightness: "画面の明るさ"
|
||||
ductRailSpotLights: "スポットライト付きダクトレール"
|
||||
_ductRailSpotLights:
|
||||
angleH: "水平角度"
|
||||
angleV: "垂直角度"
|
||||
bodyMat: "本体の素材"
|
||||
light: "照明"
|
||||
ductTape: "ガムテープ"
|
||||
herbarium: "ハーバリウム"
|
||||
electronicDisplayBoard: "電光掲示板"
|
||||
_electronicDisplayBoard:
|
||||
frameMat: "フレームの素材"
|
||||
ledBrightness: "LEDの明るさ"
|
||||
ledColor: "LEDの色"
|
||||
text: "テキスト"
|
||||
emptyBento: "空の弁当容器"
|
||||
energyDrink: "エナジードリンク"
|
||||
envelope: "封筒"
|
||||
facialTissue: "ティッシュ"
|
||||
glassCylinderPotPlant: "ガラスシリンダーの鉢植えと植物"
|
||||
handheldGameConsole: "携帯ゲーム機"
|
||||
_handheldGameConsole:
|
||||
bodyMat: "本体の素材"
|
||||
image: "画像"
|
||||
screenBrightness: "画面の明るさ"
|
||||
hangingDuctRail: "吊り下げダクトレール"
|
||||
_hangingDuctRail:
|
||||
bodyMat: "本体の素材"
|
||||
height: "高さ"
|
||||
width: "幅"
|
||||
hangingTShirt: "吊り下げTシャツ"
|
||||
icosahedron: "正二十面体のオブジェ"
|
||||
_icosahedron:
|
||||
mat: "素材"
|
||||
ironFrameTable: "アイアンフレームテーブル"
|
||||
_ironFrameTable:
|
||||
boardMat: "天板の素材"
|
||||
depth: "奥行き"
|
||||
frameMat: "フレームの素材"
|
||||
height: "高さ"
|
||||
width: "幅"
|
||||
issyoubin: "一升瓶"
|
||||
_issyoubin:
|
||||
variation: "種類"
|
||||
keyboard: "キーボード"
|
||||
_keyboard:
|
||||
bodyMat: "本体の素材"
|
||||
keyMat: "キーの素材"
|
||||
laptopPc: "ノートPC"
|
||||
_laptopPc:
|
||||
bezelMat: "ベゼルの素材"
|
||||
bodyMat: "本体の素材"
|
||||
image: "画像"
|
||||
openAngle: "開き具合"
|
||||
screenBrightness: "画面の明るさ"
|
||||
largeMousepad: "大きいマウスパッド"
|
||||
_largeMousepad:
|
||||
image: "画像"
|
||||
lavaLamp: "ラバランプ"
|
||||
_lavaLamp:
|
||||
bodyMat: "本体の素材"
|
||||
glassMat: "ガラスの素材"
|
||||
lavaColor: "オイルの色"
|
||||
lightColor: "ランプの色"
|
||||
letterCase: "レターケース"
|
||||
lowPartitionBar: "低いパーティションバー"
|
||||
_lowPartitionBar:
|
||||
bodyMat: "本体の素材"
|
||||
width: "幅"
|
||||
miObjet: "Miオブジェ"
|
||||
miPlate: "Miプレート"
|
||||
miPlateDisplayed: "飾られたMiプレート"
|
||||
milk: "牛乳"
|
||||
mixer: "ミキサー"
|
||||
monitor: "モニター"
|
||||
_monitor:
|
||||
bodyMat: "本体の素材"
|
||||
image: "画像"
|
||||
screenBrightness: "画面の明るさ"
|
||||
monitorSpeaker: "モニタースピーカー"
|
||||
_monitorSpeaker:
|
||||
mat: "素材"
|
||||
monstera: "モンステラ"
|
||||
_monstera:
|
||||
potMat: "鉢の素材"
|
||||
mug: "マグカップ"
|
||||
_mug:
|
||||
bodyMat: "コップの素材"
|
||||
liquidMat: "液体の素材"
|
||||
newtonsCradle: "ニュートンクレードル"
|
||||
_newtonsCradle:
|
||||
frameMat: "フレームの素材"
|
||||
openedCardboardBox: "開いた段ボール箱"
|
||||
pachira: "パキラ"
|
||||
_pachira:
|
||||
potMat: "鉢の素材"
|
||||
petBottle: "ペットボトル"
|
||||
_petBottle:
|
||||
empty: "空"
|
||||
variation: "種類"
|
||||
variation_greenTea: "緑茶"
|
||||
variation_mineralWater: "ミネラルウォーター"
|
||||
withCap: "キャップ"
|
||||
withLabel: "ラベル"
|
||||
piano: "ピアノ"
|
||||
_piano:
|
||||
bodyMat: "本体の素材"
|
||||
pictureFrame: "シンプルな額縁"
|
||||
_pictureFrame:
|
||||
depth: "厚さ"
|
||||
frameMat: "フレームの素材"
|
||||
frameThickness: "フレームの幅"
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
matHThickness: "マットの横幅"
|
||||
matVThickness: "マットの縦幅"
|
||||
width: "幅"
|
||||
withCover: "カバーあり"
|
||||
pizza: "ピザ"
|
||||
plant: "植物"
|
||||
plant2: "植物 2"
|
||||
poster: "ポスター"
|
||||
_poster:
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
width: "幅"
|
||||
powerStrip: "電源タップ"
|
||||
radiometer: "ラジオメーター"
|
||||
randomBooks: "雑多な本"
|
||||
_randomBooks:
|
||||
count: "数"
|
||||
seed: "シード"
|
||||
stackVertically: "平積み"
|
||||
variation: "バリエーション"
|
||||
variation_mix: "いろいろ"
|
||||
variation_mixPlain: "いろいろ(無地)"
|
||||
recordPlayer: "レコードプレーヤー"
|
||||
rolledUpPoster: "丸めたポスター"
|
||||
roundRug: "円形のラグ"
|
||||
router: "ルーター"
|
||||
siphon: "サイフォン"
|
||||
snakeplant: "サンセベリア"
|
||||
_snakeplant:
|
||||
potMat: "鉢の素材"
|
||||
sofa: "ソファ"
|
||||
_sofa:
|
||||
bodyMat: "本体の素材"
|
||||
speaker: "スピーカー"
|
||||
_speaker:
|
||||
innerMat: "内側の素材"
|
||||
outerMat: "外側の素材"
|
||||
speakerStand: "スピーカースタンド"
|
||||
_speakerStand:
|
||||
bodyMat: "本体の素材"
|
||||
height: "高さ"
|
||||
spotLight: "スポットライト"
|
||||
_spotLight:
|
||||
angleH: "横方向の角度"
|
||||
angleV: "縦方向の角度"
|
||||
bodyMat: "本体の素材"
|
||||
light: "照明"
|
||||
downlight: "ダウンライト"
|
||||
_downlight:
|
||||
bodyMat: "本体の素材"
|
||||
light: "照明"
|
||||
sprayer: "霧吹き"
|
||||
stanchionPole: "スタンションポール"
|
||||
_stanchionPole:
|
||||
bodyMat: "本体の素材"
|
||||
ropeMat: "ロープの素材"
|
||||
steelRack: "スチールラック"
|
||||
_steelRack:
|
||||
height: "高さ"
|
||||
numberOfShelfs: "シェルフの数"
|
||||
poleMat: "ポールの素材"
|
||||
shelfPositionOf: "シェルフの位置"
|
||||
shelfMat: "シェルフの素材"
|
||||
widthAndDepthVariation: "W x D"
|
||||
stormGlass: "ストームグラス"
|
||||
tableSalt: "食卓塩"
|
||||
tabletopCalendar: "卓上カレンダー"
|
||||
tabletopDigitalClock: "卓上デジタル時計"
|
||||
_tabletopDigitalClock:
|
||||
bodyMat: "本体の素材"
|
||||
lcdColor: "LCDの色"
|
||||
tabletopFlag: "卓上旗"
|
||||
_tabletopFlag:
|
||||
image: "画像"
|
||||
tabletopGlassPictureFrame: "卓上ガラス製フォトフレーム"
|
||||
_tabletopGlassPictureFrame:
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
width: "幅"
|
||||
tabletopIronFrameStand: "卓上アイアンフレームスタンド"
|
||||
_tabletopIronFrameStand:
|
||||
boardMat: "板の素材"
|
||||
depth: "奥行き"
|
||||
frameMat: "フレームの素材"
|
||||
height: "高さ"
|
||||
width: "幅"
|
||||
tabletopLcdButtonsController: "LCDボタン付き卓上コントローラー"
|
||||
_tabletopLcdButtonsController:
|
||||
bodyMat: "本体の素材"
|
||||
image: "画像"
|
||||
screenBrightness: "画面の明るさ"
|
||||
tabletopPictureFrame: "卓上フォトフレーム"
|
||||
_tabletopPictureFrame:
|
||||
depth: "厚さ"
|
||||
frameMat: "フレームの素材"
|
||||
frameThickness: "フレームの幅"
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
matHThickness: "マットの横幅"
|
||||
matVThickness: "マットの縦幅"
|
||||
width: "幅"
|
||||
tapestry: "タペストリー"
|
||||
_tapestry:
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
width: "幅"
|
||||
tetrapod: "波消ブロックの置物"
|
||||
tv: "テレビ"
|
||||
_tv:
|
||||
bodyMat: "本体の素材"
|
||||
screenBrightness: "画面の明るさ"
|
||||
twistedCubeObjet: "ねじれた立方体のオブジェ"
|
||||
usedTissue: "使用済みティッシュ"
|
||||
wallCanvas: "壁掛けキャンバス"
|
||||
_wallCanvas:
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
width: "幅"
|
||||
wallClock: "壁掛け時計"
|
||||
_wallClock:
|
||||
frameMat: "フレームの素材"
|
||||
faceMat: "文字盤の素材"
|
||||
handsMat: "針の素材"
|
||||
wallGlassPictureFrame: "ガラスの壁掛けフォトフレーム"
|
||||
_wallGlassPictureFrame:
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
width: "幅"
|
||||
wallMirror: "壁掛けミラー"
|
||||
_wallMirror:
|
||||
frameMat: "フレームの素材"
|
||||
frameThickness: "フレームの厚み"
|
||||
height: "高さ"
|
||||
width: "幅"
|
||||
wallMountSpotLight: "ウォールマウントスポットライト"
|
||||
_wallMountSpotLight:
|
||||
angleH: "横方向の角度"
|
||||
angleV: "縦方向の角度"
|
||||
bodyMat: "本体の素材"
|
||||
light: "照明"
|
||||
wallShelf: "ウォールシェルフ"
|
||||
_wallShelf:
|
||||
boardMat: "板の素材"
|
||||
boardStyle: "板のスタイル"
|
||||
"boardStyle:color": "単色"
|
||||
"boardStyle:wood": "木目"
|
||||
style: "スタイル"
|
||||
wireBasket: "ワイヤーバスケット"
|
||||
_wireBasket:
|
||||
bodyMat: "本体の素材"
|
||||
wireNet: "ワイヤーネット"
|
||||
_wireNet:
|
||||
bodyMat: "本体の素材"
|
||||
woodRingsPendantLight: "リングペンダントライト"
|
||||
_woodRingsPendantLight:
|
||||
bodyMat: "本体の素材"
|
||||
length: "長さ"
|
||||
light: "照明"
|
||||
shadeMat: "シェードの素材"
|
||||
woodSoundAbsorbingPanel: "木製吸音パネル"
|
||||
ironFrameShelf: "アイアンフレームシェルフ"
|
||||
_ironFrameShelf:
|
||||
boardMat: "板の素材"
|
||||
frameMat: "フレームの素材"
|
||||
height: "段数"
|
||||
width: "幅"
|
||||
kakejiku: "掛軸"
|
||||
_kakejiku:
|
||||
image: "画像"
|
||||
"image:kitsuaishinsei": "喫藍心整"
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ perDay: "每日"
|
|||
stopActivityDelivery: "停止發送活動"
|
||||
blockThisInstance: "封鎖此伺服器"
|
||||
silenceThisInstance: "禁言此伺服器"
|
||||
mediaSilenceThisInstance: "將這個伺服器的媒體設為禁言"
|
||||
mediaSilenceThisInstance: "將這個伺服器的媒體設為禁言(隱藏媒體預覽)"
|
||||
operations: "操作"
|
||||
software: "軟體"
|
||||
softwareName: "軟體名稱"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2026.6.0-beta.0",
|
||||
"version": "2026.6.0",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -12,9 +12,7 @@
|
|||
"packages/i18n",
|
||||
"packages/misskey-reversi",
|
||||
"packages/misskey-bubble-game",
|
||||
"packages/misskey-world",
|
||||
"packages/icons-subsetter",
|
||||
"packages/frontend-misskey-world-engine",
|
||||
"packages/frontend-shared",
|
||||
"packages/frontend-builder",
|
||||
"packages/sw",
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class WorldRoom1778744540138 {
|
||||
name = 'WorldRoom1778744540138'
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TABLE "world_room" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "description" character varying(1024) NOT NULL, "userId" character varying(32) NOT NULL, "likedCount" integer NOT NULL DEFAULT '0', "visibility" character varying(128) NOT NULL DEFAULT 'public', "def" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_40cfacaf35b0b54bb2281c89767" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_88289375952050da4a7752a366" ON "world_room" ("updatedAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_f803a5efb4125c5fd8a414285e" ON "world_room" ("userId") `);
|
||||
await queryRunner.query(`ALTER TABLE "world_room" ADD CONSTRAINT "FK_f803a5efb4125c5fd8a414285ed" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "world_room" DROP CONSTRAINT "FK_f803a5efb4125c5fd8a414285ed"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_f803a5efb4125c5fd8a414285e"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_88289375952050da4a7752a366"`);
|
||||
await queryRunner.query(`DROP TABLE "world_room"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class WorldAvatar1779921322355 {
|
||||
name = 'WorldAvatar1779921322355'
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TABLE "world_avatar" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "userId" character varying(32) NOT NULL, "def" jsonb NOT NULL DEFAULT '{}', "active" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_e7a27262285cc2c27114871f866" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_0f1d0bdfaca455cc2f13defabe" ON "world_avatar" ("updatedAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_4eba43c8e2540a92e99dd7f5a9" ON "world_avatar" ("userId") `);
|
||||
await queryRunner.query(`ALTER TABLE "world_room" ADD "accessCount" integer NOT NULL DEFAULT '0'`);
|
||||
await queryRunner.query(`ALTER TABLE "world_avatar" ADD CONSTRAINT "FK_4eba43c8e2540a92e99dd7f5a9a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "world_avatar" DROP CONSTRAINT "FK_4eba43c8e2540a92e99dd7f5a9a"`);
|
||||
await queryRunner.query(`ALTER TABLE "world_room" DROP COLUMN "accessCount"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_4eba43c8e2540a92e99dd7f5a9"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_0f1d0bdfaca455cc2f13defabe"`);
|
||||
await queryRunner.query(`DROP TABLE "world_avatar"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,10 +10,8 @@ export class NoteIdIndexForPinAndFavorite1780059833698 {
|
|||
transaction = isConcurrentIndexMigrationEnabled ? false : undefined;
|
||||
|
||||
async up(queryRunner) {
|
||||
const concurrently = isConcurrentIndexMigrationEnabled ? 'CONCURRENTLY' : '';
|
||||
|
||||
await queryRunner.query(`CREATE INDEX ${concurrently} IF NOT EXISTS "IDX_0e00498f180193423c992bc437" ON "note_favorite" ("noteId")`);
|
||||
await queryRunner.query(`CREATE INDEX ${concurrently} IF NOT EXISTS "IDX_68881008f7c3588ad7ecae471c" ON "user_note_pining" ("noteId")`);
|
||||
await this.ensureValidIndex(queryRunner, 'IDX_0e00498f180193423c992bc437', 'note_favorite', 'noteId');
|
||||
await this.ensureValidIndex(queryRunner, 'IDX_68881008f7c3588ad7ecae471c', 'user_note_pining', 'noteId');
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
|
|
@ -22,4 +20,16 @@ export class NoteIdIndexForPinAndFavorite1780059833698 {
|
|||
await queryRunner.query(`DROP INDEX ${concurrently} IF EXISTS "public"."IDX_68881008f7c3588ad7ecae471c"`);
|
||||
await queryRunner.query(`DROP INDEX ${concurrently} IF EXISTS "public"."IDX_0e00498f180193423c992bc437"`);
|
||||
}
|
||||
|
||||
async ensureValidIndex(queryRunner, indexName, tableName, columnName) {
|
||||
if (isConcurrentIndexMigrationEnabled) {
|
||||
const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = '${indexName}'`);
|
||||
if (hasValidIndex.length === 0 || hasValidIndex[0].indisvalid !== true) {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "${indexName}"`);
|
||||
await queryRunner.query(`CREATE INDEX CONCURRENTLY "${indexName}" ON "${tableName}" ("${columnName}")`);
|
||||
}
|
||||
} else {
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${indexName}" ON "${tableName}" ("${columnName}")`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^22.15.0 || ^24.10.0 || ^26.0.0"
|
||||
"node": "^22.15.0 || ^24.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "pnpm compile-config && node ./built/entry.js",
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
"@misskey-dev/emoji-assets": "17.0.3",
|
||||
"@misskey-dev/emoji-data": "17.0.3",
|
||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/summaly": "5.3.0",
|
||||
"@misskey-dev/summaly": "5.5.1",
|
||||
"@napi-rs/canvas": "1.0.0",
|
||||
"@nestjs/common": "11.1.26",
|
||||
"@nestjs/core": "11.1.26",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { defineConfig } from 'rolldown';
|
||||
import { version as summalyVersion } from '@misskey-dev/summaly';
|
||||
import type { Plugin, ExternalOption } from 'rolldown';
|
||||
import { execa, execaNode } from 'execa';
|
||||
import type { ResultPromise } from 'execa';
|
||||
|
|
@ -84,6 +85,11 @@ export default defineConfig((args) => {
|
|||
'file-type',
|
||||
];
|
||||
|
||||
const define: Record<string, string> = {
|
||||
// Summalyのバージョンを埋め込む
|
||||
'_SUMMALY_VERSION_': JSON.stringify(summalyVersion),
|
||||
};
|
||||
|
||||
if (isE2E) {
|
||||
return {
|
||||
input: './test-server/entry.ts',
|
||||
|
|
@ -92,6 +98,9 @@ export default defineConfig((args) => {
|
|||
plugins: [
|
||||
esmShim(),
|
||||
],
|
||||
transform: {
|
||||
define,
|
||||
},
|
||||
output: {
|
||||
keepNames: true,
|
||||
sourcemap: true,
|
||||
|
|
@ -116,6 +125,9 @@ export default defineConfig((args) => {
|
|||
esmShim(),
|
||||
(isWatchMode ? backendDevServerPlugin() : undefined),
|
||||
],
|
||||
transform: {
|
||||
define,
|
||||
},
|
||||
output: {
|
||||
keepNames: true,
|
||||
minify: !isWatchMode,
|
||||
|
|
|
|||
|
|
@ -1,231 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* This script starts the Misskey backend server, waits for it to be ready,
|
||||
* measures memory usage, and outputs the result as JSON.
|
||||
*
|
||||
* Usage: node scripts/measure-memory.mjs
|
||||
*/
|
||||
|
||||
import { fork } from 'node:child_process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import * as http from 'node:http';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const SAMPLE_COUNT = 3; // Number of samples to measure
|
||||
const STARTUP_TIMEOUT = 120000; // 120 seconds timeout for server startup
|
||||
const MEMORY_SETTLE_TIME = 10000; // Wait 10 seconds after startup for memory to settle
|
||||
|
||||
const keys = {
|
||||
VmPeak: 0,
|
||||
VmSize: 0,
|
||||
VmHWM: 0,
|
||||
VmRSS: 0,
|
||||
VmData: 0,
|
||||
VmStk: 0,
|
||||
VmExe: 0,
|
||||
VmLib: 0,
|
||||
VmPTE: 0,
|
||||
VmSwap: 0,
|
||||
};
|
||||
|
||||
async function getMemoryUsage(pid) {
|
||||
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
|
||||
|
||||
const result = {};
|
||||
for (const key of Object.keys(keys)) {
|
||||
const match = status.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
|
||||
if (match) {
|
||||
result[key] = parseInt(match[1], 10);
|
||||
} else {
|
||||
throw new Error(`Failed to parse ${key} from /proc/${pid}/status`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function measureMemory() {
|
||||
// Start the Misskey backend server using fork to enable IPC
|
||||
const serverProcess = fork(join(__dirname, '../built/entry.js'), ['expose-gc'], {
|
||||
cwd: join(__dirname, '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
MK_DISABLE_CLUSTERING: '1',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
execArgv: [...process.execArgv, '--expose-gc'],
|
||||
});
|
||||
|
||||
let serverReady = false;
|
||||
|
||||
// Listen for the 'ok' message from the server indicating it's ready
|
||||
serverProcess.on('message', (message) => {
|
||||
if (message === 'ok') {
|
||||
serverReady = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle server output
|
||||
serverProcess.stdout?.on('data', (data) => {
|
||||
process.stderr.write(`[server stdout] ${data}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on('data', (data) => {
|
||||
process.stderr.write(`[server stderr] ${data}`);
|
||||
});
|
||||
|
||||
// Handle server error
|
||||
serverProcess.on('error', (err) => {
|
||||
process.stderr.write(`[server error] ${err}\n`);
|
||||
});
|
||||
|
||||
async function triggerGc() {
|
||||
const ok = new Promise((resolve) => {
|
||||
serverProcess.once('message', (message) => {
|
||||
if (message === 'gc ok') resolve();
|
||||
});
|
||||
});
|
||||
|
||||
serverProcess.send('gc');
|
||||
|
||||
await ok;
|
||||
|
||||
await setTimeout(1000);
|
||||
}
|
||||
|
||||
function createRequest() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
host: 'localhost',
|
||||
port: 61812,
|
||||
path: '/api/meta',
|
||||
method: 'POST',
|
||||
}, (res) => {
|
||||
res.on('data', () => { });
|
||||
res.on('end', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for server to be ready or timeout
|
||||
const startupStartTime = Date.now();
|
||||
while (!serverReady) {
|
||||
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
|
||||
serverProcess.kill('SIGTERM');
|
||||
throw new Error('Server startup timeout');
|
||||
}
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
const startupTime = Date.now() - startupStartTime;
|
||||
process.stderr.write(`Server started in ${startupTime}ms\n`);
|
||||
|
||||
// Wait for memory to settle
|
||||
await setTimeout(MEMORY_SETTLE_TIME);
|
||||
|
||||
const pid = serverProcess.pid;
|
||||
|
||||
const beforeGc = await getMemoryUsage(pid);
|
||||
|
||||
await triggerGc();
|
||||
|
||||
const afterGc = await getMemoryUsage(pid);
|
||||
|
||||
// create some http requests to simulate load
|
||||
const REQUEST_COUNT = 10;
|
||||
await Promise.all(
|
||||
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
|
||||
);
|
||||
|
||||
await triggerGc();
|
||||
|
||||
const afterRequest = await getMemoryUsage(pid);
|
||||
|
||||
// Stop the server
|
||||
serverProcess.kill('SIGTERM');
|
||||
|
||||
// Wait for process to exit
|
||||
let exited = false;
|
||||
await new Promise((resolve) => {
|
||||
serverProcess.on('exit', () => {
|
||||
exited = true;
|
||||
resolve(undefined);
|
||||
});
|
||||
// Force kill after 10 seconds if not exited
|
||||
setTimeout(10000).then(() => {
|
||||
if (!exited) {
|
||||
serverProcess.kill('SIGKILL');
|
||||
}
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
beforeGc,
|
||||
afterGc,
|
||||
afterRequest,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 直列の方が時間的に分散されて正確そうだから直列でやる
|
||||
const results = [];
|
||||
for (let i = 0; i < SAMPLE_COUNT; i++) {
|
||||
const res = await measureMemory();
|
||||
results.push(res);
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
const beforeGc = structuredClone(keys);
|
||||
const afterGc = structuredClone(keys);
|
||||
const afterRequest = structuredClone(keys);
|
||||
for (const res of results) {
|
||||
for (const key of Object.keys(keys)) {
|
||||
beforeGc[key] += res.beforeGc[key];
|
||||
afterGc[key] += res.afterGc[key];
|
||||
afterRequest[key] += res.afterRequest[key];
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(keys)) {
|
||||
beforeGc[key] = Math.round(beforeGc[key] / SAMPLE_COUNT);
|
||||
afterGc[key] = Math.round(afterGc[key] / SAMPLE_COUNT);
|
||||
afterRequest[key] = Math.round(afterRequest[key] / SAMPLE_COUNT);
|
||||
}
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
beforeGc,
|
||||
afterGc,
|
||||
afterRequest,
|
||||
};
|
||||
|
||||
// Output as JSON to stdout
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(JSON.stringify({
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
process.exit(1);
|
||||
});
|
||||
545
packages/backend/scripts/measure-memory.mts
Normal file
545
packages/backend/scripts/measure-memory.mts
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ChildProcess, fork } from 'node:child_process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
//import * as http from 'node:http';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function readIntegerEnv(name, defaultValue, min) {
|
||||
const rawValue = process.env[name];
|
||||
if (rawValue == null || rawValue === '') return defaultValue;
|
||||
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
|
||||
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function readBooleanEnv(name, defaultValue) {
|
||||
const rawValue = process.env[name];
|
||||
if (rawValue == null || rawValue === '') return defaultValue;
|
||||
if (rawValue === '1' || rawValue === 'true') return true;
|
||||
if (rawValue === '0' || rawValue === 'false') return false;
|
||||
throw new Error(`${name} must be one of: 1, 0, true, false`);
|
||||
}
|
||||
|
||||
const SAMPLE_COUNT = readIntegerEnv('MK_MEMORY_SAMPLE_COUNT', 3, 1); // Number of samples to measure
|
||||
const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup
|
||||
const MEMORY_SETTLE_TIME = readIntegerEnv('MK_MEMORY_SETTLE_TIME_MS', 10000, 0); // Wait after startup for memory to settle
|
||||
const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses
|
||||
const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0);
|
||||
const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
|
||||
const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1);
|
||||
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
|
||||
|
||||
const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
|
||||
const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
|
||||
|
||||
const heapSnapshotCategories = [
|
||||
'Code',
|
||||
'Strings',
|
||||
'JS arrays',
|
||||
'Typed arrays',
|
||||
'System objects',
|
||||
'Other JS objects',
|
||||
'Other non-JS objects',
|
||||
'Total',
|
||||
];
|
||||
|
||||
const typedArrayNames = new Set([
|
||||
'ArrayBuffer',
|
||||
'SharedArrayBuffer',
|
||||
'DataView',
|
||||
'Int8Array',
|
||||
'Uint8Array',
|
||||
'Uint8ClampedArray',
|
||||
'Int16Array',
|
||||
'Uint16Array',
|
||||
'Int32Array',
|
||||
'Uint32Array',
|
||||
'Float16Array',
|
||||
'Float32Array',
|
||||
'Float64Array',
|
||||
'BigInt64Array',
|
||||
'BigUint64Array',
|
||||
'system / JSArrayBufferData',
|
||||
]);
|
||||
|
||||
const otherJsNodeTypes = new Set([
|
||||
'object',
|
||||
'closure',
|
||||
'regexp',
|
||||
'number',
|
||||
'symbol',
|
||||
'bigint',
|
||||
]);
|
||||
|
||||
function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string, required: boolean): Record<KS[number], number> {
|
||||
const result = {} as Record<KS[number], number>;
|
||||
for (const _key of keys) {
|
||||
const key = _key as KS[number];
|
||||
const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
|
||||
if (match) {
|
||||
result[key] = parseInt(match[1], 10);
|
||||
} else if (required) {
|
||||
throw new Error(`Failed to parse ${key} from ${path}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function bytesToKiB(value: number) {
|
||||
return Math.round(value / 1024);
|
||||
}
|
||||
|
||||
function createEmptyHeapSnapshotCategoryMap() {
|
||||
return Object.fromEntries(heapSnapshotCategories.map(category => [category, 0]));
|
||||
}
|
||||
|
||||
function isTypedArrayNode(type, name) {
|
||||
return typedArrayNames.has(name) ||
|
||||
(type === 'native' && (name.includes('ArrayBuffer') || name.includes('TypedArray')));
|
||||
}
|
||||
|
||||
function isSystemNode(type, name) {
|
||||
return type === 'hidden' ||
|
||||
type === 'synthetic' ||
|
||||
type === 'object shape' ||
|
||||
name.startsWith('system /') ||
|
||||
name.startsWith('(system ');
|
||||
}
|
||||
|
||||
function classifyHeapSnapshotNode(type, name) {
|
||||
if (type === 'code') return 'Code';
|
||||
if (type === 'string' || type === 'concatenated string' || type === 'sliced string') return 'Strings';
|
||||
if (isTypedArrayNode(type, name)) return 'Typed arrays';
|
||||
if (type === 'array' || (type === 'object' && name === 'Array')) return 'JS arrays';
|
||||
if (isSystemNode(type, name)) return 'System objects';
|
||||
if (otherJsNodeTypes.has(type)) return 'Other JS objects';
|
||||
return 'Other non-JS objects';
|
||||
}
|
||||
|
||||
function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') {
|
||||
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (label === '') return fallback;
|
||||
if (label.length <= 80) return label;
|
||||
return `${label.slice(0, 77)}...`;
|
||||
}
|
||||
|
||||
function classifyHeapSnapshotBreakdown(category, type, name) {
|
||||
if (category === 'Strings') return type;
|
||||
|
||||
if (category === 'JS arrays') {
|
||||
if (type === 'array') return 'array nodes';
|
||||
if (type === 'object' && name === 'Array') return 'Array objects';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||
}
|
||||
|
||||
if (category === 'Typed arrays') {
|
||||
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
|
||||
if (name === 'Uint8Array') return 'Uint8Array / Buffer';
|
||||
if (typedArrayNames.has(name)) return name;
|
||||
if (type === 'native' && name.includes('ArrayBuffer')) return 'native ArrayBuffer';
|
||||
if (type === 'native' && name.includes('TypedArray')) return 'native TypedArray';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||
}
|
||||
|
||||
if (category === 'System objects') {
|
||||
if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
|
||||
if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
if (category === 'Other JS objects') {
|
||||
if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
|
||||
return type;
|
||||
}
|
||||
|
||||
if (category === 'Other non-JS objects') {
|
||||
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
if (category === 'Code') {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName.includes('bytecode')) return 'bytecode';
|
||||
if (lowerName.includes('builtin')) return 'builtins';
|
||||
if (lowerName.includes('regexp')) return 'regexp code';
|
||||
if (lowerName.includes('stub')) return 'stubs';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown');
|
||||
}
|
||||
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
function collapseHeapSnapshotBreakdown(breakdowns) {
|
||||
const collapsed = {};
|
||||
|
||||
for (const [category, children] of Object.entries(breakdowns)) {
|
||||
const entries = Object.entries(children)
|
||||
.filter(([, value]) => value > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
|
||||
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
|
||||
const otherValue = entries
|
||||
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
|
||||
.reduce((sum, [, value]) => sum + value, 0);
|
||||
|
||||
const categoryBreakdown = Object.fromEntries(topEntries);
|
||||
if (otherValue > 0) categoryBreakdown.Other = otherValue;
|
||||
if (Object.keys(categoryBreakdown).length > 0) collapsed[category] = categoryBreakdown;
|
||||
}
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
function analyzeHeapSnapshot(snapshot) {
|
||||
const meta = snapshot?.snapshot?.meta;
|
||||
const nodes = snapshot?.nodes;
|
||||
const strings = snapshot?.strings;
|
||||
if (meta == null || !Array.isArray(nodes) || !Array.isArray(strings)) {
|
||||
throw new Error('Invalid heap snapshot format');
|
||||
}
|
||||
|
||||
const nodeFields = meta.node_fields;
|
||||
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
|
||||
|
||||
const typeOffset = nodeFields.indexOf('type');
|
||||
const nameOffset = nodeFields.indexOf('name');
|
||||
const selfSizeOffset = nodeFields.indexOf('self_size');
|
||||
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0) {
|
||||
throw new Error('Heap snapshot is missing required node fields');
|
||||
}
|
||||
|
||||
const nodeTypeNames = meta.node_types?.[typeOffset];
|
||||
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
|
||||
|
||||
const fieldCount = nodeFields.length;
|
||||
const categories = createEmptyHeapSnapshotCategoryMap();
|
||||
const nodeCounts = createEmptyHeapSnapshotCategoryMap();
|
||||
const breakdowns = Object.fromEntries(
|
||||
heapSnapshotCategories
|
||||
.filter(category => category !== 'Total')
|
||||
.map(category => [category, {}]),
|
||||
);
|
||||
|
||||
function addValue(map: Record<string, number>, key: string, value: number) {
|
||||
map[key] = (map[key] ?? 0) + value;
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < nodes.length; offset += fieldCount) {
|
||||
const type = nodeTypeNames[nodes[offset + typeOffset]] ?? 'unknown';
|
||||
const name = strings[nodes[offset + nameOffset]] ?? '';
|
||||
const selfSize = nodes[offset + selfSizeOffset] ?? 0;
|
||||
const category = classifyHeapSnapshotNode(type, name);
|
||||
|
||||
categories[category] += selfSize;
|
||||
categories.Total += selfSize;
|
||||
nodeCounts[category]++;
|
||||
nodeCounts.Total++;
|
||||
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), selfSize);
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
nodeCounts,
|
||||
breakdowns: collapseHeapSnapshotBreakdown(breakdowns),
|
||||
};
|
||||
}
|
||||
|
||||
async function getMemoryUsage(pid: number) {
|
||||
const path = `/proc/${pid}/status`;
|
||||
const status = await fs.readFile(path, 'utf-8');
|
||||
return parseMemoryFile(status, procStatusKeys, path, true);
|
||||
}
|
||||
|
||||
async function getSmapsRollupMemoryUsage(pid: number) {
|
||||
const path = `/proc/${pid}/smaps_rollup`;
|
||||
const smapsRollup = await fs.readFile(path, 'utf-8');
|
||||
return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
|
||||
}
|
||||
|
||||
function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIMEOUT) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
serverProcess.off('message', onMessage);
|
||||
reject(new Error(`Timed out waiting for ${description}`));
|
||||
}, timeout);
|
||||
|
||||
const onMessage = (message) => {
|
||||
if (!predicate(message)) return;
|
||||
globalThis.clearTimeout(timer);
|
||||
serverProcess.off('message', onMessage);
|
||||
resolve(message);
|
||||
};
|
||||
|
||||
serverProcess.on('message', onMessage);
|
||||
});
|
||||
}
|
||||
|
||||
async function getRuntimeMemoryUsage(serverProcess: ChildProcess) {
|
||||
const response = waitForMessage(
|
||||
serverProcess,
|
||||
message => message != null && typeof message === 'object' && message.type === 'memory usage',
|
||||
'memory usage',
|
||||
);
|
||||
|
||||
serverProcess.send('memory usage');
|
||||
|
||||
const message = await response;
|
||||
const memoryUsage = message.value;
|
||||
|
||||
return {
|
||||
HeapTotal: bytesToKiB(memoryUsage.heapTotal),
|
||||
HeapUsed: bytesToKiB(memoryUsage.heapUsed),
|
||||
External: bytesToKiB(memoryUsage.external),
|
||||
ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers),
|
||||
};
|
||||
}
|
||||
|
||||
async function getHeapSnapshotStatistics(serverProcess: ChildProcess) {
|
||||
if (!HEAP_SNAPSHOT) return null;
|
||||
|
||||
const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`);
|
||||
const response = waitForMessage(
|
||||
serverProcess,
|
||||
message => message != null && typeof message === 'object' && (message.type === 'heap snapshot' || message.type === 'heap snapshot error'),
|
||||
'heap snapshot',
|
||||
HEAP_SNAPSHOT_TIMEOUT,
|
||||
);
|
||||
|
||||
serverProcess.send({
|
||||
type: 'heap snapshot',
|
||||
path: snapshotPath,
|
||||
});
|
||||
|
||||
const message = await response;
|
||||
if (message.type === 'heap snapshot error') {
|
||||
throw new Error(`Failed to write heap snapshot: ${message.message}`);
|
||||
}
|
||||
|
||||
const writtenPath = typeof message.path === 'string' ? message.path : snapshotPath;
|
||||
|
||||
try {
|
||||
const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
|
||||
return analyzeHeapSnapshot(snapshot);
|
||||
} finally {
|
||||
await fs.unlink(writtenPath).catch(err => {
|
||||
process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllMemoryUsage(serverProcess: ChildProcess) {
|
||||
const pid = serverProcess.pid!;
|
||||
return {
|
||||
...await getMemoryUsage(pid),
|
||||
...await getSmapsRollupMemoryUsage(pid),
|
||||
...await getRuntimeMemoryUsage(serverProcess),
|
||||
};
|
||||
}
|
||||
|
||||
async function measureMemory() {
|
||||
// Start the Misskey backend server using fork to enable IPC
|
||||
const serverProcess = fork(join(__dirname, '../built/entry.js'), [], {
|
||||
cwd: join(__dirname, '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
MK_DISABLE_CLUSTERING: '1',
|
||||
MK_ONLY_SERVER: '1',
|
||||
MK_NO_DAEMONS: '1',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
execArgv: [...process.execArgv, '--expose-gc'],
|
||||
});
|
||||
|
||||
let serverReady = false;
|
||||
|
||||
// Listen for the 'ok' message from the server indicating it's ready
|
||||
serverProcess.on('message', (message) => {
|
||||
if (message === 'ok') {
|
||||
serverReady = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle server output
|
||||
serverProcess.stdout?.on('data', (data) => {
|
||||
process.stderr.write(`[server stdout] ${data}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on('data', (data) => {
|
||||
process.stderr.write(`[server stderr] ${data}`);
|
||||
});
|
||||
|
||||
// Handle server error
|
||||
serverProcess.on('error', (err) => {
|
||||
process.stderr.write(`[server error] ${err}\n`);
|
||||
});
|
||||
|
||||
async function triggerGc() {
|
||||
const ok = waitForMessage(
|
||||
serverProcess,
|
||||
message => message === 'gc ok' || message === 'gc unavailable',
|
||||
'GC completion',
|
||||
);
|
||||
|
||||
serverProcess.send('gc');
|
||||
|
||||
const message = await ok;
|
||||
if (message === 'gc unavailable') {
|
||||
throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.');
|
||||
}
|
||||
|
||||
await setTimeout(1000);
|
||||
}
|
||||
|
||||
//function createRequest() {
|
||||
// return new Promise((resolve, reject) => {
|
||||
// const req = http.request({
|
||||
// host: 'localhost',
|
||||
// port: 61812,
|
||||
// path: '/api/meta',
|
||||
// method: 'POST',
|
||||
// }, (res) => {
|
||||
// res.on('data', () => { });
|
||||
// res.on('end', () => {
|
||||
// resolve();
|
||||
// });
|
||||
// });
|
||||
// req.on('error', (err) => {
|
||||
// reject(err);
|
||||
// });
|
||||
// req.end();
|
||||
// });
|
||||
//}
|
||||
|
||||
// Wait for server to be ready or timeout
|
||||
const startupStartTime = Date.now();
|
||||
while (!serverReady) {
|
||||
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
|
||||
serverProcess.kill('SIGTERM');
|
||||
throw new Error('Server startup timeout');
|
||||
}
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
const startupTime = Date.now() - startupStartTime;
|
||||
process.stderr.write(`Server started in ${startupTime}ms\n`);
|
||||
|
||||
// Wait for memory to settle
|
||||
await setTimeout(MEMORY_SETTLE_TIME);
|
||||
|
||||
//const beforeGc = await getAllMemoryUsage(serverProcess);
|
||||
|
||||
await triggerGc();
|
||||
|
||||
const memoryUsageAfterGC = await getAllMemoryUsage(serverProcess);
|
||||
|
||||
//// create some http requests to simulate load
|
||||
//await Promise.all(
|
||||
// Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
|
||||
//);
|
||||
|
||||
//await triggerGc();
|
||||
|
||||
//const afterRequest = await getAllMemoryUsage(serverProcess);
|
||||
|
||||
const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess);
|
||||
|
||||
// Stop the server
|
||||
serverProcess.kill('SIGTERM');
|
||||
|
||||
// Wait for process to exit
|
||||
let exited = false;
|
||||
await new Promise((resolve) => {
|
||||
serverProcess.on('exit', () => {
|
||||
exited = true;
|
||||
resolve(undefined);
|
||||
});
|
||||
// Force kill after 10 seconds if not exited
|
||||
setTimeout(10000).then(() => {
|
||||
if (!exited) {
|
||||
serverProcess.kill('SIGKILL');
|
||||
}
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
phases: {
|
||||
//beforeGc,
|
||||
afterGc: {
|
||||
memoryUsage: memoryUsageAfterGC,
|
||||
heapSnapshot: heapSnapshotAfterGc,
|
||||
},
|
||||
//afterRequest,
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export type MemoryReportRaw = {
|
||||
timestamp: string;
|
||||
sampleCount: number;
|
||||
measurement: {
|
||||
startupTimeoutMs: number;
|
||||
memorySettleTimeMs: number;
|
||||
ipcTimeoutMs: number;
|
||||
requestCount: number;
|
||||
heapSnapshot: {
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
breakdownTopN: number;
|
||||
};
|
||||
};
|
||||
samples: Awaited<ReturnType<typeof measureMemory>>[];
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const results = [];
|
||||
for (let i = 0; i < SAMPLE_COUNT; i++) {
|
||||
process.stderr.write(`Starting sample ${i + 1}/${SAMPLE_COUNT}\n`);
|
||||
const res = await measureMemory();
|
||||
results.push(res);
|
||||
}
|
||||
|
||||
const result: MemoryReportRaw = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: SAMPLE_COUNT,
|
||||
measurement: {
|
||||
startupTimeoutMs: STARTUP_TIMEOUT,
|
||||
memorySettleTimeMs: MEMORY_SETTLE_TIME,
|
||||
ipcTimeoutMs: IPC_TIMEOUT,
|
||||
requestCount: REQUEST_COUNT,
|
||||
heapSnapshot: {
|
||||
enabled: HEAP_SNAPSHOT,
|
||||
timeoutMs: HEAP_SNAPSHOT_TIMEOUT,
|
||||
breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N,
|
||||
},
|
||||
},
|
||||
samples: results,
|
||||
};
|
||||
|
||||
// Output as JSON to stdout
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(JSON.stringify({
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
process.exit(1);
|
||||
});
|
||||
6
packages/backend/src/@types/global.d.ts
vendored
Normal file
6
packages/backend/src/@types/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
declare const _SUMMALY_VERSION_: string;
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { init } from 'slacc';
|
||||
import { NestLogger } from '@/NestLogger.js';
|
||||
import { envOption } from '@/env.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
||||
let slaccInitialized = false;
|
||||
|
|
@ -31,7 +32,7 @@ export async function server() {
|
|||
const serverService = app.get(ServerService);
|
||||
await serverService.launch();
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
if (process.env.NODE_ENV !== 'test' && !envOption.noDaemons) {
|
||||
const { ChartManagementService } = await import('../core/chart/ChartManagementService.js');
|
||||
const { QueueStatsService } = await import('../daemons/QueueStatsService.js');
|
||||
const { ServerStatsService } = await import('../daemons/ServerStatsService.js');
|
||||
|
|
@ -54,7 +55,9 @@ export async function jobQueue() {
|
|||
});
|
||||
|
||||
jobQueue.get(QueueProcessorService).start();
|
||||
jobQueue.get(ChartManagementService).start();
|
||||
if (!envOption.noDaemons) {
|
||||
jobQueue.get(ChartManagementService).start();
|
||||
}
|
||||
|
||||
return jobQueue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import cluster from 'node:cluster';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { writeHeapSnapshot } from 'node:v8';
|
||||
import chalk from 'chalk';
|
||||
import Xev from 'xev';
|
||||
import Logger from '@/logger.js';
|
||||
|
|
@ -91,10 +92,35 @@ process.on('message', msg => {
|
|||
if (msg === 'gc') {
|
||||
if (global.gc != null) {
|
||||
logger.info('Manual GC triggered');
|
||||
global.gc();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
global.gc();
|
||||
}
|
||||
if (process.send != null) process.send('gc ok');
|
||||
} else {
|
||||
logger.warn('Manual GC requested but gc is not available. Start the process with --expose-gc to enable this feature.');
|
||||
if (process.send != null) process.send('gc unavailable');
|
||||
}
|
||||
} else if (msg === 'memory usage') {
|
||||
if (process.send != null) {
|
||||
process.send({
|
||||
type: 'memory usage',
|
||||
value: process.memoryUsage(),
|
||||
});
|
||||
}
|
||||
} else if (msg != null && typeof msg === 'object' && 'type' in msg && msg.type === 'heap snapshot' && 'path' in msg && typeof msg.path === 'string') {
|
||||
if (process.send != null) {
|
||||
try {
|
||||
const path = writeHeapSnapshot(msg.path);
|
||||
process.send({
|
||||
type: 'heap snapshot',
|
||||
path,
|
||||
});
|
||||
} catch (err) {
|
||||
process.send({
|
||||
type: 'heap snapshot error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -154,11 +154,6 @@ import { ApQuestionService } from './activitypub/models/ApQuestionService.js';
|
|||
import { QueueModule } from './QueueModule.js';
|
||||
import { QueueService } from './QueueService.js';
|
||||
import { LoggerService } from './LoggerService.js';
|
||||
import { WorldRoomService } from './WorldRoomService.js';
|
||||
import { WorldRoomEntityService } from './entities/WorldRoomEntityService.js';
|
||||
import { WorldRoomMultiplayService } from './WorldRoomMultiplayService.js';
|
||||
import { WorldAvatarService } from './WorldAvatarService.js';
|
||||
import { WorldAvatarEntityService } from './entities/WorldAvatarEntityService.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
|
|
@ -234,9 +229,6 @@ const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatServic
|
|||
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
|
||||
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
|
||||
const $PageService: Provider = { provide: 'PageService', useExisting: PageService };
|
||||
const $WorldRoomService: Provider = { provide: 'WorldRoomService', useExisting: WorldRoomService };
|
||||
const $WorldRoomMultiplayService: Provider = { provide: 'WorldRoomMultiplayService', useExisting: WorldRoomMultiplayService };
|
||||
const $WorldAvatarService: Provider = { provide: 'WorldAvatarService', useExisting: WorldAvatarService };
|
||||
|
||||
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
|
||||
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
|
||||
|
|
@ -292,8 +284,6 @@ const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting
|
|||
const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService };
|
||||
const $MetaEntityService: Provider = { provide: 'MetaEntityService', useExisting: MetaEntityService };
|
||||
const $SystemWebhookEntityService: Provider = { provide: 'SystemWebhookEntityService', useExisting: SystemWebhookEntityService };
|
||||
const $WorldRoomEntityService: Provider = { provide: 'WorldRoomEntityService', useExisting: WorldRoomEntityService };
|
||||
const $WorldAvatarEntityService: Provider = { provide: 'WorldAvatarEntityService', useExisting: WorldAvatarEntityService };
|
||||
|
||||
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
|
||||
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
|
||||
|
|
@ -392,9 +382,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
RegistryApiService,
|
||||
ReversiService,
|
||||
PageService,
|
||||
WorldRoomService,
|
||||
WorldRoomMultiplayService,
|
||||
WorldAvatarService,
|
||||
|
||||
ChartLoggerService,
|
||||
FederationChart,
|
||||
|
|
@ -450,8 +437,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
ReversiGameEntityService,
|
||||
MetaEntityService,
|
||||
SystemWebhookEntityService,
|
||||
WorldRoomEntityService,
|
||||
WorldAvatarEntityService,
|
||||
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
|
|
@ -547,9 +532,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$PageService,
|
||||
$WorldRoomService,
|
||||
$WorldRoomMultiplayService,
|
||||
$WorldAvatarService,
|
||||
|
||||
$ChartLoggerService,
|
||||
$FederationChart,
|
||||
|
|
@ -605,8 +587,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$ReversiGameEntityService,
|
||||
$MetaEntityService,
|
||||
$SystemWebhookEntityService,
|
||||
$WorldRoomEntityService,
|
||||
$WorldAvatarEntityService,
|
||||
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
|
|
@ -702,9 +682,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
RegistryApiService,
|
||||
ReversiService,
|
||||
PageService,
|
||||
WorldRoomService,
|
||||
WorldRoomMultiplayService,
|
||||
WorldAvatarService,
|
||||
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
|
|
@ -759,8 +736,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
ReversiGameEntityService,
|
||||
MetaEntityService,
|
||||
SystemWebhookEntityService,
|
||||
WorldRoomEntityService,
|
||||
WorldAvatarEntityService,
|
||||
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
|
|
@ -855,9 +830,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$PageService,
|
||||
$WorldRoomService,
|
||||
$WorldRoomMultiplayService,
|
||||
$WorldAvatarService,
|
||||
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
|
|
@ -912,8 +884,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$ReversiGameEntityService,
|
||||
$MetaEntityService,
|
||||
$SystemWebhookEntityService,
|
||||
$WorldRoomEntityService,
|
||||
$WorldAvatarEntityService,
|
||||
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
|
|
|
|||
|
|
@ -173,16 +173,6 @@ export interface ChatEventTypes {
|
|||
};
|
||||
}
|
||||
|
||||
export interface WorldRoomEventTypes {
|
||||
enter: {
|
||||
user: Packed<'UserLite'>;
|
||||
avatar: Packed<'WorldAvatarLite'>['def'] | null;
|
||||
};
|
||||
left: {
|
||||
userId: MiUser['id'];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReversiEventTypes {
|
||||
matched: {
|
||||
game: Packed<'ReversiGameDetailed'>;
|
||||
|
|
@ -325,10 +315,6 @@ export type GlobalEvents = {
|
|||
name: `chatRoomStream:${MiChatRoom['id']}`;
|
||||
payload: EventTypesToEventPayload<ChatEventTypes>;
|
||||
};
|
||||
worldRoom: {
|
||||
name: `worldRoomStream:${string}`;
|
||||
payload: EventTypesToEventPayload<WorldRoomEventTypes>;
|
||||
};
|
||||
reversi: {
|
||||
name: `reversiStream:${MiUser['id']}`;
|
||||
payload: EventTypesToEventPayload<ReversiEventTypes>;
|
||||
|
|
@ -449,9 +435,4 @@ export class GlobalEventService {
|
|||
public publishReversiGameStream<K extends keyof ReversiGameEventTypes>(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void {
|
||||
this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishWorldRoomStream<K extends keyof WorldRoomEventTypes>(roomId: string, type: K, value?: WorldRoomEventTypes[K]): void {
|
||||
this.publish(`worldRoomStream:${roomId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ type Option = {
|
|||
visibleUsers?: MinimumUser[] | null;
|
||||
channel?: MiChannel | null;
|
||||
apMentions?: MinimumUser[] | null;
|
||||
apMentionRawCount?: number | null;
|
||||
apHashtags?: string[] | null;
|
||||
apEmojis?: string[] | null;
|
||||
uri?: string | null;
|
||||
|
|
@ -604,7 +605,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {
|
||||
const effectiveMentionCount = Math.max(mentionedUsers.length, data.apMentionRawCount ?? 0);
|
||||
if (effectiveMentionCount > 0 && effectiveMentionCount > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {
|
||||
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,17 +4,19 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserAuthService {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
|
@ -30,16 +32,58 @@ export class UserAuthService {
|
|||
twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token),
|
||||
});
|
||||
} else {
|
||||
const delta = OTPAuth.TOTP.validate({
|
||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
|
||||
digits: 6,
|
||||
token,
|
||||
window: 5,
|
||||
});
|
||||
|
||||
if (delta === null) {
|
||||
if (!await this.validateOtp(profile.userId, profile.twoFactorSecret!, token)) {
|
||||
throw new Error('authentication failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async validateOtp(
|
||||
userId: MiUserProfile['userId'],
|
||||
twoFactorSecret: string,
|
||||
token: string,
|
||||
) {
|
||||
if (process.env.NODE_ENV === 'test' && process.env.MISSKEY_TEST_CHECK_DUPLICATED_TOTP !== '1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 1. 判定に用いるタイムスタンプを固定
|
||||
const now = Date.now();
|
||||
const normalizedToken = token.trim();
|
||||
const validationWindow = 1;
|
||||
const timeStep = 30; // TOTPの周期(秒)
|
||||
|
||||
// 2. TOTPインスタンスを生成(設定を一元管理するため)
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret: OTPAuth.Secret.fromBase32(twoFactorSecret),
|
||||
digits: 6,
|
||||
period: timeStep,
|
||||
});
|
||||
|
||||
// 3. 固定したタイムスタンプを使って検証
|
||||
const delta = totp.validate({
|
||||
token: normalizedToken,
|
||||
window: validationWindow,
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
if (delta === null) {
|
||||
throw new Error('authentication failed');
|
||||
}
|
||||
|
||||
// 4. totp.counter() を用い、同じタイムスタンプから基準ステップを取得
|
||||
const currentStep = totp.counter({ timestamp: now });
|
||||
const step = currentStep + delta;
|
||||
const secretFingerprint = createHash('sha256')
|
||||
.update(twoFactorSecret ?? '')
|
||||
.digest('base64url');
|
||||
|
||||
const usedTokenRedisKey = `2fa:used:${userId}:${secretFingerprint}:${step}`;
|
||||
|
||||
// 5. TTL(有効期限)を設定いてredis set
|
||||
const ttl = timeStep * (validationWindow * 2 + 1);
|
||||
const setResult = await this.redisClient.set(usedTokenRedisKey, normalizedToken, 'EX', ttl, 'NX');
|
||||
|
||||
return setResult === 'OK';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, In, Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import {
|
||||
MiDriveFile,
|
||||
MiWorldAvatar,
|
||||
} from '@/models/_.js';
|
||||
import type { DriveFilesRepository, WorldAvatarsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
|
||||
@Injectable()
|
||||
export class WorldAvatarService {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.worldAvatarsRepository)
|
||||
private worldAvatarsRepository: WorldAvatarsRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private queryService: QueryService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
public defaultAvatar = {
|
||||
type: 'default',
|
||||
body: {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
roughness: 1,
|
||||
metallic: 0,
|
||||
},
|
||||
eyes: {
|
||||
type: 'a',
|
||||
color: [0, 0, 0],
|
||||
},
|
||||
mouth: {
|
||||
type: 'a',
|
||||
color: [0, 0, 0],
|
||||
},
|
||||
accessories: [],
|
||||
} satisfies MiWorldAvatar['def'];
|
||||
|
||||
@bindThis
|
||||
public async validateDef(
|
||||
me: MiUser,
|
||||
def: MiWorldAvatar['def'],
|
||||
): Promise<boolean> {
|
||||
// TODO
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findMyAvatarById(userId: MiUser['id'], avatarId: MiWorldAvatar['id']) {
|
||||
return this.worldAvatarsRepository.findOneBy({ id: avatarId, userId: userId });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findAvatarById(avatarId: MiWorldAvatar['id']) {
|
||||
return this.worldAvatarsRepository.findOne({ where: { id: avatarId }, relations: { user: true } });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getMyAvatarsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiWorldAvatar['id'] | null, untilId?: MiWorldAvatar['id'] | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.worldAvatarsRepository.createQueryBuilder('avatar'), sinceId, untilId)
|
||||
.andWhere('avatar.userId = :userId', { userId });
|
||||
|
||||
const avatars = await query.take(limit).getMany();
|
||||
|
||||
return avatars;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async create(
|
||||
me: MiUser,
|
||||
body: Partial<MiWorldAvatar>,
|
||||
): Promise<MiWorldAvatar> {
|
||||
const currentAvatarsCount = await this.worldAvatarsRepository.countBy({ userId: me.id });
|
||||
|
||||
// TODO: limit by role policy
|
||||
|
||||
const avatar = await this.worldAvatarsRepository.insertOne(new MiWorldAvatar({
|
||||
id: this.idService.gen(),
|
||||
updatedAt: new Date(),
|
||||
name: body.name,
|
||||
def: body.def,
|
||||
userId: me.id,
|
||||
active: currentAvatarsCount === 0,
|
||||
}));
|
||||
|
||||
return avatar;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(
|
||||
avatar: MiWorldAvatar,
|
||||
body: Partial<MiWorldAvatar>,
|
||||
): Promise<void> {
|
||||
body.updatedAt = new Date();
|
||||
const updated = await this.worldAvatarsRepository.createQueryBuilder().update()
|
||||
.set(body)
|
||||
.where('id = :id', { id: avatar.id })
|
||||
.returning('*')
|
||||
.execute()
|
||||
.then((response) => {
|
||||
return response.raw[0];
|
||||
});
|
||||
|
||||
if (body.active) {
|
||||
await this.worldAvatarsRepository.createQueryBuilder().update()
|
||||
.set({ active: false })
|
||||
.where('userId = :userId', { userId: avatar.userId })
|
||||
.andWhere('id != :id', { id: avatar.id })
|
||||
.execute();
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async delete(avatar: MiWorldAvatar, deleter?: MiUser): Promise<void> {
|
||||
await this.worldAvatarsRepository.delete(avatar.id);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getActiveAvatarOfUser(userId: MiUser['id']) {
|
||||
return this.worldAvatarsRepository.findOneBy({ userId, active: true });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getActiveAvatarOfUsers(userIds: MiUser['id'][]): Promise<MiWorldAvatar[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
return this.worldAvatarsRepository.findBy({ userId: In(userIds), active: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, In, Not } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import {
|
||||
MiWorldRoom,
|
||||
} from '@/models/_.js';
|
||||
import type { MiWorldAvatar, WorldRoomsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
|
||||
|
||||
type PlayerState = {
|
||||
position: [number, number, number],
|
||||
rotation: [number, number, number],
|
||||
sit?: string; // id
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WorldRoomMultiplayService {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.worldRoomsRepository)
|
||||
private worldRoomsRepository: WorldRoomsRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
private queryService: QueryService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private userEntityService: UserEntityService,
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
private worldAvatarEntityService: WorldAvatarEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async enter(userId: MiUser['id'], roomId: MiWorldRoom['id']) {
|
||||
console.log('enter', { userId, roomId });
|
||||
|
||||
// TODO: atomicにやる
|
||||
const currentPlayers = await this.redisClient.hlen(`worldRoom:${roomId}:players`);
|
||||
if (currentPlayers < 10) {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.hset(`worldRoom:${roomId}:players`, userId, 1);
|
||||
redisPipeline.hexpire(`worldRoom:${roomId}:players`, 30, 'FIELDS', 1, userId);
|
||||
await redisPipeline.exec();
|
||||
} else {
|
||||
throw new Error('Room is full.');
|
||||
}
|
||||
|
||||
// TODO: 既に入っていたらスキップ
|
||||
const avatar = await this.worldAvatarService.getActiveAvatarOfUser(userId);
|
||||
this.globalEventService.publishWorldRoomStream(roomId, 'enter', {
|
||||
user: await this.userEntityService.pack(userId),
|
||||
avatar: avatar?.def,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async heartbeat(userId: MiUser['id'], roomId: MiWorldRoom['id']) {
|
||||
const exists = await this.redisClient.hexists(`worldRoom:${roomId}:players`, userId);
|
||||
if (exists) {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.hexpire(`worldRoom:${roomId}:players`, 30, 'FIELDS', 1, userId);
|
||||
redisPipeline.hexpire(`worldRoom:${roomId}:playerStates`, 30, 'FIELDS', 1, userId);
|
||||
await redisPipeline.exec();
|
||||
} else {
|
||||
throw new Error('Not in room.');
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async left(userId: MiUser['id'], roomId: MiWorldRoom['id']) {
|
||||
console.log('left', { userId, roomId });
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.hdel(`worldRoom:${roomId}:players`, userId);
|
||||
redisPipeline.hdel(`worldRoom:${roomId}:playerStates`, userId);
|
||||
await redisPipeline.exec();
|
||||
|
||||
this.globalEventService.publishWorldRoomStream(roomId, 'left', {
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updatePlayerState(userId: MiUser['id'], roomId: MiWorldRoom['id'], state: PlayerState) {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.hset(`worldRoom:${roomId}:playerStates`, userId, JSON.stringify(state));
|
||||
redisPipeline.hexpire(`worldRoom:${roomId}:playerStates`, 30, 'FIELDS', 1, userId);
|
||||
await redisPipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getPlayerStates(roomId: MiWorldRoom['id']): Promise<Record<string, PlayerState>> {
|
||||
const entries = await this.redisClient.hgetall(`worldRoom:${roomId}:playerStates`);
|
||||
return Object.fromEntries(Object.entries(entries).map(([userId, state]) => [userId, JSON.parse(state) as PlayerState]));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getPlayerStatesAndHeatbeat(userId: MiUser['id'], roomId: MiWorldRoom['id']): Promise<Record<string, PlayerState>> {
|
||||
// TODO: atomicにやる
|
||||
this.heartbeat(userId, roomId);
|
||||
return this.getPlayerStates(roomId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public packPlayerProfile(user: Packed<'UserLite'>, avatar: Packed<'WorldAvatarLite'>['def'] | null) {
|
||||
return {
|
||||
user: {
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
},
|
||||
avatar: avatar ?? this.worldAvatarService.defaultAvatar,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getPlayerProfiles(roomId: MiWorldRoom['id'], userId?: MiUser['id']): Promise<Record<string, any>> {
|
||||
let playerIds = await this.redisClient.hkeys(`worldRoom:${roomId}:players`);
|
||||
playerIds = playerIds.filter(id => id !== userId);
|
||||
|
||||
const packedUsers = await this.userEntityService.packMany(playerIds);
|
||||
const avatars = await this.worldAvatarService.getActiveAvatarOfUsers(playerIds);
|
||||
|
||||
const profiles: Record<string, any> = {};
|
||||
for (const playerId of playerIds) {
|
||||
const packedUser = packedUsers.find(u => u.id === playerId);
|
||||
if (packedUser == null) continue;
|
||||
profiles[playerId] = this.packPlayerProfile(packedUser, avatars.find(a => a.userId === playerId)?.def ?? null);
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, In, Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import {
|
||||
MiDriveFile,
|
||||
MiWorldRoom,
|
||||
} from '@/models/_.js';
|
||||
import type { DriveFilesRepository, WorldRoomsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
|
||||
const driveFileReferencingOptions = {
|
||||
clippedPicture: ['image'],
|
||||
tapestry: ['image'],
|
||||
poster: ['image'],
|
||||
pictureFrame: ['image'],
|
||||
tabletopPictureFrame: ['image'],
|
||||
tabletopGlassPictureFrame: ['image'],
|
||||
wallCanvas: ['image'],
|
||||
wallGlassPictureFrame: ['image'],
|
||||
tabletopFlag: ['image'],
|
||||
tabletopLcdButtonsController: ['image'],
|
||||
djPlayer: ['image'],
|
||||
monitor: ['image'],
|
||||
allInOnePc: ['image'],
|
||||
laptopPc: ['image'],
|
||||
handheldGameConsole: ['image'],
|
||||
largeMousepad: ['image'],
|
||||
kakejiku: ['image'],
|
||||
} as Record<string, string[]>;
|
||||
|
||||
@Injectable()
|
||||
export class WorldRoomService {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.worldRoomsRepository)
|
||||
private worldRoomsRepository: WorldRoomsRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private queryService: QueryService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async validateDef(
|
||||
me: MiUser,
|
||||
def: MiWorldRoom['def'],
|
||||
): Promise<boolean> {
|
||||
// TODO: スキーマ検証(関係ないプロパティを入れたり不正な値を入れたりできないように)
|
||||
// そのためにはJSON SchemaでRoomState/各objectのoptionsを定義する必要がある
|
||||
|
||||
const objectsLimit = 100; // TODO: ref role policy
|
||||
if (def.installedFurnitures.length > objectsLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachedFilesLimit = 30; // TODO: ref role policy
|
||||
|
||||
const attachedFileIds = this.collectReferencedDriveFileIds(def);
|
||||
if (attachedFileIds.size > attachedFilesLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachedFiles = attachedFileIds.size === 0 ? [] : await this.driveFilesRepository.findBy({ id: In([...attachedFileIds]), userId: me.id });
|
||||
for (const file of attachedFiles) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return false;
|
||||
}
|
||||
if (Math.max(file.properties.width ?? 0, file.properties.height ?? 0) > 2048) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findMyRoomById(userId: MiUser['id'], roomId: MiWorldRoom['id']) {
|
||||
return this.worldRoomsRepository.findOneBy({ id: roomId, userId: userId });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findRoomById(roomId: MiWorldRoom['id']) {
|
||||
return this.worldRoomsRepository.findOne({ where: { id: roomId }, relations: { user: true } });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getRoomsOfUserWithPagination(userId: MiUser['id'], self: boolean, limit: number, sinceId?: MiWorldRoom['id'] | null, untilId?: MiWorldRoom['id'] | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.worldRoomsRepository.createQueryBuilder('room'), sinceId, untilId)
|
||||
.andWhere('room.userId = :userId', { userId });
|
||||
|
||||
if (!self) {
|
||||
query.andWhere('room.visibility = :visibility', { visibility: 'public' });
|
||||
}
|
||||
|
||||
const rooms = await query.take(limit).getMany();
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async create(
|
||||
me: MiUser,
|
||||
body: Partial<MiWorldRoom>,
|
||||
): Promise<MiWorldRoom> {
|
||||
const room = await this.worldRoomsRepository.insertOne(new MiWorldRoom({
|
||||
id: this.idService.gen(),
|
||||
updatedAt: new Date(),
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
def: body.def,
|
||||
userId: me.id,
|
||||
visibility: body.visibility,
|
||||
}));
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(
|
||||
room: MiWorldRoom,
|
||||
body: Partial<MiWorldRoom>,
|
||||
): Promise<void> {
|
||||
body.updatedAt = new Date();
|
||||
return this.worldRoomsRepository.createQueryBuilder().update()
|
||||
.set(body)
|
||||
.where('id = :id', { id: room.id })
|
||||
.returning('*')
|
||||
.execute()
|
||||
.then((response) => {
|
||||
return response.raw[0];
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async delete(room: MiWorldRoom, deleter?: MiUser): Promise<void> {
|
||||
await this.worldRoomsRepository.delete(room.id);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public collectReferencedDriveFileIds(roomState: MiWorldRoom['def']): Set<MiDriveFile['id']> {
|
||||
const fileIds = new Set<MiDriveFile['id']>();
|
||||
const installedFurnitures = roomState.installedFurnitures ?? roomState.installedObjects; // 後方互換性のため
|
||||
for (const o of installedFurnitures) {
|
||||
const def = driveFileReferencingOptions[o.type];
|
||||
if (def == null) continue;
|
||||
for (const key of def) {
|
||||
const optionValue = o.options[key];
|
||||
if (optionValue != null && optionValue.driveFileId != null && optionValue.driveFileId !== '') {
|
||||
fileIds.add(optionValue.driveFileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fileIds;
|
||||
}
|
||||
}
|
||||
|
|
@ -177,6 +177,7 @@ export class ApNoteService {
|
|||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
||||
}
|
||||
|
||||
const apMentionRawCount = new Set(this.apMentionService.extractApMentionObjects(note.tag).map(x => x.href)).size;
|
||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||
const apHashtags = extractApHashtags(note.tag);
|
||||
|
||||
|
|
@ -324,6 +325,7 @@ export class ApNoteService {
|
|||
visibility,
|
||||
visibleUsers,
|
||||
apMentions,
|
||||
apMentionRawCount,
|
||||
apHashtags,
|
||||
apEmojis,
|
||||
poll,
|
||||
|
|
|
|||
|
|
@ -58,10 +58,10 @@ export function getOneApId(value: ApObject): string {
|
|||
/**
|
||||
* Get ActivityStreams Object id
|
||||
*/
|
||||
export function getApId(value: string | IObject): string {
|
||||
export function getApId(value: string | IObject | undefined): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value.id === 'string') return value.id;
|
||||
throw new Error('cannot detemine id');
|
||||
if (value != null && typeof value.id === 'string') return value.id;
|
||||
throw new Error('cannot determine id');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { DriveFilesRepository, MiWorldAvatar, WorldAvatarsRepository } from '@/models/_.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { UserEntityService } from './UserEntityService.js';
|
||||
import { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class WorldAvatarEntityService {
|
||||
constructor(
|
||||
@Inject(DI.worldAvatarsRepository)
|
||||
private worldAvatarsRepository: WorldAvatarsRepository,
|
||||
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packLite(
|
||||
src: MiWorldAvatar['id'] | MiWorldAvatar,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
hint?: {
|
||||
packedUser?: Packed<'UserLite'>
|
||||
},
|
||||
): Promise<Packed<'WorldAvatarLite'>> {
|
||||
const meId = me ? me.id : null;
|
||||
const avatar = typeof src === 'object' ? src : await this.worldAvatarsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return await awaitAll({
|
||||
id: avatar.id,
|
||||
def: avatar.def,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packDetailed(
|
||||
src: MiWorldAvatar['id'] | MiWorldAvatar,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
hint?: {
|
||||
packedUser?: Packed<'UserLite'>
|
||||
},
|
||||
): Promise<Packed<'WorldAvatarDetailed'>> {
|
||||
const meId = me ? me.id : null;
|
||||
const avatar = typeof src === 'object' ? src : await this.worldAvatarsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return await awaitAll({
|
||||
id: avatar.id,
|
||||
createdAt: this.idService.parse(avatar.id).date.toISOString(),
|
||||
updatedAt: avatar.updatedAt.toISOString(),
|
||||
name: avatar.name,
|
||||
def: avatar.def,
|
||||
active: avatar.active,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packLiteMany(
|
||||
avatars: MiWorldAvatar[],
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
) {
|
||||
const _users = avatars.map(({ user, userId }) => user ?? userId);
|
||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||
.then(users => new Map(users.map(u => [u.id, u])));
|
||||
return Promise.all(avatars.map(avatar => this.packLite(avatar, me, { packedUser: _userMap.get(avatar.userId) })));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packDetailedMany(
|
||||
avatars: MiWorldAvatar[],
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
) {
|
||||
const _users = avatars.map(({ user, userId }) => user ?? userId);
|
||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||
.then(users => new Map(users.map(u => [u.id, u])));
|
||||
return Promise.all(avatars.map(avatar => this.packDetailed(avatar, me, { packedUser: _userMap.get(avatar.userId) })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { DriveFilesRepository, MiWorldRoom, WorldRoomsRepository } from '@/models/_.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { UserEntityService } from './UserEntityService.js';
|
||||
import { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class WorldRoomEntityService {
|
||||
constructor(
|
||||
@Inject(DI.worldRoomsRepository)
|
||||
private worldRoomsRepository: WorldRoomsRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private worldRoomService: WorldRoomService,
|
||||
private userEntityService: UserEntityService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packLite(
|
||||
src: MiWorldRoom['id'] | MiWorldRoom,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
hint?: {
|
||||
packedUser?: Packed<'UserLite'>
|
||||
},
|
||||
): Promise<Packed<'WorldRoomLite'>> {
|
||||
const meId = me ? me.id : null;
|
||||
const room = typeof src === 'object' ? src : await this.worldRoomsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return await awaitAll({
|
||||
id: room.id,
|
||||
createdAt: this.idService.parse(room.id).date.toISOString(),
|
||||
updatedAt: room.updatedAt.toISOString(),
|
||||
userId: room.userId,
|
||||
user: hint?.packedUser ?? this.userEntityService.pack(room.user ?? room.userId, me),
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packDetailed(
|
||||
src: MiWorldRoom['id'] | MiWorldRoom,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
hint?: {
|
||||
packedUser?: Packed<'UserLite'>
|
||||
},
|
||||
): Promise<Packed<'WorldRoomDetailed'>> {
|
||||
const meId = me ? me.id : null;
|
||||
const room = typeof src === 'object' ? src : await this.worldRoomsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
const attachedFileIds = this.worldRoomService.collectReferencedDriveFileIds(room.def);
|
||||
const attachedFiles = attachedFileIds.size === 0 ? [] : await this.driveFilesRepository.findBy({ id: In([...attachedFileIds]), userId: room.userId });
|
||||
|
||||
return await awaitAll({
|
||||
id: room.id,
|
||||
createdAt: this.idService.parse(room.id).date.toISOString(),
|
||||
updatedAt: room.updatedAt.toISOString(),
|
||||
userId: room.userId,
|
||||
user: hint?.packedUser ?? this.userEntityService.pack(room.user ?? room.userId, me),
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
def: room.def,
|
||||
attachedFiles: this.driveFileEntityService.packMany(attachedFiles),
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packLiteMany(
|
||||
rooms: MiWorldRoom[],
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
) {
|
||||
const _users = rooms.map(({ user, userId }) => user ?? userId);
|
||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||
.then(users => new Map(users.map(u => [u.id, u])));
|
||||
return Promise.all(rooms.map(room => this.packLite(room, me, { packedUser: _userMap.get(room.userId) })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +91,5 @@ export const DI = {
|
|||
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
|
||||
reversiGamesRepository: Symbol('reversiGamesRepository'),
|
||||
noteDraftsRepository: Symbol('noteDraftsRepository'),
|
||||
worldRoomsRepository: Symbol('worldRoomsRepository'),
|
||||
worldAvatarsRepository: Symbol('worldAvatarsRepository'),
|
||||
//#endregion
|
||||
};
|
||||
|
|
|
|||
|
|
@ -75,8 +75,6 @@ import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-i
|
|||
import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
|
||||
import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js';
|
||||
import { packedNoteDraftSchema } from '@/models/json-schema/note-draft.js';
|
||||
import { packedWorldRoomDetailedSchema, packedWorldRoomLiteSchema } from '@/models/json-schema/world-room.js';
|
||||
import { packedWorldAvatarDetailedSchema, packedWorldAvatarLiteSchema } from '@/models/json-schema/world-avatar.js';
|
||||
|
||||
export const refs = {
|
||||
UserLite: packedUserLiteSchema,
|
||||
|
|
@ -149,10 +147,6 @@ export const refs = {
|
|||
ChatRoom: packedChatRoomSchema,
|
||||
ChatRoomInvitation: packedChatRoomInvitationSchema,
|
||||
ChatRoomMembership: packedChatRoomMembershipSchema,
|
||||
WorldRoomLite: packedWorldRoomLiteSchema,
|
||||
WorldRoomDetailed: packedWorldRoomDetailedSchema,
|
||||
WorldAvatarLite: packedWorldAvatarLiteSchema,
|
||||
WorldAvatarDetailed: packedWorldAvatarDetailedSchema,
|
||||
};
|
||||
|
||||
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
|
||||
|
|
|
|||
|
|
@ -84,8 +84,6 @@ import {
|
|||
MiChatRoomMembership,
|
||||
MiChatRoomInvitation,
|
||||
MiChatApproval,
|
||||
MiWorldRoom,
|
||||
MiWorldAvatar,
|
||||
} from './_.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
|
@ -546,18 +544,6 @@ const $reversiGamesRepository: Provider = {
|
|||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $worldRoomsRepository: Provider = {
|
||||
provide: DI.worldRoomsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiWorldRoom).extend(miRepository as MiRepository<MiWorldRoom>),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $worldAvatarsRepository: Provider = {
|
||||
provide: DI.worldAvatarsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiWorldAvatar).extend(miRepository as MiRepository<MiWorldAvatar>),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
|
|
@ -637,8 +623,6 @@ const $worldAvatarsRepository: Provider = {
|
|||
$chatApprovalsRepository,
|
||||
$bubbleGameRecordsRepository,
|
||||
$reversiGamesRepository,
|
||||
$worldRoomsRepository,
|
||||
$worldAvatarsRepository,
|
||||
],
|
||||
exports: [
|
||||
$usersRepository,
|
||||
|
|
@ -717,8 +701,6 @@ const $worldAvatarsRepository: Provider = {
|
|||
$chatApprovalsRepository,
|
||||
$bubbleGameRecordsRepository,
|
||||
$reversiGamesRepository,
|
||||
$worldRoomsRepository,
|
||||
$worldAvatarsRepository,
|
||||
],
|
||||
})
|
||||
export class RepositoryModule {
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
@Entity('world_avatar')
|
||||
export class MiWorldAvatar {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
})
|
||||
public updatedAt: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 256,
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
})
|
||||
public userId: MiUser['id'];
|
||||
|
||||
@ManyToOne(() => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: MiUser | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public active: boolean;
|
||||
|
||||
@Column('jsonb', {
|
||||
default: {},
|
||||
})
|
||||
public def: Record<string, any>;
|
||||
|
||||
constructor(data: Partial<MiWorldAvatar>) {
|
||||
if (data == null) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
export const worldRoomVisibility = ['public', 'private'] as const;
|
||||
export type WorldRoomVisibility = typeof worldRoomVisibility[number];
|
||||
|
||||
@Entity('world_room')
|
||||
export class MiWorldRoom {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
})
|
||||
public updatedAt: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 256,
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
})
|
||||
public description: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
})
|
||||
public userId: MiUser['id'];
|
||||
|
||||
@ManyToOne(() => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: MiUser | null;
|
||||
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
})
|
||||
public likedCount: number;
|
||||
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
})
|
||||
public accessCount: number;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128, default: 'public',
|
||||
})
|
||||
public visibility: WorldRoomVisibility;
|
||||
|
||||
@Column('jsonb', {
|
||||
default: {},
|
||||
})
|
||||
public def: Record<string, any>;
|
||||
|
||||
constructor(data: Partial<MiWorldRoom>) {
|
||||
if (data == null) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
|||
import { MiChannel } from '@/models/Channel.js';
|
||||
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
|
||||
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
|
||||
import { MiChannelMuting } from '@/models/ChannelMuting.js';
|
||||
import { MiChannelMuting } from "@/models/ChannelMuting.js";
|
||||
import { MiChatApproval } from '@/models/ChatApproval.js';
|
||||
import { MiChatMessage } from '@/models/ChatMessage.js';
|
||||
import { MiChatRoom } from '@/models/ChatRoom.js';
|
||||
|
|
@ -84,8 +84,6 @@ import { MiUserProfile } from '@/models/UserProfile.js';
|
|||
import { MiUserPublickey } from '@/models/UserPublickey.js';
|
||||
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
|
||||
import { MiWebhook } from '@/models/Webhook.js';
|
||||
import { MiWorldRoom } from '@/models/WorldRoom.js';
|
||||
import { MiWorldAvatar } from '@/models/WorldAvatar.js';
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
|
||||
|
||||
export interface MiRepository<T extends ObjectLiteral> {
|
||||
|
|
@ -175,8 +173,6 @@ export {
|
|||
MiChatApproval,
|
||||
MiBubbleGameRecord,
|
||||
MiReversiGame,
|
||||
MiWorldRoom,
|
||||
MiWorldAvatar,
|
||||
};
|
||||
|
||||
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
|
||||
|
|
@ -257,5 +253,3 @@ export type ChatRoomInvitationsRepository = Repository<MiChatRoomInvitation> & M
|
|||
export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<MiChatApproval>;
|
||||
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
|
||||
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
|
||||
export type WorldRoomsRepository = Repository<MiWorldRoom> & MiRepository<MiWorldRoom>;
|
||||
export type WorldAvatarsRepository = Repository<MiWorldAvatar> & MiRepository<MiWorldAvatar>;
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const packedWorldAvatarLiteSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
def: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const packedWorldAvatarDetailedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
def: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
active: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const packedWorldRoomLiteSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
ref: 'UserLite',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const packedWorldRoomDetailedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
ref: 'UserLite',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
def: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
attachedFiles: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'DriveFile',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -87,8 +87,6 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
|||
import { MiReversiGame } from '@/models/ReversiGame.js';
|
||||
import { MiChatApproval } from '@/models/ChatApproval.js';
|
||||
import { MiSystemAccount } from '@/models/SystemAccount.js';
|
||||
import { MiWorldRoom } from '@/models/WorldRoom.js';
|
||||
import { MiWorldAvatar } from '@/models/WorldAvatar.js';
|
||||
|
||||
pg.types.setTypeParser(20, Number);
|
||||
|
||||
|
|
@ -256,8 +254,6 @@ export const entities = [
|
|||
MiChatApproval,
|
||||
MiBubbleGameRecord,
|
||||
MiReversiGame,
|
||||
MiWorldRoom,
|
||||
MiWorldAvatar,
|
||||
...charts,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -174,7 +174,17 @@ export class ActivityPubServerService {
|
|||
}
|
||||
}
|
||||
|
||||
this.queueService.inbox(request.body as IActivity, signature);
|
||||
const body = request.body;
|
||||
|
||||
// Reject structurally invalid activities (e.g. missing actor) here instead
|
||||
// of letting them fail deep inside the inbox processor. An actor-less
|
||||
// activity can never be authenticated, so there is no point enqueueing it.
|
||||
if (typeof body !== 'object' || body == null || !('actor' in body) || body.actor == null) {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
this.queueService.inbox(body as IActivity, signature);
|
||||
|
||||
reply.code(202);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { EndpointsModule } from '@/server/api/EndpointsModule.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import MainStreamConnection from '@/server/api/stream/Connection.js';
|
||||
import { ApiCallService } from './api/ApiCallService.js';
|
||||
import { FileServerService } from './FileServerService.js';
|
||||
import { HealthServerService } from './HealthServerService.js';
|
||||
|
|
@ -31,6 +30,7 @@ import { UrlPreviewService } from './web/UrlPreviewService.js';
|
|||
import { ClientLoggerService } from './web/ClientLoggerService.js';
|
||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||
|
||||
import MainStreamConnection from '@/server/api/stream/Connection.js';
|
||||
import { MainChannel } from './api/stream/channels/main.js';
|
||||
import { AdminChannel } from './api/stream/channels/admin.js';
|
||||
import { AntennaChannel } from './api/stream/channels/antenna.js';
|
||||
|
|
@ -49,7 +49,6 @@ import { ChatUserChannel } from './api/stream/channels/chat-user.js';
|
|||
import { ChatRoomChannel } from './api/stream/channels/chat-room.js';
|
||||
import { ReversiChannel } from './api/stream/channels/reversi.js';
|
||||
import { ReversiGameChannel } from './api/stream/channels/reversi-game.js';
|
||||
import { WorldRoomChannel } from './api/stream/channels/world-room.js';
|
||||
import { NoteStreamingHidingService } from './api/stream/NoteStreamingHidingService.js';
|
||||
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
|
||||
|
||||
|
|
@ -100,7 +99,6 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
|
|||
QueueStatsChannel,
|
||||
ServerStatsChannel,
|
||||
UserListChannel,
|
||||
WorldRoomChannel,
|
||||
NoteStreamingHidingService,
|
||||
OpenApiServerService,
|
||||
OAuth2ProviderService,
|
||||
|
|
|
|||
|
|
@ -446,14 +446,4 @@ export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitati
|
|||
export * as 'chat/rooms/invitations/outbox' from './endpoints/chat/rooms/invitations/outbox.js';
|
||||
export * as 'chat/history' from './endpoints/chat/history.js';
|
||||
export * as 'chat/read-all' from './endpoints/chat/read-all.js';
|
||||
export * as 'world/rooms/create' from './endpoints/world/rooms/create.js';
|
||||
export * as 'world/rooms/update' from './endpoints/world/rooms/update.js';
|
||||
export * as 'world/rooms/delete' from './endpoints/world/rooms/delete.js';
|
||||
export * as 'world/rooms/list-by-user' from './endpoints/world/rooms/list-by-user.js';
|
||||
export * as 'world/rooms/show' from './endpoints/world/rooms/show.js';
|
||||
export * as 'world/avatars/create' from './endpoints/world/avatars/create.js';
|
||||
export * as 'world/avatars/update' from './endpoints/world/avatars/update.js';
|
||||
export * as 'world/avatars/delete' from './endpoints/world/avatars/delete.js';
|
||||
export * as 'world/avatars/list' from './endpoints/world/avatars/list.js';
|
||||
export * as 'world/avatars/show' from './endpoints/world/avatars/show.js';
|
||||
export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js';
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||
import type { UserProfilesRepository } from '@/models/_.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserAuthService } from "@/core/UserAuthService.js";
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
|
@ -45,6 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private userAuthService: UserAuthService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
|
|
@ -56,14 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new Error('二段階認証の設定が開始されていません');
|
||||
}
|
||||
|
||||
const delta = OTPAuth.TOTP.validate({
|
||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
|
||||
digits: 6,
|
||||
token,
|
||||
window: 5,
|
||||
});
|
||||
|
||||
if (delta === null) {
|
||||
if (!await this.userAuthService.validateOtp(profile.userId, profile.twoFactorTempSecret, token)) {
|
||||
throw new Error('not verified');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import ms from 'ms';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldAvatar'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:worldAvatar',
|
||||
|
||||
limit: {
|
||||
duration: ms('1day'),
|
||||
max: 10,
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'WorldAvatarDetailed',
|
||||
},
|
||||
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', maxLength: 256 },
|
||||
def: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
required: ['name', 'def'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
private worldAvatarEntityService: WorldAvatarEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// TODO: validate avatar
|
||||
|
||||
const avatar = await this.worldAvatarService.create(me, {
|
||||
name: ps.name,
|
||||
def: ps.def,
|
||||
});
|
||||
return await this.worldAvatarEntityService.packDetailed(avatar);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldAvatar'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:worldAvatar',
|
||||
|
||||
errors: {
|
||||
noSuchAvatar: {
|
||||
message: 'No such avatar.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4c',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
avatarId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['avatarId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const avatar = await this.worldAvatarService.findMyAvatarById(me.id, ps.avatarId);
|
||||
if (avatar == null) {
|
||||
throw new ApiError(meta.errors.noSuchAvatar);
|
||||
}
|
||||
|
||||
await this.worldAvatarService.delete(avatar, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldAvatar'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'read:worldAvatar',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'WorldAvatarDetailed',
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldAvatarEntityService: WorldAvatarEntityService,
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
|
||||
|
||||
const avatars = await this.worldAvatarService.getMyAvatarsWithPagination(me.id, ps.limit, sinceId, untilId);
|
||||
return this.worldAvatarEntityService.packDetailedMany(avatars, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldAvatar'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'read:worldAvatar',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'WorldAvatarDetailed',
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchAvatar: {
|
||||
message: 'No such avatar.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: '857ae02f-8759-4d20-9adb-6e95fffe4fd8',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
avatarId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['avatarId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
private worldAvatarEntityService: WorldAvatarEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const avatar = await this.worldAvatarService.findAvatarById(ps.avatarId);
|
||||
if (avatar == null) {
|
||||
throw new ApiError(meta.errors.noSuchAvatar);
|
||||
}
|
||||
|
||||
if (avatar.userId !== me.id) {
|
||||
throw new ApiError(meta.errors.noSuchAvatar);
|
||||
}
|
||||
|
||||
return this.worldAvatarEntityService.packDetailed(avatar, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldAvatar'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:worldAvatar',
|
||||
|
||||
res: {
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchAvatar: {
|
||||
message: 'No such avatar.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: 'fcdb0f92-bda6-47f9-bd05-343e0e020933',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
avatarId: { type: 'string', format: 'misskey:id' },
|
||||
name: { type: 'string', maxLength: 256 },
|
||||
def: { type: 'object', additionalProperties: true },
|
||||
active: { type: 'boolean' },
|
||||
},
|
||||
required: ['avatarId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const avatar = await this.worldAvatarService.findMyAvatarById(me.id, ps.avatarId);
|
||||
if (avatar == null) {
|
||||
throw new ApiError(meta.errors.noSuchAvatar);
|
||||
}
|
||||
|
||||
// TODO: validate avatar
|
||||
|
||||
await this.worldAvatarService.update(avatar, {
|
||||
name: ps.name,
|
||||
def: ps.def,
|
||||
active: ps.active,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import ms from 'ms';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { WorldRoomEntityService } from '@/core/entities/WorldRoomEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldRoom'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:worldRoom',
|
||||
|
||||
limit: {
|
||||
duration: ms('1day'),
|
||||
max: 10,
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'WorldRoomDetailed',
|
||||
},
|
||||
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', maxLength: 256 },
|
||||
description: { type: 'string', maxLength: 1024 },
|
||||
visibility: { type: 'string', enum: ['public', 'private'] },
|
||||
def: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
required: ['name', 'visibility', 'def'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldRoomService: WorldRoomService,
|
||||
private worldRoomEntityService: WorldRoomEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// TODO: validate room
|
||||
|
||||
const room = await this.worldRoomService.create(me, {
|
||||
name: ps.name,
|
||||
description: ps.description ?? '',
|
||||
visibility: ps.visibility,
|
||||
def: ps.def,
|
||||
});
|
||||
return await this.worldRoomEntityService.packDetailed(room);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldRoom'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:worldRoom',
|
||||
|
||||
errors: {
|
||||
noSuchRoom: {
|
||||
message: 'No such room.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4c',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['roomId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldRoomService: WorldRoomService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const room = await this.worldRoomService.findMyRoomById(me.id, ps.roomId);
|
||||
if (room == null) {
|
||||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
await this.worldRoomService.delete(room, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { WorldRoomEntityService } from '@/core/entities/WorldRoomEntityService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldRoom'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'read:worldRoom',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'WorldRoomLite',
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
},
|
||||
required: ['userId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldRoomEntityService: WorldRoomEntityService,
|
||||
private worldRoomService: WorldRoomService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
|
||||
|
||||
const rooms = await this.worldRoomService.getRoomsOfUserWithPagination(ps.userId, ps.userId === me.id, ps.limit, sinceId, untilId);
|
||||
return this.worldRoomEntityService.packLiteMany(rooms, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { WorldRoomEntityService } from '@/core/entities/WorldRoomEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldRoom'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'read:worldRoom',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'WorldRoomDetailed',
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchRoom: {
|
||||
message: 'No such room.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: '857ae02f-8759-4d20-9adb-6e95fffe4fd8',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['roomId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldRoomService: WorldRoomService,
|
||||
private worldRoomEntityService: WorldRoomEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const room = await this.worldRoomService.findRoomById(ps.roomId);
|
||||
if (room == null) {
|
||||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
if (room.userId !== me.id && room.visibility === 'private') {
|
||||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
return this.worldRoomEntityService.packDetailed(room, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldRoom'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:worldRoom',
|
||||
|
||||
res: {
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchRoom: {
|
||||
message: 'No such room.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: 'fcdb0f92-bda6-47f9-bd05-343e0e020933',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', format: 'misskey:id' },
|
||||
name: { type: 'string', maxLength: 256 },
|
||||
description: { type: 'string', maxLength: 1024 },
|
||||
visibility: { type: 'string', enum: ['public', 'private'] },
|
||||
def: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
required: ['roomId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldRoomService: WorldRoomService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const room = await this.worldRoomService.findMyRoomById(me.id, ps.roomId);
|
||||
if (room == null) {
|
||||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
// TODO: validate room
|
||||
|
||||
await this.worldRoomService.update(room, {
|
||||
name: ps.name,
|
||||
description: ps.description,
|
||||
visibility: ps.visibility,
|
||||
def: ps.def,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +35,6 @@ import { ChatUserChannel } from '@/server/api/stream/channels/chat-user.js';
|
|||
import { ChatRoomChannel } from '@/server/api/stream/channels/chat-room.js';
|
||||
import { ReversiChannel } from '@/server/api/stream/channels/reversi.js';
|
||||
import { ReversiGameChannel } from '@/server/api/stream/channels/reversi-game.js';
|
||||
import { WorldRoomChannel } from '@/server/api/stream/channels/world-room.js';
|
||||
import type { ChannelRequest } from './channel.js';
|
||||
import type { ChannelConstructor } from './channel.js';
|
||||
import type Channel from './channel.js';
|
||||
|
|
@ -339,7 +338,6 @@ export default class Connection {
|
|||
case 'chatRoom': return ChatRoomChannel;
|
||||
case 'reversi': return ReversiChannel;
|
||||
case 'reversiGame': return ReversiGameChannel;
|
||||
case 'worldRoom': return WorldRoomChannel;
|
||||
|
||||
default:
|
||||
throw new Error(`no such channel: ${name}`);
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { WorldRoomMultiplayService } from '@/core/WorldRoomMultiplayService.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class WorldRoomChannel extends Channel {
|
||||
public readonly chName = 'worldRoom';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = true as const;
|
||||
public static kind = 'read:worldRoom';
|
||||
private roomId: string;
|
||||
private intervalId: NodeJS.Timeout;
|
||||
private isEntered = false;
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
private worldRoomService: WorldRoomService,
|
||||
private worldRoomMultiplayService: WorldRoomMultiplayService,
|
||||
) {
|
||||
super(request);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async init(params: JsonObject): Promise<boolean> {
|
||||
if (typeof params.roomId !== 'string') return false;
|
||||
if (!this.user) return false;
|
||||
|
||||
this.roomId = params.roomId;
|
||||
|
||||
const room = await this.worldRoomService.findRoomById(this.roomId);
|
||||
if (room == null) return false;
|
||||
|
||||
try {
|
||||
await this.enter();
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.subscriber.on(`worldRoomStream:${this.roomId}`, this.onEvent);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async enter() {
|
||||
if (this.isEntered) return;
|
||||
|
||||
await this.worldRoomMultiplayService.enter(this.user!.id, this.roomId);
|
||||
|
||||
this.isEntered = true;
|
||||
|
||||
this.send('entered', {
|
||||
playerProfiles: await this.worldRoomMultiplayService.getPlayerProfiles(this.roomId, this.user!.id),
|
||||
});
|
||||
|
||||
this.intervalId = setInterval(async () => {
|
||||
const states = await this.worldRoomMultiplayService.getPlayerStatesAndHeatbeat(this.user!.id, this.roomId);
|
||||
delete states[this.user!.id];
|
||||
this.send('sync', states);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onEvent(data: GlobalEvents['worldRoom']['payload']) {
|
||||
switch (data.type) {
|
||||
case 'enter': {
|
||||
if (data.body.user.id === this.user!.id) return; // 自分の入室は無視
|
||||
this.send('playerEntered', {
|
||||
id: data.body.user.id,
|
||||
profile: this.worldRoomMultiplayService.packPlayerProfile(data.body.user, data.body.avatar),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'left': {
|
||||
if (data.body.userId === this.user!.id) return; // 自分の退室は無視
|
||||
this.send('playerLeft', {
|
||||
id: data.body.userId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onMessage(type: string, body: any) {
|
||||
switch (type) {
|
||||
case 'update':
|
||||
if (this.roomId && this.isEntered) {
|
||||
this.worldRoomMultiplayService.updatePlayerState(this.user!.id, this.roomId, body);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose() {
|
||||
this.subscriber.off(`worldRoomStream:${this.roomId}`, this.onEvent);
|
||||
|
||||
clearInterval(this.intervalId);
|
||||
this.worldRoomMultiplayService.left(this.user!.id, this.roomId);
|
||||
}
|
||||
}
|
||||
|
|
@ -273,8 +273,9 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt
|
|||
}
|
||||
}
|
||||
|
||||
function firstValue(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
function firstValue(value: unknown | unknown[] | undefined): string | undefined {
|
||||
const firstElement = Array.isArray(value) ? value[0] : value;
|
||||
return typeof firstElement === 'string' ? firstElement : undefined;
|
||||
}
|
||||
|
||||
function normalizeScope(scope: string | string[] | undefined): string[] {
|
||||
|
|
@ -282,12 +283,39 @@ function normalizeScope(scope: string | string[] | undefined): string[] {
|
|||
return raw.flatMap(value => value.split(/\s+/)).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseUrlEncodedParameters(rawBody: string): OAuthRequestParameters {
|
||||
const parsed: OAuthRequestParameters = {};
|
||||
for (const [key, value] of new URLSearchParams(rawBody).entries()) {
|
||||
const current = parsed[key];
|
||||
if (current == null) {
|
||||
parsed[key] = value;
|
||||
} else if (Array.isArray(current)) {
|
||||
current.push(value);
|
||||
} else {
|
||||
parsed[key] = [current, value];
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toRequestParameters(body: unknown): OAuthRequestParameters {
|
||||
if (typeof body === 'string') {
|
||||
return parseUrlEncodedParameters(body);
|
||||
}
|
||||
|
||||
if (body instanceof URLSearchParams) {
|
||||
return parseUrlEncodedParameters(body.toString());
|
||||
}
|
||||
|
||||
if (body == null || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return body as OAuthRequestParameters;
|
||||
return Object.fromEntries(Object.entries(body).filter(([_, value]) => (
|
||||
typeof value === 'string' ||
|
||||
(Array.isArray(value) && value.every(v => typeof v === 'string'))
|
||||
)));
|
||||
}
|
||||
|
||||
function applyNoStore(reply: FastifyReply): void {
|
||||
|
|
@ -360,19 +388,7 @@ function registerFormBodyParser(fastify: FastifyInstance): void {
|
|||
|
||||
fastify.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_request, body, done) => {
|
||||
try {
|
||||
const parsed: OAuthRequestParameters = {};
|
||||
for (const [key, value] of new URLSearchParams(typeof body === 'string' ? body : body.toString('utf8')).entries()) {
|
||||
const current = parsed[key];
|
||||
if (current == null) {
|
||||
parsed[key] = value;
|
||||
} else if (Array.isArray(current)) {
|
||||
current.push(value);
|
||||
} else {
|
||||
parsed[key] = [current, value];
|
||||
}
|
||||
}
|
||||
|
||||
done(null, parsed);
|
||||
done(null, parseUrlEncodedParameters(typeof body === 'string' ? body : body.toString('utf8')));
|
||||
} catch (error) {
|
||||
done(error as Error, undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { FastifyRequest, FastifyReply } from 'fastify';
|
|||
@Injectable()
|
||||
export class UrlPreviewService {
|
||||
private logger: Logger;
|
||||
private readonly summalyDefaultUserAgent: string;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
|
|
@ -31,6 +32,7 @@ export class UrlPreviewService {
|
|||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('url-preview');
|
||||
this.summalyDefaultUserAgent = `SummalyBot/${_SUMMALY_VERSION_} (${this.config.url}; +https://github.com/misskey-dev/summaly/blob/master/README.md)`;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -113,20 +115,16 @@ export class UrlPreviewService {
|
|||
}
|
||||
|
||||
private async fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
|
||||
const agent = this.config.proxy
|
||||
? {
|
||||
http: this.httpRequestService.httpAgent,
|
||||
https: this.httpRequestService.httpsAgent,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const { summaly } = await import('@misskey-dev/summaly');
|
||||
|
||||
return summaly(url, {
|
||||
followRedirects: this.meta.urlPreviewAllowRedirect,
|
||||
lang: lang ?? 'ja-JP',
|
||||
agent: agent,
|
||||
userAgent: meta.urlPreviewUserAgent ?? undefined,
|
||||
agent: {
|
||||
http: this.httpRequestService.httpAgent,
|
||||
https: this.httpRequestService.httpsAgent,
|
||||
},
|
||||
userAgent: meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
|
||||
operationTimeout: meta.urlPreviewTimeout,
|
||||
contentLengthLimit: meta.urlPreviewMaximumContentLength,
|
||||
contentLengthRequired: meta.urlPreviewRequireContentLength,
|
||||
|
|
@ -139,7 +137,7 @@ export class UrlPreviewService {
|
|||
url: url,
|
||||
lang: lang ?? 'ja-JP',
|
||||
followRedirects: this.meta.urlPreviewAllowRedirect,
|
||||
userAgent: meta.urlPreviewUserAgent ?? undefined,
|
||||
userAgent: meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
|
||||
operationTimeout: meta.urlPreviewTimeout,
|
||||
contentLengthLimit: meta.urlPreviewMaximumContentLength,
|
||||
contentLengthRequired: meta.urlPreviewRequireContentLength,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import * as crypto from 'node:crypto';
|
|||
import cbor from 'cbor';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { loadConfig } from '@/config.js';
|
||||
import { api, signup } from '../utils.js';
|
||||
import { api, signup, sendEnvUpdateRequest } from '../utils.js';
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorAssertionResponseJSON,
|
||||
|
|
@ -20,7 +20,7 @@ import type {
|
|||
RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/server';
|
||||
import type * as misskey from 'misskey-js';
|
||||
import { describe, beforeAll, test } from 'vitest';
|
||||
import { describe, beforeAll, beforeEach, test } from 'vitest';
|
||||
|
||||
describe('2要素認証', () => {
|
||||
let alice: misskey.entities.SignupResponse;
|
||||
|
|
@ -181,6 +181,10 @@ describe('2要素認証', () => {
|
|||
alice = await signup({ username, password });
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
beforeEach(async () => {
|
||||
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_DUPLICATED_TOTP', value: '' });
|
||||
});
|
||||
|
||||
test('が設定でき、OTPでログインできる。', async () => {
|
||||
const registerResponse = await api('i/2fa/register', {
|
||||
password,
|
||||
|
|
@ -487,4 +491,33 @@ describe('2要素認証', () => {
|
|||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
});
|
||||
|
||||
test('のTOTPトークンは一度使うと同じトークンは再利用できない。', async () => {
|
||||
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_DUPLICATED_TOTP', value: '1' });
|
||||
|
||||
const registerResponse = await api('i/2fa/register', {
|
||||
password,
|
||||
}, alice);
|
||||
assert.strictEqual(registerResponse.status, 200);
|
||||
|
||||
const sharedOtpToken = otpToken(registerResponse.body.secret);
|
||||
const doneResponse = await api('i/2fa/done', {
|
||||
token: sharedOtpToken,
|
||||
}, alice);
|
||||
assert.strictEqual(doneResponse.status, 200);
|
||||
|
||||
const signinResponse = await api('signin-flow', {
|
||||
...signinParam(),
|
||||
token: sharedOtpToken,
|
||||
});
|
||||
assert.strictEqual(signinResponse.status, 403);
|
||||
|
||||
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_DUPLICATED_TOTP', value: '' });
|
||||
|
||||
// 後片付け
|
||||
await api('i/2fa/unregister', {
|
||||
password,
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -809,6 +809,66 @@ describe('OAuth', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Token endpoint', () => {
|
||||
test('Accept JSON payload', async () => {
|
||||
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||
const { code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||
|
||||
const response = await fetch(new URL('/oauth/token', host), {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientConfig.client.id,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.strictEqual(response.status, 200);
|
||||
const tokenResponse = await response.json() as {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
scope: string;
|
||||
};
|
||||
assert.strictEqual(typeof tokenResponse.access_token, 'string');
|
||||
assert.strictEqual(tokenResponse.token_type, 'Bearer');
|
||||
assert.strictEqual(tokenResponse.scope, 'write:notes');
|
||||
});
|
||||
|
||||
test('Accept x-www-form-urlencoded payload', async () => {
|
||||
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||
const { code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||
|
||||
const response = await fetch(new URL('/oauth/token', host), {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientConfig.client.id,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.strictEqual(response.status, 200);
|
||||
const tokenResponse = await response.json() as {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
scope: string;
|
||||
};
|
||||
assert.strictEqual(typeof tokenResponse.access_token, 'string');
|
||||
assert.strictEqual(tokenResponse.token_type, 'Bearer');
|
||||
assert.strictEqual(tokenResponse.scope, 'write:notes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Information Discovery', () => {
|
||||
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||
describe('JSON client metadata (11 July 2024)', () => {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/emoji-assets": "17.0.3",
|
||||
"@misskey-dev/summaly": "5.3.0",
|
||||
"@misskey-dev/summaly": "5.5.1",
|
||||
"@tabler/icons-webfont": "3.35.0",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/estree": "1.0.9",
|
||||
|
|
|
|||
|
|
@ -153,11 +153,10 @@ export function getConfig(): UserConfig {
|
|||
name: 'vue',
|
||||
test: /node_modules[\\/]vue/,
|
||||
}, {
|
||||
// split each i18n related module to each distinct module, deny hoisting
|
||||
// split i18n related module to distinct module
|
||||
name: 'i18n',
|
||||
test: /i18n\.ts/,
|
||||
minSize: 0,
|
||||
maxSize: 1,
|
||||
includeDependenciesRecursively: false,
|
||||
test: /i18n\.ts|locale\.ts/,
|
||||
}],
|
||||
},
|
||||
entryFileNames: `scripts/${localesHash}-[hash:8].js`,
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
# frontend用Misskey Worldエンジン
|
||||
|
||||
エンジンはWeb Worker内で動作し、ほぼすべてのMisskey Webの機能は使えないため、意図しないそれらへの参照/依存が原理的に発生しないように別パッケージとする
|
||||
|
||||
ただしヘッドレス動作することは(今のところ)意図していない
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import tsParser from '@typescript-eslint/parser';
|
||||
import sharedConfig from '../shared/eslint.config.js';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default [
|
||||
...sharedConfig,
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules',
|
||||
'built',
|
||||
'coverage',
|
||||
'jest.config.ts',
|
||||
'test',
|
||||
'test-d',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['./tsconfig.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"type": "module",
|
||||
"name": "frontend-misskey-world-engine",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.2",
|
||||
"@typescript-eslint/parser": "8.59.2",
|
||||
"esbuild": "0.28.0",
|
||||
"execa": "9.6.1",
|
||||
"nodemon": "3.1.14",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6"
|
||||
},
|
||||
"files": [
|
||||
"built"
|
||||
],
|
||||
"dependencies": {
|
||||
"@babylonjs/core": "9.12.0",
|
||||
"@babylonjs/inspector": "9.12.0",
|
||||
"@babylonjs/loaders": "9.12.0",
|
||||
"@babylonjs/materials": "9.12.0",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"eventemitter3": "5.0.4",
|
||||
"seedrandom": "3.0.5",
|
||||
"tinycolor2": "1.6.0",
|
||||
"hls.js": "1.6.16"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
const IN_WEB_WORKER = typeof window === 'undefined';
|
||||
|
||||
export type EngineBaseEvents = {
|
||||
'loadingProgress': (ctx: { progress: number }) => void;
|
||||
'contextlost': (ctx: { reason: string; message: string; }) => void;
|
||||
};
|
||||
|
||||
export abstract class EngineBase<EVs extends EngineBaseEvents> extends EventEmitter<{
|
||||
'ev': (ctx: { type: keyof EVs; ctx: Parameters<EVs[keyof EVs]>[0] }) => void;
|
||||
}> {
|
||||
declare _eventTypes?: EVs;
|
||||
|
||||
protected engine: BABYLON.WebGPUEngine;
|
||||
public scene: BABYLON.Scene;
|
||||
protected fps: number | null = null;
|
||||
protected disposed = false;
|
||||
|
||||
public inputs: EventEmitter<{
|
||||
'click': (event: { x: number; y: number; }) => void;
|
||||
'keydown': (event: { code: string; shiftKey: boolean; }) => void;
|
||||
'keyup': (event: { code: string; shiftKey: boolean; }) => void;
|
||||
'wheel': (event: { deltaY: number; }) => void;
|
||||
'zoom': (event: { delta: number; }) => void;
|
||||
'pointer': (event: { x: number; y: number; }) => void;
|
||||
}> = new EventEmitter();
|
||||
|
||||
constructor(options: {
|
||||
engine: BABYLON.WebGPUEngine;
|
||||
fps: number | null;
|
||||
}) {
|
||||
super();
|
||||
|
||||
this.fps = options.fps;
|
||||
|
||||
this.engine = options.engine;
|
||||
// doNotHandleContextLostがtrueだとそもそも呼ばれない
|
||||
//babylonEngine.onContextLostObservable.add(() => {
|
||||
// os.alert({
|
||||
// type: 'error',
|
||||
// title: i18n.ts.somethingHappened,
|
||||
// text: i18n.ts._miWorld.crushed_description,
|
||||
// });
|
||||
//});
|
||||
this.engine._device.lost.then((info) => { // TODO: babylonEngineの内部プロパティに依存しない方法をforumで聞く
|
||||
this.ev('contextlost', { reason: info.reason, message: info.message }); // transferableじゃないデータが含まれている可能性も考慮してinfoそのままは送らない
|
||||
});
|
||||
|
||||
this.scene = new BABYLON.Scene(this.engine);
|
||||
}
|
||||
|
||||
private currentRafId: number | null = null;
|
||||
|
||||
protected startRenderLoop() {
|
||||
if (this.fps == null) {
|
||||
this.engine.runRenderLoop(() => {
|
||||
this.scene.render();
|
||||
});
|
||||
} else {
|
||||
let then = 0;
|
||||
const interval = 1000 / this.fps;
|
||||
|
||||
const renderLoop = (timeStamp: number) => {
|
||||
if (this.disposed) return;
|
||||
|
||||
// workerで実行される可能性がある
|
||||
this.currentRafId = requestAnimationFrame(renderLoop);
|
||||
|
||||
const delta = timeStamp - then;
|
||||
if (delta <= interval) return;
|
||||
then = timeStamp - (delta % interval);
|
||||
|
||||
this.engine.beginFrame();
|
||||
this.scene.render();
|
||||
this.engine.endFrame();
|
||||
};
|
||||
|
||||
// workerで実行される可能性がある
|
||||
this.currentRafId = requestAnimationFrame(renderLoop);
|
||||
}
|
||||
}
|
||||
|
||||
public pauseRender() { // TODO: srと同じく参照カウント方式にした方が便利そう
|
||||
this.engine.stopRenderLoop();
|
||||
if (this.currentRafId != null) {
|
||||
// workerで実行される可能性がある
|
||||
cancelAnimationFrame(this.currentRafId);
|
||||
this.currentRafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
public resumeRender() {
|
||||
this.startRenderLoop();
|
||||
}
|
||||
|
||||
public abstract init(): Promise<void>;
|
||||
|
||||
protected ev<K extends keyof EVs>(type: K, ctx: Parameters<EVs[K]>[0]) {
|
||||
this.emit('ev', { type, ctx });
|
||||
}
|
||||
|
||||
public async takeScreenshot() {
|
||||
return await BABYLON.Tools.CreateScreenshotAsync(this.engine, this.scene.activeCamera!, { precision: 1 });
|
||||
}
|
||||
|
||||
public abstract resize(): void;
|
||||
|
||||
public destroy() {
|
||||
this.engine.stopRenderLoop();
|
||||
if (this.currentRafId != null) {
|
||||
// workerで実行される可能性がある
|
||||
cancelAnimationFrame(this.currentRafId);
|
||||
this.currentRafId = null;
|
||||
}
|
||||
this.engine.dispose();
|
||||
this.scene.dispose();
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
|
||||
import { AccessoryContainer } from './avatars/AccessoryContainer.js';
|
||||
import { getAccessoryDef } from './avatars/accessory-defs.js';
|
||||
import { createTextMesh, Timer } from './utility.js';
|
||||
import type { WorldAvatar } from 'misskey-world/src/types.js';
|
||||
|
||||
export type PlayerProfile = {
|
||||
user: {
|
||||
name: string;
|
||||
username: string;
|
||||
avatarUrl: string;
|
||||
} | null;
|
||||
avatar: WorldAvatar;
|
||||
};
|
||||
|
||||
export type PlayerState = {
|
||||
position: [number, number, number],
|
||||
rotation: [number, number, number],
|
||||
sit?: string; // id
|
||||
};
|
||||
|
||||
const DEFAULT_FACE_PARTS_EYES = {
|
||||
'_none_': null,
|
||||
'a': '/client-assets/world/avatars/eyes-a.png',
|
||||
'b': '/client-assets/world/avatars/eyes-b.png',
|
||||
'c': '/client-assets/world/avatars/eyes-c.png',
|
||||
'd': '/client-assets/world/avatars/eyes-d.png',
|
||||
'e': '/client-assets/world/avatars/eyes-e.png',
|
||||
'f': '/client-assets/world/avatars/eyes-f.png',
|
||||
'g': '/client-assets/world/avatars/eyes-g.png',
|
||||
};
|
||||
|
||||
const DEFAULT_FACE_PARTS_MOUTH = {
|
||||
'_none_': null,
|
||||
'a': '/client-assets/world/avatars/mouth-a.png',
|
||||
'b': '/client-assets/world/avatars/mouth-b.png',
|
||||
'c': '/client-assets/world/avatars/mouth-c.png',
|
||||
'd': '/client-assets/world/avatars/mouth-d.png',
|
||||
'e': '/client-assets/world/avatars/mouth-e.png',
|
||||
'f': '/client-assets/world/avatars/mouth-f.png',
|
||||
'g': '/client-assets/world/avatars/mouth-g.png',
|
||||
'h': '/client-assets/world/avatars/mouth-h.png',
|
||||
'i': '/client-assets/world/avatars/mouth-i.png',
|
||||
};
|
||||
|
||||
let usernameLabelMaterial: BABYLON.StandardMaterial | null = null;
|
||||
|
||||
export class PlayerContainer {
|
||||
public id: string;
|
||||
private profile: PlayerProfile;
|
||||
public root: BABYLON.TransformNode;
|
||||
private subRootContainerForAnim: BABYLON.TransformNode;
|
||||
private subRoot: BABYLON.TransformNode;
|
||||
private modelRoot: BABYLON.TransformNode | null = null;
|
||||
private sr: BABYLON.SnapshotRenderingHelper;
|
||||
private scene: BABYLON.Scene;
|
||||
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
|
||||
private animationObserver: BABYLON.Observer<BABYLON.Scene> | null = null;
|
||||
private accessoryContainers: AccessoryContainer[] = [];
|
||||
private timer: Timer = new Timer();
|
||||
private showUsername: boolean;
|
||||
private show2dAvatar: boolean;
|
||||
private usernameLabelMesh: BABYLON.Mesh | null = null;
|
||||
private twodAvatarMesh: BABYLON.Mesh | null = null;
|
||||
|
||||
constructor(params: { id: string; profile: PlayerProfile; state: PlayerState | null; sr: BABYLON.SnapshotRenderingHelper; scene: BABYLON.Scene; showUsername: boolean; show2dAvatar: boolean; }) {
|
||||
this.id = params.id;
|
||||
this.profile = params.profile;
|
||||
this.sr = params.sr;
|
||||
this.scene = params.scene;
|
||||
|
||||
this.root = new BABYLON.TransformNode(`player:${this.id}`, params.scene);
|
||||
this.root.rotationQuaternion = null;
|
||||
this.subRootContainerForAnim = new BABYLON.TransformNode(`player:${this.id}:subRootContainerForAnim`, params.scene);
|
||||
this.subRootContainerForAnim.parent = this.root;
|
||||
this.subRoot = new BABYLON.TransformNode(`player:${this.id}:subRoot`, params.scene);
|
||||
this.subRoot.parent = this.subRootContainerForAnim;
|
||||
|
||||
this.showUsername = params.showUsername;
|
||||
this.show2dAvatar = params.show2dAvatar;
|
||||
this.applyInfoMesh();
|
||||
|
||||
if (params.state) this.applyState(params.state, true);
|
||||
}
|
||||
|
||||
public async loadAvatar() {
|
||||
const filePath = '/client-assets/world/avatars/default.glb';
|
||||
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
|
||||
|
||||
// babylonによって自動で追加される右手系変換用ノード
|
||||
const modelRootMesh = loaderResult.meshes[0] as BABYLON.Mesh;
|
||||
|
||||
// meshじゃなくtransform nodeにしてパフォーマンス向上
|
||||
this.modelRoot = new BABYLON.TransformNode('__root__', this.scene);
|
||||
this.modelRoot.parent = this.subRoot;
|
||||
this.modelRoot.scaling.x = -1;
|
||||
this.modelRoot.scaling = this.modelRoot.scaling.scale(WORLD_SCALE);// cmをmに
|
||||
|
||||
for (const m of modelRootMesh.getChildren()) {
|
||||
if (m.parent === modelRootMesh) {
|
||||
m.parent = this.modelRoot;
|
||||
}
|
||||
}
|
||||
|
||||
modelRootMesh.dispose();
|
||||
|
||||
const eyesBlinkTexture = new BABYLON.Texture('/client-assets/world/avatars/eyes-blink.png', this.scene, false, false);
|
||||
eyesBlinkTexture.hasAlpha = true;
|
||||
|
||||
let eyesTex: BABYLON.Texture | null = null;
|
||||
if (this.profile.avatar.eyes.type in DEFAULT_FACE_PARTS_EYES) {
|
||||
const eyesTexPath = DEFAULT_FACE_PARTS_EYES[this.profile.avatar.eyes.type];
|
||||
if (eyesTexPath) {
|
||||
eyesTex = new BABYLON.Texture(eyesTexPath, this.scene, false, false);
|
||||
eyesTex.hasAlpha = true;
|
||||
}
|
||||
}
|
||||
|
||||
let mouthTex: BABYLON.Texture | null = null;
|
||||
if (this.profile.avatar.mouth.type in DEFAULT_FACE_PARTS_MOUTH) {
|
||||
const mouthTexPath = DEFAULT_FACE_PARTS_MOUTH[this.profile.avatar.mouth.type];
|
||||
if (mouthTexPath) {
|
||||
mouthTex = new BABYLON.Texture(mouthTexPath, this.scene, false, false);
|
||||
mouthTex.hasAlpha = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const mesh of this.modelRoot.getChildMeshes()) {
|
||||
if (mesh.name.includes('__BODY__')) {
|
||||
mesh.material.albedoColor = new BABYLON.Color3(this.profile.avatar.body.color[0], this.profile.avatar.body.color[1], this.profile.avatar.body.color[2]);
|
||||
}
|
||||
if (mesh.name.includes('__EYES__')) {
|
||||
const mat = new BABYLON.PBRMaterial('', this.scene);
|
||||
mat.albedoColor = new BABYLON.Color3(this.profile.avatar.eyes.color[0], this.profile.avatar.eyes.color[1], this.profile.avatar.eyes.color[2]);
|
||||
mat.albedoTexture = eyesTex;
|
||||
mat.roughness = 1;
|
||||
mat.metallic = 0;
|
||||
mesh.material = mat;
|
||||
|
||||
// TODO: SRを無効にせずに表現する方法を考える
|
||||
const blink = () => {
|
||||
if (mesh.isDisposed()) return;
|
||||
|
||||
this.sr.disableSnapshotRendering();
|
||||
mat.albedoTexture = eyesBlinkTexture;
|
||||
this.sr.enableSnapshotRendering();
|
||||
|
||||
this.timer.setTimeout(() => {
|
||||
this.sr.disableSnapshotRendering();
|
||||
mat.albedoTexture = eyesTex;
|
||||
this.sr.enableSnapshotRendering();
|
||||
|
||||
this.timer.setTimeout(() => {
|
||||
blink();
|
||||
}, Math.random() * 10000);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
this.timer.setTimeout(() => {
|
||||
blink();
|
||||
}, Math.random() * 10000);
|
||||
}
|
||||
if (mesh.name.includes('__MOUTH__')) {
|
||||
if (mouthTex != null) {
|
||||
const mat = new BABYLON.PBRMaterial('', this.scene);
|
||||
mat.albedoColor = new BABYLON.Color3(this.profile.avatar.mouth.color[0], this.profile.avatar.mouth.color[1], this.profile.avatar.mouth.color[2]);
|
||||
mat.albedoTexture = mouthTex;
|
||||
mat.roughness = 1;
|
||||
mat.metallic = 0;
|
||||
mesh.material = mat;
|
||||
} else {
|
||||
mesh.isVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.registerMeshes(this.modelRoot.getChildMeshes());
|
||||
|
||||
this.accessoryContainers = await Promise.all(this.profile.avatar.accessories.map(ac => this.loadAccessory({
|
||||
type: ac.type,
|
||||
id: ac.id,
|
||||
position: new BABYLON.Vector3(0, cm(19), 0),
|
||||
rotation: new BABYLON.Vector3(0, 0, 0),
|
||||
options: ac.options,
|
||||
})));
|
||||
|
||||
const anim = new BABYLON.Animation('', 'position.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
|
||||
anim.setKeys([
|
||||
{ frame: 0, value: cm(0) },
|
||||
{ frame: 30, value: cm(-2) },
|
||||
{ frame: 60, value: cm(0) },
|
||||
{ frame: 90, value: cm(2) },
|
||||
{ frame: 120, value: cm(0) },
|
||||
]);
|
||||
this.subRootContainerForAnim.animations = [anim];
|
||||
this.animationObserver = this.scene.onAfterAnimationsObservable.add(() => {
|
||||
this.sr.updateMesh(this.subRootContainerForAnim.getChildMeshes(), false);
|
||||
});
|
||||
this.scene.beginAnimation(this.subRootContainerForAnim, 0, 120, true);
|
||||
}
|
||||
|
||||
private async loadAccessory(args: {
|
||||
type: string;
|
||||
id: string;
|
||||
position: BABYLON.Vector3;
|
||||
rotation: BABYLON.Vector3;
|
||||
options: Record<string, unknown>;
|
||||
}) {
|
||||
const def = getAccessoryDef(args.type);
|
||||
|
||||
const container = new AccessoryContainer({
|
||||
id: args.id,
|
||||
type: args.type,
|
||||
position: args.position.clone(),
|
||||
rotation: args.rotation.clone(),
|
||||
options: args.options,
|
||||
sr: this.sr,
|
||||
getIsSrReady: () => true,
|
||||
lightContainer: this.lightContainer,
|
||||
graphicsQuality: this.graphicsQuality,
|
||||
scene: this.scene,
|
||||
});
|
||||
container.registerMeshes = (meshes) => {
|
||||
this.registerMeshes(meshes);
|
||||
};
|
||||
|
||||
await container.load();
|
||||
|
||||
container.root.parent = this.subRoot;
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
private applyInfoMesh() {
|
||||
if (this.showUsername ) {
|
||||
if (this.usernameLabelMesh == null) {
|
||||
if (usernameLabelMaterial == null) {
|
||||
const usernameLabelTex = new BABYLON.Texture('/client-assets/world/chars-black.png', this.scene, false, false);
|
||||
usernameLabelMaterial = new BABYLON.StandardMaterial('usernameLabelMaterial', this.scene);
|
||||
usernameLabelMaterial.roughness = 1;
|
||||
usernameLabelMaterial.diffuseColor = new BABYLON.Color3(1, 1, 1);
|
||||
usernameLabelMaterial.diffuseTexture = usernameLabelTex;
|
||||
usernameLabelMaterial.emissiveColor = new BABYLON.Color3(1, 1, 1);
|
||||
usernameLabelMaterial.emissiveTexture = usernameLabelTex;
|
||||
usernameLabelMaterial.disableLighting = true;
|
||||
}
|
||||
|
||||
this.usernameLabelMesh = createTextMesh(this.profile.user?.username ?? '(anonymous)', {
|
||||
size: cm(5),
|
||||
material: usernameLabelMaterial,
|
||||
});
|
||||
this.usernameLabelMesh.parent = this.subRoot;
|
||||
this.usernameLabelMesh.position.y = cm(40);
|
||||
this.usernameLabelMesh.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;
|
||||
this.scene.addMesh(this.usernameLabelMesh);
|
||||
}
|
||||
} else {
|
||||
if (this.usernameLabelMesh != null) {
|
||||
this.usernameLabelMesh.dispose();
|
||||
this.scene.removeMesh(this.usernameLabelMesh);
|
||||
this.usernameLabelMesh = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.show2dAvatar && this.profile.user?.avatarUrl != null) {
|
||||
if (this.twodAvatarMesh == null) {
|
||||
const twodAvatarTex = new BABYLON.Texture(this.profile.user.avatarUrl, this.scene, false, true);
|
||||
const twodAvatarMat = new BABYLON.StandardMaterial('twodAvatarMat', this.scene);
|
||||
twodAvatarMat.roughness = 1;
|
||||
twodAvatarMat.diffuseColor = new BABYLON.Color3(0.5, 0.5, 0.5);
|
||||
twodAvatarMat.diffuseTexture = twodAvatarTex;
|
||||
twodAvatarMat.emissiveColor = new BABYLON.Color3(0.5, 0.5, 0.5);
|
||||
twodAvatarMat.emissiveTexture = twodAvatarTex;
|
||||
twodAvatarMat.disableLighting = true;
|
||||
twodAvatarMat.backFaceCulling = false;
|
||||
|
||||
this.twodAvatarMesh = BABYLON.MeshBuilder.CreatePlane('twodAvatar', { size: cm(10) }, this.scene);
|
||||
this.twodAvatarMesh.material = twodAvatarMat;
|
||||
this.twodAvatarMesh.parent = this.subRoot;
|
||||
this.twodAvatarMesh.position.y = cm(40) + cm(7.5);
|
||||
this.twodAvatarMesh.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;
|
||||
this.scene.addMesh(this.twodAvatarMesh);
|
||||
}
|
||||
} else {
|
||||
if (this.twodAvatarMesh != null) {
|
||||
this.twodAvatarMesh.dispose(false, true);
|
||||
this.scene.removeMesh(this.twodAvatarMesh);
|
||||
this.twodAvatarMesh = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public updateUserInfoDisplayOptions(options: { showUsername: boolean; show2dAvatar: boolean; }) {
|
||||
this.showUsername = options.showUsername;
|
||||
this.show2dAvatar = options.show2dAvatar;
|
||||
this.applyInfoMesh();
|
||||
}
|
||||
|
||||
public applyState(state: PlayerState, forInit = false) {
|
||||
this.root.position.set(...state.position);
|
||||
this.subRoot.rotation.set(...state.rotation);
|
||||
if (!forInit) {
|
||||
const meshes = this.root.getChildMeshes();
|
||||
if (meshes.length > 0) this.sr.updateMesh(meshes);
|
||||
}
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.timer.dispose();
|
||||
if (this.animationObserver != null) {
|
||||
this.scene.onAfterAnimationsObservable.remove(this.animationObserver);
|
||||
}
|
||||
for (const ac of this.accessoryContainers) {
|
||||
ac.destroy();
|
||||
}
|
||||
this.accessoryContainers = [];
|
||||
if (this.usernameLabelMesh != null) {
|
||||
this.usernameLabelMesh.dispose();
|
||||
this.scene.removeMesh(this.usernameLabelMesh);
|
||||
this.usernameLabelMesh = null;
|
||||
}
|
||||
if (this.twodAvatarMesh != null) {
|
||||
this.twodAvatarMesh.dispose(false, true);
|
||||
this.scene.removeMesh(this.twodAvatarMesh);
|
||||
this.twodAvatarMesh = null;
|
||||
}
|
||||
this.root.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic.js';
|
||||
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
|
||||
import { ArcRotateCameraManualInput, getMeshesBoundingBox, GRAPHICS_QUALITY } from './utility.js';
|
||||
import { PlayerContainer, type PlayerProfile } from './PlayerContainer.js';
|
||||
import { EngineBase } from './EngineBase.js';
|
||||
import { deepClone } from './clone.js';
|
||||
import type { WorldAvatar } from 'misskey-world/src/types.js';
|
||||
|
||||
export class AvatarPreviewEngine extends EngineBase<{ // PlayerPreviewEngineに改名した方がいいかもしれない
|
||||
'loadingProgress': (ctx: { progress: number }) => void;
|
||||
'contextlost': (ctx: { reason: string; message: string; }) => void;
|
||||
}> {
|
||||
private sr: BABYLON.SnapshotRenderingHelper;
|
||||
private shadowGenerator: BABYLON.ShadowGenerator;
|
||||
private camera: BABYLON.ArcRotateCamera;
|
||||
private avatarOptions: WorldAvatar | null = null;
|
||||
private playerContainer: PlayerContainer | null = null;
|
||||
private envMapIndoor: BABYLON.CubeTexture;
|
||||
private roomLight: BABYLON.SpotLight;
|
||||
private pipeline: BABYLON.DefaultRenderingPipeline;
|
||||
private graphicsQuality: number;
|
||||
private profile: PlayerProfile;
|
||||
|
||||
constructor(profile: PlayerProfile, options: {
|
||||
engine: BABYLON.WebGPUEngine;
|
||||
graphicsQuality: number;
|
||||
fps: number | null;
|
||||
}) {
|
||||
super({
|
||||
engine: options.engine,
|
||||
fps: options.fps,
|
||||
});
|
||||
|
||||
registerBuiltInLoaders();
|
||||
|
||||
this.graphicsQuality = options.graphicsQuality;
|
||||
this.profile = deepClone(profile);
|
||||
|
||||
this.scene.autoClear = false;
|
||||
this.scene.skipPointerMovePicking = true;
|
||||
this.scene.skipFrustumClipping = true; // snapshot renderingでは全てのメッシュがアクティブになっている必要があるため
|
||||
this.scene.clearColor = new BABYLON.Color4(0.01, 0.01, 0.01, 1);
|
||||
|
||||
this.sr = new BABYLON.SnapshotRenderingHelper(this.scene);
|
||||
|
||||
this.camera = new BABYLON.ArcRotateCamera('camera', Math.PI / 2, Math.PI / 2.5, cm(300), new BABYLON.Vector3(0, cm(90), 0), this.scene);
|
||||
this.camera.minZ = cm(1);
|
||||
this.camera.maxZ = cm(100000);
|
||||
this.camera.fov = 0.5;
|
||||
this.camera.lowerRadiusLimit = cm(50);
|
||||
this.camera.upperRadiusLimit = cm(1000);
|
||||
this.camera.inputs.clear();
|
||||
this.camera.inputs.add(new ArcRotateCameraManualInput(this.scene, {
|
||||
rotationSensitivity: 0.0005,
|
||||
}));
|
||||
|
||||
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.scene);
|
||||
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(500), cm(500), cm(500));
|
||||
this.envMapIndoor.level = 0.6;
|
||||
|
||||
this.roomLight = new BABYLON.SpotLight('roomLight', new BABYLON.Vector3(cm(50), cm(249), cm(50)), new BABYLON.Vector3(0, -1, 0), 16, 8, this.scene);
|
||||
this.roomLight.diffuse = new BABYLON.Color3(1.0, 0.9, 0.8);
|
||||
this.roomLight.shadowMinZ = cm(10);
|
||||
this.roomLight.shadowMaxZ = cm(500);
|
||||
this.roomLight.radius = cm(30);
|
||||
this.roomLight.intensity = 15 * WORLD_SCALE * WORLD_SCALE;
|
||||
|
||||
this.shadowGenerator = new BABYLON.ShadowGenerator(2048, this.roomLight);
|
||||
this.shadowGenerator.forceBackFacesOnly = true;
|
||||
this.shadowGenerator.bias = 0.0001;
|
||||
this.shadowGenerator.usePercentageCloserFiltering = true;
|
||||
this.shadowGenerator.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
|
||||
this.shadowGenerator.getShadowMap().refreshRate = 60;
|
||||
|
||||
const gl = new BABYLON.GlowLayer('glow', this.scene, {
|
||||
blurKernelSize: 64,
|
||||
});
|
||||
gl.intensity = 0.5;
|
||||
this.scene.setRenderingAutoClearDepthStencil(gl.renderingGroupId, false);
|
||||
this.sr.updateMeshesForEffectLayer(gl);
|
||||
|
||||
this.pipeline = new BABYLON.DefaultRenderingPipeline('default', true, this.scene);
|
||||
this.pipeline.samples = 4;
|
||||
if (this.graphicsQuality >= GRAPHICS_QUALITY.HIGH) {
|
||||
this.pipeline.bloomEnabled = true;
|
||||
this.pipeline.bloomThreshold = 0.95;
|
||||
this.pipeline.bloomWeight = 0.1;
|
||||
this.pipeline.bloomKernel = 256;
|
||||
this.pipeline.bloomScale = 2;
|
||||
}
|
||||
this.pipeline.sharpenEnabled = true;
|
||||
this.pipeline.sharpen.edgeAmount = 0.5;
|
||||
}
|
||||
|
||||
public async init() {
|
||||
this.startRenderLoop();
|
||||
|
||||
await this.scene.whenReadyAsync();
|
||||
this.sr.enableSnapshotRendering();
|
||||
|
||||
this.inputs.on('wheel', (ev) => {
|
||||
this.camera.fov += ev.deltaY * 0.0005;
|
||||
this.camera.fov = Math.max(0.25, Math.min(0.5, this.camera.fov));
|
||||
});
|
||||
|
||||
this.inputs.on('zoom', (ev) => {
|
||||
this.camera.fov += -ev.delta * 0.0015;
|
||||
this.camera.fov = Math.max(0.25, Math.min(0.5, this.camera.fov));
|
||||
});
|
||||
|
||||
this.inputs.on('pointer', (ev) => {
|
||||
(this.camera.inputs.attached.manual as ArcRotateCameraManualInput).setRotationVector({ x: ev.x, y: ev.y });
|
||||
});
|
||||
|
||||
await this.load();
|
||||
}
|
||||
|
||||
private async load() {
|
||||
this.sr.disableSnapshotRendering();
|
||||
|
||||
this.playerContainer = new PlayerContainer({
|
||||
id: '',
|
||||
profile: this.profile,
|
||||
state: {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
},
|
||||
sr: this.sr,
|
||||
scene: this.scene,
|
||||
showUsername: false,
|
||||
show2dAvatar: false,
|
||||
});
|
||||
this.playerContainer.registerMeshes = (meshes) => {
|
||||
for (const mesh of meshes) {
|
||||
mesh.receiveShadows = true;
|
||||
this.shadowGenerator.addShadowCaster(mesh);
|
||||
|
||||
if (mesh.material) {
|
||||
if (mesh.material instanceof BABYLON.MultiMaterial) {
|
||||
for (const subMat of mesh.material.subMaterials) {
|
||||
(subMat as BABYLON.PBRMaterial).reflectionTexture = this.envMapIndoor;
|
||||
(subMat as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
|
||||
(subMat as BABYLON.PBRMaterial).anisotropy.isEnabled = false; // なんかきれいにレンダリングされないため
|
||||
}
|
||||
} else {
|
||||
(mesh.material as BABYLON.PBRMaterial).reflectionTexture = this.envMapIndoor;
|
||||
(mesh.material as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
|
||||
(mesh.material as BABYLON.PBRMaterial).anisotropy.isEnabled = false; // なんかきれいにレンダリングされないため
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.scene.meshes.includes(mesh)) this.scene.addMesh(mesh);
|
||||
}
|
||||
};
|
||||
|
||||
await this.playerContainer.loadAvatar();
|
||||
|
||||
const boundingInfo = getMeshesBoundingBox(this.playerContainer.root.getChildMeshes().filter(m => m.isEnabled() && m.isVisible), true);
|
||||
|
||||
this.camera.setTarget(new BABYLON.Vector3(0, boundingInfo.centerWorld.y, 0));
|
||||
|
||||
// zoom to fit
|
||||
const size = boundingInfo.extendSize;
|
||||
const distance = Math.max(size.x, size.y, size.z) * 2;
|
||||
this.camera.radius = distance * 5;
|
||||
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public clearPlayer() {
|
||||
this.sr.disableSnapshotRendering();
|
||||
if (this.playerContainer != null) {
|
||||
this.playerContainer.destroy();
|
||||
this.playerContainer = null;
|
||||
}
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public async updateAvatar(value: WorldAvatar) {
|
||||
this.profile.avatar = value;
|
||||
this.clearPlayer();
|
||||
await this.load();
|
||||
}
|
||||
|
||||
public resize() {
|
||||
// 一旦snapshot renderingを無効にしておかないとエラーが出る(babylonのバグ?)
|
||||
// ~~...が、一旦無効にしたらしたで複数のマテリアルがそれぞれ入れ替わる(?)という謎の現象が発生するためコメントアウトしとく(エラー出てもレンダリングが止まったりするわけでもないし)~~
|
||||
// ↑追記: engine.resizeした後に一瞬待つことで回避できることが判明
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.engine.resize(true);
|
||||
// workerで実行される可能性がある
|
||||
|
||||
setTimeout(() => {
|
||||
this.sr.enableSnapshotRendering();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
super.destroy();
|
||||
this.playerContainer?.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { AvatarPreviewEngine } from './avatarPreviewEngine.js';
|
||||
import { registerBabylonRuntime } from './babylonRuntime.js';
|
||||
import type { PlayerProfile } from './PlayerContainer.js';
|
||||
|
||||
registerBabylonRuntime();
|
||||
|
||||
export async function createAvatarPreviewEngine(params: {
|
||||
canvas: HTMLCanvasElement; options: { graphicsQuality: number; resolution: number; fps: number | null }; profile: PlayerProfile;
|
||||
}) {
|
||||
const babylonEngine = new BABYLON.WebGPUEngine(params.canvas, { doNotHandleContextLost: true, powerPreference: 'low-power', antialias: true });
|
||||
babylonEngine.compatibilityMode = false;
|
||||
babylonEngine.enableOfflineSupport = false;
|
||||
await babylonEngine.initAsync();
|
||||
if (params.options.resolution === 2) babylonEngine.setHardwareScalingLevel(0.5);
|
||||
if (params.options.resolution === 0.5) babylonEngine.setHardwareScalingLevel(2);
|
||||
|
||||
const engine = new AvatarPreviewEngine(params.profile, {
|
||||
engine: babylonEngine,
|
||||
...params.options,
|
||||
});
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { AvatarPreviewEngine } from './avatarPreviewEngine.js';
|
||||
import { registerBabylonRuntime } from './babylonRuntime.js';
|
||||
import type { PlayerProfile } from './PlayerContainer.js';
|
||||
|
||||
registerBabylonRuntime();
|
||||
|
||||
let engine: AvatarPreviewEngine | null = null;
|
||||
let canvas: OffscreenCanvas | null = null;
|
||||
|
||||
// TODO: 他のWorkerと実装を共通化
|
||||
onmessage = async (event) => {
|
||||
//console.log('Worker received message:', event.data);
|
||||
|
||||
switch (event.data?.type) {
|
||||
case 'init': {
|
||||
const profile = event.data.profile as PlayerProfile;
|
||||
canvas = event.data.canvas as OffscreenCanvas;
|
||||
const babylonEngine = new BABYLON.WebGPUEngine(canvas, { doNotHandleContextLost: true, powerPreference: 'low-power', antialias: true });
|
||||
babylonEngine.compatibilityMode = false;
|
||||
babylonEngine.enableOfflineSupport = false;
|
||||
await babylonEngine.initAsync();
|
||||
if (event.data.options.resolution === 2) babylonEngine.setHardwareScalingLevel(0.5);
|
||||
if (event.data.options.resolution === 0.5) babylonEngine.setHardwareScalingLevel(2);
|
||||
|
||||
engine = new AvatarPreviewEngine(profile, {
|
||||
engine: babylonEngine,
|
||||
...event.data.options,
|
||||
});
|
||||
|
||||
engine.on('ev', ({ type, ctx }) => {
|
||||
self.postMessage({ type: 'ev', ev: { type, ctx } });
|
||||
});
|
||||
|
||||
await engine.init();
|
||||
|
||||
self.postMessage({ type: 'inited' });
|
||||
break;
|
||||
}
|
||||
case 'resize': {
|
||||
canvas.width = event.data.width;
|
||||
canvas.height = event.data.height;
|
||||
if (engine != null) engine.resize();
|
||||
break;
|
||||
}
|
||||
case 'input:keydown': {
|
||||
if (engine == null) break;
|
||||
engine.inputs.emit('keydown', event.data.ev);
|
||||
break;
|
||||
}
|
||||
case 'input:keyup': {
|
||||
if (engine == null) break;
|
||||
engine.inputs.emit('keyup', event.data.ev);
|
||||
break;
|
||||
}
|
||||
case 'input:click': {
|
||||
if (engine == null) break;
|
||||
engine.inputs.emit('click', event.data.ev);
|
||||
break;
|
||||
}
|
||||
case 'input:wheel': {
|
||||
if (engine == null) break;
|
||||
engine.inputs.emit('wheel', event.data.ev);
|
||||
break;
|
||||
}
|
||||
case 'input:zoom': {
|
||||
if (engine == null) break;
|
||||
engine.inputs.emit('zoom', event.data.ev);
|
||||
break;
|
||||
}
|
||||
case 'input:pointer': {
|
||||
if (engine == null) break;
|
||||
engine.inputs.emit('pointer', event.data.ev);
|
||||
break;
|
||||
}
|
||||
case 'call': {
|
||||
if (engine == null) {
|
||||
console.error('Failed to call: Engine is not initialized yet!!!');
|
||||
break;
|
||||
}
|
||||
const res = engine[event.data.fn](...(event.data.args ?? []));
|
||||
if (event.data.needReturnValue) {
|
||||
if (res instanceof Promise) {
|
||||
res.then((r) => {
|
||||
self.postMessage({ type: 'return', id: event.data.id, value: r });
|
||||
});
|
||||
} else {
|
||||
self.postMessage({ type: 'return', id: event.data.id, value: res });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'set': {
|
||||
if (engine == null) {
|
||||
console.error('Failed to set: Engine is not initialized yet!!!');
|
||||
break;
|
||||
}
|
||||
engine[event.data.key] = event.data.value;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('Unrecognized message type:', event.data?.type);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { camelToKebab, WORLD_SCALE } from 'misskey-world/src/utility.js';
|
||||
import { ModelExplorer, scaleMorph, Timer } from '../utility.js';
|
||||
import { convertRawOptions, type ConvertedOptions, type RawOptions } from '../mono.js';
|
||||
import { getAccessoryDef } from './accessory-defs.js';
|
||||
import type { AvatarAccessoryInstance } from './accessory.js';
|
||||
|
||||
export class AccessoryContainer {
|
||||
public id: string;
|
||||
public type: string;
|
||||
private options: ConvertedOptions;
|
||||
public root: BABYLON.TransformNode;
|
||||
private subRoot: BABYLON.TransformNode | null = null;
|
||||
public instance: AvatarAccessoryInstance | null = null;
|
||||
public model: ModelExplorer | null = null;
|
||||
private scene: BABYLON.Scene;
|
||||
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
|
||||
private sr: BABYLON.SnapshotRenderingHelper;
|
||||
private getIsSrReady: () => boolean;
|
||||
private lightContainer: BABYLON.ClusteredLightContainer;
|
||||
private graphicsQuality: number;
|
||||
private timer: Timer = new Timer();
|
||||
|
||||
constructor(args: {
|
||||
id: string;
|
||||
type: string;
|
||||
options: RawOptions;
|
||||
position: BABYLON.Vector3;
|
||||
rotation: BABYLON.Vector3;
|
||||
sr: BABYLON.SnapshotRenderingHelper;
|
||||
getIsSrReady: () => boolean;
|
||||
lightContainer: BABYLON.ClusteredLightContainer;
|
||||
scene: BABYLON.Scene;
|
||||
graphicsQuality: number;
|
||||
}) {
|
||||
this.id = args.id;
|
||||
this.type = args.type;
|
||||
const def = getAccessoryDef(this.type);
|
||||
this.options = convertRawOptions(def.options.schema, args.options, { files: [] });
|
||||
this.sr = args.sr;
|
||||
this.getIsSrReady = args.getIsSrReady;
|
||||
this.lightContainer = args.lightContainer;
|
||||
this.scene = args.scene;
|
||||
this.graphicsQuality = args.graphicsQuality;
|
||||
this.root = new BABYLON.TransformNode(`accessory_${args.id}_${args.type}`, this.scene);
|
||||
this.root.position = args.position;
|
||||
this.root.rotation = args.rotation;
|
||||
}
|
||||
|
||||
public async load() {
|
||||
const def = getAccessoryDef(this.type);
|
||||
|
||||
const filePath = def.path != null ? `/client-assets/world/objects/${def.path(this.options)}.glb` : `/client-assets/world/objects/${camelToKebab(this.type)}/${camelToKebab(this.type)}.glb`;
|
||||
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
|
||||
|
||||
// babylonによって自動で追加される右手系変換用ノード
|
||||
const subRootMesh = loaderResult.meshes[0] as BABYLON.Mesh;
|
||||
|
||||
// meshじゃなくtransform nodeにしてパフォーマンス向上
|
||||
this.subRoot = new BABYLON.TransformNode('__root__', this.scene);
|
||||
this.subRoot.parent = this.root;
|
||||
this.subRoot.scaling.x = -1;
|
||||
this.subRoot.scaling = this.subRoot.scaling.scale(WORLD_SCALE);// cmをmに
|
||||
|
||||
for (const m of subRootMesh.getChildren()) {
|
||||
if (m.parent === subRootMesh) {
|
||||
m.parent = this.subRoot;
|
||||
}
|
||||
}
|
||||
|
||||
subRootMesh.dispose();
|
||||
|
||||
this.registerMeshes(this.subRoot.getChildMeshes());
|
||||
|
||||
this.model = new ModelExplorer(this.subRoot);
|
||||
|
||||
this.instance = await def.createInstance({
|
||||
scene: this.scene,
|
||||
sr: {
|
||||
updateMesh: (mesh) => {
|
||||
if (!this.getIsSrReady()) return;
|
||||
this.sr.updateMesh(mesh);
|
||||
},
|
||||
reset: () => {
|
||||
if (!this.getIsSrReady()) return;
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.sr.enableSnapshotRendering();
|
||||
},
|
||||
fixParticleSystem: (ps) => this.sr.fixParticleSystem(ps),
|
||||
},
|
||||
lc: this.lightContainer,
|
||||
root: this.root,
|
||||
options: this.options,
|
||||
model: this.model!,
|
||||
timer: this.timer,
|
||||
graphicsQuality: this.graphicsQuality,
|
||||
reloadModel: () => {
|
||||
this.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async reload() {
|
||||
this.timer.dispose();
|
||||
this.instance?.dispose?.();
|
||||
this.instance = null;
|
||||
this.model = null;
|
||||
this.subRoot?.dispose();
|
||||
this.root.removeChild(this.subRoot);
|
||||
this.scene.removeTransformNode(this.subRoot);
|
||||
|
||||
this.timer = new Timer();
|
||||
|
||||
await this.load();
|
||||
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public optionsUpdated(options: Record<string, unknown>, key: string, value: any) {
|
||||
if (this.instance == null) return;
|
||||
this.options[key] = options[key]; // 参照を切れさせないようにプロパティ個別にmutate
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.instance.onOptionsUpdated?.([key, this.options[key]]);
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.timer.dispose();
|
||||
this.instance?.dispose?.();
|
||||
this.subRoot.dispose();
|
||||
this.root.dispose();
|
||||
this.scene.removeTransformNode(this.root);
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { bolt_schema } from 'misskey-world/src/avatars/accessories/bolt.schema.js';
|
||||
import { defineAccessory } from '../accessory.js';
|
||||
|
||||
export const bolt = defineAccessory(bolt_schema, {
|
||||
createInstance: ({ model, options }) => {
|
||||
const material = model.findMaterial('__X_BOLT__');
|
||||
|
||||
const applyMat = () => {
|
||||
material.albedoColor = new BABYLON.Color3(options.mat.color[0], options.mat.color[1], options.mat.color[2]);
|
||||
material.roughness = options.mat.roughness;
|
||||
material.metallic = options.mat.metallic;
|
||||
};
|
||||
|
||||
applyMat();
|
||||
|
||||
return {
|
||||
onOptionsUpdated: ([k, v]) => {
|
||||
switch (k) {
|
||||
case 'mat': applyMat(); break;
|
||||
}
|
||||
},
|
||||
dispose: () => {
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { mikan_schema } from 'misskey-world/src/avatars/accessories/mikan.schema.js';
|
||||
import { defineAccessory } from '../accessory.js';
|
||||
|
||||
export const mikan = defineAccessory(mikan_schema, {
|
||||
createInstance: ({ scene, root, sr }) => {
|
||||
return {
|
||||
dispose: () => {
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { cm } from 'misskey-world/src/utility.js';
|
||||
import { mug_schema } from 'misskey-world/src/avatars/accessories/mug.schema.js';
|
||||
import { defineAccessory } from '../accessory.js';
|
||||
|
||||
export const mug = defineAccessory(mug_schema, {
|
||||
createInstance: ({ options, scene, root, sr, model }) => {
|
||||
const emitter = new BABYLON.TransformNode('emitter', scene);
|
||||
emitter.parent = root;
|
||||
emitter.position = new BABYLON.Vector3(0, cm(5), 0);
|
||||
const ps = new BABYLON.ParticleSystem('steamParticleSystem', 8, scene);
|
||||
ps.particleTexture = new BABYLON.Texture('/client-assets/world/objects/mug/steam.png');
|
||||
ps.emitter = emitter;
|
||||
ps.minEmitBox = new BABYLON.Vector3(cm(-1), 0, cm(-1));
|
||||
ps.maxEmitBox = new BABYLON.Vector3(cm(1), 0, cm(1));
|
||||
ps.minEmitPower = cm(10);
|
||||
ps.maxEmitPower = cm(12);
|
||||
ps.minLifeTime = 2;
|
||||
ps.maxLifeTime = 3;
|
||||
ps.addSizeGradient(0, cm(10), cm(12));
|
||||
ps.addSizeGradient(1, cm(18), cm(20));
|
||||
ps.direction1 = new BABYLON.Vector3(-0.3, 1, 0.3);
|
||||
ps.direction2 = new BABYLON.Vector3(0.3, 1, -0.3);
|
||||
ps.emitRate = 0.5;
|
||||
ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;
|
||||
ps.color1 = new BABYLON.Color4(1, 1, 1, 0.3);
|
||||
ps.color2 = new BABYLON.Color4(1, 1, 1, 0.2);
|
||||
ps.colorDead = new BABYLON.Color4(1, 1, 1, 0);
|
||||
ps.preWarmCycles = Math.random() * 1000;
|
||||
ps.start();
|
||||
sr.fixParticleSystem(ps);
|
||||
|
||||
const bodyMaterial = model.findMaterial('__X_MUG__');
|
||||
|
||||
const applyBodyMat = () => {
|
||||
bodyMaterial.albedoColor = new BABYLON.Color3(options.bodyMat.color[0], options.bodyMat.color[1], options.bodyMat.color[2]);
|
||||
bodyMaterial.roughness = options.bodyMat.roughness;
|
||||
bodyMaterial.metallic = options.bodyMat.metallic;
|
||||
};
|
||||
|
||||
applyBodyMat();
|
||||
|
||||
const liquidMaterial = model.findMaterial('__X_LIQUID__');
|
||||
|
||||
const applyLiquidMat = () => {
|
||||
liquidMaterial.albedoColor = new BABYLON.Color3(options.liquidMat.color[0], options.liquidMat.color[1], options.liquidMat.color[2]);
|
||||
liquidMaterial.roughness = options.liquidMat.roughness;
|
||||
liquidMaterial.metallic = options.liquidMat.metallic;
|
||||
};
|
||||
|
||||
applyLiquidMat();
|
||||
|
||||
return {
|
||||
onOptionsUpdated: ([k, v]) => {
|
||||
switch (k) {
|
||||
case 'bodyMat': applyBodyMat(); break;
|
||||
case 'liquidMat': applyLiquidMat(); break;
|
||||
}
|
||||
},
|
||||
dispose: () => {
|
||||
ps.stop();
|
||||
emitter.dispose();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { mug } from './accessories/mug.js';
|
||||
import { mikan } from './accessories/mikan.js';
|
||||
import { bolt } from './accessories/bolt.js';
|
||||
import type { AvatarAccessoryDef } from './accessory.js';
|
||||
|
||||
export const AVATAR_ACCESSORY_DEFS = [
|
||||
mug,
|
||||
mikan,
|
||||
bolt,
|
||||
] as AvatarAccessoryDef[];
|
||||
|
||||
export function getAccessoryDef(type: string): AvatarAccessoryDef {
|
||||
const def = AVATAR_ACCESSORY_DEFS.find(x => x.id === type) as AvatarAccessoryDef | undefined;
|
||||
if (def == null) {
|
||||
throw new Error(`Unrecognized accessory type: ${type}`);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { ModelExplorer, type Timer } from '../utility.js';
|
||||
import type { AccessorySchemaDef } from 'misskey-world/src/avatars/accessory.js';
|
||||
import type { OptionsSchema } from 'misskey-world/src/mono.js';
|
||||
import type { ConvertedOptions, GetConvertedOptionsSchemaValues } from '../mono.js';
|
||||
|
||||
export type AvatarAccessoryInstance<Options = any> = {
|
||||
onOptionsUpdated?: <K extends keyof Options, V extends Options[K]>(kv: [K, V]) => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
export type SnapshotRenderingHelperWrapper = {
|
||||
updateMesh: (meshes: BABYLON.Mesh[]) => void;
|
||||
reset: () => void;
|
||||
fixParticleSystem: (ps: BABYLON.ParticleSystem) => void;
|
||||
};
|
||||
|
||||
export type AvatarAccessoryDef<Schema extends AccessorySchemaDef = AccessorySchemaDef> = Schema & {
|
||||
path?: (options: string extends keyof Schema['options']['schema'] ? ConvertedOptions : Readonly<GetConvertedOptionsSchemaValues<Schema['options']['schema']>>) => string;
|
||||
createInstance: (args: {
|
||||
scene: BABYLON.Scene;
|
||||
// TODO: snapshot renderingの関心を隠蔽した方が綺麗かもしれない
|
||||
// 例えばmaterialUpdatedというメソッドを用意して内部的にresetを呼ぶなど
|
||||
sr: SnapshotRenderingHelperWrapper;
|
||||
lc: BABYLON.ClusteredLightContainer | null;
|
||||
root: BABYLON.TransformNode;
|
||||
options: string extends keyof Schema['options']['schema'] ? ConvertedOptions : Readonly<GetConvertedOptionsSchemaValues<Schema['options']['schema']>>;
|
||||
model: ModelExplorer;
|
||||
timer: Timer;
|
||||
graphicsQuality: number;
|
||||
reloadModel: () => void;
|
||||
}) => AvatarAccessoryInstance<string extends keyof Schema['options']['schema'] ? ConvertedOptions : GetConvertedOptionsSchemaValues<Schema['options']['schema']>> | Promise<AvatarAccessoryInstance<Schema['options']['schema'] extends undefined ? ConvertedOptions : GetConvertedOptionsSchemaValues<Schema['options']['schema']>>>; // TODO: createInstanceをasyncにするのではなく、別にreadyみたいなものを返させる
|
||||
};
|
||||
|
||||
export function defineAccessorySchema<const OpSc extends OptionsSchema>(def: AccessorySchemaDef<OpSc>): AccessorySchemaDef<OpSc> {
|
||||
return def;
|
||||
}
|
||||
|
||||
export function defineAccessory<const Schema extends AccessorySchemaDef<any>>(schema: Schema, def: Pick<AvatarAccessoryDef<Schema>, 'path' | 'createInstance'>): AvatarAccessoryDef<Schema> {
|
||||
return { ...schema, ...def };
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
//import '@babylonjs/core';
|
||||
|
||||
export function registerBabylonRuntime(): void {
|
||||
BABYLON.RegisterStandardEngineExtensions();
|
||||
BABYLON.RegisterStandardWebGPUEngineExtensions();
|
||||
BABYLON.RegisterAbstractEngineAlpha();
|
||||
BABYLON.RegisterAbstractEngineTexture();
|
||||
BABYLON.RegisterAbstractEngineCubeTexture();
|
||||
BABYLON.RegisterAbstractEngineQuery();
|
||||
BABYLON.RegisterAbstractEngineTextureSelector();
|
||||
BABYLON.RegisterAbstractEngineTimeQuery();
|
||||
BABYLON.RegisterAbstractEngineViews();
|
||||
BABYLON.RegisterEnginesWebGPUExtensionsEngineRawTexture();
|
||||
BABYLON.RegisterEnginesWebGPUExtensionsEngineReadTexture();
|
||||
BABYLON.RegisterEnginesWebGPUExtensionsEngineCubeTexture();
|
||||
BABYLON.RegisterEnginesWebGPUExtensionsEngineRenderTargetCube();
|
||||
BABYLON.RegisterEnginesWebGPUExtensionsEngineQuery();
|
||||
BABYLON.RegisterEnginesWebGPUExtensionsEngineDynamicTexture();
|
||||
BABYLON.RegisterBufferAlign();
|
||||
BABYLON.RegisterCubeTexture();
|
||||
BABYLON.RegisterStandardMaterial();
|
||||
BABYLON.RegisterRay();
|
||||
BABYLON.RegisterAnimation();
|
||||
BABYLON.RegisterAnimatable();
|
||||
BABYLON.RegisterCollisionCoordinator();
|
||||
BABYLON.RegisterPostProcessRenderPipelineManagerSceneComponent(
|
||||
BABYLON.PostProcessRenderPipelineManager,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// structredCloneが遅いため
|
||||
// SEE: http://var.blog.jp/archives/86038606.html
|
||||
// あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった
|
||||
// https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045
|
||||
|
||||
export type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | { [key: number]: Cloneable } | { [key: symbol]: Cloneable } | Cloneable[];
|
||||
|
||||
export function deepClone<T extends Cloneable>(x: T): T {
|
||||
if (typeof x === 'object') {
|
||||
if (x === null) return x;
|
||||
if (Array.isArray(x)) return x.map(deepClone) as T;
|
||||
const obj = {} as Record<string | number | symbol, Cloneable>;
|
||||
for (const [k, v] of Object.entries(x)) {
|
||||
obj[k] = v === undefined ? undefined : deepClone(v);
|
||||
}
|
||||
return obj as T;
|
||||
} else {
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,597 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic.js';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import Hls from 'hls.js';
|
||||
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
|
||||
import { RecyvlingTextGrid, Timer, createPlaneUvMapper, randomRange } from './utility.js';
|
||||
import { TIME_MAP } from './utility.js';
|
||||
import { EngineBase } from './EngineBase.js';
|
||||
|
||||
const SNAPSHOT_RENDERING = false; // 実験的
|
||||
const USE_GLOW = true; // ドローコールが増えて重い
|
||||
const IN_WEB_WORKER = typeof window === 'undefined';
|
||||
|
||||
export class WorldEngine extends EngineBase<{
|
||||
'playSfxUrl': (ctx: {
|
||||
url: string;
|
||||
options: {
|
||||
volume: number;
|
||||
playbackRate: number;
|
||||
};
|
||||
}) => void;
|
||||
'loadingProgress': (ctx: { progress: number }) => void;
|
||||
}> {
|
||||
private shadowGeneratorForSunLight: BABYLON.ShadowGenerator;
|
||||
public camera: BABYLON.UniversalCamera;
|
||||
private time: 0 | 1 | 2 = 0; // 0: 昼, 1: 夕, 2: 夜
|
||||
private envMap: BABYLON.CubeTexture;
|
||||
public lightContainer: BABYLON.ClusteredLightContainer;
|
||||
public sr: BABYLON.SnapshotRenderingHelper;
|
||||
private gl: BABYLON.GlowLayer | null = null;
|
||||
private textMaterial: BABYLON.StandardMaterial;
|
||||
private translucentTextMaterial: BABYLON.StandardMaterial;
|
||||
private reflectionProbe: BABYLON.ReflectionProbe;
|
||||
public timer: Timer = new Timer();
|
||||
public isSitting = false;
|
||||
|
||||
constructor(options: {
|
||||
canvas: HTMLCanvasElement;
|
||||
engine: BABYLON.WebGPUEngine;
|
||||
}) {
|
||||
super({
|
||||
engine: options.engine,
|
||||
fps: null,
|
||||
});
|
||||
|
||||
registerBuiltInLoaders();
|
||||
|
||||
this.scene.autoClear = false;
|
||||
//this.scene.autoClearDepthAndStencil = false;
|
||||
this.scene.skipPointerMovePicking = true;
|
||||
this.scene.skipFrustumClipping = true; // snapshot renderingでは全てのメッシュがアクティブになっている必要があるため
|
||||
this.scene.gravity = new BABYLON.Vector3(0, -0.1, 0).scale(WORLD_SCALE);
|
||||
|
||||
this.sr = new BABYLON.SnapshotRenderingHelper(this.scene);
|
||||
|
||||
const skybox = BABYLON.MeshBuilder.CreateBox('skybox', { size: cm(50000) }, this.scene);
|
||||
const skyboxMat = new BABYLON.StandardMaterial('skyboxMat', this.scene);
|
||||
skyboxMat.backFaceCulling = false;
|
||||
skyboxMat.disableLighting = true;
|
||||
skybox.material = skyboxMat;
|
||||
skybox.infiniteDistance = true;
|
||||
|
||||
this.time = TIME_MAP[new Date().getHours() as keyof typeof TIME_MAP];
|
||||
//this.time = TIME_MAP[12 as keyof typeof TIME_MAP];
|
||||
|
||||
if (this.time === 0) {
|
||||
skyboxMat.emissiveColor = new BABYLON.Color3(1, 1, 1);
|
||||
} else if (this.time === 1) {
|
||||
skyboxMat.emissiveColor = new BABYLON.Color3(0.7, 0.68, 0.66);
|
||||
} else {
|
||||
skyboxMat.emissiveColor = new BABYLON.Color3(0.48, 0.5, 0.6);
|
||||
}
|
||||
this.scene.ambientColor = new BABYLON.Color3(0.9, 0.9, 0.9);
|
||||
|
||||
this.envMap = BABYLON.CubeTexture.CreateFromPrefilteredData(this.time === 2 ? '/client-assets/room/outdoor-night.env' : '/client-assets/room/outdoor-day.env', this.scene);
|
||||
//this.envMap.level = 1;
|
||||
this.envMap.level = 0;
|
||||
|
||||
this.scene.collisionsEnabled = true;
|
||||
|
||||
this.camera = new BABYLON.UniversalCamera('camera', new BABYLON.Vector3(cm(0), cm(250), cm(3000)), this.scene);
|
||||
this.camera.attachControl(this.canvas);
|
||||
this.camera.minZ = cm(1);
|
||||
this.camera.maxZ = cm(100000);
|
||||
this.camera.fov = 1;
|
||||
this.camera.ellipsoid = new BABYLON.Vector3(cm(15), cm(65), cm(15));
|
||||
this.camera.checkCollisions = true;
|
||||
this.camera.applyGravity = true;
|
||||
this.camera.needMoveForGravity = true;
|
||||
this.camera.keysUp.push(87); // W
|
||||
this.camera.keysDown.push(83); // S
|
||||
this.camera.keysLeft.push(65); // A
|
||||
this.camera.keysRight.push(68); // D
|
||||
const normalSpeed = 0.03 * WORLD_SCALE;
|
||||
this.camera.speed = normalSpeed;
|
||||
this.scene.onKeyboardObservable.add((kbInfo) => {
|
||||
switch (kbInfo.type) {
|
||||
case BABYLON.KeyboardEventTypes.KEYDOWN:
|
||||
if (kbInfo.event.key === 'Shift') {
|
||||
this.camera.speed = normalSpeed * 4;
|
||||
}
|
||||
break;
|
||||
case BABYLON.KeyboardEventTypes.KEYUP:
|
||||
if (kbInfo.event.key === 'Shift') {
|
||||
this.camera.speed = normalSpeed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
//this.scene.activeCamera = this.camera;
|
||||
|
||||
//this.reflectionProbe = new BABYLON.ReflectionProbe('rp', 512, this.scene, true, true, false);
|
||||
//this.reflectionProbe.refreshRate = 60;
|
||||
|
||||
//const mainLight = new BABYLON.PointLight('mainLight', new BABYLON.Vector3(0, cm(300), 0), this.scene);
|
||||
//mainLight.intensity = 10000000;
|
||||
//mainLight.radius = cm(1000);
|
||||
//mainLight.diffuse = new BABYLON.Color3(1, 1, 1);
|
||||
|
||||
const ambientLight1 = new BABYLON.HemisphericLight('ambientLight1', new BABYLON.Vector3(0, 1, 0), this.scene);
|
||||
ambientLight1.diffuse = new BABYLON.Color3(1.0, 0.9, 0.8);
|
||||
ambientLight1.intensity = 1;
|
||||
const ambientLight2 = new BABYLON.HemisphericLight('ambientLight2', new BABYLON.Vector3(0, -1, 0), this.scene);
|
||||
ambientLight2.diffuse = new BABYLON.Color3(0.8, 0.9, 1.0);
|
||||
ambientLight2.intensity = 1;
|
||||
//ambientLight.intensity = 0;
|
||||
|
||||
const sunLight = new BABYLON.DirectionalLight('sunLight', new BABYLON.Vector3(0, -1, 0), this.scene);
|
||||
sunLight.position = new BABYLON.Vector3(cm(0), cm(10000), cm(0));
|
||||
sunLight.diffuse = this.time === 0 ? new BABYLON.Color3(1.0, 1.0, 1.0) : this.time === 1 ? new BABYLON.Color3(1.0, 0.8, 0.6) : new BABYLON.Color3(0.6, 0.8, 1.0);
|
||||
sunLight.intensity = this.time === 0 ? 2 : this.time === 1 ? 0.5 : 0.25;
|
||||
sunLight.shadowMinZ = cm(1000);
|
||||
sunLight.shadowMaxZ = cm(2000);
|
||||
|
||||
this.shadowGeneratorForSunLight = new BABYLON.ShadowGenerator(4096, sunLight);
|
||||
this.shadowGeneratorForSunLight.forceBackFacesOnly = true;
|
||||
this.shadowGeneratorForSunLight.bias = 0.0001;
|
||||
this.shadowGeneratorForSunLight.usePercentageCloserFiltering = true;
|
||||
this.shadowGeneratorForSunLight.usePoissonSampling = true;
|
||||
//this.shadowGeneratorForSunLight.getShadowMap().refreshRate = 60;
|
||||
|
||||
this.lightContainer = new BABYLON.ClusteredLightContainer('clustered', [], this.scene);
|
||||
|
||||
if (USE_GLOW) {
|
||||
this.gl = new BABYLON.GlowLayer('glow', this.scene, {
|
||||
//mainTextureFixedSize: 512,
|
||||
blurKernelSize: 64,
|
||||
});
|
||||
this.gl.intensity = 0.5;
|
||||
this.gl.addExcludedMesh(skybox);
|
||||
this.scene.setRenderingAutoClearDepthStencil(this.gl.renderingGroupId, false);
|
||||
|
||||
if (SNAPSHOT_RENDERING) {
|
||||
this.sr.updateMeshesForEffectLayer(this.gl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async init() {
|
||||
await this.loadEnvModel();
|
||||
|
||||
if (SNAPSHOT_RENDERING) {
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
this.inputs.on('keydown', (ev) => {
|
||||
});
|
||||
|
||||
this.inputs.on('wheel', (ev) => {
|
||||
this.camera.fov += ev.deltaY * 0.001;
|
||||
this.camera.fov = Math.max(0.25, Math.min(1, this.camera.fov));
|
||||
});
|
||||
|
||||
this.inputs.on('click', (ev) => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
private async loadEnvModel() {
|
||||
const envObj = await BABYLON.ImportMeshAsync('/client-assets/world/lobby/default.glb', this.scene);
|
||||
envObj.meshes[0].scaling = envObj.meshes[0].scaling.scale(WORLD_SCALE);
|
||||
envObj.meshes[0].bakeCurrentTransformIntoVertices();
|
||||
for (const mesh of envObj.meshes) {
|
||||
if (mesh.name === '__root__') continue;
|
||||
if (mesh.name.includes('__COLLISION__')) {
|
||||
mesh.checkCollisions = true;
|
||||
mesh.isVisible = false;
|
||||
}
|
||||
if (this.reflectionProbe != null) {
|
||||
if (mesh.material) (mesh.material as BABYLON.PBRMaterial).reflectionTexture = this.reflectionProbe.cubeTexture;
|
||||
if (mesh.material) (mesh.material as BABYLON.PBRMaterial).realTimeFiltering = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.textMaterial = new BABYLON.StandardMaterial('textMaterial', this.scene);
|
||||
this.textMaterial.diffuseTexture = new BABYLON.Texture('/client-assets/world/chars.png', this.scene, false, false);
|
||||
this.textMaterial.diffuseTexture.hasAlpha = true;
|
||||
this.textMaterial.disableLighting = true;
|
||||
this.textMaterial.transparencyMode = BABYLON.Material.MATERIAL_ALPHABLEND;
|
||||
this.textMaterial.useAlphaFromDiffuseTexture = true;
|
||||
this.textMaterial.freeze();
|
||||
|
||||
this.translucentTextMaterial = this.textMaterial.clone('translucentTextMaterial');
|
||||
this.translucentTextMaterial.alpha = 0.25;
|
||||
|
||||
{
|
||||
const objet = envObj.meshes.find(m => m.name.includes('__OBJET__'));
|
||||
objet.rotation = objet.rotationQuaternion.toEulerAngles();
|
||||
objet.rotationQuaternion = null;
|
||||
|
||||
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
|
||||
anim.setKeys([
|
||||
{ frame: 0, value: 0 },
|
||||
{ frame: 5000, value: -(Math.PI * 2) },
|
||||
]);
|
||||
objet.animations = [anim];
|
||||
this.scene.beginAnimation(objet, 0, 5000, true);
|
||||
}
|
||||
|
||||
{
|
||||
const ring = envObj.meshes.find(m => m.name.includes('__LED_RING__'));
|
||||
ring.rotation = ring.rotationQuaternion.toEulerAngles();
|
||||
ring.rotationQuaternion = null;
|
||||
|
||||
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
|
||||
anim.setKeys([
|
||||
{ frame: 0, value: 0 },
|
||||
{ frame: 5000, value: -(Math.PI * 2) },
|
||||
]);
|
||||
ring.animations = [anim];
|
||||
this.scene.beginAnimation(ring, 0, 5000, true);
|
||||
}
|
||||
|
||||
{
|
||||
const messageRingRoot = new BABYLON.TransformNode('', this.scene);
|
||||
const messageRing = envObj.meshes.find(m => m.name.includes('__MESSAGE_RING_OUTER_1__'));
|
||||
messageRing.parent = messageRingRoot;
|
||||
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
|
||||
messageRing.rotationQuaternion = null;
|
||||
const text = new RecyvlingTextGrid(messageRing, 256, {
|
||||
meshFlipped: true,
|
||||
material: this.textMaterial,
|
||||
});
|
||||
|
||||
text.write('Wellcome to Misskey World!');
|
||||
|
||||
//messageRingRoot.rotation.x = Math.PI / 4;
|
||||
|
||||
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
|
||||
anim.setKeys([
|
||||
{ frame: 0, value: 0 },
|
||||
{ frame: 10000, value: -(Math.PI * 2) },
|
||||
]);
|
||||
messageRing.animations = [anim];
|
||||
this.scene.beginAnimation(messageRing, 0, 10000, true);
|
||||
|
||||
const texts = [
|
||||
'Wellcome to Misskey World!',
|
||||
'Enjoy your stay!',
|
||||
'Feel free to look around!',
|
||||
'This is a virtual space for Misskey users!',
|
||||
//'You can chat, play games, and more!',
|
||||
//'Check out the bulletin board for announcements',
|
||||
'Have a nice day with Misskey!',
|
||||
'MAINTENANCE will begin at 9:00 A.M.',
|
||||
];
|
||||
|
||||
let currentTextIndex = 1;
|
||||
|
||||
this.timer.setInterval(() => {
|
||||
const textToShow = texts[currentTextIndex];
|
||||
currentTextIndex = (currentTextIndex + 1) % texts.length;
|
||||
text.writeWithAnimation(textToShow);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
{
|
||||
const messageRingRoot = new BABYLON.TransformNode('', this.scene);
|
||||
const messageRing = envObj.meshes.find(m => m.name.includes('__MESSAGE_RING_OUTER_2__'));
|
||||
messageRing.parent = messageRingRoot;
|
||||
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
|
||||
messageRing.rotationQuaternion = null;
|
||||
const text = new RecyvlingTextGrid(messageRing, 256, {
|
||||
meshFlipped: true,
|
||||
material: this.translucentTextMaterial,
|
||||
repeatSeparator: ' ',
|
||||
});
|
||||
|
||||
messageRingRoot.rotation.x = Math.PI / 2;
|
||||
|
||||
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
|
||||
anim.setKeys([
|
||||
{ frame: 0, value: 0 },
|
||||
{ frame: 10000, value: -(Math.PI * 2) },
|
||||
]);
|
||||
messageRing.animations = [anim];
|
||||
this.scene.beginAnimation(messageRing, 0, 10000, true);
|
||||
|
||||
this.timer.setInterval(() => {
|
||||
text.write(Date.now().toString());
|
||||
}, 10);
|
||||
}
|
||||
|
||||
{
|
||||
const messageRingRoot = new BABYLON.TransformNode('', this.scene);
|
||||
const messageRing = envObj.meshes.find(m => m.name.includes('__MESSAGE_RING_INNER_1__'));
|
||||
messageRing.parent = messageRingRoot;
|
||||
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
|
||||
messageRing.rotationQuaternion = null;
|
||||
const text = new RecyvlingTextGrid(messageRing, 64, {
|
||||
material: this.textMaterial,
|
||||
repeatSeparator: ' ',
|
||||
});
|
||||
|
||||
//messageRingRoot.rotation.x = Math.PI / 4;
|
||||
|
||||
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
|
||||
anim.setKeys([
|
||||
{ frame: 0, value: 0 },
|
||||
{ frame: 10000, value: (Math.PI * 2) },
|
||||
]);
|
||||
messageRing.animations = [anim];
|
||||
this.scene.beginAnimation(messageRing, 0, 10000, true);
|
||||
|
||||
this.timer.setInterval(() => {
|
||||
const now = new Date();
|
||||
const hours = now.getHours().toString().padStart(2, '0');
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = now.getSeconds().toString().padStart(2, '0');
|
||||
text.write(`${hours}:${minutes}:${seconds}`);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
{
|
||||
const messageRingRoot = new BABYLON.TransformNode('', this.scene);
|
||||
const messageRing = envObj.meshes.find(m => m.name.includes('__MESSAGE_RING_INNER_2__'));
|
||||
messageRing.parent = messageRingRoot;
|
||||
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
|
||||
messageRing.rotationQuaternion = null;
|
||||
const text = new RecyvlingTextGrid(messageRing, 64, {
|
||||
material: this.textMaterial,
|
||||
repeatSeparator: ' ',
|
||||
});
|
||||
|
||||
//messageRingRoot.rotation.x = Math.PI / 4;
|
||||
|
||||
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
|
||||
anim.setKeys([
|
||||
{ frame: 0, value: 0 },
|
||||
{ frame: 10000, value: -(Math.PI * 2) },
|
||||
]);
|
||||
messageRing.animations = [anim];
|
||||
this.scene.beginAnimation(messageRing, 0, 10000, true);
|
||||
|
||||
this.timer.setInterval(() => {
|
||||
const now = new Date();
|
||||
const years = now.getFullYear().toString();
|
||||
const months = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||
const days = now.getDate().toString().padStart(2, '0');
|
||||
text.write(`${years}/${months}/${days}`);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const sphereRoot = new BABYLON.TransformNode('', this.scene);
|
||||
sphereRoot.position = new BABYLON.Vector3(cm(0), cm(1000 + (100 * i)), cm(0));
|
||||
const rotation = Math.random() * Math.PI * 2;
|
||||
const sphere = BABYLON.MeshBuilder.CreateSphere('', { diameter: cm(randomRange(50, 300)), segments: 16 }, this.scene);
|
||||
sphere.parent = sphereRoot;
|
||||
sphere.position = new BABYLON.Vector3(cm(0), cm(0), cm(randomRange(2000, 7000)));
|
||||
|
||||
const mat = new BABYLON.PBRMaterial('', this.scene);
|
||||
const color = tinycolor({ h: Math.random() * 360, s: 1, l: 0.5 }).toRgb();
|
||||
mat.emissiveColor = new BABYLON.Color3(color.r / 255, color.g / 255, color.b / 255);
|
||||
mat.disableLighting = true;
|
||||
this.gl?.addExcludedMesh(sphere);
|
||||
sphere.material = mat;
|
||||
|
||||
const speed = randomRange(5000, 30000);
|
||||
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
|
||||
anim.setKeys([
|
||||
{ frame: 0, value: rotation },
|
||||
{ frame: speed, value: Math.random() < 0.5 ? rotation + (Math.PI * 2) : rotation - (Math.PI * 2) },
|
||||
]);
|
||||
sphereRoot.animations = [anim];
|
||||
this.scene.beginAnimation(sphereRoot, 0, speed, true);
|
||||
|
||||
if (this.reflectionProbe != null) this.reflectionProbe.renderList.push(sphere);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const sphereRoot = new BABYLON.TransformNode('', this.scene);
|
||||
sphereRoot.position = new BABYLON.Vector3(cm(0), cm(randomRange(-5000, 5000)), cm(0));
|
||||
const rotation = Math.random() * Math.PI * 2;
|
||||
const sphere = BABYLON.MeshBuilder.CreateSphere('', { diameter: cm(randomRange(500, 3000)), segments: 16 }, this.scene);
|
||||
sphere.parent = sphereRoot;
|
||||
sphere.position = new BABYLON.Vector3(cm(0), cm(0), cm(randomRange(10000, 15000)));
|
||||
|
||||
const mat = new BABYLON.PBRMaterial('', this.scene);
|
||||
const color = tinycolor({ h: Math.random() * 360, s: randomRange(0, 1), l: randomRange(0.75, 1) }).toRgb();
|
||||
mat.emissiveColor = new BABYLON.Color3(color.r / 255, color.g / 255, color.b / 255);
|
||||
mat.disableLighting = true;
|
||||
this.gl?.addExcludedMesh(sphere);
|
||||
sphere.material = mat;
|
||||
|
||||
const speed = randomRange(10000, 100000);
|
||||
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
|
||||
anim.setKeys([
|
||||
{ frame: 0, value: rotation },
|
||||
{ frame: speed, value: Math.random() < 0.5 ? rotation + (Math.PI * 2) : rotation - (Math.PI * 2) },
|
||||
]);
|
||||
sphereRoot.animations = [anim];
|
||||
this.scene.beginAnimation(sphereRoot, 0, speed, true);
|
||||
|
||||
if (this.reflectionProbe != null) this.reflectionProbe.renderList.push(sphere);
|
||||
}
|
||||
|
||||
//const sphere = BABYLON.MeshBuilder.CreateSphere('', { diameter: cm(10) }, this.scene);
|
||||
|
||||
const adsCountCol = 4;
|
||||
const adsCountRow = 2;
|
||||
for (let j = 0; j < adsCountRow; j++) {
|
||||
for (let i = 0; i < adsCountCol; i++) {
|
||||
const adRoot = new BABYLON.TransformNode(`ad_${j}_${i}_root`, this.scene);
|
||||
adRoot.position = new BABYLON.Vector3(cm(0), cm(500 + (1000 * j)), cm(0));
|
||||
const rotation = (i / adsCountCol) * Math.PI * 2;
|
||||
const adMesh = BABYLON.MeshBuilder.CreatePlane(`ad_${j}_${i}`, { width: cm(1000), height: cm(700) }, this.scene);
|
||||
adMesh.parent = adRoot;
|
||||
adMesh.position = new BABYLON.Vector3(cm(0), cm(0), cm(7500));
|
||||
|
||||
const tex = new BABYLON.Texture('/client-assets/world/lobby/dummy-ads/angry_ai.png', this.scene);
|
||||
const adMat = new BABYLON.StandardMaterial(`ad_${j}_${i}_mat`, this.scene);
|
||||
adMat.emissiveTexture = tex;
|
||||
adMat.disableLighting = true;
|
||||
adMesh.material = adMat;
|
||||
|
||||
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
|
||||
anim.setKeys([
|
||||
{ frame: 0, value: rotation },
|
||||
{ frame: 15000, value: j % 2 === 0 ? rotation + (Math.PI * 2) : rotation - (Math.PI * 2) },
|
||||
]);
|
||||
adRoot.animations = [anim];
|
||||
this.scene.beginAnimation(adRoot, 0, 15000, true);
|
||||
}
|
||||
}
|
||||
|
||||
const worldRingH = envObj.meshes.find(m => m.name.includes('__WORLD_RING_H__'));
|
||||
const worldRingM = envObj.meshes.find(m => m.name.includes('__WORLD_RING_M__'));
|
||||
|
||||
worldRingH.material.reflectionTexture = null;
|
||||
worldRingM.material.reflectionTexture = null;
|
||||
|
||||
if (this.reflectionProbe != null) this.reflectionProbe.renderList.push(worldRingH);
|
||||
if (this.reflectionProbe != null) this.reflectionProbe.renderList.push(worldRingM);
|
||||
|
||||
worldRingH.rotation = worldRingH.rotationQuaternion.toEulerAngles();
|
||||
worldRingM.rotation = worldRingM.rotationQuaternion.toEulerAngles();
|
||||
worldRingH.rotationQuaternion = null;
|
||||
worldRingM.rotationQuaternion = null;
|
||||
|
||||
const _1h = 1000 * 60 * 60;
|
||||
const _12h = _1h * 12;
|
||||
const _7days = _1h * 24 * 7;
|
||||
const _30days = _1h * 24 * 30;
|
||||
|
||||
this.timer.setInterval(() => {
|
||||
const time = Date.now();
|
||||
worldRingH.rotation.x = ((time % _12h) / _12h) * Math.PI * 2;
|
||||
worldRingM.rotation.y = -(((time % _1h) / _1h) * Math.PI);
|
||||
}, 100);
|
||||
|
||||
const screenMeshes = envObj.meshes.filter(m => m.name.includes('__SCREEN__'));
|
||||
const screenMaterial = screenMeshes[0].material as BABYLON.PBRMaterial;
|
||||
|
||||
const videoEl = document.createElement('video');
|
||||
videoEl.crossOrigin = 'anonymous';
|
||||
|
||||
const hls = new Hls();
|
||||
hls.loadSource('https://tvs.misskey.io/official/hq-beta/ts:abr.m3u8');
|
||||
hls.attachMedia(videoEl);
|
||||
|
||||
this.timer.setTimeout(() => {
|
||||
const tex = new BABYLON.VideoTexture('', videoEl, this.scene, true, true);
|
||||
tex.level = 0.5;
|
||||
tex.video.loop = true;
|
||||
tex.video.volume = 0.25;
|
||||
tex.video.muted = true;
|
||||
|
||||
screenMaterial.albedoColor = new BABYLON.Color3(0, 0, 0);
|
||||
screenMaterial.emissiveTexture = tex;
|
||||
screenMaterial.emissiveColor = new BABYLON.Color3(1, 1, 1);
|
||||
|
||||
tex.onLoadObservable.addOnce(() => {
|
||||
tex.video.play();
|
||||
for (const mesh of screenMeshes) {
|
||||
if (mesh instanceof BABYLON.InstancedMesh) continue;
|
||||
//normalizeUvToSquare(mesh);
|
||||
const updateUv = createPlaneUvMapper(mesh);
|
||||
if (tex == null) return;
|
||||
const srcAspect = tex.getSize().width / tex.getSize().height;
|
||||
const targetAspect = 16 / 9;
|
||||
updateUv(srcAspect, targetAspect, 'cover');
|
||||
}
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
const emitter = new BABYLON.TransformNode('emitter', this.scene);
|
||||
emitter.position = new BABYLON.Vector3(0, cm(-1000), 0);
|
||||
const ps = new BABYLON.ParticleSystem('', 128, this.scene);
|
||||
ps.particleTexture = new BABYLON.Texture('/client-assets/world/objects/lava-lamp/bubble.png');
|
||||
ps.emitter = emitter;
|
||||
ps.isLocal = true;
|
||||
ps.minEmitBox = new BABYLON.Vector3(cm(-1000), 0, cm(-1000));
|
||||
ps.maxEmitBox = new BABYLON.Vector3(cm(1000), 0, cm(1000));
|
||||
ps.minEmitPower = cm(100);
|
||||
ps.maxEmitPower = cm(500);
|
||||
ps.minLifeTime = 30;
|
||||
ps.maxLifeTime = 30;
|
||||
ps.minSize = cm(30);
|
||||
ps.maxSize = cm(300);
|
||||
ps.direction1 = new BABYLON.Vector3(0, 1, 0);
|
||||
ps.direction2 = new BABYLON.Vector3(0, 1, 0);
|
||||
ps.emitRate = 1.5;
|
||||
ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;
|
||||
ps.color1 = new BABYLON.Color4(1, 1, 1, 0.3);
|
||||
ps.color2 = new BABYLON.Color4(1, 1, 1, 0.2);
|
||||
ps.colorDead = new BABYLON.Color4(1, 1, 1, 0);
|
||||
ps.preWarmCycles = Math.random() * 1000;
|
||||
ps.start();
|
||||
}
|
||||
|
||||
public sitChair(furnitureId: string) {
|
||||
this.isSitting = true;
|
||||
this.fixedCamera.parent = this.objectMeshs.get(furnitureId);
|
||||
this.fixedCamera.position = new BABYLON.Vector3(0, cm(120), 0);
|
||||
this.fixedCamera.rotation = new BABYLON.Vector3(0, 0, 0);
|
||||
this.scene.activeCamera = this.fixedCamera;
|
||||
this.selectFurniture(null);
|
||||
}
|
||||
|
||||
public standUp() {
|
||||
this.isSitting = false;
|
||||
this.scene.activeCamera = this.camera;
|
||||
this.fixedCamera.parent = null;
|
||||
}
|
||||
|
||||
private playSfxUrl(url: string, options: { volume: number; playbackRate: number }) {
|
||||
this.emit('playSfxUrl', { url, options });
|
||||
}
|
||||
|
||||
public resize() {
|
||||
this.engine.resize(true);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
super.destroy();
|
||||
this.timer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class MessageRing {
|
||||
constructor(mesh: BABYLON.Mesh, scene: BABYLON.Scene, options: { material: BABYLON.StandardMaterial; repeatSeparator: string; }) {
|
||||
const messageRingRoot = new BABYLON.TransformNode('', this.scene);
|
||||
const messageRing = envObj.meshes.find(m => m.name.includes('__MESSAGE_RING_INNER_1__'));
|
||||
messageRing.parent = messageRingRoot;
|
||||
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
|
||||
messageRing.rotationQuaternion = null;
|
||||
const text = new RecyvlingTextGrid(messageRing, 64, {
|
||||
material: this.textMaterial,
|
||||
repeatSeparator: ' ',
|
||||
});
|
||||
|
||||
//messageRingRoot.rotation.x = Math.PI / 4;
|
||||
|
||||
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
|
||||
anim.setKeys([
|
||||
{ frame: 0, value: 0 },
|
||||
{ frame: 10000, value: (Math.PI * 2) },
|
||||
]);
|
||||
messageRing.animations = [anim];
|
||||
this.scene.beginAnimation(messageRing, 0, 10000, true);
|
||||
|
||||
this.timer.setInterval(() => {
|
||||
const now = new Date();
|
||||
const hours = now.getHours().toString().padStart(2, '0');
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = now.getSeconds().toString().padStart(2, '0');
|
||||
text.write(`${hours}:${minutes}:${seconds}`);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// ランダムな文字列が生成できればなんでも良い(時系列でソートできるなら尚良)が、とりあえずaidの実装を拝借
|
||||
|
||||
const TIME2000 = 946684800000;
|
||||
let counter = Math.floor(Math.random() * 10000);
|
||||
|
||||
function getTime(time: number): string {
|
||||
time = time - TIME2000;
|
||||
if (time < 0) time = 0;
|
||||
|
||||
return time.toString(36).padStart(8, '0');
|
||||
}
|
||||
|
||||
function getNoise(): string {
|
||||
return counter.toString(36).padStart(2, '0').slice(-2);
|
||||
}
|
||||
|
||||
export function genId(): string {
|
||||
counter++;
|
||||
return getTime(Date.now()) + getNoise();
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { OptionsSchema, NumberOptionSchema, BooleanOptionSchema, StringOptionSchema, ColorOptionSchema, MaterialOptionSchema, LightOptionSchema, EnumOptionSchema, RangeOptionSchema, ImageOptionSchema, SeedOptionSchema } from 'misskey-world/src/mono.js';
|
||||
|
||||
export type RawOptions = Record<string, unknown> & {
|
||||
readonly __brand: unique symbol;
|
||||
};
|
||||
|
||||
export type ConvertedOptions = Record<string, unknown> & {
|
||||
readonly __brand: unique symbol;
|
||||
};
|
||||
|
||||
type RawImageValue<Presets extends string = string> = { type: Presets | null | '_custom_'; driveFileId?: string | null; fit?: 'cover' | 'contain' | 'stretch'; rotation?: 0 | 1 | 2 | 3; };
|
||||
|
||||
type ConvertedImageValue<Presets extends string = string> = { type: Presets | null | '_custom_'; custom?: { url: string; } | null; fit?: 'cover' | 'contain' | 'stretch'; rotation?: 0 | 1 | 2 | 3; };
|
||||
export type GetConvertedOptionsSchemaValues<T extends OptionsSchema> = {
|
||||
[K in keyof T]:
|
||||
T[K] extends NumberOptionSchema ? number :
|
||||
T[K] extends BooleanOptionSchema ? boolean :
|
||||
T[K] extends StringOptionSchema ? string :
|
||||
T[K] extends ColorOptionSchema ? [number, number, number] :
|
||||
T[K] extends MaterialOptionSchema ? { color: [number, number, number]; metallic: number; roughness: number; } :
|
||||
T[K] extends LightOptionSchema ? { color: [number, number, number]; brightness: number; } :
|
||||
T[K] extends EnumOptionSchema ? T[K]['enum'][number]['value'] :
|
||||
T[K] extends RangeOptionSchema ? number :
|
||||
T[K] extends ImageOptionSchema ? ConvertedImageValue<T[K]['presets'][number]['value']> :
|
||||
T[K] extends SeedOptionSchema ? number :
|
||||
never;
|
||||
};
|
||||
|
||||
export function convertRawOptions<OpSc extends OptionsSchema>(schema: OpSc, raw: RawOptions, attachments: { files: { id: string; url: string; }[] }): ConvertedOptions {
|
||||
const converted = {} as ConvertedOptions;
|
||||
for (const record of Object.entries(schema)) {
|
||||
const k = record[0];
|
||||
const v = raw[k];
|
||||
if (record[1].type === 'image') {
|
||||
const _v = v as unknown as RawImageValue;
|
||||
const file = _v.type === '_custom_' ? attachments.files.find(f => f.id === _v.driveFileId) : null;
|
||||
if (file != null && file.url.startsWith('http://syu-win.local:3000/')) { // debug
|
||||
file.url = file.url.replace('http://syu-win.local:3000/', 'https://local-mi.syuilo.dev/');
|
||||
}
|
||||
|
||||
converted[k] = { type: _v.type, custom: file != null ? { url: file.url } : null, fit: _v.fit, rotation: _v.rotation } satisfies ConvertedImageValue;
|
||||
} else {
|
||||
converted[k] = v;
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { camelToKebab, WORLD_SCALE } from 'misskey-world/src/utility.js';
|
||||
import { scaleMorph, Timer } from '../utility.js';
|
||||
import { convertRawOptions, type ConvertedOptions, type RawOptions } from '../mono.js';
|
||||
import { getFurnitureDef } from './furniture-defs.js';
|
||||
import { ModelManager, SYSTEM_MESH_NAMES } from './utility.js';
|
||||
import type { RoomFurnitureInstance } from './furniture.js';
|
||||
import type { RoomAttachments } from 'misskey-world/src/room/type.js';
|
||||
|
||||
function mergeMeshes(meshes: BABYLON.Mesh[], root: BABYLON.Mesh, hasTexture: boolean) {
|
||||
const excludeMeshes = root.getChildMeshes().filter(m => SYSTEM_MESH_NAMES.some(s => m.name.includes(s)));
|
||||
|
||||
const childMeshes = root.getChildMeshes().filter(m => !excludeMeshes.some(x => x === m) && m.isVisible && !m.isDisposed());
|
||||
|
||||
const toMerge = [] as BABYLON.Mesh[];
|
||||
for (const mesh of childMeshes) {
|
||||
if (mesh instanceof BABYLON.InstancedMesh) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mesh.hasInstances) continue;
|
||||
|
||||
if (mesh instanceof BABYLON.Mesh) {
|
||||
toMerge.push(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
if (toMerge.length <= 1) { // マージ対象が一つしかない状態でマージするのは単純に無駄なのと、babylonのバグが知らないけどなぜか法線が反転する
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const mesh of toMerge) {
|
||||
if (hasTexture) {
|
||||
if (mesh.getVerticesData(BABYLON.VertexBuffer.UVKind) == null) {
|
||||
const vertexCount = mesh.getTotalVertices();
|
||||
const uvs = new Array(vertexCount * 2).fill(0);
|
||||
mesh.setVerticesData(BABYLON.VertexBuffer.UVKind, uvs, false, 2);
|
||||
}
|
||||
if (mesh.getVerticesData(BABYLON.VertexBuffer.UV2Kind) == null) {
|
||||
const vertexCount = mesh.getTotalVertices();
|
||||
const uvs = new Array(vertexCount * 2).fill(0);
|
||||
mesh.setVerticesData(BABYLON.VertexBuffer.UV2Kind, uvs, false, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const merged = BABYLON.Mesh.MergeMeshes(toMerge, true, false, undefined, false, true);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export class FurnitureContainer {
|
||||
public id: string;
|
||||
public type: string;
|
||||
private options: ConvertedOptions;
|
||||
public root: BABYLON.TransformNode;
|
||||
private subRoot: BABYLON.TransformNode | null = null;
|
||||
public instance: RoomFurnitureInstance | null = null;
|
||||
public model: ModelManager | null = null;
|
||||
private scene: BABYLON.Scene;
|
||||
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
|
||||
private sr: BABYLON.SnapshotRenderingHelper;
|
||||
private getIsSrReady: () => boolean;
|
||||
private lightContainer: BABYLON.ClusteredLightContainer;
|
||||
private graphicsQuality: number;
|
||||
private timer: Timer = new Timer();
|
||||
private sitChair: () => void = () => {};
|
||||
public boundingBox: {
|
||||
min: BABYLON.Vector3;
|
||||
max: BABYLON.Vector3;
|
||||
} | null = null;
|
||||
|
||||
constructor(args: {
|
||||
id: string;
|
||||
type: string;
|
||||
options: RawOptions;
|
||||
roomAttachments: RoomAttachments;
|
||||
position: BABYLON.Vector3;
|
||||
rotation: BABYLON.Vector3;
|
||||
sr: BABYLON.SnapshotRenderingHelper;
|
||||
getIsSrReady: () => boolean;
|
||||
lightContainer: BABYLON.ClusteredLightContainer;
|
||||
scene: BABYLON.Scene;
|
||||
graphicsQuality: number;
|
||||
sitChair?: () => void;
|
||||
}) {
|
||||
this.id = args.id;
|
||||
this.type = args.type;
|
||||
const def = getFurnitureDef(this.type);
|
||||
this.options = convertRawOptions(def.options.schema, args.options, args.roomAttachments);
|
||||
this.sr = args.sr;
|
||||
this.getIsSrReady = args.getIsSrReady;
|
||||
this.lightContainer = args.lightContainer;
|
||||
this.scene = args.scene;
|
||||
this.graphicsQuality = args.graphicsQuality;
|
||||
this.root = new BABYLON.TransformNode(`furniture_${args.id}_${args.type}`, this.scene);
|
||||
this.root.position = args.position;
|
||||
this.root.rotation = args.rotation;
|
||||
if (args.sitChair != null) this.sitChair = args.sitChair;
|
||||
}
|
||||
|
||||
public async load() {
|
||||
const def = getFurnitureDef(this.type);
|
||||
|
||||
const filePath = def.path != null ? `/client-assets/world/objects/${def.path(this.options)}.glb` : `/client-assets/world/objects/${camelToKebab(this.type)}/${camelToKebab(this.type)}.glb`;
|
||||
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
|
||||
|
||||
// babylonによって自動で追加される右手系変換用ノード
|
||||
const subRootMesh = loaderResult.meshes[0] as BABYLON.Mesh;
|
||||
|
||||
// 不要なUVを掃除
|
||||
if (!def.hasTexture) {
|
||||
for (const m of loaderResult.meshes) {
|
||||
if (m.geometry != null) {
|
||||
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UVKind);
|
||||
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV2Kind);
|
||||
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV3Kind);
|
||||
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV4Kind);
|
||||
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV5Kind);
|
||||
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV6Kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (def.canPreMeshesMerging) {
|
||||
const merged = mergeMeshes(loaderResult.meshes, subRootMesh, def.hasTexture);
|
||||
if (merged != null) {
|
||||
merged.setParent(subRootMesh);
|
||||
merged.name = 'preMerged';
|
||||
|
||||
merged.material.freeze();
|
||||
if (merged.material instanceof BABYLON.MultiMaterial) {
|
||||
for (const subMat of merged.material.subMaterials) {
|
||||
subMat.freeze();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 再帰的にする
|
||||
for (const m of loaderResult.transformNodes) {
|
||||
if (m.getChildren().length === 0) {
|
||||
m.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// meshじゃなくtransform nodeにしてパフォーマンス向上
|
||||
this.subRoot = new BABYLON.TransformNode('__root__', this.scene);
|
||||
this.subRoot.parent = this.root;
|
||||
this.subRoot.scaling.x = -1;
|
||||
this.subRoot.scaling = this.subRoot.scaling.scale(WORLD_SCALE);// cmをmに
|
||||
|
||||
for (const m of subRootMesh.getChildren()) {
|
||||
if (m.parent === subRootMesh) {
|
||||
m.parent = this.subRoot;
|
||||
}
|
||||
}
|
||||
|
||||
subRootMesh.dispose();
|
||||
|
||||
this.registerMeshes(this.subRoot.getChildMeshes());
|
||||
|
||||
this.model = new ModelManager(this.subRoot, loaderResult.meshes.filter(m => !m.isDisposed() && m.name !== '__root__'), def.hasTexture, (meshes) => {
|
||||
this.registerMeshes(meshes);
|
||||
});
|
||||
|
||||
this.instance = await def.createInstance({
|
||||
scene: this.scene,
|
||||
sr: {
|
||||
updateMesh: (mesh) => {
|
||||
if (!this.getIsSrReady()) return;
|
||||
this.sr.updateMesh(mesh);
|
||||
},
|
||||
reset: () => {
|
||||
if (!this.getIsSrReady()) return;
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.sr.enableSnapshotRendering();
|
||||
},
|
||||
fixParticleSystem: (ps) => this.sr.fixParticleSystem(ps),
|
||||
},
|
||||
lc: this.lightContainer,
|
||||
root: this.root,
|
||||
options: this.options,
|
||||
model: this.model!,
|
||||
id: this.id,
|
||||
timer: this.timer,
|
||||
graphicsQuality: this.graphicsQuality,
|
||||
reloadModel: () => {
|
||||
this.reload();
|
||||
},
|
||||
sitChair: () => {
|
||||
this.sitChair();
|
||||
},
|
||||
stickyMarkerMeshUpdated: (mesh) => {
|
||||
// TODO
|
||||
//// stickyな子の位置を更新
|
||||
//if (mesh.name.includes('__TOP__')) {
|
||||
// mesh.unfreezeWorldMatrix();
|
||||
// mesh.computeWorldMatrix(true);
|
||||
// const updateChildStickyObjectPosition = (furnitureId: string) => {
|
||||
// const stickyFurnitureIds = Array.from(this.roomState.installedFurnitures.filter(o => o.sticky === furnitureId)).map(o => o.id);
|
||||
// for (const soid of stickyFurnitureIds) {
|
||||
// const soMesh = this.objectEntities.get(soid)!.rootMesh;
|
||||
// soMesh.unfreezeWorldMatrix();
|
||||
// for (const m of soMesh.getChildMeshes()) {
|
||||
// m.unfreezeWorldMatrix();
|
||||
// }
|
||||
// console.log(mesh.getAbsolutePosition().y);
|
||||
// soMesh.position.y = mesh.getAbsolutePosition().y;
|
||||
// updateChildStickyObjectPosition(soid);
|
||||
// }
|
||||
// };
|
||||
// updateChildStickyObjectPosition(args.id);
|
||||
//}
|
||||
},
|
||||
});
|
||||
|
||||
this.instance.onInited?.();
|
||||
|
||||
this.calcBoundingBox();
|
||||
}
|
||||
|
||||
public calcBoundingBox() {
|
||||
// TODO: モーフ最大適用後のサイズが取得されてしまうのを直す
|
||||
this.boundingBox = this.subRoot.getHierarchyBoundingVectors(true);
|
||||
}
|
||||
|
||||
public interact(iid: string | null = null) {
|
||||
if (this.instance == null) return;
|
||||
if (iid == null) {
|
||||
if (this.instance.primaryInteraction != null) {
|
||||
this.instance.interactions[this.instance.primaryInteraction].fn();
|
||||
}
|
||||
} else {
|
||||
this.instance.interactions[iid].fn();
|
||||
}
|
||||
}
|
||||
|
||||
public async reload() {
|
||||
this.timer.dispose();
|
||||
this.instance?.dispose?.();
|
||||
this.instance = null;
|
||||
this.model = null;
|
||||
this.subRoot?.dispose();
|
||||
this.root.removeChild(this.subRoot);
|
||||
this.scene.removeTransformNode(this.subRoot);
|
||||
|
||||
this.timer = new Timer();
|
||||
|
||||
await this.load();
|
||||
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public optionsUpdated(options: RawOptions, key: string, value: any, roomAttachments: RoomAttachments) {
|
||||
if (this.instance == null) return;
|
||||
const def = getFurnitureDef(this.type);
|
||||
const convertedOptions = convertRawOptions(def.options.schema, options, roomAttachments);
|
||||
this.options[key] = convertedOptions[key]; // 参照を切れさせないようにプロパティ個別にmutate
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.instance.onOptionsUpdated?.([key, this.options[key]]);
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.timer.dispose();
|
||||
this.instance?.dispose?.();
|
||||
this.subRoot.dispose();
|
||||
this.root.dispose();
|
||||
this.scene.removeTransformNode(this.root);
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,123 +0,0 @@
|
|||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
|
||||
import { findMaterial, GRAPHICS_QUALITY } from '../utility.js';
|
||||
import { SYSTEM_HEYA_MESH_NAMES } from './utility.js';
|
||||
import type { RoomEngine } from './engine.js';
|
||||
|
||||
export abstract class EnvManager<T = any> {
|
||||
protected engine: RoomEngine;
|
||||
public abstract envMapIndoor: BABYLON.CubeTexture | null;
|
||||
public abstract maxCameraZ: number;
|
||||
private shadowGenerators: BABYLON.ShadowGenerator[] = [];
|
||||
protected isRoomLightOn = true;
|
||||
|
||||
constructor(engine: RoomEngine) {
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
abstract load(options: T): Promise<void>;
|
||||
abstract applyOptions(options: T): void;
|
||||
abstract setTime(time: number): void;
|
||||
abstract applyRoomLight(): void;
|
||||
|
||||
public turnOnRoomLight() {
|
||||
this.isRoomLightOn = true;
|
||||
this.applyRoomLight();
|
||||
}
|
||||
|
||||
public turnOffRoomLight() {
|
||||
this.isRoomLightOn = false;
|
||||
this.applyRoomLight();
|
||||
}
|
||||
|
||||
protected registerShadowGenerator(shadowGenerator: BABYLON.ShadowGenerator) {
|
||||
this.shadowGenerators.push(shadowGenerator);
|
||||
|
||||
const shadowMap = shadowGenerator.getShadowMap()!;
|
||||
shadowMap.refreshRate = BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
|
||||
|
||||
// https://forum.babylonjs.com/t/is-it-intentional-that-the-shadow-map-refresh-rate-is-ignored-under-fast-snapshot-rendering/63523
|
||||
const objectRenderer = shadowMap._objectRenderer;
|
||||
const originalShouldRender = objectRenderer.shouldRender.bind(objectRenderer);
|
||||
objectRenderer.shouldRender = function () {
|
||||
if (this._engine.snapshotRendering) {
|
||||
return this.refreshRate !== BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
|
||||
}
|
||||
|
||||
return originalShouldRender();
|
||||
};
|
||||
}
|
||||
|
||||
public addShadowCaster(mesh: BABYLON.AbstractMesh) {
|
||||
for (const shadowGen of this.shadowGenerators) {
|
||||
shadowGen.addShadowCaster(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
public removeShadowCaster(mesh: BABYLON.AbstractMesh) {
|
||||
for (const shadowGen of this.shadowGenerators) {
|
||||
shadowGen.removeShadowCaster(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
public async renderShadow() {
|
||||
this.engine.sr.disableSnapshotRendering();
|
||||
|
||||
for (const shadowGen of this.shadowGenerators) {
|
||||
const shadowMap = shadowGen.getShadowMap()!;
|
||||
shadowMap.refreshRate = 1;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
|
||||
for (const shadowGen of this.shadowGenerators) {
|
||||
const shadowMap = shadowGen.getShadowMap()!;
|
||||
shadowMap.refreshRate = BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
|
||||
}
|
||||
|
||||
this.engine.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
protected registerMeshes(meshes: BABYLON.AbstractMesh[]) {
|
||||
for (const mesh of meshes) {
|
||||
if (!this.engine.scene.meshes.includes(mesh)) this.engine.scene.addMesh(mesh);
|
||||
|
||||
if (SYSTEM_HEYA_MESH_NAMES.some(name => mesh.name.includes(name))) {
|
||||
mesh.isPickable = false;
|
||||
mesh.receiveShadows = false;
|
||||
mesh.isVisible = false;
|
||||
mesh.checkCollisions = false;
|
||||
if (mesh.name.includes('__COLLISION__')) {
|
||||
mesh.checkCollisions = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
mesh.isPickable = false;
|
||||
mesh.checkCollisions = false;
|
||||
if (mesh.material != null) {
|
||||
(mesh.material as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
|
||||
|
||||
if (mesh.material instanceof BABYLON.MultiMaterial) {
|
||||
for (const subMat of mesh.material.subMaterials) {
|
||||
subMat.reflectionTexture = this.envMapIndoor;
|
||||
}
|
||||
} else if (mesh.material instanceof BABYLON.PBRMaterial) {
|
||||
mesh.material.reflectionTexture = this.envMapIndoor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
for (const shadowGen of this.shadowGenerators) {
|
||||
shadowGen.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue