1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-07 19:25:00 +02:00

Compare commits

..

20 Commits

Author SHA1 Message Date
github-actions[bot]
03d224acd0 Bump version to 2026.7.0-alpha.2 2026-07-06 10:03:45 +00:00
かっこかり
c24ce9b55a deps: update dependencies (#17669)
* deps: update dependencies

* deps: update dependencies (major)

* migrate js-yaml

* migrate js-yaml

* run pnpm dedupe

* fix

* chore: reorganize dependencies

* perf: lazy-load id generators

* Revert "perf: lazy-load id generators"

This reverts commit 041f7c4206.
2026-07-06 18:55:06 +09:00
おさむのひと
7243bd34b9 fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正 (#17671)
* fix: pgをexternalにしてSentryのspanが動くようにする

* fix changelog

* add comment
2026-07-06 18:54:04 +09:00
おさむのひと
d4dd02c744 feat: Sentryバックエンドの自動計装を個別で無効化出来る仕組みを追加する (#17673)
* feat: Sentryバックエンドの自動計装を個別で無効化出来る仕組みを追加する

* fix comment
2026-07-06 18:52:57 +09:00
syuilo
3817f00803 Revert "fix(dev): fix typo of report-api-diff.yml"
This reverts commit d1cac26cfb.
2026-07-05 14:11:05 +09:00
github-actions[bot]
f066c1c2ef Bump version to 2026.7.0-alpha.1 2026-07-05 05:06:33 +00:00
かっこかり
1d41cb3d29 perf(frontend): UNIX時計ウィジェットでrequestAnimationFrameを使用するように (#17665) 2026-07-05 13:08:44 +09:00
かっこかり
1858d56918 fix(frontend): ワードミュートの判定が誤っている問題を修正 (#17668) 2026-07-05 13:07:17 +09:00
かっこかり
97082efd99 Update CHANGELOG.md [ci skip] 2026-07-04 20:46:03 +09:00
Johann150
187610d516 fix(frontend): correctly detect isMe in mention (#17664) 2026-07-04 20:43:42 +09:00
かっこかり
00b2cb2076 fix(backend/test): streaming.ts e2e の検証不備を修正 (#17663)
fix(backend/test): streaming.ts e2e の検証不備を修正 (tiramiss-community/endolphin#113)

Co-authored-by: samunohito <46447427+samunohito@users.noreply.github.com>
2026-07-04 18:53:10 +09:00
おさむのひと
426f6748e5 fix: frontend-builderのutils.tsが型エラーを起こしていたのを修正 (#17649) 2026-07-04 17:44:42 +09:00
かっこかり
c423bf92a8 Update CHANGELOG.md 2026-07-04 16:47:31 +09:00
かっこかり
f59bf35615 feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように (#17635)
* URLプレビューのサムネイルを隠す機能を追加 (MisskeyIO#214)

* Update Changelog

* fix

* Update Changelog

* fix

* fix: もうセンシティブと判定されている場合はそれをそのまま通すように

* fix: unnecessary import

* fix lint

---------

Co-authored-by: CyberRex <hspwinx86@gmail.com>
2026-07-04 16:26:22 +09:00
おさむのひと
0f056c4955 refactor: Sentry呼び出し個所の抽象化 (#17661) 2026-07-04 16:06:24 +09:00
かっこかり
eb2c7ff6c6 fix(backend/test): follow-up of #17654 (waitForを他のe2eテストにも展開) (#17659)
test(backend): block.ts等のfanout timeline反映待ちレースを修正する (tiramiss-community/endolphin#112)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-04 11:21:23 +09:00
おさむのひと
9f614517c0 fix: tsconfig.jsonにskipLibCheck: trueを追加する (#17651)
* fix: tsconfig.jsonにskipLibCheck: trueを追加する

* skipLibCheckで抜けたチェックを自前でやる
2026-07-03 19:22:23 +09:00
github-actions[bot]
5432984af8 Bump version to 2026.7.0-alpha.0 2026-07-03 10:15:24 +00:00
かっこかり
c29a3d902b refactor(frontend): MkNote/MkNoteDetailedのロジックを統合 (#17636)
* refactor(frontend): MkNote/MkNoteDetailedのロジックを統合

* refactor

* fix

* fix: 差分を解消

* fix lint

* fix types

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-07-03 19:14:48 +09:00
かっこかり
721b1b06a0 fix(backend/test): backend e2e test の beforeAll でdispose前にschema dropが走るのを修正 (#17653)
fix(backend): setup.e2e.ts の beforeAll でdispose前にschema dropが走る順序バグを修正 (Phase 6) (tiramiss-community/endolphin#52)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-03 15:57:39 +09:00
1142 changed files with 2662 additions and 34419 deletions

View File

@@ -303,6 +303,18 @@ id: 'aidx'
#sentryForBackend:
# enableNodeProfiling: true
# # Specify Sentry integration names to disable individual auto-instrumentation.
# # The names are integration .name values, not factory function names.
# # To check enabled names, set `options.debug: true` and see
# # "Integration installed: <name>" in the Sentry logs.
# #
# # As of 2026-07-05 / @sentry/node 10.59.0, useful names for Misskey include:
# # Postgres ... DB queries (when pg is externalized)
# # Redis ... ioredis commands
# # Fastify ... inbound HTTP routes
# # Http ... inbound/outbound HTTP; disabling this can also affect request isolation
# # NodeFetch ... fetch/undici requests
# disabledIntegrations: ['Postgres']
# options:
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'

View File

@@ -16,10 +16,10 @@ on:
- packages/misskey-js/**
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/misskey-world/**
- packages/frontend-misskey-world-engine/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
- package.json
pull_request:
paths:
- packages/backend/**
@@ -32,10 +32,10 @@ on:
- packages/misskey-js/**
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/misskey-world/**
- packages/frontend-misskey-world-engine/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
- package.json
jobs:
pnpm_install:
runs-on: ubuntu-latest
@@ -69,8 +69,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 }}
@@ -119,3 +117,22 @@ jobs:
- run: pnpm i --frozen-lockfile
- run: pnpm --filter "${{ matrix.workspace }}^..." run build
- run: pnpm --filter ${{ matrix.workspace }} run typecheck
check-dts:
needs: [pnpm_install]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
- uses: actions/setup-node@v6.4.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
- run: pnpm i --frozen-lockfile
- run: node --test scripts/check-dts.test.mjs
- run: pnpm check-dts

View File

@@ -65,7 +65,7 @@ jobs:
echo '```diff' >> ./output.md
cat ./api.json.diff >> ./output.md
echo '```' >> ./output.md
echo '</details>' >> ./output.md
echo '</details>' >> .output.md
fi
echo "$FOOTER" >> ./output.md

View File

@@ -1,4 +1,4 @@
## 2026.6.1
## 2026.7.0
### Note
@@ -15,19 +15,24 @@
### General
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
### Client
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
- 2025.4.0 以前の設定情報の移行処理が削除されました
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
### Server
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
- Fix: `/stats` API のレスポンス型が正しくない問題を修正
- Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正
- Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正
## 2026.6.0

View File

@@ -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

View File

@@ -1414,13 +1414,13 @@ viewRenotedChannel: "リノート先のチャンネルを見る"
previewingTheme: "テーマのプレビュー中"
previewingThemeRestore: "元に戻す"
accessToken: "アクセストークン"
choose: "選択"
rotate: "回転"
chooseEmojiPalette: "絵文字パレットを選択"
addToEmojiPalette: "絵文字パレットに追加"
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
append: "末尾に追加"
prepend: "先頭に追加"
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
_imageEditing:
_vars:
@@ -3580,476 +3580,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": "喫藍心整"

View File

@@ -1,20 +1,18 @@
{
"name": "misskey",
"version": "2026.6.1-alpha.1",
"version": "2026.7.0-alpha.2",
"codename": "nasubi",
"repository": {
"type": "git",
"url": "https://github.com/misskey-dev/misskey.git"
},
"packageManager": "pnpm@11.8.0",
"packageManager": "pnpm@11.9.0",
"workspaces": [
"packages/misskey-js",
"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",
@@ -41,7 +39,8 @@
"migrateandstart": "pnpm migrate && pnpm start",
"watch": "pnpm dev",
"dev": "node scripts/dev.mjs",
"lint": "pnpm --no-bail -r lint",
"check-dts": "node scripts/check-dts.mjs",
"lint": "pnpm --no-bail -r lint && pnpm check-dts",
"cy:open": "pnpm cypress open --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
@@ -58,23 +57,22 @@
"esbuild": "0.28.1",
"execa": "9.6.1",
"ignore-walk": "9.0.0",
"js-yaml": "4.2.0",
"tar": "7.5.16"
"js-yaml": "5.2.0",
"tar": "7.5.19"
},
"devDependencies": {
"@eslint/js": "9.39.4",
"@misskey-dev/eslint-plugin": "2.1.0",
"@types/js-yaml": "4.0.9",
"@types/node": "26.0.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@types/node": "26.0.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@typescript/native-preview": "7.0.0-dev.20260426.1",
"cross-env": "10.1.0",
"cypress": "15.17.0",
"cypress": "15.18.0",
"eslint": "9.39.4",
"globals": "17.6.0",
"globals": "17.7.0",
"ncp": "2.0.0",
"pnpm": "11.8.0",
"pnpm": "11.9.0",
"start-server-and-test": "3.0.11",
"typescript": "5.9.3"
}

View File

@@ -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"`);
}
}

View File

@@ -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"`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UrlPreviewSensitiveList1782581064131 {
name = 'UrlPreviewSensitiveList1782581064131'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "urlPreviewSensitiveList" character varying(3072) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "urlPreviewSensitiveList"`);
}
}

View File

@@ -51,8 +51,8 @@
"utf-8-validate": "6.0.6"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1073.0",
"@aws-sdk/lib-storage": "3.1073.0",
"@aws-sdk/client-s3": "3.1075.0",
"@aws-sdk/lib-storage": "3.1075.0",
"@fastify/accepts": "5.0.4",
"@fastify/cors": "11.2.0",
"@fastify/http-proxy": "11.5.0",
@@ -63,23 +63,22 @@
"@misskey-dev/emoji-data": "17.0.3",
"@misskey-dev/sharp-read-bmp": "1.3.1",
"@misskey-dev/summaly": "5.5.1",
"@napi-rs/canvas": "1.0.0",
"@napi-rs/canvas": "1.0.1",
"@nestjs/common": "11.1.27",
"@nestjs/core": "11.1.27",
"@nestjs/testing": "11.1.27",
"@oxc-project/runtime": "0.137.0",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "10.59.0",
"@sentry/profiling-node": "10.59.0",
"@simplewebauthn/server": "13.3.1",
"@sinonjs/fake-timers": "15.4.0",
"@smithy/node-http-handler": "4.8.1",
"@sentry/node": "10.62.0",
"@sentry/profiling-node": "10.62.0",
"@simplewebauthn/server": "13.3.2",
"@smithy/node-http-handler": "4.8.2",
"accepts": "1.3.8",
"ajv": "8.20.0",
"archiver": "8.0.0",
"bcryptjs": "3.0.3",
"blurhash": "2.0.5",
"bullmq": "5.79.0",
"bullmq": "5.79.2",
"cacheable-lookup": "7.0.0",
"chalk": "5.6.2",
"chalk-template": "1.1.2",
@@ -88,12 +87,12 @@
"content-disposition": "2.0.1",
"date-fns": "4.4.0",
"deep-email-validator": "0.1.27",
"fastify": "5.8.5",
"fastify": "5.9.0",
"fastify-raw-body": "5.0.0",
"feed": "5.2.1",
"file-type": "22.0.1",
"fluent-ffmpeg": "2.1.3",
"got": "15.0.5",
"got": "15.0.7",
"hpagent": "1.2.0",
"http-link-header": "1.1.3",
"i18n": "workspace:*",
@@ -110,10 +109,10 @@
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"ms": "3.0.0-canary.202508261828",
"nanoid": "5.1.14",
"nanoid": "5.1.16",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"node-html-parser": "7.1.0",
"node-html-parser": "8.0.3",
"nodemailer": "9.0.1",
"os-utils": "0.0.14",
"otpauth": "9.5.1",
@@ -135,7 +134,7 @@
"slacc": "0.1.5",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.31.7",
"systeminformation": "5.31.11",
"tinycolor2": "1.6.0",
"tmp": "0.2.7",
"tsc-alias": "1.8.17",
@@ -150,16 +149,16 @@
"@kitajs/ts-html-plugin": "4.1.4",
"@nestjs/platform-express": "11.1.27",
"@rollup/plugin-esm-shim": "0.1.8",
"@sentry/vue": "10.59.0",
"@sentry/vue": "10.62.0",
"@sinonjs/fake-timers": "15.4.0",
"@types/accepts": "1.3.7",
"@types/archiver": "8.0.0",
"@types/fluent-ffmpeg": "2.1.28",
"@types/http-link-header": "1.0.7",
"@types/js-yaml": "4.0.9",
"@types/jsonld": "1.5.15",
"@types/mime-types": "3.0.1",
"@types/ms": "2.1.0",
"@types/node": "26.0.0",
"@types/node": "26.0.1",
"@types/nodemailer": "8.0.1",
"@types/pg": "8.20.0",
"@types/qrcode": "1.5.6",
@@ -170,14 +169,13 @@
"@types/semver": "7.7.1",
"@types/simple-oauth2": "5.0.8",
"@types/sinonjs__fake-timers": "15.0.1",
"@types/supertest": "7.2.0",
"@types/tinycolor2": "1.4.6",
"@types/tmp": "0.2.6",
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@vitest/coverage-v8": "4.1.9",
"aws-sdk-client-mock": "4.1.0",
"cbor2": "2.3.0",
@@ -185,11 +183,10 @@
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",
"fkill": "10.0.3",
"js-yaml": "4.2.0",
"js-yaml": "5.2.0",
"pid-port": "2.1.1",
"rolldown": "1.1.2",
"rolldown": "1.1.3",
"simple-oauth2": "5.1.0",
"supertest": "7.2.2",
"vite": "8.1.0",
"vitest": "4.1.9",
"vitest-mock-extended": "4.0.0"

View File

@@ -75,6 +75,8 @@ export default defineConfig((args) => {
're2',
'ipaddr.js',
'file-type',
// バンドルするとSentryの自動計装が正しく行われなくなるため外しておく
'pg',
];
const define: Record<string, string> = {

View File

@@ -11,7 +11,7 @@
import fs from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
import { load as loadYaml } from 'js-yaml';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@@ -34,7 +34,7 @@ function yamlToJson(ymlPath) {
console.log(`${ymlPath}${OUTPUT_PATH}`);
const yamlContent = fs.readFileSync(ymlPath, 'utf-8');
const jsonContent = yaml.load(yamlContent);
const jsonContent = loadYaml(yamlContent);
if (!fs.existsSync(dirname(OUTPUT_PATH))) {
fs.mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
}

View File

@@ -26,7 +26,6 @@ declare module 'probe-image-size' {
function probeImageSize(src: string | ReadStream, callback: (err: Error | null, result?: ProbeResult) => void): void;
function probeImageSize(src: string | ReadStream, options: ProbeOptions, callback: (err: Error | null, result?: ProbeResult) => void): void;
namespace probeImageSize {} // Hack
export = probeImageSize;
// eslint-disable-next-line import/no-default-export
export { probeImageSize as default };
}

View File

@@ -13,6 +13,7 @@ import { loadConfig } from '@/config.js';
import type { Config } from '@/config.js';
import { showMachineInfo } from '@/misc/show-machine-info.js';
import { envOption } from '@/env.js';
import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
const logger = new Logger('core', 'cyan');
@@ -66,26 +67,7 @@ export async function masterMain() {
initExtraThreadPool(config);
if (config.sentryForBackend) {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.sentryForBackend.options,
});
}
await initTelemetry(config);
bootLogger.info(
`mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`,

View File

@@ -6,6 +6,7 @@
import cluster from 'node:cluster';
import { envOption } from '@/env.js';
import { loadConfig } from '@/config.js';
import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
/**
@@ -16,26 +17,7 @@ export async function workerMain() {
initExtraThreadPool(config);
if (config.sentryForBackend) {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.sentryForBackend.options,
});
}
await initTelemetry(config);
if (envOption.onlyServer) {
await server();

View File

@@ -20,6 +20,12 @@ type RedisOptionsSource = Partial<RedisOptions> & {
prefix?: string;
};
type SentryBackendConfig = {
options: Partial<Sentry.NodeOptions>;
enableNodeProfiling: boolean;
disabledIntegrations?: string[];
};
/**
* 設定ファイルの型
*/
@@ -64,7 +70,7 @@ type Source = {
index: string;
scope?: 'local' | 'global' | string[];
};
sentryForBackend?: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; };
sentryForBackend?: SentryBackendConfig;
sentryForFrontend?: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -201,7 +207,7 @@ export type Config = {
redisForJobQueue: RedisOptions & RedisOptionsSource;
redisForTimelines: RedisOptions & RedisOptionsSource;
redisForReactions: RedisOptions & RedisOptionsSource;
sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined;
sentryForBackend: SentryBackendConfig | undefined;
sentryForFrontend: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;

View File

@@ -154,15 +154,12 @@ 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 { TelemetryService } from './telemetry/TelemetryService.js';
import type { Provider } from '@nestjs/common';
//#region 文字列ベースでのinjection用(循環参照対応のため)
const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService };
const $TelemetryService: Provider = { provide: 'TelemetryService', useExisting: TelemetryService };
const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisting: AbuseReportService };
const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService };
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
@@ -234,9 +231,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 +286,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 +384,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RegistryApiService,
ReversiService,
PageService,
WorldRoomService,
WorldRoomMultiplayService,
WorldAvatarService,
ChartLoggerService,
FederationChart,
@@ -450,8 +439,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ReversiGameEntityService,
MetaEntityService,
SystemWebhookEntityService,
WorldRoomEntityService,
WorldAvatarEntityService,
ApAudienceService,
ApDbResolverService,
@@ -473,6 +460,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApPersonService,
ApQuestionService,
QueueService,
TelemetryService,
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
@@ -547,9 +535,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RegistryApiService,
$ReversiService,
$PageService,
$WorldRoomService,
$WorldRoomMultiplayService,
$WorldAvatarService,
$ChartLoggerService,
$FederationChart,
@@ -605,8 +590,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ReversiGameEntityService,
$MetaEntityService,
$SystemWebhookEntityService,
$WorldRoomEntityService,
$WorldAvatarEntityService,
$ApAudienceService,
$ApDbResolverService,
@@ -626,6 +609,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ApNoteService,
$ApPersonService,
$ApQuestionService,
$TelemetryService,
//#endregion
],
exports: [
@@ -702,9 +686,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RegistryApiService,
ReversiService,
PageService,
WorldRoomService,
WorldRoomMultiplayService,
WorldAvatarService,
FederationChart,
NotesChart,
@@ -759,8 +740,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ReversiGameEntityService,
MetaEntityService,
SystemWebhookEntityService,
WorldRoomEntityService,
WorldAvatarEntityService,
ApAudienceService,
ApDbResolverService,
@@ -782,6 +761,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApPersonService,
ApQuestionService,
QueueService,
TelemetryService,
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
@@ -855,9 +835,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RegistryApiService,
$ReversiService,
$PageService,
$WorldRoomService,
$WorldRoomMultiplayService,
$WorldAvatarService,
$FederationChart,
$NotesChart,
@@ -912,8 +889,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ReversiGameEntityService,
$MetaEntityService,
$SystemWebhookEntityService,
$WorldRoomEntityService,
$WorldAvatarEntityService,
$ApAudienceService,
$ApDbResolverService,
@@ -933,6 +908,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ApNoteService,
$ApPersonService,
$ApQuestionService,
$TelemetryService,
//#endregion
],
})

View File

@@ -3,11 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { URLSearchParams } from 'node:url';
import * as nodemailer from 'nodemailer';
import juice from 'juice';
import { Inject, Injectable } from '@nestjs/common';
import { validate as validateEmail } from 'deep-email-validator';
import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -197,6 +195,7 @@ export class EmailService {
} else if (this.meta.enableTruemailApi && this.meta.truemailInstance && this.meta.truemailAuthKey != null) {
validated = await this.trueMail(this.meta.truemailInstance, emailAddress, this.meta.truemailAuthKey);
} else {
const { validate: validateEmail } = await import('deep-email-validator');
validated = await validateEmail({
email: emailAddress,
validateRegex: true,

View File

@@ -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);
}
}

View File

@@ -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 });
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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) })));
}
}

View File

@@ -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) })));
}
}

View File

@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { captureMessage, shutdownTelemetry, startSpan } from './telemetry-registry.js';
import type { OnApplicationShutdown } from '@nestjs/common';
import type { TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
@Injectable()
export class TelemetryService implements OnApplicationShutdown {
@bindThis
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
captureMessage(message, opts);
}
@bindThis
public startSpan<T>(name: string, fn: () => T): T {
return startSpan(name, fn);
}
@bindThis
public async onApplicationShutdown(_signal?: string): Promise<void> {
await shutdownTelemetry();
}
}

View File

@@ -0,0 +1,83 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
type SentryIntegrationsOption = NonNullable<import('@sentry/node').NodeOptions['integrations']>;
type SentryIntegrationFactory = Extract<SentryIntegrationsOption, (integrations: any[]) => any[]>;
type SentryIntegration = Parameters<SentryIntegrationFactory>[0][number];
type BuildSentryIntegrationsOptions = {
disabledIntegrations?: string[];
enableNodeProfiling: boolean;
nodeProfilingIntegration?: () => SentryIntegration;
warn?: (message: string) => void;
};
export function buildSentryIntegrations(options: BuildSentryIntegrationsOptions): SentryIntegrationFactory {
return (defaults) => {
const disabledIntegrations = new Set(options.disabledIntegrations ?? []);
const defaultIntegrationNames = new Set(defaults.map((integration) => integration.name));
const unknownIntegrations = [...disabledIntegrations].filter((name) => !defaultIntegrationNames.has(name));
if (unknownIntegrations.length > 0) {
(options.warn ?? console.warn)(`Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: ${unknownIntegrations.join(', ')}`);
}
return [
...defaults.filter((integration) => !disabledIntegrations.has(integration.name)),
...(options.enableNodeProfiling && options.nodeProfilingIntegration != null ? [options.nodeProfilingIntegration()] : []),
];
};
}
export class SentryTelemetryAdapter implements TelemetryAdapter {
private constructor(
private readonly Sentry: typeof import('@sentry/node'),
) {
}
public static async create(config: NonNullable<Config['sentryForBackend']>): Promise<SentryTelemetryAdapter> {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.options,
integrations: buildSentryIntegrations({
disabledIntegrations: config.disabledIntegrations,
enableNodeProfiling: config.enableNodeProfiling,
nodeProfilingIntegration,
}),
});
return new SentryTelemetryAdapter(Sentry);
}
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
this.Sentry.captureMessage(message, {
level: opts.level,
...(opts.userId != null ? { user: { id: opts.userId } } : {}),
extra: opts.extra,
});
}
public startSpan<T>(name: string, fn: () => T): T {
return this.Sentry.startSpan({ name }, fn);
}
public async shutdown(): Promise<void> {
await this.Sentry.close();
}
}

View File

@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface TelemetryCaptureMessageOptions {
level: 'error';
userId?: string;
extra?: Record<string, unknown>;
}
/**
* Sentry・OpenTelemetryなど、エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。
* 新しいサービスを追加する場合はこのインターフェースを実装するアダプタをこのディレクトリに追加し、
* telemetry-registry.tsのinitTelemetry内で登録する。
*/
export interface TelemetryAdapter {
captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void;
startSpan<T>(name: string, fn: () => T): T;
shutdown(): Promise<void>;
}

View File

@@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js';
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
/**
* NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、
* DIを介さないモジュールレベルの状態として有効なアダプタを保持する。
* TelemetryServiceはこの状態への薄いラッパーとして振る舞う。
*/
const adapters: TelemetryAdapter[] = [];
export async function initTelemetry(config: Config): Promise<void> {
if (config.sentryForBackend) {
adapters.push(await SentryTelemetryAdapter.create(config.sentryForBackend));
}
}
export function captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
for (const adapter of adapters) {
adapter.captureMessage(message, opts);
}
}
export function startSpan<T>(name: string, fn: () => T): T {
const wrapped = adapters.reduceRight<() => T>(
(inner, adapter) => () => adapter.startSpan(name, inner),
fn,
);
return wrapped();
}
export async function shutdownTelemetry(): Promise<void> {
await Promise.all(adapters.map(adapter => adapter.shutdown()));
}

View File

@@ -91,7 +91,5 @@ export const DI = {
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
noteDraftsRepository: Symbol('noteDraftsRepository'),
worldRoomsRepository: Symbol('worldRoomsRepository'),
worldAvatarsRepository: Symbol('worldAvatarsRepository'),
//#endregion
};

View File

@@ -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]>;

View File

@@ -672,6 +672,11 @@ export class MiMeta {
})
public urlPreviewUserAgent: string | null;
@Column('varchar', {
length: 3072, array: true, default: '{}',
})
public urlPreviewSensitiveList: string[];
@Column('varchar', {
length: 128,
default: 'none',

View File

@@ -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 {

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
];

View File

@@ -9,6 +9,7 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
@@ -92,6 +93,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private config: Config,
private queueLoggerService: QueueLoggerService,
private telemetryService: TelemetryService,
private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService,
private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
@@ -156,13 +158,6 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
}
let Sentry: typeof import('@sentry/node') | undefined;
if (this.config.sentryForBackend) {
import('@sentry/node').then((mod) => {
Sentry = mod;
});
}
//#region system
{
const processer = (job: Bull.Job) => {
@@ -181,11 +176,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: System: ' + job.name }, () => processer(job));
} else {
return processer(job);
}
return this.telemetryService.startSpan('Queue: System: ' + job.name, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.SYSTEM),
autorun: false,
@@ -198,12 +189,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err: Error) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -238,11 +227,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: DB: ' + job.name }, () => processer(job));
} else {
return processer(job);
}
return this.telemetryService.startSpan('Queue: DB: ' + job.name, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.DB),
autorun: false,
@@ -255,12 +240,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -270,11 +253,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region deliver
{
this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: Deliver' }, () => this.deliverProcessorService.process(job));
} else {
return this.deliverProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: Deliver', () => this.deliverProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.DELIVER),
autorun: false,
@@ -295,12 +274,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (Sentry != null) {
Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -310,11 +287,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region inbox
{
this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: Inbox' }, () => this.inboxProcessorService.process(job));
} else {
return this.inboxProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: Inbox', () => this.inboxProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.INBOX),
autorun: false,
@@ -335,12 +308,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -350,11 +321,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region user-webhook deliver
{
this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: UserWebhookDeliver' }, () => this.userWebhookDeliverProcessorService.process(job));
} else {
return this.userWebhookDeliverProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: UserWebhookDeliver', () => this.userWebhookDeliverProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER),
autorun: false,
@@ -375,12 +342,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (Sentry != null) {
Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -390,11 +355,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region system-webhook deliver
{
this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: SystemWebhookDeliver' }, () => this.systemWebhookDeliverProcessorService.process(job));
} else {
return this.systemWebhookDeliverProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: SystemWebhookDeliver', () => this.systemWebhookDeliverProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER),
autorun: false,
@@ -415,12 +376,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (Sentry != null) {
Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -440,11 +399,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: Relationship: ' + job.name }, () => processer(job));
} else {
return processer(job);
}
return this.telemetryService.startSpan('Queue: Relationship: ' + job.name, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP),
autorun: false,
@@ -462,12 +417,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -485,11 +438,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: ObjectStorage: ' + job.name }, () => processer(job));
} else {
return processer(job);
}
return this.telemetryService.startSpan('Queue: ObjectStorage: ' + job.name, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE),
autorun: false,
@@ -503,12 +452,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -518,11 +465,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region ended poll notification
{
this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: EndedPollNotification' }, () => this.endedPollNotificationProcessorService.process(job));
} else {
return this.endedPollNotificationProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: EndedPollNotification', () => this.endedPollNotificationProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION),
autorun: false,
@@ -533,11 +476,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region post scheduled note
{
this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job));
} else {
return this.postScheduledNoteProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: PostScheduledNote', () => this.postScheduledNoteProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE),
autorun: false,

View File

@@ -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,

View File

@@ -16,6 +16,7 @@ import type { MiMeta, UserIpsRepository } from '@/models/_.js';
import { createTemp } from '@/misc/create-temp.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
import type { Config } from '@/config.js';
import { ApiError } from './error.js';
import { RateLimiterService } from './RateLimiterService.js';
@@ -36,7 +37,6 @@ export class ApiCallService implements OnApplicationShutdown {
private logger: Logger;
private userIpHistories: Map<MiUser['id'], Set<string>>;
private userIpHistoriesClearIntervalId: NodeJS.Timeout;
private Sentry: typeof import('@sentry/node') | null = null;
constructor(
@Inject(DI.meta)
@@ -52,6 +52,7 @@ export class ApiCallService implements OnApplicationShutdown {
private rateLimiterService: RateLimiterService,
private roleService: RoleService,
private apiLoggerService: ApiLoggerService,
private telemetryService: TelemetryService,
) {
this.logger = this.apiLoggerService.logger;
this.userIpHistories = new Map<MiUser['id'], Set<string>>();
@@ -59,12 +60,6 @@ export class ApiCallService implements OnApplicationShutdown {
this.userIpHistoriesClearIntervalId = setInterval(() => {
this.userIpHistories.clear();
}, 1000 * 60 * 60);
if (this.config.sentryForBackend) {
import('@sentry/node').then((Sentry) => {
this.Sentry = Sentry;
});
}
}
#sendApiError(reply: FastifyReply, err: ApiError): void {
@@ -126,24 +121,20 @@ export class ApiCallService implements OnApplicationShutdown {
},
});
if (this.Sentry != null) {
this.Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
level: 'error',
user: {
id: userId,
this.telemetryService.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
level: 'error',
userId,
extra: {
ep: ep.name,
ps: data,
e: {
message: err.message,
code: err.name,
stack: err.stack,
id: errId,
},
extra: {
ep: ep.name,
ps: data,
e: {
message: err.message,
code: err.name,
stack: err.stack,
id: errId,
},
},
});
}
},
});
throw new ApiError(null, {
e: {
@@ -441,15 +432,8 @@ export class ApiCallService implements OnApplicationShutdown {
}
// API invoking
if (this.Sentry != null) {
return await this.Sentry.startSpan({
name: 'API: ' + ep.name,
}, () => ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));
} else {
return await ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id));
}
return await this.telemetryService.startSpan('API: ' + ep.name, () => ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));
}
@bindThis

View File

@@ -447,14 +447,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';

View File

@@ -544,6 +544,14 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
urlPreviewSensitiveList: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
federation: {
type: 'string',
enum: ['all', 'specified', 'none'],
@@ -760,6 +768,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength,
urlPreviewUserAgent: instance.urlPreviewUserAgent,
urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl,
urlPreviewSensitiveList: instance.urlPreviewSensitiveList,
federation: instance.federation,
federationHosts: instance.federationHosts,
deliverSuspendedSoftware: instance.deliverSuspendedSoftware,

View File

@@ -189,6 +189,12 @@ export const paramDef = {
urlPreviewRequireContentLength: { type: 'boolean' },
urlPreviewUserAgent: { type: 'string', nullable: true },
urlPreviewSummaryProxyUrl: { type: 'string', nullable: true },
urlPreviewSensitiveList: {
type: 'array', nullable: true,
items: {
type: 'string',
}
},
federation: {
type: 'string',
enum: ['all', 'none', 'specified'],
@@ -734,6 +740,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.urlPreviewSummaryProxyUrl = value === '' ? null : value;
}
if (Array.isArray(ps.urlPreviewSensitiveList)) {
set.urlPreviewSensitiveList = ps.urlPreviewSensitiveList.filter(Boolean);
}
if (ps.federation !== undefined) {
set.federation = ps.federation;
}

View File

@@ -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);
});
}
}

View File

@@ -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);
});
}
}

View File

@@ -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);
});
}
}

View File

@@ -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);
});
}
}

View File

@@ -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,
});
});
}
}

View File

@@ -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);
});
}
}

View File

@@ -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);
});
}
}

View File

@@ -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);
});
}
}

View File

@@ -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);
});
}
}

View File

@@ -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,
});
});
}
}

View File

@@ -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}`);

View File

@@ -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);
}
}

View File

@@ -11,6 +11,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
import type Logger from '@/logger.js';
import { query } from '@/misc/prelude/url.js';
import { LoggerService } from '@/core/LoggerService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { ApiError } from '@/server/api/error.js';
import { MiMeta } from '@/models/Meta.js';
@@ -29,6 +30,7 @@ export class UrlPreviewService {
private meta: MiMeta,
private httpRequestService: HttpRequestService,
private utilityService: UtilityService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('url-preview');
@@ -95,6 +97,10 @@ export class UrlPreviewService {
summary.icon = this.wrap(summary.icon);
summary.thumbnail = this.wrap(summary.thumbnail);
if (summary.sensitive !== true) {
summary.sensitive = this.utilityService.isKeyWordIncluded(summary.url, this.meta.urlPreviewSensitiveList);
}
// Cache 1day
reply.header('Cache-Control', 'max-age=86400, immutable');

View File

@@ -6,10 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, beforeEach, test } from 'vitest';
import { describe, beforeAll, beforeEach, test, vi } from 'vitest';
import { UserToken, api, post, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('API visibility', () => {
describe('Note visibility', () => {
//#region vars
@@ -409,10 +411,12 @@ describe('API visibility', () => {
//#region HTL
test('[HTL] public-post が 自分が見れる', async () => {
const res = await api('notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.status, 200);
const notes = res.body.filter(n => n.id === pub.id);
assert.strictEqual(notes[0].text, 'x');
await vi.waitFor(async () => {
const res = await api('notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.status, 200);
const notes = res.body.filter(n => n.id === pub.id);
assert.strictEqual(notes[0].text, 'x');
}, waitForPushToTlOptions);
});
test('[HTL] public-post が 非フォロワーから見れない', async () => {
@@ -423,10 +427,12 @@ describe('API visibility', () => {
});
test('[HTL] followers-post が フォロワーから見れる', async () => {
const res = await api('notes/timeline', { limit: 100 }, follower);
assert.strictEqual(res.status, 200);
const notes = res.body.filter(n => n.id === fol.id);
assert.strictEqual(notes[0].text, 'x');
await vi.waitFor(async () => {
const res = await api('notes/timeline', { limit: 100 }, follower);
assert.strictEqual(res.status, 200);
const notes = res.body.filter(n => n.id === fol.id);
assert.strictEqual(notes[0].text, 'x');
}, waitForPushToTlOptions);
});
//#endregion

View File

@@ -6,10 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, test } from 'vitest';
import { describe, beforeAll, test, vi } from 'vitest';
import { api, castAsError, post, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('Block', () => {
// alice blocks bob
let alice: misskey.entities.SignupResponse;
@@ -75,13 +77,15 @@ describe('Block', () => {
const bobNote = await post(bob, { text: 'hi' });
const carolNote = await post(carol, { text: 'hi' });
const res = await api('notes/local-timeline', {}, bob);
const body = res.body as misskey.entities.Note[];
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, bob);
const body = res.body as misskey.entities.Note[];
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(body.some(note => note.id === aliceNote.id), false);
assert.strictEqual(body.some(note => note.id === bobNote.id), true);
assert.strictEqual(body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(body.some(note => note.id === aliceNote.id), false);
assert.strictEqual(body.some(note => note.id === bobNote.id), true);
assert.strictEqual(body.some(note => note.id === carolNote.id), true);
}, waitForPushToTlOptions);
});
});

View File

@@ -6,7 +6,7 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, test, expect } from 'vitest';
import { describe, beforeAll, test, expect, vi } from 'vitest';
// node-fetch only supports it's own Blob yet
// https://github.com/node-fetch/node-fetch/pull/1664
import { Blob } from 'node-fetch';
@@ -14,6 +14,8 @@ import { api, castAsError, initTestDb, post, role, signup, simpleGet, uploadFile
import type * as misskey from 'misskey-js';
import { MiUser } from '@/models/_.js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('Endpoints', () => {
let alice: misskey.entities.SignupResponse;
let bob: misskey.entities.SignupResponse;
@@ -1149,12 +1151,14 @@ describe('Endpoints', () => {
visibility: 'followers',
});
const res = await api('notes/timeline', {}, dave);
await vi.waitFor(async () => {
const res = await api('notes/timeline', {}, dave);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 1);
assert.strictEqual(res.body[0].id, carolPost.id);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 1);
assert.strictEqual(res.body[0].id, carolPost.id);
}, waitForPushToTlOptions);
});
});

View File

@@ -6,10 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { beforeAll, describe, test } from 'vitest';
import { beforeAll, describe, test, vi } from 'vitest';
import { api, post, react, signup, waitFire } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('Mute', () => {
// alice mutes carol
let alice: misskey.entities.SignupResponse;
@@ -67,13 +69,15 @@ describe('Mute', () => {
const bobNote = await post(bob, { text: 'hi' });
const carolNote = await post(carol, { text: 'hi' });
const res = await api('notes/local-timeline', {}, alice);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}, waitForPushToTlOptions);
});
test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => {
@@ -83,13 +87,15 @@ describe('Mute', () => {
renoteId: carolNote.id,
});
const res = await api('notes/local-timeline', {}, alice);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}, waitForPushToTlOptions);
});
});

View File

@@ -6,11 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { beforeAll, describe, test } from 'vitest';
import { setTimeout } from 'node:timers/promises';
import { beforeAll, describe, test, vi } from 'vitest';
import { api, post, signup, waitFire } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('Renote Mute', () => {
// alice mutes carol
let alice: misskey.entities.SignupResponse;
@@ -36,16 +37,15 @@ describe('Renote Mute', () => {
const carolRenote = await post(carol, { renoteId: bobNote.id });
const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ
await setTimeout(100);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
}, waitForPushToTlOptions);
});
test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => {
@@ -53,16 +53,15 @@ describe('Renote Mute', () => {
const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' });
const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ
await setTimeout(100);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
}, waitForPushToTlOptions);
});
// #12956
@@ -70,15 +69,14 @@ describe('Renote Mute', () => {
const carolNote = await post(carol, { text: 'hi' });
const bobRenote = await post(bob, { renoteId: carolNote.id });
// redisに追加されるのを待つ
await setTimeout(100);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true);
}, waitForPushToTlOptions);
});
test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => {

View File

@@ -510,7 +510,7 @@ describe('Streaming', () => {
test('withReplies: true のとき自分のfollowers投稿に対するリプライが流れる', async () => {
const erinNote = await post(erin, { text: 'hi', visibility: 'followers' });
const fired = await waitFire(
erin, 'homeTimeline', // erin:home
erin, 'hybridTimeline', // erin:Hybrid
() => api('notes/create', { text: 'hello', replyId: erinNote.id }, ayano), // ayano reply to erin's followers post
msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano
);
@@ -521,7 +521,7 @@ describe('Streaming', () => {
test('withReplies: false でも自分の投稿に対するリプライが流れる', async () => {
const ayanoNote = await post(ayano, { text: 'hi', visibility: 'followers' });
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
ayano, 'hybridTimeline', // ayano:Hybrid
() => api('notes/create', { text: 'hello', replyId: ayanoNote.id }, erin), // erin reply to ayano's followers post
msg => msg.type === 'note' && msg.body.userId === erin.id, // wait erin
);
@@ -530,9 +530,12 @@ describe('Streaming', () => {
});
test('withReplies: true のフォローしていない人のfollowersートに対するリプライが流れない', async () => {
// ayano は kyoko をフォローしているため kyoko の followers 投稿にリプライできるが、
// erin は kyoko をフォローしていないため、そのリプライは erin の Hybrid Timeline には流れないはず
const kyokoFollowersNote = await post(kyoko, { text: 'hi', visibility: 'followers' });
const fired = await waitFire(
erin, 'homeTimeline', // erin:home
() => api('notes/create', { text: 'hello', replyId: chitose.id }, ayano), // ayano reply to chitose's post
erin, 'hybridTimeline', // erin:Hybrid
() => api('notes/create', { text: 'hello', replyId: kyokoFollowersNote.id }, ayano), // ayano reply to kyoko's followers post
msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano
);
@@ -680,7 +683,7 @@ describe('Streaming', () => {
const fired = await waitFire(
chitose, 'userList',
() => api('notes/create', { text: 'foo' }, takumi),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
msg => msg.type === 'note' && msg.body.userId === takumi.id,
{ listId: list.id },
);

View File

@@ -6,10 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { beforeAll, describe, test } from 'vitest';
import { beforeAll, describe, test, vi } from 'vitest';
import { api, post, signup, uploadUrl } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('users/notes', () => {
let alice: misskey.entities.SignupResponse;
let jpgNote: misskey.entities.Note;
@@ -32,16 +34,18 @@ describe('users/notes', () => {
}, 1000 * 60 * 2);
test('withFiles', async () => {
const res = await api('users/notes', {
userId: alice.id,
withFiles: true,
}, alice);
await vi.waitFor(async () => {
const res = await api('users/notes', {
userId: alice.id,
withFiles: true,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 3);
assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 3);
assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true);
}, waitForPushToTlOptions);
});
});

View File

@@ -7,6 +7,9 @@ import { beforeAll } from 'vitest';
import { initTestDb, sendEnvResetRequest } from './utils.js';
beforeAll(async () => {
await initTestDb(false);
// 前ファイルのNestJSアプリをdispose(env-reset)した後にスキーマをdrop & 再作成する。
// 逆順だと、前ファイルの最後のテストが投げっぱなしにした非同期処理(cacheServiceのrefresh等)が
// dispose前のdrop中に発火し、Unhandled Rejection (relation does not exist) でクラッシュしうる。
await sendEnvResetRequest();
await initTestDb(false);
});

View File

@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { buildSentryIntegrations } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js';
type TestIntegration = Parameters<ReturnType<typeof buildSentryIntegrations>>[0][number];
function testIntegration(name: string): TestIntegration {
return { name };
}
describe('SentryTelemetryAdapter', () => {
test('removes disabled integrations from Sentry defaults', () => {
const integrations = buildSentryIntegrations({
disabledIntegrations: ['Postgres'],
enableNodeProfiling: false,
});
const result = integrations([
testIntegration('Http'),
testIntegration('Postgres'),
testIntegration('Redis'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'Redis']);
});
test('keeps profiling integration when enabled', () => {
const integrations = buildSentryIntegrations({
disabledIntegrations: [],
enableNodeProfiling: true,
nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'),
});
const result = integrations([
testIntegration('Http'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']);
});
test('warns about unknown disabled integration names without removing defaults', () => {
const warn = vi.fn();
const integrations = buildSentryIntegrations({
disabledIntegrations: ['Unknown'],
enableNodeProfiling: false,
warn,
});
const result = integrations([
testIntegration('Http'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http']);
expect(warn).toHaveBeenCalledWith('Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: Unknown');
});
});

View File

@@ -11,16 +11,16 @@
},
"devDependencies": {
"@types/estree": "1.0.9",
"@types/node": "26.0.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@types/node": "26.0.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"rollup": "4.62.2"
},
"dependencies": {
"i18n": "workspace:*",
"magic-string": "0.30.21",
"oxc-walker": "1.0.0",
"rolldown": "1.1.2",
"rolldown": "1.1.3",
"vite": "8.1.0"
}
}

View File

@@ -9,6 +9,7 @@
"sourceMap": false,
"noEmit": true,
"removeComments": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"strict": true,
"strictFunctionTypes": true,

View File

@@ -8,5 +8,5 @@ export function assertNever(x: never): never {
throw new Error(`Unexpected type: ${(x as any)?.type ?? x}`);
}
export function assertType<T>(_node: unknown): asserts node is T {
export function assertType<T>(_node: unknown): asserts _node is T {
}

View File

@@ -4,7 +4,7 @@
*/
declare module '@@/themes/*.json5' {
import { Theme } from '@/theme.js';
import { Theme } from '@@/js/theme.js';
const theme: Theme;

View File

@@ -10,9 +10,6 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"dependencies": {
"@rollup/plugin-json": "6.1.0",
"@rollup/pluginutils": "5.4.0",
"@vitejs/plugin-vue": "6.0.7",
"buraha": "0.0.1",
"frontend-shared": "workspace:*",
"i18n": "workspace:*",
@@ -21,42 +18,34 @@
"mfm-js": "0.26.0",
"misskey-js": "workspace:*",
"punycode.js": "2.3.1",
"rollup": "4.62.2",
"shiki": "4.2.0",
"shiki": "4.3.0",
"tinycolor2": "1.6.0",
"uuid": "14.0.0",
"vue": "3.5.38"
"uuid": "14.0.1",
"vue": "3.5.39"
},
"devDependencies": {
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/summaly": "5.5.1",
"@rollup/plugin-json": "6.1.0",
"@rollup/pluginutils": "5.4.0",
"@tabler/icons-webfont": "3.35.0",
"@testing-library/vue": "8.1.0",
"@types/estree": "1.0.9",
"@types/micromatch": "4.0.10",
"@types/node": "26.0.0",
"@types/node": "26.0.1",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@vitest/coverage-v8": "4.1.9",
"@vue/runtime-core": "3.5.38",
"acorn": "8.17.0",
"cross-env": "10.1.0",
"@vitejs/plugin-vue": "6.0.7",
"@vue/runtime-core": "3.5.39",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.9.2",
"happy-dom": "20.10.6",
"intersection-observer": "0.12.2",
"lightningcss": "1.32.0",
"micromatch": "4.0.8",
"msw": "2.14.6",
"prettier": "3.8.4",
"sass-embedded": "1.100.0",
"start-server-and-test": "3.0.11",
"tsx": "4.22.4",
"vite": "8.1.0",
"vite-plugin-turbosnap": "1.0.3",
"vue-component-type-helpers": "3.3.5",
"vue-eslint-parser": "10.4.1",
"vue-tsc": "3.3.5"

View File

@@ -13,6 +13,7 @@
"module": "ES2022",
"moduleResolution": "Bundler",
"removeComments": false,
"skipLibCheck": true,
"noLib": false,
"strict": true,
"strictNullChecks": true,

View File

@@ -1,7 +1,7 @@
import path from 'path';
import pluginVue from '@vitejs/plugin-vue';
import { defineConfig, type UserConfig } from 'vite';
import * as yaml from 'js-yaml';
import { load as loadYaml } from 'js-yaml';
import { promises as fsp } from 'fs';
import locales from 'i18n';
@@ -11,7 +11,7 @@ import pluginJson5 from './lib/vite-plugin-json5.js';
import { pluginRemoveUnrefI18n } from '../frontend-builder/rollup-plugin-remove-unref-i18n';
import { Features } from 'lightningcss';
const url = process.env.NODE_ENV === 'development' ? (yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')) as any).url : null;
const url = process.env.NODE_ENV === 'development' ? (loadYaml(await fsp.readFile('../../.config/default.yml', 'utf-8')) as any).url : null;
const host = url ? (new URL(url)).hostname : undefined;
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];

View File

@@ -1,5 +0,0 @@
# frontend用Misskey Worldエンジン
エンジンはWeb Worker内で動作し、ほぼすべてのMisskey Webの機能は使えないため、意図しないそれらへの参照/依存が原理的に発生しないように別パッケージとする
ただしヘッドレス動作することは(今のところ)意図していない

View File

@@ -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,
},
},
},
];

View File

@@ -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.15.0",
"@babylonjs/inspector": "9.15.0",
"@babylonjs/loaders": "9.15.0",
"@babylonjs/materials": "9.15.0",
"@types/throttle-debounce": "5.0.2",
"eventemitter3": "5.0.4",
"seedrandom": "3.0.5",
"tinycolor2": "1.6.0",
"hls.js": "1.6.16"
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}
};

View File

@@ -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();
}
}

View File

@@ -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: () => {
},
};
},
});

View File

@@ -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: () => {
},
};
},
});

View File

@@ -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();
},
};
},
});

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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';
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.RegisterInstancedMesh();
BABYLON.RegisterPostProcessRenderPipelineManagerSceneComponent(
BABYLON.PostProcessRenderPipelineManager,
);
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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();
}
}
}

View File

@@ -1,343 +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 { EnvManager } from '../env.js';
import type { RoomEngine } from '../engine.js';
import type { CustomMadoriEnvOptions } from 'misskey-world/src/room/env.js';
export class CustomMadoriEnvManager extends EnvManager<CustomMadoriEnvOptions> {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private meshes: BABYLON.Mesh[] = [];
private rootNode: BABYLON.TransformNode;
private unitRootNodes: (BABYLON.TransformNode | null)[] = [];
private floorRootNode: BABYLON.TransformNode | null = null;
private wallRootNode: BABYLON.TransformNode | null = null;
private floorMaterials: Record<string, BABYLON.PBRMaterial> = {};
private wallMaterials: Record<string, BABYLON.PBRMaterial> = {};
private wallBeamMaterials: Record<string, BABYLON.PBRMaterial> = {};
private pillarMaterials: Record<string, BABYLON.PBRMaterial> = {};
private ceilingMaterials: Record<string, BABYLON.PBRMaterial> = {};
private beamMesh: BABYLON.Mesh | null = null;
private baseboardMesh: BABYLON.Mesh | null = null;
private wallARootNode: BABYLON.TransformNode | null = null;
private wallBRootNode: BABYLON.TransformNode | null = null;
private skybox: BABYLON.Mesh | null = null;
private skyboxMat: BABYLON.StandardMaterial | null = null;
private roomLight: BABYLON.DirectionalLight | null = null;
public envMapIndoor: BABYLON.CubeTexture | null = null;
public maxCameraZ = cm(3000);
constructor(engine: RoomEngine) {
super(engine);
this.rootNode = new BABYLON.TransformNode('customMadoriRoot', this.engine.scene);
//this.rootNode.scaling = new BABYLON.Vector3(WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
}
public async load(options: CustomMadoriEnvOptions) {
this.skybox = BABYLON.MeshBuilder.CreateBox('skybox', { size: cm(3000) }, this.engine.scene);
this.skyboxMat = new BABYLON.StandardMaterial('skyboxMat', this.engine.scene);
this.skyboxMat.backFaceCulling = false;
this.skyboxMat.disableLighting = true;
this.skybox.material = this.skyboxMat;
this.skybox.infiniteDistance = true;
this.roomLight = new BABYLON.DirectionalLight('env:RoomLight', new BABYLON.Vector3(0, -1, 0), this.engine.scene);
this.roomLight.position = new BABYLON.Vector3(0, cm(300), 0);
this.roomLight.shadowMinZ = cm(10);
this.roomLight.shadowMaxZ = cm(500);
this.roomLight.radius = cm(30);
this.applyRoomLight();
if (this.engine.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM) {
const shadowGeneratorForRoomLight = new BABYLON.ShadowGenerator(this.engine.graphicsQuality <= GRAPHICS_QUALITY.MEDIUM ? 1024 : 2048, this.roomLight);
shadowGeneratorForRoomLight.forceBackFacesOnly = true;
shadowGeneratorForRoomLight.bias = 0.0005;
shadowGeneratorForRoomLight.usePercentageCloserFiltering = true;
shadowGeneratorForRoomLight.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
//shadowGeneratorForRoomLight.useContactHardeningShadow = true;
//shadowGeneratorForRoomLight.contactHardeningLightSizeUVRatio = 0.01;
this.registerShadowGenerator(shadowGeneratorForRoomLight);
}
for (const materialDef of options.flooringMaterials) {
const mat = new BABYLON.PBRMaterial(`flooring_${materialDef.id}`, this.engine.scene);
mat.albedoColor = new BABYLON.Color3(...materialDef.color);
mat.metallic = 0;
mat.roughness = 1;
const texPath = materialDef.texture === 'wood' ? '/client-assets/room/textures/flooring-wood.png'
: materialDef.texture === 'concrete' ? '/client-assets/room/textures/concrete3.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
mat.albedoTexture = tex;
}
//mat.freeze();
this.floorMaterials[materialDef.id] = mat;
}
for (const materialDef of options.wallMaterials) {
const mat = new BABYLON.PBRMaterial(`wall_${materialDef.id}`, this.engine.scene);
mat.albedoColor = new BABYLON.Color3(...materialDef.color);
mat.metallic = 0;
mat.roughness = 1;
const texPath = materialDef.texture === 'wood' ? '/client-assets/room/textures/wall-wood2.png'
: materialDef.texture === 'concrete' ? '/client-assets/room/textures/concrete1.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
mat.albedoTexture = tex;
}
//mat.freeze();
this.wallMaterials[materialDef.id] = mat;
}
for (const materialDef of options.ceilingMaterials) {
const mat = new BABYLON.PBRMaterial(`ceiling_${materialDef.id}`, this.engine.scene);
mat.albedoColor = new BABYLON.Color3(...materialDef.color);
mat.metallic = 0;
mat.roughness = 1;
const texPath = materialDef.texture === 'wood' ? '/client-assets/room/textures/ceiling-wood.png'
: materialDef.texture === 'concrete' ? '/client-assets/room/textures/concrete3.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
mat.albedoTexture = tex;
}
//mat.freeze();
this.ceilingMaterials[materialDef.id] = mat;
}
this.loaderResult = await BABYLON.LoadAssetContainerAsync('/client-assets/room/envs/custom-madori/units.glb', this.engine.scene);
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.engine.scene);
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(2000), cm(500), cm(2000));
this.meshes = this.loaderResult.meshes.filter(m => m instanceof BABYLON.Mesh);
this.meshes[0].rotationQuaternion = null;
this.meshes[0].rotation = new BABYLON.Vector3(0, 0, 0);
for (const m of this.meshes[0].getChildren()) {
if (m.parent === this.meshes[0]) {
m.parent = this.rootNode;
}
}
// instanced mesh を通常の mesh に変換 (そうしないとマテリアルが共有される)
for (const mesh of this.loaderResult.meshes) {
if (mesh instanceof BABYLON.InstancedMesh) {
const realizedMesh = mesh.sourceMesh.clone(mesh.name, null, true);
realizedMesh.position = mesh.position.clone();
if (mesh.rotationQuaternion) {
realizedMesh.rotationQuaternion = mesh.rotationQuaternion.clone();
} else {
realizedMesh.rotation = mesh.rotation.clone();
}
realizedMesh.scaling = mesh.scaling.clone();
realizedMesh.parent = mesh.parent;
mesh.dispose();
this.engine.scene.removeMesh(mesh);
this.meshes.push(realizedMesh);
}
}
this.floorRootNode = this.loaderResult.transformNodes.find(t => t.name.includes('__FLOOR__'))!;
this.wallRootNode = this.loaderResult.transformNodes.find(t => t.name.includes('__WALL__'))!;
this.beamMesh = this.loaderResult.meshes.find(m => m.name.includes('__BEAM__')) as BABYLON.Mesh;
this.baseboardMesh = this.loaderResult.meshes.find(m => m.name.includes('__BASEBOARD__')) as BABYLON.Mesh;
this.wallARootNode = this.loaderResult.transformNodes.find(t => t.name.includes('__WALL_A__'))!;
this.wallBRootNode = this.loaderResult.transformNodes.find(t => t.name.includes('__WALL_B__'))!;
const baseboardMaterial = findMaterial(this.rootNode, '__BASEBOARD__');
//baseboardMaterial.metadata.disableEnvMap = true;
for (const mesh of this.meshes) {
if (SYSTEM_HEYA_MESH_NAMES.some(name => mesh.name.includes(name))) continue;
mesh.receiveShadows = true;
}
await this.applyOptions(options);
}
private createUnit(options: CustomMadoriEnvOptions, x: number, z: number) {
function indexToPos(index: number): [number, number] {
const z = Math.floor(index / options.dimension[0]);
const x = index % options.dimension[0];
return [x, z];
}
function posToIndex(x: number, z: number): number {
if (x < 0 || z < 0 || x >= options.dimension[0] || z >= options.dimension[1]) return -1;
return x + (options.dimension[0] * z);
}
const unitDef = options.units[posToIndex(x, z)];
if (unitDef == null) return;
const unitZPositiveDef = options.units[posToIndex(x, z + 1)];
const unitZNegativeDef = options.units[posToIndex(x, z - 1)];
const unitXPositiveDef = options.units[posToIndex(x + 1, z)];
const unitXNegativeDef = options.units[posToIndex(x - 1, z)];
const shiftedX = x - (options.dimension[0] / 2) + 0.5;
const unitRoot = new BABYLON.TransformNode(`unit_${x}_${z}`, this.engine.scene);
unitRoot.parent = this.rootNode;
unitRoot.position = new BABYLON.Vector3(cm(100) * shiftedX, 0, cm(100) * z);
const defaultFlooringMaterial = this.floorMaterials[options.flooringMaterials[0].id];
const unitFloorRootNode = this.floorRootNode.clone(`unit_${x}_${z}_floor`, unitRoot)!;
unitFloorRootNode.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
const flooringMesh = unitFloorRootNode.getChildMeshes().find(m => m.name.includes('__FLOOR__'));
flooringMesh.material = unitDef.flooring?.material != null && this.floorMaterials[unitDef.flooring.material] != null ? this.floorMaterials[unitDef.flooring.material] : defaultFlooringMaterial;
const defaultCeilingMaterial = this.ceilingMaterials[options.ceilingMaterials[0].id];
const ceilingMesh = unitFloorRootNode.getChildMeshes().find(m => m.name.includes('__CEILING__'));
ceilingMesh.material = unitDef.ceiling?.material != null && this.ceilingMaterials[unitDef.ceiling.material] != null ? this.ceilingMaterials[unitDef.ceiling.material] : defaultCeilingMaterial;
const defaultWallMaterial = this.wallMaterials[options.wallMaterials[0].id];
const createWall = (dir: 'zPositive' | 'zNegative' | 'xPositive' | 'xNegative') => {
const wallDef = unitDef.walls?.[dir] ?? {};
const wallRootNode = this.wallRootNode.clone(`unit_${x}_${z}_wall_${dir}`, unitRoot)!;
wallRootNode.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
switch (dir) {
case 'zPositive':
wallRootNode.rotation = new BABYLON.Vector3(0, Math.PI, 0);
wallRootNode.position = new BABYLON.Vector3(0, 0, cm(50));
break;
case 'zNegative':
wallRootNode.position = new BABYLON.Vector3(0, 0, cm(-50));
break;
case 'xPositive':
wallRootNode.rotation = new BABYLON.Vector3(0, -Math.PI / 2, 0);
wallRootNode.position = new BABYLON.Vector3(cm(50), 0, 0);
break;
case 'xNegative':
wallRootNode.rotation = new BABYLON.Vector3(0, Math.PI / 2, 0);
wallRootNode.position = new BABYLON.Vector3(cm(-50), 0, 0);
break;
}
const beamMesh = wallRootNode.getChildMeshes().find(m => m.name.includes('__BEAM__'));
beamMesh.isVisible = wallDef.withBeam === true;
const baseboardMesh = wallRootNode.getChildMeshes().find(m => m.name.includes('__BASEBOARD__'));
baseboardMesh.isVisible = wallDef.withBaseboard === true;
switch (wallDef.type) {
case 'window': {
const wallNode = this.wallBRootNode.clone('', wallRootNode)!;
const wallMesh = wallNode.getChildMeshes().find(m => m.name.includes('__WALL__'))!;
wallMesh.material = wallDef.material != null && this.wallMaterials[wallDef.material] != null ? this.wallMaterials[wallDef.material] : defaultWallMaterial;
break;
}
case 'door': {
//wallMeshOriginal = this.wallAMesh;
break;
}
default: {
const wallNode = this.wallARootNode.clone('', wallRootNode)!;
const wallMesh = wallNode.getChildMeshes().find(m => m.name.includes('__WALL__'))!;
wallMesh.material = wallDef.material != null && this.wallMaterials[wallDef.material] != null ? this.wallMaterials[wallDef.material] : defaultWallMaterial;
break;
}
}
};
if (unitZPositiveDef == null) createWall('zPositive');
if (unitZNegativeDef == null) createWall('zNegative');
if (unitXPositiveDef == null) createWall('xPositive');
if (unitXNegativeDef == null) createWall('xNegative');
for (const mesh of unitRoot.getChildMeshes()) {
this.meshes.push(mesh);
}
this.registerMeshes(unitRoot.getChildMeshes());
return unitRoot;
}
public setTime(time: number) {
if (this.skyboxMat == null) return;
if (time === 0) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.7, 0.9, 1.0);
} else if (time === 1) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.8, 0.5, 0.3);
} else {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.05, 0.05, 0.2);
}
if (this.sunLight != null) {
this.sunLight.diffuse = time === 0 ? new BABYLON.Color3(1.0, 0.9, 0.8) : time === 1 ? new BABYLON.Color3(1.0, 0.8, 0.6) : new BABYLON.Color3(0.6, 0.8, 1.0);
this.sunLight.intensity = time === 0 ? 3 : time === 1 ? 1 : 0.25;
}
}
public applyRoomLight(): void {
if (this.roomLight == null) return;
this.roomLight.diffuse = new BABYLON.Color3(...this.engine.roomState.light.color);
this.roomLight.intensity = 0.0005 * WORLD_SCALE * WORLD_SCALE * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0);
if (this.envMapIndoor != null) this.envMapIndoor.level = 0.025 + (0.575 * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0));
for (const m of this.engine.scene.materials) {
if (m.metadata?.disableEnvMap) {
m.ambientColor = this.isRoomLightOn ? new BABYLON.Color3(0.5, 0.5, 0.5) : new BABYLON.Color3(0.025, 0.025, 0.025);
}
}
}
public applyOptions(options: CustomMadoriEnvOptions) {
// TODO: 返り値をpromiseにしてちゃんとテクスチャが読み終わってからresolveする
for (const n of this.unitRootNodes) {
if (n != null) n.dispose();
}
this.unitRootNodes = [];
for (let z = 0; z < options.dimension[1]; z++) {
for (let x = 0; x < options.dimension[0]; x++) {
const node = this.createUnit(options, x, z);
this.unitRootNodes.push(node);
}
}
}
public dispose() {
for (const m of this.meshes) {
m.dispose(false, true);
}
this.skybox?.dispose();
this.skyboxMat?.dispose();
this.envMapIndoor?.dispose();
this.roomLight?.dispose();
this.sunLight?.dispose();
if (this.loaderResult != null) {
for (const m of this.loaderResult.meshes) {
m.dispose(false, true);
}
for (const t of this.loaderResult.transformNodes) {
t.dispose(false, true);
}
}
super.dispose();
}
}

View File

@@ -1,160 +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 { EnvManager } from '../env.js';
import type { RoomEngine } from '../engine.js';
import type { JapaneseEnvOptions } from 'misskey-world/src/room/env.js';
export class JapaneseEnvManager extends EnvManager<JapaneseEnvOptions> {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private meshes: BABYLON.Mesh[] = [];
private skybox: BABYLON.Mesh | null = null;
private skyboxMat: BABYLON.StandardMaterial | null = null;
private roomLight: BABYLON.SpotLight | null = null;
private sunLight: BABYLON.DirectionalLight | null = null;
public envMapIndoor: BABYLON.CubeTexture | null = null;
public maxCameraZ = cm(1000);
constructor(engine: RoomEngine) {
super(engine);
}
public async load(options: JapaneseEnvOptions) {
this.skybox = BABYLON.MeshBuilder.CreateBox('skybox', { size: cm(1000) }, this.engine.scene);
this.skyboxMat = new BABYLON.StandardMaterial('skyboxMat', this.engine.scene);
this.skyboxMat.backFaceCulling = false;
this.skyboxMat.disableLighting = true;
this.skybox.material = this.skyboxMat;
this.skybox.infiniteDistance = true;
this.roomLight = new BABYLON.SpotLight('env:RoomLight', new BABYLON.Vector3(0, cm(249), 0), new BABYLON.Vector3(0, -1, 0), 16, 8, this.engine.scene);
this.roomLight.shadowMinZ = cm(10);
this.roomLight.shadowMaxZ = cm(300);
this.roomLight.radius = cm(30);
this.applyRoomLight();
if (this.engine.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM) {
const shadowGeneratorForRoomLight = new BABYLON.ShadowGenerator(this.engine.graphicsQuality <= GRAPHICS_QUALITY.MEDIUM ? 1024 : 2048, this.roomLight);
shadowGeneratorForRoomLight.forceBackFacesOnly = true;
shadowGeneratorForRoomLight.bias = 0.0005;
shadowGeneratorForRoomLight.usePercentageCloserFiltering = true;
shadowGeneratorForRoomLight.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
//shadowGeneratorForRoomLight.useContactHardeningShadow = true;
//shadowGeneratorForRoomLight.contactHardeningLightSizeUVRatio = 0.01;
this.registerShadowGenerator(shadowGeneratorForRoomLight);
}
if (this.engine.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM) {
this.sunLight = new BABYLON.DirectionalLight('env:SunLight', new BABYLON.Vector3(0.2, -1, -1), this.engine.scene);
this.sunLight.position = new BABYLON.Vector3(cm(-20), cm(1000), cm(1000));
this.sunLight.shadowMinZ = cm(1000);
this.sunLight.shadowMaxZ = cm(2000);
const shadowGeneratorForSunLight = new BABYLON.ShadowGenerator(this.engine.graphicsQuality <= GRAPHICS_QUALITY.MEDIUM ? 1024 : 2048, this.sunLight);
shadowGeneratorForSunLight.forceBackFacesOnly = true;
shadowGeneratorForSunLight.bias = 0.00001;
shadowGeneratorForSunLight.usePercentageCloserFiltering = true;
shadowGeneratorForSunLight.usePoissonSampling = true;
this.registerShadowGenerator(shadowGeneratorForSunLight);
}
this.loaderResult = await BABYLON.ImportMeshAsync('/client-assets/room/envs/japanese/japanese.glb', this.engine.scene);
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.engine.scene);
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(500), cm(500), cm(500));
this.meshes = this.loaderResult.meshes.filter(m => m instanceof BABYLON.Mesh);
this.meshes[0].scaling = this.meshes[0].scaling.scale(WORLD_SCALE);
this.meshes[0].rotationQuaternion = null;
this.meshes[0].rotation = new BABYLON.Vector3(0, 0, 0);
// instanced mesh を通常の mesh に変換 (そうしないとマテリアルが共有される)
for (const mesh of this.loaderResult.meshes) {
if (mesh instanceof BABYLON.InstancedMesh) {
const realizedMesh = mesh.sourceMesh.clone(mesh.name, null, true);
realizedMesh.position = mesh.position.clone();
if (mesh.rotationQuaternion) {
realizedMesh.rotationQuaternion = mesh.rotationQuaternion.clone();
} else {
realizedMesh.rotation = mesh.rotation.clone();
}
realizedMesh.scaling = mesh.scaling.clone();
realizedMesh.parent = mesh.parent;
mesh.dispose();
this.engine.scene.removeMesh(mesh);
this.meshes.push(realizedMesh);
}
}
for (const mesh of this.meshes) {
if (SYSTEM_HEYA_MESH_NAMES.some(name => mesh.name.includes(name))) continue;
mesh.receiveShadows = true;
this.addShadowCaster(mesh);
}
await this.applyOptions(options);
}
public setTime(time: number) {
if (this.skyboxMat == null) return;
if (time === 0) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.7, 0.9, 1.0);
} else if (time === 1) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.8, 0.5, 0.3);
} else {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.05, 0.05, 0.2);
}
if (this.sunLight != null) {
this.sunLight.diffuse = time === 0 ? new BABYLON.Color3(1.0, 0.9, 0.8) : time === 1 ? new BABYLON.Color3(1.0, 0.8, 0.6) : new BABYLON.Color3(0.6, 0.8, 1.0);
this.sunLight.intensity = time === 0 ? 3 : time === 1 ? 1 : 0.25;
}
}
public applyRoomLight(): void {
if (this.roomLight == null) return;
this.roomLight.diffuse = new BABYLON.Color3(...this.engine.roomState.light.color);
this.roomLight.intensity = 18 * WORLD_SCALE * WORLD_SCALE * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0);
if (this.envMapIndoor != null) this.envMapIndoor.level = 0.025 + (0.575 * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0));
for (const m of this.engine.scene.materials) {
if (m.metadata?.disableEnvMap) {
m.ambientColor = this.isRoomLightOn ? new BABYLON.Color3(0.5, 0.5, 0.5) : new BABYLON.Color3(0.025, 0.025, 0.025);
}
}
}
public applyOptions(options: JapaneseEnvOptions) {
this.registerMeshes(this.meshes);
}
public dispose() {
for (const m of this.meshes) {
m.dispose(false, true);
}
this.skybox?.dispose();
this.skyboxMat?.dispose();
this.envMapIndoor?.dispose();
this.roomLight?.dispose();
this.sunLight?.dispose();
if (this.loaderResult != null) {
for (const m of this.loaderResult.meshes) {
m.dispose(false, true);
}
for (const t of this.loaderResult.transformNodes) {
t.dispose(false, true);
}
}
super.dispose();
}
}

View File

@@ -1,136 +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 { EnvManager } from '../env.js';
import type { RoomEngine } from '../engine.js';
import type { MuseumEnvOptions } from 'misskey-world/src/room/env.js';
export class MuseumEnvManager extends EnvManager<MuseumEnvOptions> {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private meshes: BABYLON.Mesh[] = [];
private roomLight: BABYLON.DirectionalLight | null = null;
private subRoomLights: BABYLON.SpotLight[] = [];
public envMapIndoor: BABYLON.CubeTexture | null = null;
public maxCameraZ = cm(3000);
constructor(engine: RoomEngine) {
super(engine);
}
public async load(options: MuseumEnvOptions) {
this.loaderResult = await BABYLON.ImportMeshAsync('/client-assets/room/envs/museum/museum.glb', this.engine.scene);
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.engine.scene);
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(2000), cm(500), cm(2000));
this.meshes = this.loaderResult.meshes.filter(m => m instanceof BABYLON.Mesh);
this.meshes[0].scaling = this.meshes[0].scaling.scale(WORLD_SCALE);
this.meshes[0].rotationQuaternion = null;
this.meshes[0].rotation = new BABYLON.Vector3(0, 0, 0);
// instanced mesh を通常の mesh に変換 (そうしないとマテリアルが共有される)
for (const mesh of this.loaderResult.meshes) {
if (mesh instanceof BABYLON.InstancedMesh) {
const realizedMesh = mesh.sourceMesh.clone(mesh.name, null, true);
realizedMesh.position = mesh.position.clone();
if (mesh.rotationQuaternion) {
realizedMesh.rotationQuaternion = mesh.rotationQuaternion.clone();
} else {
realizedMesh.rotation = mesh.rotation.clone();
}
realizedMesh.scaling = mesh.scaling.clone();
realizedMesh.parent = mesh.parent;
mesh.dispose();
this.engine.scene.removeMesh(mesh);
this.meshes.push(realizedMesh);
}
}
this.roomLight = new BABYLON.DirectionalLight('env:RoomLight', new BABYLON.Vector3(0, -1, 0), this.engine.scene);
this.roomLight.position = new BABYLON.Vector3(0, cm(300), 0);
this.roomLight.shadowMinZ = cm(10);
this.roomLight.shadowMaxZ = cm(500);
this.roomLight.radius = cm(30);
if (this.engine.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM) {
const shadowGeneratorForRoomLight = new BABYLON.ShadowGenerator(this.engine.graphicsQuality <= GRAPHICS_QUALITY.MEDIUM ? 1024 : 2048, this.roomLight);
shadowGeneratorForRoomLight.forceBackFacesOnly = true;
shadowGeneratorForRoomLight.bias = 0.00001;
shadowGeneratorForRoomLight.normalBias = 0.005;
shadowGeneratorForRoomLight.usePercentageCloserFiltering = true;
shadowGeneratorForRoomLight.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
//this.shadowGeneratorForRoomLight.useContactHardeningShadow = true;
this.registerShadowGenerator(shadowGeneratorForRoomLight);
}
for (const node of this.meshes.filter(mesh => mesh.name.includes('__LIGHT__'))) {
const light = new BABYLON.SpotLight('env:SubRoomLight', node.position, new BABYLON.Vector3(0, -1, 0), 16, 8, this.engine.scene, true);
light.range = cm(500);
light.radius = cm(15);
light.parent = this.meshes[0];
this.engine.lightContainer.addLight(light);
this.subRoomLights.push(light);
}
this.applyRoomLight();
for (const mesh of this.meshes) {
if (SYSTEM_HEYA_MESH_NAMES.some(name => mesh.name.includes(name))) continue;
mesh.receiveShadows = true;
//this.addShadowCaster(mesh);
}
await this.applyOptions(options);
}
public setTime(time: number) {
}
public applyRoomLight(): void {
if (this.roomLight == null) return;
this.roomLight.diffuse = new BABYLON.Color3(...this.engine.roomState.light.color);
this.roomLight.intensity = 0.00005 * WORLD_SCALE * WORLD_SCALE * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0);
for (const subLight of this.subRoomLights) {
subLight.diffuse = new BABYLON.Color3(...this.engine.roomState.light.color);
subLight.intensity = 20 * WORLD_SCALE * WORLD_SCALE * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0);
}
if (this.envMapIndoor != null) this.envMapIndoor.level = 0.025 + (0.175 * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0));
for (const m of this.engine.scene.materials) {
if (m.metadata?.disableEnvMap) {
m.ambientColor = this.isRoomLightOn ? new BABYLON.Color3(0.5, 0.5, 0.5) : new BABYLON.Color3(0.025, 0.025, 0.025);
}
}
}
public applyOptions(options: MuseumEnvOptions) {
this.registerMeshes(this.meshes);
}
public dispose() {
this.envMapIndoor?.dispose();
this.roomLight?.dispose();
for (const subLight of this.subRoomLights) {
subLight.dispose();
}
if (this.loaderResult != null) {
for (const m of this.loaderResult.meshes) {
m.dispose(false, true);
}
for (const t of this.loaderResult.transformNodes) {
t.dispose(false, true);
}
}
for (const m of this.meshes) {
m.dispose(false, true);
}
super.dispose();
}
}

View File

@@ -1,474 +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, treeClone } from '../../utility.js';
import { SYSTEM_HEYA_MESH_NAMES } from '../utility.js';
import { EnvManager } from '../env.js';
import type { RoomEngine } from '../engine.js';
import type { SimpleEnvOptions } from 'misskey-world/src/room/env.js';
// TODO: マテリアルは必要になるまで作成しないようにする
export class SimpleEnvManager extends EnvManager<SimpleEnvOptions> {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private rootNode: BABYLON.TransformNode;
private wallRoots: Record<'zPositive' | 'zNegative' | 'xPositive' | 'xNegative', BABYLON.TransformNode>;
private wallScalingContainers: Record<'zPositive' | 'zNegative' | 'xPositive' | 'xNegative', BABYLON.TransformNode>;
private wallMaterials: Record<'zPositive' | 'zNegative' | 'xPositive' | 'xNegative', BABYLON.PBRMaterial>;
private wallBeamMaterials: Record<'zPositive' | 'zNegative' | 'xPositive' | 'xNegative', BABYLON.PBRMaterial>;
private pillarRoots: Record<'zp_xp' | 'zp_xn' | 'zn_xp' | 'zn_xn', BABYLON.TransformNode>;
private pillarScalingContainers: Record<'zp_xp' | 'zp_xn' | 'zn_xp' | 'zn_xn', BABYLON.TransformNode>;
private pillarMaterials: Record<'zp_xp' | 'zp_xn' | 'zn_xp' | 'zn_xn', BABYLON.PBRMaterial>;
private ceilingMaterial: BABYLON.PBRMaterial;
private floorMaterial: BABYLON.PBRMaterial;
private skybox: BABYLON.Mesh | null = null;
private skyboxMat: BABYLON.StandardMaterial | null = null;
private roomLight: BABYLON.SpotLight | null = null;
private sunLight: BABYLON.DirectionalLight | null = null;
public envMapIndoor: BABYLON.CubeTexture | null = null;
public maxCameraZ = cm(1000);
constructor(engine: RoomEngine) {
super(engine);
this.rootNode = new BABYLON.TransformNode('simpleEnvRoot', this.engine.scene);
this.wallRoots = {
zPositive: new BABYLON.TransformNode('wallRootZPositive', this.engine.scene),
zNegative: new BABYLON.TransformNode('wallRootZNegative', this.engine.scene),
xPositive: new BABYLON.TransformNode('wallRootXPositive', this.engine.scene),
xNegative: new BABYLON.TransformNode('wallRootXNegative', this.engine.scene),
};
this.wallRoots.zPositive.parent = this.rootNode;
this.wallRoots.zPositive.position.z = cm(150);
this.wallRoots.zPositive.rotation.y = Math.PI;
this.wallRoots.zNegative.parent = this.rootNode;
this.wallRoots.zNegative.position.z = -cm(150);
this.wallRoots.xPositive.parent = this.rootNode;
this.wallRoots.xPositive.position.x = cm(150);
this.wallRoots.xPositive.rotation.y = -Math.PI / 2;
this.wallRoots.xNegative.parent = this.rootNode;
this.wallRoots.xNegative.position.x = -cm(150);
this.wallRoots.xNegative.rotation.y = Math.PI / 2;
this.wallScalingContainers = {
zPositive: new BABYLON.TransformNode('wallScalingContainerZPositive', this.engine.scene),
zNegative: new BABYLON.TransformNode('wallScalingContainerZNegative', this.engine.scene),
xPositive: new BABYLON.TransformNode('wallScalingContainerXPositive', this.engine.scene),
xNegative: new BABYLON.TransformNode('wallScalingContainerXNegative', this.engine.scene),
};
for (const [k, v] of Object.entries(this.wallScalingContainers)) {
v.parent = this.wallRoots[k as keyof typeof this.wallRoots];
v.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
}
this.pillarRoots = {
zp_xp: new BABYLON.TransformNode('pillarRootZpXp', this.engine.scene),
zp_xn: new BABYLON.TransformNode('pillarRootZpXn', this.engine.scene),
zn_xp: new BABYLON.TransformNode('pillarRootZnXp', this.engine.scene),
zn_xn: new BABYLON.TransformNode('pillarRootZnXn', this.engine.scene),
};
this.pillarRoots.zp_xp.parent = this.rootNode;
this.pillarRoots.zp_xp.position = new BABYLON.Vector3(cm(150), 0, cm(150));
this.pillarRoots.zp_xp.rotation.y = -Math.PI / 2;
this.pillarRoots.zp_xn.parent = this.rootNode;
this.pillarRoots.zp_xn.position = new BABYLON.Vector3(-cm(150), 0, cm(150));
this.pillarRoots.zp_xn.rotation.y = Math.PI;
this.pillarRoots.zn_xp.parent = this.rootNode;
this.pillarRoots.zn_xp.position = new BABYLON.Vector3(cm(150), 0, -cm(150));
this.pillarRoots.zn_xp.rotation.y = 0;
this.pillarRoots.zn_xn.parent = this.rootNode;
this.pillarRoots.zn_xn.position = new BABYLON.Vector3(-cm(150), 0, -cm(150));
this.pillarRoots.zn_xn.rotation.y = Math.PI / 2;
this.pillarScalingContainers = {
zp_xp: new BABYLON.TransformNode('pillarScalingContainerZpXp', this.engine.scene),
zp_xn: new BABYLON.TransformNode('pillarScalingContainerZpXn', this.engine.scene),
zn_xp: new BABYLON.TransformNode('pillarScalingContainerZnXp', this.engine.scene),
zn_xn: new BABYLON.TransformNode('pillarScalingContainerZnXn', this.engine.scene),
};
for (const [k, v] of Object.entries(this.pillarScalingContainers)) {
v.parent = this.pillarRoots[k as keyof typeof this.pillarRoots];
v.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
}
const wallMaterial = new BABYLON.PBRMaterial('wallMaterial', this.engine.scene);
wallMaterial.albedoColor = new BABYLON.Color3(0.8, 0.8, 0.8);
wallMaterial.roughness = 0.7;
wallMaterial.metallic = 0;
this.wallMaterials = {
zPositive: wallMaterial.clone('wallZPositiveMaterial'),
zNegative: wallMaterial.clone('wallZNegativeMaterial'),
xPositive: wallMaterial.clone('wallXPositiveMaterial'),
xNegative: wallMaterial.clone('wallXNegativeMaterial'),
};
const beamMaterial = wallMaterial.clone('beamMaterial');
this.wallBeamMaterials = {
zPositive: beamMaterial.clone('beamZPositiveMaterial'),
zNegative: beamMaterial.clone('beamZNegativeMaterial'),
xPositive: beamMaterial.clone('beamXPositiveMaterial'),
xNegative: beamMaterial.clone('beamXNegativeMaterial'),
};
const pillarMaterial = wallMaterial.clone('pillarMaterial');
this.pillarMaterials = {
zp_xp: pillarMaterial.clone('pillarMaterialZpXp'),
zp_xn: pillarMaterial.clone('pillarMaterialZpXn'),
zn_xp: pillarMaterial.clone('pillarMaterialZnXp'),
zn_xn: pillarMaterial.clone('pillarMaterialZnXn'),
};
this.ceilingMaterial = new BABYLON.PBRMaterial('ceilingMaterial', this.engine.scene);
this.ceilingMaterial.albedoColor = new BABYLON.Color3(0.8, 0.8, 0.8);
this.ceilingMaterial.roughness = 0.7;
this.ceilingMaterial.metallic = 0;
this.floorMaterial = new BABYLON.PBRMaterial('floorMaterial', this.engine.scene);
this.floorMaterial.albedoColor = new BABYLON.Color3(0.8, 0.8, 0.8);
this.floorMaterial.roughness = 0.7;
this.floorMaterial.metallic = 0;
const baseboardMaterial = new BABYLON.PBRMaterial('baseboardMaterial', this.engine.scene);
baseboardMaterial.albedoColor = new BABYLON.Color3(0.8, 0.8, 0.8);
baseboardMaterial.roughness = 0.7;
baseboardMaterial.metallic = 0;
this.skybox = BABYLON.MeshBuilder.CreateBox('skybox', { size: cm(1000) }, this.engine.scene);
this.skyboxMat = new BABYLON.StandardMaterial('skyboxMat', this.engine.scene);
this.skyboxMat.backFaceCulling = false;
this.skyboxMat.disableLighting = true;
this.skybox.material = this.skyboxMat;
this.skybox.infiniteDistance = true;
this.roomLight = new BABYLON.SpotLight('env:RoomLight', new BABYLON.Vector3(0, cm(249), 0), new BABYLON.Vector3(0, -1, 0), 16, 8, this.engine.scene);
this.roomLight.shadowMinZ = cm(10);
this.roomLight.shadowMaxZ = cm(300);
this.roomLight.radius = cm(30);
this.applyRoomLight();
if (this.engine.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM) {
const shadowGeneratorForRoomLight = new BABYLON.ShadowGenerator(this.engine.graphicsQuality <= GRAPHICS_QUALITY.MEDIUM ? 1024 : 2048, this.roomLight);
shadowGeneratorForRoomLight.forceBackFacesOnly = true;
shadowGeneratorForRoomLight.bias = 0.0005;
shadowGeneratorForRoomLight.usePercentageCloserFiltering = true;
shadowGeneratorForRoomLight.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
//shadowGeneratorForRoomLight.useContactHardeningShadow = true;
//shadowGeneratorForRoomLight.contactHardeningLightSizeUVRatio = 0.01;
this.registerShadowGenerator(shadowGeneratorForRoomLight);
}
if (this.engine.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM) {
this.sunLight = new BABYLON.DirectionalLight('env:SunLight', new BABYLON.Vector3(0.2, -1, -1), this.engine.scene);
this.sunLight.position = new BABYLON.Vector3(cm(-20), cm(1000), cm(1000));
this.sunLight.shadowMinZ = cm(1000);
this.sunLight.shadowMaxZ = cm(2000);
const shadowGeneratorForSunLight = new BABYLON.ShadowGenerator(this.engine.graphicsQuality <= GRAPHICS_QUALITY.MEDIUM ? 1024 : 2048, this.sunLight);
shadowGeneratorForSunLight.forceBackFacesOnly = true;
shadowGeneratorForSunLight.bias = 0.00001;
shadowGeneratorForSunLight.usePercentageCloserFiltering = true;
shadowGeneratorForSunLight.usePoissonSampling = true;
this.registerShadowGenerator(shadowGeneratorForSunLight);
}
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.engine.scene);
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(500), cm(500), cm(500));
}
public async load(options: SimpleEnvOptions) {
this.loaderResult = await BABYLON.LoadAssetContainerAsync('/client-assets/room/envs/simple/300.glb', this.engine.scene);
const collisionScalingContainer = new BABYLON.TransformNode('collisionScalingContainer', this.engine.scene);
collisionScalingContainer.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
collisionScalingContainer.parent = this.rootNode;
const collision = this.loaderResult.meshes.find(m => m.name.includes('__COLLISION__'))!;
collision.parent = collisionScalingContainer;
const lightBlockerScalingContainer = new BABYLON.TransformNode('lightBlockerScalingContainer', this.engine.scene);
lightBlockerScalingContainer.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
lightBlockerScalingContainer.parent = this.rootNode;
const lightBlocker = this.loaderResult.meshes.find(m => m.name.includes('__LIGHT_BLOCKER__'))!;
lightBlocker.parent = lightBlockerScalingContainer;
lightBlocker.rotationQuaternion = null;
lightBlocker.rotation.y = Math.PI;
const originalFloorRoot = this.loaderResult.transformNodes.find(t => t.name.includes('__FLOOR__'))!;
originalFloorRoot.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
originalFloorRoot.parent = this.rootNode;
for (const child of originalFloorRoot.getChildMeshes()) {
if (child.material.name.includes('__FLOOR__')) {
child.material = this.floorMaterial;
}
}
const originalCeilingRoot = this.loaderResult.transformNodes.find(t => t.name.includes('__CEILING__'))!;
originalCeilingRoot.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
originalCeilingRoot.position.y = cm(250);
originalCeilingRoot.parent = this.rootNode;
for (const child of originalCeilingRoot.getChildMeshes()) {
if (child.material.name.includes('__CEILING__')) {
child.material = this.ceilingMaterial;
}
}
await this.applyOptions(options);
}
public applyOptions(options: SimpleEnvOptions) {
// clean up
for (const type of ['zPositive', 'zNegative', 'xPositive', 'xNegative'] as const) {
const wallRoot = this.wallScalingContainers[type];
for (const mesh of wallRoot.getChildMeshes()) {
mesh.dispose();
this.engine.scene.removeMesh(mesh);
this.removeShadowCaster(mesh);
}
}
for (const type of ['zp_xp', 'zp_xn', 'zn_xp', 'zn_xn'] as const) {
const pillarRoot = this.pillarScalingContainers[type];
for (const mesh of pillarRoot.getChildMeshes()) {
mesh.dispose();
this.engine.scene.removeMesh(mesh);
this.removeShadowCaster(mesh);
}
}
// TODO: 返り値をpromiseにしてちゃんとテクスチャが読み終わってからresolveする
for (const type of ['zPositive', 'zNegative', 'xPositive', 'xNegative'] as const) {
const wallRoot = this.wallScalingContainers[type];
const wallOptions = options.walls[type];
const originalRoot =
type === 'zPositive' ?
options.window === 'kosidakamado' ? this.loaderResult!.transformNodes.find(t => t.name.includes('__WALL_KOSIDAKAMADO__'))! :
options.window === 'demado' ? this.loaderResult!.transformNodes.find(t => t.name.includes('__WALL_DEMADO__'))! :
this.loaderResult!.transformNodes.find(t => t.name.includes('__WALL__'))!
: type === 'zNegative'
? this.loaderResult!.transformNodes.find(t => t.name.includes('__WALL_DOOR__'))!
: this.loaderResult!.transformNodes.find(t => t.name.includes('__WALL__'))!;
for (const child of treeClone(originalRoot).getChildren()) {
child.parent = wallRoot;
}
for (const child of wallRoot.getChildMeshes()) {
if (child.material.name.includes('__WALL__')) {
child.material = this.wallMaterials[type];
} else if (child.material.name.includes('__BEAM__')) {
child.material = this.wallBeamMaterials[type];
}
}
for (const mesh of wallRoot.getChildMeshes()) {
if (mesh.name.includes('__BEAM__')) {
mesh.setEnabled(wallOptions.withBeam);
} else if (mesh.name.includes('__BASEBOARD__')) {
mesh.setEnabled(wallOptions.withBaseboard);
}
}
{
const targetMaterial = this.wallMaterials[type];
targetMaterial.unfreeze();
targetMaterial.albedoColor = new BABYLON.Color3(...wallOptions.color);
const texPath = wallOptions.material === 'wood' ? '/client-assets/room/textures/wall-wood2.png'
: wallOptions.material === 'concrete' ? '/client-assets/room/textures/concrete1.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
tex.wrapU = BABYLON.Texture.WRAP_ADDRESSMODE;
tex.wrapV = BABYLON.Texture.WRAP_ADDRESSMODE;
targetMaterial.albedoTexture = tex;
} else {
targetMaterial.albedoTexture = null;
}
targetMaterial.freeze();
}
{
const targetMaterial = this.wallBeamMaterials[type];
targetMaterial.unfreeze();
targetMaterial.albedoColor = new BABYLON.Color3(...wallOptions.beamColor);
const texPath = wallOptions.beamMaterial === 'wood' ? '/client-assets/room/textures/wall-wood2.png'
: wallOptions.beamMaterial === 'concrete' ? '/client-assets/room/textures/concrete1.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
targetMaterial.albedoTexture = tex;
} else {
targetMaterial.albedoTexture = null;
}
targetMaterial.freeze();
}
}
for (const type of ['zp_xp', 'zp_xn', 'zn_xp', 'zn_xn'] as const) {
const pillarRoot = this.pillarScalingContainers[type];
const pillarOptions = options.pillars[type];
let isEnabled = pillarOptions.show;
if (!isEnabled) {
// 梁同士が直交することは許さない(z-fightingが発生する)ので柱を強制追加
if (type === 'zp_xp') {
isEnabled = options.walls.zPositive.withBeam && options.walls.xPositive.withBeam;
} else if (type === 'zp_xn') {
isEnabled = options.walls.zPositive.withBeam && options.walls.xNegative.withBeam;
} else if (type === 'zn_xp') {
isEnabled = options.walls.zNegative.withBeam && options.walls.xPositive.withBeam;
} else if (type === 'zn_xn') {
isEnabled = options.walls.zNegative.withBeam && options.walls.xNegative.withBeam;
}
}
if (!isEnabled) continue;
const originalRoot = this.loaderResult!.transformNodes.find(t => t.name.includes('__PILLAR__'))!;
for (const child of treeClone(originalRoot).getChildren()) {
child.parent = pillarRoot;
}
for (const child of pillarRoot.getChildMeshes()) {
if (child.material.name.includes('__PILLAR__')) {
child.material = this.pillarMaterials[type];
}
}
{
const targetMaterial = this.pillarMaterials[type];
targetMaterial.unfreeze();
targetMaterial.albedoColor = new BABYLON.Color3(...pillarOptions.color);
const texPath = pillarOptions.material === 'wood' ? '/client-assets/room/textures/wall-wood2.png'
: pillarOptions.material === 'concrete' ? '/client-assets/room/textures/concrete1.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
targetMaterial.albedoTexture = tex;
} else {
targetMaterial.albedoTexture = null;
}
targetMaterial.freeze();
}
}
{
this.ceilingMaterial.unfreeze();
this.ceilingMaterial.albedoColor = new BABYLON.Color3(...options.ceiling.color);
const texPath = options.ceiling.material === 'wood' ? '/client-assets/room/textures/ceiling-wood.png'
: options.ceiling.material === 'concrete' ? '/client-assets/room/textures/concrete3.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
this.ceilingMaterial.albedoTexture = tex;
} else {
this.ceilingMaterial.albedoTexture = null;
}
this.ceilingMaterial.freeze();
}
{
this.floorMaterial.unfreeze();
this.floorMaterial.albedoColor = new BABYLON.Color3(...options.flooring.color);
const texPath = options.flooring.material === 'wood' ? '/client-assets/room/textures/flooring-wood.png'
: options.flooring.material === 'concrete' ? '/client-assets/room/textures/concrete3.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
this.floorMaterial.albedoTexture = tex;
} else {
this.floorMaterial.albedoTexture = null;
}
this.floorMaterial.freeze();
}
for (const mesh of this.rootNode.getChildMeshes()) {
if (SYSTEM_HEYA_MESH_NAMES.some(name => mesh.name.includes(name))) continue;
mesh.receiveShadows = true;
//if (mesh.material !== this.floorMaterial) { // 床は他の何にも影を落とさないことが確定している
this.addShadowCaster(mesh);
//}
}
this.registerMeshes(this.rootNode.getChildMeshes());
}
public setTime(time: number) {
if (this.skyboxMat == null) return;
if (time === 0) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.7, 0.9, 1.0);
} else if (time === 1) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.8, 0.5, 0.3);
} else {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.05, 0.05, 0.2);
}
if (this.sunLight != null) {
this.sunLight.diffuse = time === 0 ? new BABYLON.Color3(1.0, 0.9, 0.8) : time === 1 ? new BABYLON.Color3(1.0, 0.8, 0.6) : new BABYLON.Color3(0.6, 0.8, 1.0);
this.sunLight.intensity = time === 0 ? 3 : time === 1 ? 1 : 0.25;
}
}
public applyRoomLight(): void {
if (this.roomLight == null) return;
this.roomLight.diffuse = new BABYLON.Color3(...this.engine.roomState.light.color);
this.roomLight.intensity = 18 * WORLD_SCALE * WORLD_SCALE * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0);
if (this.envMapIndoor != null) this.envMapIndoor.level = 0.025 + (0.575 * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0));
for (const m of this.engine.scene.materials) {
if (m.metadata?.disableEnvMap) {
m.ambientColor = this.isRoomLightOn ? new BABYLON.Color3(0.5, 0.5, 0.5) : new BABYLON.Color3(0.025, 0.025, 0.025);
}
}
}
public dispose() {
for (const m of this.rootNode.getChildMeshes()) {
m.dispose(false, true);
}
for (const m of Object.values(this.wallMaterials ?? {})) {
m.dispose();
}
for (const m of Object.values(this.wallBeamMaterials ?? {})) {
m.dispose();
}
for (const m of Object.values(this.pillarMaterials ?? {})) {
m.dispose();
}
this.skybox?.dispose();
this.skyboxMat?.dispose();
this.envMapIndoor?.dispose();
this.roomLight?.dispose();
this.sunLight?.dispose();
if (this.loaderResult != null) {
for (const m of this.loaderResult.meshes) {
m.dispose(false, true);
}
for (const t of this.loaderResult.transformNodes) {
t.dispose(false, true);
}
}
super.dispose();
}
}

Some files were not shown because too many files have changed in this diff Show More